diff --git a/README.md b/README.md index c34fd61..13b3def 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,6 @@ These are added under `lua/plugins/` and are **not** shipped with LazyVim by def | [vim-bundler](https://github.com/tpope/vim-bundler) | `tpope.lua` | Jump to gems from `Gemfile` (`gf` in Ruby buffers) | | [asyncrun.vim](https://github.com/skywind3000/asyncrun.vim) | `reprobado.lua` | Background jobs for `:Reprobado` / `:Reprobada` audio | | [vim-fetch](https://github.com/kopischke/vim-fetch) | `vim-fetch.lua` | Preserve file cursor position across reloads | -| [sonokai](https://github.com/sainnhe/sonokai) | `sonokai.lua` | Extra colorscheme (lazy-loaded; pick via Snacks or `:colorscheme`) | | [clipring.nvim](https://github.com/alexesba/clipring.nvim) | `clipring.lua` | Persistent yank/clipboard history ring | #### oil.nvim @@ -141,29 +140,24 @@ These are added under `lua/plugins/` and are **not** shipped with LazyVim by def #### reprobado (local audio commands) - `:Reprobado` / `:Reprobada` play Ogg files from `sounds/` via `ogg123` and [asyncrun.vim](https://github.com/skywind3000/asyncrun.vim). -- Logic lives in `lua/reprobado/init.lua`; lazy spec in `lua/plugins/reprobado.lua`. -- Depends on [asyncrun.vim](https://github.com/skywind3000/asyncrun.vim) and a local `vorbis-tools` meta-spec (same file) that installs `ogg123` when missing (Homebrew / apt / pacman / dnf / zypper). -- Keymaps (`no` / `na`) stay in `lua/config/keymaps.lua`. +- Logic lives in `lua/reprobado/init.lua`; lazy spec in `lua/plugins/reprobado.lua` (loads on command or `no` / `na`). +- Depends on [asyncrun.vim](https://github.com/skywind3000/asyncrun.vim); installs `ogg123` when missing (Homebrew / apt / pacman / dnf / zypper). #### clipring.nvim - `yh` opens the yank history picker. - Ring is persisted across sessions (`persist = true`, up to 100 entries). -#### sonokai - -- Not loaded until selected; install list includes it in `lua/config/lazy.lua` for `:colorscheme sonokai` or the Snacks colorscheme picker. - ### LazyVim overrides (tweaks, not new plugins) | File | What it changes | | --- | --- | | `snaks.lua` | Disables Snacks file explorer (Oil is used instead); custom Snacks picker keys (`fg`, `fk`) | | `noice.lua` | Rounded LSP hover borders only; cmdline/messages/popupmenu stay on Vim defaults | +| `conform.lua` | Custom SQL/JSON/XML formatters; Prettier for CSS/SCSS; `cf` uses defaults per filetype | | `lsp.lua` | Disables diagnostic virtual text (float on `CursorHold` in `autocmds.lua`) | | `autosave-colorscheme.lua` | Saves/restores last colorscheme via [autosave-colorscheme.nvim](https://github.com/alexesba/autosave-colorscheme.nvim) | | `disabled.lua` | Turns off `bufferline.nvim`, `neo-tree.nvim`, `mini.files` | -| `nvin-web-devicons.lua` | Ensures devicons are lazy-loaded (dependency for Oil, luatab) | ### Disabled LazyVim plugins @@ -207,7 +201,7 @@ Press `` and wait for which-key for the full list. ### Colorschemes 1. `uC` or `:ColorScheme` — pick a theme in Snacks (live preview, works on empty buffers). -2. Or `:colorscheme ` (e.g. `sonokai`, `tokyonight`). +2. Or `:colorscheme ` (e.g. `tokyonight`). 3. Restart Neovim — your last theme is restored automatically. To reset persistence: @@ -264,7 +258,7 @@ Restart Neovim after structural config changes (lazy.nvim does not fully reload ### Custom commands (`:command`) -Formatting and cleanup: `FormatJSON`, `FormatJSONV2`, `FormatCss`, `FormatSQL`, `FormatSQLV2`, `FormatXML`, `RemoveEmptyLines`, `RemoveExtraEmptyLines`, `CleanWhiteSpaces`, `ConvertTabToSpaces`, `AddLineNumbers`, `RemoveLineBreak` +Formatting and cleanup: `FormatJSON`, `FormatCss`, `FormatSQL`, `FormatSQLFormatter` (`FormatSQLV2` alias), `FormatXML`, `RemoveEmptyLines`, `RemoveExtraEmptyLines`, `CleanWhiteSpaces`, `ConvertTabToSpaces`, `AddLineNumbers`, `RemoveLineBreak` — SQL/JSON/XML/CSS use [conform.nvim](https://github.com/stevearc/conform.nvim) (same engines as `cf` for those filetypes) Quotes / Ruby: `DoubleQuotes`, `SingleQuotes`, `DoubleQuotesC`, `SingleQuotesC`, `HashNewSyntax`, `HashOldSyntax`, `UpdateRubyHashesByLines`, `UnscapeDoubleQuotes` diff --git a/lazyvim.json b/lazyvim.json index 1ca8e66..13b143a 100644 --- a/lazyvim.json +++ b/lazyvim.json @@ -2,6 +2,7 @@ "extras": [ "lazyvim.plugins.extras.ai.copilot", "lazyvim.plugins.extras.ai.copilot-chat", + "lazyvim.plugins.extras.ai.sidekick", "lazyvim.plugins.extras.coding.mini-surround" ], "install_version": 8, diff --git a/lua/config/autocmds.lua b/lua/config/autocmds.lua index 8d30ca9..1613884 100644 --- a/lua/config/autocmds.lua +++ b/lua/config/autocmds.lua @@ -8,31 +8,30 @@ -- e.g. vim.api.nvim_del_augroup_by_name("lazyvim_wrap_spell") require("utils.functions") -vim.api.nvim_create_user_command("FormatJSON", function() - if require("utils.require_tool").ensure("python3") then - vim.cmd("%!python3 -m json.tool") - end -end, { bang = true, desc = "Format json files with python" }) - -vim.api.nvim_create_user_command("FormatJSONV2", function() - if require("utils.require_tool").ensure("underscore") then - vim.cmd("%!underscore print --outfmt json") - end -end, { bang = true, desc = "Format json files using underline-cli" }) +vim.api.nvim_create_user_command("FormatJSON", FormatJSON, { desc = "Format json files with python" }) -vim.api.nvim_create_user_command("FormatCss", FormatCss, { desc = "Format css using regex" }) +vim.api.nvim_create_user_command("FormatCss", FormatCss, { desc = "Format CSS/SCSS with Prettier" }) -vim.api.nvim_create_user_command("RemoveExtraEmptyLines", RemoveExtraEmptyLines, { desc = "Remove extra empty lines" }) +vim.api.nvim_create_user_command("RemoveExtraEmptyLines", RemoveExtraEmptyLines, { + desc = "Collapse multiple blank lines to one between functions/blocks", +}) vim.api.nvim_create_user_command("AddLineNumbers", AddLineNumbers, { desc = "Add number for the entire file" }) vim.api.nvim_create_user_command("ConvertTabToSpaces", ConvertTabToSpaces, { desc = "Convert Tab to spaces" }) -vim.api.nvim_create_user_command("RemoveEmptyLines", RemoveEmptyLines, { desc = "Remove Empty Lines" }) +vim.api.nvim_create_user_command("RemoveEmptyLines", RemoveEmptyLines, { + desc = "Remove all blank lines (including whitespace-only)", +}) -vim.api.nvim_create_user_command("FormatSQL", FormatSQL, { desc = "Format SQL files using sqlformat" }) +vim.api.nvim_create_user_command("FormatSQL", FormatSQL, { desc = "Format SQL with sqlformat (sqlparse)" }) -vim.api.nvim_create_user_command("FormatSQLV2", FormatSQLV2, { desc = "Format SQL using sql-formatter-cli" }) +vim.api.nvim_create_user_command("FormatSQLFormatter", FormatSQLFormatter, { + desc = "Format SQL with sql-formatter-cli (npm)", +}) +vim.api.nvim_create_user_command("FormatSQLV2", FormatSQLFormatter, { + desc = "Alias for FormatSQLFormatter (sql-formatter-cli)", +}) local function copy_to_clipboard(path, title) if path == "" then @@ -81,13 +80,16 @@ vim.api.nvim_create_user_command( { desc = "Replace double quotes with single quotes with confirmation" } ) -vim.api.nvim_create_user_command("UpdateRubyHashesByLines", FormatHashes, { desc = "Update ruby hashes by lines" }) +vim.api.nvim_create_user_command("UpdateRubyHashesByLines", FormatHashes, { + desc = "Normalize Ruby hashes: rocket→new syntax, split }, {, single quotes, re-indent", +}) +vim.api.nvim_create_user_command("FormatHashes", FormatHashes, { + desc = "Alias for UpdateRubyHashesByLines", +}) vim.api.nvim_create_user_command("HashNewSyntax", HashNewSyntax, { desc = "update hash old to new syntax" }) vim.api.nvim_create_user_command("HashOldSyntax", HashOldSyntax, { desc = "update hash new to old syntax" }) -vim.api.nvim_create_user_command("HashNewSyntax", HashOldSyntax, { desc = "update hash syntax" }) - vim.api.nvim_create_user_command("CleanWhiteSpaces", CleanWhiteSpaces, { desc = "Clean White spaces" }) vim.api.nvim_create_user_command("UnscapeDoubleQuotes", UnscapeDoubleQuotes, { desc = "unscape double quotes" }) diff --git a/lua/config/keymaps.lua b/lua/config/keymaps.lua index 81db4b0..08feb06 100644 --- a/lua/config/keymaps.lua +++ b/lua/config/keymaps.lua @@ -16,15 +16,6 @@ map("n", "8", ":tabn 8", { noremap = true, silent = true }) map("n", "9", ":tabn 9", { noremap = true, silent = true }) map("n", "0", ":tabn 0", { noremap = true, silent = true }) -map("n", "no", [[lua require("reprobado").play("Reprobado.ogg")]], { - silent = true, - desc = "Play Reprobado", -}) -map("n", "na", [[lua require("reprobado").play("Reprobada.ogg")]], { - silent = true, - desc = "Play Reprobada", -}) - -- Remove newbie crutches in Normal Mode map("n", "", "") map("n", "", "") diff --git a/lua/config/lazy.lua b/lua/config/lazy.lua index f1c7fa5..87d3d33 100644 --- a/lua/config/lazy.lua +++ b/lua/config/lazy.lua @@ -30,7 +30,7 @@ require("lazy").setup({ version = false, -- always use the latest git commit -- version = "*", -- try installing the latest stable version for plugins that support semver }, - install = { colorscheme = { "tokyonight", "habamax", "sonokai" } }, + install = { colorscheme = { "tokyonight" } }, checker = { enabled = true, -- check for plugin updates periodically notify = false, -- notify on update diff --git a/lua/config/options.lua b/lua/config/options.lua index b68c87c..a402e4a 100644 --- a/lua/config/options.lua +++ b/lua/config/options.lua @@ -3,22 +3,9 @@ -- Personal overrides: lua/config/options.local.lua (gitignored; see options.local.lua.example) local load_local = require("utils.local").load local set = vim.opt + vim.o.winborder = "rounded" -set.number = true -set.relativenumber = true -set.cp = false -set.hidden = true -set.expandtab = true -set.backup = false -set.wrap = false -set.writebackup = false -set.autoindent = true -set.hlsearch = true -set.incsearch = true -set.showmatch = true -set.backspace = { "indent", "eol", "start" } -set.laststatus = 2 -set.ruler = true +set.colorcolumn = "81" set.listchars = { eol = "$", extends = ">", @@ -26,14 +13,11 @@ set.listchars = { tab = ">-", trail = "~", } -set.swapfile = false set.synmaxcol = 150 -set.cursorline = true -set.signcolumn = "yes" -set.termguicolors = true -set.colorcolumn = "81" -set.updatetime = 100 set.diffopt:append({ "vertical" }) +set.list = false +set.updatetime = 500 -- ms before CursorHold fires (affects diagnostic float speed) + vim.g.mapleader = "," vim.g.maplocalleader = "\\" @@ -43,29 +27,4 @@ vim.g.table_mode_corner = "+" vim.g.enable_italic_font = 1 vim.inccommand = "nosplit" -set.wildignore:append({ - "blue.vim", - "darkblue.vim", - "default.vim", - "delek.vim", - "desert.vim", - "elflord.vim", - "evening.vim", - "industry.vim", - "koehler.vim", - "morning.vim", - "murphy.vim", - "pablo.vim", - "peachpuff.vim", - "ron.vim", - "shine.vim", - "slate.vim", - "torte.vim", - "zellner.vim", -}) - -set.list = false -set.updatetime = 500 -- ms before CursorHold fires (affects diagnostic float speed) --- set.cmdheight=0 - load_local("options") diff --git a/lua/plugins/blink.lua b/lua/plugins/blink.lua new file mode 100644 index 0000000..1e49d6f --- /dev/null +++ b/lua/plugins/blink.lua @@ -0,0 +1,12 @@ +return { + { + "saghen/blink.cmp", + opts = { + keymap = { + -- Keep LazyVim preset ("enter"): /, /, , etc. + [""] = { "select_next", "fallback" }, + [""] = { "select_prev", "fallback" }, + }, + }, + }, +} diff --git a/lua/plugins/conform.lua b/lua/plugins/conform.lua new file mode 100644 index 0000000..791006a --- /dev/null +++ b/lua/plugins/conform.lua @@ -0,0 +1,36 @@ +return { + { + "stevearc/conform.nvim", + opts = { + formatters_by_ft = { + sql = { "sqlformat", stop_after_first = true }, + json = { "json_tool" }, + xml = { "xml_minidom" }, + css = { "prettier", stop_after_first = true }, + scss = { "prettier", stop_after_first = true }, + less = { "prettier" }, + sass = { "prettier" }, + }, + formatters = { + sqlformat = { + command = "sqlformat", + args = { "--reindent", "--keywords", "upper", "--identifiers", "lower", "-" }, + }, + sql_formatter_cli = { + command = "sql-formatter-cli", + }, + json_tool = { + command = "python3", + args = { "-m", "json.tool" }, + }, + xml_minidom = { + command = "python3", + args = { + "-c", + "import xml.dom.minidom, sys; print(xml.dom.minidom.parse(sys.stdin).toprettyxml())", + }, + }, + }, + }, + }, +} diff --git a/lua/plugins/nvin-web-devicons.lua b/lua/plugins/nvin-web-devicons.lua deleted file mode 100644 index 267c9f1..0000000 --- a/lua/plugins/nvin-web-devicons.lua +++ /dev/null @@ -1,4 +0,0 @@ -return { - "kyazdani42/nvim-web-devicons", - lazy = true, -} diff --git a/lua/plugins/oil.lua b/lua/plugins/oil.lua index bcf0677..18c963e 100644 --- a/lua/plugins/oil.lua +++ b/lua/plugins/oil.lua @@ -1,12 +1,11 @@ return { "stevearc/oil.nvim", - lazy = false, + lazy = true, opts = { default_file_explorer = true, }, - -- Optional dependencies dependencies = { "nvim-web-devicons" }, - cmd = { "Exp", "Explore", "Explorer" }, + cmd = { "Exp", "Explore", "Explorer", "Oil" }, keys = { { "-", diff --git a/lua/plugins/reprobado.lua b/lua/plugins/reprobado.lua index a7fa6e9..743e669 100644 --- a/lua/plugins/reprobado.lua +++ b/lua/plugins/reprobado.lua @@ -1,22 +1,31 @@ --- Local reprobado commands (:Reprobado / :Reprobada). Keymaps live in lua/config/keymaps.lua. +-- Local reprobado commands (:Reprobado / :Reprobada). return { - { - dir = vim.fn.stdpath("config"), - name = "vorbis-tools", - lazy = false, - config = function() - require("reprobado").ensure_player() - end, - }, { dir = vim.fn.stdpath("config"), name = "reprobado", - lazy = false, + lazy = true, + cmd = { "Reprobado", "Reprobada" }, + keys = { + { + "no", + function() + require("reprobado").play("Reprobado.ogg") + end, + desc = "Play Reprobado", + }, + { + "na", + function() + require("reprobado").play("Reprobada.ogg") + end, + desc = "Play Reprobada", + }, + }, dependencies = { "skywind3000/asyncrun.vim", - "vorbis-tools", }, config = function() + require("reprobado").ensure_player() require("reprobado").setup() require("reprobado").setup_commands() end, diff --git a/lua/plugins/sonokai.lua b/lua/plugins/sonokai.lua deleted file mode 100644 index 4d41ac7..0000000 --- a/lua/plugins/sonokai.lua +++ /dev/null @@ -1,6 +0,0 @@ -return { - { - "sainnhe/sonokai", - lazy = true, - }, -} diff --git a/lua/utils/cmdPreservePosition.lua b/lua/utils/cmdPreservePosition.lua index 6a28694..3610288 100644 --- a/lua/utils/cmdPreservePosition.lua +++ b/lua/utils/cmdPreservePosition.lua @@ -1,7 +1,24 @@ -local function cmdPreserveCursorPosition(cmd) - local save_cursor = vim.fn.getpos(".") - vim.cmd(cmd) - vim.fn.setpos(".", save_cursor) +local M = {} + +--- Run a Vim command or Lua function, then restore the cursor position. +---@param action string|function +---@param ... any Arguments passed when action is a function +---@return ... Return values from action when it is a function +function M.with_cursor(action, ...) + local pos = vim.fn.getpos(".") + if type(action) == "function" then + local ret = { action(...) } + vim.fn.setpos(".", pos) + return unpack(ret, 1, ret.n) + end + vim.cmd(action) + vim.fn.setpos(".", pos) end -return cmdPreserveCursorPosition +setmetatable(M, { + __call = function(_, action, ...) + return M.with_cursor(action, ...) + end, +}) + +return M diff --git a/lua/utils/format.lua b/lua/utils/format.lua new file mode 100644 index 0000000..ff4eb1c --- /dev/null +++ b/lua/utils/format.lua @@ -0,0 +1,98 @@ +local M = {} + +local TOOL_BY_FORMATTER = { + sqlformat = "sqlformat", + sql_formatter_cli = "sql-formatter-cli", + json_tool = "python3", + xml_minidom = "python3", + prettier = "prettier", +} + +--- Ensure the current buffer can receive formatter edits. +---@param label string +---@return boolean, table|nil restore options to apply after formatting +local function prepare_buffer(label) + local bufnr = vim.api.nvim_get_current_buf() + local buftype = vim.bo[bufnr].buftype + local readonly = vim.bo[bufnr].readonly + local modifiable = vim.bo[bufnr].modifiable + + if buftype ~= "" then + vim.notify( + ("%s: buffer is not a normal file (buftype=%s)"):format(label, buftype), + vim.log.levels.ERROR + ) + return false + end + + if readonly then + vim.notify(("%s: buffer is readonly"):format(label), vim.log.levels.ERROR) + return false + end + + if modifiable then + return true, nil + end + + -- Some plugins leave 'nomodifiable' on an otherwise editable file buffer. + -- Conform applies edits via nvim_buf_set_text, which requires modifiable. + vim.bo[bufnr].modifiable = true + return true, { bufnr = bufnr, modifiable = false } +end + +--- Run conform formatters by name (used by :FormatSQL, :FormatJSON, etc.). +---@param formatters string[] +---@param label? string +---@return boolean +function M.run(formatters, label) + label = label or "Format" + local require_tool = require("utils.require_tool") + + for _, name in ipairs(formatters) do + local tool = TOOL_BY_FORMATTER[name] + if tool and not require_tool.ensure(tool) then + return false + end + end + + local ok, conform = pcall(require, "conform") + if not ok then + vim.notify("conform.nvim is not available", vim.log.levels.ERROR) + return false + end + + local prepared, restore = prepare_buffer(label) + if not prepared then + return false + end + + local format_err + local ran, err = pcall(function() + conform.format({ + async = false, + timeout_ms = 5000, + lsp_format = "never", + formatters = formatters, + }, function(cb_err) + format_err = cb_err + end) + end) + + if restore then + vim.bo[restore.bufnr].modifiable = restore.modifiable + end + + if not ran then + vim.notify(("%s: %s"):format(label, err), vim.log.levels.ERROR) + return false + end + + if format_err then + vim.notify(("%s: %s"):format(label, format_err), vim.log.levels.ERROR) + return false + end + + return true +end + +return M diff --git a/lua/utils/functions.lua b/lua/utils/functions.lua index a517ca4..b73ecc2 100644 --- a/lua/utils/functions.lua +++ b/lua/utils/functions.lua @@ -1,5 +1,6 @@ -local cmdPreserveCursorPosition = require("utils.cmdPreservePosition") +local preserve_cursor = require("utils.cmdPreservePosition") local require_tool = require("utils.require_tool") +local format = require("utils.format") local function with_tool(tool, fn) if not require_tool.ensure(tool) then @@ -9,88 +10,122 @@ local function with_tool(tool, fn) end function FormatCss() - cmdPreserveCursorPosition([[silent! :%s/[{;}]/&\r/g|norm! =gg]]) + format.run({ "prettier" }, "Format CSS") end function FormatXML() with_tool("python3", function() - local save_cursor = vim.fn.getpos(".") - vim.cmd([[ + preserve_cursor([[ silent! %s/\\"/"/g | - silent! %s/\\n//g | - silent! %!python3 -c "import xml.dom.minidom, sys; print(xml.dom.minidom.parse(sys.stdin).toprettyxml())" + silent! %s/\\n//g ]]) - vim.fn.setpos(".", save_cursor) + format.run({ "xml_minidom" }, "Format XML") end) end +--- Collapse consecutive blank lines (including whitespace-only) to one empty line. function RemoveExtraEmptyLines() - with_tool("cat", function() - cmdPreserveCursorPosition([[%!cat -s]]) + preserve_cursor(function() + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + local result = {} + local in_blank_run = false + + for _, line in ipairs(lines) do + if line:match("^%s*$") then + if not in_blank_run then + result[#result + 1] = "" + in_blank_run = true + end + else + in_blank_run = false + result[#result + 1] = line + end + end + + vim.api.nvim_buf_set_lines(0, 0, -1, true, result) end) end function ConvertTabToSpaces() - cmdPreserveCursorPosition([[%s/\t/ /g]]) + preserve_cursor([[%s/\t/ /g]]) end +--- Remove all blank lines (including whitespace-only). function RemoveEmptyLines() - cmdPreserveCursorPosition([[g/^$/d]]) + preserve_cursor(function() + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + local result = {} + + for _, line in ipairs(lines) do + if not line:match("^%s*$") then + result[#result + 1] = line + end + end + + vim.api.nvim_buf_set_lines(0, 0, -1, true, result) + end) end function FormatSQL() - with_tool("sqlformat", function() - cmdPreserveCursorPosition([[ - '%!sqlformat --reindent --keywords upper --identifiers lower -' - ]]) - end) + format.run({ "sqlformat" }, "Format SQL") end -function FormatSQLV2() - with_tool("sql-formatter-cli", function() - cmdPreserveCursorPosition([[%!sql-formatter-cli]]) - end) +function FormatJSON() + format.run({ "json_tool" }, "Format JSON") end -function DoubleQuotes() - cmdPreserveCursorPosition([[%s/'\([^']*\)'/"\1"/g]]) +function FormatSQLFormatter() + format.run({ "sql_formatter_cli" }, "Format SQL") end -function SingleQuotes() - cmdPreserveCursorPosition([[%s/"\([^"]*\)"/'\1'/g]]) +function DoubleQuotes() + preserve_cursor([[%s/'\([^']*\)'/"\1"/g]]) end +function SingleQuotes() + preserve_cursor([[%s/"\([^"]*\)"/'\1'/g]]) +end + +--- Normalize multi-line Ruby hashes in the current buffer. +--- +--- Steps (order preserved; no sorting or column alignment): +--- 1. `:key =>` → `key: ` (old rocket syntax to new, with space after `:`) +--- 2. `}, {` → `},\n {` (split two inline hashes onto separate lines) +--- 3. `"…"` → `'…'` (double quotes to single quotes) +--- 4. `ggVG=` — re-indent the buffer (Ruby indent when `filetype=ruby`) +--- +--- Commands: `:UpdateRubyHashesByLines`, `:FormatHashes` (alias). function FormatHashes() - local save_cursor = vim.fn.getpos(".") - vim.cmd([[ - silent! %s/:\([^ ]*\)\(\s*\)=>/\1: /g | - silent! %s/}, {/},\r {/g | - silent! %s/"\([^"]*\)"/'\1'/g - ]]) - vim.cmd.normal(vim.api.nvim_replace_termcodes("gg=", true, true, true)) - vim.fn.setpos(".", save_cursor) + preserve_cursor(function() + vim.cmd([[ + silent! %s/:\([^ ]*\)\(\s*\)=>/\1: /g | + silent! %s/}, {/},\r {/g | + silent! %s/"\([^"]*\)"/'\1'/g + ]]) + vim.cmd.normal(vim.api.nvim_replace_termcodes("gg=", true, true, true)) + end) end function HashNewSyntax() - cmdPreserveCursorPosition([[:%s/:\([^ ]*\)\(\s*\)=>/\1:/g]]) + preserve_cursor([[:%s/:\([^ ]*\)\(\s*\)=>/\1:/g]]) end function HashOldSyntax() - cmdPreserveCursorPosition([[:%s/\(\w*\): \([':]\)/:\1 => \2/g]]) + preserve_cursor([[:%s/\(\w*\): \([':]\)/:\1 => \2/g]]) end function UnscapeDoubleQuotes() - cmdPreserveCursorPosition([[%s/\\"//g]]) + preserve_cursor([[%s/\\"//g]]) end function RemoveLineBreak() - cmdPreserveCursorPosition([[%s/\\n//g]]) + preserve_cursor([[%s/\\n//g]]) end function CleanWhiteSpaces() - cmdPreserveCursorPosition([[%s/\s\+$//e]]) + preserve_cursor([[%s/\s\+$//e]]) end function AddLineNumbers() - cmdPreserveCursorPosition([[%s/^/\=printf('%-2d', line('.'))]]) + preserve_cursor([[%s/^/\=printf('%-2d', line('.'))]]) end diff --git a/lua/utils/require_tool.lua b/lua/utils/require_tool.lua index 69b963c..4a293ce 100644 --- a/lua/utils/require_tool.lua +++ b/lua/utils/require_tool.lua @@ -42,16 +42,16 @@ local tools = { fallback = "npm install -g sql-formatter-cli", }, }, - underscore = { - bin = "underscore", - label = "underscore-cli", + prettier = { + bin = { "prettier", "prettierd" }, + label = "Prettier", install = { - Darwin = "npm install -g underscore-cli", - Linux_apt = "npm install -g underscore-cli", - Linux_pacman = "npm install -g underscore-cli", - Linux_dnf = "npm install -g underscore-cli", - Linux_zypper = "npm install -g underscore-cli", - fallback = "npm install -g underscore-cli", + Darwin = "npm install -g prettier", + Linux_apt = "npm install -g prettier", + Linux_pacman = "npm install -g prettier", + Linux_dnf = "npm install -g prettier", + Linux_zypper = "npm install -g prettier", + fallback = "npm install -g prettier", }, }, cat = { diff --git a/spec/README.md b/spec/README.md index 8a06a64..ce917b3 100644 --- a/spec/README.md +++ b/spec/README.md @@ -14,6 +14,7 @@ Current coverage: - `lua/utils/cmdPreservePosition.lua` — cursor preserved after commands - `lua/utils/map.lua` — default `noremap` and merged caller options - `lua/utils/require_tool.lua` — missing-tool detection with OS-specific install hints +- `lua/utils/format.lua` — conform formatter dispatch for SQL/JSON/XML commands - `lua/utils/functions.lua` — in-buffer transforms and external-tool command wiring Format helpers that shell out to OS tools are covered by contract tests (mocked `vim.cmd` / `cmdPreservePosition`), not by running the binaries in CI: @@ -21,12 +22,12 @@ Format helpers that shell out to OS tools are covered by contract tests (mocked | Command / function | External tool | |--------------------|---------------| | `FormatXML` | `python3` (`xml.dom.minidom`) | -| `FormatSQL` | `sqlformat` | -| `FormatSQLV2` | `sql-formatter-cli` | -| `RemoveExtraEmptyLines` | `cat` | -| `FormatJSON` / `FormatJSONV2` (autocmds) | `python3`, `underscore` | +| `FormatSQL` / `FormatSQLFormatter` | `sqlformat`, `sql-formatter-cli` (via conform) | +| `FormatCss` | `prettier` (via conform) | +| `FormatXML` | `python3` (via conform) | +| `RemoveExtraEmptyLines` | (built-in Vim substitute) | -Install these locally when you use the commands. If a tool is missing, Neovim notifies you with the install command to run (e.g. `pip3 install sqlparse`, `npm install -g sql-formatter-cli`). CI verifies both the install hints and the expected filter wiring. +Install these locally when you use the commands. If a tool is missing, Neovim notifies you with the install command to run (e.g. `pip3 install sqlparse`). CI verifies both the install hints and the expected filter wiring. - `lua/utils/local.lua` — overlay load, missing file, and error notification - `lua/config/autocmds.lua` — command registration, `CopyFullPath`, `CopyRelativePath` diff --git a/tests/autocmds_spec.lua b/tests/autocmds_spec.lua index 5dc52c5..b045535 100644 --- a/tests/autocmds_spec.lua +++ b/tests/autocmds_spec.lua @@ -34,10 +34,8 @@ describe("config.autocmds", function() assert.is_not_nil(cmds.CopyRelativePath) assert.is_not_nil(cmds.FormatCss) assert.is_not_nil(cmds.FormatJSON) - assert.is_not_nil(cmds.FormatJSONV2) assert.equals("Copy file full path", helpers.command_desc(cmds.CopyFullPath)) assert.equals("Format json files with python", helpers.command_desc(cmds.FormatJSON)) - assert.equals("Format json files using underline-cli", helpers.command_desc(cmds.FormatJSONV2)) end) it("FormatJSON warns when python3 is missing", function() diff --git a/tests/format_spec.lua b/tests/format_spec.lua new file mode 100644 index 0000000..1891e5b --- /dev/null +++ b/tests/format_spec.lua @@ -0,0 +1,128 @@ +local helpers = require("tests.helpers") + +describe("utils.format", function() + local format + + before_each(function() + package.loaded["conform"] = nil + helpers.reload_module("utils.require_tool") + helpers.reload_module("utils.format") + format = require("utils.format") + vim.bo.buftype = "" + vim.bo.modifiable = true + vim.bo.readonly = false + end) + + it("warns when a required tool is missing", function() + local messages = helpers.capture_notify(function() + helpers.with_mocked_fn({ + executable = function() + return 0 + end, + }, function() + format.run({ "sqlformat" }, "Format SQL") + end) + end) + + assert.matches("sqlformat", messages[1]) + assert.matches("install with:", messages[1]) + end) + + it("calls conform.format with the requested formatters", function() + local called + package.loaded["conform"] = { + format = function(opts, callback) + called = opts + if callback then + callback(nil, true) + end + return true + end, + } + + helpers.with_mocked_fn({ + executable = function() + return 1 + end, + }, function() + assert.is_true(format.run({ "sql_formatter_cli" }, "Format SQL")) + end) + + assert.same({ "sql_formatter_cli" }, called.formatters) + assert.is_false(called.async) + assert.equals("never", called.lsp_format) + end) + + it("notifies when conform reports an error", function() + package.loaded["conform"] = { + format = function(_, callback) + if callback then + callback("formatter failed", false) + end + return true + end, + } + + local messages = helpers.capture_notify(function() + helpers.with_mocked_fn({ + executable = function() + return 1 + end, + }, function() + assert.is_false(format.run({ "json_tool" }, "Format JSON")) + end) + end) + + assert.matches("Format JSON: formatter failed", messages[1]) + end) + + it("refuses to format special buftype buffers", function() + vim.bo.buftype = "nofile" + + package.loaded["conform"] = { + format = function() + error("should not format") + end, + } + + local messages = helpers.capture_notify(function() + helpers.with_mocked_fn({ + executable = function() + return 1 + end, + }, function() + assert.is_false(format.run({ "sql_formatter_cli" }, "Format SQL")) + end) + end) + + vim.bo.buftype = "" + assert.matches("buftype=nofile", messages[1]) + end) + + it("temporarily enables modifiable when needed", function() + vim.bo.buftype = "" + vim.bo.modifiable = false + + local saw_modifiable + package.loaded["conform"] = { + format = function(_, callback) + saw_modifiable = vim.bo.modifiable + if callback then + callback(nil, true) + end + return true + end, + } + + helpers.with_mocked_fn({ + executable = function() + return 1 + end, + }, function() + assert.is_true(format.run({ "sql_formatter_cli" }, "Format SQL")) + end) + + assert.is_true(saw_modifiable) + assert.is_false(vim.bo.modifiable) + end) +end) diff --git a/tests/require_tool_spec.lua b/tests/require_tool_spec.lua index eb03df1..9785d8d 100644 --- a/tests/require_tool_spec.lua +++ b/tests/require_tool_spec.lua @@ -70,4 +70,21 @@ describe("utils.require_tool", function() assert.matches("npm install %-g sql%-formatter%-cli", messages[1]) end) + + it("warns with cat fallback when cat is missing", function() + local messages = helpers.capture_notify(function() + helpers.with_mocked_fn({ + executable = function() + return 0 + end, + system = function() + return "Linux\n" + end, + }, function() + assert.is_false(require_tool.ensure("cat")) + end) + end) + + assert.matches("coreutils", messages[1]) + end) end) diff --git a/tests/utils_spec.lua b/tests/utils_spec.lua index c189b62..df51272 100644 --- a/tests/utils_spec.lua +++ b/tests/utils_spec.lua @@ -10,6 +10,18 @@ describe("utils.cmdPreservePosition", function() assert.same({ 2, 0 }, vim.api.nvim_win_get_cursor(0)) end) + + it("restores the cursor after running a function", function() + vim.api.nvim_buf_set_lines(0, 0, -1, true, { "one", "two", "three" }) + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + + local preserve = require("utils.cmdPreservePosition") + preserve(function() + vim.api.nvim_buf_set_lines(0, 1, 2, true, { "TWO" }) + end) + + assert.same({ 2, 0 }, vim.api.nvim_win_get_cursor(0)) + end) end) describe("utils.map", function() @@ -70,6 +82,21 @@ describe("utils.functions", function() assert.same({ "keep", "also" }, lines) end) + it("RemoveEmptyLines deletes whitespace-only lines", function() + local lines = with_buffer({ "keep", " ", "also" }, RemoveEmptyLines) + assert.same({ "keep", "also" }, lines) + end) + + it("RemoveExtraEmptyLines keeps one blank line between blocks", function() + local lines = with_buffer({ "def foo", "", "", "end", "", "", "", "def bar" }, RemoveExtraEmptyLines) + assert.same({ "def foo", "", "end", "", "def bar" }, lines) + end) + + it("RemoveExtraEmptyLines treats whitespace-only lines as blank", function() + local lines = with_buffer({ "", " ", " ", " ", " ", " <author/>" }, RemoveExtraEmptyLines) + assert.same({ "<book>", "", " <title/>", "", " <author/>" }, lines) + end) + it("DoubleQuotes converts single-quoted strings to double quotes", function() local lines = with_buffer({ "x = 'value'" }, DoubleQuotes) assert.same({ 'x = "value"' }, lines) @@ -105,6 +132,32 @@ describe("utils.functions", function() assert.same({ ":foo => 'bar'" }, lines) end) + it("AddLineNumbers prefixes each line with its line number", function() + local lines = with_buffer({ "first", "second" }, AddLineNumbers) + assert.same({ "1 first", "2 second" }, lines) + end) + + it("FormatHashes converts rocket syntax and double quotes", function() + local lines = with_buffer({ + " :zebra => 1,", + ' :apple => "foo",', + }, FormatHashes) + + local text = table.concat(lines, "\n") + assert.not_matches("=>", text) + assert.not_matches('"foo"', text) + assert.matches("zebra:%s+1,", text) + assert.matches("apple:%s+'foo',", text) + end) + + it("FormatHashes splits }, { onto separate lines", function() + local lines = with_buffer({ "{ :b => 2 }, { :a => 1 }" }, FormatHashes) + + assert.equals(2, #lines) + assert.matches("{ b:%s+2 },", lines[1]) + assert.matches("{ a:%s+1 }", lines[2]) + end) + it("preserves the cursor when transforming buffer text", function() vim.api.nvim_buf_set_lines(0, 0, -1, true, { " :foo => 1", "unchanged" }) vim.api.nvim_win_set_cursor(0, { 2, 0 }) @@ -119,9 +172,20 @@ describe("utils.functions", function() local function with_tool_and_preserve(tool_bin, fn) captured_cmds = {} - package.loaded["utils.cmdPreservePosition"] = function(cmd) - captured_cmds[#captured_cmds + 1] = cmd - end + package.loaded["utils.cmdPreservePosition"] = setmetatable({ + with_cursor = function(action, ...) + if type(action) == "string" then + captured_cmds[#captured_cmds + 1] = action + end + if type(action) == "function" then + action(...) + end + end, + }, { + __call = function(_, action, ...) + return package.loaded["utils.cmdPreservePosition"].with_cursor(action, ...) + end, + }) helpers.reload_module("utils.require_tool") helpers.reload_module("utils.functions") helpers.with_mocked_fn({ @@ -139,6 +203,7 @@ describe("utils.functions", function() end) it("FormatSQL warns and skips when sqlformat is missing", function() + helpers.reload_module("utils.format") helpers.reload_module("utils.functions") local messages = helpers.capture_notify(function() helpers.with_mocked_fn({ @@ -155,45 +220,88 @@ describe("utils.functions", function() assert.matches("install with:", messages[1]) end) - it("FormatSQL pipes the buffer through sqlformat", function() - with_tool_and_preserve("sqlformat", function() - FormatSQL() + it("FormatCss runs conform prettier formatter", function() + local formatters_run + package.loaded["utils.format"] = { + run = function(names, label) + formatters_run = names + assert.equals("Format CSS", label) + return true + end, + } + + helpers.with_mocked_fn({ + executable = function() + return 1 + end, + }, function() + helpers.reload_module("utils.functions") + require("utils.functions") + FormatCss() end) - assert.matches("sqlformat", captured_cmds[1]) + + package.loaded["utils.format"] = nil + assert.same({ "prettier" }, formatters_run) end) - it("FormatSQLV2 pipes the buffer through sql-formatter-cli", function() - with_tool_and_preserve("sql-formatter-cli", function() - FormatSQLV2() + it("FormatSQLFormatter runs conform sql_formatter_cli formatter", function() + local formatters_run + package.loaded["utils.format"] = { + run = function(names, label) + formatters_run = names + assert.equals("Format SQL", label) + return true + end, + } + + helpers.with_mocked_fn({ + executable = function() + return 1 + end, + }, function() + helpers.reload_module("utils.functions") + require("utils.functions") + FormatSQLFormatter() end) - assert.matches("sql%-formatter%-cli", captured_cmds[1]) + + package.loaded["utils.format"] = nil + assert.same({ "sql_formatter_cli" }, formatters_run) end) - it("RemoveExtraEmptyLines pipes the buffer through cat -s", function() - with_tool_and_preserve("cat", function() - RemoveExtraEmptyLines() + it("FormatJSON runs conform json_tool formatter", function() + local formatters_run + package.loaded["utils.format"] = { + run = function(names, label) + formatters_run = names + assert.equals("Format JSON", label) + return true + end, + } + + helpers.with_mocked_fn({ + executable = function() + return 1 + end, + }, function() + helpers.reload_module("utils.functions") + require("utils.functions") + FormatJSON() end) - assert.matches("cat %-s", captured_cmds[1]) - end) - it("FormatCss runs a brace-aware substitute command", function() - captured_cmds = {} - package.loaded["utils.cmdPreservePosition"] = function(cmd) - captured_cmds[#captured_cmds + 1] = cmd - end - helpers.reload_module("utils.functions") - require("utils.functions") - FormatCss() - assert.matches("%%s/%[{;}", captured_cmds[1]) + package.loaded["utils.format"] = nil + assert.same({ "json_tool" }, formatters_run) end) - it("FormatXML runs python minidom via vim.cmd", function() - local cmd_calls = {} - local original_cmd = vim.cmd + it("FormatXML preprocesses escapes then runs conform xml formatter", function() + local formatters_run + package.loaded["utils.format"] = { + run = function(names) + formatters_run = names + return true + end, + } - vim.cmd = function(command) - cmd_calls[#cmd_calls + 1] = command - end + vim.api.nvim_buf_set_lines(0, 0, -1, true, { '{\\"a\\": 1}' }) helpers.with_mocked_fn({ executable = function(name) @@ -205,11 +313,9 @@ describe("utils.functions", function() FormatXML() end) - vim.cmd = original_cmd - - local joined = table.concat(cmd_calls, "\n") - assert.matches("python3", joined) - assert.matches("xml%.dom%.minidom", joined) + package.loaded["utils.format"] = nil + assert.same({ "xml_minidom" }, formatters_run) + assert.same({ '{"a": 1}' }, vim.api.nvim_buf_get_lines(0, 0, -1, true)) end) end) end)