Nearly there!!

This commit is contained in:
2026-10-07 08:21:36 +02:00
parent aeb9df3e0e
commit ee1bb98515
3 changed files with 218 additions and 168 deletions
+53 -114
View File
@@ -1,129 +1,68 @@
-- ~/.config/vis/build.lua -- Runs ./build.sh from vis's cwd (project root), logs the output for the
-- Runs the nearest build.sh, shows the output, and can jump to the first -- error picker (pickers.errors), and returns to vis on Enter/Esc.
-- Odin error. Loaded from visrc.lua.
local M = {} local M = {}
-- args: list of flags passed through to build.sh, e.g. { 'run' } -- last build's output; pickers.errors() reads it
M.log = '/tmp/vis-build.log'
-- single-quote a string for the shell
local function q(s) return "'" .. s:gsub("'", "'\\''") .. "'" end
-- write a raw escape sequence straight to the terminal
local function tty(s)
local t = io.open('/dev/tty', 'w')
if t then t:write(s) t:close() end
end
local function exists(path)
local f = io.open(path, 'r')
if f then f:close() end
return f ~= nil
end
-- args: flags passed through to build.sh, e.g. { 'run' }
function M.run(args) function M.run(args)
-- find the nearest build.sh, searching upward from vis's working dir if not exists('build.sh') then
local p = io.popen('pwd') vis:info('No build.sh in current directory')
local dir = p and p:read('*l')
if p then p:close() end
while dir and dir ~= '' do
local f = io.open(dir .. '/build.sh', 'r')
if f then
f:close()
break
end
dir = dir:match('^(.*)/[^/]*$')
end
if not dir or dir == '' then
vis:info('No build.sh found in this directory or any parent')
return return
end end
-- save the current buffer so the build sees your latest changes -- save so the build sees your latest changes
local win = vis.win local file = vis.win and vis.win.file
if win and win.file and win.file.modified and win.file.path then if file and file.modified and file.path then vis:command('w') end
vis:command('w')
end
-- shell-quote each flag and the directory local parts = {}
local quoted = {} for _, a in ipairs(args or {}) do parts[#parts + 1] = q(a) end
for _, a in ipairs(args) do local argstr = table.concat(parts, ' ')
table.insert(quoted, "'" .. a:gsub("'", "'\\''") .. "'")
end
local argstr = table.concat(quoted, ' ')
local qdir = "'" .. dir:gsub("'", "'\\''") .. "'"
local display = ('./build.sh ' .. table.concat(args, ' ')):gsub('%s+$', '')
local qdisplay = "'" .. display:gsub("'", "'\\''") .. "'"
local out_file = os.tmpname()
local status_file = os.tmpname()
local answer_file = os.tmpname()
local script = table.concat({ local script = table.concat({
'saved=$(stty -g)', 'saved=$(stty -g); stty sane',
'stty sane', -- script runs the build on a pty (colors stay on) and logs it to M.log
'cd ' .. qdir .. ' || exit 1', 'script -qefc ' .. q('bash ./build.sh ' .. argstr) .. ' ' .. q(M.log),
'printf "\\n\\033[1m$ %s\\033[0m (in %s)\\n\\n" ' .. qdisplay .. ' ' .. qdir, 'st=$?',
'{ if [ -x ./build.sh ]; then ./build.sh ' .. argstr [[printf '\n[exit %s] enter/esc: back to vis ' "$st"]],
.. '; else bash ./build.sh ' .. argstr .. '; fi; echo $? > \'' [[while IFS= read -rsn1 k; do [ -z "$k" ] || [ "$k" = $'\e' ] && break; done]],
.. status_file .. "'; } 2>&1 | tee '" .. out_file .. "'",
"st=$(cat '" .. status_file .. "')",
'if [ "$st" = 0 ]; then',
' printf "\\n\\033[32m[build ok]\\033[0m enter: back to vis "',
'else',
' printf "\\n\\033[31m[build failed: exit %s]\\033[0m '
.. 'enter: back to vis | e + enter: jump to first error " "$st"',
'fi',
'read -r ans',
"printf '%s' \"$ans\" > '" .. answer_file .. "'",
'stty "$saved"', 'stty "$saved"',
}, '\n') }, '\n')
-- leave the alternate screen so the build output lands in your normal -- leave the alt screen and clear it, so each run starts fresh and the
-- terminal scrollback, run it, then come back to vis -- output lands in normal scrollback; run; come back to vis
local tty = io.open('/dev/tty', 'w') tty('\27[?1049l\27[H\27[2J')
if tty then tty:write('\27[?1049l') tty:close() end os.execute('bash -c ' .. q(script) .. ' </dev/tty >/dev/tty 2>&1')
tty('\27[?1049h')
os.execute('{\n' .. script .. '\n} </dev/tty >/dev/tty 2>&1')
tty = io.open('/dev/tty', 'w')
if tty then tty:write('\27[?1049h') tty:close() end
vis:redraw() vis:redraw()
-- read back exit status and the key you answered with
local f = io.open(status_file, 'r')
local status = f and f:read('*l')
if f then f:close() end
f = io.open(answer_file, 'r')
local answer = f and f:read('*l')
if f then f:close() end
-- first Odin error: `file.odin(12:5)` (default) or `file.odin:12:5` (unix)
local path, line, col
f = io.open(out_file, 'r')
if f then
for l in f:lines() do
path, line, col = l:match('^%s*(.-%.odin)%((%d+):(%d+)%)')
if not path then
path, line, col = l:match('^%s*(.-%.odin):(%d+):(%d+)')
end
if path then break end
end
f:close()
end
if path and path:sub(1, 1) ~= '/' then path = dir .. '/' .. path end
os.remove(out_file)
os.remove(status_file)
os.remove(answer_file)
if status == '0' then
vis:info('build ok: ' .. display)
return
end
if not path then
vis:info('build failed: exit ' .. (status or '?'))
return
end
if answer ~= 'e' then
vis:info(string.format('build failed: first error at %s:%s', path, line))
return
end
-- jump to the error: open the file unless it's already the current one
local current = vis.win and vis.win.file and vis.win.file.path
if current ~= path then
if vis.win.file.modified then
vis:info('Buffer has unsaved changes — :w first (or use :e! manually)')
return
end
vis:command(string.format("e '%s'", path))
end
vis.win.selection:to(tonumber(line), tonumber(col))
end end
return M -- ---------------------------------------------------------------------------
-- events
-- ---------------------------------------------------------------------------
-- re-index tags in the background after saving an .odin file
-- (build.sh -t re-scans src and merges it with the cached stdlib tags)
vis.events.subscribe(vis.events.FILE_SAVE_POST, function(file, path) -- luacheck: no unused args
if path and path:match('%.odin$') and exists('build.sh') then
os.execute('bash ./build.sh -t </dev/null >/dev/null 2>&1 &')
end
end)
return M
+139 -33
View File
@@ -1,12 +1,30 @@
--pickers: fuzzy file finder, live ripgrep, yazi file manager, gitu. -- pickers: fuzzy file finder, live ripgrep, build errors, yazi file manager, gitu.
local M = {} local M = {}
-- height of the drop-down fzf panel at the top ('100%' = full screen) -- height of the drop-down fzf panel at the top ('100%' = full screen)
M.height = '100%' M.height = '100%'
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- helper funcs -- shared constants
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
local BAT = 'bat --theme=custom_zen --color=always --style=numbers'
local PREVIEW_FILE = BAT .. ' --line-range=:500 {}' -- {} = path
local PREVIEW_AT = BAT .. ' --highlight-line {2} {1}' -- {1}=path {2}=line
local KEYS_NAV = 'ctrl-n:down,ctrl-p:up'
local KEYS_PREVIEW = 'ctrl-/:toggle-preview,shift-up:preview-up,shift-down:preview-down'
-- an Odin error line (-error-pos-style:unix): path.odin:line:col: message
local ERROR_PATTERN = '^(.-%.odin):(%d+):(%d+):'
-- ---------------------------------------------------------------------------
-- helpers
-- ---------------------------------------------------------------------------
-- single-quote a string for the shell
local function q(s)
return "'" .. s:gsub("'", "'\\''") .. "'"
end
-- write a raw escape sequence straight to the terminal -- write a raw escape sequence straight to the terminal
local function tty_write(seq) local function tty_write(seq)
@@ -23,6 +41,17 @@ local function restore_screen()
vis:redraw() vis:redraw()
end end
-- fzf command line: common flags + a list of extra options
local function fzf(prompt, opts)
return 'fzf --height=' .. M.height .. ' --layout=reverse --border'
.. ' --prompt ' .. q(prompt) .. ' ' .. table.concat(opts, ' ')
end
-- "--flag 'value'"
local function opt(flag, value)
return flag .. ' ' .. q(value)
end
-- run an fzf pipeline; returns its output lines and exit code -- run an fzf pipeline; returns its output lines and exit code
local function run_fzf(cmd) local function run_fzf(cmd)
-- --height mode draws from the cursor down, so start at the top-left -- --height mode draws from the cursor down, so start at the top-left
@@ -66,18 +95,14 @@ end
-- ctrl-n/p move down/up -- ctrl-n/p move down/up
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
function M.files() function M.files()
local preview = 'bat --theme=custom_zen --color=always --style=numbers --line-range=:500 {}' local cmd = 'fd --type f --hidden --exclude .git | ' .. fzf('files> ', {
'--print-query --expect=alt-enter,ctrl-v', -- output: query, key, choice
local cmd = 'fd --type f --hidden --exclude .git | fzf' opt('--header', 'enter: open · alt-enter: new file · ctrl-v: vsplit'),
.. ' --height=' .. M.height .. ' --layout=reverse --border' opt('--preview', PREVIEW_FILE),
.. " --prompt 'files> '" opt('--preview-window', 'down:75%:border-top'),
.. ' --print-query --expect=alt-enter,ctrl-v' -- output: query, key, choice opt('--bind', KEYS_NAV),
.. " --header 'enter: open · alt-enter: new file · ctrl-v: vsplit'" opt('--bind', KEYS_PREVIEW),
.. " --preview '" .. preview .. "'" })
.. " --preview-window 'down:75%:border-top'"
.. " --bind 'ctrl-n:down,ctrl-p:up'"
.. " --bind 'ctrl-/:toggle-preview'"
.. " --bind 'shift-up:preview-up,shift-down:preview-down'"
local out, code = run_fzf(cmd) local out, code = run_fzf(cmd)
if not out or code == 130 then return end -- failed, or Esc / Ctrl-C if not out or code == 130 then return end -- failed, or Esc / Ctrl-C
@@ -100,28 +125,29 @@ end
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- grep: live ripgrep search across the project, jump to the match -- grep: live ripgrep search across the project, jump to the match
-- M.grep() start empty, results appear as you type
-- M.grep_selection() visual mode: start with the selected text
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
function M.grep() local function grep_with(query)
-- {1} = path, {2} = line number (fields split on ':')
local preview = 'bat --theme=custom_zen --color=always --style=numbers --highlight-line {2} {1}'
-- don't run rg on an empty query (it would match every line) -- don't run rg on an empty query (it would match every line)
local reload = '[ -n {q} ] && rg --column --line-number --no-heading' local reload = '[ -n {q} ] && rg --column --line-number --no-heading'
.. ' --color=always --smart-case --hidden --glob "!.git" -- {q} || true' .. ' --color=always --smart-case --hidden --glob "!.git" -- {q} || true'
-- empty stdin: results only appear once you start typing local opts = {
local cmd = ': | fzf' '--ansi --disabled', -- let rg do the matching, not fzf
.. ' --height=' .. M.height .. ' --layout=reverse --border' '--delimiter :', -- fields: path:line:col:text
.. " --prompt 'rg> '" opt('--bind', 'change:reload:' .. reload),
.. ' --ansi --disabled' -- let rg do the matching, not fzf opt('--preview', PREVIEW_AT),
.. ' --delimiter :' opt('--preview-window', 'down:90%:border-top:+{2}/2'),
.. " --bind 'change:reload:" .. reload .. "'" opt('--bind', KEYS_PREVIEW),
.. " --preview '" .. preview .. "'" }
.. " --preview-window 'down:90%:border-top:+{2}/2'" if query then
.. " --bind 'ctrl-/:toggle-preview'" table.insert(opts, opt('--query', query))
.. " --bind 'shift-up:preview-up,shift-down:preview-down'" table.insert(opts, opt('--bind', 'start:reload:' .. reload))
end
local out = run_fzf(cmd) -- empty stdin: results only appear once there is a query
local out = run_fzf(': | ' .. fzf('rg> ', opts))
local choice = out and out[1] local choice = out and out[1]
if not choice or choice == '' then return end if not choice or choice == '' then return end
@@ -132,6 +158,84 @@ function M.grep()
vis.win.selection:to(tonumber(line), tonumber(col)) vis.win.selection:to(tonumber(line), tonumber(col))
end end
function M.grep()
grep_with(nil)
end
function M.grep_selection()
local win = vis.win
local text
if win and win.file and win.selection.range then
text = win.file:content(win.selection.range)
end
-- leave visual mode before fzf takes over the screen
vis.mode = vis.modes.NORMAL
-- first line of the selection only; nothing usable -> plain grep
text = text and text:match('^[^\n]*')
if not text or text:match('^%s*$') then
grep_with(nil)
else
grep_with(text)
end
end
-- ---------------------------------------------------------------------------
-- errors: browse the errors from the last build (the log build.lua writes)
-- enter jump to the error
-- ---------------------------------------------------------------------------
function M.errors()
local f = io.open(require('build').log, 'r')
if not f then
vis:info('No build log yet — run a build first')
return
end
-- keep only the path.odin:line:col: lines; the log comes from a pty, so
-- strip colour escapes and carriage returns first
local errors = {}
for line in f:lines() do
local clean = line:gsub('\27%[[%d;?]*%a', ''):gsub('\r', '')
if clean:match(ERROR_PATTERN) then table.insert(errors, clean) end
end
f:close()
if #errors == 0 then
vis:info('No errors in the last build')
return
end
local tmp = os.tmpname()
f = io.open(tmp, 'w')
if not f then
vis:info('errors: cannot write temp file')
return
end
f:write(table.concat(errors, '\n'), '\n')
f:close()
local cmd = fzf('errors> ', {
'--no-sort', -- keep build order: the first error is the one to fix first
'--delimiter :',
opt('--preview', PREVIEW_AT),
opt('--preview-window', 'down:75%:border-top:+{2}/2'),
opt('--bind', KEYS_NAV),
opt('--bind', KEYS_PREVIEW),
}) .. ' < ' .. q(tmp)
local out = run_fzf(cmd)
os.remove(tmp)
local choice = out and out[1]
if not choice or choice == '' then return end
local path, line, col = choice:match(ERROR_PATTERN)
if not path or not open_path(path) then return end
vis.win.selection:to(tonumber(line), tonumber(col))
end
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- fm: browse/create/rename/delete in yazi, then open the file you pick -- fm: browse/create/rename/delete in yazi, then open the file you pick
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@@ -153,6 +257,7 @@ end
-- git: run gitu, then hand the screen back to vis -- git: run gitu, then hand the screen back to vis
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
function M.git() function M.git()
tty_write('\27[?1049l')
os.execute('gitu </dev/tty >/dev/tty 2>&1') os.execute('gitu </dev/tty >/dev/tty 2>&1')
restore_screen() restore_screen()
@@ -164,16 +269,17 @@ function M.git()
end end
end end
-- ---------------------------------------------------------------------------
-- events
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- create missing parent directories on save, so a new file like -- create missing parent directories on save, so a new file like
-- src/util/new.odin can be written even if src/util/ doesn't exist yet -- src/util/new.odin can be written even if src/util/ doesn't exist yet
-- ---------------------------------------------------------------------------
vis.events.subscribe(vis.events.FILE_SAVE_PRE, function(file, path) -- luacheck: no unused args vis.events.subscribe(vis.events.FILE_SAVE_PRE, function(file, path) -- luacheck: no unused args
local dir = path and path:match('^(.*)/[^/]+$') local dir = path and path:match('^(.*)/[^/]+$')
if dir and dir ~= '' then if dir and dir ~= '' then
os.execute("mkdir -p '" .. (dir:gsub("'", "'\\''")) .. "'") os.execute('mkdir -p ' .. q(dir))
end end
return true return true
end) end)
return M return M
+26 -21
View File
@@ -1,19 +1,8 @@
require('vis') require('vis')
local pickers = require('pickers')
local build = require('build')
require('plugins/vis-ctags') require('plugins/vis-ctags')
-- --------------------------------------------------------------------------- local build = require('build')
-- Syntax highlighting: ~/.config/vis/lexers/odin.lua local pickers = require('pickers')
-- ---------------------------------------------------------------------------
if vis.ftdetect then
-- vis 0.9 and earlier
if vis.ftdetect.filetypes then
vis.ftdetect.filetypes.odin = { ext = { '%.odin$' } }
end
end
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Global options, key mappings and commands -- Global options, key mappings and commands
@@ -21,17 +10,26 @@ end
vis.events.subscribe(vis.events.INIT, function() vis.events.subscribe(vis.events.INIT, function()
vis:command('set escdelay 1') vis:command('set escdelay 1')
vis:command('set autoindent off') vis:command('set autoindent off')
vis:command('set ignorecase on')
vis:command('set theme custom_zen') vis:command('set theme custom_zen')
-- jump list
vis:map(vis.modes.NORMAL, "<M-j>", "g>")
vis:map(vis.modes.NORMAL, "<M-k>", "g<")
-- pickers -- pickers
vis:map(vis.modes.NORMAL, '<C-p>', pickers.files, 'Fuzzy find / create a file') vis:map(vis.modes.NORMAL, '<C-p>', pickers.files, 'Fuzzy find / create a file')
vis:map(vis.modes.NORMAL, '<C-f>', pickers.grep, 'Live grep the project with ripgrep') vis:map(vis.modes.NORMAL, '<C-f>', pickers.grep, 'Live grep the project with ripgrep')
vis:map(vis.modes.VISUAL, '<C-f>', pickers.grep_selection, 'Live grep for the selected text')
vis:map(vis.modes.NORMAL, '<C-e>', pickers.fm, 'terminal file manager') vis:map(vis.modes.NORMAL, '<C-e>', pickers.fm, 'terminal file manager')
vis:map(vis.modes.NORMAL, '<M-e>', pickers.errors, 'Browse errors from the last build')
-- picker commands -- picker commands
vis:command_register('files', function() pickers.files() return true end, 'Fuzzy find / create a file') vis:command_register('files', function() pickers.files() return true end, 'Fuzzy find / create a file')
vis:command_register('grep', function() pickers.grep() return true end, 'Live grep the project with ripgrep') vis:command_register('grep', function() pickers.grep() return true end, 'Live grep the project with ripgrep')
vis:command_register('fm', function() pickers.fm() return true end, 'Pick a file with a terminal file manager') vis:command_register('fm', function() pickers.fm() return true end, 'Pick a file with a terminal file manager')
vis:command_register('git', function() pickers.git() return true end, 'Open gitu') vis:command_register('git', function() pickers.git() return true end, 'Open gitu')
vis:command_register('errors', function() pickers.errors() return true end, 'Browse errors from the last build')
-- build.sh setup -- build.sh setup
vis:map(vis.modes.NORMAL, '<C-b>', function() build.run({}) end, 'Run ./build.sh') vis:map(vis.modes.NORMAL, '<C-b>', function() build.run({}) end, 'Run ./build.sh')
@@ -40,7 +38,7 @@ vis.events.subscribe(vis.events.INIT, function()
for i = 1, #argv do table.insert(args, argv[i]) end for i = 1, #argv do table.insert(args, argv[i]) end
build.run(args) build.run(args)
return true return true
end, 'Run ./build.sh with optional flags, e.g. :build run') end, 'Run ./build.sh with optional flags, e.g :build run')
-- system clipboard -- system clipboard
local function clip_copy() local function clip_copy()
@@ -53,15 +51,12 @@ vis.events.subscribe(vis.events.INIT, function()
vis:feedkeys('<Escape>') -- clear any selection left on the pasted text vis:feedkeys('<Escape>') -- clear any selection left on the pasted text
end end
vis:map(vis.modes.VISUAL, '<C-y>', clip_copy, 'Copy selection to system clipboard') vis:map(vis.modes.VISUAL, '<C-c>', clip_copy, 'Copy selection to system clipboard')
vis:map(vis.modes.VISUAL, '<C-v>', clip_paste, 'Paste system clipboard at cursor') vis:map(vis.modes.VISUAL, '<C-v>', clip_paste, 'Paste system clipboard at cursor')
vis:map(vis.modes.NORMAL, '<C-v>', clip_paste, 'Paste system clipboard at cursor') vis:map(vis.modes.NORMAL, '<C-v>', clip_paste, 'Paste system clipboard at cursor')
-- ctag navigation
-- ctags: 'gd' to jump to tag definition under cursor vis:map(vis.modes.NORMAL, 'gd', '<C-]>', 'Go to definition (ctags picker)')
-- vis-ctags binds <C-]> (jump) and <C-t> (back) itself;
vis:map(vis.modes.NORMAL, 'gd', '<C-]>')
end) end)
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@@ -73,3 +68,13 @@ vis.events.subscribe(vis.events.WIN_OPEN, function(win)
vis:command('set number on') vis:command('set number on')
end) end)
-- ---------------------------------------------------------------------------
-- Syntax highlighting: ~/.config/vis/lexers/odin.lua
-- ---------------------------------------------------------------------------
if vis.ftdetect then
-- vis 0.9 and earlier
if vis.ftdetect.filetypes then
vis.ftdetect.filetypes.odin = { ext = { '%.odin$' } }
end
end