From 9d09f214a1df170e2fa50d80dc5870d35759f3e9 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 24 Jul 2026 00:23:25 +0800 Subject: [PATCH 01/12] feat(tooling): shared discovery-first tool resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add modules.utils.tools — the $PATH → Mason-install → aggregated-warning resolver (M.resolve) shared by LSP, formatters, linters, and DAP, with Mason as an optional backend. Includes the missing-tool collector (reason upgrades, timeout-placeholder retraction, attempted-and-failed vs typo classification), the install hand-off / :ToolsRetry paths (phase-1 $PATH failures stay reachable), per-tick registry/mason-root memoization read live to avoid latching, $MASON read live, and the shared parity-sweep delay. Removes the now-unused utils.register_server. Folds subsequent hardening and review fixes: an empty-string dep name is classified invalid (not a blank dep); package.searchpath is guarded for non-LuaJIT nvim builds (degrade to vim.loader.find); superseded resolver sessions drop on config re-source (drop_sessions, per-consumer); static_binaries specs skip phase 2's identical $PATH re-probe; the collector retracts a standing missing-report once a late configure succeeds (coalesced corrective INFO when the warning was already shown — a slow install no longer strands a stale timeout warning); user-config load errors classify by module existence instead of message sniffing; luadoc fun types are parenthesized so lua_ls parses the spec contracts, and load_plugin merges over `opts or {}` per its documented nil|table contract. --- lua/modules/utils/init.lua | 23 +- lua/modules/utils/tools.lua | 1531 +++++++++++++++++++++++++++++++++++ 2 files changed, 1533 insertions(+), 21 deletions(-) create mode 100644 lua/modules/utils/tools.lua diff --git a/lua/modules/utils/init.lua b/lua/modules/utils/init.lua index 6a87aa47..3747df8f 100644 --- a/lua/modules/utils/init.lua +++ b/lua/modules/utils/init.lua @@ -269,19 +269,6 @@ function M.get_lsp_capabilities() ) end ----Setup and enable a language server in one call. ----@param server string @Name of the language server ----@param config? vim.lsp.Config @Optional config to apply -function M.register_server(server, config) - vim.validate("server", server, "string", false) - vim.validate("config", config, "table", true) - - if config then - vim.lsp.config(server, config) - end - vim.lsp.enable(server) -end - ---Convert number (0/1) to boolean ---@param value number @The value to check ---@return boolean|nil @Returns nil if failed @@ -325,15 +312,9 @@ end ---@param user_config string @The module name used to require user config ---@return table @Extended config function M.extend_config(config, user_config) - local ok, extras = pcall(require, user_config) + local ok, extras = require("modules.utils.tools").load_module_or_report(user_config, "[utils] Runtime Error") if ok and type(extras) == "table" then config = tbl_recursive_merge(config, extras) - elseif not ok and type(extras) == "string" and not extras:find("module .* not found") then - vim.notify( - string.format("[utils] Error loading %s: %s", user_config, extras), - vim.log.levels.ERROR, - { title = "[utils] Runtime Error" } - ) end return config end @@ -375,7 +356,7 @@ function M.load_plugin(plugin_name, opts, vim_plugin, setup_callback) if ok then -- Extend base config if the returned user config is a table if type(user_config) == "table" then - opts = tbl_recursive_merge(opts, user_config) + opts = tbl_recursive_merge(opts or {}, user_config) setup_callback(opts) -- Replace base config if the returned user config is a function elseif type(user_config) == "function" then diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua new file mode 100644 index 00000000..67d2b8be --- /dev/null +++ b/lua/modules/utils/tools.lua @@ -0,0 +1,1531 @@ +-- Discovery-first tool resolution (RFC: ayamir/nvimdots#1293): $PATH → Mason +-- install → aggregated missing-tool warning, with Mason as an optional backend +-- and `M.resolve` as the shared loop. +-- +-- Only mason.setup() (lazy-loaded) puts Mason's bin dir on $PATH, so resolve() +-- keeps it there first (`M.ensure_mason_on_path`, idempotent membership +-- check) — appended, not prepended, so a system copy still wins. A bare-name +-- spawn that bypasses `M.resolve` must call `M.ensure_mason_on_path()` itself +-- or use `M.find_executable`. +local M = {} + +-- Fallback when `settings.tool_install_timeout` is missing or non-positive; +-- lua/core/settings.lua's doc comment references this constant by name. +local DEFAULT_TOOL_INSTALL_TIMEOUT_MS = 300000 + +-- ONE source for the parity-sweep deadline shared by the two per-filetype +-- consumers (mason-lspconfig's LSP sweep, nvim-lint's linter sweep): every +-- deferred dep is classified at most this long after its resolve pass even if +-- its filetype never opens. The two sweeps deliberately DIFFER in mechanics — +-- lint arms a CursorHold idle gate plus a fallback timer and warms module +-- loads one per tick (linter modules may block at load: golangcilint runs its +-- binary), while the LSP sweep is a plain generation-guarded timer (its +-- module loads are cheap) — only the deadline itself is common. +M.SWEEP_DELAY_MS = 120000 + +---Split a caller-supplied name spec into valid names and the raw entries +---dropped: string → singleton; table → non-string/empty entries collected +---into `invalid` (config mistakes the caller may report), nil holes skipped +---(maxn, so a hole can't truncate the list); anything else → no names. The +---ONE definition of what counts as a valid dep entry. +---@param names any @Executable name(s) as callers pass them. +---@return string[] valid +---@return any[] invalid @Raw non-nil dropped entries, in order. +local function split_dep_names(names) + if type(names) == "string" then + -- An empty string is invalid, same as in the table branch below — route + -- it to `invalid` so the ONE validity definition holds for both shapes + -- (a blank string must never read as a valid, label-less dep). + if names == "" then + return {}, { "" } + end + return { names }, {} + end + if type(names) ~= "table" then + return {}, {} + end + local out, invalid = {}, {} + for i = 1, table.maxn(names) do + local name = names[i] + if type(name) == "string" and name ~= "" then + out[#out + 1] = name + elseif name ~= nil then + invalid[#invalid + 1] = name + end + end + return out, invalid +end +M.split_dep_names = split_dep_names + +---Keep a value only when it is a string: probe/config results are untrusted +---external shapes, and every non-string sanitizes to nil. +---@param value any +---@return string|nil +local function str_or_nil(value) + return type(value) == "string" and value or nil +end + +---Locate the first of the given executables: $PATH, then Mason's bin dir +---(reachable before mason.setup() prepends it). First-found by design: +---callers pass alternates for one tool, not a list of required binaries. +---@param names string|string[] @Executable name(s), probed in order. +---@return string|nil @Absolute path of the first name found, or nil. +function M.find_executable(names) + names = (split_dep_names(names)) + for _, name in ipairs(names) do + local path = vim.fn.exepath(name) + if path ~= "" then + return path + end + end + local root = M.mason_root() + if root then + for _, name in ipairs(names) do + -- exepath() also resolves the Windows `.cmd` shim extension. + local path = vim.fn.exepath(root .. "/bin/" .. name) + if path ~= "" then + return path + end + end + end + return nil +end + +-- Hits only: rtp grows as lazy.nvim loads plugins, so a cached miss would go +-- stale mid-session. +local module_path_cache = {} + +---Locate a module file on the paths require() uses, without executing it: +---package.path plus the runtimepath loader (covers `user.*`). +---@param module string +---@return string|nil +function M.module_path(module) + if module_path_cache[module] then + return module_path_cache[module] + end + -- package.searchpath is a Lua 5.2 extension LuaJIT ships (our runtime), but a + -- PUC-Lua 5.1 nvim build lacks it; guard so module resolution degrades to + -- vim.loader.find (nvim core, always present) instead of hard-erroring there. + local path = package.searchpath and package.searchpath(module, package.path) or nil + if not path then + -- vim.loader.find covers foo.lua and foo/init.lua without an rtp glob. + local found = vim.loader.find(module)[1] + path = found and found.modpath or nil + end + module_path_cache[module] = path + return path +end + +-- First load error per module: a re-require after a failed load only says +-- "previous error loading module", so the original message must be kept. +local broken_module_reason = {} + +---Require a module, distinguishing "missing" from "exists but broken": a file +---present on the search paths that throws at load is notified (once) and +---keeps `exists` true, so callers don't misread it as an unknown name. +---@param module string +---@param title? string @Notification title for broken-module load errors. +---@return boolean ok @Whether the require succeeded. +---@return any value @The module's value when ok, else nil. +---@return boolean exists @Whether the module file exists on the search paths. +---@return string|nil reason @The load error when the module exists but is broken. +function M.load_module_or_report(module, title) + local ok, value = pcall(require, module) + if ok then + return true, value, true + end + if not M.module_path(module) then + return false, nil, false + end + local reason = broken_module_reason[module] + if not reason then + reason = tostring(value) + broken_module_reason[module] = reason + vim.notify( + string.format("Failed to load `%s`:\n%s", module, reason), + vim.log.levels.ERROR, + { title = title or "tools" } + ) + end + return false, nil, true, reason +end + +---Enforce the no-fall-through contract for a locally loaded config: a broken +---candidate raises its reason verbatim (it must never read as success to the +---resolver — that would suppress both the warning and the install fallback), +---and a loaded value outside the accepted shapes raises a labeled shape +---error. Returns the value (nil = no config exists) once safe to consume. +---@param value any @The loaded config value (nil when no candidate exists). +---@param broken_reason string|nil @From a candidate-loader (DAP's load_client_config / LSP's server_info). +---@param opts { label: string, expected: string, shapes: table } +---@return any value +function M.usable_or_raise(value, broken_reason, opts) + if broken_reason then + M.raise_verbatim(broken_reason) + end + if value ~= nil and not opts.shapes[type(value)] then + M.raise_verbatim(string.format("%s must return %s (got `%s`)", opts.label, opts.expected, type(value))) + end + return value +end + +---Keep Mason's bin dir on $PATH (see the file header). Idempotent via the +---exact-entry membership check each call (one plain find over $PATH): a +---set-once flag would skip exactly the cases the recheck exists for — a +---$PATH something overwrote mid-session, and a root that switched once +---mason.setup applied a different one (see mason_root). APPEND-ONLY on +---purpose: identical strings in an externally mutable list admit no robust +---ownership proof, so nothing is ever removed — and per Mason PATH mode +---(mason 2.x semantics) nothing needs to be: the default prepend puts the +---new root's bin ahead of any stale appended entry on a switch, and append +---mode's tail ordering is the user's own choice (same-name shadowing until +---restart accepted). KNOWN DEVIATION: mason's `PATH = "skip"` is NOT honored +---here — deliberately. The resolver's availability verdicts (find_executable, +---consulted by every resolve()) and its consumers' bare-name spawns +---(conform/nvim-lint commands, LSP cmds) must AGREE, and this append is what +---keeps them agreeing; honoring skip would classify a Mason-only tool as +---available while its spawn fails, turning the aggregated report into a lie. +---Honoring skip coherently requires absolute-command configuration across +---every bare-spawn consumer — a cross-cutting change tracked as follow-up +---work, out of this module's scope. +function M.ensure_mason_on_path() + local root = M.mason_root() + if not root then + return + end + local bin = root .. "/bin" + local sep = require("core.global").is_windows and ";" or ":" + local path = vim.env.PATH or "" + -- Exact-entry membership check (mason.setup may have prepended it): wrap + -- both sides in the separator; plain find — $PATH may hold magic chars. + if not (sep .. path .. sep):find(sep .. bin .. sep, 1, true) then + vim.env.PATH = path ~= "" and (path .. sep .. bin) or bin + end +end + +---Lazy mason-registry thunk for resolve() specs — the ONE copy (dap and the +---runtime-tools resolver share it, so a change to the load guard cannot +---drift between consumers). Passed as a spec `registry` value: phase 1 never +---calls it — phase 2 and the retry paths resolve it only once leftovers +---exist, so a fully-provisioned setup never loads mason-registry. +---@return table|nil +function M.default_registry() + local ok, resolved = pcall(require, "mason-registry") + return ok and resolved or nil +end + +---Resolve the first of the given executable names, or `error()` with the +---install hint — at level 0 so the message carries no "file:line:" prefix. +---@param names string|string[] @Executable name(s), probed in order. +---@param hint string @Actionable install guidance appended to the error. +---@return string @Absolute path of the first name found. +function M.exepath_or_error(names, hint) + local path = M.find_executable(names) + if path then + return path + end + local shown = (split_dep_names(names)) + local label = #shown > 0 and table.concat(shown, "/") or "" + error(string.format("%s not found on $PATH or in Mason's bin dir; %s", label, hint), 0) +end + +-- Real chunkname prefixes error() prepends, most specific first. A lazy +-- catch-all would also eat the leading prose of a prefix-less level-0 raise +-- whose message contains ":: " (e.g. "parse failed at 12:30: ..."). +local CHUNK_PREFIXES = { + '^%[string "[^\n]*"%]:%d+: ', + "^%[C%]:%d+: ", + "^[^\n]-%.lua:%d+: ", +} + +---Normalize a pcall-captured error: a raise_verbatim sentinel yields its +---reason untouched; a string loses the "chunkname:line: " prefix error() adds +---(only shapes that ARE chunknames — a custom non-.lua chunk keeps its +---prefix, which is more information, never less); anything else is dropped. +---@param err any +---@return string|nil +local function error_reason(err) + if type(err) == "table" and type(err.reason) == "string" then + return err.reason + end + if type(err) ~= "string" then + return nil + end + for _, pattern in ipairs(CHUNK_PREFIXES) do + local stripped, hits = err:gsub(pattern, "") + if hits > 0 then + return stripped + end + end + return err +end + +-- Private brand for raise_verbatim errors: only sentinels carrying this key +-- count as config-layer failures — a config that happens to throw its own +-- `{ reason = ... }` table stays an ordinary error. +local VERBATIM = {} + +---Raise a config failure whose message must reach the warning verbatim: it may +---itself start with a "path:line:" that position stripping would eat. The +---resolver also treats this sentinel as a config-layer error: a "validates" +---local config raising it is reported immediately, never answered with an +---install (an install can't fix a broken config). +---@param reason string +function M.raise_verbatim(reason) + -- selene: allow(incorrect_standard_library_use) -- error() does accept any value + error({ reason = reason, [VERBATIM] = true }) +end + +---Aggregate tools that could not be set up into one deferred warning, in two +---sections: `mark` (install it / config failed) and `mark_unknown` +---(unrecognized name — fix the config, don't install). +---@class ToolCollector +---@field mark fun(name: string, reason?: string, provisional?: boolean, final?: boolean) @Record an unresolved tool; +--- a provisional reason is a placeholder a later concrete failure may replace. +---@field mark_unknown fun(name: string) @Record an unrecognized name (typo / outdated / unsupported). +---@field track fun(pkg: table, name: string, recheck: (fun(): boolean), on_ready?: fun(), fail_reason?: string) +---@field is_unsettled fun(name: string): boolean @Whether an in-flight tracked install still owns the name. +---@field retract fun(name: string) @A success contradicts any standing `missing`-bucket entry for the name. +---@field done fun() @Flush the aggregated warning once all tracked installs settle. +---@param title string @Notification title identifying the subsystem. +---@param timeout_ms? number @Deadline before the warning is flushed despite unsettled installs. +---@return ToolCollector +local function missing_collector(title, timeout_ms) + -- ONE record per name across both report buckets — upgrades, the + -- placeholder retraction, the bucket migration, and a success deleting the + -- record outright each mutate a single record instead of five parallel + -- structures in lockstep. + -- bucket "missing" (install it / config failed) | "unknown" (typo). + -- reason rendered in the missing section only. + -- provisional the reason is a placeholder (generic install-timeout / + -- registry-refresh note): a later concrete failure may + -- upgrade it or the migration may re-bucket the name. + -- final from an actual ATTEMPTED-AND-FAILED configure/install: + -- never migrated to the typo bucket (a name that resolved + -- far enough to run is real; a late "unknown" verdict would + -- be the misclassification). + -- emitted already notified; cleared to re-notify a changed record. + ---@type table + local entries = {} + local queued = {} + -- Tracked installs whose "closed" callback hasn't run yet + -- (name -> { reason = phase-1 fail_reason or false }); each entry owns a + -- defer_fn timer that settles it if the closed callback never does. + -- Deliberately separate from `entries`: different lifecycle (install + -- tracking, not reporting). + local unsettled = {} + local flush_scheduled = false + local announce_scheduled = false + -- Forward declaration: add/add_unknown re-flush after an upgrade/migration. + local flush + + -- Dedup across both buckets; non-string names are tostring()'d, nil/empty dropped. + local function normalize(name) + if name == nil or name == "" then + return nil + end + return type(name) == "string" and name or tostring(name) + end + local function add(name, reason, is_provisional, is_final) + name = normalize(name) + if name == nil then + return + end + local entry = entries[name] + if entry == nil then + entries[name] = { + bucket = "missing", + reason = (type(reason) == "string" and reason ~= "") and reason or nil, + provisional = is_provisional or nil, + final = is_final or nil, + } + return + end + if is_final then + entry.final = true + end + -- Already recorded: a concrete reason may replace a placeholder one + -- (generic timeout/refresh note, or a reason-less mark) and re-notify — + -- also for a name sitting in the UNKNOWN bucket, where the reason is + -- unread but the re-emission stands ("unknown wins once assigned": the + -- bucket never flips back). The first REAL reason wins; later ones + -- never overwrite it. + if + type(reason) == "string" + and reason ~= "" + and reason ~= entry.reason + and (entry.reason == nil or entry.provisional) + then + entry.reason = reason + entry.provisional = is_provisional or nil + entry.emitted = nil + flush() + end + end + local function add_unknown(name) + name = normalize(name) + if name == nil then + return + end + local entry = entries[name] + if entry == nil then + entries[name] = { bucket = "unknown" } + return + end + -- Bucket migration: a name parked in `missing` under a placeholder + -- reason (registry-refresh timeout) may classify as unknown once the + -- late refresh completes — move it so a typo doesn't keep stale + -- install guidance. Entries with a real reason never migrate, and + -- neither does anything flagged `final` (attempted-and-failed) — a + -- reason-LESS real failure must not turn into typo guidance. + if entry.bucket == "unknown" or entry.final or not (entry.provisional or entry.reason == nil) then + return + end + entry.bucket = "unknown" + entry.reason = nil + entry.provisional = nil + entry.emitted = nil + flush() + end + + -- Coalesced corrective INFO for retracted-after-emission entries. + local retract_queue = {} + local retract_scheduled = false + local function retract(name) + name = normalize(name) + if name == nil then + return + end + local entry = entries[name] + -- Only the missing bucket: an `unknown` verdict (typo) is not contradicted + -- by a configure succeeding under a different resolution path. + if entry == nil or entry.bucket ~= "missing" then + return + end + local seen = entry.emitted + entries[name] = nil + if not seen then + return -- never notified: silently dropping it IS the correction + end + retract_queue[#retract_queue + 1] = name + if retract_scheduled then + return + end + retract_scheduled = true + vim.schedule(function() + retract_scheduled = false + if #retract_queue == 0 then + return + end + table.sort(retract_queue) + vim.notify( + "Now installed and configured (the earlier warning about these no longer applies): " + .. table.concat(retract_queue, ", "), + vim.log.levels.INFO, + { title = title } + ) + retract_queue = {} + end) + end + + local function render(name) + local entry = entries[name] + local reason = entry and entry.reason + return reason and (name .. " — " .. reason) or name + end + + -- Emit names not yet notified, coalesced per event-loop tick. Gated while + -- installs are pending unless forced (done() and per-install timers force: + -- their records are final); `emitted` lets late failures still notify. + function flush(force) + if not force and next(unsettled) ~= nil then + return + end + if flush_scheduled then + return + end + flush_scheduled = true + vim.schedule(function() + flush_scheduled = false + -- Un-emitted records only; each batch is sorted below, so the + -- pairs() order never reaches the user. + local missing_new, unknown_new = {}, {} + for name, entry in pairs(entries) do + if not entry.emitted then + entry.emitted = true + if entry.bucket == "missing" then + missing_new[#missing_new + 1] = name + else + unknown_new[#unknown_new + 1] = name + end + end + end + if #missing_new == 0 and #unknown_new == 0 then + return + end + local sections = {} + if #missing_new > 0 then + table.sort(missing_new) + local lines = {} + for _, name in ipairs(missing_new) do + lines[#lines + 1] = render(name) + end + sections[#sections + 1] = "The following tools could not be set up automatically.\n" + .. "Install them / ensure they are on $PATH, or check their configuration\n" + .. "for errors:\n • " + .. table.concat(lines, "\n • ") + .. "\n\nMason installs are picked up automatically; after installing a tool\n" + .. "outside Mason, run :ToolsRetry or restart Neovim." + end + if #unknown_new > 0 then + table.sort(unknown_new) + sections[#sections + 1] = "The following names are not recognized (likely a typo, or an outdated\n" + .. "or unsupported name) — correct or remove them from your config:\n • " + .. table.concat(unknown_new, "\n • ") + end + vim.notify(table.concat(sections, "\n\n"), vim.log.levels.WARN, { title = title }) + end) + end + + return { + mark = add, + mark_unknown = add_unknown, + ---An install tracked here whose "closed" callback hasn't run yet (and + ---whose deadline hasn't settled it): such a name is owned by that + ---callback, so hand-off retries leave it alone. + is_unsettled = function(name) + return unsettled[name] ~= nil + end, + retract = retract, + track = function(pkg, name, recheck, on_ready, fail_reason) + -- Attach to an in-flight OPEN install handle instead of starting a + -- duplicate (install() asserts; once("closed") never fires on a closed one). + local handle + if type(pkg) == "table" and type(pkg.get_install_handle) == "function" then + local ok, opt = pcall(function() + return pkg:get_install_handle() + end) + if ok and type(opt) == "table" and type(opt.if_present) == "function" then + opt:if_present(function(h) + local ok_closed, closed = pcall(function() + return h:is_closed() + end) + if ok_closed and not closed then + handle = h + end + end) + end + end + + -- Only a self-started install is announced in the "Installing N tool(s)" INFO. + local started_here = handle == nil + if not handle then + local ok, h = pcall(function() + return pkg:install() + end) + if not ok or type(h) ~= "table" or type(h.once) ~= "function" then + -- A synchronous install() throw is the only failure detail there is. + add(name, fail_reason or (not ok and error_reason(h) or nil), nil, true) + return + end + handle = h + elseif type(handle.once) ~= "function" then + -- Shared handle isn't usable (unexpected shape); mark rather than hang. + add(name, fail_reason, nil, true) + return + end + + -- Only the first in-flight track for a name creates the entry: a + -- repeat (re-resolve pass / shared-handle piggyback) must not add a + -- second settle against one install. `track` is only ever called + -- with a non-empty string name (start_install), so `unsettled` + -- membership IS the whole accounting — the flush gate reads it. + local first_track = unsettled[name] == nil + if first_track then + unsettled[name] = { reason = fail_reason or false } + -- One timer per tracked install, settling only its own entry + -- (a no-op when the closed callback got there first), so a + -- late-tracked install always keeps its full window. Without + -- a configured timeout the entry settles via "closed" only. + if type(timeout_ms) == "number" and timeout_ms > 0 then + vim.defer_fn(function() + local entry = unsettled[name] + if entry == nil then + return + end + unsettled[name] = nil + -- The generic note is a placeholder a later concrete + -- failure may upgrade (see add()). + add( + name, + type(entry.reason) == "string" and entry.reason + or "Mason install did not finish within the timeout (check :Mason for progress)", + entry.reason == false + ) + -- This entry is final: force past the unsettled gate. + flush(true) + end, timeout_ms) + end + end + -- "closed" fires in a fast event context: hop to the main loop. recheck/ + -- on_ready are pcall'd so a throw can't suppress flush; clearing the + -- entry is a no-op when the deadline already settled this install, + -- but on_ready still runs. + local registered, reg_err = pcall( + handle.once, + handle, + "closed", + vim.schedule_wrap(function() + unsettled[name] = nil + local rc_ok, available = pcall(recheck) + if rc_ok and available then + if type(on_ready) == "function" then + -- A configure throw after the install must still reach the + -- warning. (A nil error_reason with nil fail_reason can + -- still strand a deadline placeholder here — rare, + -- pre-existing shape shared with the branch below.) + local ready_ok, ready_err = pcall(on_ready) + if not ready_ok then + add(name, error_reason(ready_err) or fail_reason, nil, true) + end + end + elseif fail_reason ~= nil then + add(name, fail_reason, nil, true) -- a concrete reason upgrades any placeholder + elseif entries[name] and entries[name].provisional then + -- The deadline timer already parked this name under the + -- "did not finish within the timeout" note — now known + -- FALSE: the install finished (and failed) with no reason + -- to offer. Retract the placeholder and re-emit the bare + -- name (accurate: unknown failure) instead of pointing + -- the user at :Mason progress that will never come. + local entry = entries[name] + entry.reason = nil + entry.provisional = nil + entry.final = true -- retraction IS an attempted-and-failed verdict + entry.emitted = nil -- the trailing flush re-emits the corrected entry + else + add(name, nil, nil, true) + end + flush() + end) + ) + -- once() threw: undo only what THIS track added, or a failed piggyback + -- would clear another caller's in-flight entry. + if not registered then + if first_track then + unsettled[name] = nil + end + add(name, fail_reason or error_reason(reg_err), nil, true) + elseif started_here and type(name) == "string" and name ~= "" then + -- Announce only self-started installs, only once registration stuck. + queued[#queued + 1] = name + end + end, + done = function() + -- One coalesced INFO per batch so a first launch shows progress; + -- drained after announcing so a later done() can't re-announce. + if #queued > 0 and not announce_scheduled then + announce_scheduled = true + vim.schedule(function() + announce_scheduled = false + if #queued == 0 then + return + end + table.sort(queued) + local message = string.format( + "Installing %d tool(s) via Mason in the background; each is configured\n" + .. "automatically once its install finishes (relaunch if one isn't picked up):\n • %s", + #queued, + table.concat(queued, "\n • ") + ) + queued = {} + vim.notify(message, vim.log.levels.INFO, { title = title }) + end) + end + -- done()'s records are final: force past the unsettled gate. + flush(true) + end, + } +end + +---Whether the registry's source specs are all on disk. On API drift err toward +---false: resolve() then refreshes redundantly (a no-op), and package_for_binary +---rebuilds instead of freezing — both err toward correctness. +---@param registry table|nil @The mason-registry module (or nil). +---@return boolean +local function registry_bootstrapped(registry) + if not registry or registry.sources == nil then + return false + end + local ok, all_installed = pcall(function() + return registry.sources:is_all_installed() + end) + if not ok then + return false + end + return all_installed == true +end + +-- Frozen bin -> package index (see package_for_binary below). +local bin_to_package = nil +-- Unfrozen scans stay re-buildable by design (self-heal after a late +-- refresh), but within ONE event-loop tick nothing can change: memoize per +-- tick so a batch of lookups decodes registry.json once, not N times. +local unfrozen_index = nil + +---Find the Mason package shipping the given binary, via a lazily-built +---bin -> package index over the registry specs: tool name, binary, and +---package name may all differ (cmake_format -> cmake-format -> cmakelang). +---@param registry table @The mason-registry module. +---@param binary string @Executable name to look up. +---@return string|nil @Mason package name shipping that binary, or nil. +local function package_for_binary(registry, binary) + -- Freeze the index only once the registry is FULLY bootstrapped: + -- get_all_package_specs() silently skips uninstalled sources, and a frozen + -- partial index would outlive resolve()'s re-refresh self-heal. (A source + -- appended at runtime after the freeze is out of scope.) + if bin_to_package == nil then + if unfrozen_index then + return unfrozen_index[binary] + end + local index = {} + local populated = false + local ok, specs = pcall(registry.get_all_package_specs) + if ok and type(specs) == "table" then + for _, spec in ipairs(specs) do + if type(spec) == "table" and type(spec.name) == "string" and type(spec.bin) == "table" then + for bin_name in pairs(spec.bin) do + if index[bin_name] == nil then + index[bin_name] = spec.name + populated = true + end + end + end + end + end + if populated and registry_bootstrapped(registry) then + bin_to_package = index + else + -- Partial/uncertain scan: consult what was found, keep it only for + -- the current tick. + unfrozen_index = index + vim.schedule(function() + unfrozen_index = nil + end) + return index[binary] + end + end + return bin_to_package[binary] +end + +---Executable name(s) a Mason package provides, from its spec. Without a `bin` +---table prefer `pkg.name`: the subsystem name can differ (python -> debugpy). +---@param pkg table @A mason-registry Package object. +---@param fallback string @Last-resort name when even `pkg.name` is absent. +---@return string[] +function M.package_binaries(pkg, fallback) + local bins = (type(pkg.spec) == "table" and type(pkg.spec.bin) == "table") and vim.tbl_keys(pkg.spec.bin) or {} + -- tbl_keys order is unspecified; sort so multi-bin probes stay stable. + table.sort(bins) + if #bins == 0 then + bins = { type(pkg.name) == "string" and pkg.name or fallback } + end + return bins +end + +---Mason's install root WITHOUT loading Mason (this runs from every resolve()): +---a set-up mason.settings always wins — checked LIVE each call, because the +---module may load long after the first resolve cached a fallback and call +---timing must not invert the priority; gated on mason.has_setup because +---mason-registry requires mason.settings at load with the DEFAULTS, and only +---setup() applies a user's custom root — then $MASON (read live each call, +---like every other branch: a stale export corrected via :let must take +---effect, and a latched dead path must not shadow the guess forever), then +---the default data-dir guess. The guess only counts when no `user.configs.mason` +---override exists (the one supported home for a custom root) and is never +---cached; every returned root is re-checked for existence each call (the dir +---appears after the first install). Kept public deliberately so these +---priority semantics stay independently testable from outside the module +---(test hook); no external runtime caller today. +---@return string|nil +-- The user-override existence check is memoized separately from module_path's +-- hit-only cache: it merely gates the default-dir guess below, and a user +-- adding user/configs/mason mid-session needs a restart for a new root +-- anyway — so unlike a general module miss, staleness here is harmless. +local mason_override_absent = nil +function M.mason_root() + local mason = package.loaded["mason"] + local settings = package.loaded["mason.settings"] + if + type(mason) == "table" + and mason.has_setup == true + and type(settings) == "table" + and type(settings.current) == "table" + and type(settings.current.install_root_dir) == "string" + then + -- Presence decides the branch, existence decides the return: a + -- configured-but-not-yet-created root yields nil, it must NOT fall + -- back to a wrong root. + local root = settings.current.install_root_dir + return vim.uv.fs_stat(root) and root or nil + end + local env_root = type(vim.env.MASON) == "string" and vim.env.MASON ~= "" and vim.env.MASON or nil + if env_root then + return vim.uv.fs_stat(env_root) and env_root or nil + end + -- Never require() mason.settings here — it lazy-loads all of mason.nvim + -- during a pure discovery probe, defeating "Mason optional". + local guess = vim.fn.stdpath("data") .. "/mason" + if mason_override_absent == nil then + mason_override_absent = M.module_path("user.configs.mason") == nil + end + if mason_override_absent and vim.uv.fs_stat(guess) then + return guess + end + return nil +end + +-- One collector per title so a subsystem that resolves in batches (nvim-lint, +-- per filetype) aggregates one warning instead of one per batch. +local collectors_by_title = {} + +-- Install hand-off: resolve() sessions whose deps are still waiting for a tool +-- to appear. Each entry pairs a spec with its pending set so a later Mason +-- install event or :ToolsRetry can finish the configure without a restart. +local sessions = {} + +---Deregister a session once nothing is pending (leak-free bookkeeping). Sole +---owner of removal — called from every path that empties a pending set, so +---iterations over `sessions` never race a second remover. +---@param session table +local function drop_session_if_done(session) + if next(session.pending) ~= nil then + return + end + for index = #sessions, 1, -1 do + if sessions[index] == session then + table.remove(sessions, index) + return + end + end +end + +---Drop every pending session recorded under `title`: a consumer calls this +---when it rebuilds its resolve state (re-source), because the superseded +---run's sessions hold stale configure closures that retry paths would still +---invoke. The new run re-resolves every dep it still cares about, so +---dropped pending names are re-covered, not lost. (The sweep timers guard +---the same hazard with vim.g generation tokens; sessions need this explicit +---drop instead — multiple LIVE sessions per title are legal within one run, +---so a per-title singleton rule can't work.) +---@param title string +function M.drop_sessions(title) + for index = #sessions, 1, -1 do + if sessions[index].title == title then + table.remove(sessions, index) + end + end +end + +---Gated late-configure across sessions: for every pending name accepted by +---`eligible`, run the session's configure — at most one SUCCESS per name (the +---pending set is the gate; a failure keeps the name recoverable) — and report +---each subsystem's batch in one INFO. An emptied session drops out via the +---configure path itself (drop_session_if_done); descending order keeps the +---iteration safe across that removal. +---@param eligible fun(session: table, name: string): boolean +local function retry_pending(eligible) + local configured_by_title = {} + local failed_by_title = {} + for index = #sessions, 1, -1 do + local session = sessions[index] + for _, name in ipairs(vim.tbl_keys(session.pending)) do + -- Re-check pending: an earlier name's configure may have consumed it. + if session.pending[name] ~= nil and eligible(session, name) then + local ok, reason = session.configure(name) + if ok then + local bucket = configured_by_title[session.title] or {} + bucket[#bucket + 1] = name + configured_by_title[session.title] = bucket + else + -- A silent failed retry is indistinguishable from "retry + -- never considered this name" — and the collector's + -- first-real-reason-wins gate absorbs the fresh reason, + -- so this WARN is the only accurate signal. nil reasons + -- render as the bare name (never concat nil). + local bucket = failed_by_title[session.title] or {} + bucket[#bucket + 1] = reason and (name .. " — " .. reason) or name + failed_by_title[session.title] = bucket + end + end + end + end + for title, names in pairs(configured_by_title) do + table.sort(names) + vim.notify("Configured after install: " .. table.concat(names, ", "), vim.log.levels.INFO, { title = title }) + end + for title, lines in pairs(failed_by_title) do + table.sort(lines) + vim.notify( + "Retry failed (still pending):\n • " .. table.concat(lines, "\n • "), + vim.log.levels.WARN, + { title = title } + ) + end +end + +-- Attached once per session lifetime; each subscription pcall'd SEPARATELY +-- and flagged SEPARATELY (mason-registry API-drift guard): with one shared +-- flag, a throw from the second subscription after the first registered +-- would leave the flag unset, and the next attach would register the install +-- handler TWICE — duplicate retry_pending walks on every later install. A +-- still-false half retries on the next attach; no events at all = status +-- quo: the tool is picked up on the next launch. +local install_events_attached = false +local update_events_attached = false + +---A pending name's declared bare binaries, probed on $PATH / Mason's bin — +---the first (cheapest) availability evidence BOTH install hand-off paths +---share (the Mason install event and :ToolsRetry). The is_unsettled +---ownership guard deliberately stays at each call site: the two paths +---interleave DIFFERENT extra evidence around this probe (the event handler +---checks the installed package's name first; retry layers registry-derived +---binaries and the validates re-run after), so only the probe itself is the +---common core. +---@param session table +---@param name string +---@return boolean +local function pending_bins_available(session, name) + local bins_ok, bins = pcall(session.spec.binaries_of, name, nil) + return bins_ok and M.find_executable(bins) ~= nil +end + +---Subscribe to Mason install successes so a package installed mid-session by +---ANY means (resolver-started, :MasonInstall, the :Mason UI) finishes the +---pending configure of every subsystem that waited for it. Never require()s +---mason-registry itself — callers pass a registry they already hold, keeping +---"a fully-provisioned setup never loads Mason" intact. +---@param registry table|nil @The mason-registry module (or nil). +function M.attach_registry_events(registry) + if install_events_attached and update_events_attached then + return + end + if type(registry) ~= "table" or type(registry.on) ~= "function" then + return + end + if not install_events_attached then + install_events_attached = pcall(function() + registry:on( + "package:install:success", + -- The emitter fires from the install handle's lifecycle (fast event + -- context): hop to the main loop before touching consumer configs. + vim.schedule_wrap(function(pkg) + local pkg_name = type(pkg) == "table" and type(pkg.name) == "string" and pkg.name or nil + if not pkg_name then + return + end + retry_pending(function(session, name) + -- An install still in flight is owned by its closed callback. + if session.is_unsettled(name) then + return false + end + local ok, mapped = pcall(session.spec.package_of, name, registry) + if ok and mapped == pkg_name then + return true + end + return pending_bins_available(session, name) + end) + end) + ) + end) + end + if not update_events_attached then + update_events_attached = pcall(function() + registry:on("update:success", function() + -- SYNCHRONOUS on purpose: mason emits this inside the update + -- success path, before callers' callbacks run — a scheduled clear + -- would leave a same-tick window still reading the stale frozen + -- index. Clearing two locals is pure Lua, fast-event-safe; the + -- next lookup rebuilds (and re-freezes on a bootstrapped registry). + -- Were upstream ever to emit this asynchronously instead, the + -- sync clear degrades to one harmless extra rebuild. + bin_to_package = nil + unfrozen_index = nil + end) + end) + end +end + +---Re-attempt every pending dep whose tool has since appeared — the manual +---hand-off entry (:ToolsRetry) for installs done outside Mason (mise/nix/npm). +function M.retry_missing() + retry_pending(function(session, name) + -- An install still in flight is owned by its closed callback. + if session.is_unsettled(name) then + return false + end + local spec = session.spec + if pending_bins_available(session, name) then + return true + end + -- Function-cmd LSP servers (jsonls/yamlls) declare no bare binary: + -- derive it from the Mason package spec even when the binary itself was + -- installed outside Mason. + local registry = session.resolve_registry() + if registry then + local ok, pkg_name = pcall(spec.package_of, name, registry) + if ok and type(pkg_name) == "string" then + local pkg_ok, pkg = pcall(registry.get_package, pkg_name) + if pkg_ok and type(pkg) == "table" then + local pkg_bins_ok, pkg_bins = pcall(spec.binaries_of, name, pkg) + if pkg_bins_ok and M.find_executable(pkg_bins) ~= nil then + return true + end + end + end + end + -- Only self-VALIDATING configs may retry on config evidence alone: their + -- configure re-checks the tool and a still-missing one fails the attempt, + -- keeping the name pending. A generic local config (LSP) would instead + -- enable a server whose binary is still absent. + if spec.local_config_mode ~= "validates" or type(spec.has_local_config) ~= "function" then + return false + end + local has_ok, has = pcall(spec.has_local_config, name) + return has_ok and has == true + end) +end + +---Shared discovery-first resolution loop. For each entry in `spec.deps`: +--- 0. Unrecognized name (`unknown_of`) -> fix the config; never install. +--- 1. Available (Mason-installed / on $PATH) -> configure now. +--- 2. Mason package exists but not available yet -> a "validates" local +--- config tries first; else configure if its declared bins are on $PATH; +--- else install, then configure on completion. +--- 3. No Mason package but a local config exists -> configure now. +--- 4. Otherwise -> aggregated warning. +--- +---Everything resolvable without an install configures on the load tick (before +---lazy.nvim replays the trigger); each dep is pcall-isolated. `configure` gets +---`late = true` after resolve() returned (deferred phase 2, install completion, +---async refresh) — no replayed trigger backs such a call, so the consumer must +---re-drive its own event if one is needed. +---`local_config_mode` (for a binary-less local config): nil defers to phase 2, +---"resolves" trusts it outright, "validates" lets it try and installs on +---failure — keep "validates" checks cheap (phase 1 runs on the load tick); a +---bounded probe spawn on the miss path is acceptable when it keeps config-time +---and launch-time resolution in agreement. +---@param spec { +--- title: string, +--- deps: string[], +--- registry: table|(fun(): table|nil)|nil, +--- package_of: (fun(name: string, registry: table|nil): string|nil), +--- binaries_of: (fun(name: string, pkg: table|nil): string[]), +--- unknown_of?: (fun(name: string): boolean), +--- has_local_config?: (fun(name: string): boolean), +--- local_config_mode?: "resolves"|"validates", +--- unresolvable_of?: (fun(name: string): string|nil), +--- missing_reason_of?: (fun(name: string): string|nil), +--- configure?: (fun(name: string, late: boolean)), +--- static_binaries?: boolean, +--- defer_phase2?: boolean, +--- defer?: boolean, +---} +---`unresolvable_of` (optional): a non-nil reason short-circuits the name in +---phase 1 — marked missing with that reason and flushed immediately, never +---classified against the registry (the one exception to "phase 1 never marks +---missing"). +---`missing_reason_of` (optional): consulted only for the final reason-less +---missing mark in phase 2 (no package, no local config) — a string return +---annotates the aggregated warning; errors and non-strings are ignored. +---`static_binaries` (optional): declares that `binaries_of` ignores `pkg`, so +---phase 2 skips its already-on-$PATH re-probe (it would be identical to the +---one phase 1 already ran); see the comment at that probe for the accepted +---external-install race delta. +---`defer` (optional): move the WHOLE resolve off the caller's tick. The +---resolver still pays ensure_mason_on_path synchronously — deferring must not +---lose the same-tick guarantee that a replayed trigger's bare Mason spawns +---can resolve — and phase-1 configures inside the scheduled run still count +---as trigger-backed (`late = false`). +function M.resolve(spec) + -- Every call site owns a configure (the resolver's whole job funnels into + -- it): a missing one is a programmer error — fail at the call site instead + -- of silently "succeeding" every configure. + assert(type(spec.configure) == "function", "tools.resolve: spec.configure must be a function") + local ok_settings, settings = pcall(require, "core.settings") + local timeout_ms = ( + ok_settings + and type(settings.tool_install_timeout) == "number" + and settings.tool_install_timeout > 0 + ) + and settings.tool_install_timeout + or DEFAULT_TOOL_INSTALL_TIMEOUT_MS + local collector = collectors_by_title[spec.title] + if not collector then + collector = missing_collector(spec.title, timeout_ms) + collectors_by_title[spec.title] = collector + end + + -- False once run() returns: a configure running later (deferred phase 2, + -- install completion, async refresh) has no replayed trigger behind it and + -- gets `late = true`. + local synchronous = true + + -- This call's hand-off record: phase-2 leftovers park in `pending` until a + -- configure SUCCEEDS, so the Mason install event or :ToolsRetry can finish + -- them later; registered in `sessions` only when leftovers exist. + local session = { + title = spec.title, + spec = spec, + pending = {}, + is_unsettled = collector.is_unsettled, + ---The spec's registry (value or lazy thunk), resolved on demand; a + ---drifted non-table result normalizes to nil (degrade, never crash). + resolve_registry = function() + local registry = spec.registry + if type(registry) == "function" then + local ok, resolved = pcall(registry) + registry = ok and resolved or nil + end + return type(registry) == "table" and registry or nil + end, + } + + ---Configure one tool; false plus the cleaned error when the config threw. + ---The third result flags a BRANDED raise_verbatim sentinel — a config-layer + ---error an install can't fix. Any other thrown value (including a config's + ---own `{ reason = ... }` table) stays an ordinary failure. + local function try_configure(name) + local ok, err = pcall(spec.configure, name, not synchronous) + if ok then + return true + end + return false, error_reason(err), type(err) == "table" and err[VERBATIM] == true + end + + -- Configure one tool, surfacing a config-time error in the aggregated + -- warning. Late calls race (install completion, the registry install event, + -- :ToolsRetry, a late refresh finish): `pending` is the gate — the first + -- SUCCESS clears it and later arrivals skip; a failure keeps the name + -- recoverable. Returns true only when the configure ran and succeeded; + -- on failure the cleaned reason and the config-layer flag ride along + -- (phase-1 parking and the retry report consume them). + local function do_configure(name) + if not synchronous and session.pending[name] == nil then + return false + end + local ok, reason, config_error = try_configure(name) + if ok then + session.pending[name] = nil + -- A success contradicts any standing missing-report for this name + -- (deadline/refresh placeholder, or a phase-1 failure an install fixed). + collector.retract(name) + drop_session_if_done(session) + return true + end + collector.mark(name, reason, nil, true) + return false, reason, config_error + end + session.configure = do_configure + + -- Phase-1 "validates" failures, surfaced by phase 2 without re-running the + -- config. One record per failed name: `reason` (string or nil for a + -- message-less failure) and `config_error` (a raise_verbatim config-layer + -- error an install can't fix). Record EXISTENCE is the failure mark. + local validates = {} + ---@param name string + ---@return string|nil + local function validate_reason(name) + local record = validates[name] + return record and record.reason or nil + end + + -- Install `pkg`, configure on completion; failures keep the phase-1 reason. + local function start_install(pkg, name) + local reason = validate_reason(name) + collector.track(pkg, name, function() + return pkg:is_installed() or M.find_executable(spec.binaries_of(name, pkg)) ~= nil + end, function() + do_configure(name) + end, reason) + end + + ---Phase 1 — configure a dep resolvable without the Mason registry ($PATH, + ---self-resolving local config, or a succeeding "validates" resolver). + ---False = needs the registry (phase 2). Never marks missing, stays same-tick. + ---@param name string + ---@return boolean handled + local function configure_available(name) + if M.find_executable(spec.binaries_of(name, nil)) ~= nil then + local ok, _, config_error = do_configure(name) + -- A failed configure whose binary IS on $PATH parks for the retry + -- paths (pending_bins_available re-accepts it, so :ToolsRetry and + -- the install event can finish it after the cause is fixed) — + -- except a raise_verbatim config-layer error, which no install or + -- retry can fix: the mark above is its final report, mirroring + -- resolve_missing's report-immediately policy. + if not ok and not config_error then + session.pending[name] = true + end + return true + end + -- "resolves" only: a binary-less LSP server (jsonls) still maps to a + -- package by NAME and must reach phase 2 to install. A FAILURE here is + -- deliberately NOT parked: every retry gate is structurally closed for + -- a binary-less "resolves" name (no bins for pending_bins_available, + -- no package_of mapping for the install event, validates gate closed + -- by mode), so parking it would leak the session until restart. + if spec.local_config_mode == "resolves" and spec.has_local_config and spec.has_local_config(name) then + do_configure(name) + return true + end + -- A "validates" config is its own resolver: let it try; remember a + -- failure for phase 2 instead of re-running it there. + if spec.local_config_mode == "validates" and spec.has_local_config and spec.has_local_config(name) then + local ok, reason, config_error = try_configure(name) + if ok then + -- try_configure bypasses do_configure here, so a stale emitted entry + -- from a previous run on this shared per-title collector (collectors + -- survive in collectors_by_title) would otherwise never retract. + collector.retract(name) + return true + end + validates[name] = { + reason = str_or_nil(reason), + config_error = config_error == true, + } + end + return false + end + + ---Phase 2 — resolve a dep against the now-ready registry: configure an + ---installed package, install a missing one, or mark it missing/unknown. + ---@param name string + ---@param registry table|nil + local function resolve_missing(name, registry) + -- Judged after the refresh: unknown_of may consult the Mason mapping. + if spec.unknown_of and spec.unknown_of(name) then + -- A typo can't be fixed by an install: drop it from the hand-off set. + session.pending[name] = nil + collector.mark_unknown(name) + return + end + + local pkg, pkg_unknown = nil, false + local pkg_name = spec.package_of(name, registry) + if pkg_name and registry then + local ok, resolved = pcall(registry.get_package, pkg_name) + if ok then + pkg = resolved + else + -- Stale mapping; reported only if nothing below resolves the tool. + pkg_unknown = true + end + end + + -- The config already failed this pass. A raise_verbatim failure is a + -- config-layer error an install can't fix: report it immediately. Only + -- a provisioning failure (missing binary) is answered with an install. + if validates[name] ~= nil then + if not validates[name].config_error and pkg ~= nil and not pkg:is_installed() then + start_install(pkg, name) -- annotates the failure reason itself + else + -- The "validates" configure ran and failed: attempted-and-failed, + -- never typo-migratable. + collector.mark(name, validate_reason(name), nil, true) + end + return + end + + -- Installed via Mason but its binary name differs from the phase-1 probe. + if pkg ~= nil and pkg:is_installed() then + do_configure(name) + return + end + + -- The package's declared binaries are already on $PATH: system-provided, + -- don't install a duplicate (covers function-cmd servers like jsonls). + -- Skipped for static_binaries specs, whose binaries_of ignores `pkg`: + -- the probe would be identical to phase 1's. Accepted delta: an external + -- install landing inside the phase-2 window proceeds to a redundant + -- (self-healing) Mason install instead of being caught here. + if pkg ~= nil and not spec.static_binaries and M.find_executable(spec.binaries_of(name, pkg)) ~= nil then + do_configure(name) + return + end + + -- Mason ships it but it isn't available yet: install, configure on completion. + if pkg ~= nil then + start_install(pkg, name) + return + end + + -- No installable package: local config self-validates, else mark missing/unknown. + if spec.has_local_config and spec.has_local_config(name) then + do_configure(name) + elseif pkg_unknown and #spec.binaries_of(name, nil) == 0 then + -- A stale mapping can't be fixed by an install either: drop it from + -- the hand-off set like the unknown_of branch above. + session.pending[name] = nil + collector.mark_unknown(pkg_name == name and name or (pkg_name .. " (for " .. name .. ")")) + else + -- An optional spec hook may supply a concrete reason for an + -- otherwise reason-less miss (e.g. DAP naming recorded mapping + -- drift that made package_of return nil); a hook throw must not + -- break the mark itself. + local reason + if type(spec.missing_reason_of) == "function" then + local hook_ok, hook_reason = pcall(spec.missing_reason_of, name) + reason = hook_ok and str_or_nil(hook_reason) or nil + end + collector.mark(name, reason) + end + end + + local function run() + -- Make Mason's bin dir resolvable before any probe or spawn (see file header). + M.ensure_mason_on_path() + -- ONE normalization policy for every consumer (see split_dep_names): + -- non-table deps degrade to nothing to resolve, and dropped entries + -- (non-string / empty — config mistakes) surface in the unknown bucket + -- instead of vanishing or flowing into module-name concatenation. + local deps, invalid_entries = split_dep_names(spec.deps) + for _, entry in ipairs(invalid_entries) do + collector.mark_unknown(entry == "" and '""' or entry) + end + -- Phase 1: configure everything resolvable without the registry. + local visited = {} + local unresolved = {} + local finalized = false + for _, name in ipairs(deps) do + -- Dedup (a duplicate would double-install); pcall isolates each dep. + if not visited[name] then + visited[name] = true + -- A name the spec knows it can never resolve (e.g. a conform + -- function-form override that yields nothing at probe time) is + -- final here: tailored reason, no phase 2, no registry. + local unresolvable = nil + if spec.unresolvable_of then + local ok, reason = pcall(spec.unresolvable_of, name) + unresolvable = ok and str_or_nil(reason) or nil + end + if unresolvable then + collector.mark(name, unresolvable) + finalized = true + else + local ok, handled = pcall(configure_available, name) + if not ok then + collector.mark(name, error_reason(handled)) + elseif handled ~= true then + unresolved[#unresolved + 1] = name + session.pending[name] = true + end + end + end + end + -- Final phase-1 marks must not wait behind installs or a stalled + -- registry refresh elsewhere in the batch: flush them now (`emitted` + -- dedups the later done()). + if finalized then + collector.done() + end + + -- Expose anything pending to the install hand-off paths — phase-2 + -- leftovers AND parked phase-1 $PATH-branch failures (pending ⊇ + -- unresolved, so this is the single registration point). + if next(session.pending) ~= nil then + sessions[#sessions + 1] = session + end + + -- Nothing left to install: Mason stays unloaded. + if #unresolved == 0 then + collector.done() + return + end + + -- Phase 2: resolve leftovers against the registry (value, nil, or lazy + -- thunk — via session.resolve_registry; phase 1 never touches it, so a + -- fully-provisioned subsystem with no leftovers never loads Mason), + -- refreshing a never-bootstrapped one first. + local registry = session.resolve_registry() + -- The registry is loaded anyway: make sure mid-session installs hand off. + if registry then + M.attach_registry_events(registry) + end + local function finish() + for _, name in ipairs(unresolved) do + local ok, err = pcall(resolve_missing, name, registry) + if not ok then + collector.mark(name, error_reason(err)) + end + end + collector.done() + -- Unknown-name removals don't pass through do_configure: sweep here + -- so a fully-classified session doesn't linger in `sessions`. + drop_session_if_done(session) + end + if registry and type(registry.refresh) == "function" and not registry_bootstrapped(registry) then + -- A stalled refresh() never calls back: arm a REPORTING deadline. It + -- does not cancel a late refresh — finish() still runs on completion, + -- its re-marks absorbed by the entries-table dedup. + local finished = false + local function on_refreshed() + if finished then + return + end + finished = true + finish() + end + vim.defer_fn(function() + if finished then + return + end + for _, name in ipairs(unresolved) do + local reason = validate_reason(name) + -- The generic refresh note is a placeholder: a later concrete + -- failure (or a late unknown classification) may replace it. + collector.mark( + name, + reason or "Mason registry refresh did not complete (cannot classify or auto-install)", + reason == nil + ) + end + collector.done() + end, timeout_ms) + -- pcall guards a synchronous throw; the callback arrives in a fast event context. + local ok = pcall(registry.refresh, function() + if vim.in_fast_event() then + vim.schedule(on_refreshed) + else + on_refreshed() + end + end) + if not ok then + on_refreshed() + end + elseif spec.defer_phase2 then + -- Opt-in consumers (runtime tools, LSP) move the full registry spec + -- decode off the lazy-load trigger's tick; their late configures + -- have their own catch-up paths (enable() attaches open buffers, + -- lint re-runs itself). + vim.schedule(finish) + else + -- DAP configures IN phase 2, and a cmd-triggered lazy-load replays + -- synchronously right after config: finish on this tick. + finish() + end + end + + if spec.defer then + M.ensure_mason_on_path() + vim.schedule(function() + run() + synchronous = false + end) + else + run() + synchronous = false + end +end + +---Discovery-first resolution for a subsystem whose own runtime registrations +---are the ground truth (conform, nvim-lint). `probe(name)` returns: +--- * nil -> unknown name (typo) -> fix config. +--- * { binary = "x" } -> the tool invokes executable "x". +--- * { binary = nil } -> the tool resolves its own command at runtime. +--- * { unresolved = true } -> the name is real but its command can't be +--- verified at probe time (e.g. a per-buffer +--- function override): reported missing with a +--- tailored reason — never a typo, never an install. +--- * { broken = "reason" } -> config exists but errors: surfaced with the +--- reason — never typo guidance, never an install. +---Mason is only the lazy install fallback, reverse-looked-up from the binary. +---@param title string @Notification title identifying the subsystem. +---@param deps string[] @Tool names as the subsystem knows them. +---@param probe fun(name: string): { binary: string|nil, broken: string|nil, unresolved: boolean|nil, reason: string|nil }|nil +---@param configure? fun(name: string, late: boolean) @Optional: run for each available/local tool +--- (e.g. rewrite its command to an absolute path while Mason's bin dir is +--- still off $PATH). +---@param opts? { defer?: boolean } @`defer` moves the whole resolve off the +--- caller's tick (see M.resolve); the $PATH guarantee stays synchronous. +function M.resolve_runtime_tools(title, deps, probe, configure, opts) + local cache = {} + local function info(name) + if cache[name] == nil then + local ok, result = pcall(probe, name) + if ok and type(result) == "table" then + cache[name] = { + known = true, + binary = str_or_nil(result.binary), + broken = str_or_nil(result.broken), + unresolved = result.unresolved == true, + -- Consumer-specific phrasing for the unresolved warning + -- stays in the consumer's probe, not in this shared helper. + reason = str_or_nil(result.reason), + } + else + -- A probe error is treated as unknown: either way, fix the config entry. + cache[name] = { known = false } + end + end + return cache[name] + end + + M.resolve({ + title = title, + deps = deps, + registry = M.default_registry, + package_of = function(name, registry) + local binary = info(name).binary + if not registry or not binary then + return nil + end + return package_for_binary(registry, binary) + end, + binaries_of = function(name) + local binary = info(name).binary + return binary and { binary } or {} + end, + -- binaries_of ignores `pkg` (one static probe name): phase 2 must not + -- repeat phase 1's identical $PATH probe for these specs. + static_binaries = true, + unknown_of = function(name) + return not info(name).known + end, + has_local_config = function(name) + -- A broken config counts as local so configure below surfaces its + -- reason; an unresolved one does NOT (nothing verifiable to trust). + local i = info(name) + return i.known and i.binary == nil and not i.unresolved + end, + unresolvable_of = function(name) + local i = info(name) + if i.unresolved then + return i.reason or "config could not be verified at startup" + end + end, + -- A binary-less runtime tool can't map to a package, so it self-resolves. + local_config_mode = "resolves", + defer_phase2 = true, + defer = type(opts) == "table" and opts.defer == true or nil, + configure = function(name, late) + -- A broken config must never configure: raise its reason verbatim (it + -- may carry the broken file's own "path:line:"). + local reason = info(name).broken + if reason then + M.raise_verbatim(reason) + end + if type(configure) == "function" then + return configure(name, late) + end + end, + }) +end + +-- Manual hand-off entry for installs Mason can't announce (mise/nix/npm); +-- pcall so a re-source of this module can't fail on the existing command. +pcall(vim.api.nvim_create_user_command, "ToolsRetry", function() + M.retry_missing() +end, { desc = "Retry configuring missing tools (pick up installs done outside Mason)" }) + +return M From 8dd985d6af96edc8da7744a70d8641911a112c94 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 24 Jul 2026 00:23:39 +0800 Subject: [PATCH 02/12] feat(conform): resolve formatters discovery-first Resolve formatter_deps through the shared resolver against conform's own registry (deferred off the BufWritePre tick), classifying broken vs unknown vs per-buffer-unverifiable configs honestly. Function-form commands are evaluated at probe time so a node_modules copy passes and the bare-name fallback stays in the install/warn contract. autoformat_allowed groups the save-time gates. Folds later fixes: the superseded-session guard on config re-source, and a lazily-memoized disabled-dir matcher (an eager vim.regex hoist could abort config load on a user-supplied pattern). --- lua/modules/configs/completion/conform.lua | 105 +++++++++++++++++---- 1 file changed, 89 insertions(+), 16 deletions(-) diff --git a/lua/modules/configs/completion/conform.lua b/lua/modules/configs/completion/conform.lua index 657de856..363a6b2e 100644 --- a/lua/modules/configs/completion/conform.lua +++ b/lua/modules/configs/completion/conform.lua @@ -1,6 +1,21 @@ return function() local settings = require("core.settings") local disabled_workspaces = settings.format_disabled_dirs + -- Compile-once cache per configured dir: entries are user input that may + -- throw at vim.regex compile time (settings.lua blesses vim-regex strings), + -- so compilation stays lazy — on the save path, exactly where it fails today — + -- and only SUCCESSFUL compiles are cached. The normalized string rides along + -- for the notify below. + local disabled_dir_cache = {} + local function disabled_matcher(dir) + local hit = disabled_dir_cache[dir] + if not hit then + local normalized = vim.fs.normalize(dir) + hit = { regex = vim.regex(normalized), normalized = normalized } + disabled_dir_cache[dir] = hit + end + return hit + end local format_on_save_enabled = settings.format_on_save local format_notify = settings.format_notify local format_modifications_only = settings.format_modifications_only @@ -26,10 +41,11 @@ return function() local function is_disabled_workspace(bufnr) local filedir = vim.fn.fnamemodify(vim.api.nvim_buf_get_name(bufnr), ":h") for _, dir in ipairs(disabled_workspaces) do - if vim.regex(vim.fs.normalize(dir)):match_str(filedir) ~= nil then + local matcher = disabled_matcher(dir) + if matcher.regex:match_str(filedir) ~= nil then if format_notify then vim.notify( - string.format("[Conform] Formatting disabled for files under [%s].", vim.fs.normalize(dir)), + string.format("[Conform] Formatting disabled for files under [%s].", matcher.normalized), vim.log.levels.WARN, { title = "Conform" } ) @@ -40,6 +56,19 @@ return function() return false end + ---The gates a buffer must pass before an automatic format, grouped into one + ---named predicate for the format_on_save callback's readability. The + ---format_on_save SETTING is not re-checked here: its gate lives at the + ---single place the callback is installed (format_on_save = enabled and …). + ---@param bufnr integer + ---@return boolean + local function autoformat_allowed(bufnr) + return block_list[vim.bo[bufnr].filetype] ~= true + and not is_disabled_workspace(bufnr) + and not vim.g.disable_autoformat + and not vim.b[bufnr].disable_autoformat + end + ---Format only git-modified lines using gitsigns hunks + conform range format ---@param bufnr integer ---@return boolean @true if modifications were formatted @@ -95,6 +124,8 @@ return function() return true end + local tools = require("modules.utils.tools") + require("modules.utils").load_plugin("conform", { default_format_opts = { timeout_ms = format_timeout, @@ -142,22 +173,10 @@ return function() }, }, format_on_save = format_on_save_enabled and function(bufnr) - -- Check disabled filetypes - if block_list[vim.bo[bufnr].filetype] == true then - return - end - - -- Check disabled workspaces - if is_disabled_workspace(bufnr) then - return - end - - -- Check global toggle - if vim.g.disable_autoformat or vim.b[bufnr].disable_autoformat then + if not autoformat_allowed(bufnr) then return end - -- Format only modified lines if enabled if format_modifications_only then if format_modifications(bufnr) then return @@ -169,6 +188,60 @@ return function() end or false, }) + -- Resolve `formatter_deps` (conform formatter names) discovery-first against + -- conform's own registry, so a missing formatter is installed / reported. + -- The probe only drives install/warn — nothing on the save path reads it — + -- so `defer` moves the resolve off the BufWritePre tick that lazy-loaded + -- conform; the resolver itself keeps the same-tick guarantee that Mason's + -- bin dir is on $PATH before the replayed save's spawns. + -- Superseded sessions from a previous run of this consumer must not be retried (re-source guard). + tools.drop_sessions("conform.nvim") + tools.resolve_runtime_tools("conform.nvim", settings.formatter_deps, function(name) + -- get_formatter_config is conform's @private API; if it vanishes, every + -- formatter is UNVERIFIABLE — report unresolved with the reason (missing + -- bucket, immediate flush) instead of silently classifying them all as + -- self-resolving, which would turn off installs and warnings wholesale. + local conform = require("conform") + if type(conform.get_formatter_config) ~= "function" then + return { + unresolved = true, + reason = "conform.get_formatter_config is unavailable (conform API drift?) — formatters cannot be verified", + } + end + -- get_formatter_config runs a function-form override directly, so pcall keeps a + -- throwing override (a broken config) from being misread as an unknown name. + local ok, config, err = pcall(conform.get_formatter_config, name) + if not ok then + return { broken = tostring(config) } + end + if config then + -- A function-form command resolves per buffer at format time (e.g. the + -- builtin from_node_modules): treat it as self-resolving rather than + -- evaluating it for a representative binary — a node_modules command + -- shouldn't map to a Mason install anyway. + if type(config.command) == "function" then + return { binary = nil } + end + return { binary = config.command } + end + -- (nil, err) is a real formatter with a broken config; bare nil is an unknown name. + if type(err) == "string" then + return { broken = err } + end + -- A function-form override may legitimately return nil for the + -- probe-time buffer (this probe runs on a scheduled tick against + -- whatever buffer happens to be current): its existence proves the + -- name real, but nothing is verifiable — report it unresolved + -- (missing bucket, tailored reason) instead of a typo or a silent pass. + local overrides = conform.formatters + if type(overrides) == "table" and type(overrides[name]) == "function" then + -- The reason rides on the probe result: the phrasing is conform's, + -- not the shared resolver's (nvim-lint shares resolve_runtime_tools). + return { unresolved = true, reason = "config resolves per buffer and could not be verified at startup" } + end + return nil + end, nil, { defer = true }) + -- User commands vim.api.nvim_create_user_command("Format", function(args) local range = nil @@ -227,7 +300,7 @@ return function() end end, { nargs = 1, complete = "filetype" }) - -- Auto stop shell LSPs for .env files (migrated from null-ls config). + -- Auto stop shell LSPs for .env files. -- Both bashls and shuck attach to .env's `sh` filetype and only add noise there. vim.api.nvim_create_autocmd("LspAttach", { callback = function(event) From 64110f5bf34220074a76ac8204f726055091db80 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 24 Jul 2026 00:23:39 +0800 Subject: [PATCH 03/12] feat(lsp): resolve language servers discovery-first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite mason-lspconfig around the shared resolver + lsp.lua ordering (setup → user overrides → resolve). Per-filetype deferral is event-driven — no hardcoded eager set: a listed bucket resolves that ft, a dotted variant resolves its base bucket, an unlisted real-file ft drains the rest; enable() attaches by the module's real filetypes so deferring is correctness-safe. The user.configs.lsp override window records/replays per-server ops and closes so a captured proxy can't re-globalize. yamlls/gh_actions_ls claim yaml.github; yamlls prunes the traefik v2/v3 fileMatch conflict by pattern. Folds later rounds: the per-ft deferral state derives from the static bucket keys instead of three lockstep tables; the yamlls brace-prune engine is replaced by a literal basename filter with one shared glob_basename helper (deliberately not vim.fs.basename — its separator handling is platform-dependent); mason.nvim and mason-lspconfig.nvim leave nvim-lspconfig's dependencies so a provisioned session never loads Mason on the BufReadPre tick (a cmd-triggered spec restores :LspInstall/:LspUninstall and owns setup(); the phase-2 mapping fetch is lazy, resolve_batch passes the registry thunk, and an M._mapped_package test hook pins the mapping verdict); ServerInfo's four write-only cache fields demote to locals; resolver state is guarded against config re-source. --- lua/modules/configs/completion/lsp.lua | 34 +- .../completion/mason-lspconfig-setup.lua | 14 + .../configs/completion/mason-lspconfig.lua | 823 ++++++++++++++++-- .../completion/servers/gh_actions_ls.lua | 9 + .../configs/completion/servers/shuck.lua | 18 +- .../configs/completion/servers/yamlls.lua | 176 +++- lua/modules/plugins/completion.lua | 15 +- 7 files changed, 938 insertions(+), 151 deletions(-) create mode 100644 lua/modules/configs/completion/mason-lspconfig-setup.lua create mode 100644 lua/modules/configs/completion/servers/gh_actions_ls.lua diff --git a/lua/modules/configs/completion/lsp.lua b/lua/modules/configs/completion/lsp.lua index 866260b3..bf0fc1b6 100644 --- a/lua/modules/configs/completion/lsp.lua +++ b/lua/modules/configs/completion/lsp.lua @@ -1,30 +1,18 @@ return function() + -- Handler/probe machinery first (no discovery yet): sets up the read + -- trigger the override pass below relies on. require("completion.mason-lspconfig").setup() - local opts = { - capabilities = require("modules.utils").get_lsp_capabilities(), - } - -- Configure LSPs that are not managed by Mason but are available in `nvim-lspconfig`. - -- Servers are defined in `settings.external_lsp_deps` as { server_name = "executable" }. - for lsp_name, exe in pairs(require("core.settings").external_lsp_deps) do - if vim.fn.executable(exe) == 1 then - local ok, _opts = pcall(require, "user.configs.lsp-servers." .. lsp_name) - if not ok then - local default_ok, default_opts = pcall(require, "completion.servers." .. lsp_name) - if default_ok then - _opts = default_opts - end - end - if type(_opts) == "table" then - local final_opts = vim.tbl_deep_extend("keep", _opts, opts) - require("modules.utils").register_server(lsp_name, final_opts) - else - require("modules.utils").register_server(lsp_name, opts) - end - end - end + -- Run `user.configs.lsp` with its vim.lsp.config registrations recorded: a + -- mid-session install registers after this point, and the replay keeps the + -- user's overrides on top regardless of timing. + -- `user.configs.lsp-servers.` remains the richer per-server hook. + require("completion.mason-lspconfig").run_user_lsp_overrides() - pcall(require, "user.configs.lsp") + -- Discovery LAST (Mason-installed / on $PATH / installable / missing, + -- driven by `settings.lsp_deps`): user runtime registrations above must be + -- visible to the unknown/binary classification. + require("completion.mason-lspconfig").resolve_deps() -- Start LSPs pcall(vim.cmd.LspStart) diff --git a/lua/modules/configs/completion/mason-lspconfig-setup.lua b/lua/modules/configs/completion/mason-lspconfig-setup.lua new file mode 100644 index 00000000..4d535beb --- /dev/null +++ b/lua/modules/configs/completion/mason-lspconfig-setup.lua @@ -0,0 +1,14 @@ +return function() + -- Single setup() owner for mason-lspconfig: reached by the spec's cmd trigger + -- (:LspInstall/:LspUninstall) and by any require through lazy.nvim's module + -- loader (the LSP resolver's phase-2 mapping fetch). setup() registers the + -- :LspInstall/:LspUninstall user commands (api/command.lua has no other + -- require site, and the plugin ships no plugin/ dir) and kicks one async + -- registry.refresh — a full registry update when the local cache is stale + -- (>24h), not just alias registration — Mason-flavored paths only. + require("modules.utils").load_plugin("mason-lspconfig", { + ensure_installed = {}, + -- Skip auto enable because we are loading language servers lazily + automatic_enable = false, + }) +end diff --git a/lua/modules/configs/completion/mason-lspconfig.lua b/lua/modules/configs/completion/mason-lspconfig.lua index fa3d2a4e..6b7a140c 100644 --- a/lua/modules/configs/completion/mason-lspconfig.lua +++ b/lua/modules/configs/completion/mason-lspconfig.lua @@ -1,15 +1,212 @@ local M = {} -M.setup = function() - local lsp_deps = require("core.settings").lsp_deps - local mason_registry = require("mason-registry") - local mason_lspconfig = require("mason-lspconfig") - - require("modules.utils").load_plugin("mason-lspconfig", { - ensure_installed = lsp_deps, - -- Skip auto enable because we are loading language servers lazily - automatic_enable = false, +-- vim.lsp.config registrations from `user.configs.lsp` (name -> ordered list +-- of { cfg } merges / { replace = true, cfg } assignments): EVERY configure +-- replays them, since its repo-spec registration would otherwise force-merge +-- over the user's keys — a post-install late configure is merely the latest +-- timing this covers. +local user_lsp_configs = {} + +-- Bridge set by setup(): read-triggered registration (fun(real, name)) so a +-- `user.configs.lsp` read of a not-yet-registered lsp_deps server sees the +-- post-registration view regardless of install timing. +local registration_trigger = nil +-- Bridge set by setup(): drops a server's cached probe when a user op or a +-- registration changes what the truth source resolves for it. +local server_info_invalidate = nil +-- Bridge set by setup(): the discovery pass, run AFTER `user.configs.lsp` so +-- its runtime registrations are visible to the unknown/binary classification. +local resolve_deps = nil +-- Bridge set by setup(): resolves every still-deferred per-filetype batch — +-- the parity sweep's target and a manual escape hatch. +local resolve_remaining = nil +-- User overrides run once per session (see run_user_lsp_overrides' doc): +-- a second call is refused loudly. +local overrides_ran = false +-- lspconfig server name -> Mason package name. Re-fetched while empty: +-- get_mappings() returns {} on a never-bootstrapped registry. +local lspconfig_to_package = nil +local update_clear_attached = false + +---Run `user.configs.lsp` with vim.lsp.config proxied to record per-server +---registrations. Reads forward to the real table — after triggering the dep's +---real registration first, so a read-modify-write override snapshots the same +---base whether or not the server was installed yet. "*" is not recorded (core +---merges it at read time); the real table is always restored right after the +---pcall'd require (same silent-if-missing behavior as before). +--- +---Supported surface for the user module: per-name operations only — +---`vim.lsp.config[name]` reads, `vim.lsp.config(name, cfg)` calls and +---`vim.lsp.config[name] = cfg` assignments (record/replay and the read +---trigger exist for exactly these). Enumeration (pairs over the proxy, +---core-private `_configs`) is out of contract: its visible set depends on +---registration order by nature. +--- +---Once per session: the ops write through to the real table, so a rerun +---could not rebuild from a clean slate; changes need a restart. +function M.run_user_lsp_overrides() + if overrides_ran then + vim.notify( + "user LSP overrides are applied once per session; restart Neovim to apply changes", + vim.log.levels.WARN, + { title = "nvim-lspconfig" } + ) + return + end + overrides_ran = true + local real = vim.lsp.config + -- The proxy may outlive the require below (a user module can stash the + -- reference and read it from a deferred context): every metamethod is + -- gated on this window so a post-restore read can never re-install the + -- proxy as the global, and post-window ops write through without being + -- recorded as overrides. (Deliberate consequence: server_info_invalidate + -- no longer fires for post-window writes through a captured reference — + -- an out-of-contract surface either way.) + local window_open = true + local proxy + proxy = setmetatable({}, { + __index = function(_, key) + if window_open and registration_trigger and type(key) == "string" and key ~= "*" then + -- The handler (and a function-form spec) registers through the + -- GLOBAL vim.lsp.config: it must be the real table while the + -- trigger runs, or the registration would be recorded as user + -- ops / re-enter this proxy. pcall guarantees the restore. + vim.lsp.config = real + pcall(registration_trigger, real, key) + vim.lsp.config = proxy + end + return real[key] + end, + __newindex = function(_, key, value) + real[key] = value + if window_open and type(key) == "string" and key ~= "*" then + -- Assignment replaces the whole config: supersede earlier recordings. + user_lsp_configs[key] = { { replace = true, cfg = value } } + -- The op may have changed what the truth source resolves (cmd + -- included): drop the cached probe. + if server_info_invalidate then + server_info_invalidate(key) + end + end + end, + __call = function(_, name, cfg) + real(name, cfg) + if window_open and type(name) == "string" and name ~= "*" and type(cfg) == "table" then + local list = user_lsp_configs[name] or {} + list[#list + 1] = { cfg = cfg } + user_lsp_configs[name] = list + if server_info_invalidate then + server_info_invalidate(name) + end + end + end, }) + vim.lsp.config = proxy + pcall(require, "user.configs.lsp") + vim.lsp.config = real + window_open = false +end + +---Test hook (anti-rot): recorded user-op count for a server — the override +---window's observable. No runtime reader. +---@param name string +---@return integer +function M._recorded_ops_count(name) + return #(user_lsp_configs[name] or {}) +end + +-- Test hook (anti-rot): read by the phase-2 invariant pin. Reads the mapping +-- cache directly — package_of's self_resolving guard returns nil for any +-- already-configured function-cmd server (jsonls post-configure), which would +-- blind the pin in the healthy state. +M._mapped_package = function(name) + return lspconfig_to_package and lspconfig_to_package[name] or nil +end + +---The discovery pass, split out of setup(): lsp.lua runs it AFTER +---`user.configs.lsp`, so runtime registrations from the user module exist +---before the unknown/binary classification judges the deps. No-op until +---setup() has installed the pass. +function M.resolve_deps() + if resolve_deps then + resolve_deps() + else + -- Fail loudly, matching run_user_lsp_overrides' convention: a + -- pre-setup call means every lsp_deps entry would silently stay + -- unconfigured (pcall(LspStart) masks the aftermath). + vim.notify( + "mason-lspconfig setup() has not run; lsp_deps were not resolved", + vim.log.levels.WARN, + { title = "nvim-lspconfig" } + ) + end +end + +---Resolve every per-filetype batch still deferred. Manual escape hatch and +---the harness hook (the in-file parity sweep calls the LOCAL directly). +function M.resolve_remaining() + if resolve_remaining then + resolve_remaining() + else + vim.notify( + "mason-lspconfig setup() has not run; nothing to resolve", + vim.log.levels.WARN, + { title = "nvim-lspconfig" } + ) + end +end + +M.setup = function() + local settings = require("core.settings") + local tools = require("modules.utils.tools") + + -- lsp_deps as a set: the read trigger below only acts on names this config + -- actually manages. + local deps_set = {} + -- Parenthesized: split_dep_names' second return must not reach ipairs. + for _, name in ipairs((tools.split_dep_names(settings.lsp_deps))) do + deps_set[name] = true + end + -- Servers whose registration already ran (configure() or a read trigger): + -- configure() skips re-registration, so a registration triggered before the + -- user's ops can never be re-asserted over them later. + local registered = {} + + ---Ordered server-spec modules for a server: user override, then repo default. + ---@param name string + ---@return string[] + local function server_modules(name) + return { "user.configs.lsp-servers." .. name, "completion.servers." .. name } + end + + ---cmd → probeable binary: a table cmd's argv[0], nil otherwise (a function + ---cmd resolves its own launch). The ONE extraction rule for every + ---classification site in server_info/unknown_of below. + ---@param cmd any + ---@return string|nil + local function binary_of_cmd(cmd) + return type(cmd) == "table" and cmd[1] or nil + end + + ---THE guarded truth-source read (vim.lsp.config resolves '*', rtp lsp/ + ---files and stored registrations exactly the way enable() consumes them): + ---one implementation for every consulting site. Deliberately NOT memoized — + ---unknown_of re-consults after a cached negative and server_info rebuilds + ---after invalidation, both of which need fresh reads. Per-purpose field + ---checks stay at the call sites. + ---@param name string + ---@return table|nil + local function resolved_config(name) + local ok, config = pcall(function() + return vim.lsp.config[name] + end) + return (ok and type(config) == "table") and config or nil + end + + -- Late parity sweep: every lsp_deps entry is classified at most this long + -- after resolve_deps even if its filetype never opens (missing-tool + -- warnings and installs still happen once per session, off any hot path). + local SWEEP_DELAY_MS = tools.SWEEP_DELAY_MS -- one source; divergence rationale lives there vim.diagnostic.config({ signs = true, @@ -21,96 +218,556 @@ M.setup = function() local opts = { capabilities = require("modules.utils").get_lsp_capabilities(), } - ---A handler to setup all servers defined under `completion/servers/*.lua` + + ---Probe and cache a server's spec once. `binary` = first table-cmd entry in + ---precedence order (user, repo, lspconfig); nil for a function/absent cmd. + ---Post-registration the resolved config's cmd wins unconditionally (a + ---registered cmd is what enable() will spawn). + local server_info_cache = {} + ---@class mason_lspconfig.ServerInfo + ---@field has_module boolean + ---@field binary string|nil + ---@field known_lspconfig boolean + ---@field self_resolving boolean|nil @Resolved cmd is a function owned by the config: no Mason classification. + ---@field spec any @Winning local spec (user precedence, else repo default). + ---@field merge_base table|nil @Repo default table under a user TABLE override (merge-under policy). + ---@field broken_reason string|nil + ---@param name string + ---@return mason_lspconfig.ServerInfo + local function server_info(name) + local cached = server_info_cache[name] + if cached then + return cached + end + local info = { + has_module = false, + binary = nil, + known_lspconfig = false, + broken_reason = nil, + } + local modules = server_modules(name) + -- A spec that exists but throws at load is a broken config, not a typo: + -- `exists` keeps it out of the unknown bucket, and the reason makes + -- mason_lsp_handler refuse to fall through past it. + local user_ok, user_spec, user_exists, user_reason = tools.load_module_or_report(modules[1], "nvim-lspconfig") + if user_ok then + info.has_module = true + if type(user_spec) == "table" then + info.binary = binary_of_cmd(user_spec.cmd) + end + elseif user_exists then + info.has_module = true + info.broken_reason = user_reason + or string.format("failed to load `%s` (see the earlier error notification)", modules[1]) + end + -- Load the repo preset only when usable (no override, or as merge base under a + -- table override): a function-form override replaces it wholesale. + -- POLICY (unified since the discovery-first branch, including the formerly + -- "external" servers nil_ls/nixd/shuck): a TABLE override MERGES over the + -- repo preset ("write what you change"); full replacement is expressed as + -- a function-form override. + local default_loaded, default_spec = false, nil + if not user_ok or type(user_spec) == "table" then + local ok, spec, exists, reason = tools.load_module_or_report(modules[2], "nvim-lspconfig") + if ok then + info.has_module = true + default_loaded = true + default_spec = spec + if info.binary == nil and type(spec) == "table" then + info.binary = binary_of_cmd(spec.cmd) + end + elseif exists then + info.has_module = true + -- Under a valid user TABLE override a broken preset is merely the + -- optional merge base: degrade to {} (already notified once by + -- load_module_or_report) instead of disabling the server. + if not user_ok and info.broken_reason == nil then + info.broken_reason = reason + or string.format("failed to load `%s` (see the earlier error notification)", modules[2]) + end + end + end + -- Precedence, decided ONCE here (the handler consumes these fields + -- instead of re-deriving it): user wins; a user TABLE override merges + -- over the repo default (merge_base), any other user shape replaces it. + if user_ok then + info.spec = user_spec + if type(user_spec) == "table" and type(default_spec) == "table" then + info.merge_base = default_spec + end + elseif default_loaded then + info.spec = default_spec + end + ---The truth source: vim.lsp.config[name] resolves '*', rtp lsp/.lua + ---files and stored registrations exactly the way enable() will consume + ---them; nil = no name-specific source at all (a pure '*' config cannot + ---make a name known). The per-name rtp rescan only happens for names + ---this cache hasn't answered yet — bounded by lsp_deps. + -- (Reads go through the shared resolved_config above.) + ---Whether the recorded user ops explicitly set a cmd: the replay + ---guarantees that cmd is the enable-time winner. + local user_sets_cmd = false + for _, entry in ipairs(user_lsp_configs[name] or {}) do + if type(entry.cfg) == "table" and entry.cfg.cmd ~= nil then + user_sets_cmd = true + end + end + if registered[name] or user_sets_cmd then + -- Registered (or user-overridden cmd): the resolved config IS what + -- enable() will spawn — it outranks the module-derived binary. A + -- non-table cmd (function) means the config owns its own launch: + -- flag it so Mason never classifies/installs against it. + local config = resolved_config(name) + if config and config.cmd ~= nil then + info.known_lspconfig = true + info.binary = binary_of_cmd(config.cmd) + if type(config.cmd) ~= "table" then + info.self_resolving = true + end + end + elseif info.binary == nil or not info.has_module then + -- Pre-registration the module spec is the better predictor of the + -- upcoming stored registration (an lspconfig rtp default must not + -- shadow a module's custom path); consult the truth source only for + -- what the modules couldn't answer. Any cmd (even a function) + -- proves the name real (keeps jsonls out of the unknown bucket). + local config = resolved_config(name) + if config and config.cmd ~= nil then + info.known_lspconfig = true + if info.binary == nil then + info.binary = binary_of_cmd(config.cmd) + end + end + end + server_info_cache[name] = info + return info + end + server_info_invalidate = function(name) + server_info_cache[name] = nil + end + + ---Register (not enable) a server's config, reusing the spec server_info() + ---loaded; raises on a broken/misshapen spec so the resolver aggregates the + ---reason. vim.lsp.enable() runs later in configure(). ---@param lsp_name string local function mason_lsp_handler(lsp_name) - -- rust_analyzer is configured using mrcjkb/rustaceanvim - -- warn users if they have set it up manually - if lsp_name == "rust_analyzer" then - local config_exist = pcall(require, "completion.servers." .. lsp_name) - if config_exist then - vim.notify( - [[ -`rust_analyzer` is configured independently via `mrcjkb/rustaceanvim`. To get rid of this warning, -please REMOVE your LSP configuration (rust_analyzer.lua) from the `servers` directory and configure -`rust_analyzer` using the appropriate init options provided by `rustaceanvim` instead.]], - vim.log.levels.WARN, - { title = "nvim-lspconfig" } - ) + local info = server_info(lsp_name) + -- No-fall-through contract, enforced by the ONE shared implementation + -- (tools.usable_or_raise): a broken or wrong-shaped config must never + -- read as success — that would suppress both the warning and the + -- install fallback. Precedence was decided by server_info + -- (info.spec / info.merge_base). + local spec = tools.usable_or_raise(info.spec, info.broken_reason, { + label = "server config", + expected = "a fun(opts) or a table", + shapes = { ["function"] = true, table = true }, + }) + if spec == nil then + -- Default to use factory config for server(s) that doesn't include a spec + vim.lsp.config(lsp_name, opts) + elseif type(spec) == "function" then + -- Server owns its setup; it must call vim.lsp.config() itself (see + -- clangd.lua for an example). + spec(opts) + else + vim.lsp.config(lsp_name, vim.tbl_deep_extend("force", opts, info.merge_base or {}, spec)) + end + end + + local function package_of(name) + -- A registered/user function cmd owns its own launch: never classify + -- it against a Mason package (no install fallback for it). + if server_info(name).self_resolving then + return nil + end + if lspconfig_to_package == nil or next(lspconfig_to_package) == nil then + -- Lazy fetch: requiring mason-lspconfig here (absent/broken module + -- degrades to $PATH-only classification — empty map → nil → the + -- plain missing/unknown report, not a raw Lua error into the + -- resolver's error-mark) goes through lazy.nvim's module loader, + -- which loads mason-core (and thus mason.setup) first, then runs + -- the spec config (mason-lspconfig-setup) exactly once. The empty + -- map keeps the re-fetch-while-empty semantics: a persistently + -- drifted call re-degrades per lookup instead of freezing a bad + -- shape. + -- TRUNCATION WARNING: pcall(...) as a non-final operand of and/or + -- is adjusted to ONE value — keep this a two-statement fetch, not + -- `ok_mod and pcall(f) or false, nil` (that assigns mappings = nil + -- ALWAYS and permanently freezes the cache at {}). + local ok_mod, mlsp = pcall(require, "mason-lspconfig") + local ok, mappings = false, nil + if ok_mod then + ok, mappings = pcall(mlsp.get_mappings) + end + lspconfig_to_package = (ok and type(mappings) == "table" and type(mappings.lspconfig_to_package) == "table") + and mappings.lspconfig_to_package + or {} + -- The mapping derives from the registry specs: a registry update + -- must not leave the first non-empty snapshot frozen. SYNCHRONOUS + -- clear on purpose — the emit-order rationale lives at tools.lua's + -- update:success handler (the one place that fact is argued). + -- Attached lazily, right here: the cache this guards cannot be + -- non-empty before this branch runs (it fills three lines up). + -- Runs on FAILED fetches too — default_registry() may then load + -- mason.nvim itself; this branch is phase-2/event-only, where + -- that load is permitted. Once attached, never re-tried; pcall + -- guards mason-registry API drift. If the registry never loads + -- through this file, the cache stays nil/{} and there is nothing + -- to invalidate (re-fetch-while-empty covers it). + if not update_clear_attached then + local mason_registry = tools.default_registry() + if mason_registry and type(mason_registry.on) == "function" then + update_clear_attached = pcall(function() + mason_registry:on("update:success", function() + lspconfig_to_package = nil + end) + end) + end end - return end + return lspconfig_to_package[name] + end - local ok, custom_handler = pcall(require, "user.configs.lsp-servers." .. lsp_name) - local default_ok, default_handler = pcall(require, "completion.servers." .. lsp_name) - -- Use preset if there is no user definition - if not ok then - ok, custom_handler = default_ok, default_handler + ---Use a manual/built-in spec as fallback only when its binary can't be probed + ---statically (function/absent `cmd`); with a known binary the $PATH check decides. + ---@param name string + ---@return boolean + local function has_local_config(name) + local info = server_info(name) + return info.binary == nil and (info.has_module or info.known_lspconfig) + end + + ---Typo/outdated name vs valid-but-uninstalled: a Mason mapping, a repo/user server + ---module, or a built-in lspconfig config all mean the name is real. + ---@param name string + ---@return boolean + local function unknown_of(name) + if package_of(name) then + return false end + local info = server_info(name) + if info.has_module or info.known_lspconfig then + return false + end + -- A runtime registration may have landed after the probe cached its + -- negative (the user read the name first, then wrote a cmd): re-consult + -- the truth source before the typo verdict, upgrading the cache in place. + local config = resolved_config(name) + if config and config.cmd ~= nil then + info.known_lspconfig = true + if info.binary == nil then + info.binary = binary_of_cmd(config.cmd) + end + return false + end + return true + end - if not ok then - -- Default to use factory config for server(s) that doesn't include a spec - require("modules.utils").register_server(lsp_name, opts) - elseif type(custom_handler) == "function" then - -- Case where language server requires its own setup - -- Be sure to call `vim.lsp.config()` within the setup function. - -- Refer to |vim.lsp.config()| for documentation. - -- For an example, see `clangd.lua`. - custom_handler(opts) - vim.lsp.enable(lsp_name) - elseif type(custom_handler) == "table" then - require("modules.utils").register_server( - lsp_name, - vim.tbl_deep_extend( - "force", - opts, - type(default_handler) == "table" and default_handler or {}, - custom_handler - ) - ) + ---Register a server (unless a read trigger already did), then enable it (a + ---bare Mason `cmd` spawns fine: the resolver put Mason's bin dir on $PATH). + local function configure(name) + if not registered[name] then + mason_lsp_handler(name) + registered[name] = true + -- The probe cached pre-registration state: rebuild so later readers + -- (group-1 retry/event matchers) see the resolved cmd. + server_info_cache[name] = nil + end + -- Replay recorded `user.configs.lsp` registrations on top: discovery + -- runs AFTER the user module (lsp.lua orders setup → overrides → + -- resolve), so the repo-spec registration above just force-merged over + -- any write-only override's keys — this replay restores them on every + -- configure. Only the read-triggered path (registration before + -- recording) makes it a natural no-op. + for _, entry in ipairs(user_lsp_configs[name] or {}) do + if entry.replace then + vim.lsp.config[name] = entry.cfg + else + vim.lsp.config(name, entry.cfg) + end + end + vim.lsp.enable(name) + end + + -- Read-triggered registration (transactional). Any spec form is covered by + -- simply running the real registration: the function-form contract is + -- "only registers via vim.lsp.config()" (see clangd.lua), so running it + -- early is registration, not behavior. A failing registration is rolled + -- back so the read never leaks a partial config; `registered` stays false + -- and the resolver's configure() path reports the raise as usual. + registration_trigger = function(real, name) + if not deps_set[name] or registered[name] then + return + end + -- Core keeps stored registrations in `vim.lsp.config._configs`; without + -- it (API drift) the trigger degrades to the status-quo read — no + -- half-transaction. + local store = rawget(real, "_configs") + if type(store) ~= "table" then + return + end + -- vim.deepcopy raises on userdata values; without a snapshot there is + -- no rollback guarantee, so degrade to the status-quo read exactly + -- like the _configs drift guard above. (Hardening: the sole caller + -- already pcalls the trigger, but the swallow made the abort silent.) + local copy_ok, before = pcall(vim.deepcopy, store[name]) + if not copy_ok then + return + end + if pcall(mason_lsp_handler, name) then + registered[name] = true + -- The handler's own server_info ran pre-registration (a function-form + -- spec registers its cmd mid-trigger, bypassing the proxy hooks): + -- rebuild so the probe reflects the resolved, registered cmd. + server_info_cache[name] = nil else - vim.notify( - string.format( - "Failed to setup [%s].\n\nServer definition under `completion/servers` must return\neither a fun(opts) or a table (got '%s' instead)", - lsp_name, - type(custom_handler) - ), - vim.log.levels.ERROR, - { title = "nvim-lspconfig" } - ) - end - end - - ---A simplified mimic of 's `setup_handlers` callback. - ---Invoked for each Mason package (name or `Package` object) to configure its language server. - ---@param pkg string|{name: string} Either the package name (string) or a Package object - local function setup_lsp_for_package(pkg) - -- First try to grab the builtin mappings - local mappings = mason_lspconfig.get_mappings().package_to_lspconfig - -- If empty or nil, build it by hand - if not mappings or vim.tbl_isempty(mappings) then - mappings = {} - for _, spec in ipairs(mason_registry.get_all_package_specs()) do - local lspconfig = vim.tbl_get(spec, "neovim", "lspconfig") - if lspconfig then - mappings[spec.name] = lspconfig + store[name] = before + end + end + + ---One resolve pass over a batch of deps. The collector aggregates by + ---title across batches; each batch gets its own session/pending record + ---(retry_pending walks all of them). + ---@param deps any[] + local function resolve_batch(deps) + tools.resolve({ + title = "LSP", + deps = deps, + -- A thunk: phase 1 never touches it; phase 2 resolves it via + -- session.resolve_registry only once leftovers exist. + registry = tools.default_registry, + package_of = package_of, + binaries_of = function(name, pkg) + local info = server_info(name) + if info.binary then + return { info.binary } + end + -- A registered/user function cmd resolves its own launch: probing + -- Mason bins would misread it as installable/missing. + if info.self_resolving then + return {} end + -- Function-cmd MODULE server (jsonls): probe the package's declared + -- bins so a system copy is found instead of installing a duplicate. + if pkg ~= nil then + return tools.package_binaries(pkg, name) + end + return {} + end, + unknown_of = unknown_of, + has_local_config = has_local_config, + configure = configure, + -- Phase 2 (registry classification: full mappings decode + package + -- hydration) moves off the BufReadPre tick. Safe because a late + -- configure's vim.lsp.enable() attaches already-open matching + -- buffers (documented semantics — :h vim.lsp.enable()), so the one + -- same-tick beneficiary — a Mason-installed server whose binary + -- name differs from the probe — still reaches the triggering + -- buffer. + defer_phase2 = true, + }) + end + + -- Per-filetype deferral state, (re)built by each resolve_deps run. One static + -- shape plus one grow-only set — every other question is derived, so there is + -- no lockstep to drift (the old third table, bucketed_fts, existed only + -- because consuming a bucket destroyed the "was ever bucketed" fact; a static + -- map's keys keep it by construction): + -- bucket_by_ft ft -> the GENERATION-STATIC name list deferred under it + -- (never mutated after the build; keys double as the + -- re-fire/N4 tombstone). + -- handed_off name -> already handed to the resolver (grow-only; a server + -- listed under several filetypes — gopls: go/gomod/ + -- gowork/gotmpl — resolves ONCE). + -- undeferred countdown of UNIQUE deferred names not yet handed off; 0 is + -- the augroup-teardown gate (name-accurate, unlike the + -- old bucket-emptiness gate — see the teardown note). + -- A resolve_batch throw can leave the augroup inert: thrown on a later + -- FileType fire, the (already scheduled) sweep still tears it down; thrown + -- during the initial buffer replay it can linger until the next + -- resolve_deps' clear=true. Benign either way — the callback no-ops at the + -- undeferred guard. + local bucket_by_ft = {} + local handed_off = {} + local undeferred = 0 + local perft_group = nil + + ---Hand a filetype's still-unresolved names to the resolver. No-op (empty + ---batch) for a consumed or unknown ft — the caller never needs to know which. + ---@param ft string + local function resolve_ft(ft) + local bucket = bucket_by_ft[ft] + if not bucket then + return + end + local batch = {} + for _, name in ipairs(bucket) do + if not handed_off[name] then + handed_off[name] = true + undeferred = undeferred - 1 + batch[#batch + 1] = name end end + if #batch > 0 then + resolve_batch(batch) + end + -- Teardown when no NAME remains unresolved: once every deferred server is + -- handed off, later FileType fires have nothing to do even for buckets + -- whose own ft never fired (their members resolved via sibling fts). + if undeferred <= 0 and perft_group then + pcall(vim.api.nvim_del_augroup_by_id, perft_group) + perft_group = nil + end + end - -- Figure out the package name and lookup - local name = type(pkg) == "string" and pkg or pkg.name - local srv = mappings[name] - if not srv then - return + resolve_remaining = function() + -- Drain through resolve_ft — the ONE copy of the hand-off invariant + -- (handed_off gate, undeferred countdown, augroup teardown at zero). + -- Per-ft batches are the designed-for shape: the collector aggregates + -- one warning per title across batches, and retry_pending walks every + -- session. Mid-drain throw: marking-before-batch ordering is preserved, + -- so a throwing batch still leaves OTHER fts deferred with a live + -- FileType recovery path instead of draining them into the throwing + -- batch — their buckets are static and their unhanded names still + -- route; the next resolve_deps' clear = true recreates the group. + for _, ft in ipairs(vim.tbl_keys(bucket_by_ft)) do + resolve_ft(ft) end + end - -- Invoke the handler - mason_lsp_handler(srv) + ---Route a filetype event to the right deferred resolution. The bucket only + ---controls WHEN a server loads (enable() attaches by the module's real + ---filetypes), so this only affects load timing, never attach correctness. + ---@param buf integer + ---@param ft string + local function on_filetype(buf, ft) + if ft == "" or undeferred <= 0 then + return + end + if bucket_by_ft[ft] then + -- 1. listed ft: targeted resolve; a re-fired ALREADY-consumed listed ft + -- takes this same path and no-ops on the empty batch — the static key + -- IS the old bucketed_fts tombstone (reopening a resolved `.lua`/`.go` + -- must never fall through and drain the still-deferred yaml servers). + resolve_ft(ft) + return + end + local base = ft:match("^([^.]+)%.") + if base and bucket_by_ft[base] then + -- 2. dotted variant (yaml.github → yaml): resolve the base bucket; a + -- consumed base no-ops the same way (the old N4 branch). ASSUMPTION + -- unchanged from A2: the server owning a dotted variant is in its + -- base's bucket (true for every current dep); a future foo.bar-without- + -- foo server waits for the sweep — bounded, still resolves. + resolve_ft(base) + return + end + -- 3. An unlisted REAL-file filetype: a repo module may declare it beyond + -- lspconfig's defaults (shuck→ksh, harper_ls→text) and we cannot know which + -- without loading, so drain the rest now — the 120s sweep, triggered early. + -- buftype guard keeps help/quickfix/dashboard/terminal/prompt buffers from + -- tripping it; a real file whose ft has no server just pays one early drain + -- (identical work the sweep would do), one-shot. Residual, bounded and + -- one-shot (never worse than the pre-refactor eager load): an unlisted + -- real-file ft (a module-added plain ft like text/ksh, or a serverless ft + -- like csv) drains all remaining deferred servers early. + if vim.bo[buf].buftype == "" then + resolve_remaining() + end end - for _, pkg in ipairs(mason_registry.get_installed_package_names()) do - setup_lsp_for_package(pkg) + -- The discovery pass itself runs via M.resolve_deps() AFTER + -- run_user_lsp_overrides() (see lsp.lua): user runtime registrations must + -- exist before the unknown/binary classification. It partitions the RAW + -- dep list: invalid entries stay in the immediate batch verbatim (the + -- resolver's own re-scan reports them); user-overridden names (either + -- hook) keep today's load-tick semantics; only names whose filetypes come + -- from NON-user sources (rtp lsp/ files, default registrations) defer to + -- their filetype's first buffer, resolved by on_filetype, with the sweep as + -- the parity backstop. + resolve_deps = function() + -- Superseded sessions from a previous run of this consumer must not be retried (re-source guard). + tools.drop_sessions("LSP") + local immediate = {} + bucket_by_ft, handed_off, undeferred = {}, {}, 0 + -- Re-source/generation token for the sweep timer, same pattern as + -- nvim-lint's parity sweep (vim.g survives both re-source flavors): a + -- stale timer from a previous run — or a previous module instance — + -- must not drain rebuilt buckets at the wrong deadline. + local gen = (vim.g._masonlsp_resolve_gen or 0) + 1 + vim.g._masonlsp_resolve_gen = gen + -- ONE container-shape policy, shared with deps_set at setup top + -- (split_dep_names: bare string = singleton dep) — a divergent + -- drop-whole here let a string lsp_deps register via the read trigger + -- yet never resolve, enable, or warn. + local valid, invalid = tools.split_dep_names(settings.lsp_deps) + local seen = {} + for _, entry in ipairs(valid) do + local fts = nil + local user_hooked = tools.module_path(server_modules(entry)[1]) ~= nil + or (user_lsp_configs[entry] and #user_lsp_configs[entry] > 0) + if not user_hooked then + local config = resolved_config(entry) + if config and type(config.filetypes) == "table" then + fts = config.filetypes + end + end + local placed = false + if fts then + for _, ft in ipairs(fts) do + if type(ft) == "string" and ft ~= "" then + local bucket = bucket_by_ft[ft] or {} + bucket[#bucket + 1] = entry + bucket_by_ft[ft] = bucket + if not seen[entry] then + seen[entry] = true + undeferred = undeferred + 1 + end + placed = true + end + end + end + if not placed then + immediate[#immediate + 1] = entry + end + end + -- Entries split_dep_names drops (non-string / empty) are config + -- mistakes: forward them raw so the resolver's own re-scan reports + -- them in the unknown bucket — same pattern as nvim-lint. + vim.list_extend(immediate, invalid) + + -- Immediate batch first: same-tick semantics identical to the old + -- whole-list resolve for everything that must not defer. + if #immediate > 0 then + resolve_batch(immediate) + end + + if undeferred > 0 then + perft_group = vim.api.nvim_create_augroup("MasonLspPerFtResolve", { clear = true }) + vim.api.nvim_create_autocmd("FileType", { + group = perft_group, + pattern = "*", + callback = function(args) + on_filetype(args.buf, args.match) + end, + desc = "lsp: resolve this filetype's language servers", + }) + -- Already-loaded buffers (including the one whose BufReadPre + -- triggered this whole config, if its FileType already fired): + -- resolve their filetypes on this same tick. + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_loaded(buf) then + on_filetype(buf, vim.bo[buf].filetype) + end + end + if undeferred > 0 then + vim.defer_fn(function() + if vim.g._masonlsp_resolve_gen == gen then + resolve_remaining() + end + end, SWEEP_DELAY_MS) + end + end end end diff --git a/lua/modules/configs/completion/servers/gh_actions_ls.lua b/lua/modules/configs/completion/servers/gh_actions_ls.lua new file mode 100644 index 00000000..f7735069 --- /dev/null +++ b/lua/modules/configs/completion/servers/gh_actions_ls.lua @@ -0,0 +1,9 @@ +-- gh-actions-language-server: workflow-only by design (upstream root_dir gates +-- attach to .github/.forgejo/.gitea workflow dirs). Core attaches by EXACT +-- filetype match, so the dotted filetype from ftdetect/github.lua must be +-- claimed explicitly; plain `yaml` stays so the forgejo/gitea workflow dirs — +-- which the ftdetect pattern does not re-type — keep attaching as upstream +-- intends. +return { + filetypes = { "yaml", "yaml.github" }, +} diff --git a/lua/modules/configs/completion/servers/shuck.lua b/lua/modules/configs/completion/servers/shuck.lua index 95319fbe..e000c4de 100644 --- a/lua/modules/configs/completion/servers/shuck.lua +++ b/lua/modules/configs/completion/servers/shuck.lua @@ -1,17 +1,11 @@ -- shuck: Rust shell linter/formatter/language server. -- https://ewhauser.github.io/shuck/docs/lsp/ --- --- Installed via mise (`cargo:shuck-cli`), not Mason, so it is wired through --- `settings.external_lsp_deps` rather than `lsp_deps`. shuck is not shipped in --- nvim-lspconfig either, so cmd/filetypes/root_markers must be declared here. --- --- Provides live diagnostics, code actions (incl. `source.fixAll.shuck`), --- suppression-code hover, and document/range formatting over LSP. --- --- `zsh` is intentionally excluded: shuck's zsh dialect still misparses some --- zsh-isms (e.g. path literals inside `[(I)...]` subscripts) and emits false --- positives, so zsh files are left to `zsh -n` (nvim-lint). Re-add "zsh" here --- once shuck's zsh support is solid. +-- Installed via mise (`cargo:shuck-cli`) by choice — Mason ships a shuck +-- package now, but under discovery-first the $PATH copy wins, so the +-- resolver enables it from the `shuck` binary (fix = `mise install`). +-- `zsh` is intentionally excluded: shuck's zsh dialect (as of v0.0.4x, +-- 2026-07) still misparses some zsh-isms and emits false positives, so zsh +-- stays on `zsh -n` (nvim-lint). return { cmd = { "shuck", "server" }, filetypes = { "sh", "bash", "ksh" }, diff --git a/lua/modules/configs/completion/servers/yamlls.lua b/lua/modules/configs/completion/servers/yamlls.lua index f8052abf..1a06df14 100644 --- a/lua/modules/configs/completion/servers/yamlls.lua +++ b/lua/modules/configs/completion/servers/yamlls.lua @@ -1,7 +1,151 @@ -- https://github.com/neovim/nvim-lspconfig/blob/master/lsp/yamlls.lua +-- Our own Traefik v3 schema URL and literal claimed files, referenced by the +-- extra below and by the traefik-claim cleanup after it. +local traefik_v3_url = "https://www.schemastore.org/traefik-v3.json" +local traefik_v3_files = { "traefik.yml", "traefik.yaml" } +local schemas = require("schemastore").yaml.schemas({ + extra = { + { + name = "azure-pipelines", + description = "azure-pipelines YAML schema", + fileMatch = { "azure-pipelines.{yml,yaml}" }, + url = "https://raw.githubusercontent.com/microsoft/azure-pipelines-vscode/master/service-schema.json", + }, + { + name = "gh-dash config", + description = "gh-dash config YAML schema", + fileMatch = "*/gh-dash/config.{yml,yaml}", + -- The DOCUMENTED canonical form (docs + astro site config use the + -- bare host; the www alias is just today's hosting redirect target). + url = "https://gh-dash.dev/schema.json", + }, + -- The catalog's own "Traefik v3" entry carries no fileMatch, so this + -- extra is what actually claims the static-config files for v3. + { + name = "Traefik v3", + description = "Traefik v3 static configuration", + fileMatch = traefik_v3_files, + url = traefik_v3_url, + }, + }, +}) + +-- The files our v3 extra claims — LITERALS by contract (asserted), so the +-- prune below is exact-basename matching, no glob engine. Derived from the +-- extra itself (no mirror of catalog naming); matched on the file pattern, +-- not v2's name/URL, so a catalog rename can't let v2 re-claim them +-- (schemastore's replace/ignore match by name and can't give that). +local claimed = {} +for _, file in ipairs(traefik_v3_files) do + -- A future edit that sneaks glob magic back in must fail loudly: the + -- literal contract is what licenses basename equality as the whole test. + assert(not file:find("[%*%?%[{]"), "traefik_v3_files must be literal filenames: " .. file) + claimed[file] = true +end +-- Stems ("traefik"), for the visibility WARN below — derived, not mirrored. +local claimed_stems = {} +for file in pairs(claimed) do + local stem = file:match("^[^.]+") + assert(stem and stem ~= "", "claimed file yields no stem: " .. file) + claimed_stems[stem:lower()] = true +end + +---Basename of a glob, both slash kinds split uniformly on EVERY platform — +---deliberately NOT vim.fs.basename: it splits only "/" as a separator and +---normalizes backslashes only on Windows (probe-verified here: +---vim.fs.basename("a\\b.yml") == "a\\b.yml" on Darwin), which would silently +---change prune verdicts for backslash-carrying globs on mac/linux (schema +---selection is correctness). A separator-terminated glob ("dir/") falls back +---to the whole string. +---@param glob string +---@return string +local function glob_basename(glob) + return glob:match("([^/\\]+)$") or glob +end + +---A glob conflicts exactly when its basename IS a claimed literal: v2's +---`traefik.yml`/`traefik.yaml` and `**/`-prefixed forms do; traefik-NAMED +---globs (.traefik.yml, traefik-dynamic.*) do not and keep their schemas. +---Non-string catalog drift keeps its silent degrade (kept, never thrown on). +---@param glob any +---@return boolean +local function check_glob(glob) + if type(glob) ~= "string" then + return false + end + local base = glob_basename(glob) + return claimed[base] == true +end + +-- Strip exactly the claimed files from every OTHER schema (the catalog's v2 +-- entry). Keys snapshotted so `schemas` isn't mutated mid-pairs. Brace globs +-- whose basename mentions a claimed stem are past this literal matcher: +-- KEPT (fail-open) and WARNED for review — see the disposition note below. +local kept_unjudged = {} +local function note_if_unjudged(glob) + if type(glob) == "string" and glob:find("[{}]") then + -- De-brace the basename before the stem test: a brace can split the + -- stem ("trae{fik,x}.yml"), which a contiguous find would miss + -- (round-1 gate finding — the sentinel must see through alternation). + local base = glob_basename(glob):lower():gsub("[{},]", "") + for stem in pairs(claimed_stems) do + if base:find(stem, 1, true) then + kept_unjudged[#kept_unjudged + 1] = glob + return + end + end + end +end +for _, url in ipairs(vim.tbl_keys(schemas)) do + local fileMatch = schemas[url] + if url ~= traefik_v3_url then + if type(fileMatch) == "string" then + if check_glob(fileMatch) then + schemas[url] = nil + else + note_if_unjudged(fileMatch) + end + elseif type(fileMatch) == "table" then + local kept = {} + for _, glob in ipairs(fileMatch) do + if not check_glob(glob) then + kept[#kept + 1] = glob + note_if_unjudged(glob) + end + end + schemas[url] = #kept > 0 and kept or nil + end + end +end +-- Visibility sentinel (disposition REVISED from the round-3 engine: it +-- DROPPED these fail-closed; we KEEP them and warn). Rationale: the trigger +-- set is empty in the entire installed catalog (braces exist only in our own +-- extras), a wrong drop silently loses a wanted schema, while a wrong keep +-- surfaces as yaml-ls's own multi-schema conflict PLUS this WARN. Scheduled: +-- this module loads on the first-file-open tick, possibly before the notifier. +if #kept_unjudged > 0 then + table.sort(kept_unjudged) + vim.schedule(function() + vim.notify( + "yamlls schema prune: kept brace fileMatch globs that mention a claimed\n" + .. "file's stem but are past the literal matcher — review for a v2/v3\n" + .. "schema conflict:\n • " + .. table.concat(kept_unjudged, "\n • "), + vim.log.levels.WARN, + { title = "completion.servers.yamlls" } + ) + end) +end + return { single_file_support = true, debounce_text_changes = 150, + -- Core attaches by EXACT filetype match (no dot splitting), so the dotted + -- workflow filetype from ftdetect/github.lua must be claimed explicitly. + -- Upstream's list (nvim-lspconfig lsp/yamlls.lua) plus our yaml.github; + -- accepted trade-off: a dotted variant upstream adds later must be + -- mirrored here (the same rot class as any repo filetypes override). + filetypes = { "yaml", "yaml.docker-compose", "yaml.github", "yaml.gitlab", "yaml.helm-values" }, settings = { -- https://github.com/redhat-developer/vscode-redhat-telemetry#how-to-disable-telemetry-reporting redhat = { telemetry = { enabled = false } }, @@ -18,37 +162,7 @@ return { -- Avoid TypeError: Cannot read properties of undefined (reading 'length') url = "", }, - schemas = require("schemastore").yaml.schemas({ - extra = { - { - name = "azure-pipelines", - description = "azure-pipelines YAML schema", - fileMatch = { "azure-pipelines.{yml,yaml}" }, - url = "https://raw.githubusercontent.com/microsoft/azure-pipelines-vscode/master/service-schema.json", - }, - { - name = "gh-dash config", - description = "gh-dash config YAML schema", - fileMatch = "*/gh-dash/config.yml", - url = "https://dlvdhr.github.io/gh-dash/configuration/gh-dash/schema.json", - }, - { - name = "Traefik v3", - description = "Traefik v3 static configuration", - fileMatch = { "traefik.yml", "traefik.yaml" }, - url = "https://www.schemastore.org/traefik-v3.json", - }, - }, - -- Override built-in Traefik v2 with v3 - replace = { - ["Traefik v2"] = { - name = "Traefik v3", - description = "Traefik v3 static configuration", - fileMatch = { "traefik.yml", "traefik.yaml" }, - url = "https://www.schemastore.org/traefik-v3.json", - }, - }, - }), + schemas = schemas, -- trace = { server = "debug" }, }, }, diff --git a/lua/modules/plugins/completion.lua b/lua/modules/plugins/completion.lua index 6ca48902..8834c7d0 100644 --- a/lua/modules/plugins/completion.lua +++ b/lua/modules/plugins/completion.lua @@ -18,11 +18,22 @@ completion["neovim/nvim-lspconfig"] = { event = { "BufReadPre", "BufNewFile" }, config = require("completion.lsp"), dependencies = { - { "mason-org/mason.nvim" }, - { "mason-org/mason-lspconfig.nvim" }, { "b0o/schemastore.nvim" }, }, } +-- mason-lspconfig only supplies the lspconfig -> package mappings; the LSP +-- resolver degrades to $PATH discovery when it (or mason.nvim) is absent. +-- Deliberately NOT a dependency of nvim-lspconfig: lazy.nvim loads dependencies +-- together with the parent, and a fully provisioned session must not pay Mason +-- on the BufReadPre tick — the resolver requires it on demand (module loader). +-- The cmd trigger restores :LspInstall/:LspUninstall (registered only inside +-- setup(), which nothing reaches on the BufReadPre/load tick); both load paths +-- funnel through the config below exactly once. +completion["mason-org/mason-lspconfig.nvim"] = { + lazy = true, + cmd = { "LspInstall", "LspUninstall" }, + config = require("completion.mason-lspconfig-setup"), +} completion["nvimdev/lspsaga.nvim"] = { lazy = true, event = "LspAttach", From 283887413a74dce6d465173edb7c3772b8a4652c Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 24 Jul 2026 00:23:51 +0800 Subject: [PATCH 04/12] feat(lint): resolve linters discovery-first Resolve linter_deps through the shared resolver against nvim-lint's registry, batched per filetype off the lint events, with an idle-gated 120s parity sweep (generation-guarded) for never-opened filetypes and a fallback timer so an already-idle session isn't stranded. The plugin-loading buffer gets its initial lint. Late configures re-lint only the new linter; a broken-linter verdict is memoized so its load-time side effects run once. ftdetect/github.lua detects GitHub workflow files as yaml.github (consumed by the actionlint/shuck run: linters here, and claimed by the LSP servers). Folds the config re-source guard for its resolver sessions. --- ftdetect/github.lua | 12 + lua/modules/configs/completion/nvim-lint.lua | 408 +++++++++++++++++-- 2 files changed, 391 insertions(+), 29 deletions(-) create mode 100644 ftdetect/github.lua diff --git a/ftdetect/github.lua b/ftdetect/github.lua new file mode 100644 index 00000000..d4b0ca0c --- /dev/null +++ b/ftdetect/github.lua @@ -0,0 +1,12 @@ +-- GitHub Actions workflow files get the dotted filetype so their dedicated +-- tooling keys on it: nvim-lint's `yaml.github` linters (actionlint + shuck) +-- and the LSP filetype claims in servers/yamlls.lua / servers/gh_actions_ls.lua. +-- Deliberately unanchored, same shape as ftdetect/gitlab.lua: an explicit `$` +-- (any variant) empirically stops vim.filetype.match from ever matching the +-- pattern on this nvim, so suffixed copies (ci.yml.bak) re-type too — accepted +-- parity with the gitlab precedent. +vim.filetype.add({ + pattern = { + [".*/%.github/workflows/.*%.ya?ml"] = "yaml.github", + }, +}) diff --git a/lua/modules/configs/completion/nvim-lint.lua b/lua/modules/configs/completion/nvim-lint.lua index cc021534..437142fa 100644 --- a/lua/modules/configs/completion/nvim-lint.lua +++ b/lua/modules/configs/completion/nvim-lint.lua @@ -1,33 +1,52 @@ return function() local lint = require("lint") - -- selene: stdin mode uses process CWD to find selene.toml, which may not be the nvim - -- config dir. Pass --config explicitly so vim.yml is always found. - lint.linters.selene.args = { - "--display-style", - "json", - "--config", - vim.fn.stdpath("config") .. "/selene.toml", - "-", - } - - -- markdownlint-cli2: only add the global config — in stdin mode it discovers - -- configs upward from the process CWD, so buffers outside a project carrying - -- its own would otherwise fall back to factory defaults (e.g. MD013 at 80). - -- stdin mode and the parser stay upstream's; its errorformat fallback - -- ("stdin:%l %m") keeps findings whose column is unknown (e.g. MD012). - lint.linters["markdownlint-cli2"].args = { - "--config", - vim.fn.stdpath("config") .. "/.markdownlint.yml", - "-", + -- Local customizations of upstream linter modules, as functions so they can + -- be re-applied when refresh_linter below rebuilds a module after an install. + local overrides = { + -- selene: stdin mode uses process CWD to find selene.toml, which may not be the + -- nvim config dir. Pass --config explicitly so vim.yml is always found. + selene = function(linter) + linter.args = { + "--display-style", + "json", + "--config", + vim.fn.stdpath("config") .. "/selene.toml", + "-", + } + end, + -- markdownlint-cli2: only add the global config — in stdin mode it discovers + -- configs upward from the process CWD, so buffers outside a project carrying + -- its own would otherwise fall back to factory defaults (e.g. MD013 at 80). + -- stdin mode and the parser stay upstream's; its errorformat fallback + -- ("stdin:%l %m") keeps findings whose column is unknown (e.g. MD012). + ["markdownlint-cli2"] = function(linter) + linter.args = { + "--config", + vim.fn.stdpath("config") .. "/.markdownlint.yml", + "-", + } + end, } + ---@param name string + local function apply_override(name) + local apply = overrides[name] + if not apply then + return + end + local linter = lint.linters[name] + if type(linter) == "table" then + apply(linter) + end + end + for name in pairs(overrides) do + apply_override(name) + end - -- shuck: lints shell embedded in GitHub Actions workflows (the `run:` blocks). - -- Complements actionlint, which validates workflow syntax/expressions but not - -- the embedded shell. Standalone sh/bash/zsh diagnostics already come from the - -- shuck LSP server (see servers/shuck.lua), so shuck is only added to - -- `yaml.github` here, not to `sh`/`zsh`. `shuck check` has no working stdin - -- mode (it needs a project root), so run file-based and parse JSON output. + -- shuck: lints shell embedded in GitHub Actions `run:` blocks with rules + -- beyond actionlint's shellcheck pass-through; standalone sh/bash comes + -- from the shuck LSP server. No usable stdin mode (needs a project root), + -- so run file-based and parse JSON. lint.linters.shuck = { name = "shuck", cmd = "shuck", @@ -69,7 +88,7 @@ return function() end, } - lint.linters_by_ft = { + local by_ft = { dockerfile = { "hadolint" }, go = { "golangcilint" }, lua = { "selene" }, @@ -84,11 +103,342 @@ return function() ["yaml.github"] = { "actionlint", "shuck" }, zsh = { "zsh" }, } + lint.linters_by_ft = by_ft + + -- Filetype -> linter names via nvim-lint's own (private) resolution while it + -- survives, so the two can't drift; else the exact by_ft entry — every + -- compound filetype this config lints is an explicit key. + local function linters_for_ft(ft) + local ok, names = pcall(lint._resolve_linter_by_ft, ft) + if ok and type(names) == "table" then + return names + end + return by_ft[ft] or {} + end + + -- Resolve `linter_deps` discovery-first against nvim-lint's own registry, + -- batched BY FILETYPE off the lint events below: the probe requires the + -- linter module, and some block at load (see refresh_linter below). + local tools = require("modules.utils.tools") + -- No factory-wrapper machinery here on purpose: both overridden linters + -- (selene, markdownlint-cli2) are plain-table modules upstream, so a + -- per-call wrapper would be unreachable speculation. If upstream ever + -- converts one to a factory, the override silently stops applying (the + -- probe's factory branch still resolves the linter) — revisit then. + ---Rebuild a module-backed linter for a late configure: some (golangcilint) + ---compute `args` by RUNNING their binary at module-load time, and a result + ---computed while the binary was absent would persist all session. + ---Deliberately SYNCHRONOUS inside the resolver's configure (F24 evaluated + ---and rejected deferral): the hand-off pending gate and the aggregated + ---warning both key off configure's synchronous success/failure — deferring + ---the reload would report success before the rebuild happened. + ---@param name string + local function refresh_linter(name) + local module = "lint.linters." .. name + if not tools.module_path(module) then + return -- defined inline (e.g. shuck), not module-backed: nothing to reload + end + local prev = lint.linters[name] + package.loaded[module] = nil + -- Also drop any explicit assignment (e.g. a prior error-path restore + -- below) shadowing the lint.linters __index loader. + rawset(lint.linters, name, nil) + -- Require it OURSELVES: the __index loader pcall-swallows a reload + -- failure into nil, which would read as configure success and clear + -- the hand-off pending gate over a stale linter. + local ok, err = pcall(require, module) + if not ok then + -- Restore — stale args beat a deleted linter (asserts on try_lint) — + -- then raise so the resolver keeps the name pending and reported. + rawset(lint.linters, name, prev) + tools.raise_verbatim("linter reload failed after install: " .. tostring(err)) + end + apply_override(name) + end + -- First broken-load verdict per linter module: the recovery re-require + -- below re-runs any side effects the module executed before its failure + -- point (golangcilint spawns its binary at load time — see refresh_linter), + -- so it must run at most once per session, not once per resolve batch. + -- Session staleness is fine: fixing a broken module needs a restart anyway. + local broken_probe = {} + ---Resolve one batch of linter names. Synchronous configures land before the + ---lint that triggered the batch; late configures (install completion / + ---post-refresh) have no later lint event and re-lint buffers themselves. + ---@param names string[] + local function resolve_batch(names) + tools.resolve_runtime_tools("nvim-lint", names, function(name) + -- Read — and possibly lazy-load — the linter (the __index loader + -- requires the module; Mason's bin dir is already on $PATH). + local linter = lint.linters[name] + if linter == nil then + -- The metatable swallows loader errors: a module that exists but + -- throws is a broken config, not a typo. Re-require re-throws. + local module = "lint.linters." .. name + if not tools.module_path(module) then + return nil -- unknown linter name (typo / not registered) + end + if broken_probe[module] then + return { broken = broken_probe[module] } + end + -- The swallowed require left the loader sentinel behind: retrying + -- as-is only yields "loop or previous error loading module". + -- Clear it so the retry re-throws the ORIGINAL error. COST, + -- accepted and bounded by the memo above: side effects the module + -- ran before failing (load-time spawns) run a second time here. + package.loaded[module] = nil + local ok, err = pcall(require, module) + if not ok then + broken_probe[module] = tostring(err) + return { broken = broken_probe[module] } + end + -- The retry loaded (nondeterministic loader): pick up the fresh value. + linter = lint.linters[name] + if linter == nil then + return nil + end + end + if type(linter) == "function" then + -- A throwing factory is a broken config, not an unknown name. + local ok, resolved = pcall(linter) + if not ok then + return { broken = tostring(resolved) } + end + linter = resolved + end + if type(linter) ~= "table" then + return nil + end + local cmd = linter.cmd + if type(cmd) == "function" then + local ok, resolved = pcall(cmd) + if not ok then + return { broken = tostring(resolved) } + end + cmd = resolved + end + -- A non-string cmd means the linter resolves its command at runtime. + return { binary = type(cmd) == "string" and cmd or nil } + end, function(name, late) + if late then + -- Rebuild so load-time work sees the just-installed binary + -- (refresh_linter re-applies the local override itself). + refresh_linter(name) + else + apply_override(name) + end + if late then + -- No lint event follows a late configure: re-lint every loaded + -- buffer whose filetype maps to this linter — running only THIS + -- linter (try_lint(nil) would respawn the buffer's whole set). + -- Siblings that haven't linted yet (the plugin-loading buffer's + -- FileType is resolve-only) catch up on their next natural lint + -- event; a late configure is not that event. + vim.schedule(function() + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if + vim.api.nvim_buf_is_loaded(buf) + and vim.tbl_contains(linters_for_ft(vim.bo[buf].filetype), name) + then + vim.api.nvim_buf_call(buf, function() + pcall(function() + lint.try_lint(name, { ignore_errors = true }) + end) + end) + end + end + end) + end + end) + end - vim.api.nvim_create_autocmd({ "BufWritePost", "BufReadPost", "InsertLeave" }, { + -- Names mapped in `by_ft` resolve lazily on their filetype's first lint + -- event; the rest (typos, manual-only linters) get a deferred immediate pass. + local raw_deps = require("core.settings").linter_deps + local deps, invalid_deps = tools.split_dep_names(raw_deps) + -- Superseded sessions from a previous run of this consumer must not be retried (re-source guard). + tools.drop_sessions("nvim-lint") + local mapped = {} + for _, names in pairs(by_ft) do + for _, name in ipairs(names) do + mapped[name] = true + end + end + local pending, immediate = {}, {} + for _, name in ipairs(deps) do + if mapped[name] then + pending[name] = true + else + immediate[#immediate + 1] = name + end + end + -- Entries split_dep_names drops (non-string / empty) are config mistakes: + -- forward them raw so the resolver's own sweep reports them in the unknown + -- bucket, identically to the other consumers. + vim.list_extend(immediate, invalid_deps) + if #immediate > 0 then + vim.schedule(function() + resolve_batch(immediate) + end) + end + + local ft_done = {} + local function ensure_resolved(ft) + if ft == "" or ft_done[ft] then + return + end + ft_done[ft] = true + local batch = {} + for _, name in ipairs(linters_for_ft(ft)) do + if pending[name] then + pending[name] = nil + batch[#batch + 1] = name + end + end + if #batch > 0 then + resolve_batch(batch) + end + end + + -- FileType is resolve-only, no lint: it covers the buffer whose BufReadPost + -- loaded this plugin — lazy.nvim replays that event before filetype + -- detection, so the lint callback sees an empty filetype there. + vim.api.nvim_create_autocmd({ "BufWritePost", "BufReadPost", "InsertLeave", "FileType" }, { group = vim.api.nvim_create_augroup("NvimLint", { clear = true }), - callback = function() - lint.try_lint(nil, { ignore_errors = true }) + callback = function(args) + -- Resolve this filetype's deps before the lint they gate, so the first + -- lint already spawns by absolute path when Mason's bin dir is off $PATH. + ensure_resolved(vim.bo[args.buf].filetype) + if args.event ~= "FileType" then + lint.try_lint(nil, { ignore_errors = true }) + end end, }) + + -- Parity backstop, mirroring mason-lspconfig's sweep intent: a mapped + -- linter whose filetype never fires a lint event this session must still + -- be resolved (installed or reported) instead of silently skipped. + -- + -- Shape, driven by two constraints: + -- * The probe requires linter modules and golangcilint blocks at load + -- (deliberate, see refresh_linter above), so the sweep must not land + -- mid-editing: the 120s timer only ARMS a one-shot CursorHold(I) idle + -- gate, and the gate warms module loads one per tick before a single + -- aggregated resolve. + -- * A config re-source strands this closure with a stale `pending` + -- table: every async hop re-checks a vim.g generation token (survives + -- both re-source flavors), and the gate autocmd dies with the + -- augroup's clear=true above. + local SWEEP_DELAY_MS = tools.SWEEP_DELAY_MS -- one source; divergence rationale lives there + local SWEEP_FALLBACK_MS = 60000 -- idle-gate fallback: bounds the sweep when idle predates the gate + local gen = (vim.g._nvimlint_sweep_gen or 0) + 1 + vim.g._nvimlint_sweep_gen = gen + local function sweep_live() + return vim.g._nvimlint_sweep_gen == gen + end + + local function run_sweep() + -- Snapshot WITHOUT draining: `pending` stays owned by ensure_resolved + -- until the moment of resolution, so a filetype opened mid-warm still + -- takes its normal first-event path (resolve before its first lint). + local names = vim.tbl_keys(pending) + if #names == 0 then + return + end + table.sort(names) -- pairs order is nondeterministic; keep the warning stable + -- The event path loads linter modules inside tools.resolve(), AFTER it + -- put Mason's bin dir on $PATH. PATH-sensitive module loads (see + -- refresh_linter above) must see the same environment when the warm + -- phase loads them first instead. + tools.ensure_mason_on_path() + -- Warm the __index module loads one per tick (bounds any blocking + -- load to a single tick), then resolve everything still pending in + -- ONE batch so the missing-tools warning stays aggregated. Loader + -- errors are swallowed here on purpose: resolve_batch's probe + -- re-derives broken-vs-unknown. + local index = 0 + local function step() + if not sweep_live() then + return + end + index = index + 1 + if index <= #names then + local name = names[index] + if pending[name] then -- skip names an event already claimed + pcall(function() + local _ = lint.linters[name] + end) + end + vim.schedule(step) + return + end + local batch = {} + for _, name in ipairs(names) do + if pending[name] then + pending[name] = nil + batch[#batch + 1] = name + end + end + if #batch > 0 then + resolve_batch(batch) -- order-preserving subset of sorted `names` + end + end + step() + end + + vim.defer_fn(function() + if not sweep_live() or next(pending) == nil then + return + end + -- Arm, don't run: CursorHold(I) keeps the module loads off keystroke + -- bursts in the interactive case. But an autocmd created MID-idle never + -- fires — nvim's did_cursorhold flag was already consumed by whichever + -- CursorHold consumer registered at startup and only a real key press + -- resets it — so a session left idle before this arms would defer the + -- sweep unboundedly. The fallback timer bounds that: first trigger + -- wins; run_sweep's warm loop bounds per-tick blocking either way. + local fired = false + local gate = nil + local function fire() + if fired or not sweep_live() then + return + end + fired = true + if gate then + pcall(vim.api.nvim_del_autocmd, gate) + end + run_sweep() + end + -- One-shot across BOTH events via del_autocmd — a callback returning + -- true only drops the registration of the event that fired. + gate = vim.api.nvim_create_autocmd({ "CursorHold", "CursorHoldI" }, { + group = "NvimLint", + callback = fire, + }) + vim.defer_fn(fire, SWEEP_FALLBACK_MS) + end, SWEEP_DELAY_MS) + + -- The buffer that loaded this plugin (lazy triggers: BufReadPost / BufWritePost) + -- can slip through the initial lint in ONE case: a BufReadPost load, where + -- lazy.nvim replays BufReadPost BEFORE ftdetect (ft="", a no-op lint) and the + -- FileType handler above is resolve-only — so this buffer alone would wait for + -- the next InsertLeave/save. When ft is ALREADY set at config time (a + -- BufWritePost load, or any ft-set load), the replayed trigger event lints via + -- the main handler, so NO help is needed — registering a one-shot there would + -- double-lint (the round-1 O1 defect). Hence: only the ft="" case, and only + -- once, in the NvimLint group so a config re-source's clear=true wipes a stale + -- copy instead of stacking. + local load_buf = vim.api.nvim_get_current_buf() + if vim.bo[load_buf].filetype == "" then + vim.api.nvim_create_autocmd("FileType", { + group = "NvimLint", + buffer = load_buf, + once = true, + callback = function() + ensure_resolved(vim.bo[load_buf].filetype) + vim.api.nvim_buf_call(load_buf, function() + lint.try_lint(nil, { ignore_errors = true }) + end) + end, + }) + end end From 3b96d40b84ae2765397a929c211c179276cefa2d Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 24 Jul 2026 00:23:51 +0800 Subject: [PATCH 05/12] feat(dap): resolve debug adapters discovery-first Resolve dap_deps through the shared resolver; adapters self-validate from $PATH (delve via dlv, codelldb, lldb, python via debugpy) with Mason loaded lazily off the :Dap tick. Client configs follow the validate-FIRST / raise-LAST contract so remote-attach adapters stay registered. debugpy resolves via a bounded probe cascade with a negative cache. Shared attach_endpoint validates remote host/port shape at config time (config.port and opts.default_port to the same 1-65535 contract). Folds later rounds: the debugpy import-probe negative cache collapses to one flat 10s TTL; :DapInstall/:DapUninstall are restored via a cmd-triggered mason-nvim-dap spec whose config module is the single setup() owner (require-path loads funnel through lazy's module loader, and the user-override slot moves to user.configs.mason-nvim-dap); attach_endpoint's argument guards raise clean error(..., 0) like every other raise in the helper; resolver state is guarded against config re-source. --- .../configs/tool/dap/clients/codelldb.lua | 9 +- .../configs/tool/dap/clients/delve.lua | 77 ++-- lua/modules/configs/tool/dap/clients/lldb.lua | 12 +- .../configs/tool/dap/clients/python.lua | 181 +++++++-- lua/modules/configs/tool/dap/init.lua | 349 ++++++++++++++++-- lua/modules/configs/tool/mason-nvim-dap.lua | 11 + lua/modules/utils/dap.lua | 74 +++- 7 files changed, 628 insertions(+), 85 deletions(-) create mode 100644 lua/modules/configs/tool/mason-nvim-dap.lua diff --git a/lua/modules/configs/tool/dap/clients/codelldb.lua b/lua/modules/configs/tool/dap/clients/codelldb.lua index 64124c61..355fd7ba 100644 --- a/lua/modules/configs/tool/dap/clients/codelldb.lua +++ b/lua/modules/configs/tool/dap/clients/codelldb.lua @@ -4,13 +4,18 @@ return function() local utils = require("modules.utils.dap") local is_windows = require("core.global").is_windows + -- Self-validate at config time (validate FIRST — contract: tool/dap/init.lua + -- resolver spec): launch AND attach both spawn the local binary, so + -- unlike delve/python nothing is worth registering without it — error if missing. + local command = + require("modules.utils.tools").exepath_or_error("codelldb", "install it via Mason or your package manager") dap.adapters.codelldb = { type = "server", port = "${port}", executable = { - command = vim.fn.exepath("codelldb"), -- Find codelldb on $PATH + command = command, args = { "--port", "${port}" }, - detached = is_windows and false or true, + detached = not is_windows, }, } dap.configurations.c = { diff --git a/lua/modules/configs/tool/dap/clients/delve.lua b/lua/modules/configs/tool/dap/clients/delve.lua index b1a70b3c..f8a2a158 100644 --- a/lua/modules/configs/tool/dap/clients/delve.lua +++ b/lua/modules/configs/tool/dap/clients/delve.lua @@ -1,34 +1,53 @@ --- https://github.com/mfussenegger/nvim-dap/wiki/Debug-Adapter-installation#go +-- https://github.com/mfussenegger/nvim-dap/wiki/Debug-Adapter-installation#go-using-delve-directly -- https://github.com/golang/vscode-go/blob/master/docs/debugging.md return function() local dap = require("dap") local utils = require("modules.utils.dap") + local is_windows = require("core.global").is_windows - if not require("mason-registry").is_installed("go-debug-adapter") then - vim.notify( - "Automatically installing `go-debug-adapter` for go debugging", - vim.log.levels.INFO, - { title = "nvim-dap" } - ) + -- Use delve's built-in DAP server (`dlv dap`) directly, no separate go-debug-adapter. + -- `dlv` resolves lazily on the spawn path: remote attach needs no local binary, so + -- the adapter registers even when it's absent. - local go_dbg = require("mason-registry").get_package("go-debug-adapter") - go_dbg:install():once( - "closed", - vim.schedule_wrap(function() - if go_dbg:is_installed() then - vim.notify("Successfully installed `go-debug-adapter`", vim.log.levels.INFO, { title = "nvim-dap" }) - end - end) - ) + ---A function adapter (over a static table) so a remote `attach` config can + ---connect to an already-running `dlv dap` instead of spawning a local one. + ---@param callback fun(adapter: table) + ---@param config table + local function delve_adapter(callback, config) + if config.request == "attach" and config.mode == "remote" then + -- Shared shape validation (utils.attach_endpoint): malformed values + -- error at config time instead of surfacing as an opaque connection + -- failure. Default when unset: 38697 is the conventional example + -- port from the nvim-dap wiki, not a delve default. + local host, port = utils.attach_endpoint(config, { label = "delve remote attach", default_port = 38697 }) + callback({ + type = "server", + host = host, + port = port, + }) + else + -- Resolve lazily so a dlv installed after config load is picked up without reconfiguring. + local command = require("modules.utils.tools").exepath_or_error( + "dlv", + "install delve via Mason or your package manager" + ) + callback({ + type = "server", + port = "${port}", + executable = { + command = command, + args = { "dap", "-l", "127.0.0.1:${port}" }, + detached = not is_windows, + }, + }) + end end - dap.adapters.go = { - type = "executable", - command = "node", - args = { - vim.env.MASON .. "/packages/go-debug-adapter" .. "/extension/dist/debugAdapter.js", - }, - } + -- Register under both names: the configurations below use `type = "go"`, while + -- mason-nvim-dap / other integrations reference the adapter as `delve`. + dap.adapters.go = delve_adapter + dap.adapters.delve = delve_adapter + dap.configurations.go = { { type = "go", @@ -37,7 +56,6 @@ return function() cwd = "${workspaceFolder}", program = utils.input_file_path(), console = "integratedTerminal", - dlvToolPath = vim.fn.exepath("dlv"), showLog = true, showRegisters = true, stopOnEntry = false, @@ -50,7 +68,6 @@ return function() program = utils.input_file_path(), args = utils.input_args(), console = "integratedTerminal", - dlvToolPath = vim.fn.exepath("dlv"), showLog = true, showRegisters = true, stopOnEntry = false, @@ -63,7 +80,6 @@ return function() program = utils.input_exec_path(), args = utils.input_args(), console = "integratedTerminal", - dlvToolPath = vim.fn.exepath("dlv"), mode = "exec", showLog = true, showRegisters = true, @@ -76,7 +92,6 @@ return function() cwd = "${workspaceFolder}", program = utils.input_file_path(), console = "integratedTerminal", - dlvToolPath = vim.fn.exepath("dlv"), mode = "test", showLog = true, showRegisters = true, @@ -89,11 +104,17 @@ return function() cwd = "${workspaceFolder}", program = "./${relativeFileDirname}", console = "integratedTerminal", - dlvToolPath = vim.fn.exepath("dlv"), mode = "test", showLog = true, showRegisters = true, stopOnEntry = false, }, } + + -- Availability check LAST (contract: tool/dap/init.lua resolver spec); the raise + -- is the provisioning signal — the attach-capable adapters above stay registered. + require("modules.utils.tools").exepath_or_error( + "dlv", + "local `dlv dap` launch is unavailable until installed (remote attach still works)" + ) end diff --git a/lua/modules/configs/tool/dap/clients/lldb.lua b/lua/modules/configs/tool/dap/clients/lldb.lua index 8461c77a..e3ba1bc7 100644 --- a/lua/modules/configs/tool/dap/clients/lldb.lua +++ b/lua/modules/configs/tool/dap/clients/lldb.lua @@ -3,9 +3,19 @@ return function() local dap = require("dap") local utils = require("modules.utils.dap") + -- Opt-in preset (not in default dap_deps; codelldb covers C-family). Self-validate + -- so the resolver surfaces `lldb` (validate FIRST — contract: + -- tool/dap/init.lua resolver spec). LLVM 18 renamed `lldb-vscode` -> + -- `lldb-dap`; probe the new name first, keep the old for distros still + -- shipping it. + local command = require("modules.utils.tools").exepath_or_error( + { "lldb-dap", "lldb-vscode" }, + "install it via your package manager (ships with LLVM/lldb)" + ) + dap.adapters.lldb = { type = "executable", - command = vim.fn.exepath("lldb-vscode"), -- Find lldb-vscode on $PATH + command = command, } dap.configurations.c = { { diff --git a/lua/modules/configs/tool/dap/clients/python.lua b/lua/modules/configs/tool/dap/clients/python.lua index 688256b4..be5709b2 100644 --- a/lua/modules/configs/tool/dap/clients/python.lua +++ b/lua/modules/configs/tool/dap/clients/python.lua @@ -1,74 +1,213 @@ -- https://github.com/mfussenegger/nvim-dap/wiki/Debug-Adapter-installation#python -- https://github.com/microsoft/debugpy/wiki/Debug-configuration-settings + return function() local dap = require("dap") local utils = require("modules.utils.dap") + local tools = require("modules.utils.tools") local is_windows = require("core.global").is_windows - local debugpy_root = vim.env.MASON .. "/packages/debugpy" + -- Interpreter candidates probed for a debugpy-capable python; the config-time + -- availability check and the launch path share one cascade (debugpy_command). + local py_candidates = is_windows and { "pythonw.exe", "python.exe", "python" } or { "python3", "python" } + + -- Platform-specific venv interpreter layout, in one place. + ---@param root string @A virtualenv root directory. + ---@return string @Absolute path to the venv's python interpreter. + local function venv_python(root) + return is_windows and root .. "/Scripts/pythonw.exe" or root .. "/bin/python" + end + + -- Resolve the debugpy command discovery-first. The higher-priority source + -- (the `debugpy-adapter` shim) is re-probed every call, so a mid-session + -- install takes over; the cache is consulted LAST purely to spare the + -- blocking import probe, and a cached hit re-checks existence. (A `pip + -- uninstall` that keeps the interpreter fails only at import time — accepted.) + local resolved + -- Negative session cache for the import probe, on a single flat TTL: + -- launch attempts inside the window stay spawn-free; each expiry allows + -- one re-probe, so the common install-then-retry flow picks a + -- pip-installed debugpy up within seconds. Deliberately NOT a permanent + -- latch: a name no longer in the resolver's pending set has no + -- :ToolsRetry path back here, so a dead-forever cache would make a later + -- `pip install debugpy` unreachable without a restart. Every probe is + -- itself bounded (5s :wait) and only runs on explicit launch attempts — + -- the accepted worst case is one bounded probe per window while launches + -- keep failing. Faster recovery paths stay: the fast probes run FIRST + -- each call (a Mason install takes over there), and a resolver + -- re-configure rebuilds this whole closure. + local IMPORT_PROBE_TTL_NS = 10 * 1000 * 1000 * 1000 -- flat window between re-probes + local import_probe_failed_at = nil + local function found(command, args) + resolved = { command = command, args = args } + -- Any successful resolution clears the failure window. + import_probe_failed_at = nil + return command, args + end + -- One copy for all raise sites so the install guidance can't drift. + local no_debugpy = "debugpy not found: no `debugpy-adapter` shim on $PATH or in Mason's bin dir,\n" + .. "and no python able to import debugpy; install debugpy via Mason (`:Mason`)\n" + .. "or your package manager" + + -- Fast, non-blocking probes only (executable()/exepath()): the + -- `debugpy-adapter` shim, then the cached last resolution. Spawns nothing + -- itself; the import probe lives in the full cascade below and runs only + -- when these probes miss. The shim is the SUPPORTED Mason surface — it is + -- declared by the debugpy package spec and exists exactly when the managed + -- venv does, so no probe hardcodes Mason's packages//venv layout. + -- (Accepted nuance: on Windows the shim spawns via .cmd instead of + -- pythonw.exe, so no console-window suppression on that path.) + local function fast_command() + -- find_executable reaches a Mason shim even before mason.setup() puts its + -- bin dir on $PATH; spawn by absolute path (the bare name wouldn't launch). + local adapter = tools.find_executable("debugpy-adapter") + if adapter then + return found(adapter, {}) + end + -- Cache LAST: it holds whichever source last resolved — possibly a + -- lower-priority import-probe result — and probing the managed sources + -- above first is what lets a mid-session install win. + if resolved then + if vim.fn.executable(resolved.command) == 1 then + return resolved.command, resolved.args + end + -- The cached binary vanished; drop it and re-run the cascade. + resolved = nil + end + return nil + end + + -- Full cascade: fast probes, then a system python that can import debugpy. + -- The import probe spawns a short-lived python, but only when the fast probes + -- miss. Both the launch path and the config-time availability check at the + -- bottom use this cascade, so the two resolutions agree by construction. + local function debugpy_command() + local command, args = fast_command() + if command then + return command, args + end + if import_probe_failed_at and vim.uv.hrtime() - import_probe_failed_at < IMPORT_PROBE_TTL_NS then + -- Inside the window: no respawn. (The stamp at the cascade's end + -- restarts the window whenever a round misses again.) + return nil + end + -- Last resort: probe interpreter candidates rather than hard-coding one + -- (pythonw.exe is often absent on a Windows box with only python.exe). + local probed = {} + for _, py in ipairs(py_candidates) do + -- Confirm the module actually imports, not just that the interpreter exists. + -- Dedup by resolved path so `python`→`python3` symlinks spawn only once. + if vim.fn.executable(py) == 1 then + local abs = vim.fn.exepath(py) + local real = (abs ~= "" and vim.uv.fs_realpath(abs)) or abs + -- Key by candidate name when exepath came back empty, so one empty + -- result can't blanket-skip the remaining candidates. + local key = real ~= "" and real or py + if not probed[key] then + probed[key] = true + local probe_cmd = abs ~= "" and abs or py + -- vim.system, not vim.fn.system: the exit code stays local + -- instead of round-tripping through the v:shell_error global + -- (repo convention — core/pack.lua, servers/gopls.lua). + -- Bounded wait: this runs synchronously on the :Dap* setup + -- tick, and a hung interpreter (dead network FS, broken + -- shim) would otherwise freeze the editor. On timeout the + -- probe is SIGKILLed with code 124 — a failed probe, so + -- resolution falls through to the remaining candidates. + if vim.system({ probe_cmd, "-c", "import debugpy" }):wait(5000).code == 0 then + -- Spawn by the probed exepath, not its realpath: a venv python is a + -- symlink and pyvenv.cfg discovery precedes symlink resolution. + return found(probe_cmd, { "-m", "debugpy.adapter" }) + end + end + end + end + import_probe_failed_at = vim.uv.hrtime() + return nil + end dap.adapters.python = function(callback, config) if config.request == "attach" then - local port = (config.connect or config).port - local host = (config.connect or config).host or "127.0.0.1" + -- Shared shape validation (utils.attach_endpoint): a malformed + -- port/host errors clearly at config time instead of surfacing as + -- an opaque TCP failure. No default port — attach must name one. + local host, port = utils.attach_endpoint(config.connect or config, { label = "python attach" }) callback({ type = "server", - port = assert(port, "`connect.port` is required for a python `attach` configuration"), + port = port, host = host, options = { source_filetype = "python" }, }) else + -- Launch path only: attach uses the server branch above and needs no local debugpy. + local command, args = debugpy_command() + if not command then + error(no_debugpy, 0) + end callback({ type = "executable", - command = is_windows and debugpy_root .. "/venv/Scripts/pythonw.exe" - or debugpy_root .. "/venv/bin/python", - args = { "-m", "debugpy.adapter" }, + command = command, + args = args, options = { source_filetype = "python" }, }) end end dap.configurations.python = { { - -- The first three options are required by nvim-dap - type = "python", -- the type here established the link to the adapter definition: `dap.adapters.python` + type = "python", -- links to the adapter definition: `dap.adapters.python` request = "launch", name = "Debug", - -- Options below are for debugpy, see https://github.com/microsoft/debugpy/wiki/Debug-configuration-settings for supported options console = "integratedTerminal", program = utils.input_file_path(), pythonPath = function() local venv = vim.env.CONDA_PREFIX if venv then - return is_windows and venv .. "/Scripts/pythonw.exe" or venv .. "/bin/python" + return venv_python(venv) else return is_windows and "pythonw.exe" or "python3" end end, }, { - -- NOTE: This setting is for people using venv type = "python", request = "launch", name = "Debug (using venv)", - -- Options below are for debugpy, see https://github.com/microsoft/debugpy/wiki/Debug-configuration-settings for supported options console = "integratedTerminal", program = utils.input_file_path(), pythonPath = function() -- Prefer the venv that is defined by the designated environment variable. local cwd, venv = vim.uv.cwd(), vim.env.VIRTUAL_ENV - local python = venv and (is_windows and venv .. "/Scripts/pythonw.exe" or venv .. "/bin/python") or "" + local python = venv and venv_python(venv) or "" if vim.fn.executable(python) == 1 then return python end - -- Otherwise, fall back to check if there are any local venvs available. - venv = vim.uv.fs_stat(cwd .. "/venv") and cwd .. "/venv" or cwd .. "/.venv" - python = is_windows and venv .. "/Scripts/pythonw.exe" or venv .. "/bin/python" - if vim.fn.executable(python) == 1 then - return python - else - return is_windows and "pythonw.exe" or "python3" + -- Otherwise fall back to any local venv — guarded: vim.uv.cwd() + -- returns nil when the process working directory was deleted. + if cwd then + venv = vim.fn.isdirectory(cwd .. "/venv") == 1 and cwd .. "/venv" or cwd .. "/.venv" + python = venv_python(venv) + if vim.fn.executable(python) == 1 then + return python + end end + return is_windows and "pythonw.exe" or "python3" end, }, } + + -- Availability check LAST (contract: tool/dap/init.lua resolver spec); the + -- raise is the provisioning signal — the attach adapters stay registered. + -- One bounded cascade decides (typical cost with a python present is a + -- fast `import debugpy` exit; the 5s :wait only bites on pathological + -- hangs), so config-time and launch-time resolution agree by construction, + -- and a capable system python is honored BEFORE any Mason install — the + -- discovery-first $PATH-wins contract. A spawn-free "raise early when + -- Mason can provision" gate (e6f08f8) sat here before: it skipped the + -- probe and auto-installed Mason's debugpy over a working pip copy, and + -- its evidence chain load-bore two private upstream surfaces + -- (lazy.core.config, mason-nvim-dap.mappings.source) — reversed on + -- purpose. + if not debugpy_command() then + error(no_debugpy .. " (remote attach works regardless)", 0) + end end diff --git a/lua/modules/configs/tool/dap/init.lua b/lua/modules/configs/tool/dap/init.lua index 1e998f8b..47e0ecdd 100644 --- a/lua/modules/configs/tool/dap/init.lua +++ b/lua/modules/configs/tool/dap/init.lua @@ -1,11 +1,56 @@ return function() local dap = require("dap") local dapui = require("dapui") - local mason_dap = require("mason-nvim-dap") local icons = { dap = require("modules.utils.icons").get("dap") } local colors = require("modules.utils").get_palette() local mappings = require("tool.dap.dap-keymap") + local tools = require("modules.utils.tools") + + ---Ordered client-config modules for an adapter: user override, then repo preset. + ---@param name string + ---@return string[] + local function client_modules(name) + return { "user.configs.dap-clients." .. name, "tool.dap.clients." .. name } + end + + local client_config_cache = {} + ---Load the first usable client config (user override, then repo preset), + ---memoized per adapter: the resolver consults it from several predicates + ---(has_local_config, unknown_of) plus the handler, and an uncached miss + ---would re-run a failed require each time. First success wins outright (no + ---merge-base semantics); a higher-precedence exists-but-broken candidate's + ---reason survives alongside a lower-precedence success, so the handler can + ---refuse to fall past a broken override (usable_or_raise raises it). + ---@param name string + ---@return any value, string|nil broken_reason, boolean any_exists, boolean user_won + local function load_client_config(name) + local cached = client_config_cache[name] + if not cached then + cached = { value = nil, broken_reason = nil, any_exists = false, user_won = false } + local modules = client_modules(name) + for index, module in ipairs(modules) do + local ok, value, exists, reason = tools.load_module_or_report(module, "nvim-dap") + if ok then + cached.value = value + -- Recorded here, where `modules` is already built — the + -- handler must not rebuild the list per call just to ask + -- whether the user candidate won. + cached.user_won = index == 1 + cached.any_exists = true + break + end + if exists then + cached.any_exists = true + if cached.broken_reason == nil then + cached.broken_reason = reason + end + end + end + client_config_cache[name] = cached + end + return cached.value, cached.broken_reason, cached.any_exists, cached.user_won + end -- Initialize debug hooks _G._debugging = false @@ -50,42 +95,282 @@ return function() ) vim.fn.sign_define("DapLogPoint", { text = icons.dap.LogPoint, texthl = "DapLogPoint", linehl = "", numhl = "" }) - ---A handler to setup all clients defined under `tool/dap/clients/*.lua` - ---@param config table - local function mason_dap_handler(config) - local dap_name = config.name - local ok, custom_handler = pcall(require, "user.configs.dap-clients." .. dap_name) - if not ok then - -- Use preset if there is no user definition - ok, custom_handler = pcall(require, "tool.dap.clients." .. dap_name) + -- Everything Mason-flavored loads LAZILY: a session where every adapter + -- self-validates from $PATH — with no user overrides — must not load + -- mason.nvim / mason-nvim-dap on the :Dap* tick. First use (the factory + -- fallback, a phase-2 classification, or a user override's opts + -- materialization) goes through lazy.nvim's module loader; absence + -- degrades to client-config/$PATH resolution as before. + local mason_dap = nil + local function mason_dap_mod() + if mason_dap == nil then + -- load_module_or_report, not a bare pcall: an exists-but-broken + -- mason-nvim-dap must surface its real load error once instead of + -- silently reading as "Mason absent" (the factory error's + -- "unavailable for a default setup" would otherwise be the only, + -- misleading, signal). Genuinely absent stays silent as before. + -- setup() is owned by the plugin spec's config (tool.mason-nvim-dap): + -- this require goes through lazy.nvim's module loader, which loads + -- the plugin and runs that config exactly once. + local ok, m = tools.load_module_or_report("mason-nvim-dap", "nvim-dap") + if ok and type(m) == "table" then + mason_dap = m + else + mason_dap = false + end + end + return mason_dap or nil + end + -- mason-nvim-dap private internals, not a public API: guard each require so + -- drift degrades to client-config/$PATH resolution instead of aborting. + -- Failed requires are RECORDED: with presence evidence they are upstream + -- API drift worth naming; in a Mason-less session the same failures are + -- expected silence (mason_evidence below tells the two apart). + local mapping_drift = {} + local mapping_sibling_ok = false + local mapping_drift_warned = false + local function map_or_empty(mod, default) + local ok, m = pcall(require, mod) + if ok and type(m) == "table" then + mapping_sibling_ok = true + return m + end + mapping_drift[#mapping_drift + 1] = mod:match("[^.]+$") + return default + end + ---Mason presence evidence without forcing a load: a sibling mapping module + ---loaded, the parent is already in package.loaded, or its file is + ---locatable on the search paths (rtp'd by a failed lazy require attempt). + local function mason_evidence() + return mapping_sibling_ok + or package.loaded["mason-nvim-dap"] ~= nil + or tools.module_path("mason-nvim-dap") ~= nil + end + local mason_maps_cache = nil + local function mason_maps() + if not mason_maps_cache then + mason_maps_cache = { + source = map_or_empty("mason-nvim-dap.mappings.source", {}), + adapters = map_or_empty("mason-nvim-dap.mappings.adapters", {}), + configurations = map_or_empty("mason-nvim-dap.mappings.configurations", {}), + filetypes = map_or_empty("mason-nvim-dap.mappings.filetypes", {}), + } + -- The module may load with the indexed field renamed/removed; normalize + -- so package_of/unknown_of below never index nil. + if type(mason_maps_cache.source.nvim_dap_to_package) ~= "table" then + mason_maps_cache.source.nvim_dap_to_package = {} + end end - if not ok then - -- Default to use factory config for clients(s) that doesn't include a spec - mason_dap.default_setup(config) - return - elseif type(custom_handler) == "function" then - -- Case where the protocol requires its own setup - -- Make sure to set - -- * dap.adpaters. = { your config } + return mason_maps_cache + end + ---The eager 4-field handler opts, built from mason_maps() at the CALL + ---site so drift recording keeps its ordering relative to each branch's + ---error/WARN reads. (The zero-arg repo clients' lazy proxy stays separate: + ---currently unexercised future-proofing that keeps Mason off the :Dap tick + ---should a repo client ever start reading opts.) + ---@param dap_name string + ---@return { name: string, adapters: any, configurations: any, filetypes: any } + local function eager_opts(dap_name) + local map = mason_maps() + return { + name = dap_name, + adapters = map.adapters[dap_name], + configurations = map.configurations[dap_name], + filetypes = map.filetypes[dap_name], + } + end + + ---A handler to setup all clients defined under `tool/dap/clients/*.lua`. + ---The factory branch and a user override's opts materialization load + ---mason-nvim-dap; a repo zero-arg client (every adapter in this repo) + ---configures without loading Mason. + ---@param dap_name string + local function mason_dap_handler(dap_name) + local custom_handler, broken_reason, _, user_won = load_client_config(dap_name) + -- No-fall-through contract, enforced by the ONE shared implementation + -- (tools.usable_or_raise): a broken or wrong-shaped config must never + -- read as success — that would suppress both the warning and the + -- install fallback. + custom_handler = tools.usable_or_raise(custom_handler, broken_reason, { + label = "client config", + expected = "a fun(opts)", + shapes = { ["function"] = true }, + }) + if custom_handler == nil then + -- No client config: fall back to Mason's factory config, erroring + -- (level 0) so the resolver reports failures. + local m = mason_dap_mod() + if not m then + error( + string.format( + "no client config for `%s` and mason-nvim-dap is unavailable for a default setup", + dap_name + ), + 0 + ) + end + local config = eager_opts(dap_name) + -- default_setup silently no-ops on a nil adapter config: error instead. + -- No drift CLAIM on the nil lookup itself — upstream legitimately + -- ships source-only names (js/javadbg/elixir…) with no default + -- adapter; the remedy is the same either way. Only a recorded + -- module-level require failure is named as drift. + if config.adapters == nil then + local drift = #mapping_drift > 0 + and (" — mapping modules failed to load: " .. table.concat(mapping_drift, ", ") .. " (mason-nvim-dap API drift?)") + or "" + error( + string.format( + "no client config for `%s`; mason-nvim-dap can install its package but ships no\n" + .. "default adapter setup for it — add a client config (`tool/dap/clients/%s.lua`\n" + .. "or `user.configs.dap-clients.%s`)%s", + dap_name, + dap_name, + dap_name, + drift + ), + 0 + ) + end + -- Partial mappings drift can hand us configurations without + -- filetypes: upstream's default_setup guards configurations + -- (`or {}`) but ipairs()es filetypes whenever configurations are + -- non-empty — that combination raises ipairs(nil). Normalize both + -- halves defensively. + if type(config.configurations) ~= "table" then + config.configurations = {} + end + if type(config.filetypes) ~= "table" then + config.filetypes = {} + end + m.default_setup(config) + else + -- Function form (the only other shape usable_or_raise lets through): + -- the protocol owns its setup. Make sure to set + -- * dap.adapters. = { your config } -- * dap.configurations. = { your config } -- See `codelldb.lua` for a concrete example. - custom_handler(config) - else - vim.notify( - string.format( - "Failed to setup [%s].\n\nClient definition under `tool/dap/clients` must return\na fun(opts) (got '%s' instead)", - config.name, - type(custom_handler) - ), - vim.log.levels.ERROR, - { title = "nvim-dap" } - ) + if user_won then + -- User-authored override: the historical contract is a PLAIN + -- table — pairs()/tbl_deep_extend must see the mapping fields, + -- which a lazy __index proxy cannot provide (LuaJIT has no + -- __pairs). Costs an eager mason_maps() load only for adapters + -- the user explicitly overrode. + local opts = eager_opts(dap_name) + -- Module-level drift would vanish here: the override "succeeds", + -- so neither the factory error nor the resolver's missing path + -- runs. One WARN per session, only with Mason evidence (absence + -- is not drift) — the override itself still runs: a + -- self-sufficient one keeps working. A nil field WITHOUT module + -- drift is the legitimate source-only shape: silent. + if + not mapping_drift_warned + and #mapping_drift > 0 + and (opts.adapters == nil or opts.configurations == nil or opts.filetypes == nil) + and mason_evidence() + then + mapping_drift_warned = true + vim.notify( + string.format( + "mason-nvim-dap mapping modules failed to load: %s — Mason-derived fields\n" + .. "in the `%s` override opts may be nil (upstream API drift?)", + table.concat(mapping_drift, ", "), + dap_name + ), + vim.log.levels.WARN, + { title = "nvim-dap" } + ) + end + custom_handler(opts) + else + -- Repo clients are zero-arg and must not ENUMERATE opts: the + -- lazy proxy keeps Mason off the :Dap tick for provisioned + -- sessions; mapping fields resolve on first ACCESS only. + custom_handler(setmetatable({ name = dap_name }, { + __index = function(_, key) + local map = mason_maps()[key] + return type(map) == "table" and map[dap_name] or nil + end, + })) + end end end - require("modules.utils").load_plugin("mason-nvim-dap", { - ensure_installed = require("core.settings").dap_deps, - automatic_installation = false, - handlers = { mason_dap_handler }, + local settings = require("core.settings") + + ---Does an explicit client config exist for this adapter (system-resolved)? + ---A config that exists but fails to load still counts — treating it as + ---absent would misread a broken config as an unknown adapter name. + ---@param name string + ---@return boolean + local function has_client_config(name) + local value, _, any_exists = load_client_config(name) + return value ~= nil or any_exists + end + + -- Discovery-first resolution, shared with LSP and formatters/linters. nvim-dap + -- has no command registry like nvim-lspconfig, so $PATH detection leans on the + -- Mason package's declared binaries; configs without a package resolve their own. + -- Superseded sessions from a previous run of this consumer must not be retried (re-source guard). + tools.drop_sessions("DAP") + tools.resolve({ + title = "DAP", + deps = settings.dap_deps, + -- The shared lazy thunk (tools.default_registry): a fully-provisioned + -- setup never loads mason-registry. + registry = tools.default_registry, + package_of = function(name) + return mason_maps().source.nvim_dap_to_package[name] + end, + binaries_of = function(name, pkg) + if pkg ~= nil then + return tools.package_binaries(pkg, name) + end + -- No Package, no probe: adapter names are not binary names (`delve` + -- ships `dlv`). Client configs self-validate. + return {} + end, + ---Typo/outdated name vs valid-but-unprovisioned. A client config or mapping means + ---the name is real; an empty map (Mason absent) is untrusted to avoid false typos. + unknown_of = function(name) + if has_client_config(name) then + return false + end + local src = mason_maps().source.nvim_dap_to_package + if next(src) == nil then + return false + end + return src[name] == nil + end, + has_local_config = has_client_config, + ---A drifted mappings.source makes package_of return nil, so the name + ---dead-ends in the resolver's reason-less missing branch — name the + ---recorded drift there. Only with Mason evidence: absence is not drift. + missing_reason_of = function(name) + if has_client_config(name) or #mapping_drift == 0 or not mason_evidence() then + return nil + end + return "mason-nvim-dap mapping modules failed to load (" + .. table.concat(mapping_drift, ", ") + .. ") — cannot derive its Mason package (upstream API drift?)" + end, + -- Client configs self-validate, so try an existing config before the Mason + -- install fallback — python resolves debugpy from a venv $PATH can't see. + -- The raise on a missing launch binary is the provisioning signal. + -- + -- CANONICAL availability contract for dap/clients/*.lua (the raise-LAST + -- clients carry pointer notes back here; keep the two patterns in sync). + -- Shape enforcement itself lives in tools.usable_or_raise; a + -- metadata-declared contract ({ attach_capable, binaries }) was + -- considered and rejected — it would churn the fun(opts) user-override + -- interface for four clients. Revisit if the client count grows. + -- * validate FIRST (top of config): launch AND attach both spawn the local + -- binary, so nothing is worth registering without it — codelldb, lldb. + -- * raise LAST (bottom of config): attach needs no local binary, so the + -- attach-capable adapters are registered BEFORE the check and stay + -- registered when it raises; the raise only signals provisioning (the + -- warning reasons say remote attach still works) — delve, python. + local_config_mode = "validates", + configure = mason_dap_handler, }) end diff --git a/lua/modules/configs/tool/mason-nvim-dap.lua b/lua/modules/configs/tool/mason-nvim-dap.lua new file mode 100644 index 00000000..09178e8e --- /dev/null +++ b/lua/modules/configs/tool/mason-nvim-dap.lua @@ -0,0 +1,11 @@ +return function() + -- Single setup() owner for mason-nvim-dap: reached by the spec's cmd trigger + -- (:DapInstall/:DapUninstall) and by any require through lazy.nvim's module + -- loader (the DAP resolver's factory branch and its mapping reads). setup() + -- registers the :DapInstall/:DapUninstall user commands (api/command.lua has + -- no other require site, and the plugin ships no plugin/ dir). + require("modules.utils").load_plugin("mason-nvim-dap", { + ensure_installed = {}, + automatic_installation = false, + }) +end diff --git a/lua/modules/utils/dap.lua b/lua/modules/utils/dap.lua index 5bebdb9a..dcc28043 100644 --- a/lua/modules/utils/dap.lua +++ b/lua/modules/utils/dap.lua @@ -21,7 +21,77 @@ function M.get_env() return variables end -return setmetatable({}, { +---Validate a remote-attach endpoint's shape at config time — a clear error +---beats an opaque connection failure. Hosts are free-form (hostname/IPv4/ +---IPv6), so only the shape is checked; ports must be integers 1-65535 +---(tonumber-coerced). `opts.default_port` fills an absent port; nil means the +---port is required. An absent host defaults to "127.0.0.1". +---@param config table @The table carrying .host/.port (caller resolves any connect indirection). +---@param opts { label: string, default_port?: integer } @default_port omitted = port required. +---@return string host, integer port +function M.attach_endpoint(config, opts) + -- Actionable config-time errors beat an opaque "attempt to index" deeper in + -- (this helper's whole purpose): guard the two shapes it dereferences. + -- error(..., 0) like every raise below — an assert()'s "dap.lua:N:" prefix + -- points into this helper, not at the caller, so it adds noise, not a lead. + if type(config) ~= "table" then + error("attach_endpoint: config must be a table", 0) + end + if type(opts) ~= "table" or type(opts.label) ~= "string" then + error("attach_endpoint: opts.label (string) is required", 0) + end + -- config.port and opts.default_port share the 1-65535 integer contract, so + -- validate them the same way — an invalid default (0, 70000, "abc") must not + -- slip through to the return. + local function coerce_port(v) + local n = tonumber(v) + if not n or n ~= math.floor(n) or n < 1 or n > 65535 then + return nil + end + return n + end + local port + if config.port ~= nil then + port = coerce_port(config.port) + if not port then + error( + string.format("%s: invalid `port` %s (want an integer 1-65535)", opts.label, vim.inspect(config.port)), + 0 + ) + end + elseif opts.default_port ~= nil then + port = coerce_port(opts.default_port) + if not port then + error( + string.format( + "%s: invalid `default_port` %s (want an integer 1-65535)", + opts.label, + vim.inspect(opts.default_port) + ), + 0 + ) + end + else + error(string.format("%s: `port` is required", opts.label), 0) + end + local host = "127.0.0.1" + if config.host ~= nil then + if type(config.host) ~= "string" or config.host == "" then + error( + string.format("%s: invalid `host` %s (want a non-empty string)", opts.label, vim.inspect(config.host)), + 0 + ) + end + host = config.host + end + return host, port +end + +local export = setmetatable({}, { + -- Lazy nullary double-thunk: `program = utils.input_file_path()` hands + -- nvim-dap a zero-arg closure. Any key reached through this __index + -- DISCARDS call arguments — functions that take arguments must be + -- CONCRETE keys on the export (raw hits bypass __index). __index = function(_, key) return function() return function() @@ -30,3 +100,5 @@ return setmetatable({}, { end end, }) +export.attach_endpoint = M.attach_endpoint +return export From 4150ce8a903fd3af9184514fe04fb316ac37dc79 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 24 Jul 2026 00:24:01 +0800 Subject: [PATCH 06/12] feat(settings): resolver-native dep lists, mason as optional backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move lsp/formatter/linter/dap dep lists to resolver-native names with a tool_install_timeout knob, and guard the removed external_lsp_deps key with a migration warning. mason.lua drops the bootstrap ensure-install loop (the resolvers own installs) and attaches registry events for mid-session install hand-off; mason-nvim-dap becomes a standalone lazy spec so a provisioned session never pays Mason on the :Dap tick. vim.yml teaches selene package.searchpath. Folds later rounds: the external_lsp_deps migration guard relocates to core/migrations.lua (settings.lua stays declarative), and mason.lua's registry acquisition reuses tools.default_registry — the one copy of the guarded load. --- lua/core/migrations.lua | 64 +++++++++++++++++++++++ lua/core/settings.lua | 65 ++++++++++++++++-------- lua/modules/configs/completion/mason.lua | 34 +++---------- lua/modules/plugins/tool.lua | 29 ++++++----- vim.yml | 10 ++++ 5 files changed, 141 insertions(+), 61 deletions(-) create mode 100644 lua/core/migrations.lua diff --git a/lua/core/migrations.lua b/lua/core/migrations.lua new file mode 100644 index 00000000..32022d0d --- /dev/null +++ b/lua/core/migrations.lua @@ -0,0 +1,64 @@ +-- One-off migration guards for settings keys removed from the declarative +-- surface: core/settings.lua calls M.check(merged) once per startup after the +-- user merge. The imperative classification/notify logic lives here so +-- settings.lua stays a pure declarative source of truth; add future removed-key +-- guards as further branches in M.check (same pattern), not in settings.lua. +local M = {} + +---Warn about residue of settings keys the refactors removed. +---@param merged table @The post-merge settings table (read, never written). +function M.check(merged) + -- Migration guard: the discovery-first refactor removed this key; a stale + -- user/settings.lua would merge it in and feed nothing — its servers would + -- vanish without a word. + if merged.external_lsp_deps ~= nil then + -- The removed setting was a MAP of server name -> executable name, but a + -- stale override can survive in any shape: classify before advising so the + -- guidance never presents numeric indices as keys, never drops entries a + -- half-migrated LIST residue still carries, and always names the final + -- step (deleting the dead key). Neither group suppresses the other. + local string_keys, list_items = {}, {} + if type(merged.external_lsp_deps) == "table" then + for k, v in pairs(merged.external_lsp_deps) do + if type(k) == "string" then + string_keys[#string_keys + 1] = k + elseif type(k) == "number" and type(v) == "string" then + list_items[#list_items + 1] = v + end + end + table.sort(string_keys) + table.sort(list_items) + end + local guidance + if #string_keys > 0 and #list_items > 0 then + guidance = "Move its KEYS (" + .. table.concat(string_keys, ", ") + .. ") AND its list entries (" + .. table.concat(list_items, ", ") + .. ")\n— all of them server names — into `lsp_deps`, then delete `external_lsp_deps`." + elseif #string_keys > 0 then + guidance = "Move its KEYS (" + .. table.concat(string_keys, ", ") + .. ") — the server names, not the\n" + .. "executable values — into `lsp_deps`, then delete `external_lsp_deps`." + elseif #list_items > 0 then + guidance = "It now holds a LIST (" + .. table.concat(list_items, ", ") + .. ") — those are already the\n" + .. "server names; move them into `lsp_deps` and delete `external_lsp_deps`." + else + guidance = "It is empty or not a map — delete `external_lsp_deps` from user/settings.lua." + end + -- (Scheduled: the notifier plugin isn't loaded this early; the default + -- notify still lands in :messages.) + vim.schedule(function() + vim.notify( + "`external_lsp_deps` was removed: non-Mason servers are now discovered\nfrom $PATH. " .. guidance, + vim.log.levels.WARN, + { title = "core.settings" } + ) + end) + end +end + +return M diff --git a/lua/core/settings.lua b/lua/core/settings.lua index c9cab8c2..3d7df0bc 100644 --- a/lua/core/settings.lua +++ b/lua/core/settings.lua @@ -57,7 +57,8 @@ settings["disabled_plugins"] = {} -- These settings will override the defaults during initialization. -- Parameters will auto-complete as you type. -- Example: { sky = "#04A5E5" } ----@type palette[] +---@type palette +---@diagnostic disable-next-line: missing-fields settings["palette_overwrite"] = {} -- Set the colorscheme here. @@ -84,23 +85,19 @@ settings["external_browser"] = "chrome-cli open" ---@type boolean settings["lsp_inlayhints"] = false --- LSPs installed outside Mason (e.g. via system package manager). --- These will be configured but not installed by Mason. --- Key: lspconfig server name, Value: executable name to check availability. ----@type table -settings["external_lsp_deps"] = { - nixd = "nixd", - nil_ls = "nil", - shuck = "shuck", -- shell linter/formatter/LSP (Rust); installed via mise, not Mason - -- dartls = "dart", -} - --- LSPs to install during bootstrap. +-- Language servers to enable, resolved discovery-first at runtime: binary on $PATH is +-- used as-is; else Mason installs it when it ships a package; else an aggregated warning +-- asks you to provision it. Names whose filetypes lspconfig knows resolve on that +-- filetype's FIRST buffer (a late sweep classifies the rest once per session); names +-- with user overrides, repo modules that override filetypes, or no filetype data +-- resolve on the first file open. See `modules.utils.tools` and +-- `completion/mason-lspconfig.lua`. -- Full list: https://github.com/neovim/nvim-lspconfig/tree/master/lsp ---@type string[] settings["lsp_deps"] = { "bashls", "clangd", + -- "dartls", -- Dart LSP (ships with the Dart SDK) "dockerls", "gh_actions_ls", -- "gitlab_ci_ls", @@ -111,7 +108,10 @@ settings["lsp_deps"] = { "lua_ls", "marksman", "neocmake", + "nil_ls", -- Nix LSP; the Nix-provisioned $PATH binary is preferred + "nixd", -- Nix LSP (Rust); provisioned from Nix ($PATH) "ruff", + "shuck", -- shell linter/formatter/LSP (Rust); installed via mise by choice ($PATH wins) "systemd_lsp", "terraformls", "tflint", @@ -120,39 +120,56 @@ settings["lsp_deps"] = { "zuban", } --- Formatters to install during bootstrap (Mason package names). --- These are managed by Mason and used by conform.nvim. +-- Formatters to resolve when conform.nvim lazy-loads (first BufWritePre / +-- :Format). conform formatter names, resolved discovery-first like lsp_deps. ---@type string[] settings["formatter_deps"] = { "beautysh", "clang-format", - "cmakelang", + "cmake_format", "fixjson", "gofumpt", "goimports", "mdsf", + "nixfmt", -- Nix formatter; prefer the $PATH binary (Nix) "prettier", "superhtml", "shellharden", + "statix", -- Nix linter, its `fix` mode doubles as a conform formatter; from Nix ($PATH) "stylua", } --- Linters to install during bootstrap (Mason package names). --- These are managed by Mason and used by nvim-lint. +-- Linters to resolve discovery-first (nvim-lint linter names). A name mapped +-- to a filetype resolves on that filetype's FIRST matching event after +-- nvim-lint lazy-loads (the resolve-only FileType autocmd or a lint event) — +-- nothing is installed or warned about before such a buffer opens; unmapped +-- names (typos, manual-only linters) get an immediate deferred pass instead. ---@type string[] settings["linter_deps"] = { "actionlint", + "deadnix", -- Nix dead-code linter; prefer the $PATH binary (Nix) "hadolint", "markdownlint-cli2", "oxlint", -- "rumdl", -- markdownlint Rust rewrite; waiting for rule coverage to mature - "golangci-lint", + "golangcilint", "selene", "shellcheck", + "shuck", -- shell linter for yaml.github `run:` blocks; installed via mise by choice + "statix", -- Nix linter; prefer the $PATH binary (Nix) "systemdlint", + "zsh", -- `zsh -n` syntax check via the system shell itself } --- Debug Adapter Protocol (DAP) clients to install and configure during bootstrap. +-- Deadline (ms) for background Mason work before the aggregated missing-tool warning +-- flushes anyway. Gates each tracked install (its own window) AND the registry refresh +-- wait; late completions still recover. Missing or non-positive values fall back to +-- the resolver's DEFAULT_TOOL_INSTALL_TIMEOUT_MS in `modules/utils/tools.lua`. +---@type number +settings["tool_install_timeout"] = 300000 + +-- DAP adapters to enable (mason-nvim-dap adapter names), resolved +-- discovery-first when nvim-dap lazy-loads (first :Dap* command or debug keymap). -- Supported DAPs: https://github.com/jay-babu/mason-nvim-dap.nvim/blob/main/lua/mason-nvim-dap/mappings/source.lua ---@type string[] settings["dap_deps"] = { @@ -241,4 +258,10 @@ settings["dashboard_image"] = { [[⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠿⠿⢿⠿⠷⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀]], } -return require("modules.utils").extend_config(settings, "user.settings") +local merged = require("modules.utils").extend_config(settings, "user.settings") + +-- Removed-key migration guards live in core/migrations.lua, keeping this file +-- declarative; they only read `merged` and notify. +require("core.migrations").check(merged) + +return merged diff --git a/lua/modules/configs/completion/mason.lua b/lua/modules/configs/completion/mason.lua index 450cdefa..b99db783 100644 --- a/lua/modules/configs/completion/mason.lua +++ b/lua/modules/configs/completion/mason.lua @@ -27,34 +27,14 @@ M.setup = function() }, }) - -- Ensure formatters and linters are installed (only if mason loaded) - local ok, registry = pcall(require, "mason-registry") - if not ok then - return - end - - local settings = require("core.settings") - local ensure_installed = vim.list_extend(vim.deepcopy(settings.formatter_deps), settings.linter_deps) + -- Formatter/linter resolution lives in conform.lua and nvim-lint.lua (against their + -- own registrations); Mason here is UI-only / lazy install fallback. - for _, pkg_name in ipairs(ensure_installed) do - local pkg_ok, pkg = pcall(registry.get_package, pkg_name) - if not pkg_ok then - vim.notify( - string.format("[Mason] Package '%s' not found in registry", pkg_name), - vim.log.levels.WARN, - { title = "Mason" } - ) - elseif not pkg:is_installed() then - pkg:install():once("closed", function() - if not pkg:is_installed() then - vim.notify( - string.format("[Mason] Failed to install '%s'", pkg_name), - vim.log.levels.WARN, - { title = "Mason" } - ) - end - end) - end + -- A user-driven install (:MasonInstall / the :Mason UI) must finish any + -- pending resolver hand-off even when no resolver ever loaded the registry. + local registry = require("modules.utils.tools").default_registry() + if registry then + require("modules.utils.tools").attach_registry_events(registry) end end diff --git a/lua/modules/plugins/tool.lua b/lua/modules/plugins/tool.lua index a5105bb1..90236101 100644 --- a/lua/modules/plugins/tool.lua +++ b/lua/modules/plugins/tool.lua @@ -76,18 +76,6 @@ tool["aaronhallaert/advanced-git-search.nvim"] = { }, } --- tool["amitds1997/remote-nvim.nvim"] = { --- lazy = true, --- version = "*", --- cmd = { "RemoteStart", "RemoteStop", "RemoteInfo", "RemoteCleanup", "RemoteConfigDel", "RemoteLog" }, --- dependencies = { --- "nvim-lua/plenary.nvim", --- "MunifTanjim/nui.nvim", --- "nvim-telescope/telescope.nvim", --- }, --- config = true, --- } - ---------------------------------------------------------------------- -- DAP Plugins -- ---------------------------------------------------------------------- @@ -106,7 +94,6 @@ tool["mfussenegger/nvim-dap"] = { }, config = require("tool.dap"), dependencies = { - { "jay-babu/mason-nvim-dap.nvim" }, { "rcarriga/nvim-dap-ui", dependencies = "nvim-neotest/nvim-nio", @@ -115,6 +102,22 @@ tool["mfussenegger/nvim-dap"] = { }, } +-- mason-nvim-dap only supplies the adapter -> package mappings; the DAP resolver +-- degrades to $PATH discovery when it (or mason.nvim) is absent. Deliberately +-- NOT a dependency of nvim-dap: lazy.nvim loads dependencies together with the +-- parent, and a fully provisioned session must not pay Mason on the :Dap* +-- tick — the DAP config requires it on demand (module loader) instead. +tool["jay-babu/mason-nvim-dap.nvim"] = { + lazy = true, + -- The cmd trigger restores :DapInstall/:DapUninstall (registered only inside + -- setup(), which nothing reaches when every dap_deps adapter has a repo + -- client config); require-path loading and the cmd trigger both funnel + -- through the config below exactly once (lazy marks the plugin loaded + -- before running it). + cmd = { "DapInstall", "DapUninstall" }, + config = require("tool.mason-nvim-dap"), +} + tool["trixnz/sops.nvim"] = { lazy = false, } diff --git a/vim.yml b/vim.yml index 72501f58..2504ced1 100644 --- a/vim.yml +++ b/vim.yml @@ -6,3 +6,13 @@ globals: any: true _debugging: any: true + # LuaJIT ships the Lua 5.2 extension package.searchpath; the lua51 base + # selene std lib doesn't know it. + package.searchpath: + args: + - type: string + - type: string + - type: string + required: false + - type: string + required: false From 635e666cdb55d177dff9774ef0d5ced259ff69ca Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 24 Jul 2026 00:24:01 +0800 Subject: [PATCH 07/12] style(types): align editor-facing annotations with lua_ls The remainder of the zero-warning lua-language-server pass that no subsystem commit owns: suppress undefined-doc-name on blink.lua's three upstream @type names (real upstream types, just outside the workspace library), align snacks.lua's indent-filter doc param with the real (buf, win) signature, and narrow keymap.lua's types (@cast on the modes list; vim.deepcopy(opts or {}), behavior-equivalent for every input shape). --- lua/modules/configs/completion/blink.lua | 3 +++ lua/modules/configs/ui/snacks.lua | 2 +- lua/modules/utils/keymap.lua | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lua/modules/configs/completion/blink.lua b/lua/modules/configs/completion/blink.lua index d1652209..664c33de 100644 --- a/lua/modules/configs/completion/blink.lua +++ b/lua/modules/configs/completion/blink.lua @@ -1,4 +1,5 @@ ---@module 'blink.cmp' +---@diagnostic disable-next-line: undefined-doc-name ---@type blink.cmp.Config local opts = { -- 'default' for mappings similar to built-in completion @@ -19,6 +20,7 @@ local opts = { [""] = { "scroll_documentation_down", "fallback" }, [""] = { "show_signature", "hide_signature", "fallback" }, }, + ---@diagnostic disable-next-line: undefined-doc-name ---@type blink.cmp.CmdlineConfig cmdline = { enabled = true, @@ -176,6 +178,7 @@ local opts = { module = "blink-ripgrep", name = "Ripgrep", ---@module "blink-ripgrep" + ---@diagnostic disable-next-line: undefined-doc-name ---@type blink-ripgrep.Options opts = { prefix_min_len = 3, diff --git a/lua/modules/configs/ui/snacks.lua b/lua/modules/configs/ui/snacks.lua index 2622b148..65e8a1b8 100644 --- a/lua/modules/configs/ui/snacks.lua +++ b/lua/modules/configs/ui/snacks.lua @@ -80,7 +80,7 @@ return function() }, chunk = { enabled = false }, ---@param buf number - ---@param win number + ---@param _ number @Window handle (unused). filter = function(buf, _) local excluded_ft = { [""] = true, diff --git a/lua/modules/utils/keymap.lua b/lua/modules/utils/keymap.lua index 9c10920c..2f79cd27 100644 --- a/lua/modules/utils/keymap.lua +++ b/lua/modules/utils/keymap.lua @@ -101,10 +101,11 @@ end ---@param opts? table @Additional keymap options function M.amend(cond, global_flag, mode, lhs, rhs, opts) local modes = type(mode) == "table" and mode or { mode } + ---@cast modes string[] for _, m in ipairs(modes) do local map = get_map(m, lhs) local fallback = get_fallback(map) - local options = vim.deepcopy(opts) or {} + local options = vim.deepcopy(opts or {}) options.desc = table.concat({ "[" .. cond, (options.desc and ": " .. options.desc or ""), From 70cf3ef04218802d95a637b498d1b8d4c58d2e2b Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 24 Jul 2026 12:07:34 +0800 Subject: [PATCH 08/12] fix(tools): unpark config-layer validates failures in resolve_missing A "validates" local config failing with a raise_verbatim config-layer error was final-marked in phase 2 while its session.pending entry stayed set: the session leaked until restart, and every :ToolsRetry or Mason install event re-ran the broken configure and re-emitted the warning. This contradicted phase 1's own policy (configure_available never parks config-layer errors). Clear session.pending[name] in the config_error sub-case only; provisioning failures keep their legitimate retry paths. Session drop rides finish()'s existing drop_session_if_done sweep, the same pattern as the mark_unknown branches. Found by /code-review (CONFIRMED); plan and diff both passed adversarial review. --- lua/modules/utils/tools.lua | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index 67d2b8be..bcc5ab04 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -1233,6 +1233,12 @@ function M.resolve(spec) -- The "validates" configure ran and failed: attempted-and-failed, -- never typo-migratable. collector.mark(name, validate_reason(name), nil, true) + -- A config-layer error is unfixable by install or retry (phase 1's + -- $PATH branch never parks these — see configure_available): unpark + -- it so the retry gates skip it and the session can drain. + if validates[name].config_error then + session.pending[name] = nil + end end return end From c563b9b768f9391928e6636522bdad8f8005602c Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 24 Jul 2026 12:16:29 +0800 Subject: [PATCH 09/12] refactor(conform): cache only the compiled regex in disabled_matcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-dir cache stored both the compiled vim.regex and the normalized string, but the string's only reader is the rare format_notify warning, where the loop's `dir` is in scope — derive it there with vim.fs.normalize(dir) instead and drop the wrapper table. The lazy-compile contract is unchanged: vim.regex still throws before the cache write, so only successful compiles are cached. Round-8 review finding (PLAUSIBLE); plan and diff both passed adversarial review. --- lua/modules/configs/completion/conform.lua | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/lua/modules/configs/completion/conform.lua b/lua/modules/configs/completion/conform.lua index 363a6b2e..bec00540 100644 --- a/lua/modules/configs/completion/conform.lua +++ b/lua/modules/configs/completion/conform.lua @@ -4,17 +4,15 @@ return function() -- Compile-once cache per configured dir: entries are user input that may -- throw at vim.regex compile time (settings.lua blesses vim-regex strings), -- so compilation stays lazy — on the save path, exactly where it fails today — - -- and only SUCCESSFUL compiles are cached. The normalized string rides along - -- for the notify below. + -- and only SUCCESSFUL compiles are cached. local disabled_dir_cache = {} local function disabled_matcher(dir) - local hit = disabled_dir_cache[dir] - if not hit then - local normalized = vim.fs.normalize(dir) - hit = { regex = vim.regex(normalized), normalized = normalized } - disabled_dir_cache[dir] = hit + local regex = disabled_dir_cache[dir] + if not regex then + regex = vim.regex(vim.fs.normalize(dir)) + disabled_dir_cache[dir] = regex end - return hit + return regex end local format_on_save_enabled = settings.format_on_save local format_notify = settings.format_notify @@ -41,11 +39,10 @@ return function() local function is_disabled_workspace(bufnr) local filedir = vim.fn.fnamemodify(vim.api.nvim_buf_get_name(bufnr), ":h") for _, dir in ipairs(disabled_workspaces) do - local matcher = disabled_matcher(dir) - if matcher.regex:match_str(filedir) ~= nil then + if disabled_matcher(dir):match_str(filedir) ~= nil then if format_notify then vim.notify( - string.format("[Conform] Formatting disabled for files under [%s].", matcher.normalized), + string.format("[Conform] Formatting disabled for files under [%s].", vim.fs.normalize(dir)), vim.log.levels.WARN, { title = "Conform" } ) From eddb602ddbac1f8d9b13af95977fdc1c2b6d9c95 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 24 Jul 2026 12:21:33 +0800 Subject: [PATCH 10/12] refactor(tools): dedupe local-config guard, flatten reason-upgrade gate configure_available evaluated the identical `spec.has_local_config and spec.has_local_config(name)` guard in both the mutually-exclusive "resolves" and "validates" branches; hoist it into one guard and let the mode pick the branch. Call count is unchanged (the old first branch short-circuited on the mode compare before the lookup), and both branch bodies and comments carry over verbatim. missing_collector's add() compressed its reason-upgrade rule into a four-clause compound; split it into two guard clauses (usable-reason check, then first-real-reason-wins) with the finality latch staying ahead of the early returns. Both are behavior-preserving (De Morgan-exact for add(); truth-table-verified for configure_available). Round-8 review findings (PLAUSIBLE); plan gate passed round 2 after re-mapping the regression pins to real-resolver collector scenarios (t2/t3/t9/t11), fix gate passed round 1; all 7 pinned scenarios green pre- and post-edit. --- lua/modules/utils/tools.lua | 53 ++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index bcc5ab04..1510152c 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -348,19 +348,18 @@ local function missing_collector(title, timeout_ms) -- (generic timeout/refresh note, or a reason-less mark) and re-notify — -- also for a name sitting in the UNKNOWN bucket, where the reason is -- unread but the re-emission stands ("unknown wins once assigned": the - -- bucket never flips back). The first REAL reason wins; later ones - -- never overwrite it. - if - type(reason) == "string" - and reason ~= "" - and reason ~= entry.reason - and (entry.reason == nil or entry.provisional) - then - entry.reason = reason - entry.provisional = is_provisional or nil - entry.emitted = nil - flush() + -- bucket never flips back). + if type(reason) ~= "string" or reason == "" or reason == entry.reason then + return + end + -- The first REAL reason wins; later ones never overwrite it. + if entry.reason ~= nil and not entry.provisional then + return end + entry.reason = reason + entry.provisional = is_provisional or nil + entry.emitted = nil + flush() end local function add_unknown(name) name = normalize(name) @@ -1169,19 +1168,23 @@ function M.resolve(spec) end return true end - -- "resolves" only: a binary-less LSP server (jsonls) still maps to a - -- package by NAME and must reach phase 2 to install. A FAILURE here is - -- deliberately NOT parked: every retry gate is structurally closed for - -- a binary-less "resolves" name (no bins for pending_bins_available, - -- no package_of mapping for the install event, validates gate closed - -- by mode), so parking it would leak the session until restart. - if spec.local_config_mode == "resolves" and spec.has_local_config and spec.has_local_config(name) then - do_configure(name) - return true - end - -- A "validates" config is its own resolver: let it try; remember a - -- failure for phase 2 instead of re-running it there. - if spec.local_config_mode == "validates" and spec.has_local_config and spec.has_local_config(name) then + -- Both local-config modes gate on the same lookup: evaluate it once and + -- let the mutually-exclusive mode pick the branch. + local mode = spec.local_config_mode + if (mode == "resolves" or mode == "validates") and spec.has_local_config and spec.has_local_config(name) then + if mode == "resolves" then + -- A binary-less LSP server (jsonls) still maps to a package by + -- NAME and must reach phase 2 to install. A FAILURE here is + -- deliberately NOT parked: every retry gate is structurally + -- closed for a binary-less "resolves" name (no bins for + -- pending_bins_available, no package_of mapping for the install + -- event, validates gate closed by mode), so parking it would + -- leak the session until restart. + do_configure(name) + return true + end + -- A "validates" config is its own resolver: let it try; remember a + -- failure for phase 2 instead of re-running it there. local ok, reason, config_error = try_configure(name) if ok then -- try_configure bypasses do_configure here, so a stale emitted entry From a738f75ffec5cb228cfc0ae862ca8e61f3f37dc4 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Sat, 25 Jul 2026 17:30:52 +0800 Subject: [PATCH 11/12] docs(tools): record template-method deviations in resolve() Name the shape resolve() actually implements (Template Method with steps injected as a spec table) and document the three places it departs from that shape, so a reviewer reading the skeleton is not left inferring the contract from behaviour. The header note lists the deviations with their re-evaluation triggers: variant flags (local_config_mode, static_binaries, defer_phase2) branching inside the skeleton instead of being steps, local_config_mode leaking which retry gates a name can reach, and Mason's API being called straight from the skeleton rather than from behind a step. Each site carries a one-line marker pointing back at the note rather than repeating the argument. Comments only; no behaviour change. --- lua/modules/utils/tools.lua | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index 1510152c..9134ed12 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -1046,6 +1046,29 @@ end ---lose the same-tick guarantee that a replayed trigger's bare Mason spawns ---can resolve — and phase-1 configures inside the scheduled run still count ---as trigger-backed (`late = false`). +--- +---Shape (Template Method, with the steps injected as a spec table instead of by +---inheritance): run() below owns the algorithm and the invariants no consumer +---should have to re-derive — dep normalization and dedup, per-dep pcall +---isolation, the configure-once race gate, the aggregated report, and the "no +---leftovers means mason-registry is never loaded" guarantee. Consumers fill +---named steps only: `configure` is the single required one, the rest are hooks +---with skeleton-side defaults. Three deviations from that shape are accepted +---while the consumer count stays this small: +--- * Variant FLAGS instead of steps — `local_config_mode`, `static_binaries` +--- and `defer_phase2` branch INSIDE the skeleton (marked at each site), so +--- run() carries a taxonomy of its own callers. Consequence: a fourth +--- local-config semantic means editing run() rather than writing a new +--- spec, which is the signal to push the modes down into hooks. +--- * `local_config_mode` leaks skeleton state — choosing it correctly needs +--- knowledge of which retry gates a name can still reach (see the +--- "resolves" branch in configure_available); that contract lives in a +--- comment, not in the type. +--- * Mason is hardwired, not a step — get_package / is_installed / refresh / +--- get_all_package_specs are called straight from the skeleton. This is +--- deliberately a Mason-specific resolver with pluggable per-subsystem +--- judgment, not a generic provisioning one; a SECOND registry-like backend +--- is what would justify an interface here, not the possibility of one. function M.resolve(spec) -- Every call site owns a configure (the resolver's whole job funnels into -- it): a missing one is a programmer error — fail at the call site instead @@ -1170,6 +1193,8 @@ function M.resolve(spec) end -- Both local-config modes gate on the same lookup: evaluate it once and -- let the mutually-exclusive mode pick the branch. + -- Flag-shaped variant (see the shape note on M.resolve): the skeleton + -- switches on WHICH KIND of consumer this is instead of calling a step. local mode = spec.local_config_mode if (mode == "resolves" or mode == "validates") and spec.has_local_config and spec.has_local_config(name) then if mode == "resolves" then @@ -1179,7 +1204,9 @@ function M.resolve(spec) -- closed for a binary-less "resolves" name (no bins for -- pending_bins_available, no package_of mapping for the install -- event, validates gate closed by mode), so parking it would - -- leak the session until restart. + -- leak the session until restart. This reasoning is the leaked + -- contract the shape note names: a consumer cannot pick + -- "resolves" correctly without knowing these gates exist. do_configure(name) return true end @@ -1203,6 +1230,11 @@ function M.resolve(spec) ---Phase 2 — resolve a dep against the now-ready registry: configure an ---installed package, install a missing one, or mark it missing/unknown. + ---Mason's API (get_package / is_installed, plus refresh and + ---get_all_package_specs upstream of it) is called from the skeleton rather + ---than from behind a step — the hardwired-backend deviation in the shape + ---note on M.resolve. Swapping in another provisioning backend means editing + ---this function, not supplying a different spec. ---@param name string ---@param registry table|nil local function resolve_missing(name, registry) @@ -1258,6 +1290,7 @@ function M.resolve(spec) -- the probe would be identical to phase 1's. Accepted delta: an external -- install landing inside the phase-2 window proceeds to a redundant -- (self-healing) Mason install instead of being caught here. + -- Second flag-shaped variant (see the shape note on M.resolve). if pkg ~= nil and not spec.static_binaries and M.find_executable(spec.binaries_of(name, pkg)) ~= nil then do_configure(name) return @@ -1417,6 +1450,8 @@ function M.resolve(spec) -- decode off the lazy-load trigger's tick; their late configures -- have their own catch-up paths (enable() attaches open buffers, -- lint re-runs itself). + -- Third flag-shaped variant (see the shape note on M.resolve): the + -- skeleton, not a step, decides scheduling per consumer kind. vim.schedule(finish) else -- DAP configures IN phase 2, and a cmd-triggered lazy-load replays From 65e8ea5422d6ac9a79d33782bcae4ac6a6d9e335 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Wed, 22 Jul 2026 02:03:17 +0800 Subject: [PATCH 12/12] fix(conform): evaluate function-form commands at probe time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trusting a function-form command blindly as self-resolving let the prettier dep silently leave the install/warn contract: on a fresh machine with no node_modules copy and no global binary, nothing would ever install it or name it in the aggregated warning (a regression against main's ensure_installed loop). Evaluate the function against the probe-time buffer the way conform will: a project-local node_modules bin passes the $PATH check as-is, the bare-name fallback re-enters install/warn via the bin index, and a failed or non-string evaluation degrades to the previous self-resolving behavior — never a typo report. --- lua/modules/configs/completion/conform.lua | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/lua/modules/configs/completion/conform.lua b/lua/modules/configs/completion/conform.lua index bec00540..e8122ceb 100644 --- a/lua/modules/configs/completion/conform.lua +++ b/lua/modules/configs/completion/conform.lua @@ -212,12 +212,23 @@ return function() return { broken = tostring(config) } end if config then - -- A function-form command resolves per buffer at format time (e.g. the - -- builtin from_node_modules): treat it as self-resolving rather than - -- evaluating it for a representative binary — a node_modules command - -- shouldn't map to a Mason install anyway. + -- A function-form command (the builtin from_node_modules) resolves per + -- buffer at format time, so evaluate it against the probe-time buffer + -- the same way conform will: a project-local node_modules bin passes + -- the $PATH check as-is, and the bare-name FALLBACK ("prettier") keeps + -- the dep inside the install/warn contract — trusting the function + -- blindly would let a fresh machine with no copy anywhere silently + -- skip both the Mason install and the aggregated warning. A failed or + -- non-string evaluation degrades to self-resolving (no install/warn), + -- never to a typo report. if type(config.command) == "function" then - return { binary = nil } + local fname = vim.api.nvim_buf_get_name(0) + local cmd_ok, cmd = pcall(config.command, config, { + buf = vim.api.nvim_get_current_buf(), + filename = fname, + dirname = fname ~= "" and vim.fs.dirname(fname) or vim.fn.getcwd(), + }) + return { binary = (cmd_ok and type(cmd) == "string" and cmd ~= "") and cmd or nil } end return { binary = config.command } end