From ee1bb985157381c5319ed976811ee44c4da88378 Mon Sep 17 00:00:00 2001 From: Jason Hilder Date: Wed, 7 Oct 2026 08:21:36 +0200 Subject: [PATCH] Nearly there!! --- config/vis/build.lua | 167 +++++++++++++-------------------------- config/vis/pickers.lua | 172 +++++++++++++++++++++++++++++++++-------- config/vis/visrc.lua | 47 ++++++----- 3 files changed, 218 insertions(+), 168 deletions(-) diff --git a/config/vis/build.lua b/config/vis/build.lua index 969529c..c208fc5 100644 --- a/config/vis/build.lua +++ b/config/vis/build.lua @@ -1,129 +1,68 @@ --- ~/.config/vis/build.lua --- Runs the nearest build.sh, shows the output, and can jump to the first --- Odin error. Loaded from visrc.lua. - +-- Runs ./build.sh from vis's cwd (project root), logs the output for the +-- error picker (pickers.errors), and returns to vis on Enter/Esc. 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) - -- find the nearest build.sh, searching upward from vis's working dir - local p = io.popen('pwd') - 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') + if not exists('build.sh') then + vis:info('No build.sh in current directory') return end - -- save the current buffer so the build sees your latest changes - local win = vis.win - if win and win.file and win.file.modified and win.file.path then - vis:command('w') - end + -- save so the build sees your latest changes + local file = vis.win and vis.win.file + if file and file.modified and file.path then vis:command('w') end - -- shell-quote each flag and the directory - local quoted = {} - for _, a in ipairs(args) do - 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 parts = {} + for _, a in ipairs(args or {}) do parts[#parts + 1] = q(a) end + local argstr = table.concat(parts, ' ') local script = table.concat({ - 'saved=$(stty -g)', - 'stty sane', - 'cd ' .. qdir .. ' || exit 1', - 'printf "\\n\\033[1m$ %s\\033[0m (in %s)\\n\\n" ' .. qdisplay .. ' ' .. qdir, - '{ if [ -x ./build.sh ]; then ./build.sh ' .. argstr - .. '; else bash ./build.sh ' .. argstr .. '; fi; echo $? > \'' - .. 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 .. "'", + 'saved=$(stty -g); stty sane', + -- script runs the build on a pty (colors stay on) and logs it to M.log + 'script -qefc ' .. q('bash ./build.sh ' .. argstr) .. ' ' .. q(M.log), + 'st=$?', + [[printf '\n[exit %s] enter/esc: back to vis ' "$st"]], + [[while IFS= read -rsn1 k; do [ -z "$k" ] || [ "$k" = $'\e' ] && break; done]], 'stty "$saved"', }, '\n') - -- leave the alternate screen so the build output lands in your normal - -- terminal scrollback, run it, then come back to vis - local tty = io.open('/dev/tty', 'w') - if tty then tty:write('\27[?1049l') tty:close() end - - os.execute('{\n' .. script .. '\n} /dev/tty 2>&1') - - tty = io.open('/dev/tty', 'w') - if tty then tty:write('\27[?1049h') tty:close() end + -- leave the alt screen and clear it, so each run starts fresh and the + -- output lands in normal scrollback; run; come back to vis + tty('\27[?1049l\27[H\27[2J') + os.execute('bash -c ' .. q(script) .. ' /dev/tty 2>&1') + tty('\27[?1049h') 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 -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 2>&1 &') + end +end) + +return M \ No newline at end of file diff --git a/config/vis/pickers.lua b/config/vis/pickers.lua index 7097b2a..074f7bd 100644 --- a/config/vis/pickers.lua +++ b/config/vis/pickers.lua @@ -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 = {} -- height of the drop-down fzf panel at the top ('100%' = full screen) 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 local function tty_write(seq) @@ -23,6 +41,17 @@ local function restore_screen() vis:redraw() 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 local function run_fzf(cmd) -- --height mode draws from the cursor down, so start at the top-left @@ -66,18 +95,14 @@ end -- ctrl-n/p move down/up -- --------------------------------------------------------------------------- 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' - .. ' --height=' .. M.height .. ' --layout=reverse --border' - .. " --prompt 'files> '" - .. ' --print-query --expect=alt-enter,ctrl-v' -- output: query, key, choice - .. " --header 'enter: open · alt-enter: new file · ctrl-v: vsplit'" - .. " --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 cmd = 'fd --type f --hidden --exclude .git | ' .. fzf('files> ', { + '--print-query --expect=alt-enter,ctrl-v', -- output: query, key, choice + opt('--header', 'enter: open · alt-enter: new file · ctrl-v: vsplit'), + opt('--preview', PREVIEW_FILE), + opt('--preview-window', 'down:75%:border-top'), + opt('--bind', KEYS_NAV), + opt('--bind', KEYS_PREVIEW), + }) local out, code = run_fzf(cmd) 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 +-- M.grep() start empty, results appear as you type +-- M.grep_selection() visual mode: start with the selected text -- --------------------------------------------------------------------------- -function M.grep() - -- {1} = path, {2} = line number (fields split on ':') - local preview = 'bat --theme=custom_zen --color=always --style=numbers --highlight-line {2} {1}' - +local function grep_with(query) -- don't run rg on an empty query (it would match every line) local reload = '[ -n {q} ] && rg --column --line-number --no-heading' .. ' --color=always --smart-case --hidden --glob "!.git" -- {q} || true' - -- empty stdin: results only appear once you start typing - local cmd = ': | fzf' - .. ' --height=' .. M.height .. ' --layout=reverse --border' - .. " --prompt 'rg> '" - .. ' --ansi --disabled' -- let rg do the matching, not fzf - .. ' --delimiter :' - .. " --bind 'change:reload:" .. reload .. "'" - .. " --preview '" .. preview .. "'" - .. " --preview-window 'down:90%:border-top:+{2}/2'" - .. " --bind 'ctrl-/:toggle-preview'" - .. " --bind 'shift-up:preview-up,shift-down:preview-down'" + local opts = { + '--ansi --disabled', -- let rg do the matching, not fzf + '--delimiter :', -- fields: path:line:col:text + opt('--bind', 'change:reload:' .. reload), + opt('--preview', PREVIEW_AT), + opt('--preview-window', 'down:90%:border-top:+{2}/2'), + opt('--bind', KEYS_PREVIEW), + } + if query then + table.insert(opts, opt('--query', query)) + 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] if not choice or choice == '' then return end @@ -132,6 +158,84 @@ function M.grep() vis.win.selection:to(tonumber(line), tonumber(col)) 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 -- --------------------------------------------------------------------------- @@ -153,6 +257,7 @@ end -- git: run gitu, then hand the screen back to vis -- --------------------------------------------------------------------------- function M.git() + tty_write('\27[?1049l') os.execute('gitu /dev/tty 2>&1') restore_screen() @@ -164,16 +269,17 @@ function M.git() end end +-- --------------------------------------------------------------------------- +-- events -- --------------------------------------------------------------------------- -- 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 --- --------------------------------------------------------------------------- vis.events.subscribe(vis.events.FILE_SAVE_PRE, function(file, path) -- luacheck: no unused args local dir = path and path:match('^(.*)/[^/]+$') if dir and dir ~= '' then - os.execute("mkdir -p '" .. (dir:gsub("'", "'\\''")) .. "'") + os.execute('mkdir -p ' .. q(dir)) end return true end) -return M +return M \ No newline at end of file diff --git a/config/vis/visrc.lua b/config/vis/visrc.lua index 708b6bc..6fddfc4 100644 --- a/config/vis/visrc.lua +++ b/config/vis/visrc.lua @@ -1,19 +1,8 @@ require('vis') - -local pickers = require('pickers') -local build = require('build') - require('plugins/vis-ctags') --- --------------------------------------------------------------------------- --- 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 +local build = require('build') +local pickers = require('pickers') -- --------------------------------------------------------------------------- -- Global options, key mappings and commands @@ -21,17 +10,26 @@ end vis.events.subscribe(vis.events.INIT, function() vis:command('set escdelay 1') vis:command('set autoindent off') + vis:command('set ignorecase on') vis:command('set theme custom_zen') + -- jump list + vis:map(vis.modes.NORMAL, "", "g>") + vis:map(vis.modes.NORMAL, "", "g<") + -- pickers vis:map(vis.modes.NORMAL, '', pickers.files, 'Fuzzy find / create a file') vis:map(vis.modes.NORMAL, '', pickers.grep, 'Live grep the project with ripgrep') + vis:map(vis.modes.VISUAL, '', pickers.grep_selection, 'Live grep for the selected text') vis:map(vis.modes.NORMAL, '', pickers.fm, 'terminal file manager') + vis:map(vis.modes.NORMAL, '', pickers.errors, 'Browse errors from the last build') + -- picker commands 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('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 vis:map(vis.modes.NORMAL, '', 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 build.run(args) 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 local function clip_copy() @@ -53,15 +51,12 @@ vis.events.subscribe(vis.events.INIT, function() vis:feedkeys('') -- clear any selection left on the pasted text end - vis:map(vis.modes.VISUAL, '', clip_copy, 'Copy selection to system clipboard') + vis:map(vis.modes.VISUAL, '', clip_copy, 'Copy selection to system clipboard') vis:map(vis.modes.VISUAL, '', clip_paste, 'Paste system clipboard at cursor') vis:map(vis.modes.NORMAL, '', clip_paste, 'Paste system clipboard at cursor') - - -- ctags: 'gd' to jump to tag definition under cursor - -- vis-ctags binds (jump) and (back) itself; - vis:map(vis.modes.NORMAL, 'gd', '') - + -- ctag navigation + vis:map(vis.modes.NORMAL, 'gd', '', 'Go to definition (ctags picker)') end) -- --------------------------------------------------------------------------- @@ -73,3 +68,13 @@ vis.events.subscribe(vis.events.WIN_OPEN, function(win) vis:command('set number on') 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 +