diff --git a/init.lua b/init.lua index 8db9da5..11b9975 100644 --- a/init.lua +++ b/init.lua @@ -1,7 +1,5 @@ -_G.Config = {} -local nix = require('config.nix').init { non_nix_value = true } -Config.isNixCats = nix.is_nix -Config.nixConfig = nix +local Config = require('config') +_G.Config = Config -- keep global alias for keymaps/backward compatibility require('lze').register_handlers(require('nixCatsUtils.lzUtils').for_cat) @@ -12,8 +10,11 @@ if not Config.isNixCats then local mini_path = path_package .. 'pack/deps/start/mini.nvim' if not vim.uv.fs_stat(mini_path) then vim.cmd('echo "Installing `mini.nvim`" | redraw') + -- Pin to the stable branch for reproducible non-Nix installs. + -- Change the tag/branch here if you need a newer version. + local mini_nvim_tag = 'stable' local clone_cmd = { - 'git', 'clone', '--filter=blob:none', + 'git', 'clone', '--filter=blob:none', '--branch=' .. mini_nvim_tag, 'https://github.com/echasnovski/mini.nvim', mini_path } vim.fn.system(clone_cmd) diff --git a/lua/config/init.lua b/lua/config/init.lua new file mode 100644 index 0000000..60b80e5 --- /dev/null +++ b/lua/config/init.lua @@ -0,0 +1,10 @@ +--- Shared configuration / state table. +--- Plugin submodules attach their helpers to this table at runtime. +local M = {} + +-- Detect whether this Neovim was launched via nixCats. +local nix = require('config.nix').init { non_nix_value = true } +M.isNixCats = nix.is_nix +M.nixConfig = nix + +return M diff --git a/lua/keymap/core.lua b/lua/keymap/core.lua new file mode 100644 index 0000000..8c0abe3 --- /dev/null +++ b/lua/keymap/core.lua @@ -0,0 +1,44 @@ +local Config = require('config') + +-- Basic mappings ============================================================= +-- NOTE: Most basic mappings come from 'mini.basics' +-- Shorter version of the most frequent way of going outside of terminal window +vim.keymap.set('t', '', [[h]]) +-- Select all +-- vim.keymap.set({ "n", "v", "x" }, "", "gg3vG$", { noremap = true, silent = true, desc = "Select all" }) +-- Escape deletes highlights +vim.keymap.set("n", "", "nohlsearch") +-- Paste before/after linewise +local cmd = vim.fn.has('nvim-0.12') == 1 and 'iput' or 'put' +vim.keymap.set({ 'n', 'x' }, '[p', 'exe "' .. cmd .. '! " . v:register', { desc = 'Paste Above' }) +vim.keymap.set({ 'n', 'x' }, ']p', 'exe "' .. cmd .. ' " . v:register', { desc = 'Paste Below' }) + +vim.keymap.set({ "n", "v", "x" }, "p", '"+p', { noremap = true, silent = true, desc = "Paste from clipboard" }) +vim.keymap.set({ "n", "v", "x" }, "y", '"+y', { noremap = true, silent = true, desc = "Copy toclipboard" }) + +-- Create global tables with information about clue group in certain modes +-- Structure of tables is taken to be compatible with 'mini.clue'. +Config.leader_group_clues = { + { mode = 'n', keys = 'a', desc = '+AI' }, + { mode = 'n', keys = 'b', desc = '+Buffer' }, + { mode = 'n', keys = 'e', desc = '+Explore' }, + { mode = 'n', keys = 'f', desc = '+Find' }, + { mode = 'n', keys = 'fl', desc = '+LSP' }, + { mode = 'n', keys = 'fa', desc = '+Git' }, + { mode = 'n', keys = 'g', desc = '+Git' }, + { mode = 'n', keys = 'l', desc = '+LSP' }, + { mode = 'n', keys = 'L', desc = '+Lua/Log' }, + { mode = 'n', keys = 'o', desc = '+Other' }, + { mode = 'n', keys = 'r', desc = '+R' }, + { mode = 'n', keys = 's', desc = '+Send' }, + { mode = 'n', keys = 'd', desc = '+Debug' }, + { mode = 'n', keys = 't', desc = '+Terminal' }, + { mode = 'n', keys = 'u', desc = '+UI' }, + { mode = 'n', keys = 'v', desc = '+Visits' }, + { mode = 'n', keys = 'w', desc = '+Windows' }, + { mode = 'x', keys = 'l', desc = '+LSP' }, + { mode = 'x', keys = 'r', desc = '+R' }, + { mode = 'n', keys = 'z', desc = '+ZK' }, + { mode = 'n', keys = 'zr', desc = '+Reviews' }, + { mode = 'x', keys = 'a', desc = '+AI' }, +} diff --git a/lua/keymap/helpers.lua b/lua/keymap/helpers.lua new file mode 100644 index 0000000..10cdaa2 --- /dev/null +++ b/lua/keymap/helpers.lua @@ -0,0 +1,25 @@ +local M = {} + +---Create a normal-mode `` mapping. +function M.nmap_leader(suffix, rhs, desc, opts) + opts = opts or {} + opts.desc = desc + vim.keymap.set('n', '' .. suffix, rhs, opts) +end + +---Create a visual-mode `` mapping. +function M.xmap_leader(suffix, rhs, desc, opts) + opts = opts or {} + opts.desc = desc + vim.keymap.set('x', '' .. suffix, rhs, opts) +end + +---Create a normal-mode LSP keymap with an "(LSP)" suffix in the description. +function M.nmap_lsp(keys, func, desc) + if desc then + desc = desc .. "(LSP)" + end + vim.keymap.set("n", keys, func, { desc = desc }) +end + +return M diff --git a/lua/keymap/leader.lua b/lua/keymap/leader.lua new file mode 100644 index 0000000..795079f --- /dev/null +++ b/lua/keymap/leader.lua @@ -0,0 +1,264 @@ +local Config = require('config') +local helpers = require('keymap.helpers') +local nmap_leader = helpers.nmap_leader +local xmap_leader = helpers.xmap_leader +local nmap_lsp = helpers.nmap_lsp + +-- stylua: ignore start + +-- Switch buffers +nmap_leader('', 'bnext', 'Next buffer') +nmap_leader('', 'bprev', 'Prev buffer') + +-- a is for 'AI' +nmap_leader("aa", "CodeCompanion /agent", "Agent chat (@{agent} tools)") +nmap_leader("ac", "CodeCompanionChat Toggle", "Chat Toggle") +nmap_leader("aC", function() + local chat = require("codecompanion").last_chat() + if not chat then + return vim.notify("No CodeCompanion chat to compact", vim.log.levels.WARN) + end + require("codecompanion.interactions.chat.context_management.compaction").compact(chat, { min_token_savings = 0 }) +end, "Compact chat") +nmap_leader("ag", "CodeCompanion /commit", "Generate commit message") +nmap_leader("ai", "CodeCompanionActions", "Chat Action") +nmap_leader("al", "CodeCompanion /lsp", "Explain LSP Diagnostics") +nmap_leader("an", "CodeCompanionChat Add", "Chat New") +nmap_leader("as", "CodeCompanion /suggest", "Suggest Improvements") +nmap_leader("aw", "CodeCompanion /tdd", "Workflow: plan, implement, test") +nmap_leader("ax", "CodeCompanion /fixer", "Code Fixer") +xmap_leader("aa", "CodeCompanion /agent", "Agent on selection") +xmap_leader("ae", "CodeCompanion /explain", "Explain Code") +xmap_leader("af", "CodeCompanion /fix", "Fix Code") +xmap_leader("ap", "CodeCompanion /expert", "Code Expert") +xmap_leader("as", "CodeCompanion /suggest", "Suggest Improvements") +nmap_leader("ak", "CodeCompanionChat adapter=codex", "Chat with Codex") + +-- b is for 'buffer' +nmap_leader('bb', 'b#', 'Alternate') +nmap_leader('bd', 'lua MiniBufremove.delete()', 'Delete') +nmap_leader('bD', 'lua MiniBufremove.delete(0, true)', 'Delete!') +nmap_leader('bs', 'lua Config.new_scratch_buffer()', 'Scratch') +nmap_leader('bw', 'lua MiniBufremove.wipeout()', 'Wipeout') +nmap_leader('bW', 'lua MiniBufremove.wipeout(0, true)', 'Wipeout!') +nmap_leader('bq', 'qall', 'Quit all') + +-- e is for 'explore' and 'edit' +nmap_leader('ed', 'lua MiniFiles.open()', 'Directory') +nmap_leader('ef', 'lua Config.try_opendir()', 'File directory') +nmap_leader('es', 'lua MiniSessions.select()', 'Sessions') +nmap_leader('eq', 'lua Config.toggle_quickfix()', 'Quickfix') +nmap_leader('ez', 'lua MiniFiles.open(os.getenv("ZK_NOTEBOOK_DIR"))', 'Notes directory') + +-- f is for 'fuzzy find' +nmap_leader('f/', 'Pick history scope="/"', '"/" history') +nmap_leader('f:', 'Pick history scope=":"', '":" history') +nmap_leader('f,', 'Pick visit_labels', 'Visit labels') +nmap_leader('faa', 'Pick git_hunks scope="staged"', 'Added hunks (all)') +nmap_leader('faA', 'Pick git_hunks path="%" scope="staged"', 'Added hunks (current)') +nmap_leader('fb', 'Pick buffers', 'Buffers') +nmap_leader(',', 'Pick buffers', 'Buffers') +nmap_leader('fac', 'Pick git_commits', 'Commits (all)') +nmap_leader('faC', 'Pick git_commits path="%"', 'Commits (current)') +nmap_leader('fd', 'Pick diagnostic scope="all"', 'Diagnostic workspace') +nmap_leader('fD', 'Pick diagnostic scope="current"', 'Diagnostic buffer') +nmap_leader('ff', 'Pick files', 'Files') +nmap_leader('fg', 'Pick grep_live', 'Grep live') +nmap_leader('fG', 'Pick grep pattern=""', 'Grep current word') +nmap_leader('fh', 'Pick help', 'Help tags') +nmap_leader('fH', 'Pick hl_groups', 'Highlight groups') +nmap_leader('fj', 'Pick buf_lines scope="all"', 'Lines (all)') +nmap_leader('fJ', 'Pick buf_lines scope="current"', 'Lines (current)') +nmap_leader('fam', 'Pick git_hunks', 'Modified hunks (all)') +nmap_leader('faM', 'Pick git_hunks path="%"', 'Modified hunks (current)') +nmap_leader('fm', 'Pick marks', 'Marks') +nmap_leader('fn', 'ZkNotes', "Notes") +nmap_leader('fk', 'Pick keymaps', 'Keymaps') +nmap_leader('fR', 'Pick resume', 'Resume') +nmap_leader('fp', 'Pick files', 'Files') +nmap_leader('fq', 'Pick list scope="quickfix"', 'Quickfix') +nmap_leader('fr', 'Pick lsp scope="references"', 'References (LSP)') +nmap_leader('flr', 'Pick lsp scope="references"', 'References (LSP)') +nmap_leader('fS', 'Pick lsp scope="workspace_symbol"', 'Symbols workspace (LSP)') +nmap_leader('flS', 'Pick lsp scope="workspace_symbol"', 'Symbols workspace (LSP)') +nmap_leader('fs', 'Pick lsp scope="document_symbol"', 'Symbols buffer (LSP)') +nmap_leader('fls', 'Pick lsp scope="document_symbol"', 'Symbols buffer (LSP)') +nmap_leader('fld', 'Pick lsp scope="definition"', 'Definition (LSP)') +nmap_leader('flD', 'Pick lsp scope="declaration"', 'Declaration (LSP)') +nmap_leader('flt', 'Pick lsp scope="type_definition"', 'Type Definition (LSP)') +nmap_leader('fv', 'Pick visit_paths cwd=""', 'Visit paths (all)') +nmap_leader('fV', 'Pick visit_paths', 'Visit paths (cwd)') + +-- g is for git +local git_log_cmd = [[Git log --pretty=format:\%h\ \%as\ │\ \%s --topo-order]] + +nmap_leader('gc', 'Git commit', 'Commit') +nmap_leader('gC', 'Git commit --amend', 'Commit amend') +nmap_leader('gd', 'Git diff', 'Diff') +nmap_leader('gD', 'Git diff -- %', 'Diff buffer') +nmap_leader("gg", "Neogit", "Open Neogit UI") +nmap_leader('gl', '' .. git_log_cmd .. '', 'Log') +nmap_leader('gL', '' .. git_log_cmd .. ' --follow -- %', 'Log buffer') +nmap_leader('go', 'lua MiniDiff.toggle_overlay()', 'Toggle overlay') +nmap_leader('gp', 'Git pull', 'Pull') +nmap_leader('gP', 'Git push', 'Push') +nmap_leader('gs', 'lua MiniGit.show_at_cursor()', 'Show at cursor') + +xmap_leader('gs', 'lua MiniGit.show_at_cursor()', 'Show at selection') + +-- j/k navigate quickfix +nmap_leader("j", 'cnextzz', "Quickfix next") +nmap_leader("k", 'cprevzz', "Quickfix prev") + +-- l is for 'LSP' (Language Server Protocol) +vim.keymap.set({ 'n' }, 'grd', 'lua vim.lsp.buf.definition()', { desc = 'Definition' }) +vim.keymap.set({ 'n' }, 'grk', 'lua vim.lsp.buf.hover()', { desc = 'Documentation' }) +vim.keymap.set({ 'n' }, 'gre', 'lua vim.diagnostic.open_float()', { desc = 'Diagnostics' }) + +nmap_lsp("K", 'lua vim.lsp.buf.hover()', "Documentation") +local formatting_cmd = 'lua require("conform").format({ lsp_format = "fallback" })' +nmap_leader('la', 'lua vim.lsp.buf.code_action()', 'Actions') +nmap_leader('le', 'lua vim.diagnostic.open_float()', 'Diagnostics popup') +nmap_leader('lf', formatting_cmd, 'Format') +nmap_leader('lk', 'lua vim.lsp.buf.hover()', 'Documentation') +nmap_leader('li', 'lua vim.lsp.buf.implementation()', 'Information') +-- use ]d and [d +--nmap_leader('lj', 'lua vim.diagnostic.goto_next()', 'Next diagnostic') +--nmap_leader('lk', 'lua vim.diagnostic.goto_prev()', 'Prev diagnostic') +nmap_leader('lR', 'lua vim.lsp.buf.references()', 'References') +nmap_leader('lr', 'lua vim.lsp.buf.rename()', 'Rename') +nmap_leader('ls', 'lua vim.lsp.buf.definition()', 'Source definition') + +xmap_leader('lf', formatting_cmd, 'Format selection') + +-- L is for 'Lua' +nmap_leader('Lc', 'lua Config.log_clear()', 'Clear log') +nmap_leader('LL', 'luafile %echo "Sourced lua"', 'Source buffer') +nmap_leader('Ls', 'lua Config.log_print()', 'Show log') +nmap_leader('Lx', 'lua Config.execute_lua_line()', 'Execute `lua` line') + +-- m is free + +-- o is for 'other' +local trailspace_toggle_command = 'lua vim.b.minitrailspace_disable = not vim.b.minitrailspace_disable' +nmap_leader('oh', 'normal gxiagxila', 'Move arg left') +nmap_leader('ol', 'normal gxiagxina', 'Move arg right') +nmap_leader('or', 'lua MiniMisc.resize_window()', 'Resize to default width') +nmap_leader('ot', 'lua MiniTrailspace.trim()', 'Trim trailspace') +nmap_leader('oT', trailspace_toggle_command, 'Trailspace hl toggle') +nmap_leader('oz', 'lua MiniMisc.zoom()', 'Zoom toggle') +nmap_leader('ow', + "lua MiniSessions.write(vim.fn.input('Session name: ', string.match(vim.fn.getcwd(), \"[^/]+$\") .. '-session.vim'))", + 'Write session') + +-- r is for 'R' +nmap_leader('rc', 'RSend devtools::check()', 'Check') +nmap_leader('rC', 'RSend devtools::test_coverage()', 'Coverage') +nmap_leader('rd', 'RSend devtools::document()', 'Document') +nmap_leader('ri', 'RSend devtools::install(keep_source=TRUE)', 'Install') +nmap_leader('rk', 'RSend quarto::quarto_preview("%")', 'Knit file') +nmap_leader('rl', 'RSend devtools::load_all()', 'Load all') +nmap_leader('rL', 'RSend devtools::load_all(recompile=TRUE)', 'Load all recompile') +nmap_leader('rm', 'RSend Rcpp::compileAttributes()', 'Run examples') +nmap_leader('rT', 'RSend testthat::test_file("%")', 'Test file') +nmap_leader('rt', 'RSend devtools::test()', 'Test') + +-- Visual `r` bindings (dispatched by the filetype-aware REPL +-- runner in `lua/keymap/repl.lua`). Previously the visual clue group was +-- empty, so this restores parity between the visual and normal `r`-group. +xmap_leader('rr', function() require('keymap.repl').send_selection() end, 'Send selection to REPL') +-- - Copy to clipboard and make reprex (which itself is loaded to clipboard) +xmap_leader('rx', '"+y :RSend reprex::reprex()', 'Reprex selection') + +-- s is for 'send' (Send text to the active REPL/runner) +nmap_leader('s', function() require('keymap.repl').send_line() end, 'Send to REPL') +xmap_leader('s', function() require('keymap.repl').send_selection() end, 'Send selection to REPL') + +-- d is for 'debug' (nvim-dap) +nmap_leader('db', 'lua require("dap").toggle_breakpoint()', 'Toggle breakpoint') +nmap_leader('dB', 'lua require("dap").set_breakpoint(vim.fn.input("Condition: "))', 'Conditional breakpoint') +nmap_leader('dc', 'lua require("dap").continue()', 'Continue') +nmap_leader('do', 'lua require("dap").step_over()', 'Step over') +nmap_leader('di', 'lua require("dap").step_into()', 'Step into') +nmap_leader('dO', 'lua require("dap").step_out()', 'Step out') +nmap_leader('dr', 'lua require("dap").repl.open()', 'Open DAP REPL') +nmap_leader('du', 'lua require("dapui").toggle()', 'Toggle DAP UI') +nmap_leader('dK', 'lua require("dapui").eval()', 'Evaluate expression') + +-- u is for UI +nmap_leader('ut', 'TSContext toggle', 'Toggle TScontext') +nmap_leader('ua', 'Copilot toggle', 'Toggle AI completion') + +-- v is for 'visits' +nmap_leader('vv', 'lua MiniVisits.add_label("core")', 'Add "core" label') +nmap_leader('vV', 'lua MiniVisits.remove_label("core")', 'Remove "core" label') +nmap_leader('vl', 'lua MiniVisits.add_label()', 'Add label') +nmap_leader('vL', 'lua MiniVisits.remove_label()', 'Remove label') + +local map_pick_core = function(keys, cwd, desc) + local rhs = function() + local sort_latest = MiniVisits.gen_sort.default({ recency_weight = 1 }) + MiniExtra.pickers.visit_paths({ + cwd = cwd, + filter = 'core', + sort = sort_latest + }, { source = { name = desc } }) + end + nmap_leader(keys, rhs, desc) +end +map_pick_core('vc', '', 'Core visits (all)') +map_pick_core('vC', nil, 'Core visits (cwd)') + +-- w is for 'windows' +nmap_leader("wh", "h", "Go to Left Window", { remap = true }) +nmap_leader("wj", "j", "Go to Lower Window", { remap = true }) +nmap_leader("wk", "k", "Go to Upper Window", { remap = true }) +nmap_leader("wl", "l", "Go to Right Window", { remap = true }) + +nmap_leader("_", "s", "Split Window Below", { remap = true }) +nmap_leader("|", "v", "Split Window Right", { remap = true }) +nmap_leader("wd", "c", "Delete Window", { remap = true }) +nmap_leader("wo", "o", "Delete Other Windows", { remap = true }) + +-- z is for 'ZettelKasten' +nmap_leader("zo", 'ZkNotes', "Notes") +nmap_leader("zt", 'ZkTags', "Tags") + +nmap_leader( + "zrd", + 'ZkNew { group = "dreviews" }', + "Daily Review" +) +nmap_leader( + "zrw", + 'ZkNew { group = "wreviews" }', + "Weekly Review" +) +nmap_leader( + "zn", + 'ZkNew { group = "inbox", title = vim.fn.input("Title: ") }', + "New" +) +nmap_leader( + "zp", + "ZkNew { group = 'permanent', title = vim.fn.input('Title: ') }", + "Permanent" +) + +nmap_leader( + "zl", + "ZkNew { group = 'literature', title = vim.fn.input('Title: '), extra.author = vim.fn.input('Author: '), extra.year = vim.fn.input('Year: ') }", + "Literature" +) + +nmap_leader( + "zd", + "ZkNew { group = 'dashboard', title = vim.fn.input('Title: ') }", + "Dashboard" +) +nmap_leader( + "zP", + "ZkNew { group = 'project', title = vim.fn.input('Title: ')}", + "Project" +) +-- stylua: ignore end diff --git a/lua/keymap/repl.lua b/lua/keymap/repl.lua new file mode 100644 index 0000000..35425aa --- /dev/null +++ b/lua/keymap/repl.lua @@ -0,0 +1,101 @@ +--- Filetype-aware REPL/runner dispatcher. +--- Keeps the existing terminal setup intact; it only decides *which* runner +--- to use for the current buffer/filetype. + +local M = {} + +--- Resolve the filetype to use for dispatching. +--- Quarto buffers report `quarto`; R buffers report `r`. Fallback to the +--- actual filetype if no special handling is needed. +local function dispatch_ft() + local ft = vim.bo.filetype + if ft == "quarto" or ft == "rmd" or ft == "markdown" then + return "quarto" + end + return ft +end + +--- Send the current line to the active REPL/runner. +function M.send_line() + local ft = dispatch_ft() + + if ft == "r" then + -- R.nvim v1+ exposes a Lua API; try the modern `r.send` module first, + -- then fall back to the older `r.run` module, and finally to . + local ok, rmod = pcall(require, "r.send") + if not ok or not rmod then + ok, rmod = pcall(require, "r.run") + end + if ok and rmod then + if type(rmod.line) == "function" then + rmod.line() + return + elseif type(rmod.send_line) == "function" then + rmod.send_line() + return + end + end + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes("RDSendLine", true, false, true), + "m", + false + ) + return + end + + if ft == "quarto" then + local ok, runner = pcall(require, "quarto.runner") + if ok and runner then + if runner.run_line then + runner.run_line() + elseif runner.run_cell then + runner.run_cell() + end + return + end + end + + -- Default: vim-slime (terminal). + local line = vim.api.nvim_get_current_line() + vim.fn["slime#send"](line .. "\n") + -- Move to the next line, matching the previous behaviour. + vim.cmd("normal! j") +end + +--- Send the current visual selection to the active REPL/runner. +function M.send_selection() + local ft = dispatch_ft() + + if ft == "r" then + -- Prefer R.nvim v1+ Lua API; try the modern `r.send` module first, + -- then fall back to the older `r.run` module, and finally to . + local ok, rmod = pcall(require, "r.send") + if not ok or not rmod then + ok, rmod = pcall(require, "r.run") + end + if ok and rmod then + if type(rmod.selection) == "function" then + rmod.selection() + return + elseif type(rmod.send_selection) == "function" then + rmod.send_selection() + return + end + end + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes("RSendSelection", true, false, true), + "m", + false + ) + return + end + + -- For Quarto/others, fall back to vim-slime's visual send. + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes("SlimeRegionSend", true, false, true), + "m", + false + ) +end + +return M diff --git a/lua/keymap/terminal.lua b/lua/keymap/terminal.lua new file mode 100644 index 0000000..bb89596 --- /dev/null +++ b/lua/keymap/terminal.lua @@ -0,0 +1,17 @@ +local Config = require('config') +local helpers = require('keymap.helpers') +local nmap_leader = helpers.nmap_leader + +-- Exit terminal insert mode with +vim.keymap.set("t", "", [[]], { desc = "Exit terminal mode" }) + +-- t is for 'terminal' +nmap_leader("tc", 'lua Config.terminal.open_clickhouse_client()', 'Open Clickhouse client') +nmap_leader("tl", 'lua Config.terminal.open_clickhouse_local()', 'Open Clickhouse local') +nmap_leader("tp", 'lua Config.terminal.open_python()', 'Open Python') +nmap_leader("tj", 'lua Config.terminal.open_julia()', 'Open Julia') +nmap_leader("td", 'lua Config.terminal.open_duckdb();Config.terminal.toggle_bracket()', 'Open DuckDB') +nmap_leader("tx", 'lua Config.terminal.open_in_terminal()', 'Terminal Command') +nmap_leader("tt", 'lua Config.terminal.open_shell()', 'Terminal') +nmap_leader("tb", 'lua Config.terminal.toggle_bracket()', 'Toggle bracketed paste') +nmap_leader("up", 'lua Config.terminal.toggle_bracket()', 'Toggle bracketed paste') diff --git a/lua/nix_smart_send.lua b/lua/nix_smart_send.lua index 1f82383..51e9611 100644 --- a/lua/nix_smart_send.lua +++ b/lua/nix_smart_send.lua @@ -1,5 +1,12 @@ local M = {} +-- Define comment node types as constants +local COMMENT_TYPES = { + comment = true, + block_comment = true, + line_comment = true, +} + -- Helper function to check if value exists in list (optimized with early return) local function is_in_list(list, value) if not list or not value then @@ -14,83 +21,90 @@ local function is_in_list(list, value) return false end --- Define comment node types as constants -local COMMENT_TYPES = { - comment = true, - block_comment = true, - line_comment = true, -} - +-- Safely get the Tree-sitter node under the cursor. function M.get_current_node() - local cur_win = vim.api.nvim_get_current_win() - return vim.treesitter.get_node({ - winid = cur_win, - ignore_injections = true, - }) + local ok, node = pcall(vim.treesitter.get_node, { ignore_injections = false }) + return ok and node or nil end +-- Detect the root node type of the current buffer's Tree-sitter tree. function M.detect_global_node() local cur_node = M.get_current_node() local root if not cur_node then - -- print("No node detected") - local parser = vim.treesitter.get_parser() - if not parser then + local ok, parser = pcall(vim.treesitter.get_parser) + if not ok or not parser then return nil end - root = parser:parse()[1]:root() + local trees = parser:parse() + if not trees or not trees[1] then + return nil + end + root = trees[1]:root() else root = cur_node:root() end - if not root then + return root and root:type() or nil +end + +-- Ascend the tree from the current node until we hit a node whose parent is a +-- "global" node (or the root). This is the unit of code we want to send. +local function get_target_node(global_nodes) + local root_type = M.detect_global_node() + global_nodes = global_nodes or {} + + local node = M.get_current_node() + if not node then return nil end - return root:type() -end - -function M.move_to_next_non_empty_line() - -- Search for the next non-empty line - local line_num = vim.fn.search("[^;\\s]", "W") - - if line_num <= 0 then - -- print("No non-empty line found below the current position") - return false - end - - -- Get the line content and find first non-whitespace character - local line_content = vim.api.nvim_buf_get_lines(0, line_num - 1, line_num, false)[1] - local first_non_ws = line_content:find("%S") or 1 - vim.api.nvim_win_set_cursor(0, { line_num, first_non_ws - 1 }) - - local node = M.get_current_node() - if not node or not node:type() then - -- print("No node found") - return false - end - - local global_node_type = M.detect_global_node() - - -- Skip comments and global nodes - while node and (COMMENT_TYPES[node:type()] or node:type() == global_node_type) do - line_num = line_num + 1 - local max_lines = vim.api.nvim_buf_line_count(0) - - if line_num > max_lines then - -- print("Reached end of buffer") - return false + while node do + local parent = node:parent() + if not parent then + break end - -- Get the line content and find first non-whitespace character - line_content = vim.api.nvim_buf_get_lines(0, line_num - 1, line_num, false)[1] - first_non_ws = line_content:find("%S") or 1 - vim.api.nvim_win_set_cursor(0, { line_num, first_non_ws - 1 }) - node = vim.treesitter.get_node() + local p_type = parent:type() + if is_in_list(global_nodes, p_type) or p_type == root_type then + break + end + node = parent end - return true + return node +end + +-- Move the cursor to the next named sibling that is not a comment. +-- Operates on the Tree-sitter AST instead of scanning lines, so it is fast and +-- language-agnostic. +function M.move_to_next_non_empty_line(current_node) + local node = current_node + if not node then + node = get_target_node({}) + end + if not node then + return false + end + + -- Walk up the tree until we find a node with a next named sibling, + -- so we escape nested blocks when we are on the last statement. + while node and not node:next_named_sibling() do + node = node:parent() + end + + node = node:next_named_sibling() + while node do + if not COMMENT_TYPES[node:type()] then + local start_row, start_col = node:range() + pcall(vim.api.nvim_win_set_cursor, 0, { start_row + 1, start_col }) + return true, node + end + node = node:next_named_sibling() + end + + return false end function M.vselect_node(node) @@ -108,93 +122,52 @@ function M.vselect_node(node) end function M.select_until_global(global_nodes) - local root_node = M.detect_global_node() - if not root_node and global_nodes then - root_node = global_nodes[1] - end - - -- Use empty table if no global nodes provided - global_nodes = global_nodes or {} - - local node = vim.treesitter.get_node() - if not node then - -- print("No syntax node found at cursor position") + local target = get_target_node(global_nodes) + if not target then return nil end - local node_type = node:type() - - if node_type == root_node then - -- print("Cursor is on the root " .. root_node .. " node or in an empty area.") - return nil - end - - -- Check if current node is a global - if is_in_list(global_nodes, node_type) then - if M.vselect_node(node) then - return node - end - end - - -- Traverse up the tree until we find a global node or reach the root - local parent = node:parent() - local parent_type = parent:type() or "" - if parent and is_in_list(global_nodes, parent_type) then - if M.vselect_node(node) then - return node - end - end - while parent and not is_in_list(global_nodes, parent:type()) do - node = parent - parent = node:parent() - end - - if M.vselect_node(node) then - return node + if M.vselect_node(target) then + return target end return nil end function M.slime_send_region() - -- Check if slime plugin is available - if not vim.fn.exists('*slime#send_op') then - vim.notify("slime plugin not available", vim.log.levels.ERROR) - return - end - - local slime_command = ":call slime#send_op(visualmode(), 1)" - local termcodes = vim.api.nvim_replace_termcodes(slime_command, true, true, true) - - vim.api.nvim_feedkeys(termcodes, "x", true) + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes("SlimeRegionSend", true, false, true), + "m", + false + ) end function M.send_repl(global_nodes) - local cur_node = M.get_current_node() - - if not cur_node then - M.move_to_next_non_empty_line() - else - local cur_type = cur_node:type() - if COMMENT_TYPES[cur_type] or is_in_list(global_nodes, cur_type) then - M.move_to_next_non_empty_line() - end - end - - local sel_node = M.select_until_global(global_nodes) - if not sel_node then - -- print("No node selected for REPL") + local target_node = get_target_node(global_nodes) + if not target_node then return end - -- Send the selected text to the terminal using vim-slime - M.slime_send_region() + -- If sitting on a comment, step forward first so we don't send comments. + if COMMENT_TYPES[target_node:type()] then + local moved, next_node = M.move_to_next_non_empty_line(target_node) + if not moved or not next_node then + return + end + target_node = next_node + end - -- Move cursor and continue - local _, _, er, ec = sel_node:range() - vim.api.nvim_win_set_cursor(0, { er + 1, ec }) + -- Extract node text and send directly to avoid visual-mode/feedkeys races. + local ok, text = pcall(vim.treesitter.get_node_text, target_node, 0) + if not ok or not text then + vim.notify("Could not extract code from Tree-sitter node", vim.log.levels.WARN) + return + end - M.move_to_next_non_empty_line() + vim.fn["slime#send"](text .. "\n") + + -- Jump to the next relevant AST node instead of scanning lines + M.move_to_next_non_empty_line(target_node) end return M diff --git a/modules/module/settings/cat-packages.nix b/modules/module/settings/cat-packages.nix index 2709ca0..ee1a255 100644 --- a/modules/module/settings/cat-packages.nix +++ b/modules/module/settings/cat-packages.nix @@ -5,6 +5,10 @@ ... }: let + # Include packages from a category only if that category is enabled. + # NOTE: Package list expressions are lazily evaluated, and derivations are + # not built until needed, so keep side-effecting expressions out of these + # lists. maybe = cat: pkgsList: lib.optionals (config.cats.${cat} or false) pkgsList; rPackages = (pkgs.baseRPackages or [ ]) ++ config.settings.lang_packages.r; @@ -21,11 +25,14 @@ in }; config.catPkgs = { - always = maybe "always" (with pkgs; [ ]); + always = maybe "always" (with pkgs; [ + ripgrep + ]); clickhouse = maybe "clickhouse" (with pkgs; [ clickhouse-lts ]); external = maybe "external" (with pkgs; [ + nodejs perl ruby shfmt @@ -40,10 +47,12 @@ in lua = maybe "lua" (with pkgs; [ lua-language-server ]); markdown = maybe "markdown" (with pkgs; [ - python313Packages.pylatexenc + python3Packages.pylatexenc quartoPkg zk marksman + texlab + imagemagick ]); nix = maybe "nix" (with pkgs; [ @@ -88,7 +97,6 @@ in in with pkgs; [ python_with_packages - nodejs ruff basedpyright uv diff --git a/modules/module/settings/lang-packages.nix b/modules/module/settings/lang-packages.nix index a91d9a5..3be7a9a 100644 --- a/modules/module/settings/lang-packages.nix +++ b/modules/module/settings/lang-packages.nix @@ -44,6 +44,11 @@ data_table janitor styler + # vscDebugger is not on CRAN/Bioconductor, so it is not available in + # pkgs.rpkgs.rPackages. Install it manually in your R library if you + # want to use the nvim-dap R adapter (see plugin/26_dap.lua). + # vscDebugger + lintr ]) ); julia = lib.mkDefault [ diff --git a/modules/module/specs/plugins.nix b/modules/module/specs/plugins.nix index 63d32d0..7630632 100644 --- a/modules/module/specs/plugins.nix +++ b/modules/module/specs/plugins.nix @@ -3,7 +3,52 @@ pkgs, lib, ... -}: { +}: +let + parserList = [ + "bash" + "bibtex" + "c" + "cpp" + "csv" + "diff" + "dockerfile" + "git_config" + "git_rebase" + "gitattributes" + "gitcommit" + "gitignore" + "html" + "javascript" + "json" + "julia" + "latex" + "lua" + "luadoc" + "make" + "markdown" + "markdown_inline" + "matlab" + "nix" + "python" + "query" + "r" + "rnoweb" + "regex" + "sql" + "toml" + "vim" + "vimdoc" + "xml" + "yaml" + "zig" + ]; +in { + options.settings.treesitter_parsers = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = parserList; + description = "Tree-sitter parser names to install when the treesitterParsers category is enabled."; + }; config.specs.gitPlugins = lib.mkIf (config.cats.gitPlugins or false) { data = []; @@ -24,6 +69,7 @@ lazy = true; data = [ config.nvim-lib.neovimPlugins.cmp-pandoc-references + pkgs.vimPlugins.image-nvim ]; }; @@ -94,6 +140,7 @@ data = with pkgs.vimPlugins; [ quarto-nvim render-markdown-nvim + vimtex { data = otter-nvim; pname = "otter"; @@ -125,42 +172,7 @@ }; config.specs.treesitterParsers = lib.mkIf (config.cats.treesitterParsers or false) { - data = with pkgs.vimPlugins.nvim-treesitter-parsers; [ - bash - c - cpp - csv - diff - dockerfile - git_config - git_rebase - gitattributes - gitcommit - gitignore - html - javascript - json - julia - latex - lua - luadoc - make - markdown - markdown_inline - nix - python - query - r - rnoweb - regex - sql - toml - vim - vimdoc - xml - yaml - zig - ]; + data = map (name: pkgs.vimPlugins.nvim-treesitter-parsers.${name}) config.settings.treesitter_parsers; }; config.specs.utils-lazy = lib.mkIf (config.cats.utils or false) { @@ -172,11 +184,21 @@ colorful-menu-nvim conform-nvim copilot-lua + nvim-lint + vim-slime + ]; + }; + + # Lazy-loaded plugins needed when the `r` cat is on. Kept separate from + # `utils-lazy` so users with `r=true` and `utils=false` still get the + # nvim-dap R adapter and in-buffer image rendering for plots. + config.specs.r-lazy = lib.mkIf (config.cats.r or false) { + lazy = true; + data = with pkgs.vimPlugins; [ nvim-dap nvim-dap-ui nvim-dap-virtual-text - nvim-lint - vim-slime + image-nvim ]; }; diff --git a/plugin/01_lib.lua b/plugin/01_lib.lua index 64bd37e..988905c 100644 --- a/plugin/01_lib.lua +++ b/plugin/01_lib.lua @@ -1,3 +1,5 @@ +local Config = require('config') + -- Global Functions Config.new_scratch_buffer = function() vim.api.nvim_win_set_buf(0, vim.api.nvim_create_buf(true, true)) end @@ -32,7 +34,7 @@ end Config.execute_lua_line = function() local line = 'lua ' .. vim.api.nvim_get_current_line() vim.api.nvim_command(line) - print(line) + vim.notify(line, vim.log.levels.INFO) vim.api.nvim_input('') end diff --git a/plugin/02_startup.lua b/plugin/02_startup.lua index c4fa2a9..b4d1c7e 100644 --- a/plugin/02_startup.lua +++ b/plugin/02_startup.lua @@ -1,3 +1,5 @@ +local Config = require('config') + local M = {} -- Helper function to normalize input to a list diff --git a/plugin/03_terminal.lua b/plugin/03_terminal.lua index 95d25b1..95d7a29 100644 --- a/plugin/03_terminal.lua +++ b/plugin/03_terminal.lua @@ -1,3 +1,9 @@ +local Config = require('config') + +-- vim-slime target: use Neovim's built-in terminal. +-- Must be set before any slime send happens. +vim.g.slime_target = "neovim" + local M = {} -- Configuration @@ -31,11 +37,15 @@ function M.split_and_open_terminal() vim.cmd("resize " .. math.floor(vim.fn.winheight(0) * 0.9)) local term_buf = vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()) M.opt_term = term_buf - + -- Set buffer-local variables for vim-slime local job_id = vim.b[term_buf].terminal_job_id + if not job_id then + vim.notify("Terminal job id not available", vim.log.levels.WARN) + return term_buf + end vim.b[term_buf].slime_config = { jobid = job_id } - + return M.opt_term end @@ -44,26 +54,45 @@ function M.open_in_terminal(cmd) local command = cmd or "" local current_window = vim.api.nvim_get_current_win() local code_buf = vim.api.nvim_get_current_buf() - + + if not vim.api.nvim_buf_is_valid(code_buf) then + vim.notify("Code buffer is not valid", vim.log.levels.ERROR) + return + end + -- Open terminal and get buffer local term_buf = M.split_and_open_terminal() - + + if not term_buf or not vim.api.nvim_buf_is_valid(term_buf) then + vim.notify("Failed to open terminal buffer", vim.log.levels.ERROR) + return + end + -- Send command if provided + local job_id = vim.b[term_buf].terminal_job_id if command ~= "" then - -- We can use standard slime sending if needed, or direct chan_send for initialization - local job_id = vim.b[term_buf].terminal_job_id - if job_id then - vim.api.nvim_chan_send(job_id, command .. "\r") + if not job_id then + vim.notify("Terminal job not ready, cannot send command", vim.log.levels.WARN) + else + local ok, err = pcall(vim.api.nvim_chan_send, job_id, command .. "\r") + if not ok then + vim.notify("Failed to send command to terminal: " .. tostring(err), vim.log.levels.ERROR) + end end end - + -- Configure slime for the ORIGINAL code buffer to point to this new terminal -- This makes "Send to Terminal" work immediately - local slime_config = { jobid = vim.b[term_buf].terminal_job_id } - - -- Fix: Set the variable on the captured code buffer, not the current (terminal) buffer - vim.api.nvim_buf_set_var(code_buf, "slime_config", slime_config) - + if job_id then + local slime_config = { jobid = job_id } + + -- Fix: Set the variable on the captured code buffer, not the current (terminal) buffer + local ok, err = pcall(vim.api.nvim_buf_set_var, code_buf, "slime_config", slime_config) + if not ok then + vim.notify("Failed to set slime_config on code buffer: " .. tostring(err), vim.log.levels.ERROR) + end + end + -- Switch back to code buffer vim.api.nvim_set_current_win(current_window) end diff --git a/plugin/04_treesitter.lua b/plugin/04_treesitter.lua index 5a8d315..88d3cb7 100644 --- a/plugin/04_treesitter.lua +++ b/plugin/04_treesitter.lua @@ -1,3 +1,5 @@ +local Config = require('config') + local M = {} -- Default parsers list moved from startup config @@ -93,12 +95,12 @@ end function M.get_type() local cur_node = smart_send.get_current_node() if not cur_node then - print("Not a node") + vim.notify("No Tree-sitter node under cursor", vim.log.levels.WARN) return nil end local node_type = cur_node:type() - print("Node type: " .. node_type) + vim.notify("Node type: " .. node_type, vim.log.levels.INFO) return node_type end @@ -119,11 +121,17 @@ function M.setup_keybindings(global_nodes) vim.keymap.set('n', 'a', function() smart_send.send_repl(current_global_nodes) end, { noremap = true, silent = true, desc = "Send node to REPL", buffer = true }) - vim.keymap.set({ 'n', 'i' }, '', function() smart_send.send_repl(current_global_nodes) end, - { noremap = true, silent = true, desc = "Send node to REPL", buffer = true }) - - vim.keymap.set('n', '', function() smart_send.send_repl(current_global_nodes) end, - { noremap = true, silent = true, desc = "Send node to REPL", buffer = true }) + -- Both `` and `` were removed from `M.setup_keybindings`. They + -- were hard, implicit overrides that clobbered Vim/filetype defaults and + -- the user's snippet + insert-mode workflows (see C2 + H5 in the PR + -- review). To opt back in for a specific filetype, override per-buffer + -- from a `ftplugin/.lua`: + -- + -- -- e.g. ftplugin/r.lua or ftplugin/quarto.lua + -- vim.keymap.set('n', '', function() + -- require('config').treesitter_helpers.setup_keybindings(global_nodes) + -- require('nix_smart_send').send_repl(global_nodes) + -- end, { buffer = true, desc = 'Send node to REPL' }) vim.keymap.set('n', 'n', function() current_global_nodes = M.add_global_node(current_global_nodes) end, @@ -134,8 +142,8 @@ function M.setup_keybindings(global_nodes) { noremap = true, silent = true, desc = "Remove node under cursor from globals", buffer = true }) vim.keymap.set('n', 'o', function() - pout = table.concat(global_nodes, ', ') .. "" - print(pout) + local pout = table.concat(global_nodes, ', ') + vim.notify("global_nodes: " .. pout, vim.log.levels.INFO) end, { noremap = true, silent = true, desc = "Print globals", buffer = true }) vim.keymap.set('n', 'p', function() M.get_type() end, @@ -144,4 +152,46 @@ end Config.treesitter_helpers = M +-- Tree-sitter text objects: functions, calls, and assignments are especially +-- useful when editing R/tidyverse pipelines (e.g. `df |> mutate(...)`). +local ts_ok, treesitter = pcall(require, "nvim-treesitter.configs") +if ts_ok then + treesitter.setup({ + textobjects = { + select = { + enable = true, + lookahead = true, + keymaps = { + ["af"] = "@function.outer", + ["if"] = "@function.inner", + ["ac"] = "@call.outer", + ["ic"] = "@call.inner", + ["aa"] = "@assignment.outer", + ["ia"] = "@assignment.inner", + }, + selection_modes = { + ["@function.outer"] = "V", + ["@function.inner"] = "V", + ["@call.outer"] = "v", + ["@call.inner"] = "v", + ["@assignment.outer"] = "v", + ["@assignment.inner"] = "v", + }, + }, + move = { + enable = true, + set_jumps = true, + goto_next_start = { + ["]f"] = "@function.outer", + ["]c"] = "@call.outer", + }, + goto_previous_start = { + ["[f"] = "@function.outer", + ["[c"] = "@call.outer", + }, + }, + }, + }) +end + return M diff --git a/plugin/10_keymap.lua b/plugin/10_keymap.lua index 8ae0a1c..98074fb 100644 --- a/plugin/10_keymap.lua +++ b/plugin/10_keymap.lua @@ -1,323 +1,11 @@ --- Basic mappings ============================================================= --- NOTE: Most basic mappings come from 'mini.basics' --- Shorter version of the most frequent way of going outside of terminal window -vim.keymap.set('t', '', [[h]]) --- Select all --- vim.keymap.set({ "n", "v", "x" }, "", "gg3vG$", { noremap = true, silent = true, desc = "Select all" }) --- Escape deletes highlights -vim.keymap.set("n", "", "nohlsearch") --- Paste before/after linewise -local cmd = vim.fn.has('nvim-0.12') == 1 and 'iput' or 'put' -vim.keymap.set({ 'n', 'x' }, '[p', 'exe "' .. cmd .. '! " . v:register', { desc = 'Paste Above' }) -vim.keymap.set({ 'n', 'x' }, ']p', 'exe "' .. cmd .. ' " . v:register', { desc = 'Paste Below' }) +local Config = require('config') -vim.keymap.set({ "n", "v", "x" }, "p", '"+p', { noremap = true, silent = true, desc = "Paste from clipboard" }) -vim.keymap.set({ "n", "v", "x" }, "y", '"+y', { noremap = true, silent = true, desc = "Copy toclipboard" }) --- Leader mappings ============================================================ --- stylua: ignore start +-- Domain-specific keymap modules. Core must load first because it defines the +-- leader clue groups used by the mini.clue setup in the startup plugins. +require('keymap.core') +require('keymap.leader') +require('keymap.terminal') --- Create global tables with information about clue groups in certain modes --- Structure of tables is taken to be compatible with 'mini.clue'. -_G.Config.leader_group_clues = { - { mode = 'n', keys = 'a', desc = '+AI' }, - { mode = 'n', keys = 'b', desc = '+Buffer' }, - { mode = 'n', keys = 'e', desc = '+Explore' }, - { mode = 'n', keys = 'f', desc = '+Find' }, - { mode = 'n', keys = 'fl', desc = '+LSP' }, - { mode = 'n', keys = 'fa', desc = '+Git' }, - { mode = 'n', keys = 'g', desc = '+Git' }, - { mode = 'n', keys = 'l', desc = '+LSP' }, - { mode = 'n', keys = 'L', desc = '+Lua/Log' }, - { mode = 'n', keys = 'o', desc = '+Other' }, - { mode = 'n', keys = 'r', desc = '+R' }, - { mode = 'n', keys = 't', desc = '+Terminal' }, - { mode = 'n', keys = 'u', desc = '+UI' }, - { mode = 'n', keys = 'v', desc = '+Visits' }, - { mode = 'n', keys = 'w', desc = '+Windows' }, - { mode = 'x', keys = 'l', desc = '+LSP' }, - { mode = 'x', keys = 'r', desc = '+R' }, - { mode = 'n', keys = 'z', desc = '+ZK' }, - { mode = 'n', keys = 'zr', desc = '+Reviews' }, - { mode = 'x', keys = 'a', desc = '+AI' }, -} - --- Create `` mappings -local nmap_leader = function(suffix, rhs, desc, opts) - opts = opts or {} - opts.desc = desc - vim.keymap.set('n', '' .. suffix, rhs, opts) -end -local xmap_leader = function(suffix, rhs, desc, opts) - opts = opts or {} - opts.desc = desc - vim.keymap.set('x', '' .. suffix, rhs, opts) -end --- Other mappings -local nmap_lsp = function(keys, func, desc) - if desc then - desc = desc .. "(LSP)" - end - - vim.keymap.set("n", keys, func, { desc = desc }) -end - --- Switch buffers -nmap_leader('', 'bnext', 'Next buffer') -nmap_leader('', 'bprev', 'Prev buffer') - --- a is for 'AI' -nmap_leader("aa", "CodeCompanion /agent", "Agent chat (@{agent} tools)") -nmap_leader("ac", "CodeCompanionChat Toggle", "Chat Toggle") -nmap_leader("aC", function() - local chat = require("codecompanion").last_chat() - if not chat then - return vim.notify("No CodeCompanion chat to compact", vim.log.levels.WARN) - end - require("codecompanion.interactions.chat.context_management.compaction").compact(chat, { min_token_savings = 0 }) -end, "Compact chat") -nmap_leader("ag", "CodeCompanion /commit", "Generate commit message") -nmap_leader("ai", "CodeCompanionActions", "Chat Action") -nmap_leader("al", "CodeCompanion /lsp", "Explain LSP Diagnostics") -nmap_leader("an", "CodeCompanionChat Add", "Chat New") -nmap_leader("as", "CodeCompanion /suggest", "Suggest Improvements") -nmap_leader("aw", "CodeCompanion /tdd", "Workflow: plan, implement, test") -nmap_leader("ax", "CodeCompanion /fixer", "Code Fixer") -xmap_leader("aa", "CodeCompanion /agent", "Agent on selection") -xmap_leader("ae", "CodeCompanion /explain", "Explain Code") -xmap_leader("af", "CodeCompanion /fix", "Fix Code") -xmap_leader("ap", "CodeCompanion /expert", "Code Expert") -xmap_leader("as", "CodeCompanion /suggest", "Suggest Improvements") -nmap_leader("ak", "CodeCompanionChat adapter=codex", "Chat with Codex") - --- b is for 'buffer' -nmap_leader('bb', 'b#', 'Alternate') -nmap_leader('bd', 'lua MiniBufremove.delete()', 'Delete') -nmap_leader('bD', 'lua MiniBufremove.delete(0, true)', 'Delete!') -nmap_leader('bs', 'lua Config.new_scratch_buffer()', 'Scratch') -nmap_leader('bw', 'lua MiniBufremove.wipeout()', 'Wipeout') -nmap_leader('bW', 'lua MiniBufremove.wipeout(0, true)', 'Wipeout!') -nmap_leader('bq', 'qall', 'Quit all') - --- e is for 'explore' and 'edit' -nmap_leader('ed', 'lua MiniFiles.open()', 'Directory') -nmap_leader('ef', 'lua Config.try_opendir()', 'File directory') -nmap_leader('es', 'lua MiniSessions.select()', 'Sessions') -nmap_leader('eq', 'lua Config.toggle_quickfix()', 'Quickfix') -nmap_leader('ez', 'lua MiniFiles.open(os.getenv("ZK_NOTEBOOK_DIR"))', 'Notes directory') - --- f is for 'fuzzy find' -nmap_leader('f/', 'Pick history scope="/"', '"/" history') -nmap_leader('f:', 'Pick history scope=":"', '":" history') -nmap_leader('f,', 'Pick visit_labels', 'Visit labels') -nmap_leader('faa', 'Pick git_hunks scope="staged"', 'Added hunks (all)') -nmap_leader('faA', 'Pick git_hunks path="%" scope="staged"', 'Added hunks (current)') -nmap_leader('fb', 'Pick buffers', 'Buffers') -nmap_leader(',', 'Pick buffers', 'Buffers') -nmap_leader('fac', 'Pick git_commits', 'Commits (all)') -nmap_leader('faC', 'Pick git_commits path="%"', 'Commits (current)') -nmap_leader('fd', 'Pick diagnostic scope="all"', 'Diagnostic workspace') -nmap_leader('fD', 'Pick diagnostic scope="current"', 'Diagnostic buffer') -nmap_leader('ff', 'Pick files', 'Files') -nmap_leader('fg', 'Pick grep_live', 'Grep live') -nmap_leader('fG', 'Pick grep pattern=""', 'Grep current word') -nmap_leader('fh', 'Pick help', 'Help tags') -nmap_leader('fH', 'Pick hl_groups', 'Highlight groups') -nmap_leader('fj', 'Pick buf_lines scope="all"', 'Lines (all)') -nmap_leader('fJ', 'Pick buf_lines scope="current"', 'Lines (current)') -nmap_leader('fam', 'Pick git_hunks', 'Modified hunks (all)') -nmap_leader('faM', 'Pick git_hunks path="%"', 'Modified hunks (current)') -nmap_leader('fm', 'Pick marks', 'Marks') -nmap_leader('fn', 'ZkNotes', "Notes") -nmap_leader('fk', 'Pick keymaps', 'Keymaps') -nmap_leader('fR', 'Pick resume', 'Resume') -nmap_leader('fp', 'Pick files', 'Files') -nmap_leader('fq', 'Pick list scope="quickfix"', 'Quickfix') -nmap_leader('fr', 'Pick lsp scope="references"', 'References (LSP)') -nmap_leader('flr', 'Pick lsp scope="references"', 'References (LSP)') -nmap_leader('fS', 'Pick lsp scope="workspace_symbol"', 'Symbols workspace (LSP)') -nmap_leader('flS', 'Pick lsp scope="workspace_symbol"', 'Symbols workspace (LSP)') -nmap_leader('fs', 'Pick lsp scope="document_symbol"', 'Symbols buffer (LSP)') -nmap_leader('fls', 'Pick lsp scope="document_symbol"', 'Symbols buffer (LSP)') -nmap_leader('fld', 'Pick lsp scope="definition"', 'Definition (LSP)') -nmap_leader('flD', 'Pick lsp scope="declaration"', 'Declaration (LSP)') -nmap_leader('flt', 'Pick lsp scope="type_definition"', 'Type Definition (LSP)') -nmap_leader('fv', 'Pick visit_paths cwd=""', 'Visit paths (all)') -nmap_leader('fV', 'Pick visit_paths', 'Visit paths (cwd)') - --- g is for git -local git_log_cmd = [[Git log --pretty=format:\%h\ \%as\ │\ \%s --topo-order]] - -nmap_leader('gc', 'Git commit', 'Commit') -nmap_leader('gC', 'Git commit --amend', 'Commit amend') -nmap_leader('gd', 'Git diff', 'Diff') -nmap_leader('gD', 'Git diff -- %', 'Diff buffer') -nmap_leader("gg", "Neogit", "Open Neogit UI") -nmap_leader('gl', '' .. git_log_cmd .. '', 'Log') -nmap_leader('gL', '' .. git_log_cmd .. ' --follow -- %', 'Log buffer') -nmap_leader('go', 'lua MiniDiff.toggle_overlay()', 'Toggle overlay') -nmap_leader('gp', 'Git pull', 'Pull') -nmap_leader('gP', 'Git push', 'Push') -nmap_leader('gs', 'lua MiniGit.show_at_cursor()', 'Show at cursor') - -xmap_leader('gs', 'lua MiniGit.show_at_cursor()', 'Show at selection') - --- j/k navigate quickfix -nmap_leader("j", 'cnextzz', "Quickfix next") -nmap_leader("k", 'cprevzz', "Quickfix prev") - --- l is for 'LSP' (Language Server Protocol) -vim.keymap.set({ 'n' }, 'grd', 'lua vim.lsp.buf.definition()', { desc = 'Definition' }) -vim.keymap.set({ 'n' }, 'grk', 'lua vim.lsp.buf.hover()', { desc = 'Documentation' }) -vim.keymap.set({ 'n' }, 'gre', 'lua vim.diagnostic.open_float()', { desc = 'Diagnostics' }) - -nmap_lsp("K", 'lua vim.lsp.buf.hover()', "Documentation") -local formatting_cmd = 'lua require("conform").format({ lsp_format = "fallback" })' -nmap_leader('la', 'lua vim.lsp.buf.code_action()', 'Actions') -nmap_leader('le', 'lua vim.diagnostic.open_float()', 'Diagnostics popup') -nmap_leader('lf', formatting_cmd, 'Format') -nmap_leader('lk', 'lua vim.lsp.buf.hover()', 'Documentation') -nmap_leader('li', 'lua vim.lsp.buf.implementation()', 'Information') --- use ]d and [d ---nmap_leader('lj', 'lua vim.diagnostic.goto_next()', 'Next diagnostic') ---nmap_leader('lk', 'lua vim.diagnostic.goto_prev()', 'Prev diagnostic') -nmap_leader('lR', 'lua vim.lsp.buf.references()', 'References') -nmap_leader('lr', 'lua vim.lsp.buf.rename()', 'Rename') -nmap_leader('ls', 'lua vim.lsp.buf.definition()', 'Source definition') - -xmap_leader('lf', formatting_cmd, 'Format selection') - --- L is for 'Lua' -nmap_leader('Lc', 'lua Config.log_clear()', 'Clear log') -nmap_leader('LL', 'luafile %echo "Sourced lua"', 'Source buffer') -nmap_leader('Ls', 'lua Config.log_print()', 'Show log') -nmap_leader('Lx', 'lua Config.execute_lua_line()', 'Execute `lua` line') - --- m is free - --- o is for 'other' -local trailspace_toggle_command = 'lua vim.b.minitrailspace_disable = not vim.b.minitrailspace_disable' -nmap_leader('oh', 'normal gxiagxila', 'Move arg left') -nmap_leader('ol', 'normal gxiagxina', 'Move arg right') -nmap_leader('or', 'lua MiniMisc.resize_window()', 'Resize to default width') -nmap_leader('ot', 'lua MiniTrailspace.trim()', 'Trim trailspace') -nmap_leader('oT', trailspace_toggle_command, 'Trailspace hl toggle') -nmap_leader('oz', 'lua MiniMisc.zoom()', 'Zoom toggle') -nmap_leader('ow', - "lua MiniSessions.write(vim.fn.input('Session name: ', string.match(vim.fn.getcwd(), \"[^/]+$\") .. '-session.vim'))", - 'Write session') - --- r is for 'R' -nmap_leader('rc', 'RSend devtools::check()', 'Check') -nmap_leader('rC', 'RSend devtools::test_coverage()', 'Coverage') -nmap_leader('rd', 'RSend devtools::document()', 'Document') -nmap_leader('ri', 'RSend devtools::install(keep_source=TRUE)', 'Install') -nmap_leader('rk', 'RSend quarto::quarto_preview("%")', 'Knit file') -nmap_leader('rl', 'RSend devtools::load_all()', 'Load all') -nmap_leader('rL', 'RSend devtools::load_all(recompile=TRUE)', 'Load all recompile') -nmap_leader('rm', 'RSend Rcpp::compileAttributes()', 'Run examples') -nmap_leader('rT', 'RSend testthat::test_file("%")', 'Test file') -nmap_leader('rt', 'RSend devtools::test()', 'Test') - --- - Copy to clipboard and make reprex (which itself is loaded to clipboard) -xmap_leader('rx', '"+y :RSend reprex::reprex()', 'Reprex selection') - --- s is for 'send' (Send text to neoterm buffer) -nmap_leader('s', 'SlimeSendCurrentLinej', 'Send to terminal') - --- - In simple visual mode send text and move to the last character in --- selection and move to the right. Otherwise (like in line or block visual --- mode) send text and move one line down from bottom of selection. -xmap_leader('s', 'SlimeRegionSend', 'Send to terminal') - --- t is for 'terminal' -vim.keymap.set("t", "", [[]], { desc = "Exit terminal mode" }) -vim.keymap.set("n", "tc", 'lua Config.terminal.open_clickhouse_client()', - { desc = "Open Clickhouse client" }) -vim.keymap.set("n", "tl", 'lua Config.terminal.open_clickhouse_local()', - { desc = "Open Clickhouse local" }) -vim.keymap.set("n", "tp", 'lua Config.terminal.open_python()', { desc = "Open Python" }) -vim.keymap.set("n", "tj", 'lua Config.terminal.open_julia()', { desc = "Open Julia" }) -vim.keymap.set("n", "td", 'lua Config.terminal.open_duckdb();Config.terminal.toggle_bracket()', - { desc = "Open DuckDB" }) -vim.keymap.set("n", "tx", 'lua Config.terminal.open_in_terminal()', { desc = "Terminal Command" }) -vim.keymap.set("n", "tt", 'lua Config.terminal.open_shell()', { desc = "Terminal" }) -nmap_leader("tb", 'lua Config.terminal.toggle_bracket()', "Toggle bracketed paste") -nmap_leader("up", 'lua Config.terminal.toggle_bracket()', "Toggle bracketed paste") - --- u is for UI -nmap_leader('ut', 'TSContext toggle', 'Toggle TScontext') -nmap_leader('ua', 'Copilot toggle', 'Toggle AI completion') - --- v is for 'visits' -nmap_leader('vv', 'lua MiniVisits.add_label("core")', 'Add "core" label') -nmap_leader('vV', 'lua MiniVisits.remove_label("core")', 'Remove "core" label') -nmap_leader('vl', 'lua MiniVisits.add_label()', 'Add label') -nmap_leader('vL', 'lua MiniVisits.remove_label()', 'Remove label') - -local map_pick_core = function(keys, cwd, desc) - local rhs = function() - local sort_latest = MiniVisits.gen_sort.default({ recency_weight = 1 }) - MiniExtra.pickers.visit_paths({ - cwd = cwd, - filter = 'core', - sort = sort_latest - }, { source = { name = desc } }) - end - nmap_leader(keys, rhs, desc) -end -map_pick_core('vc', '', 'Core visits (all)') -map_pick_core('vC', nil, 'Core visits (cwd)') - --- w is for 'windows' -nmap_leader("wh", "h", "Go to Left Window", { remap = true }) -nmap_leader("wj", "j", "Go to Lower Window", { remap = true }) -nmap_leader("wk", "k", "Go to Upper Window", { remap = true }) -nmap_leader("wl", "l", "Go to Right Window", { remap = true }) - -nmap_leader("_", "s", "Split Window Below", { remap = true }) -nmap_leader("|", "v", "Split Window Right", { remap = true }) -nmap_leader("wd", "c", "Delete Window", { remap = true }) -nmap_leader("wo", "o", "Delete Other Windows", { remap = true }) - --- z is for 'ZettelKasten' -nmap_leader("zo", 'ZkNotes', "Notes") -nmap_leader("zt", 'ZkTags', "Tags") - -nmap_leader( - "zrd", - 'ZkNew { group = "dreviews" }', - "Daily Review" -) -nmap_leader( - "zrw", - 'ZkNew { group = "wreviews" }', - "Weekly Review" -) -nmap_leader( - "zn", - 'ZkNew { group = "inbox", title = vim.fn.input("Title: ") }', - "New" -) -nmap_leader( - "zp", - "ZkNew { group = 'permanent', title = vim.fn.input('Title: ') }", - "Permanent" -) - -nmap_leader( - "zl", - "ZkNew { group = 'literature', title = vim.fn.input('Title: '), extra.author = vim.fn.input('Author: '), extra.year = vim.fn.input('Year: ') }", - "Literature" -) - -nmap_leader( - "zd", - "ZkNew { group = 'dashboard', title = vim.fn.input('Title: ') }", - "Dashboard" -) -nmap_leader( - "zP", - "ZkNew { group = 'project', title = vim.fn.input('Title: ')}", - "Project" -) --- stylua: ignore end +-- `_G.Config` is set by `init.lua:2` once at start-up; no need to re-export +-- here. The single source of truth is `init.lua` so future refactors don't +-- have to chase which file currently publishes the alias. diff --git a/plugin/20_startup.lua b/plugin/20_startup.lua index e8d6693..36bab13 100644 --- a/plugin/20_startup.lua +++ b/plugin/20_startup.lua @@ -1,3 +1,4 @@ +local Config = require('config') local now = MiniDeps.now local later = MiniDeps.later local now_if_args = Config.now_if_args diff --git a/plugin/21_datascience.lua b/plugin/21_datascience.lua index cb496b9..807d8ed 100644 --- a/plugin/21_datascience.lua +++ b/plugin/21_datascience.lua @@ -22,7 +22,6 @@ end -- terminal later(function() - vim.g.slime_target = "neovim" vim.g.slime_no_mappings = true add("vim-slime") vim.g.slime_cell_delimiter = vim.g.slime_cell_delimiter or "# %%" diff --git a/plugin/22_languages.lua b/plugin/22_languages.lua index 70bec23..cd5ebed 100644 --- a/plugin/22_languages.lua +++ b/plugin/22_languages.lua @@ -1,3 +1,5 @@ +local Config = require('config') + local add = Config.add local now_if_args = Config.now_if_args local later = MiniDeps.later @@ -30,6 +32,68 @@ later(function() end end) +-- Linting (via nvim-lint) +later(function() + Config.add("nvim-lint") + local lint_ok, lint = pcall(require, "lint") + if not lint_ok then + return + end + + -- R code style via lintr (must be available in the R runtime). + -- lintr::lint() returns a "lints" object; format() turns it into the + -- standard "file:line:col: severity: message" lines. + lint.linters.lintr = { + cmd = "Rscript", + stdin = false, + args = { + "-e", + "args <- commandArgs(trailingOnly=TRUE); l <- lintr::lint(args[1]); if (length(l) > 0) cat(paste(format(l), collapse='\\n'), '\\n')", + }, + append_fname = true, + stream = "both", + ignore_exitcode = true, + parser = function(output, bufnr, linter_cwd) + local diagnostics = {} + -- Pattern: /path/file.R:10:5: style: Some message + for line in output:gmatch("[^\r\n]+") do + local path, lnum, col, severity, message = line:match("^[^:]+:(%d+):(%d+):%s*(%w+):%s*(.+)$") + if path then + local severity_map = { + style = vim.diagnostic.severity.INFO, + warning = vim.diagnostic.severity.WARN, + error = vim.diagnostic.severity.ERROR, + } + table.insert(diagnostics, { + bufnr = bufnr, + lnum = math.max(0, tonumber(lnum) - 1), + col = math.max(0, tonumber(col) - 1), + end_lnum = tonumber(lnum) - 1, + end_col = tonumber(col), + severity = severity_map[severity:lower()] or vim.diagnostic.severity.WARN, + message = message or "lintr issue", + source = "lintr", + }) + end + end + return diagnostics + end, + } + + lint.linters_by_ft = { + r = { "lintr" }, + rmd = { "lintr" }, + quarto = { "lintr" }, + } + + vim.api.nvim_create_autocmd({ "BufReadPost", "BufWritePost", "InsertLeave" }, { + group = vim.api.nvim_create_augroup("LintOnEvents", { clear = true }), + callback = function() + lint.try_lint() + end, + }) +end) + -- Markdown now_if_args(function() add("render-markdown.nvim") diff --git a/plugin/24_completion.lua b/plugin/24_completion.lua index f6516a1..c2291c7 100644 --- a/plugin/24_completion.lua +++ b/plugin/24_completion.lua @@ -1,3 +1,4 @@ +local Config = require('config') local add = Config.add local later = MiniDeps.later local now = MiniDeps.now diff --git a/plugin/25_lsp.lua b/plugin/25_lsp.lua index 8c40915..e249872 100644 --- a/plugin/25_lsp.lua +++ b/plugin/25_lsp.lua @@ -1,3 +1,4 @@ +local Config = require('config') local now_if_args = Config.now_if_args if not Config.isNixCats then @@ -16,6 +17,42 @@ now_if_args(function() marksman = { filetypes = { "markdown", "markdown_inline", "codecompanion" }, }, + nil_ls = { + settings = { + ["nil"] = { + formatting = { + command = { "alejandra" }, + }, + }, + }, + }, + nixd = { + settings = { + nixd = { + formatting = { + command = { "alejandra" }, + }, + options = { + -- Downstream flakes can override these via lib.mkMerge on the lsp config. + nixos = { expr = "(builtins.getFlake \"/etc/nixos\").nixosConfigurations.\"default\".options" }, + home_manager = { expr = "(builtins.getFlake \"/etc/nixos\").homeConfigurations.\"default\".options" }, + }, + }, + }, + }, + yamlls = { + settings = { + yaml = { + schemas = { + ["https://raw.githubusercontent.com/quarto-dev/quarto-cli/main/src/resources/schema/project.json"] = "**/_quarto.yml", + ["https://raw.githubusercontent.com/quarto-dev/quarto-cli/main/src/resources/schema/document-quarto.json"] = "**/*.qmd", + }, + }, + }, + }, + texlab = { + single_file_support = true, + }, julials = { settings = { julia = { diff --git a/plugin/26_dap.lua b/plugin/26_dap.lua new file mode 100644 index 0000000..73deeb8 --- /dev/null +++ b/plugin/26_dap.lua @@ -0,0 +1,79 @@ +local Config = require('config') + +local later = MiniDeps.later +local nix = require('config.nix') + +later(function() + -- Only pull in the DAP packages when R (the only cat that has a real + -- adapter for now) is enabled; otherwise the lazy load + autoload chain + -- still works but those three would sit unused on the runtime path. + if not nix.get_cat("r", false) then + return + end + + Config.add("nvim-dap") + Config.add("nvim-dap-ui") + Config.add("nvim-dap-virtual-text") +end) + +later(function() + if not nix.get_cat("r", false) then + return + end + + local dap_ok, dap = pcall(require, "dap") + if not dap_ok then + vim.notify("nvim-dap not available", vim.log.levels.WARN) + return + end + + local dapui_ok, dapui = pcall(require, "dapui") + if dapui_ok then + dapui.setup() + dap.listeners.after.event_initialized["dapui_config"] = function() + dapui.open() + end + dap.listeners.before.event_terminated["dapui_config"] = function() + dapui.close() + end + dap.listeners.before.event_exited["dapui_config"] = function() + dapui.close() + end + end + + local vt_ok, _ = pcall(require, "nvim-dap-virtual-text") + if vt_ok then + -- Default setup is enough; virtual text is enabled automatically. + end + + -- R adapter via vscDebugger (https://github.com/cwida/vscDebugger) + if nix.get_cat("r", false) then + dap.adapters.r = { + type = "executable", + command = "R", + args = { + "--quiet", + "--no-save", + "-e", + "vscDebugger::main()", + }, + } + + dap.configurations.r = { + { + type = "r", + name = "Debug current R script", + request = "launch", + program = "${file}", + debugMode = "function", + }, + { + type = "r", + name = "Attach to R process", + request = "attach", + hostName = "localhost", + port = 18721, + }, + } + end +end) diff --git a/plugin/27_image.lua b/plugin/27_image.lua new file mode 100644 index 0000000..89e0d2f --- /dev/null +++ b/plugin/27_image.lua @@ -0,0 +1,50 @@ +local Config = require('config') + +local later = MiniDeps.later +local nix = require('config.nix') + +-- Only load image-nvim when a cat that benefits from in-buffer plots is on. +later(function() + if not nix.get_cat({ "r", "markdown" }, false) then + return + end + + Config.add("image-nvim") +end) + +later(function() + if not nix.get_cat({ "r", "markdown" }, false) then + return + end + + local ok, image = pcall(require, "image") + if not ok then + vim.notify("image.nvim not available", vim.log.levels.DEBUG) + return + end + + image.setup({ + -- Backend is intentionally NOT set so image.nvim auto-detects the + -- graphics protocol (kitty / wezterm / iterm / sixel) at runtime and + -- falls back to no rendering on unsupported terminals. The literal + -- strings "auto" / "none" are not accepted by image.nvim.setup(), so + -- setting either would silently disable rendering everywhere. + integrations = { + markdown = { + enabled = true, + clear_in_insert_mode = false, + download_remote_images = true, + only_render_image_at_cursor = false, + filetypes = { "markdown", "quarto" }, + }, + }, + max_width = nil, + max_height = nil, + max_width_window_percentage = nil, + max_height_window_percentage = 50, + window_overlap_clear_enabled = false, + window_overlap_clear_ft_ignore = { "cmp_menu", "cmp_docs" }, + editor_only_render_when_focused = false, + hijack_file_patterns = { "*.png", "*.jpg", "*.jpeg", "*.gif", "*.webp" }, + }) +end) diff --git a/plugin/28_latex.lua b/plugin/28_latex.lua new file mode 100644 index 0000000..b77b201 --- /dev/null +++ b/plugin/28_latex.lua @@ -0,0 +1,43 @@ +-- vimtex integration for raw .tex workflows (paper drafts, AEA submissions, +-- beamer slides). Rides in the `markdown` cat because Quarto users routinely +-- also write standalone .tex. + +local Config = require('config') +local later = MiniDeps.later +local nix = require('config.nix') + +later(function() + if not nix.get_cat("markdown", false) then + return + end + + Config.add("vimtex") + + local ok, vimtex = pcall(require, "vimtex") + if not ok then + vim.notify("vimtex not available", vim.log.levels.WARN) + return + end + + -- Keep conservative defaults: latexmk continuous compilation off, single + -- viewer (zathura falls back to Evince on most setups). + vimtex.setup({ + enabled = true, + compile_on_save = false, + compiler = "latexmk", + -- Avoid hooking spell/formatting into our global group; + -- vimtex stashes its own mappings automatically. + }) + + -- Filetype detection is normally on, but force it explicitly so .tex files + -- opened outside Quarto still pick up the LSP + viewer hooks. + vim.api.nvim_create_autocmd("BufReadPost", { + pattern = { "*.tex", "*.sty", "*.cls" }, + callback = function(args) + local buf = args.buf + if vim.api.nvim_buf_is_loaded(buf) then + vim.bo[buf].filetype = "tex" + end + end, + }) +end)