From 666c81ca5f052c49fbd84f262541477690cd9fd3 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Thu, 16 Jul 2026 18:27:03 +0800 Subject: [PATCH 01/35] feat(utils): add discovery-first tool resolution helpers Shared resolution loop for LSP/DAP/formatter/linter deps: probe $PATH and Mason's bin dir first, fall back to a Mason install, and aggregate every unresolved tool into one warning with a configurable deadline (settings.tool_install_timeout). Teach selene about package.searchpath, which module_path uses. --- lua/core/settings.lua | 7 + lua/modules/utils/tools.lua | 835 ++++++++++++++++++++++++++++++++++++ vim.yml | 10 + 3 files changed, 852 insertions(+) create mode 100644 lua/modules/utils/tools.lua diff --git a/lua/core/settings.lua b/lua/core/settings.lua index b4a8c812..123448fe 100644 --- a/lua/core/settings.lua +++ b/lua/core/settings.lua @@ -152,6 +152,13 @@ settings["linter_deps"] = { "systemdlint", } +-- Deadline (ms) for background Mason work (installs, first registry refresh) +-- before the aggregated missing-tool warning is flushed anyway, so one stalled +-- download can't suppress it all session. A finished install past the deadline +-- is still configured. Non-positive values fall back to the default. +---@type number +settings["tool_install_timeout"] = 300000 + -- Debug Adapter Protocol (DAP) clients to install and configure during bootstrap. -- Supported DAPs: https://github.com/jay-babu/mason-nvim-dap.nvim/blob/main/lua/mason-nvim-dap/mappings/source.lua ---@type string[] diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua new file mode 100644 index 00000000..7ae12523 --- /dev/null +++ b/lua/modules/utils/tools.lua @@ -0,0 +1,835 @@ +-- Discovery-first tool resolution helpers (RFC: ayamir/nvimdots#1293). +-- Mason is an optional installer backend, not a hard requirement: every +-- external tool (LSP server, formatter, linter, DAP adapter) resolves as +-- $PATH → Mason install → surfaced to the user, and all subsystems share the +-- resolution loop (`M.resolve`) plus one aggregated missing-tool warning. +local M = {} + +-- In-flight Mason install handles, keyed by package name. One package can back +-- several subsystems (e.g. `superhtml` is both LSP server and formatter), and a +-- second install() while one runs trips mason's `assert(not self:is_installing())`. +---@type table +local installing = {} + +---Locate the first of the given executables: on $PATH, then in Mason's bin +---directory (covers Mason installs with `PATH = "skip"`, where the binary +---exists but a plain exepath() misses it). Intentionally *first-found*, not +---*all*: callers pass alternates for one tool, and requiring all would +---false-negative packages that declare auxiliary 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) + if type(names) == "string" then + names = { names } + end + -- exepath() throws on non-string/empty args; iterate to maxn, not ipairs, so + -- a nil hole from a conditional entry doesn't truncate the search. + if type(names) ~= "table" then + return nil + end + local last = table.maxn(names) + for i = 1, last do + local name = names[i] + if type(name) == "string" and name ~= "" then + local path = vim.fn.exepath(name) + if path ~= "" then + return path + end + end + end + local root = M.mason_root() + if root then + for i = 1, last do + local name = names[i] + if type(name) == "string" and name ~= "" then + -- exepath() validates executability and resolves the Windows extension + -- (mason bin shims are `.cmd` there). + local path = vim.fn.exepath(root .. "/bin/" .. name) + if path ~= "" then + return path + end + end + end + end + return nil +end + +---Locate a module file on the search paths require() uses, WITHOUT executing +---it. Probes both package.path (covers `completion.servers.*` via the entries +---core/pack.lua appends) and the runtimepath loader (covers `user.*`). +---@param module string +---@return string|nil +function M.module_path(module) + local path = package.searchpath(module, package.path) + if path then + return path + end + local base = "lua/" .. module:gsub("%.", "/") + local hits = vim.api.nvim_get_runtime_file(base .. ".lua", false) + if #hits == 0 then + hits = vim.api.nvim_get_runtime_file(base .. "/init.lua", false) + end + return hits[1] +end + +-- Modules already reported broken, so multi-site lookups notify once per module. +local broken_module_reported = {} + +---Require the first loadable module from an ordered candidate list (user +---override before repo default). Returns its value, or nil when none load +---(config modules return non-nil, so nil unambiguously means "not found"). +---A candidate that exists but throws at load is a broken config, not a missing +---one: its error is notified (once) and the lookup falls through. +---@param candidates string[] @Module names, highest precedence first. +---@param title? string @Notification title for broken-module load errors. +---@return any|nil @The first module's value, or nil if none loaded. +function M.first_module(candidates, title) + for _, module in ipairs(candidates) do + local ok, value = pcall(require, module) + if ok and value ~= nil then + return value + end + if not ok and not broken_module_reported[module] and M.module_path(module) then + broken_module_reported[module] = true + vim.notify( + string.format("Failed to load `%s`:\n%s", module, value), + vim.log.levels.ERROR, + { title = title or "tools" } + ) + end + end + return nil +end + +---Absolute path to spawn a tool by when its bare name isn't usable as-is. +---Non-nil only when the binary is off $PATH but present in Mason's bin dir +---(the `PATH = "skip"` case, where a bare-name spawn would fail at runtime); +---nil when the bare name already works or nothing resolves at all. +---@param binary string @The bare executable name a tool is configured to launch. +---@return string|nil @Absolute path to spawn by, or nil to keep the bare name. +function M.off_path_command(binary) + if type(binary) ~= "string" or binary == "" or vim.fn.executable(binary) == 1 then + return nil + end + return M.find_executable(binary) +end + +---Resolve the first of the given executable names (see `find_executable`), or +---`error()` with an install hint — config-time self-validation for client/server +---configs, whose message the shared resolver aggregates. Thrown at level 0 so +---it arrives without a "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) + if type(names) == "string" then + names = { names } + end + local path = M.find_executable(names) + if path then + return path + end + -- Render only well-formed entries: table.concat throws on non-strings/nil + -- holes. maxn to match the probe above. + local shown = {} + if type(names) == "table" then + for i = 1, table.maxn(names) do + local name = names[i] + if type(name) == "string" and name ~= "" then + shown[#shown + 1] = name + end + end + end + local label = #shown > 0 and table.concat(shown, "/") or "" + error(string.format("%s not found on $PATH; %s", label, hint), 0) +end + +---Create a collector that aggregates tools which could not be set up into one +---deferred warning instead of one notification per tool. Two sections, so the +---guidance matches the cause: `mark` (install it / config failed, with an +---optional inline reason) and `mark_unknown` (unrecognized name — fix the +---config, don't install). +---@class ToolCollector +---@field mark fun(name: string, reason?: string) @Record an unresolved tool (optionally with a reason). +---@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) @Track an async Mason install. +---@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) + local missing = {} + local reasons = {} + local unknown = {} + local queued = {} + local seen = {} + local emitted = {} + local pending = 0 + local deadline_started = false + local deadline_passed = false + + -- De-duplicated across both buckets. A non-string name (malformed deps entry) + -- is tostring()'d so it still surfaces; nil/empty strings are 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 record(bucket, name) + if name == nil or seen[name] then + return false + end + seen[name] = true + bucket[#bucket + 1] = name + return true + end + local function add(name, reason) + name = normalize(name) + if record(missing, name) and type(reason) == "string" and reason ~= "" then + reasons[name] = reason + end + end + local function add_unknown(name) + record(unknown, normalize(name)) + end + + local function render(name) + local reason = reasons[name] + return reason and (name .. " — " .. reason) or name + end + + -- Emit any collected names not yet notified. No-op while installs are pending + -- unless the deadline passed; the `emitted` set lets failures arriving after + -- the deadline still get their own notification. + local function flush() + if pending > 0 and not deadline_passed then + return + end + local missing_new, unknown_new = {}, {} + for _, name in ipairs(missing) do + if not emitted[name] then + emitted[name] = true + missing_new[#missing_new + 1] = name + end + end + for _, name in ipairs(unknown) do + if not emitted[name] then + emitted[name] = true + unknown_new[#unknown_new + 1] = name + 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 • ") + 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 + local message = table.concat(sections, "\n\n") + vim.schedule(function() + vim.notify(message, vim.log.levels.WARN, { title = title }) + end) + end + + return { + mark = add, + mark_unknown = add_unknown, + track = function(pkg, name, recheck, on_ready, fail_reason) + local pkg_name = type(pkg) == "table" and pkg.name or nil + local handle = pkg_name and installing[pkg_name] or nil + + -- A cached handle may already be closed (mason emits "closed" synchronously, + -- the cache clears next tick) and its emitter never fires once() after close, + -- which would leak `pending`. Drop it and resolve a fresh handle. + if handle then + local ok_closed, closed = pcall(function() + return handle:is_closed() + end) + if not ok_closed or closed then + installing[pkg_name] = nil + handle = nil + end + end + + -- Already installing elsewhere (another subsystem / a manual install): + -- attach to that handle instead of starting a duplicate install. + if not handle and type(pkg.is_installing) == "function" then + local ok_ing, is_ing = pcall(function() + return pkg:is_installing() + end) + if ok_ing and is_ing and type(pkg.get_install_handle) == "function" then + local ok_h, opt = pcall(function() + return pkg:get_install_handle() + end) + if ok_h and type(opt) == "table" and type(opt.if_present) == "function" then + opt:if_present(function(h) + handle = h + end) + end + end + end + + -- Only a self-started install is announced in the "Installing N tool(s)" + -- INFO; attaching to a shared/manual install must not claim it. + 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 + add(name, fail_reason) + return + end + handle = h + if pkg_name then + installing[pkg_name] = handle + end + elseif type(handle.once) ~= "function" then + -- Shared handle isn't usable (unexpected shape); mark rather than hang. + add(name, fail_reason) + return + end + + pending = pending + 1 + -- Arm the deadline on the first tracked install so an install that never + -- closes can't suppress the aggregated warning all session; late failures + -- past the deadline still notify via flush's `emitted` set. + if not deadline_started and type(timeout_ms) == "number" and timeout_ms > 0 then + deadline_started = true + vim.defer_fn(function() + deadline_passed = true + flush() + end, timeout_ms) + end + -- Queue only real strings (done() sorts `queued`; a non-string would make + -- the sort throw) and only self-started installs. Remember whether we + -- appended so the once()-throw path below pops the right entry. + local queued_here = started_here and type(name) == "string" and name ~= "" + if queued_here then + queued[#queued + 1] = name + end + -- "closed" fires in a fast event context where Vim APIs are unsafe: hop to + -- the main loop. Both callbacks are pcall'd and pending is decremented + -- unconditionally so a throw can't suppress the aggregated warning. + local registered = pcall( + handle.once, + handle, + "closed", + vim.schedule_wrap(function() + if pkg_name then + installing[pkg_name] = nil + end + local rc_ok, available = pcall(recheck) + if rc_ok and available then + if type(on_ready) == "function" then + pcall(on_ready) + end + else + add(name, fail_reason) + end + pending = pending - 1 + flush() + end) + ) + -- once() threw: no callback will ever decrement, so undo the accounting and + -- drop the cached handle (or later resolutions would reuse it, never retrying). + if not registered then + pending = pending - 1 + if queued_here then + queued[#queued] = nil + end + if pkg_name then + installing[pkg_name] = nil + end + add(name, fail_reason) + end + end, + done = function() + -- One aggregated INFO so a first launch shows progress and a "relaunch" + -- hint instead of silently downloading in the background. + if #queued > 0 then + 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 • ") + ) + vim.schedule(function() + vim.notify(message, vim.log.levels.INFO, { title = title }) + end) + end + flush() + end, + } +end + +---Find the Mason package that ships the given binary, via a lazily-built +---bin -> package-name index over the registry specs. Install-fallback reverse +---lookup for subsystems whose deps carry a ground-truth binary (conform / +---nvim-lint): tool name, binary, and package name may all differ +---(cmake_format -> cmake-format -> cmakelang). Module-level cache, shared +---across subsystems for the session. +---@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 bin_to_package = nil +local function package_for_binary(registry, binary) + -- Cache only a *populated* index: a scan against a not-yet-bootstrapped + -- registry yields nothing, and caching that empty table would disable the + -- install fallback for the rest of the session. + if bin_to_package == nil then + 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 then + bin_to_package = index + else + return nil + end + end + return bin_to_package[binary] +end + +---Collect the executable name(s) a Mason package provides, from its spec. +---Without a `bin` table, prefer `pkg.name` over the caller-supplied name: the +---subsystem name can differ from the package (adapter `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 = {} + if type(pkg.spec) == "table" and type(pkg.spec.bin) == "table" then + for bin_name, _ in pairs(pkg.spec.bin) do + bins[#bins + 1] = bin_name + end + end + if #bins == 0 then + bins = { type(pkg.name) == "string" and pkg.name or fallback } + end + return bins +end + +---Mason's package install root, or nil when Mason isn't available. Settings +---API first — `$MASON` is only set as a side effect of `mason.setup()`. The +---path is resolved once (false = Mason absent; failed requires aren't cached +---by Lua, so re-probing would repeat the failed require on every $PATH miss). +---@return string|nil +local mason_root_dir = nil +function M.mason_root() + if mason_root_dir == nil then + local ok, settings = pcall(require, "mason.settings") + if ok and settings.current and type(settings.current.install_root_dir) == "string" then + mason_root_dir = settings.current.install_root_dir + elseif type(vim.env.MASON) == "string" and vim.env.MASON ~= "" then + mason_root_dir = vim.env.MASON + else + mason_root_dir = false + end + end + -- Existence stays re-checked: on a fresh setup the configured dir only + -- appears after the first install. + if mason_root_dir and vim.uv.fs_stat(mason_root_dir) then + return mason_root_dir + end + return nil +end + +---Shared discovery-first resolution loop for a subsystem's desired tools. +---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_config` local +--- config tries first; else if the package's declared bins are on $PATH, +--- configure without installing; else install, then configure on completion. +--- 3. No Mason package but a local config exists -> configure now (the config +--- self-validates its binary and `error()`s if absent). +--- 4. Otherwise -> aggregated install warning +--- (or `mark_unknown` for a stale Mason mapping with no known binary). +--- +---Everything resolvable without an install is configured synchronously, in the +---same tick, so lazy-loaded subsystems see their tools registered before +---lazy.nvim replays the trigger; only installs defer. `configure` is optional +---and pcall'd — as is each dep — so one failure can't abort the rest. +---@param spec { +--- title: string, +--- deps: string[], +--- registry: table|nil, +--- package_of: fun(name: string): string|nil, +--- binaries_of: fun(name: string, pkg: table|nil): string[], +--- unknown_of?: fun(name: string): boolean, +--- has_local_config?: fun(name: string): boolean, +--- validates_config?: boolean, +--- binaryless_self_resolves?: boolean, +--- configure?: fun(name: string), +---} +function M.resolve(spec) + 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 300000 + local collector = missing_collector(spec.title, timeout_ms) + + -- The registry may be a value, nil, or a lazy thunk. Resolved (memoized) only + -- on first need, so a fully-provisioned subsystem never loads Mason at all. + local registry_source = spec.registry + local registry_pending = type(registry_source) == "function" + local registry_value = not registry_pending and registry_source or nil + local function get_registry() + if registry_pending then + registry_pending = false + local ok, resolved = pcall(registry_source) + registry_value = ok and resolved or nil + end + return registry_value + end + + -- Strip the "chunkname:line: " prefix a bare `error(msg)` adds; `error(msg, 0)` + -- messages pass through untouched. + local function strip_position(err) + if type(err) ~= "string" then + return nil + end + return (err:gsub("^[^\n]-:%d+: ", "")) + end + + ---Configure one tool. Returns true, or false plus the cleaned error when the + ---config threw; callers decide whether that is final or an install trigger. + local function try_configure(name) + if type(spec.configure) ~= "function" then + return true + end + local ok, err = pcall(spec.configure, name) + if ok then + return true + end + return false, strip_position(err) + end + + -- Configure one tool, surfacing a config-time error as a missing entry + -- annotated with the error message. + local function do_configure(name) + local ok, reason = try_configure(name) + if not ok then + collector.mark(name, reason) + end + end + + -- Cleaned error from a `validates_config` config that phase 1 ran and that + -- failed, so phase 2 can surface it WITHOUT re-running the config (and its + -- side effects / blocking probes). `false` = failed with no message. + local validate_failed = {} + + -- Install `pkg` (backing tool `name`), configure on completion; a failed + -- install is annotated with the phase-1 validation error. + local function start_install(pkg, name) + local reason = type(validate_failed[name]) == "string" and validate_failed[name] or nil + 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: on $PATH / + ---in Mason's bin dir, a self-resolving local config, or a `validates_config` + ---resolver that succeeds. Returns false when the dep needs the registry to + ---decide install-vs-missing (phase 2). Never marks a tool missing and never + ---forces the lazy registry, so a fully-provisioned subsystem finishes here — + ---synchronously, which the same-tick trigger replay depends on. + ---@param name string + ---@return boolean handled + local function configure_available(name) + local bins = spec.binaries_of(name, nil) or {} + if M.find_executable(bins) ~= nil then + do_configure(name) + return true + end + -- Binary-less self-resolving config (function command / pure-Lua). Gated on + -- binaryless_self_resolves: a binary-less LSP server (function `cmd`, e.g. + -- jsonls) still maps to a package by NAME and must reach phase 2 to install. + if #bins == 0 and spec.binaryless_self_resolves and spec.has_local_config and spec.has_local_config(name) then + do_configure(name) + return true + end + -- A validates_config config is its own discovery-first resolver (venv / + -- system interpreter): let it try; on failure remember the error and defer + -- to phase 2 (which won't re-run it just to report the failure). + if spec.validates_config and spec.has_local_config and spec.has_local_config(name) then + local ok, reason = try_configure(name) + if ok then + return true + end + validate_failed[name] = reason or false + end + return false + end + + ---Phase 2 — resolve a dep phase 1 could not, against the now-ready registry: + ---configure an already-installed package, install a missing one, or mark it + ---missing / unknown. + ---@param name string + ---@param registry table|nil + local function resolve_missing(name, registry) + -- Unknown names are judged HERE, after the registry refresh: unknown_of may + -- consult the Mason mapping, which is empty on a never-bootstrapped registry — + -- a valid server known only via its mapping would be misread as a typo. + if spec.unknown_of and spec.unknown_of(name) then + collector.mark_unknown(name) + return + end + + local bins = spec.binaries_of(name, nil) or {} + 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 + -- Mapping points at a package the registry doesn't have (stale mapping); + -- report it only if nothing below resolves the tool. + pkg_unknown = true + end + 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 (pkg.spec.bin) are already on $PATH: the + -- tool is system-provided, don't install a duplicate. This is the generic + -- probe for tools whose launch binary can't be read statically (function-cmd + -- LSP servers like jsonls). + if pkg ~= nil 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: surface the phase-1 validation error without + -- re-running the config, else let a local config self-validate, else mark + -- missing (or unknown, for a bad mapping with no known binary). + if validate_failed[name] ~= nil then + collector.mark(name, type(validate_failed[name]) == "string" and validate_failed[name] or nil) + elseif spec.has_local_config and spec.has_local_config(name) then + do_configure(name) + elseif pkg_unknown and #bins == 0 then + collector.mark_unknown(pkg_name == name and name or (pkg_name .. " (for " .. name .. ")")) + else + collector.mark(name) + end + end + + -- Whether the registry's package specs are already on disk. When we can't + -- introspect (API drift), err toward refreshing: a redundant refresh is a + -- cheap no-op, skipping a needed one loses every auto-install. + 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 + + local function run() + -- A non-table `deps` (user override gone wrong) would throw out of run() + -- and abort the caller's whole config; degrade to "nothing to resolve". + if type(spec.deps) ~= "table" then + collector.done() + return + end + -- Phase 1: configure everything resolvable without the registry. + local visited = {} + local unresolved = {} + -- maxn, not ipairs: a nil hole must skip a slot, not end resolution. + for i = 1, table.maxn(spec.deps) do + local name = spec.deps[i] + -- Dedup (a duplicate would double-install) and isolate one dep's failure + -- from the rest. + if name ~= nil and not visited[name] then + visited[name] = true + local ok, handled = pcall(configure_available, name) + if not ok then + collector.mark(name, strip_position(handled)) + elseif handled ~= true then + unresolved[#unresolved + 1] = name + end + end + end + + -- Everything on $PATH / self-resolving is now configured (same tick). If + -- nothing is left, Mason stays unloaded. + if #unresolved == 0 then + collector.done() + return + end + + -- Phase 2: resolve the leftovers against the registry, refreshing first on a + -- never-bootstrapped one (its specs aren't on disk, so get_package, the + -- mapping, and installs would all fail). Only these leftovers wait for the + -- network — never the synchronous registration above. + local registry = get_registry() + local function finish() + for _, name in ipairs(unresolved) do + local ok, err = pcall(resolve_missing, name, registry) + if not ok then + collector.mark(name, strip_position(err)) + end + end + collector.done() + end + if registry and type(registry.refresh) == "function" and not registry_bootstrapped(registry) then + -- refresh() only calls back on completion or failure — a *stalled* + -- transfer fires neither, and nothing downstream would ever run. Arm a + -- deadline that reports the leftovers instead of staying silent all + -- session; `settled` makes callback and deadline mutually exclusive. + local settled = false + local function on_refreshed() + if settled then + return + end + settled = true + finish() + end + vim.defer_fn(function() + if settled then + return + end + settled = true + for _, name in ipairs(unresolved) do + local reason = type(validate_failed[name]) == "string" and validate_failed[name] + or "Mason registry refresh did not complete (cannot classify or auto-install)" + collector.mark(name, reason) + end + collector.done() + end, timeout_ms) + -- pcall so a synchronous throw can't abort the caller's config; the + -- callback arrives in a fast event context, hop to the main loop. + 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 + else + finish() + end + end + + run() +end + +---Discovery-first resolution for a subsystem whose own runtime registrations +---are the ground truth (conform formatters, nvim-lint linters). Each dep is +---the tool's name *in that subsystem* (same semantics as lsp_deps/dap_deps). +---`probe(name)` returns: +--- * nil -> unknown name (typo / not registered) -> fix config. +--- * { binary = "x" } -> the tool invokes executable "x" ($PATH-probeable). +--- * { binary = nil } -> the tool resolves its own command at runtime. +---Mason enters only as the install fallback, required lazily; the package is +---reverse-looked-up from the binary (see `package_for_binary`), so tool name, +---binary, and package name are free to differ. +---@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 }|nil +---@param configure? fun(name: string) @Optional: run for each available/local tool +--- (e.g. rewrite its command to an absolute path under Mason `PATH = "skip"`). +function M.resolve_runtime_tools(title, deps, probe, configure) + 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 = type(result.binary) == "string" and result.binary or nil } + else + -- A probe error is treated as unknown: the guidance either way is to + -- fix the config entry, not to install something. + cache[name] = { known = false } + end + end + return cache[name] + end + + M.resolve({ + title = title, + deps = deps, + -- Lazy thunk: only required once a known tool's binary is actually missing, + -- so a fully-provisioned (or Mason-less) setup never loads mason-registry. + registry = function() + local ok, resolved = pcall(require, "mason-registry") + return ok and resolved or nil + end, + 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, + unknown_of = function(name) + return not info(name).known + end, + has_local_config = function(name) + local i = info(name) + return i.known and i.binary == nil + end, + -- A binary-less runtime tool can't be reverse-looked-up to a package, so it + -- genuinely self-resolves (unlike a binary-less LSP server, which maps by name). + binaryless_self_resolves = true, + configure = configure, + }) +end + +return M 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 fe6faa0437e4d4c68a1f22b78adff9b4693aa133 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Thu, 16 Jul 2026 18:27:11 +0800 Subject: [PATCH 02/35] refactor(lsp): resolve language servers discovery-first Fold external_lsp_deps into a single lsp_deps list resolved via the shared loop: probe manual specs and lspconfig cmds for binaries, install via Mason when a package exists, rewrite off-$PATH cmds before enabling, and route typos to the unknown-name bucket. Registration stays synchronous; only installs defer. --- lua/core/settings.lua | 21 +- lua/modules/configs/completion/lsp.lua | 30 +- .../configs/completion/mason-lspconfig.lua | 290 ++++++++++++++---- .../configs/completion/servers/shuck.lua | 14 +- 4 files changed, 255 insertions(+), 100 deletions(-) diff --git a/lua/core/settings.lua b/lua/core/settings.lua index 123448fe..3fae4339 100644 --- a/lua/core/settings.lua +++ b/lua/core/settings.lua @@ -84,23 +84,17 @@ 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 — one flat list, resolved discovery-first at +-- runtime: binary on $PATH (system / Nix / Mason) → used as-is; else Mason +-- installs it when it ships a package; else an aggregated warning asks you to +-- provision it (home-manager switch / mise install for package-less servers). +-- 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 +105,10 @@ settings["lsp_deps"] = { "lua_ls", "marksman", "neocmake", + "nil_ls", -- Nix LSP; prefer the $PATH binary (Nix), else Mason installs it (package `nil`) + "nixd", -- Nix LSP (Rust); no Mason package, comes from Nix ($PATH) "ruff", + "shuck", -- shell linter/formatter/LSP (Rust); no Mason package, installed via mise "systemd_lsp", "terraformls", "tflint", diff --git a/lua/modules/configs/completion/lsp.lua b/lua/modules/configs/completion/lsp.lua index 866260b3..f6b7b5a2 100644 --- a/lua/modules/configs/completion/lsp.lua +++ b/lua/modules/configs/completion/lsp.lua @@ -1,29 +1,13 @@ return function() + -- Server resolution (Mason-installed / on $PATH / installable / missing) is + -- handled centrally and discovery-first in `mason-lspconfig.setup`, driven by + -- the single `settings.lsp_deps` list. 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 - + -- NOTE: a server Mason auto-installs mid-session registers only when its + -- install finishes — after this point — so a `vim.lsp.config` override in + -- `user.configs.lsp` for it gets overwritten by the repo spec. Overrides that + -- must always win belong in `user.configs.lsp-servers.`. pcall(require, "user.configs.lsp") -- Start LSPs diff --git a/lua/modules/configs/completion/mason-lspconfig.lua b/lua/modules/configs/completion/mason-lspconfig.lua index fa3d2a4e..b2851c9d 100644 --- a/lua/modules/configs/completion/mason-lspconfig.lua +++ b/lua/modules/configs/completion/mason-lspconfig.lua @@ -1,15 +1,29 @@ 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, - }) + local settings = require("core.settings") + -- Mason is an optional installer backend: guard its requires so a Mason-less + -- setup still resolves servers from $PATH instead of hard-erroring here. + local has_registry, mason_registry = pcall(require, "mason-registry") + local has_mlsp, mason_lspconfig = pcall(require, "mason-lspconfig") + local mason_ok = has_registry and has_mlsp + local tools = require("modules.utils.tools") + + ---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 + + local module_path = tools.module_path + + -- Servers configured by a dedicated plugin, never by lspconfig here. + -- Keyed by server name -> the plugin that owns it. + ---@type table + local externally_managed = { + rust_analyzer = "mrcjkb/rustaceanvim", + } vim.diagnostic.config({ signs = true, @@ -21,45 +35,120 @@ M.setup = function() local opts = { capabilities = require("modules.utils").get_lsp_capabilities(), } - ---A handler to setup all servers defined under `completion/servers/*.lua` + + ---Probe a server's manual spec once and cache both the facts the resolver's + ---predicates need and the loaded spec values (mason_lsp_handler consumes them + ---instead of re-requiring). `binary` prefers an explicit table `cmd` (user + ---override, repo default, then nvim-lspconfig's); nil for a function/absent + ---cmd — such a spec resolves its own command at launch. + local server_info_cache = {} + ---@param name string + ---@return { has_module: boolean, binary: string|nil, known_lspconfig: boolean, user_loaded: boolean, user_spec: any, default_loaded: boolean, default_spec: any } + 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, + user_loaded = false, + user_spec = nil, + default_loaded = false, + default_spec = nil, + } + local modules = server_modules(name) + ---A spec file that exists but throws at load is a broken config, not a typo: + ---count it as a module (out of the unknown-name bucket) and surface the error. + ---@param module string + ---@param err any @The pcall error for the failed require. + ---@return boolean @Whether the module file exists on package.path. + local function report_broken_spec(module, err) + if not module_path(module) then + return false + end + vim.notify( + string.format("Failed to load `%s`:\n%s", module, err), + vim.log.levels.ERROR, + { title = "nvim-lspconfig" } + ) + return true + end + local user_ok, user_spec = pcall(require, modules[1]) + if user_ok then + info.has_module = true + info.user_loaded = true + info.user_spec = user_spec + if type(user_spec) == "table" and type(user_spec.cmd) == "table" then + info.binary = user_spec.cmd[1] + end + elseif report_broken_spec(modules[1], user_spec) then + info.has_module = true + end + -- Load the repo preset only when the handler could use it (no override, or + -- as the merge base under a table override): a function-form override + -- replaces it wholesale, and requiring it anyway would run the preset's + -- module-level code for a spec that cannot be used. + if not user_ok or type(user_spec) == "table" then + local ok, spec = pcall(require, modules[2]) + if ok then + info.has_module = true + info.default_loaded = true + info.default_spec = spec + if info.binary == nil and type(spec) == "table" and type(spec.cmd) == "table" then + info.binary = spec.cmd[1] + end + elseif report_broken_spec(modules[2], spec) then + info.has_module = true + end + end + -- nvim-lspconfig's built-in config: a table cmd yields the launch binary, + -- and any cmd (even a function) proves the name is a real server — keeping + -- function-cmd servers (e.g. jsonls) out of the unknown-name bucket. + local ok, config = pcall(function() + return vim.lsp.config[name] + end) + if ok and type(config) == "table" and config.cmd ~= nil then + info.known_lspconfig = true + if info.binary == nil and type(config.cmd) == "table" then + info.binary = config.cmd[1] + end + end + server_info_cache[name] = info + return info + end + + ---Register a server's config from the specs under `completion/servers/*.lua`, + ---reusing the modules server_info() already loaded. Registers only — + ---vim.lsp.enable() runs in configure() below, AFTER the off-$PATH cmd rewrite + ---(see there for why the order matters). ---@param lsp_name string + ---@return boolean registered @Whether a config was registered (and can be enabled). 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" } - ) - end - return + if externally_managed[lsp_name] then + return false end - local ok, custom_handler = pcall(require, "user.configs.lsp-servers." .. lsp_name) - local default_ok, default_handler = pcall(require, "completion.servers." .. lsp_name) + local info = server_info(lsp_name) + local ok, custom_handler = info.user_loaded, info.user_spec + local default_handler = info.default_spec -- Use preset if there is no user definition if not ok then - ok, custom_handler = default_ok, default_handler + ok, custom_handler = info.default_loaded, info.default_spec 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) + vim.lsp.config(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( + vim.lsp.config( lsp_name, vim.tbl_deep_extend( "force", @@ -78,40 +167,133 @@ please REMOVE your LSP configuration (rust_analyzer.lua) from the `servers` dire vim.log.levels.ERROR, { title = "nvim-lspconfig" } ) + return false end + return true 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 - end + -- Warn about a stray manual spec for an externally-managed server — a + -- misconfiguration regardless of whether it is in `lsp_deps` (the resolver + -- only visits deps entries, so the handler can't catch this). + for server, plugin in pairs(externally_managed) do + for _, module in ipairs(server_modules(server)) do + -- Presence check WITHOUT executing the module: a stray spec must be + -- reported even (especially) when it errors at load. + local path = module_path(module) + if path then + vim.notify( + string.format( + "`%s` is configured independently via `%s`. To get rid of this warning,\n" + .. "please REMOVE the conflicting spec at `%s`\n" + .. "and configure `%s` using the appropriate init options provided by `%s` instead.", + server, + plugin, + path, + server, + plugin + ), + vim.log.levels.WARN, + { title = "nvim-lspconfig" } + ) + break end 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 + if mason_ok then + -- lspconfig integration only; installs are driven by the shared resolver, + -- not gated on Mason's installed set. + require("modules.utils").load_plugin("mason-lspconfig", { + ensure_installed = {}, + -- Skip auto enable because we are loading language servers lazily + automatic_enable = false, + }) + end + + -- lspconfig server name -> Mason package name. Re-fetched while still empty: + -- get_mappings() returns {} on a never-bootstrapped registry, and caching that + -- would freeze the empty map for the session. + local lspconfig_to_package = nil + local function package_of(name) + if not mason_ok then + return nil + end + if lspconfig_to_package == nil or next(lspconfig_to_package) == nil then + local mappings = mason_lspconfig.get_mappings() + lspconfig_to_package = (mappings and mappings.lspconfig_to_package) or {} end + return lspconfig_to_package[name] + end + + ---Treat a manual/built-in spec as a resolution fallback only when its launch + ---binary can't be probed statically (function/absent `cmd` on a real server). + ---With a known binary the $PATH check decides; configuring anyway would + ---silently enable a server whose binary is missing. + ---@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 - -- Invoke the handler - mason_lsp_handler(srv) + ---Typo / outdated name, vs valid-but-uninstalled? A Mason mapping, a repo/user + ---server module, or a built-in nvim-lspconfig config all mean the name is real + ---and should get install/$PATH guidance rather than "correct your config". + ---@param name string + ---@return boolean + local function unknown_of(name) + if package_of(name) then + return false + end + local info = server_info(name) + return not info.has_module and not info.known_lspconfig end - for _, pkg in ipairs(mason_registry.get_installed_package_names()) do - setup_lsp_for_package(pkg) + ---Register a server's config, rewrite its resolved `cmd[1]` to an absolute + ---path when off $PATH (Mason `PATH = "skip"`), and only THEN enable it: + ---vim.lsp.enable() synchronously fires FileType autocmds for already-open + ---buffers, which would spawn the still-bare, unspawnable name. + local function configure(name) + if not mason_lsp_handler(name) then + return + end + local ok, config = pcall(function() + return vim.lsp.config[name] + end) + if ok and type(config) == "table" and type(config.cmd) == "table" then + local abs = tools.off_path_command(config.cmd[1]) + if abs then + -- vim.lsp.config replaces list values wholesale; pass a full copied cmd. + local cmd = vim.deepcopy(config.cmd) + cmd[1] = abs + vim.lsp.config(name, { cmd = cmd }) + end + end + vim.lsp.enable(name) end + + tools.resolve({ + title = "LSP", + deps = settings.lsp_deps, + registry = mason_ok and mason_registry or nil, + package_of = package_of, + binaries_of = function(name, pkg) + local binary = server_info(name).binary + if binary then + return { binary } + end + -- Function-cmd server (e.g. jsonls): probe the Mason package's declared + -- bins so a system-provided copy is discovered 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, + }) end return M diff --git a/lua/modules/configs/completion/servers/shuck.lua b/lua/modules/configs/completion/servers/shuck.lua index 95319fbe..d617bbd1 100644 --- a/lua/modules/configs/completion/servers/shuck.lua +++ b/lua/modules/configs/completion/servers/shuck.lua @@ -1,17 +1,9 @@ -- 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. --- +-- No Mason package — installed via mise (`cargo:shuck-cli`); the resolver +-- enables it from the `shuck` binary on $PATH (fix = `mise install`). -- `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. +-- zsh-isms and emits false positives, so zsh stays on `zsh -n` (nvim-lint). return { cmd = { "shuck", "server" }, filetypes = { "sh", "bash", "ksh" }, From 22c1c25fb0f4d8f05abb284aed837f8522400180 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Thu, 16 Jul 2026 18:27:19 +0800 Subject: [PATCH 03/35] refactor(mason): resolve formatters and linters discovery-first conform.nvim and nvim-lint resolve their own deps against their runtime registries (the ground truth), reverse-looking-up Mason packages by binary; mason.lua's ensure_installed loop is gone. nvim-lint's probe defers off the first BufReadPost and re-lints after late installs; conform stays synchronous so the off-$PATH rewrite lands before the first format-on-save. --- lua/core/settings.lua | 12 +- lua/modules/configs/completion/conform.lua | 68 +++++++++++ lua/modules/configs/completion/mason.lua | 32 +---- lua/modules/configs/completion/nvim-lint.lua | 118 ++++++++++++++++++- 4 files changed, 189 insertions(+), 41 deletions(-) diff --git a/lua/core/settings.lua b/lua/core/settings.lua index 3fae4339..41417a97 100644 --- a/lua/core/settings.lua +++ b/lua/core/settings.lua @@ -117,13 +117,13 @@ 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", @@ -134,8 +134,8 @@ settings["formatter_deps"] = { "stylua", } --- Linters to install during bootstrap (Mason package names). --- These are managed by Mason and used by nvim-lint. +-- Linters to resolve when nvim-lint lazy-loads (first BufReadPost). nvim-lint +-- linter names, resolved discovery-first like formatter_deps. ---@type string[] settings["linter_deps"] = { "actionlint", @@ -143,7 +143,7 @@ settings["linter_deps"] = { "markdownlint-cli2", "oxlint", -- "rumdl", -- markdownlint Rust rewrite; waiting for rule coverage to mature - "golangci-lint", + "golangcilint", "selene", "shellcheck", "systemdlint", diff --git a/lua/modules/configs/completion/conform.lua b/lua/modules/configs/completion/conform.lua index 2a52dfa2..60298ebb 100644 --- a/lua/modules/configs/completion/conform.lua +++ b/lua/modules/configs/completion/conform.lua @@ -168,6 +168,74 @@ return function() end or false, }) + -- Resolve `formatter_deps` discovery-first against conform's own registry + -- (builtins + the `formatters` overrides above). Deps are conform formatter + -- names, same semantics as lsp_deps/dap_deps. + local tools = require("modules.utils.tools") + -- Synchronous, unlike nvim-lint's deferred pass: only cheap exepath() stats + -- here, and running before lazy.nvim replays BufWritePre keeps the off-$PATH + -- rewrite in place for the first format-on-save under Mason PATH = "skip". + tools.resolve_runtime_tools("conform.nvim", settings.formatter_deps, function(name) + -- get_formatter_config is conform's @private API; if a future release drops + -- it, degrade to "resolves itself" rather than misreporting every formatter + -- as an unknown name. + local conform = require("conform") + if type(conform.get_formatter_config) ~= "function" then + return { binary = nil } + end + local config, err = conform.get_formatter_config(name) + if config then + return { binary = config.command } + end + -- (nil, err) is a real formatter whose configuration is broken (vs bare nil + -- = unknown name): surface conform's message and treat it as self-resolving — + -- the typo guidance and the Mason install fallback would both misdirect. + if type(err) == "string" then + vim.notify( + string.format("Failed to resolve formatter config `%s`:\n%s", name, err), + vim.log.levels.ERROR, + { title = "conform.nvim" } + ) + return { binary = nil } + end + return nil + end, function(name) + -- Under Mason PATH = "skip" the binary is installed but off $PATH, so + -- conform would spawn a bare name that fails at format time; rewrite it to + -- an absolute path. No-op on $PATH. + local conform = require("conform") + local prev = conform.formatters[name] + if type(prev) == "function" then + -- Function-form override resolves its command per buffer: wrap it and + -- rewrite an off-$PATH result, preserving the override's args/cwd. + conform.formatters[name] = function(bufnr) + local cfg = prev(bufnr) + if type(cfg) == "table" and type(cfg.command) == "string" then + local abs = tools.off_path_command(cfg.command) + if abs then + -- prev may return a shared/cached table; copy before rewriting. + cfg = vim.tbl_extend("force", {}, cfg) + cfg.command = abs + end + end + return cfg + end + return + end + if type(conform.get_formatter_config) ~= "function" then + return + end + local config = conform.get_formatter_config(name) + if not config or type(config.command) ~= "string" then + return + end + local abs = tools.off_path_command(config.command) + if abs then + conform.formatters[name] = + vim.tbl_deep_extend("force", type(prev) == "table" and prev or {}, { command = abs }) + end + end) + -- User commands vim.api.nvim_create_user_command("Format", function(args) local range = nil diff --git a/lua/modules/configs/completion/mason.lua b/lua/modules/configs/completion/mason.lua index 450cdefa..2ff4864e 100644 --- a/lua/modules/configs/completion/mason.lua +++ b/lua/modules/configs/completion/mason.lua @@ -27,35 +27,9 @@ 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) - - 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 - end + -- Formatter/linter resolution lives with its ground truth: conform.lua and + -- nvim-lint.lua resolve their deps against their own registrations (see + -- tools.resolve_runtime_tools). Mason here is UI-only / lazy install fallback. end return M diff --git a/lua/modules/configs/completion/nvim-lint.lua b/lua/modules/configs/completion/nvim-lint.lua index e4b88eef..0884c9db 100644 --- a/lua/modules/configs/completion/nvim-lint.lua +++ b/lua/modules/configs/completion/nvim-lint.lua @@ -26,12 +26,10 @@ return function() { source = "markdownlint" } ) - -- 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 (actionlint skips + -- those). Only on `yaml.github` — standalone sh/bash comes from the shuck LSP + -- server (see servers/shuck.lua). `shuck check` has no working stdin mode + -- (needs a project root), so run file-based and parse JSON. lint.linters.shuck = { name = "shuck", cmd = "shuck", @@ -89,6 +87,114 @@ return function() zsh = { "zsh" }, } + -- Resolve `linter_deps` discovery-first against nvim-lint's own registry + -- (builtins via the `lint.linters` metatable + the overrides above). Deps are + -- nvim-lint linter names, same semantics as lsp_deps/dap_deps. + local tools = require("modules.utils.tools") + -- Unwrap a possibly-function value (nvim-lint allows function linters and + -- function cmds); pcall so a throwing resolver degrades to "unresolved". + local function eval(v) + if type(v) == "function" then + local ok, resolved = pcall(v) + return ok and resolved or nil + end + return v + end + -- Deferred: the probe requires every linter_deps module, and some + -- (golangcilint) run blocking `vim.fn.system` calls at module-load time. + -- Nothing here needs to register before the first lint — this pass only + -- installs/warns/rewrites. + vim.schedule(function() + -- Because this pass is deferred, the first lint already ran with a bare + -- off-$PATH name under Mason PATH = "skip"; re-lint at the end if any cmd + -- was rewritten. + local rewrote = false + -- Late configure paths (install completion / post-refresh) have no later + -- lint event, so re-lint from the callback for already-open buffers. + local initial_pass_done = false + ---Rewrite a linter's cmd to an absolute path: under Mason PATH = "skip" the + ---binary is installed but off $PATH. No-op on $PATH. + ---@param name string + local function rewrite_off_path(name) + local linter = lint.linters[name] + if type(linter) == "function" then + -- Function linter factory (nvim-lint calls it at each lint run): wrap it + -- and rewrite the string cmd of every table it returns. Probe once + -- upfront for the re-lint flag. + local probed = eval(linter) + if type(probed) == "table" then + local resolved = eval(probed.cmd) + if type(resolved) == "string" and tools.off_path_command(resolved) then + rewrote = true + end + end + lint.linters[name] = function(...) + local out = linter(...) + if type(out) == "table" and type(out.cmd) == "string" then + out.cmd = tools.off_path_command(out.cmd) or out.cmd + end + return out + end + return + end + if type(linter) ~= "table" then + return + end + local cmd = linter.cmd + if type(cmd) == "string" then + local abs = tools.off_path_command(cmd) + if abs then + linter.cmd = abs + rewrote = true + end + elseif type(cmd) == "function" then + -- Function cmd (e.g. oxlint): wrap so an off-$PATH result is rewritten + -- at lint time, preserving the per-run resolution. + local resolved = eval(cmd) + if type(resolved) == "string" and tools.off_path_command(resolved) then + rewrote = true + end + linter.cmd = function(...) + local out = cmd(...) + if type(out) == "string" then + local abs = tools.off_path_command(out) + if abs then + return abs + end + end + return out + end + end + end + tools.resolve_runtime_tools("nvim-lint", require("core.settings").linter_deps, function(name) + local linter = eval(lint.linters[name]) + if type(linter) ~= "table" then + return nil -- unknown linter name (typo / not registered) + end + -- resolve_runtime_tools coerces a non-string cmd to "resolves itself at runtime". + return { binary = eval(linter.cmd) } + end, function(name) + rewrite_off_path(name) + if initial_pass_done then + -- Late configure (install completion / post-refresh): the linter only + -- became runnable now, so lint once for the buffers already open. + vim.schedule(function() + pcall(function() + lint.try_lint(nil, { ignore_errors = true }) + end) + end) + end + end) + initial_pass_done = true + -- Only re-lint when a rewrite actually happened (PATH = "skip"); the common + -- PATH = "prepend" case rewrote nothing, so no redundant lint is triggered. + if rewrote then + pcall(function() + lint.try_lint(nil, { ignore_errors = true }) + end) + end + end) + vim.api.nvim_create_autocmd({ "BufWritePost", "BufReadPost", "InsertLeave" }, { group = vim.api.nvim_create_augroup("NvimLint", { clear = true }), callback = function() From b23f4adabb733f036bdebd075c577af4e7c26866 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Thu, 16 Jul 2026 18:27:24 +0800 Subject: [PATCH 04/35] refactor(dap): resolve debug adapters discovery-first Adapters resolve via the shared loop, with mason-nvim-dap's mappings guarded as private internals and an adapter_meta ground-truth fallback (delve -> dlv, python -> debugpy-adapter). Client configs self-validate and resolve their binaries lazily on the spawn path, so remote attach works without them. --- lua/core/settings.lua | 3 +- .../configs/tool/dap/clients/codelldb.lua | 10 +- .../configs/tool/dap/clients/delve.lua | 88 +++++++---- lua/modules/configs/tool/dap/clients/lldb.lua | 11 +- .../configs/tool/dap/clients/python.lua | 91 ++++++++++- lua/modules/configs/tool/dap/init.lua | 149 ++++++++++++++++-- lua/modules/plugins/tool.lua | 3 + 7 files changed, 301 insertions(+), 54 deletions(-) diff --git a/lua/core/settings.lua b/lua/core/settings.lua index 41417a97..5fbb4dc9 100644 --- a/lua/core/settings.lua +++ b/lua/core/settings.lua @@ -156,7 +156,8 @@ settings["linter_deps"] = { ---@type number settings["tool_install_timeout"] = 300000 --- Debug Adapter Protocol (DAP) clients to install and configure during bootstrap. +-- DAP adapters to enable (mason-nvim-dap adapter names), resolved +-- discovery-first when nvim-dap lazy-loads (first :Dap* command). -- 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"] = { diff --git a/lua/modules/configs/tool/dap/clients/codelldb.lua b/lua/modules/configs/tool/dap/clients/codelldb.lua index 64124c61..239b74ed 100644 --- a/lua/modules/configs/tool/dap/clients/codelldb.lua +++ b/lua/modules/configs/tool/dap/clients/codelldb.lua @@ -4,13 +4,19 @@ return function() local utils = require("modules.utils.dap") local is_windows = require("core.global").is_windows + -- Config-time self-validation: error when codelldb is missing so the resolver + -- surfaces it (or installs it) instead of registering an adapter that only + -- fails at session start. Launch AND attach both spawn the local binary, so + -- unlike delve/python nothing is worth registering without it. + 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..686c4544 100644 --- a/lua/modules/configs/tool/dap/clients/delve.lua +++ b/lua/modules/configs/tool/dap/clients/delve.lua @@ -1,34 +1,66 @@ --- 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 — matching how tool/dap/init.lua resolves the `delve` + -- package (bin `dlv`). `dlv` resolves lazily on the spawn path only: 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" }) + ---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 + -- Default when unset, but a *malformed* user port is an error — not a + -- silent fallback to delve's default 38697. + local port = 38697 + if config.port ~= nil then + local n = tonumber(config.port) + if not n or n ~= math.floor(n) or n < 1 or n > 65535 then + error( + string.format( + "delve remote attach: invalid `port` %s (want an integer 1-65535)", + vim.inspect(config.port) + ), + 0 + ) end - end) - ) + port = n + end + callback({ + type = "server", + host = config.host or "127.0.0.1", + port = port, + }) + else + -- Resolve lazily so a dlv installed after config load (a finished Mason + -- install, say) 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 +69,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 +81,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 +93,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 +105,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 +117,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: erroring here lets the resolver surface `delve` + -- (or install it) while remote attach keeps what was registered above. + 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..2595501c 100644 --- a/lua/modules/configs/tool/dap/clients/lldb.lua +++ b/lua/modules/configs/tool/dap/clients/lldb.lua @@ -3,9 +3,18 @@ return function() local dap = require("dap") local utils = require("modules.utils.dap") + -- Opt-in preset (not in the default dap_deps; codelldb covers C-family). + -- Self-validate so the resolver surfaces `lldb` instead of registering an + -- adapter with an empty command. LLVM 15 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..3c7aa082 100644 --- a/lua/modules/configs/tool/dap/clients/python.lua +++ b/lua/modules/configs/tool/dap/clients/python.lua @@ -3,8 +3,70 @@ 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" + local mason_root = tools.mason_root() + local debugpy_root = mason_root and (mason_root .. "/packages/debugpy") or nil + + -- 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 + + -- Shared prefix for the "debugpy can't be resolved" errors, so the enumerated + -- probe order stays in sync across sites. + local debugpy_missing = "debugpy not found: no Mason venv, `debugpy-adapter`, or python with the debugpy\n" + .. "module on $PATH; " + + -- Resolve the debugpy adapter command discovery-first: Mason's managed venv → + -- `debugpy-adapter` on $PATH → a system python that can import debugpy. Only + -- a *successful* resolution is cached: the import probe shells out (blocking), + -- but a failure must stay re-probeable so a mid-session install is picked up. + local resolved + local function debugpy_command() + if resolved then + return resolved.command, resolved.args + end + local function found(command, args) + resolved = { command = command, args = args } + return command, args + end + if debugpy_root then + local mason_python = venv_python(debugpy_root .. "/venv") + if vim.fn.executable(mason_python) == 1 then + return found(mason_python, { "-m", "debugpy.adapter" }) + end + end + -- find_executable covers a Mason shim under PATH = "skip"; spawn by its + -- absolute path (the off-$PATH shim wouldn't launch by bare name). + local adapter = tools.find_executable("debugpy-adapter") + if adapter then + return found(adapter, {}) + 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 candidates = is_windows and { "pythonw.exe", "python.exe", "python" } or { "python3", "python" } + local probed = {} + for _, py in ipairs(candidates) do + -- Confirm the module actually imports (list-form system(), no shell), not + -- just that the interpreter exists. Dedup by resolved path so + -- `python`→`python3` symlinks don't pay the blocking spawn twice. + if vim.fn.executable(py) == 1 then + local real = vim.fn.exepath(py) + real = (real ~= "" and vim.uv.fs_realpath(real)) or real + if not probed[real] then + probed[real] = true + vim.fn.system({ py, "-c", "import debugpy" }) + if vim.v.shell_error == 0 then + return found(py, { "-m", "debugpy.adapter" }) + end + end + end + end + return nil + end dap.adapters.python = function(callback, config) if config.request == "attach" then @@ -17,11 +79,16 @@ return function() options = { source_filetype = "python" }, }) else + -- Launch path only: attach uses the server branch above and needs no + -- local debugpy. Fail fast with a clear error, never a guessed command. + local command, args = debugpy_command() + if not command then + error(debugpy_missing .. "install debugpy via Mason (`:Mason`) or your package manager", 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 @@ -38,7 +105,7 @@ return function() 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 @@ -55,14 +122,14 @@ return function() 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" + 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 else @@ -71,4 +138,12 @@ return function() end, }, } + + -- Availability check LAST: erroring here lets the resolver surface `python` + -- (or install debugpy) while remote attach keeps what was registered above. + -- On success the result is cached for the first launch; on failure it stays + -- uncached so a mid-session install is picked up. + if not debugpy_command() then + error(debugpy_missing .. "local launch is unavailable until installed (remote attach still works)", 0) + end end diff --git a/lua/modules/configs/tool/dap/init.lua b/lua/modules/configs/tool/dap/init.lua index 1e998f8b..e439f2ba 100644 --- a/lua/modules/configs/tool/dap/init.lua +++ b/lua/modules/configs/tool/dap/init.lua @@ -1,11 +1,21 @@ return function() local dap = require("dap") local dapui = require("dapui") - local mason_dap = require("mason-nvim-dap") + -- Mason is optional: a Mason-less setup still configures adapters that + -- resolve their own binary (client configs / $PATH). + local has_mason_dap, mason_dap = pcall(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 -- Initialize debug hooks _G._debugging = false @@ -54,19 +64,27 @@ return function() ---@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) - 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 + local custom_handler = tools.first_module(client_modules(dap_name), "nvim-dap") + if custom_handler == nil then + -- Default to Mason's factory config for clients without a spec, only when + -- mason-nvim-dap is available. + if has_mason_dap then + mason_dap.default_setup(config) + return + end + -- No client config and no mason-nvim-dap default: error (level 0) so the + -- shared resolver reports it, instead of a silent return reading as success. + error( + string.format( + "no client config for `%s` and mason-nvim-dap is unavailable for a default setup", + dap_name + ), + 0 + ) elseif type(custom_handler) == "function" then -- Case where the protocol requires its own setup -- Make sure to set - -- * dap.adpaters. = { your config } + -- * dap.adapters. = { your config } -- * dap.configurations. = { your config } -- See `codelldb.lua` for a concrete example. custom_handler(config) @@ -83,9 +101,110 @@ return function() 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") + + -- Mason-driven bits (mappings + install) only exist when Mason does; setup + -- stays discovery-first either way, not gated on mason-nvim-dap's installed set. + local has_registry, registry = pcall(require, "mason-registry") + local mason_ok = has_mason_dap and has_registry + local source_map = { nvim_dap_to_package = {} } + local adapters_map, configs_map, filetypes_map = {}, {}, {} + if mason_ok then + require("modules.utils").load_plugin("mason-nvim-dap", { + ensure_installed = {}, + automatic_installation = false, + }) + -- mason-nvim-dap private internals, not a public API: guard each require so + -- drift degrades to client-config/$PATH resolution instead of aborting. + local function map_or_empty(mod, default) + local ok, m = pcall(require, mod) + return (ok and type(m) == "table") and m or default + end + source_map = map_or_empty("mason-nvim-dap.mappings.source", {}) + adapters_map = map_or_empty("mason-nvim-dap.mappings.adapters", {}) + configs_map = map_or_empty("mason-nvim-dap.mappings.configurations", {}) + filetypes_map = map_or_empty("mason-nvim-dap.mappings.filetypes", {}) + -- The module may load with the indexed field renamed/removed; normalize so + -- package_of/binaries_of below never index nil. + if type(source_map.nvim_dap_to_package) ~= "table" then + source_map.nvim_dap_to_package = {} + end + end + + ---Does an explicit client config exist for this adapter (system-resolved)? + ---@param name string + ---@return boolean + local function has_client_config(name) + return tools.first_module(client_modules(name), "nvim-dap") ~= nil + end + + ---Configure an adapter via the shared handler. Client configs self-validate + ---(error() when their binary is missing), which the resolver surfaces — no + ---separate post-config sanity check here. + ---@param name string + local function configure_adapter(name) + mason_dap_handler({ + name = name, + adapters = adapters_map[name], + configurations = configs_map[name], + filetypes = filetypes_map[name], + }) + end + + -- Ground-truth adapter -> { Mason package, launch binaries } for the shipped + -- adapters, used when mason-nvim-dap's mapping isn't available. Without it the + -- resolver would probe the ADAPTER name as an executable — but `delve` ships + -- `dlv`, `debugpy` ships `debugpy-adapter`, and `python` collides with the + -- interpreter. The mason-nvim-dap map and package bins still take precedence. + local adapter_meta = { + codelldb = { package = "codelldb", binaries = { "codelldb" } }, + delve = { package = "delve", binaries = { "dlv" } }, + python = { package = "debugpy", binaries = { "debugpy-adapter" } }, + } + + -- Discovery-first resolution, shared with LSP and formatters/linters. + -- nvim-dap has no uniform command registry like nvim-lspconfig, so $PATH + -- detection leans on the Mason package's declared binaries; client configs + -- without a Mason package resolve their own binary. + tools.resolve({ + title = "DAP", + deps = settings.dap_deps, + -- Gate on the registry alone: it can install a mapped adapter's package by + -- itself, so a setup without mason-nvim-dap still auto-installs. + registry = has_registry and registry or nil, + package_of = function(name) + return source_map.nvim_dap_to_package[name] or (adapter_meta[name] and adapter_meta[name].package) + end, + binaries_of = function(name, pkg) + if pkg ~= nil then + return tools.package_binaries(pkg, name) + end + -- No Package object: prefer the adapter's declared binaries, else probe + -- the mapped package / adapter name so a system-provided adapter is still + -- discovered. + if adapter_meta[name] and adapter_meta[name].binaries then + return adapter_meta[name].binaries + end + return { source_map.nvim_dap_to_package[name] or name } + end, + ---Typo / outdated adapter name, vs valid-but-unprovisioned? A client config, + ---adapter_meta entry, or mason-nvim-dap mapping means the name is real. The + ---map is only trusted when populated — an empty one (Mason absent / module + ---drift) would misread real adapters as typos. + unknown_of = function(name) + if adapter_meta[name] or has_client_config(name) then + return false + end + if next(source_map.nvim_dap_to_package) == nil then + return false + end + return source_map.nvim_dap_to_package[name] == nil + end, + has_local_config = has_client_config, + -- Client configs self-validate, so let an existing config try before the + -- Mason install fallback — e.g. python resolves debugpy from a venv/system + -- interpreter the $PATH probe can't see. + validates_config = true, + configure = configure_adapter, }) end diff --git a/lua/modules/plugins/tool.lua b/lua/modules/plugins/tool.lua index a5105bb1..dd2bb5e8 100644 --- a/lua/modules/plugins/tool.lua +++ b/lua/modules/plugins/tool.lua @@ -106,6 +106,9 @@ tool["mfussenegger/nvim-dap"] = { }, config = require("tool.dap"), dependencies = { + -- mason-nvim-dap only supplies the adapter -> package mappings (mason.nvim's + -- config is UI-only, so loading DAP triggers no resolver side effects); the + -- DAP resolver degrades to adapter_meta + $PATH when either is absent. { "jay-babu/mason-nvim-dap.nvim" }, { "rcarriga/nvim-dap-ui", From 9bc4d082b5b098e2af79335667a82a999d54d3b1 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Thu, 16 Jul 2026 18:27:29 +0800 Subject: [PATCH 05/35] fix(lsp): drop traefik v2 schema in favor of a v3 entry The built-in v2 entry's fileMatch overlaps the v3 schema added via extra, and schemastore's replace can't rename v2 -> v3. --- lua/modules/configs/completion/servers/yamlls.lua | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/lua/modules/configs/completion/servers/yamlls.lua b/lua/modules/configs/completion/servers/yamlls.lua index f8052abf..0942cb64 100644 --- a/lua/modules/configs/completion/servers/yamlls.lua +++ b/lua/modules/configs/completion/servers/yamlls.lua @@ -39,15 +39,9 @@ return { 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", - }, - }, + -- The built-in Traefik v2 entry's fileMatch overlaps the v3 extra above, + -- and schemastore's `replace` can't rename v2 -> v3. + ignore = { "Traefik v2" }, }), -- trace = { server = "debug" }, }, From 81b241a7875e093d0569087a85ffcbca3cb859c2 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Thu, 16 Jul 2026 22:10:05 +0800 Subject: [PATCH 06/35] refactor(dap): drop adapter_meta and bare-name executable probing the hardcoded adapter -> { package, binaries } table duplicated knowledge the client configs already own (they self-validate in phase 1), and mirrored external naming that rots. bare-name probing also let typos collide with system executables (gdb, python3) and misread mapped package names as binaries (cpptools ships OpenDebugAD7, not cppdbg). binaries_of now returns {} without a Package object, so phase 2 relies on the package's declared bins and unknown names surface as typo warnings instead of silently configuring a no-op adapter. --- lua/modules/configs/tool/dap/init.lua | 35 +++++++++------------------ 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/lua/modules/configs/tool/dap/init.lua b/lua/modules/configs/tool/dap/init.lua index e439f2ba..53513d71 100644 --- a/lua/modules/configs/tool/dap/init.lua +++ b/lua/modules/configs/tool/dap/init.lua @@ -151,17 +151,6 @@ return function() }) end - -- Ground-truth adapter -> { Mason package, launch binaries } for the shipped - -- adapters, used when mason-nvim-dap's mapping isn't available. Without it the - -- resolver would probe the ADAPTER name as an executable — but `delve` ships - -- `dlv`, `debugpy` ships `debugpy-adapter`, and `python` collides with the - -- interpreter. The mason-nvim-dap map and package bins still take precedence. - local adapter_meta = { - codelldb = { package = "codelldb", binaries = { "codelldb" } }, - delve = { package = "delve", binaries = { "dlv" } }, - python = { package = "debugpy", binaries = { "debugpy-adapter" } }, - } - -- Discovery-first resolution, shared with LSP and formatters/linters. -- nvim-dap has no uniform command registry like nvim-lspconfig, so $PATH -- detection leans on the Mason package's declared binaries; client configs @@ -173,26 +162,24 @@ return function() -- itself, so a setup without mason-nvim-dap still auto-installs. registry = has_registry and registry or nil, package_of = function(name) - return source_map.nvim_dap_to_package[name] or (adapter_meta[name] and adapter_meta[name].package) + return source_map.nvim_dap_to_package[name] end, binaries_of = function(name, pkg) if pkg ~= nil then return tools.package_binaries(pkg, name) end - -- No Package object: prefer the adapter's declared binaries, else probe - -- the mapped package / adapter name so a system-provided adapter is still - -- discovered. - if adapter_meta[name] and adapter_meta[name].binaries then - return adapter_meta[name].binaries - end - return { source_map.nvim_dap_to_package[name] or name } + -- No Package object, no probe: adapter/package names are not binary names + -- (`delve` ships `dlv`, `cpptools` ships `OpenDebugAD7`, `python` collides + -- with the interpreter). Client configs self-validate in phase 1; phase 2 + -- probes the package's declared bins for system-provided adapters. + return {} end, - ---Typo / outdated adapter name, vs valid-but-unprovisioned? A client config, - ---adapter_meta entry, or mason-nvim-dap mapping means the name is real. The - ---map is only trusted when populated — an empty one (Mason absent / module - ---drift) would misread real adapters as typos. + ---Typo / outdated adapter name, vs valid-but-unprovisioned? A client config + ---or mason-nvim-dap mapping means the name is real. The map is only trusted + ---when populated — an empty one (Mason absent / module drift) would misread + ---real adapters as typos. unknown_of = function(name) - if adapter_meta[name] or has_client_config(name) then + if has_client_config(name) then return false end if next(source_map.nvim_dap_to_package) == nil then From 6b1fa543368146701977bc2f70f9ae8c01ac1e93 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Thu, 16 Jul 2026 22:18:00 +0800 Subject: [PATCH 07/35] fix(dap): error on nil adapter config instead of silent default_setup no-op mason-nvim-dap's default_setup wraps config.adapters in Optional.of_nilable():map(), so a name missing from its adapters map (elixir, javadbg, javatest, js, mock, puppet today) registered nothing while the resolver read the call as success. raise a level-0 error so the shared collector surfaces the missing definition instead. --- lua/modules/configs/tool/dap/init.lua | 38 +++++++++++++++++---------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/lua/modules/configs/tool/dap/init.lua b/lua/modules/configs/tool/dap/init.lua index 53513d71..ab785d91 100644 --- a/lua/modules/configs/tool/dap/init.lua +++ b/lua/modules/configs/tool/dap/init.lua @@ -66,21 +66,31 @@ return function() local dap_name = config.name local custom_handler = tools.first_module(client_modules(dap_name), "nvim-dap") if custom_handler == nil then - -- Default to Mason's factory config for clients without a spec, only when - -- mason-nvim-dap is available. - if has_mason_dap then - mason_dap.default_setup(config) - return + -- No client config: fall back to Mason's factory config. Both failure + -- modes error (level 0) so the shared resolver reports them, instead of + -- a silent return reading as success. + if not has_mason_dap then + error( + string.format( + "no client config for `%s` and mason-nvim-dap is unavailable for a default setup", + dap_name + ), + 0 + ) end - -- No client config and no mason-nvim-dap default: error (level 0) so the - -- shared resolver reports it, instead of a silent return reading as success. - error( - string.format( - "no client config for `%s` and mason-nvim-dap is unavailable for a default setup", - dap_name - ), - 0 - ) + -- default_setup silently no-ops on a nil adapter config + -- (Optional.of_nilable(nil):map()) — the adapter would never be + -- registered while the resolver reads the call as success. + if config.adapters == nil then + error( + string.format( + "no client config for `%s` and mason-nvim-dap has no adapter definition for it", + dap_name + ), + 0 + ) + end + mason_dap.default_setup(config) elseif type(custom_handler) == "function" then -- Case where the protocol requires its own setup -- Make sure to set From 4908db3ec51cbf942ea93806d9d29204b2e74d6a Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Thu, 16 Jul 2026 22:50:28 +0800 Subject: [PATCH 08/35] perf(tooling): keep blocking probes out of phase-1 resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the PR review shared one root cause: blocking probes (synchronous spawns) running on the phase-1 resolve path, plus a mis-ordered validate_failed short-circuit that re-ran already-failed probes. tools.lua: move the validate_failed short-circuit ahead of the is_installed / bins-on-$PATH branches in resolve_missing — a config that already failed this pass would fail identically, so re-running it only repeats its blocking probes; only an install can change the outcome. Document the contract that validates_config configs may only run non-blocking checks at config time. dap/clients/python.lua: split debugpy resolution into fast_command() (Mason venv / debugpy-adapter, non-blocking) and the full cascade with the blocking `import debugpy` interpreter probe. The tail availability check now uses only the fast path, so loading any :Dap* command no longer spawns python; launch keeps the full cascade (user-triggered, blocking acceptable). Trade-off: pip-only debugpy users (no Mason venv, no debugpy-adapter) get a Mason-managed duplicate installed in phase 2, or — without Mason — a once-per-session warning that says launch still probes the interpreter. nvim-lint.lua: resolve linter_deps per filetype on the lint events instead of one deferred all-deps pass, so a linter module (and any load-time spawn like golangcilint's ~180ms `golangci-lint version` + `go env GOMOD`) loads only when nvim-lint would require it anyway, and GOMOD sniffing sees the buffer's cwd instead of the startup one. Deps not mapped in linters_by_ft keep an immediate deferred pass (typo warnings unchanged). A resolve-only FileType hook covers the buffer whose BufReadPost loaded the plugin — lazy.nvim replays that event before filetype detection runs. The rewrote/re-lint machinery is gone: batch rewrites land before the lint that triggered them. Trade-off: a missing linter's install/warning now waits for the first buffer of its filetype, consistent with the lazy-loading philosophy. --- lua/modules/configs/completion/nvim-lint.lua | 205 +++++++++++------- .../configs/tool/dap/clients/python.lua | 54 +++-- lua/modules/utils/tools.lua | 25 ++- 3 files changed, 185 insertions(+), 99 deletions(-) diff --git a/lua/modules/configs/completion/nvim-lint.lua b/lua/modules/configs/completion/nvim-lint.lua index 0884c9db..a5b80b22 100644 --- a/lua/modules/configs/completion/nvim-lint.lua +++ b/lua/modules/configs/completion/nvim-lint.lua @@ -71,7 +71,7 @@ return function() end, } - lint.linters_by_ft = { + local by_ft = { dockerfile = { "hadolint" }, go = { "golangcilint" }, lua = { "selene" }, @@ -86,10 +86,18 @@ return function() ["yaml.github"] = { "actionlint", "shuck" }, zsh = { "zsh" }, } + lint.linters_by_ft = by_ft -- Resolve `linter_deps` discovery-first against nvim-lint's own registry -- (builtins via the `lint.linters` metatable + the overrides above). Deps are -- nvim-lint linter names, same semantics as lsp_deps/dap_deps. + -- + -- Resolution is batched BY FILETYPE and hangs off the lint events below: the + -- probe requires the linter module, and some (golangcilint) run blocking + -- `vim.fn.system` calls at module-load time. Deferring each module's require + -- to the moment nvim-lint would require it anyway makes the pass ~free for + -- filetypes never opened this session, and load-time cwd sniffing (GOMOD) + -- sees the buffer's cwd instead of the startup one. local tools = require("modules.utils.tools") -- Unwrap a possibly-function value (nvim-lint allows function linters and -- function cmds); pcall so a throwing resolver degrades to "unresolved". @@ -100,73 +108,50 @@ return function() end return v end - -- Deferred: the probe requires every linter_deps module, and some - -- (golangcilint) run blocking `vim.fn.system` calls at module-load time. - -- Nothing here needs to register before the first lint — this pass only - -- installs/warns/rewrites. - vim.schedule(function() - -- Because this pass is deferred, the first lint already ran with a bare - -- off-$PATH name under Mason PATH = "skip"; re-lint at the end if any cmd - -- was rewritten. - local rewrote = false - -- Late configure paths (install completion / post-refresh) have no later - -- lint event, so re-lint from the callback for already-open buffers. - local initial_pass_done = false - ---Rewrite a linter's cmd to an absolute path: under Mason PATH = "skip" the - ---binary is installed but off $PATH. No-op on $PATH. - ---@param name string - local function rewrite_off_path(name) - local linter = lint.linters[name] - if type(linter) == "function" then - -- Function linter factory (nvim-lint calls it at each lint run): wrap it - -- and rewrite the string cmd of every table it returns. Probe once - -- upfront for the re-lint flag. - local probed = eval(linter) - if type(probed) == "table" then - local resolved = eval(probed.cmd) - if type(resolved) == "string" and tools.off_path_command(resolved) then - rewrote = true - end + ---Rewrite a linter's cmd to an absolute path: under Mason PATH = "skip" the + ---binary is installed but off $PATH. No-op on $PATH. + ---@param name string + local function rewrite_off_path(name) + local linter = lint.linters[name] + if type(linter) == "function" then + -- Function linter factory (nvim-lint calls it at each lint run): wrap it + -- and rewrite the string cmd of every table it returns. + lint.linters[name] = function(...) + local out = linter(...) + if type(out) == "table" and type(out.cmd) == "string" then + out.cmd = tools.off_path_command(out.cmd) or out.cmd end - lint.linters[name] = function(...) - local out = linter(...) - if type(out) == "table" and type(out.cmd) == "string" then - out.cmd = tools.off_path_command(out.cmd) or out.cmd - end - return out - end - return - end - if type(linter) ~= "table" then - return + return out end - local cmd = linter.cmd - if type(cmd) == "string" then - local abs = tools.off_path_command(cmd) - if abs then - linter.cmd = abs - rewrote = true - end - elseif type(cmd) == "function" then - -- Function cmd (e.g. oxlint): wrap so an off-$PATH result is rewritten - -- at lint time, preserving the per-run resolution. - local resolved = eval(cmd) - if type(resolved) == "string" and tools.off_path_command(resolved) then - rewrote = true - end - linter.cmd = function(...) - local out = cmd(...) - if type(out) == "string" then - local abs = tools.off_path_command(out) - if abs then - return abs - end - end - return out + return + end + if type(linter) ~= "table" then + return + end + local cmd = linter.cmd + if type(cmd) == "string" then + linter.cmd = tools.off_path_command(cmd) or cmd + elseif type(cmd) == "function" then + -- Function cmd (e.g. oxlint): wrap so an off-$PATH result is rewritten + -- at lint time, preserving the per-run resolution. + linter.cmd = function(...) + local out = cmd(...) + if type(out) == "string" then + return tools.off_path_command(out) or out end + return out end end - tools.resolve_runtime_tools("nvim-lint", require("core.settings").linter_deps, function(name) + end + ---Resolve one batch of linter names. Synchronous configures (available / + ---local) run inside the resolve call — before the batch flag flips — so their + ---cmd rewrites land before the lint that triggered the batch; late configures + ---(install completion / post-refresh) have no later lint event and re-lint + ---the already-open buffers themselves. + ---@param names string[] + local function resolve_batch(names) + local batch_done = false + tools.resolve_runtime_tools("nvim-lint", names, function(name) local linter = eval(lint.linters[name]) if type(linter) ~= "table" then return nil -- unknown linter name (typo / not registered) @@ -175,9 +160,7 @@ return function() return { binary = eval(linter.cmd) } end, function(name) rewrite_off_path(name) - if initial_pass_done then - -- Late configure (install completion / post-refresh): the linter only - -- became runnable now, so lint once for the buffers already open. + if batch_done then vim.schedule(function() pcall(function() lint.try_lint(nil, { ignore_errors = true }) @@ -185,20 +168,88 @@ return function() end) end end) - initial_pass_done = true - -- Only re-lint when a rewrite actually happened (PATH = "skip"); the common - -- PATH = "prepend" case rewrote nothing, so no redundant lint is triggered. - if rewrote then - pcall(function() - lint.try_lint(nil, { ignore_errors = true }) - end) + batch_done = true + end + + -- Split the deps: names mapped in `by_ft` resolve lazily on their filetype's + -- first lint event; the rest (typos, manually-triggered linters — empty with + -- the default config) keep a deferred immediate pass, whose module require + -- misses for a typo so there is no load-time spawn risk. + local deps = require("core.settings").linter_deps + if type(deps) ~= "table" then + deps = {} + end + local mapped = {} + for _, names in pairs(by_ft) do + for _, name in ipairs(names) do + mapped[name] = true + end + end + local pending, immediate = {}, {} + for i = 1, table.maxn(deps) do + local name = deps[i] + if name ~= nil then + if mapped[name] then + pending[name] = true + else + immediate[#immediate + 1] = name + end end - end) + end + if #immediate > 0 then + vim.schedule(function() + resolve_batch(immediate) + end) + end - vim.api.nvim_create_autocmd({ "BufWritePost", "BufReadPost", "InsertLeave" }, { + -- Mirror nvim-lint's linter lookup (lua/lint.lua `_resolve_linter_by_ft`): + -- exact filetype key first, else the union over its `.`-separated segments. + -- Local copy so we don't depend on its private API. + local function linters_for_ft(ft) + local exact = by_ft[ft] + if exact then + return exact + end + local union = {} + for _, part in ipairs(vim.split(ft, ".", { plain = true })) do + for _, name in ipairs(by_ft[part] or {}) do + union[#union + 1] = name + end + end + return union + 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 (parity with the lint cadence before + -- lazy resolution): it covers the buffer whose BufReadPost loaded this + -- plugin — lazy.nvim replays that event before filetype detection runs, so + -- its lint-event callback below sees an empty filetype. + 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: the batch's + -- synchronous rewrites land in the same tick, so the first lint already + -- spawns by absolute path under Mason PATH = "skip". + ensure_resolved(vim.bo[args.buf].filetype) + if args.event ~= "FileType" then + lint.try_lint(nil, { ignore_errors = true }) + end end, }) end diff --git a/lua/modules/configs/tool/dap/clients/python.lua b/lua/modules/configs/tool/dap/clients/python.lua index 3c7aa082..75d9a473 100644 --- a/lua/modules/configs/tool/dap/clients/python.lua +++ b/lua/modules/configs/tool/dap/clients/python.lua @@ -15,24 +15,22 @@ return function() return is_windows and root .. "/Scripts/pythonw.exe" or root .. "/bin/python" end - -- Shared prefix for the "debugpy can't be resolved" errors, so the enumerated - -- probe order stays in sync across sites. - local debugpy_missing = "debugpy not found: no Mason venv, `debugpy-adapter`, or python with the debugpy\n" - .. "module on $PATH; " - - -- Resolve the debugpy adapter command discovery-first: Mason's managed venv → - -- `debugpy-adapter` on $PATH → a system python that can import debugpy. Only - -- a *successful* resolution is cached: the import probe shells out (blocking), - -- but a failure must stay re-probeable so a mid-session install is picked up. + -- Resolve the debugpy adapter command discovery-first, split into a fast and + -- a full cascade sharing one success cache. Only a *successful* resolution is + -- cached: a failure must stay re-probeable so a mid-session install is picked + -- up. local resolved - local function debugpy_command() + local function found(command, args) + resolved = { command = command, args = args } + return command, args + end + + -- Fast, non-blocking probes only (executable()/exepath()): Mason's managed + -- venv → `debugpy-adapter` on $PATH. Safe on the config-time resolve path. + local function fast_command() if resolved then return resolved.command, resolved.args end - local function found(command, args) - resolved = { command = command, args = args } - return command, args - end if debugpy_root then local mason_python = venv_python(debugpy_root .. "/venv") if vim.fn.executable(mason_python) == 1 then @@ -45,6 +43,17 @@ return function() if adapter then return found(adapter, {}) end + return nil + end + + -- Full cascade: the fast probes above, then a system python that can import + -- debugpy. The import probe shells out (blocking), so this is reserved for a + -- user-triggered launch, never the config-time resolve path. + local function debugpy_command() + local command, args = fast_command() + if command then + return command, args + 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 candidates = is_windows and { "pythonw.exe", "python.exe", "python" } or { "python3", "python" } @@ -83,7 +92,11 @@ return function() -- local debugpy. Fail fast with a clear error, never a guessed command. local command, args = debugpy_command() if not command then - error(debugpy_missing .. "install debugpy via Mason (`:Mason`) or your package manager", 0) + error( + "debugpy not found: no Mason venv, `debugpy-adapter`, or python with the debugpy\n" + .. "module on $PATH; install debugpy via Mason (`:Mason`) or your package manager", + 0 + ) end callback({ type = "executable", @@ -141,9 +154,16 @@ return function() -- Availability check LAST: erroring here lets the resolver surface `python` -- (or install debugpy) while remote attach keeps what was registered above. + -- Fast probes only — this runs on the config-time resolve path (any `:Dap*` + -- loads it), so the blocking interpreter import probe is deferred to launch. -- On success the result is cached for the first launch; on failure it stays -- uncached so a mid-session install is picked up. - if not debugpy_command() then - error(debugpy_missing .. "local launch is unavailable until installed (remote attach still works)", 0) + if not fast_command() then + error( + "debugpy not found: no Mason venv and no `debugpy-adapter` on $PATH; install debugpy via\n" + .. "Mason (`:Mason`) or your package manager — a python able to `import debugpy` is still\n" + .. "probed at launch, so local launch may work regardless (remote attach always works)", + 0 + ) end end diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index 7ae12523..109dcdfa 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -478,6 +478,11 @@ end ---same tick, so lazy-loaded subsystems see their tools registered before ---lazy.nvim replays the trigger; only installs defer. `configure` is optional ---and pcall'd — as is each dep — so one failure can't abort the rest. +--- +---Contract for `validates_config` configs: at config time they may only run +---non-blocking checks (`executable()`/`exepath()`/fs stats) — the resolve pass +---runs on the phase-1 hot path, so any expensive validation that spawns a +---process must be deferred to launch/run time. ---@param spec { --- title: string, --- deps: string[], @@ -624,6 +629,19 @@ function M.resolve(spec) end end + -- A validates_config config already ran (and failed) earlier in this same + -- resolve pass; nothing the installed/on-$PATH branches below observe has + -- changed since, so re-running it would repeat its blocking probes and fail + -- identically. Only an install can change the outcome. + if validate_failed[name] ~= nil then + if pkg ~= nil and not pkg:is_installed() then + start_install(pkg, name) -- annotates the failure reason itself + else + collector.mark(name, type(validate_failed[name]) == "string" and validate_failed[name] or nil) + 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) @@ -645,12 +663,9 @@ function M.resolve(spec) return end - -- No installable package: surface the phase-1 validation error without - -- re-running the config, else let a local config self-validate, else mark + -- No installable package: let a local config self-validate, else mark -- missing (or unknown, for a bad mapping with no known binary). - if validate_failed[name] ~= nil then - collector.mark(name, type(validate_failed[name]) == "string" and validate_failed[name] or nil) - elseif spec.has_local_config and spec.has_local_config(name) then + if spec.has_local_config and spec.has_local_config(name) then do_configure(name) elseif pkg_unknown and #bins == 0 then collector.mark_unknown(pkg_name == name and name or (pkg_name .. " (for " .. name .. ")")) From 58546854c7fc4b03a2d726baf22094c3e0a13e24 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Thu, 16 Jul 2026 23:23:36 +0800 Subject: [PATCH 09/35] fix(tooling): resolve and rewrite function-form commands consistently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit three subsystems handled function-form commands inconsistently: conform exempted function commands/overrides from install/warn/rewrite entirely, nvim-lint rewrote a factory's returned table in place and swallowed throwing factories as typos, and mason-lspconfig left function-cmd servers (jsonls) spawning bare names under mason PATH = "skip". - tools: add wrap_off_path to unify the wrap-and-rewrite idiom (table results are copied before rewriting) and wrap_with_mason_path to run internally-spawning fns with mason's bin dir APPENDED to $PATH — appended, not prepended, to keep the discovery-first "$PATH wins, mason is the fallback" order; $PATH restores on return and on throw - tools: extend the resolve_runtime_tools probe contract with { broken = "reason" }: a config that exists but errors at evaluation surfaces in the aggregated warning with its reason, never as typo guidance and never as an install - conform: pcall get_formatter_config and evaluate function-form commands with a hand-built ctx (a config-time buffer approximation; runtime per-buffer differences are covered by the configure wrapper), then rewrite function commands/overrides via wrap_off_path - nvim-lint: classify throwing factories/cmds and broken linter modules as broken instead of typos; wrap factories via wrap_off_path so the factory's returned table is copied before its cmd is rewritten - mason-lspconfig: wrap function cmds with wrap_with_mason_path before vim.lsp.enable so they spawn with mason's bin dir reachable --- lua/modules/configs/completion/conform.lua | 64 ++++++++----- .../configs/completion/mason-lspconfig.lua | 5 ++ lua/modules/configs/completion/nvim-lint.lua | 65 ++++++++------ lua/modules/utils/tools.lua | 89 +++++++++++++++++-- 4 files changed, 167 insertions(+), 56 deletions(-) diff --git a/lua/modules/configs/completion/conform.lua b/lua/modules/configs/completion/conform.lua index 60298ebb..dc94aabe 100644 --- a/lua/modules/configs/completion/conform.lua +++ b/lua/modules/configs/completion/conform.lua @@ -183,20 +183,38 @@ return function() if type(conform.get_formatter_config) ~= "function" then return { binary = nil } end - local config, err = conform.get_formatter_config(name) + -- get_formatter_config runs a function-form `formatters` override directly, + -- so a throwing override would leak out of the probe (and be misread as a + -- typo); that's a broken config, not 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 + if type(config.command) == "function" then + -- Function-form command (e.g. builtin `from_node_modules`): evaluate it + -- with a hand-built ctx (conform.Context's public fields, resolve-time + -- buffer) for a representative answer — runtime per-buffer differences + -- are covered by configure's wrapper below. + local buf = vim.api.nvim_get_current_buf() + local filename = vim.api.nvim_buf_get_name(buf) + local ok_cmd, cmd = pcall(config.command, config, { + buf = buf, + filename = filename, + dirname = vim.fs.dirname(filename), + }) + if not ok_cmd then + return { broken = tostring(cmd) } + end + return { binary = type(cmd) == "string" and cmd or nil } + end return { binary = config.command } end -- (nil, err) is a real formatter whose configuration is broken (vs bare nil - -- = unknown name): surface conform's message and treat it as self-resolving — - -- the typo guidance and the Mason install fallback would both misdirect. + -- = unknown name): the typo guidance and the Mason install fallback would + -- both misdirect. if type(err) == "string" then - vim.notify( - string.format("Failed to resolve formatter config `%s`:\n%s", name, err), - vim.log.levels.ERROR, - { title = "conform.nvim" } - ) - return { binary = nil } + return { broken = err } end return nil end, function(name) @@ -208,25 +226,27 @@ return function() if type(prev) == "function" then -- Function-form override resolves its command per buffer: wrap it and -- rewrite an off-$PATH result, preserving the override's args/cwd. - conform.formatters[name] = function(bufnr) - local cfg = prev(bufnr) - if type(cfg) == "table" and type(cfg.command) == "string" then - local abs = tools.off_path_command(cfg.command) - if abs then - -- prev may return a shared/cached table; copy before rewriting. - cfg = vim.tbl_extend("force", {}, cfg) - cfg.command = abs - end - end - return cfg - end + conform.formatters[name] = tools.wrap_off_path(prev, "command") return end if type(conform.get_formatter_config) ~= "function" then return end local config = conform.get_formatter_config(name) - if not config or type(config.command) ~= "string" then + if not config then + return + end + if type(config.command) == "function" then + -- Function-form command resolves per buffer: wrap it so an off-$PATH + -- result is rewritten at format time; (self, ctx) pass through. + conform.formatters[name] = vim.tbl_deep_extend( + "force", + type(prev) == "table" and prev or {}, + { command = tools.wrap_off_path(config.command) } + ) + return + end + if type(config.command) ~= "string" then return end local abs = tools.off_path_command(config.command) diff --git a/lua/modules/configs/completion/mason-lspconfig.lua b/lua/modules/configs/completion/mason-lspconfig.lua index b2851c9d..488354f4 100644 --- a/lua/modules/configs/completion/mason-lspconfig.lua +++ b/lua/modules/configs/completion/mason-lspconfig.lua @@ -268,6 +268,11 @@ M.setup = function() cmd[1] = abs vim.lsp.config(name, { cmd = cmd }) end + elseif ok and type(config) == "table" and type(config.cmd) == "function" then + -- A function cmd spawns internally (vim.lsp.rpc.start) and can't be + -- evaluated or rewritten; under Mason PATH = "skip" its bare binary + -- name would ENOENT. Run it with Mason's bin dir appended to $PATH. + vim.lsp.config(name, { cmd = tools.wrap_with_mason_path(config.cmd) }) end vim.lsp.enable(name) end diff --git a/lua/modules/configs/completion/nvim-lint.lua b/lua/modules/configs/completion/nvim-lint.lua index a5b80b22..20b44a90 100644 --- a/lua/modules/configs/completion/nvim-lint.lua +++ b/lua/modules/configs/completion/nvim-lint.lua @@ -99,15 +99,6 @@ return function() -- filetypes never opened this session, and load-time cwd sniffing (GOMOD) -- sees the buffer's cwd instead of the startup one. local tools = require("modules.utils.tools") - -- Unwrap a possibly-function value (nvim-lint allows function linters and - -- function cmds); pcall so a throwing resolver degrades to "unresolved". - local function eval(v) - if type(v) == "function" then - local ok, resolved = pcall(v) - return ok and resolved or nil - end - return v - end ---Rewrite a linter's cmd to an absolute path: under Mason PATH = "skip" the ---binary is installed but off $PATH. No-op on $PATH. ---@param name string @@ -115,14 +106,9 @@ return function() local linter = lint.linters[name] if type(linter) == "function" then -- Function linter factory (nvim-lint calls it at each lint run): wrap it - -- and rewrite the string cmd of every table it returns. - lint.linters[name] = function(...) - local out = linter(...) - if type(out) == "table" and type(out.cmd) == "string" then - out.cmd = tools.off_path_command(out.cmd) or out.cmd - end - return out - end + -- and rewrite the string cmd of the table it returns (copied first — the + -- factory may return a shared/cached table). + lint.linters[name] = tools.wrap_off_path(linter, "cmd") return end if type(linter) ~= "table" then @@ -134,13 +120,7 @@ return function() elseif type(cmd) == "function" then -- Function cmd (e.g. oxlint): wrap so an off-$PATH result is rewritten -- at lint time, preserving the per-run resolution. - linter.cmd = function(...) - local out = cmd(...) - if type(out) == "string" then - return tools.off_path_command(out) or out - end - return out - end + linter.cmd = tools.wrap_off_path(cmd) end end ---Resolve one batch of linter names. Synchronous configures (available / @@ -152,12 +132,41 @@ return function() local function resolve_batch(names) local batch_done = false tools.resolve_runtime_tools("nvim-lint", names, function(name) - local linter = eval(lint.linters[name]) - if type(linter) ~= "table" then + local linter = lint.linters[name] + if linter == nil then + -- The lint.linters metatable pcalls require and swallows loader errors: + -- a linter module that exists but throws at load is a broken config, + -- not a typo. Re-require to capture the message (a failed require is + -- not cached, so this re-throws). + local module = "lint.linters." .. name + if tools.module_path(module) then + local _, err = pcall(require, module) + return { broken = tostring(err) } + end return nil -- unknown linter name (typo / not registered) end - -- resolve_runtime_tools coerces a non-string cmd to "resolves itself at runtime". - return { binary = eval(linter.cmd) } + if type(linter) == "function" then + -- Function linter factory: 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) rewrite_off_path(name) if batch_done then diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index 109dcdfa..cbf98b57 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -114,6 +114,63 @@ function M.off_path_command(binary) return M.find_executable(binary) end +---Wrap a function-form command resolver so its result is rewritten to an +---absolute path when off $PATH (Mason PATH = "skip"). With `field`, the +---result is a config table and result[field] holds the command — the table +---is COPIED before rewriting (the fn may return a shared/cached table). +---Without `field`, the result is the command string itself. +---@param fn function @The resolver to wrap; arguments pass through untouched. +---@param field? string @Key holding the command when the result is a table. +---@return function +function M.wrap_off_path(fn, field) + return function(...) + local out = fn(...) + if field == nil then + if type(out) == "string" then + return M.off_path_command(out) or out + end + return out + end + if type(out) == "table" and type(out[field]) == "string" then + local abs = M.off_path_command(out[field]) + if abs then + out = vim.tbl_extend("force", {}, out) + out[field] = abs + end + end + return out + end +end + +---Wrap a function that spawns internally (e.g. an LSP function `cmd` calling +---vim.lsp.rpc.start) so it runs with Mason's bin dir APPENDED to $PATH — +---appended, not prepended, to keep the discovery-first "$PATH wins, Mason is +---the fallback" order. $PATH is restored whether the fn returns or throws; +---without a Mason root the fn passes through untouched. +---@param fn function +---@return function +function M.wrap_with_mason_path(fn) + return function(...) + local root = M.mason_root() + if not root then + return fn(...) + end + local prev = vim.env.PATH or "" + local sep = vim.fn.has("win32") == 1 and ";" or ":" + vim.env.PATH = prev .. sep .. root .. "/bin" + -- restore(pcall(...)) keeps every return value while guaranteeing the + -- $PATH reset runs before a throw propagates. + local function restore(ok, ...) + vim.env.PATH = prev + if not ok then + error((...), 0) + end + return ... + end + return restore(pcall(fn, ...)) + end +end + ---Resolve the first of the given executable names (see `find_executable`), or ---`error()` with an install hint — config-time self-validation for client/server ---configs, whose message the shared resolver aggregates. Thrown at level 0 so @@ -786,15 +843,18 @@ end ---are the ground truth (conform formatters, nvim-lint linters). Each dep is ---the tool's name *in that subsystem* (same semantics as lsp_deps/dap_deps). ---`probe(name)` returns: ---- * nil -> unknown name (typo / not registered) -> fix config. ---- * { binary = "x" } -> the tool invokes executable "x" ($PATH-probeable). ---- * { binary = nil } -> the tool resolves its own command at runtime. +--- * nil -> unknown name (typo / not registered) -> fix config. +--- * { binary = "x" } -> the tool invokes executable "x" ($PATH-probeable). +--- * { binary = nil } -> the tool resolves its own command at runtime. +--- * { broken = "reason" } -> the config EXISTS but errors at evaluation: surfaced +--- in the aggregated warning with the reason — never +--- typo guidance, never an install. ---Mason enters only as the install fallback, required lazily; the package is ---reverse-looked-up from the binary (see `package_for_binary`), so tool name, ---binary, and package name are free to differ. ---@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 }|nil +---@param probe fun(name: string): { binary: string|nil, broken: string|nil }|nil ---@param configure? fun(name: string) @Optional: run for each available/local tool --- (e.g. rewrite its command to an absolute path under Mason `PATH = "skip"`). function M.resolve_runtime_tools(title, deps, probe, configure) @@ -803,7 +863,11 @@ function M.resolve_runtime_tools(title, deps, probe, configure) if cache[name] == nil then local ok, result = pcall(probe, name) if ok and type(result) == "table" then - cache[name] = { known = true, binary = type(result.binary) == "string" and result.binary or nil } + cache[name] = { + known = true, + binary = type(result.binary) == "string" and result.binary or nil, + broken = type(result.broken) == "string" and result.broken or nil, + } else -- A probe error is treated as unknown: the guidance either way is to -- fix the config entry, not to install something. @@ -837,13 +901,26 @@ function M.resolve_runtime_tools(title, deps, probe, configure) return not info(name).known end, has_local_config = function(name) + -- A broken config counts as local (binary-less) so it reaches configure + -- below, where its reason is surfaced. local i = info(name) return i.known and i.binary == nil end, -- A binary-less runtime tool can't be reverse-looked-up to a package, so it -- genuinely self-resolves (unlike a binary-less LSP server, which maps by name). binaryless_self_resolves = true, - configure = configure, + configure = function(name) + -- A broken config must never configure: error with the probe's reason so + -- the resolver's pcall machinery marks the tool with it in the aggregated + -- warning (missing section, not typo guidance). + local reason = info(name).broken + if reason then + error(reason, 0) + end + if type(configure) == "function" then + return configure(name) + end + end, }) end From 923e4c7aa3f0e1964811447cab651d9a094ec861 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Thu, 16 Jul 2026 23:23:46 +0800 Subject: [PATCH 10/35] refactor(tooling): consolidate duplicated module loading and registry passing - tools: add load_module_or_report, distinguishing "missing" from "exists but broken" with a session-deduped error notification, and rebuild first_module on top of it (behavior unchanged) - mason-lspconfig: drop report_broken_spec in favor of the shared helper; has_module semantics are preserved (a spec that loads, or exists but throws, still counts as a module) - utils: drop register_server, dead code with zero callers since its logic was inlined into the resolver's configure step - lsp/dap: pass the mason registry to tools.resolve as a lazy thunk, matching resolve_runtime_tools, so it is only consulted in phase 2 --- .../configs/completion/mason-lspconfig.lua | 33 ++++++---------- lua/modules/configs/tool/dap/init.lua | 7 +++- lua/modules/utils/init.lua | 13 ------- lua/modules/utils/tools.lua | 38 ++++++++++++++----- 4 files changed, 46 insertions(+), 45 deletions(-) diff --git a/lua/modules/configs/completion/mason-lspconfig.lua b/lua/modules/configs/completion/mason-lspconfig.lua index 488354f4..7694c692 100644 --- a/lua/modules/configs/completion/mason-lspconfig.lua +++ b/lua/modules/configs/completion/mason-lspconfig.lua @@ -59,23 +59,10 @@ M.setup = function() default_spec = nil, } local modules = server_modules(name) - ---A spec file that exists but throws at load is a broken config, not a typo: - ---count it as a module (out of the unknown-name bucket) and surface the error. - ---@param module string - ---@param err any @The pcall error for the failed require. - ---@return boolean @Whether the module file exists on package.path. - local function report_broken_spec(module, err) - if not module_path(module) then - return false - end - vim.notify( - string.format("Failed to load `%s`:\n%s", module, err), - vim.log.levels.ERROR, - { title = "nvim-lspconfig" } - ) - return true - end - local user_ok, user_spec = pcall(require, modules[1]) + -- A spec file that exists but throws at load is a broken config, not a typo: + -- load_module_or_report surfaces the error (deduped) and `exists` keeps the + -- name counted as a module, out of the unknown-name bucket. + local user_ok, user_spec, user_exists = tools.load_module_or_report(modules[1], "nvim-lspconfig") if user_ok then info.has_module = true info.user_loaded = true @@ -83,7 +70,7 @@ M.setup = function() if type(user_spec) == "table" and type(user_spec.cmd) == "table" then info.binary = user_spec.cmd[1] end - elseif report_broken_spec(modules[1], user_spec) then + elseif user_exists then info.has_module = true end -- Load the repo preset only when the handler could use it (no override, or @@ -91,7 +78,7 @@ M.setup = function() -- replaces it wholesale, and requiring it anyway would run the preset's -- module-level code for a spec that cannot be used. if not user_ok or type(user_spec) == "table" then - local ok, spec = pcall(require, modules[2]) + local ok, spec, exists = tools.load_module_or_report(modules[2], "nvim-lspconfig") if ok then info.has_module = true info.default_loaded = true @@ -99,7 +86,7 @@ M.setup = function() if info.binary == nil and type(spec) == "table" and type(spec.cmd) == "table" then info.binary = spec.cmd[1] end - elseif report_broken_spec(modules[2], spec) then + elseif exists then info.has_module = true end end @@ -280,7 +267,11 @@ M.setup = function() tools.resolve({ title = "LSP", deps = settings.lsp_deps, - registry = mason_ok and mason_registry or nil, + -- Lazy thunk (parity with resolve_runtime_tools): only consulted when a + -- dep actually needs the registry in phase 2. + registry = function() + return mason_ok and mason_registry or nil + end, package_of = package_of, binaries_of = function(name, pkg) local binary = server_info(name).binary diff --git a/lua/modules/configs/tool/dap/init.lua b/lua/modules/configs/tool/dap/init.lua index ab785d91..fa654067 100644 --- a/lua/modules/configs/tool/dap/init.lua +++ b/lua/modules/configs/tool/dap/init.lua @@ -169,8 +169,11 @@ return function() title = "DAP", deps = settings.dap_deps, -- Gate on the registry alone: it can install a mapped adapter's package by - -- itself, so a setup without mason-nvim-dap still auto-installs. - registry = has_registry and registry or nil, + -- itself, so a setup without mason-nvim-dap still auto-installs. Lazy thunk + -- (parity with resolve_runtime_tools): only consulted in phase 2. + registry = function() + return has_registry and registry or nil + end, package_of = function(name) return source_map.nvim_dap_to_package[name] end, diff --git a/lua/modules/utils/init.lua b/lua/modules/utils/init.lua index 6a87aa47..9e991259 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 diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index cbf98b57..611703dd 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -75,6 +75,34 @@ end -- Modules already reported broken, so multi-site lookups notify once per module. local broken_module_reported = {} +---Require a module, distinguishing "missing" from "exists but broken". A file +---that is present on the search paths yet throws at load is a broken config, +---not a missing one: its error is notified (once per module per session) and +---`exists` stays true so callers keep it out of any unknown-name bucket. +---@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. +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 + if not broken_module_reported[module] then + broken_module_reported[module] = true + vim.notify( + string.format("Failed to load `%s`:\n%s", module, value), + vim.log.levels.ERROR, + { title = title or "tools" } + ) + end + return false, nil, true +end + ---Require the first loadable module from an ordered candidate list (user ---override before repo default). Returns its value, or nil when none load ---(config modules return non-nil, so nil unambiguously means "not found"). @@ -85,18 +113,10 @@ local broken_module_reported = {} ---@return any|nil @The first module's value, or nil if none loaded. function M.first_module(candidates, title) for _, module in ipairs(candidates) do - local ok, value = pcall(require, module) + local ok, value = M.load_module_or_report(module, title) if ok and value ~= nil then return value end - if not ok and not broken_module_reported[module] and M.module_path(module) then - broken_module_reported[module] = true - vim.notify( - string.format("Failed to load `%s`:\n%s", module, value), - vim.log.levels.ERROR, - { title = title or "tools" } - ) - end end return nil end From e7510dd5d7badd568130f9c5fec96ee2de66834d Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 17 Jul 2026 00:07:52 +0800 Subject: [PATCH 11/35] fix(lint): relint all open buffers when a linter configures late a late configure (install completion / post-refresh) lands after BufReadPost, so buffers opened during the install have no later lint event to pick the linter up. re-lint every loaded buffer whose filetype maps to the configured linter instead of only the current one, via nvim_buf_call so try_lint resolves each buffer's own filetype. linters_for_ft moves above resolve_batch unchanged so the callback can see it. --- lua/modules/configs/completion/nvim-lint.lua | 52 +++++++++++++------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/lua/modules/configs/completion/nvim-lint.lua b/lua/modules/configs/completion/nvim-lint.lua index 20b44a90..73eed018 100644 --- a/lua/modules/configs/completion/nvim-lint.lua +++ b/lua/modules/configs/completion/nvim-lint.lua @@ -88,6 +88,23 @@ return function() } lint.linters_by_ft = by_ft + -- Mirror nvim-lint's linter lookup (lua/lint.lua `_resolve_linter_by_ft`): + -- exact filetype key first, else the union over its `.`-separated segments. + -- Local copy so we don't depend on its private API. + local function linters_for_ft(ft) + local exact = by_ft[ft] + if exact then + return exact + end + local union = {} + for _, part in ipairs(vim.split(ft, ".", { plain = true })) do + for _, name in ipairs(by_ft[part] or {}) do + union[#union + 1] = name + end + end + return union + end + -- Resolve `linter_deps` discovery-first against nvim-lint's own registry -- (builtins via the `lint.linters` metatable + the overrides above). Deps are -- nvim-lint linter names, same semantics as lsp_deps/dap_deps. @@ -170,10 +187,23 @@ return function() end, function(name) rewrite_off_path(name) if batch_done then + -- Late configure (install completion / post-refresh) has no later lint + -- event: re-lint every loaded buffer whose filetype maps to this linter, + -- not just the current one — others opened during the install would + -- otherwise never get diagnostics. vim.schedule(function() - pcall(function() - lint.try_lint(nil, { ignore_errors = true }) - end) + 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(nil, { ignore_errors = true }) + end) + end) + end + end end) end end) @@ -211,22 +241,6 @@ return function() end) end - -- Mirror nvim-lint's linter lookup (lua/lint.lua `_resolve_linter_by_ft`): - -- exact filetype key first, else the union over its `.`-separated segments. - -- Local copy so we don't depend on its private API. - local function linters_for_ft(ft) - local exact = by_ft[ft] - if exact then - return exact - end - local union = {} - for _, part in ipairs(vim.split(ft, ".", { plain = true })) do - for _, name in ipairs(by_ft[part] or {}) do - union[#union + 1] = name - end - end - return union - end local ft_done = {} local function ensure_resolved(ft) if ft == "" or ft_done[ft] then From 88ff458811e5428472291d3e04e402e569b9727e Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 17 Jul 2026 00:07:58 +0800 Subject: [PATCH 12/35] refactor(tooling): drop the installing shadow cache for mason handle state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the module-level installing table mirrored state mason already keeps on the package: install() registers its handle synchronously (visible to a same-tick second caller), so track() now reads it straight off the package via get_install_handle(). the handle outlives its install (mason never clears it), so a closed handle is treated as no handle and a fresh install starts — this replaces the cached-handle is_closed() guard as the protection against registering once("closed") on a dead emitter, which never fires and would leak the pending count. --- lua/modules/utils/tools.lua | 65 +++++++++++-------------------------- 1 file changed, 19 insertions(+), 46 deletions(-) diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index 611703dd..de42efc7 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -5,12 +5,6 @@ -- resolution loop (`M.resolve`) plus one aggregated missing-tool warning. local M = {} --- In-flight Mason install handles, keyed by package name. One package can back --- several subsystems (e.g. `superhtml` is both LSP server and formatter), and a --- second install() while one runs trips mason's `assert(not self:is_installing())`. ----@type table -local installing = {} - ---Locate the first of the given executables: on $PATH, then in Mason's bin ---directory (covers Mason installs with `PATH = "skip"`, where the binary ---exists but a plain exepath() misses it). Intentionally *first-found*, not @@ -327,37 +321,26 @@ local function missing_collector(title, timeout_ms) mark = add, mark_unknown = add_unknown, track = function(pkg, name, recheck, on_ready, fail_reason) - local pkg_name = type(pkg) == "table" and pkg.name or nil - local handle = pkg_name and installing[pkg_name] or nil - - -- A cached handle may already be closed (mason emits "closed" synchronously, - -- the cache clears next tick) and its emitter never fires once() after close, - -- which would leak `pending`. Drop it and resolve a fresh handle. - if handle then - local ok_closed, closed = pcall(function() - return handle:is_closed() + -- Attach to an in-flight install (another subsystem / a manual install) + -- instead of starting a duplicate: mason's install() asserts + -- `not self:is_installing()`. The handle is read straight off the package — + -- install() registers it synchronously, so no shadow cache is needed. It + -- also OUTLIVES its install (mason never clears it): only an OPEN handle + -- means an in-flight install; a closed one would never fire once("closed"). + 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 not ok_closed or closed then - installing[pkg_name] = nil - handle = nil - end - end - - -- Already installing elsewhere (another subsystem / a manual install): - -- attach to that handle instead of starting a duplicate install. - if not handle and type(pkg.is_installing) == "function" then - local ok_ing, is_ing = pcall(function() - return pkg:is_installing() - end) - if ok_ing and is_ing and type(pkg.get_install_handle) == "function" then - local ok_h, opt = pcall(function() - return pkg:get_install_handle() - end) - if ok_h and type(opt) == "table" and type(opt.if_present) == "function" then - opt:if_present(function(h) - handle = h + 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) - end + if ok_closed and not closed then + handle = h + end + end) end end @@ -373,9 +356,6 @@ local function missing_collector(title, timeout_ms) return end handle = h - if pkg_name then - installing[pkg_name] = handle - end elseif type(handle.once) ~= "function" then -- Shared handle isn't usable (unexpected shape); mark rather than hang. add(name, fail_reason) @@ -408,9 +388,6 @@ local function missing_collector(title, timeout_ms) handle, "closed", vim.schedule_wrap(function() - if pkg_name then - installing[pkg_name] = nil - end local rc_ok, available = pcall(recheck) if rc_ok and available then if type(on_ready) == "function" then @@ -423,16 +400,12 @@ local function missing_collector(title, timeout_ms) flush() end) ) - -- once() threw: no callback will ever decrement, so undo the accounting and - -- drop the cached handle (or later resolutions would reuse it, never retrying). + -- once() threw: no callback will ever decrement, so undo the accounting. if not registered then pending = pending - 1 if queued_here then queued[#queued] = nil end - if pkg_name then - installing[pkg_name] = nil - end add(name, fail_reason) end end, From 6f4e03d481fd96a68a6770c2768a1bc7ae782c0b Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 17 Jul 2026 00:18:02 +0800 Subject: [PATCH 13/35] docs(tooling): de-bloat discovery-first comments comment-only pass over the pr (verified: stripping full-line comments yields byte-identical code vs head). compress multi-line narration to the constraint it carried, drop design-history and correctness justification, keep external-api facts (mason handle lifetime, fast event contexts, path = "skip", maxn-vs-ipairs). also rewrites a stale adapter_meta reference dropped in 81b241a. net -163 lines. --- lua/core/settings.lua | 15 +- lua/modules/configs/completion/conform.lua | 43 +-- lua/modules/configs/completion/lsp.lua | 12 +- .../configs/completion/mason-lspconfig.lua | 78 ++--- lua/modules/configs/completion/mason.lua | 5 +- lua/modules/configs/completion/nvim-lint.lua | 74 ++--- .../configs/tool/dap/clients/codelldb.lua | 6 +- .../configs/tool/dap/clients/delve.lua | 17 +- lua/modules/configs/tool/dap/clients/lldb.lua | 5 +- .../configs/tool/dap/clients/python.lua | 28 +- lua/modules/configs/tool/dap/init.lua | 43 +-- lua/modules/plugins/tool.lua | 5 +- lua/modules/utils/tools.lua | 300 +++++++----------- 13 files changed, 234 insertions(+), 397 deletions(-) diff --git a/lua/core/settings.lua b/lua/core/settings.lua index 5fbb4dc9..7ac826d0 100644 --- a/lua/core/settings.lua +++ b/lua/core/settings.lua @@ -84,11 +84,9 @@ settings["external_browser"] = "chrome-cli open" ---@type boolean settings["lsp_inlayhints"] = false --- Language servers to enable — one flat list, resolved discovery-first at --- runtime: binary on $PATH (system / Nix / Mason) → used as-is; else Mason --- installs it when it ships a package; else an aggregated warning asks you to --- provision it (home-manager switch / mise install for package-less servers). --- See `modules.utils.tools` and `completion/mason-lspconfig.lua`. +-- 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. 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"] = { @@ -149,10 +147,9 @@ settings["linter_deps"] = { "systemdlint", } --- Deadline (ms) for background Mason work (installs, first registry refresh) --- before the aggregated missing-tool warning is flushed anyway, so one stalled --- download can't suppress it all session. A finished install past the deadline --- is still configured. Non-positive values fall back to the default. +-- Deadline (ms) for background Mason work before the aggregated missing-tool warning is +-- flushed anyway, so one stalled download can't suppress it all session. A finished +-- install past the deadline is still configured; non-positive values use the default. ---@type number settings["tool_install_timeout"] = 300000 diff --git a/lua/modules/configs/completion/conform.lua b/lua/modules/configs/completion/conform.lua index dc94aabe..d5a916df 100644 --- a/lua/modules/configs/completion/conform.lua +++ b/lua/modules/configs/completion/conform.lua @@ -168,34 +168,28 @@ return function() end or false, }) - -- Resolve `formatter_deps` discovery-first against conform's own registry - -- (builtins + the `formatters` overrides above). Deps are conform formatter - -- names, same semantics as lsp_deps/dap_deps. + -- Resolve `formatter_deps` (conform formatter names) discovery-first against conform's + -- own registry. Runs synchronously before lazy.nvim replays BufWritePre so the + -- off-$PATH rewrite is in place for the first format-on-save under Mason PATH = "skip". local tools = require("modules.utils.tools") - -- Synchronous, unlike nvim-lint's deferred pass: only cheap exepath() stats - -- here, and running before lazy.nvim replays BufWritePre keeps the off-$PATH - -- rewrite in place for the first format-on-save under Mason PATH = "skip". tools.resolve_runtime_tools("conform.nvim", settings.formatter_deps, function(name) - -- get_formatter_config is conform's @private API; if a future release drops - -- it, degrade to "resolves itself" rather than misreporting every formatter - -- as an unknown name. + -- get_formatter_config is conform's @private API; if dropped, degrade to + -- "resolves itself" rather than misreporting every formatter as unknown. local conform = require("conform") if type(conform.get_formatter_config) ~= "function" then return { binary = nil } end - -- get_formatter_config runs a function-form `formatters` override directly, - -- so a throwing override would leak out of the probe (and be misread as a - -- typo); that's a broken config, not an unknown name. + -- 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 if type(config.command) == "function" then - -- Function-form command (e.g. builtin `from_node_modules`): evaluate it - -- with a hand-built ctx (conform.Context's public fields, resolve-time - -- buffer) for a representative answer — runtime per-buffer differences - -- are covered by configure's wrapper below. + -- Function-form command (e.g. builtin `from_node_modules`): evaluate it with + -- a hand-built ctx for a representative answer; per-buffer differences are + -- covered by configure's wrapper below. local buf = vim.api.nvim_get_current_buf() local filename = vim.api.nvim_buf_get_name(buf) local ok_cmd, cmd = pcall(config.command, config, { @@ -210,22 +204,19 @@ return function() end return { binary = config.command } end - -- (nil, err) is a real formatter whose configuration is broken (vs bare nil - -- = unknown name): the typo guidance and the Mason install fallback would - -- both misdirect. + -- (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 return nil end, function(name) - -- Under Mason PATH = "skip" the binary is installed but off $PATH, so - -- conform would spawn a bare name that fails at format time; rewrite it to - -- an absolute path. No-op on $PATH. + -- Under Mason PATH = "skip" the binary is off $PATH; rewrite it to an absolute + -- path so conform doesn't spawn a bare name that fails. No-op on $PATH. local conform = require("conform") local prev = conform.formatters[name] if type(prev) == "function" then - -- Function-form override resolves its command per buffer: wrap it and - -- rewrite an off-$PATH result, preserving the override's args/cwd. + -- Function-form override resolves per buffer: wrap it to rewrite an off-$PATH + -- result, preserving the override's args/cwd. conform.formatters[name] = tools.wrap_off_path(prev, "command") return end @@ -237,8 +228,8 @@ return function() return end if type(config.command) == "function" then - -- Function-form command resolves per buffer: wrap it so an off-$PATH - -- result is rewritten at format time; (self, ctx) pass through. + -- Function-form command resolves per buffer: wrap it to rewrite an off-$PATH + -- result at format time; (self, ctx) pass through. conform.formatters[name] = vim.tbl_deep_extend( "force", type(prev) == "table" and prev or {}, diff --git a/lua/modules/configs/completion/lsp.lua b/lua/modules/configs/completion/lsp.lua index f6b7b5a2..5be17e71 100644 --- a/lua/modules/configs/completion/lsp.lua +++ b/lua/modules/configs/completion/lsp.lua @@ -1,13 +1,11 @@ return function() - -- Server resolution (Mason-installed / on $PATH / installable / missing) is - -- handled centrally and discovery-first in `mason-lspconfig.setup`, driven by - -- the single `settings.lsp_deps` list. + -- Server resolution (Mason-installed / on $PATH / installable / missing) is handled + -- discovery-first in `mason-lspconfig.setup`, driven by `settings.lsp_deps`. require("completion.mason-lspconfig").setup() - -- NOTE: a server Mason auto-installs mid-session registers only when its - -- install finishes — after this point — so a `vim.lsp.config` override in - -- `user.configs.lsp` for it gets overwritten by the repo spec. Overrides that - -- must always win belong in `user.configs.lsp-servers.`. + -- A server Mason installs mid-session registers only when its install finishes (after + -- this point), so a `vim.lsp.config` override here gets overwritten by the repo spec. + -- Overrides that must always win belong in `user.configs.lsp-servers.`. pcall(require, "user.configs.lsp") -- Start LSPs diff --git a/lua/modules/configs/completion/mason-lspconfig.lua b/lua/modules/configs/completion/mason-lspconfig.lua index 7694c692..c4f8c3b2 100644 --- a/lua/modules/configs/completion/mason-lspconfig.lua +++ b/lua/modules/configs/completion/mason-lspconfig.lua @@ -18,8 +18,7 @@ M.setup = function() local module_path = tools.module_path - -- Servers configured by a dedicated plugin, never by lspconfig here. - -- Keyed by server name -> the plugin that owns it. + -- Server name -> plugin that owns it; these are configured by that plugin, never here. ---@type table local externally_managed = { rust_analyzer = "mrcjkb/rustaceanvim", @@ -36,11 +35,9 @@ M.setup = function() capabilities = require("modules.utils").get_lsp_capabilities(), } - ---Probe a server's manual spec once and cache both the facts the resolver's - ---predicates need and the loaded spec values (mason_lsp_handler consumes them - ---instead of re-requiring). `binary` prefers an explicit table `cmd` (user - ---override, repo default, then nvim-lspconfig's); nil for a function/absent - ---cmd — such a spec resolves its own command at launch. + ---Probe and cache a server's spec once (resolver predicates and mason_lsp_handler + ---both read it). `binary` prefers a table `cmd` (user override, repo default, then + ---nvim-lspconfig's); nil for a function/absent cmd that resolves its command at launch. local server_info_cache = {} ---@param name string ---@return { has_module: boolean, binary: string|nil, known_lspconfig: boolean, user_loaded: boolean, user_spec: any, default_loaded: boolean, default_spec: any } @@ -59,9 +56,8 @@ M.setup = function() default_spec = nil, } local modules = server_modules(name) - -- A spec file that exists but throws at load is a broken config, not a typo: - -- load_module_or_report surfaces the error (deduped) and `exists` keeps the - -- name counted as a module, out of the unknown-name bucket. + -- A spec that exists but throws at load is a broken config, not a typo: `exists` + -- keeps the name counted as a module (out of the unknown-name bucket). local user_ok, user_spec, user_exists = tools.load_module_or_report(modules[1], "nvim-lspconfig") if user_ok then info.has_module = true @@ -73,10 +69,8 @@ M.setup = function() elseif user_exists then info.has_module = true end - -- Load the repo preset only when the handler could use it (no override, or - -- as the merge base under a table override): a function-form override - -- replaces it wholesale, and requiring it anyway would run the preset's - -- module-level code for a spec that cannot be used. + -- Load the repo preset only when usable (no override, or as merge base under a + -- table override): a function-form override replaces it wholesale. if not user_ok or type(user_spec) == "table" then local ok, spec, exists = tools.load_module_or_report(modules[2], "nvim-lspconfig") if ok then @@ -90,9 +84,8 @@ M.setup = function() info.has_module = true end end - -- nvim-lspconfig's built-in config: a table cmd yields the launch binary, - -- and any cmd (even a function) proves the name is a real server — keeping - -- function-cmd servers (e.g. jsonls) out of the unknown-name bucket. + -- nvim-lspconfig's built-in config: a table cmd yields the launch binary; any cmd + -- (even a function) proves a real server, keeping e.g. jsonls out of unknown names. local ok, config = pcall(function() return vim.lsp.config[name] end) @@ -106,10 +99,8 @@ M.setup = function() return info end - ---Register a server's config from the specs under `completion/servers/*.lua`, - ---reusing the modules server_info() already loaded. Registers only — - ---vim.lsp.enable() runs in configure() below, AFTER the off-$PATH cmd rewrite - ---(see there for why the order matters). + ---Register (not enable) a server's config from `completion/servers/*.lua`, reusing + ---the spec server_info() loaded. vim.lsp.enable() runs later in configure(). ---@param lsp_name string ---@return boolean registered @Whether a config was registered (and can be enabled). local function mason_lsp_handler(lsp_name) @@ -159,13 +150,12 @@ M.setup = function() return true end - -- Warn about a stray manual spec for an externally-managed server — a - -- misconfiguration regardless of whether it is in `lsp_deps` (the resolver - -- only visits deps entries, so the handler can't catch this). + -- Warn about a stray manual spec for an externally-managed server, whether or not + -- it is in `lsp_deps` (the resolver only visits deps, so the handler can't catch it). for server, plugin in pairs(externally_managed) do for _, module in ipairs(server_modules(server)) do - -- Presence check WITHOUT executing the module: a stray spec must be - -- reported even (especially) when it errors at load. + -- Presence check WITHOUT executing the module, so a spec that errors at load + -- is still reported. local path = module_path(module) if path then vim.notify( @@ -197,9 +187,8 @@ M.setup = function() }) end - -- lspconfig server name -> Mason package name. Re-fetched while still empty: - -- get_mappings() returns {} on a never-bootstrapped registry, and caching that - -- would freeze the empty map for the session. + -- lspconfig server name -> Mason package name. Re-fetched while empty: get_mappings() + -- returns {} on a never-bootstrapped registry, and caching that would freeze it. local lspconfig_to_package = nil local function package_of(name) if not mason_ok then @@ -212,10 +201,8 @@ M.setup = function() return lspconfig_to_package[name] end - ---Treat a manual/built-in spec as a resolution fallback only when its launch - ---binary can't be probed statically (function/absent `cmd` on a real server). - ---With a known binary the $PATH check decides; configuring anyway would - ---silently enable a server whose binary is missing. + ---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) @@ -223,9 +210,8 @@ M.setup = function() 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 nvim-lspconfig config all mean the name is real - ---and should get install/$PATH guidance rather than "correct your config". + ---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) @@ -236,10 +222,9 @@ M.setup = function() return not info.has_module and not info.known_lspconfig end - ---Register a server's config, rewrite its resolved `cmd[1]` to an absolute - ---path when off $PATH (Mason `PATH = "skip"`), and only THEN enable it: - ---vim.lsp.enable() synchronously fires FileType autocmds for already-open - ---buffers, which would spawn the still-bare, unspawnable name. + ---Register a server, rewrite its resolved `cmd[1]` to an absolute path when off $PATH + ---(Mason `PATH = "skip"`), and only THEN enable it: vim.lsp.enable() synchronously + ---fires FileType autocmds for open buffers, which would spawn the still-bare name. local function configure(name) if not mason_lsp_handler(name) then return @@ -256,9 +241,8 @@ M.setup = function() vim.lsp.config(name, { cmd = cmd }) end elseif ok and type(config) == "table" and type(config.cmd) == "function" then - -- A function cmd spawns internally (vim.lsp.rpc.start) and can't be - -- evaluated or rewritten; under Mason PATH = "skip" its bare binary - -- name would ENOENT. Run it with Mason's bin dir appended to $PATH. + -- A function cmd spawns internally and can't be rewritten; under Mason + -- PATH = "skip" its bare name would ENOENT. Run it with Mason's bin on $PATH. vim.lsp.config(name, { cmd = tools.wrap_with_mason_path(config.cmd) }) end vim.lsp.enable(name) @@ -267,8 +251,7 @@ M.setup = function() tools.resolve({ title = "LSP", deps = settings.lsp_deps, - -- Lazy thunk (parity with resolve_runtime_tools): only consulted when a - -- dep actually needs the registry in phase 2. + -- Lazy thunk: only consulted when a dep actually needs the registry in phase 2. registry = function() return mason_ok and mason_registry or nil end, @@ -278,9 +261,8 @@ M.setup = function() if binary then return { binary } end - -- Function-cmd server (e.g. jsonls): probe the Mason package's declared - -- bins so a system-provided copy is discovered instead of installing a - -- duplicate. + -- Function-cmd server (e.g. jsonls): probe the Mason package's declared bins + -- so a system-provided copy is found instead of installing a duplicate. if pkg ~= nil then return tools.package_binaries(pkg, name) end diff --git a/lua/modules/configs/completion/mason.lua b/lua/modules/configs/completion/mason.lua index 2ff4864e..fc8eb4cf 100644 --- a/lua/modules/configs/completion/mason.lua +++ b/lua/modules/configs/completion/mason.lua @@ -27,9 +27,8 @@ M.setup = function() }, }) - -- Formatter/linter resolution lives with its ground truth: conform.lua and - -- nvim-lint.lua resolve their deps against their own registrations (see - -- tools.resolve_runtime_tools). Mason here is UI-only / lazy install fallback. + -- Formatter/linter resolution lives in conform.lua and nvim-lint.lua (against their + -- own registrations); Mason here is UI-only / lazy install fallback. end return M diff --git a/lua/modules/configs/completion/nvim-lint.lua b/lua/modules/configs/completion/nvim-lint.lua index 73eed018..0328c92c 100644 --- a/lua/modules/configs/completion/nvim-lint.lua +++ b/lua/modules/configs/completion/nvim-lint.lua @@ -26,10 +26,9 @@ return function() { source = "markdownlint" } ) - -- shuck: lints shell embedded in GitHub Actions `run:` blocks (actionlint skips - -- those). Only on `yaml.github` — standalone sh/bash comes from the shuck LSP - -- server (see servers/shuck.lua). `shuck check` has no working stdin mode - -- (needs a project root), so run file-based and parse JSON. + -- shuck: lints shell embedded in GitHub Actions `run:` blocks (actionlint + -- skips those); 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", @@ -88,9 +87,8 @@ return function() } lint.linters_by_ft = by_ft - -- Mirror nvim-lint's linter lookup (lua/lint.lua `_resolve_linter_by_ft`): - -- exact filetype key first, else the union over its `.`-separated segments. - -- Local copy so we don't depend on its private API. + -- Mirror nvim-lint's private _resolve_linter_by_ft: exact filetype key + -- first, else the union over its `.`-separated segments. local function linters_for_ft(ft) local exact = by_ft[ft] if exact then @@ -105,16 +103,10 @@ return function() return union end - -- Resolve `linter_deps` discovery-first against nvim-lint's own registry - -- (builtins via the `lint.linters` metatable + the overrides above). Deps are - -- nvim-lint linter names, same semantics as lsp_deps/dap_deps. - -- - -- Resolution is batched BY FILETYPE and hangs off the lint events below: the - -- probe requires the linter module, and some (golangcilint) run blocking - -- `vim.fn.system` calls at module-load time. Deferring each module's require - -- to the moment nvim-lint would require it anyway makes the pass ~free for - -- filetypes never opened this session, and load-time cwd sniffing (GOMOD) - -- sees the buffer's cwd instead of the startup one. + -- Resolve `linter_deps` discovery-first against nvim-lint's own registry. + -- Resolution is batched BY FILETYPE off the lint events below: the probe + -- requires the linter module, and some (golangcilint) run blocking system + -- calls and sniff the cwd (GOMOD) at module-load time. local tools = require("modules.utils.tools") ---Rewrite a linter's cmd to an absolute path: under Mason PATH = "skip" the ---binary is installed but off $PATH. No-op on $PATH. @@ -122,9 +114,7 @@ return function() local function rewrite_off_path(name) local linter = lint.linters[name] if type(linter) == "function" then - -- Function linter factory (nvim-lint calls it at each lint run): wrap it - -- and rewrite the string cmd of the table it returns (copied first — the - -- factory may return a shared/cached table). + -- Factory linter: wrap it and rewrite the cmd of the table it returns. lint.linters[name] = tools.wrap_off_path(linter, "cmd") return end @@ -135,26 +125,21 @@ return function() if type(cmd) == "string" then linter.cmd = tools.off_path_command(cmd) or cmd elseif type(cmd) == "function" then - -- Function cmd (e.g. oxlint): wrap so an off-$PATH result is rewritten - -- at lint time, preserving the per-run resolution. + -- Function cmd (e.g. oxlint): rewrite its result at lint time. linter.cmd = tools.wrap_off_path(cmd) end end - ---Resolve one batch of linter names. Synchronous configures (available / - ---local) run inside the resolve call — before the batch flag flips — so their - ---cmd rewrites land before the lint that triggered the batch; late configures - ---(install completion / post-refresh) have no later lint event and re-lint - ---the already-open buffers themselves. + ---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) local batch_done = false tools.resolve_runtime_tools("nvim-lint", names, function(name) local linter = lint.linters[name] if linter == nil then - -- The lint.linters metatable pcalls require and swallows loader errors: - -- a linter module that exists but throws at load is a broken config, - -- not a typo. Re-require to capture the message (a failed require is - -- not cached, so this re-throws). + -- The lint.linters 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 tools.module_path(module) then local _, err = pcall(require, module) @@ -163,8 +148,7 @@ return function() return nil -- unknown linter name (typo / not registered) end if type(linter) == "function" then - -- Function linter factory: a throwing factory is a broken config, not - -- an unknown name. + -- A throwing factory is a broken config, not an unknown name. local ok, resolved = pcall(linter) if not ok then return { broken = tostring(resolved) } @@ -187,10 +171,8 @@ return function() end, function(name) rewrite_off_path(name) if batch_done then - -- Late configure (install completion / post-refresh) has no later lint - -- event: re-lint every loaded buffer whose filetype maps to this linter, - -- not just the current one — others opened during the install would - -- otherwise never get diagnostics. + -- No lint event follows a late configure: re-lint every loaded buffer + -- whose filetype maps to this linter, not just the current one. vim.schedule(function() for _, buf in ipairs(vim.api.nvim_list_bufs()) do if @@ -210,10 +192,8 @@ return function() batch_done = true end - -- Split the deps: names mapped in `by_ft` resolve lazily on their filetype's - -- first lint event; the rest (typos, manually-triggered linters — empty with - -- the default config) keep a deferred immediate pass, whose module require - -- misses for a typo so there is no load-time spawn risk. + -- 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 deps = require("core.settings").linter_deps if type(deps) ~= "table" then deps = {} @@ -259,16 +239,14 @@ return function() end end - -- FileType is resolve-only, no lint (parity with the lint cadence before - -- lazy resolution): it covers the buffer whose BufReadPost loaded this - -- plugin — lazy.nvim replays that event before filetype detection runs, so - -- its lint-event callback below sees an empty filetype. + -- 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(args) - -- Resolve this filetype's deps before the lint they gate: the batch's - -- synchronous rewrites land in the same tick, so the first lint already - -- spawns by absolute path under Mason PATH = "skip". + -- Resolve this filetype's deps before the lint they gate, so the first + -- lint already spawns by absolute path under Mason PATH = "skip". ensure_resolved(vim.bo[args.buf].filetype) if args.event ~= "FileType" then lint.try_lint(nil, { ignore_errors = true }) diff --git a/lua/modules/configs/tool/dap/clients/codelldb.lua b/lua/modules/configs/tool/dap/clients/codelldb.lua index 239b74ed..70b2a1b1 100644 --- a/lua/modules/configs/tool/dap/clients/codelldb.lua +++ b/lua/modules/configs/tool/dap/clients/codelldb.lua @@ -4,10 +4,8 @@ return function() local utils = require("modules.utils.dap") local is_windows = require("core.global").is_windows - -- Config-time self-validation: error when codelldb is missing so the resolver - -- surfaces it (or installs it) instead of registering an adapter that only - -- fails at session start. Launch AND attach both spawn the local binary, so - -- unlike delve/python nothing is worth registering without it. + -- Self-validate at config time: 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 = { diff --git a/lua/modules/configs/tool/dap/clients/delve.lua b/lua/modules/configs/tool/dap/clients/delve.lua index 686c4544..c1cf862e 100644 --- a/lua/modules/configs/tool/dap/clients/delve.lua +++ b/lua/modules/configs/tool/dap/clients/delve.lua @@ -5,10 +5,9 @@ return function() local utils = require("modules.utils.dap") local is_windows = require("core.global").is_windows - -- Use delve's built-in DAP server (`dlv dap`) directly — no separate - -- go-debug-adapter — matching how tool/dap/init.lua resolves the `delve` - -- package (bin `dlv`). `dlv` resolves lazily on the spawn path only: remote - -- attach needs no local binary, so the adapter registers even when it's absent. + -- 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. ---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. @@ -16,8 +15,8 @@ return function() ---@param config table local function delve_adapter(callback, config) if config.request == "attach" and config.mode == "remote" then - -- Default when unset, but a *malformed* user port is an error — not a - -- silent fallback to delve's default 38697. + -- Default when unset, but a malformed user port errors rather than silently + -- falling back to delve's default 38697. local port = 38697 if config.port ~= nil then local n = tonumber(config.port) @@ -38,8 +37,7 @@ return function() port = port, }) else - -- Resolve lazily so a dlv installed after config load (a finished Mason - -- install, say) is picked up without reconfiguring. + -- 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" @@ -124,8 +122,7 @@ return function() }, } - -- Availability check LAST: erroring here lets the resolver surface `delve` - -- (or install it) while remote attach keeps what was registered above. + -- Availability check LAST so remote attach keeps what was registered above. require("modules.utils.tools").exepath_or_error( "dlv", "local `dlv dap` launch is unavailable until installed (remote attach still works)" diff --git a/lua/modules/configs/tool/dap/clients/lldb.lua b/lua/modules/configs/tool/dap/clients/lldb.lua index 2595501c..6ce14c42 100644 --- a/lua/modules/configs/tool/dap/clients/lldb.lua +++ b/lua/modules/configs/tool/dap/clients/lldb.lua @@ -3,9 +3,8 @@ return function() local dap = require("dap") local utils = require("modules.utils.dap") - -- Opt-in preset (not in the default dap_deps; codelldb covers C-family). - -- Self-validate so the resolver surfaces `lldb` instead of registering an - -- adapter with an empty command. LLVM 15 renamed `lldb-vscode` -> `lldb-dap`; + -- Opt-in preset (not in default dap_deps; codelldb covers C-family). Self-validate + -- so the resolver surfaces `lldb`. LLVM 15 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" }, diff --git a/lua/modules/configs/tool/dap/clients/python.lua b/lua/modules/configs/tool/dap/clients/python.lua index 75d9a473..43ecddac 100644 --- a/lua/modules/configs/tool/dap/clients/python.lua +++ b/lua/modules/configs/tool/dap/clients/python.lua @@ -15,10 +15,8 @@ return function() return is_windows and root .. "/Scripts/pythonw.exe" or root .. "/bin/python" end - -- Resolve the debugpy adapter command discovery-first, split into a fast and - -- a full cascade sharing one success cache. Only a *successful* resolution is - -- cached: a failure must stay re-probeable so a mid-session install is picked - -- up. + -- Resolve the debugpy command discovery-first via a fast and a full cascade. Only + -- a successful resolution is cached, so a mid-session install is still picked up. local resolved local function found(command, args) resolved = { command = command, args = args } @@ -46,9 +44,8 @@ return function() return nil end - -- Full cascade: the fast probes above, then a system python that can import - -- debugpy. The import probe shells out (blocking), so this is reserved for a - -- user-triggered launch, never the config-time resolve path. + -- Full cascade: fast probes, then a system python that can import debugpy. The + -- import probe blocks, so this is for a user-triggered launch, not config time. local function debugpy_command() local command, args = fast_command() if command then @@ -59,9 +56,8 @@ return function() local candidates = is_windows and { "pythonw.exe", "python.exe", "python" } or { "python3", "python" } local probed = {} for _, py in ipairs(candidates) do - -- Confirm the module actually imports (list-form system(), no shell), not - -- just that the interpreter exists. Dedup by resolved path so - -- `python`→`python3` symlinks don't pay the blocking spawn twice. + -- 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 real = vim.fn.exepath(py) real = (real ~= "" and vim.uv.fs_realpath(real)) or real @@ -88,8 +84,7 @@ return function() options = { source_filetype = "python" }, }) else - -- Launch path only: attach uses the server branch above and needs no - -- local debugpy. Fail fast with a clear error, never a guessed command. + -- Launch path only: attach uses the server branch above and needs no local debugpy. local command, args = debugpy_command() if not command then error( @@ -152,12 +147,9 @@ return function() }, } - -- Availability check LAST: erroring here lets the resolver surface `python` - -- (or install debugpy) while remote attach keeps what was registered above. - -- Fast probes only — this runs on the config-time resolve path (any `:Dap*` - -- loads it), so the blocking interpreter import probe is deferred to launch. - -- On success the result is cached for the first launch; on failure it stays - -- uncached so a mid-session install is picked up. + -- Availability check LAST so remote attach keeps what was registered above. Runs + -- on the config-time resolve path, so fast probes only — the blocking import probe + -- waits for launch. Cached on success; uncached on failure to catch a later install. if not fast_command() then error( "debugpy not found: no Mason venv and no `debugpy-adapter` on $PATH; install debugpy via\n" diff --git a/lua/modules/configs/tool/dap/init.lua b/lua/modules/configs/tool/dap/init.lua index fa654067..cd85358e 100644 --- a/lua/modules/configs/tool/dap/init.lua +++ b/lua/modules/configs/tool/dap/init.lua @@ -66,9 +66,8 @@ return function() local dap_name = config.name local custom_handler = tools.first_module(client_modules(dap_name), "nvim-dap") if custom_handler == nil then - -- No client config: fall back to Mason's factory config. Both failure - -- modes error (level 0) so the shared resolver reports them, instead of - -- a silent return reading as success. + -- No client config: fall back to Mason's factory config, erroring (level 0) + -- so the shared resolver reports failures instead of reading them as success. if not has_mason_dap then error( string.format( @@ -78,9 +77,8 @@ return function() 0 ) end - -- default_setup silently no-ops on a nil adapter config - -- (Optional.of_nilable(nil):map()) — the adapter would never be - -- registered while the resolver reads the call as success. + -- default_setup silently no-ops on a nil adapter config, so error rather + -- than register nothing while the resolver reads the call as success. if config.adapters == nil then error( string.format( @@ -148,9 +146,7 @@ return function() return tools.first_module(client_modules(name), "nvim-dap") ~= nil end - ---Configure an adapter via the shared handler. Client configs self-validate - ---(error() when their binary is missing), which the resolver surfaces — no - ---separate post-config sanity check here. + ---Configure an adapter via the shared handler; client configs self-validate. ---@param name string local function configure_adapter(name) mason_dap_handler({ @@ -161,16 +157,14 @@ return function() }) end - -- Discovery-first resolution, shared with LSP and formatters/linters. - -- nvim-dap has no uniform command registry like nvim-lspconfig, so $PATH - -- detection leans on the Mason package's declared binaries; client configs - -- without a Mason package resolve their own binary. + -- 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. tools.resolve({ title = "DAP", deps = settings.dap_deps, - -- Gate on the registry alone: it can install a mapped adapter's package by - -- itself, so a setup without mason-nvim-dap still auto-installs. Lazy thunk - -- (parity with resolve_runtime_tools): only consulted in phase 2. + -- Registry alone can install a mapped adapter's package, so a setup without + -- mason-nvim-dap still auto-installs. Lazy thunk: only consulted in phase 2. registry = function() return has_registry and registry or nil end, @@ -181,16 +175,12 @@ return function() if pkg ~= nil then return tools.package_binaries(pkg, name) end - -- No Package object, no probe: adapter/package names are not binary names - -- (`delve` ships `dlv`, `cpptools` ships `OpenDebugAD7`, `python` collides - -- with the interpreter). Client configs self-validate in phase 1; phase 2 - -- probes the package's declared bins for system-provided adapters. + -- No Package object, no probe: adapter names are not binary names (`delve` + -- ships `dlv`, `cpptools` ships `OpenDebugAD7`). Client configs self-validate. return {} end, - ---Typo / outdated adapter name, vs valid-but-unprovisioned? A client config - ---or mason-nvim-dap mapping means the name is real. The map is only trusted - ---when populated — an empty one (Mason absent / module drift) would misread - ---real adapters as typos. + ---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 @@ -201,9 +191,8 @@ return function() return source_map.nvim_dap_to_package[name] == nil end, has_local_config = has_client_config, - -- Client configs self-validate, so let an existing config try before the - -- Mason install fallback — e.g. python resolves debugpy from a venv/system - -- interpreter the $PATH probe can't see. + -- Client configs self-validate, so try an existing config before the Mason + -- install fallback — python resolves debugpy from a venv $PATH can't see. validates_config = true, configure = configure_adapter, }) diff --git a/lua/modules/plugins/tool.lua b/lua/modules/plugins/tool.lua index dd2bb5e8..5ad7aa4f 100644 --- a/lua/modules/plugins/tool.lua +++ b/lua/modules/plugins/tool.lua @@ -106,9 +106,8 @@ tool["mfussenegger/nvim-dap"] = { }, config = require("tool.dap"), dependencies = { - -- mason-nvim-dap only supplies the adapter -> package mappings (mason.nvim's - -- config is UI-only, so loading DAP triggers no resolver side effects); the - -- DAP resolver degrades to adapter_meta + $PATH when either is absent. + -- mason-nvim-dap only supplies the adapter -> package mappings; the DAP resolver + -- degrades to $PATH discovery when it (or mason.nvim) is absent. { "jay-babu/mason-nvim-dap.nvim" }, { "rcarriga/nvim-dap-ui", diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index de42efc7..822f9147 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -1,23 +1,18 @@ --- Discovery-first tool resolution helpers (RFC: ayamir/nvimdots#1293). --- Mason is an optional installer backend, not a hard requirement: every --- external tool (LSP server, formatter, linter, DAP adapter) resolves as --- $PATH → Mason install → surfaced to the user, and all subsystems share the --- resolution loop (`M.resolve`) plus one aggregated missing-tool warning. +-- Discovery-first tool resolution (RFC: ayamir/nvimdots#1293): every external +-- tool resolves $PATH → Mason install → aggregated missing-tool warning, with +-- Mason as an optional backend and `M.resolve` as the shared loop. local M = {} ---Locate the first of the given executables: on $PATH, then in Mason's bin ----directory (covers Mason installs with `PATH = "skip"`, where the binary ----exists but a plain exepath() misses it). Intentionally *first-found*, not ----*all*: callers pass alternates for one tool, and requiring all would ----false-negative packages that declare auxiliary binaries. +---dir (covers Mason installs with `PATH = "skip"`). 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) if type(names) == "string" then names = { names } end - -- exepath() throws on non-string/empty args; iterate to maxn, not ipairs, so - -- a nil hole from a conditional entry doesn't truncate the search. + -- exepath() throws on non-strings; maxn so a nil hole doesn't truncate the search. if type(names) ~= "table" then return nil end @@ -36,8 +31,7 @@ function M.find_executable(names) for i = 1, last do local name = names[i] if type(name) == "string" and name ~= "" then - -- exepath() validates executability and resolves the Windows extension - -- (mason bin shims are `.cmd` there). + -- exepath() also resolves the Windows `.cmd` shim extension. local path = vim.fn.exepath(root .. "/bin/" .. name) if path ~= "" then return path @@ -48,9 +42,8 @@ function M.find_executable(names) return nil end ----Locate a module file on the search paths require() uses, WITHOUT executing ----it. Probes both package.path (covers `completion.servers.*` via the entries ----core/pack.lua appends) and the runtimepath loader (covers `user.*`). +---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) @@ -69,10 +62,9 @@ end -- Modules already reported broken, so multi-site lookups notify once per module. local broken_module_reported = {} ----Require a module, distinguishing "missing" from "exists but broken". A file ----that is present on the search paths yet throws at load is a broken config, ----not a missing one: its error is notified (once per module per session) and ----`exists` stays true so callers keep it out of any unknown-name bucket. +---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. @@ -98,10 +90,8 @@ function M.load_module_or_report(module, title) end ---Require the first loadable module from an ordered candidate list (user ----override before repo default). Returns its value, or nil when none load ----(config modules return non-nil, so nil unambiguously means "not found"). ----A candidate that exists but throws at load is a broken config, not a missing ----one: its error is notified (once) and the lookup falls through. +---override before repo default); nil when none load. A candidate that exists +---but throws is notified (once) and skipped. ---@param candidates string[] @Module names, highest precedence first. ---@param title? string @Notification title for broken-module load errors. ---@return any|nil @The first module's value, or nil if none loaded. @@ -115,10 +105,9 @@ function M.first_module(candidates, title) return nil end ----Absolute path to spawn a tool by when its bare name isn't usable as-is. ----Non-nil only when the binary is off $PATH but present in Mason's bin dir ----(the `PATH = "skip"` case, where a bare-name spawn would fail at runtime); ----nil when the bare name already works or nothing resolves at all. +---Absolute path for a binary that is off $PATH but in Mason's bin dir (the +---`PATH = "skip"` case); nil when the bare name already works or nothing +---resolves. ---@param binary string @The bare executable name a tool is configured to launch. ---@return string|nil @Absolute path to spawn by, or nil to keep the bare name. function M.off_path_command(binary) @@ -129,11 +118,10 @@ function M.off_path_command(binary) end ---Wrap a function-form command resolver so its result is rewritten to an ----absolute path when off $PATH (Mason PATH = "skip"). With `field`, the ----result is a config table and result[field] holds the command — the table ----is COPIED before rewriting (the fn may return a shared/cached table). ----Without `field`, the result is the command string itself. ----@param fn function @The resolver to wrap; arguments pass through untouched. +---absolute path when off $PATH. With `field` the result is a table holding +---the command at that key (copied before rewriting — it may be shared); +---without, the result is the command string itself. +---@param fn function ---@param field? string @Key holding the command when the result is a table. ---@return function function M.wrap_off_path(fn, field) @@ -156,11 +144,9 @@ function M.wrap_off_path(fn, field) end end ----Wrap a function that spawns internally (e.g. an LSP function `cmd` calling ----vim.lsp.rpc.start) so it runs with Mason's bin dir APPENDED to $PATH — ----appended, not prepended, to keep the discovery-first "$PATH wins, Mason is ----the fallback" order. $PATH is restored whether the fn returns or throws; ----without a Mason root the fn passes through untouched. +---Wrap a function that spawns internally (e.g. a function LSP `cmd`) so it +---runs with Mason's bin dir APPENDED to $PATH — appended so $PATH still wins. +---$PATH is restored whether the fn returns or throws. ---@param fn function ---@return function function M.wrap_with_mason_path(fn) @@ -172,8 +158,7 @@ function M.wrap_with_mason_path(fn) local prev = vim.env.PATH or "" local sep = vim.fn.has("win32") == 1 and ";" or ":" vim.env.PATH = prev .. sep .. root .. "/bin" - -- restore(pcall(...)) keeps every return value while guaranteeing the - -- $PATH reset runs before a throw propagates. + -- Keeps all return values while resetting $PATH before a throw propagates. local function restore(ok, ...) vim.env.PATH = prev if not ok then @@ -185,10 +170,8 @@ function M.wrap_with_mason_path(fn) end end ----Resolve the first of the given executable names (see `find_executable`), or ----`error()` with an install hint — config-time self-validation for client/server ----configs, whose message the shared resolver aggregates. Thrown at level 0 so ----it arrives without a "file:line:" prefix. +---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. @@ -200,8 +183,7 @@ function M.exepath_or_error(names, hint) if path then return path end - -- Render only well-formed entries: table.concat throws on non-strings/nil - -- holes. maxn to match the probe above. + -- table.concat throws on non-strings/nil holes; render only valid entries. local shown = {} if type(names) == "table" then for i = 1, table.maxn(names) do @@ -215,11 +197,9 @@ function M.exepath_or_error(names, hint) error(string.format("%s not found on $PATH; %s", label, hint), 0) end ----Create a collector that aggregates tools which could not be set up into one ----deferred warning instead of one notification per tool. Two sections, so the ----guidance matches the cause: `mark` (install it / config failed, with an ----optional inline reason) and `mark_unknown` (unrecognized name — fix the ----config, don't install). +---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) @Record an unresolved tool (optionally with a reason). ---@field mark_unknown fun(name: string) @Record an unrecognized name (typo / outdated / unsupported). @@ -239,8 +219,7 @@ local function missing_collector(title, timeout_ms) local deadline_started = false local deadline_passed = false - -- De-duplicated across both buckets. A non-string name (malformed deps entry) - -- is tostring()'d so it still surfaces; nil/empty strings are dropped. + -- 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 @@ -270,9 +249,8 @@ local function missing_collector(title, timeout_ms) return reason and (name .. " — " .. reason) or name end - -- Emit any collected names not yet notified. No-op while installs are pending - -- unless the deadline passed; the `emitted` set lets failures arriving after - -- the deadline still get their own notification. + -- Emit names not yet notified. No-op while installs are pending unless the + -- deadline passed; `emitted` lets late failures still notify. local function flush() if pending > 0 and not deadline_passed then return @@ -321,12 +299,9 @@ local function missing_collector(title, timeout_ms) mark = add, mark_unknown = add_unknown, track = function(pkg, name, recheck, on_ready, fail_reason) - -- Attach to an in-flight install (another subsystem / a manual install) - -- instead of starting a duplicate: mason's install() asserts - -- `not self:is_installing()`. The handle is read straight off the package — - -- install() registers it synchronously, so no shadow cache is needed. It - -- also OUTLIVES its install (mason never clears it): only an OPEN handle - -- means an in-flight install; a closed one would never fire once("closed"). + -- Attach to an in-flight install instead of starting a duplicate (mason's + -- install() asserts not is_installing()). The handle outlives its install, + -- so only an OPEN one counts: 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() @@ -344,8 +319,7 @@ local function missing_collector(title, timeout_ms) end end - -- Only a self-started install is announced in the "Installing N tool(s)" - -- INFO; attaching to a shared/manual install must not claim it. + -- 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() @@ -363,9 +337,8 @@ local function missing_collector(title, timeout_ms) end pending = pending + 1 - -- Arm the deadline on the first tracked install so an install that never - -- closes can't suppress the aggregated warning all session; late failures - -- past the deadline still notify via flush's `emitted` set. + -- Arm the deadline on the first tracked install so one that never closes + -- can't suppress the aggregated warning all session. if not deadline_started and type(timeout_ms) == "number" and timeout_ms > 0 then deadline_started = true vim.defer_fn(function() @@ -373,16 +346,14 @@ local function missing_collector(title, timeout_ms) flush() end, timeout_ms) end - -- Queue only real strings (done() sorts `queued`; a non-string would make - -- the sort throw) and only self-started installs. Remember whether we - -- appended so the once()-throw path below pops the right entry. + -- Queue only real strings (done() sorts `queued`) from self-started + -- installs; `queued_here` lets the once()-throw path pop the right entry. local queued_here = started_here and type(name) == "string" and name ~= "" if queued_here then queued[#queued + 1] = name end - -- "closed" fires in a fast event context where Vim APIs are unsafe: hop to - -- the main loop. Both callbacks are pcall'd and pending is decremented - -- unconditionally so a throw can't suppress the aggregated warning. + -- "closed" fires in a fast event context: hop to the main loop. pending is + -- decremented unconditionally so a throwing callback can't suppress flush. local registered = pcall( handle.once, handle, @@ -410,8 +381,7 @@ local function missing_collector(title, timeout_ms) end end, done = function() - -- One aggregated INFO so a first launch shows progress and a "relaunch" - -- hint instead of silently downloading in the background. + -- One INFO so a first launch shows progress instead of silently downloading. if #queued > 0 then table.sort(queued) local message = string.format( @@ -429,20 +399,16 @@ local function missing_collector(title, timeout_ms) } end ----Find the Mason package that ships the given binary, via a lazily-built ----bin -> package-name index over the registry specs. Install-fallback reverse ----lookup for subsystems whose deps carry a ground-truth binary (conform / ----nvim-lint): tool name, binary, and package name may all differ ----(cmake_format -> cmake-format -> cmakelang). Module-level cache, shared ----across subsystems for the session. +---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 bin_to_package = nil local function package_for_binary(registry, binary) - -- Cache only a *populated* index: a scan against a not-yet-bootstrapped - -- registry yields nothing, and caching that empty table would disable the - -- install fallback for the rest of the session. + -- Cache only a populated index: a not-yet-bootstrapped registry scans empty, + -- and caching that would disable the install fallback all session. if bin_to_package == nil then local index = {} local populated = false @@ -468,9 +434,8 @@ local function package_for_binary(registry, binary) return bin_to_package[binary] end ----Collect the executable name(s) a Mason package provides, from its spec. ----Without a `bin` table, prefer `pkg.name` over the caller-supplied name: the ----subsystem name can differ from the package (adapter `python` -> `debugpy`). +---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[] @@ -487,10 +452,8 @@ function M.package_binaries(pkg, fallback) return bins end ----Mason's package install root, or nil when Mason isn't available. Settings ----API first — `$MASON` is only set as a side effect of `mason.setup()`. The ----path is resolved once (false = Mason absent; failed requires aren't cached ----by Lua, so re-probing would repeat the failed require on every $PATH miss). +---Mason's install root, or nil when Mason isn't available. Settings API first +---($MASON is only set by mason.setup()); resolved once, false = absent. ---@return string|nil local mason_root_dir = nil function M.mason_root() @@ -504,35 +467,27 @@ function M.mason_root() mason_root_dir = false end end - -- Existence stays re-checked: on a fresh setup the configured dir only - -- appears after the first install. + -- Existence stays re-checked: the dir only appears after the first install. if mason_root_dir and vim.uv.fs_stat(mason_root_dir) then return mason_root_dir end return nil end ----Shared discovery-first resolution loop for a subsystem's desired tools. ----For each entry in `spec.deps`: +---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_config` local ---- config tries first; else if the package's declared bins are on $PATH, ---- configure without installing; else install, then configure on completion. ---- 3. No Mason package but a local config exists -> configure now (the config ---- self-validates its binary and `error()`s if absent). ---- 4. Otherwise -> aggregated install warning ---- (or `mark_unknown` for a stale Mason mapping with no known binary). ---- ----Everything resolvable without an install is configured synchronously, in the ----same tick, so lazy-loaded subsystems see their tools registered before ----lazy.nvim replays the trigger; only installs defer. `configure` is optional ----and pcall'd — as is each dep — so one failure can't abort the rest. +--- 2. Mason package exists but not available yet -> a `validates_config` +--- 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. --- ----Contract for `validates_config` configs: at config time they may only run ----non-blocking checks (`executable()`/`exepath()`/fs stats) — the resolve pass ----runs on the phase-1 hot path, so any expensive validation that spawns a ----process must be deferred to launch/run time. +---Everything resolvable without an install configures synchronously — same +---tick, before lazy.nvim replays the trigger — and only installs defer; each +---dep and `configure` is pcall'd so one failure can't abort the rest. +---validates_config configs may only run non-blocking checks at config time +---(the pass is on the phase-1 hot path); process spawns defer to launch time. ---@param spec { --- title: string, --- deps: string[], @@ -570,8 +525,7 @@ function M.resolve(spec) return registry_value end - -- Strip the "chunkname:line: " prefix a bare `error(msg)` adds; `error(msg, 0)` - -- messages pass through untouched. + -- Strip the "chunkname:line: " prefix a bare error(msg) adds. local function strip_position(err) if type(err) ~= "string" then return nil @@ -579,8 +533,7 @@ function M.resolve(spec) return (err:gsub("^[^\n]-:%d+: ", "")) end - ---Configure one tool. Returns true, or false plus the cleaned error when the - ---config threw; callers decide whether that is final or an install trigger. + ---Configure one tool; false plus the cleaned error when the config threw. local function try_configure(name) if type(spec.configure) ~= "function" then return true @@ -592,8 +545,7 @@ function M.resolve(spec) return false, strip_position(err) end - -- Configure one tool, surfacing a config-time error as a missing entry - -- annotated with the error message. + -- Configure one tool, surfacing a config-time error in the aggregated warning. local function do_configure(name) local ok, reason = try_configure(name) if not ok then @@ -601,13 +553,11 @@ function M.resolve(spec) end end - -- Cleaned error from a `validates_config` config that phase 1 ran and that - -- failed, so phase 2 can surface it WITHOUT re-running the config (and its - -- side effects / blocking probes). `false` = failed with no message. + -- Phase-1 validates_config failures, so phase 2 surfaces them without + -- re-running the config; `false` = failed with no message. local validate_failed = {} - -- Install `pkg` (backing tool `name`), configure on completion; a failed - -- install is annotated with the phase-1 validation error. + -- Install `pkg`, configure on completion; failures keep the phase-1 reason. local function start_install(pkg, name) local reason = type(validate_failed[name]) == "string" and validate_failed[name] or nil collector.track(pkg, name, function() @@ -617,12 +567,9 @@ function M.resolve(spec) end, reason) end - ---Phase 1 — configure a dep resolvable WITHOUT the Mason registry: on $PATH / - ---in Mason's bin dir, a self-resolving local config, or a `validates_config` - ---resolver that succeeds. Returns false when the dep needs the registry to - ---decide install-vs-missing (phase 2). Never marks a tool missing and never - ---forces the lazy registry, so a fully-provisioned subsystem finishes here — - ---synchronously, which the same-tick trigger replay depends on. + ---Phase 1 — configure a dep resolvable without the Mason registry ($PATH, + ---self-resolving local config, or a succeeding validates_config resolver). + ---False = needs the registry (phase 2). Never marks missing, stays same-tick. ---@param name string ---@return boolean handled local function configure_available(name) @@ -631,16 +578,14 @@ function M.resolve(spec) do_configure(name) return true end - -- Binary-less self-resolving config (function command / pure-Lua). Gated on - -- binaryless_self_resolves: a binary-less LSP server (function `cmd`, e.g. - -- jsonls) still maps to a package by NAME and must reach phase 2 to install. + -- Gated on binaryless_self_resolves: a binary-less LSP server (e.g. jsonls) + -- still maps to a package by NAME and must reach phase 2 to install. if #bins == 0 and spec.binaryless_self_resolves and spec.has_local_config and spec.has_local_config(name) then do_configure(name) return true end - -- A validates_config config is its own discovery-first resolver (venv / - -- system interpreter): let it try; on failure remember the error and defer - -- to phase 2 (which won't re-run it just to report the failure). + -- A validates_config config is its own resolver: let it try; on failure + -- remember the error for phase 2 instead of re-running it there. if spec.validates_config and spec.has_local_config and spec.has_local_config(name) then local ok, reason = try_configure(name) if ok then @@ -651,15 +596,13 @@ function M.resolve(spec) return false end - ---Phase 2 — resolve a dep phase 1 could not, against the now-ready registry: - ---configure an already-installed package, install a missing one, or mark it - ---missing / unknown. + ---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) - -- Unknown names are judged HERE, after the registry refresh: unknown_of may - -- consult the Mason mapping, which is empty on a never-bootstrapped registry — - -- a valid server known only via its mapping would be misread as a typo. + -- Judged after the registry refresh: unknown_of may consult the Mason + -- mapping, which is empty on a never-bootstrapped registry. if spec.unknown_of and spec.unknown_of(name) then collector.mark_unknown(name) return @@ -673,15 +616,12 @@ function M.resolve(spec) if ok then pkg = resolved else - -- Mapping points at a package the registry doesn't have (stale mapping); - -- report it only if nothing below resolves the tool. + -- Stale mapping; reported only if nothing below resolves the tool. pkg_unknown = true end end - -- A validates_config config already ran (and failed) earlier in this same - -- resolve pass; nothing the installed/on-$PATH branches below observe has - -- changed since, so re-running it would repeat its blocking probes and fail + -- The config already ran and failed this pass; re-running would fail -- identically. Only an install can change the outcome. if validate_failed[name] ~= nil then if pkg ~= nil and not pkg:is_installed() then @@ -698,10 +638,8 @@ function M.resolve(spec) return end - -- The package's declared binaries (pkg.spec.bin) are already on $PATH: the - -- tool is system-provided, don't install a duplicate. This is the generic - -- probe for tools whose launch binary can't be read statically (function-cmd - -- LSP servers like jsonls). + -- The package's declared binaries are already on $PATH: system-provided, + -- don't install a duplicate (covers function-cmd servers like jsonls). if pkg ~= nil and M.find_executable(spec.binaries_of(name, pkg)) ~= nil then do_configure(name) return @@ -713,8 +651,7 @@ function M.resolve(spec) return end - -- No installable package: let a local config self-validate, else mark - -- missing (or unknown, for a bad mapping with no known binary). + -- 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 #bins == 0 then @@ -724,9 +661,8 @@ function M.resolve(spec) end end - -- Whether the registry's package specs are already on disk. When we can't - -- introspect (API drift), err toward refreshing: a redundant refresh is a - -- cheap no-op, skipping a needed one loses every auto-install. + -- Whether the registry specs are on disk. On API drift err toward refreshing: + -- a redundant refresh is a no-op, a skipped one loses every auto-install. local function registry_bootstrapped(registry) if not registry or registry.sources == nil then return false @@ -741,8 +677,7 @@ function M.resolve(spec) end local function run() - -- A non-table `deps` (user override gone wrong) would throw out of run() - -- and abort the caller's whole config; degrade to "nothing to resolve". + -- A non-table deps (user override gone wrong) degrades to "nothing to resolve". if type(spec.deps) ~= "table" then collector.done() return @@ -753,8 +688,7 @@ function M.resolve(spec) -- maxn, not ipairs: a nil hole must skip a slot, not end resolution. for i = 1, table.maxn(spec.deps) do local name = spec.deps[i] - -- Dedup (a duplicate would double-install) and isolate one dep's failure - -- from the rest. + -- Dedup (a duplicate would double-install); pcall isolates each dep. if name ~= nil and not visited[name] then visited[name] = true local ok, handled = pcall(configure_available, name) @@ -766,17 +700,14 @@ function M.resolve(spec) end end - -- Everything on $PATH / self-resolving is now configured (same tick). If - -- nothing is left, Mason stays unloaded. + -- Nothing left to install: Mason stays unloaded. if #unresolved == 0 then collector.done() return end - -- Phase 2: resolve the leftovers against the registry, refreshing first on a - -- never-bootstrapped one (its specs aren't on disk, so get_package, the - -- mapping, and installs would all fail). Only these leftovers wait for the - -- network — never the synchronous registration above. + -- Phase 2: resolve the leftovers against the registry, refreshing a + -- never-bootstrapped one first (nothing works without its specs on disk). local registry = get_registry() local function finish() for _, name in ipairs(unresolved) do @@ -788,10 +719,8 @@ function M.resolve(spec) collector.done() end if registry and type(registry.refresh) == "function" and not registry_bootstrapped(registry) then - -- refresh() only calls back on completion or failure — a *stalled* - -- transfer fires neither, and nothing downstream would ever run. Arm a - -- deadline that reports the leftovers instead of staying silent all - -- session; `settled` makes callback and deadline mutually exclusive. + -- A stalled refresh() never calls back: arm a deadline that reports the + -- leftovers; `settled` makes callback and deadline mutually exclusive. local settled = false local function on_refreshed() if settled then @@ -812,8 +741,7 @@ function M.resolve(spec) end collector.done() end, timeout_ms) - -- pcall so a synchronous throw can't abort the caller's config; the - -- callback arrives in a fast event context, hop to the main loop. + -- 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) @@ -833,18 +761,13 @@ function M.resolve(spec) end ---Discovery-first resolution for a subsystem whose own runtime registrations ----are the ground truth (conform formatters, nvim-lint linters). Each dep is ----the tool's name *in that subsystem* (same semantics as lsp_deps/dap_deps). ----`probe(name)` returns: ---- * nil -> unknown name (typo / not registered) -> fix config. ---- * { binary = "x" } -> the tool invokes executable "x" ($PATH-probeable). +---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. ---- * { broken = "reason" } -> the config EXISTS but errors at evaluation: surfaced ---- in the aggregated warning with the reason — never ---- typo guidance, never an install. ----Mason enters only as the install fallback, required lazily; the package is ----reverse-looked-up from the binary (see `package_for_binary`), so tool name, ----binary, and package name are free to differ. +--- * { 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 }|nil @@ -862,8 +785,7 @@ function M.resolve_runtime_tools(title, deps, probe, configure) broken = type(result.broken) == "string" and result.broken or nil, } else - -- A probe error is treated as unknown: the guidance either way is to - -- fix the config entry, not to install something. + -- A probe error is treated as unknown: either way, fix the config entry. cache[name] = { known = false } end end @@ -873,8 +795,7 @@ function M.resolve_runtime_tools(title, deps, probe, configure) M.resolve({ title = title, deps = deps, - -- Lazy thunk: only required once a known tool's binary is actually missing, - -- so a fully-provisioned (or Mason-less) setup never loads mason-registry. + -- Lazy thunk so a fully-provisioned setup never loads mason-registry. registry = function() local ok, resolved = pcall(require, "mason-registry") return ok and resolved or nil @@ -894,18 +815,15 @@ function M.resolve_runtime_tools(title, deps, probe, configure) return not info(name).known end, has_local_config = function(name) - -- A broken config counts as local (binary-less) so it reaches configure - -- below, where its reason is surfaced. + -- A broken config counts as local so configure below surfaces its reason. local i = info(name) return i.known and i.binary == nil end, - -- A binary-less runtime tool can't be reverse-looked-up to a package, so it - -- genuinely self-resolves (unlike a binary-less LSP server, which maps by name). + -- A binary-less runtime tool can't map to a package, so it self-resolves. binaryless_self_resolves = true, configure = function(name) -- A broken config must never configure: error with the probe's reason so - -- the resolver's pcall machinery marks the tool with it in the aggregated - -- warning (missing section, not typo guidance). + -- the resolver marks the tool with it. local reason = info(name).broken if reason then error(reason, 0) From 92e8fe4dead20cef1119e16d0ea77b6da46079d6 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 17 Jul 2026 00:35:49 +0800 Subject: [PATCH 14/35] fix(tooling): avoid empty $path entry when appending mason bin dir appending to an empty $path produced a leading separator; a zero-length entry means the current working directory on posix (environ(7)), which silently adds cwd to executable resolution. --- lua/modules/utils/tools.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index 822f9147..ff8e1024 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -157,7 +157,8 @@ function M.wrap_with_mason_path(fn) end local prev = vim.env.PATH or "" local sep = vim.fn.has("win32") == 1 and ";" or ":" - vim.env.PATH = prev .. sep .. root .. "/bin" + -- No separator on an empty prev: a zero-length $PATH entry means CWD on POSIX. + vim.env.PATH = prev ~= "" and (prev .. sep .. root .. "/bin") or (root .. "/bin") -- Keeps all return values while resetting $PATH before a throw propagates. local function restore(ok, ...) vim.env.PATH = prev From 4eb8ccb568a94e9fdc601cf2a6fe0bcc10250e39 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 17 Jul 2026 00:35:49 +0800 Subject: [PATCH 15/35] fix(dap): spawn debugpy fallback by the probed interpreter's exepath pin the absolute path resolved at probe time so the adapter spawns the exact interpreter that passed the import probe even if $path changes later. deliberately exepath, not realpath: a venv python is a symlink and pyvenv.cfg discovery precedes symlink resolution, so realpath would strip the venv the probe just validated. --- lua/modules/configs/tool/dap/clients/python.lua | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lua/modules/configs/tool/dap/clients/python.lua b/lua/modules/configs/tool/dap/clients/python.lua index 43ecddac..e472fd07 100644 --- a/lua/modules/configs/tool/dap/clients/python.lua +++ b/lua/modules/configs/tool/dap/clients/python.lua @@ -59,13 +59,15 @@ return function() -- 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 real = vim.fn.exepath(py) - real = (real ~= "" and vim.uv.fs_realpath(real)) or real + local abs = vim.fn.exepath(py) + local real = (abs ~= "" and vim.uv.fs_realpath(abs)) or abs if not probed[real] then probed[real] = true vim.fn.system({ py, "-c", "import debugpy" }) if vim.v.shell_error == 0 then - return found(py, { "-m", "debugpy.adapter" }) + -- Spawn by the probed exepath, not its realpath: a venv python is a + -- symlink and pyvenv.cfg discovery precedes symlink resolution. + return found(abs ~= "" and abs or py, { "-m", "debugpy.adapter" }) end end end From bb37048876646b3789e9a5f4f75714fa680438a9 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 17 Jul 2026 00:43:44 +0800 Subject: [PATCH 16/35] fix(dap): harden debugpy probe dedup and align probe with spawn key the dedup by candidate name when exepath comes back empty so one empty result can't skip the remaining candidates, and run the import probe by the same resolved path the adapter will spawn. --- lua/modules/configs/tool/dap/clients/python.lua | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lua/modules/configs/tool/dap/clients/python.lua b/lua/modules/configs/tool/dap/clients/python.lua index e472fd07..3675294b 100644 --- a/lua/modules/configs/tool/dap/clients/python.lua +++ b/lua/modules/configs/tool/dap/clients/python.lua @@ -61,13 +61,17 @@ return function() if vim.fn.executable(py) == 1 then local abs = vim.fn.exepath(py) local real = (abs ~= "" and vim.uv.fs_realpath(abs)) or abs - if not probed[real] then - probed[real] = true - vim.fn.system({ py, "-c", "import debugpy" }) + -- 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.fn.system({ probe_cmd, "-c", "import debugpy" }) if vim.v.shell_error == 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(abs ~= "" and abs or py, { "-m", "debugpy.adapter" }) + return found(probe_cmd, { "-m", "debugpy.adapter" }) end end end From f080f0b88ea03c407b60a48bf4c58b16df8a2dfa Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 17 Jul 2026 00:52:22 +0800 Subject: [PATCH 17/35] fix(lint): dedup the linters_for_ft union to mirror upstream nvim-lint's _resolve_linter_by_ft dedups segment unions via a set; keep the local mirror faithful (order-stable variant) so future call sites can't observe duplicates upstream would never produce. --- lua/modules/configs/completion/nvim-lint.lua | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lua/modules/configs/completion/nvim-lint.lua b/lua/modules/configs/completion/nvim-lint.lua index 0328c92c..ac738363 100644 --- a/lua/modules/configs/completion/nvim-lint.lua +++ b/lua/modules/configs/completion/nvim-lint.lua @@ -94,10 +94,13 @@ return function() if exact then return exact end - local union = {} + local union, seen = {}, {} for _, part in ipairs(vim.split(ft, ".", { plain = true })) do for _, name in ipairs(by_ft[part] or {}) do - union[#union + 1] = name + if not seen[name] then + seen[name] = true + union[#union + 1] = name + end end end return union From 1ccdf09a2cddce4d1e7f9cab716a53a583c23fe6 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 17 Jul 2026 01:28:41 +0800 Subject: [PATCH 18/35] fix(dap): count a broken client config as existing in has_client_config load_module_or_report keeps exists true for a config that is present but fails to load, precisely so callers keep it out of the unknown-name bucket; has_client_config dropped that flag, letting unknown_of misread a broken unmapped adapter config as a typo. matches nvim-lint's broken-counts-as-local convention. --- lua/modules/configs/tool/dap/init.lua | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lua/modules/configs/tool/dap/init.lua b/lua/modules/configs/tool/dap/init.lua index cd85358e..ab63585a 100644 --- a/lua/modules/configs/tool/dap/init.lua +++ b/lua/modules/configs/tool/dap/init.lua @@ -140,10 +140,18 @@ return function() end ---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) - return tools.first_module(client_modules(name), "nvim-dap") ~= nil + for _, module in ipairs(client_modules(name)) do + local ok, value, exists = tools.load_module_or_report(module, "nvim-dap") + if (ok and value ~= nil) or exists then + return true + end + end + return false end ---Configure an adapter via the shared handler; client configs self-validate. From f3f9e2e97a4edf3e8c460acccffef8dc02481785 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 17 Jul 2026 01:28:41 +0800 Subject: [PATCH 19/35] fix(lint): only classify a linter as broken when its re-require fails the probe assumed a nil lint.linters entry implies the module throws on require; check the retry's result and re-read the entry instead, so a loader that succeeds on retry can't be misreported as broken. --- lua/modules/configs/completion/nvim-lint.lua | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/lua/modules/configs/completion/nvim-lint.lua b/lua/modules/configs/completion/nvim-lint.lua index ac738363..5040f388 100644 --- a/lua/modules/configs/completion/nvim-lint.lua +++ b/lua/modules/configs/completion/nvim-lint.lua @@ -144,11 +144,18 @@ return function() -- The lint.linters 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 tools.module_path(module) then - local _, err = pcall(require, module) + if not tools.module_path(module) then + return nil -- unknown linter name (typo / not registered) + end + local ok, err = pcall(require, module) + if not ok then return { broken = tostring(err) } end - return nil -- unknown linter name (typo / not registered) + -- 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. From e3cd7b1a4d2e04ffb4c8f53920618231f8d47113 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 17 Jul 2026 01:44:40 +0800 Subject: [PATCH 20/35] fix(tooling): state the mason bin probe in exepath_or_error's message find_executable probes $path and mason's bin dir; saying "not found on $path" misleads under mason path = "skip". --- lua/modules/utils/tools.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index ff8e1024..a931cd44 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -195,7 +195,7 @@ function M.exepath_or_error(names, hint) end end local label = #shown > 0 and table.concat(shown, "/") or "" - error(string.format("%s not found on $PATH; %s", label, hint), 0) + error(string.format("%s not found on $PATH or in Mason's bin dir; %s", label, hint), 0) end ---Aggregate tools that could not be set up into one deferred warning, in two From 6602291114da0a2bbfa4ea0143008f4fb56d79d8 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 17 Jul 2026 01:44:40 +0800 Subject: [PATCH 21/35] fix(dap): state the mason bin probe in debugpy error messages both debugpy errors described the lookup as $path-only while find_executable also probes mason's bin dir. --- lua/modules/configs/tool/dap/clients/python.lua | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lua/modules/configs/tool/dap/clients/python.lua b/lua/modules/configs/tool/dap/clients/python.lua index 3675294b..712d0573 100644 --- a/lua/modules/configs/tool/dap/clients/python.lua +++ b/lua/modules/configs/tool/dap/clients/python.lua @@ -94,8 +94,9 @@ return function() local command, args = debugpy_command() if not command then error( - "debugpy not found: no Mason venv, `debugpy-adapter`, or python with the debugpy\n" - .. "module on $PATH; install debugpy via Mason (`:Mason`) or your package manager", + "debugpy not found: no Mason venv, no `debugpy-adapter` on $PATH or in Mason's bin\n" + .. "dir, and no python able to import debugpy; install debugpy via Mason (`:Mason`)\n" + .. "or your package manager", 0 ) end @@ -158,9 +159,10 @@ return function() -- waits for launch. Cached on success; uncached on failure to catch a later install. if not fast_command() then error( - "debugpy not found: no Mason venv and no `debugpy-adapter` on $PATH; install debugpy via\n" - .. "Mason (`:Mason`) or your package manager — a python able to `import debugpy` is still\n" - .. "probed at launch, so local launch may work regardless (remote attach always works)", + "debugpy not found: no Mason venv and no `debugpy-adapter` on $PATH or in Mason's bin dir;\n" + .. "install debugpy via Mason (`:Mason`) or your package manager — a python able to\n" + .. "`import debugpy` is still probed at launch, so local launch may work regardless\n" + .. "(remote attach always works)", 0 ) end From 4921d8686e4868e7838bd0118cbee75850934c87 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 17 Jul 2026 12:12:02 +0800 Subject: [PATCH 22/35] perf(tooling): memoize module_path hits and use the loader index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit module_path globbed the whole runtimepath twice per miss (foo.lua then foo/init.lua) and re-ran the lookup every time resolve() revisited the same candidate module across its phases. query vim.loader's per-directory index instead of nvim_get_runtime_file, and cache hits only — misses stay uncached because rtp grows as lazy.nvim loads plugins, so a remembered absence would go stale mid-session. --- lua/modules/utils/tools.lua | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index a931cd44..9823a58c 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -42,21 +42,28 @@ function M.find_executable(names) return nil end +-- Hits only: resolve() re-queries the same candidate modules across phases, but +-- a miss must stay uncached — rtp grows as lazy.nvim loads plugins, so a +-- remembered "absent" 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) - local path = package.searchpath(module, package.path) - if path then - return path - end - local base = "lua/" .. module:gsub("%.", "/") - local hits = vim.api.nvim_get_runtime_file(base .. ".lua", false) - if #hits == 0 then - hits = vim.api.nvim_get_runtime_file(base .. "/init.lua", false) + if module_path_cache[module] then + return module_path_cache[module] end - return hits[1] + local path = package.searchpath(module, package.path) + if not path then + -- The loader's per-directory index covers `foo.lua` and `foo/init.lua` + -- in one lookup, without globbing the whole runtimepath. + local found = vim.loader.find(module)[1] + path = found and found.modpath or nil + end + module_path_cache[module] = path + return path end -- Modules already reported broken, so multi-site lookups notify once per module. From 8e8ac8aa7cac9ee8d7ee86563c352879c0abd0d6 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 17 Jul 2026 12:12:26 +0800 Subject: [PATCH 23/35] fix(tooling): retry mason root resolution instead of caching absence mason_root() stored false the first time mason.settings and $MASON were both unavailable and never probed again, so a mason.nvim that appeared later (e.g. a mid-session :Lazy install) stayed invisible to find_executable and every PATH="skip" rewrite for the rest of the session. cache only a resolved root and re-probe absence on each call; the retry costs one require miss, which lazy.nvim's module loader hook resolves as soon as mason is actually installed. --- lua/modules/utils/tools.lua | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index 9823a58c..b2222684 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -461,18 +461,18 @@ function M.package_binaries(pkg, fallback) end ---Mason's install root, or nil when Mason isn't available. Settings API first ----($MASON is only set by mason.setup()); resolved once, false = absent. +---($MASON is only set by mason.setup()). Only a resolved root is cached: +---absence is re-probed each call, so a mason.nvim that appears later (e.g. a +---mid-session `:Lazy install`) is still picked up by every discovery path. ---@return string|nil local mason_root_dir = nil function M.mason_root() - if mason_root_dir == nil then + if not mason_root_dir then local ok, settings = pcall(require, "mason.settings") if ok and settings.current and type(settings.current.install_root_dir) == "string" then mason_root_dir = settings.current.install_root_dir elseif type(vim.env.MASON) == "string" and vim.env.MASON ~= "" then mason_root_dir = vim.env.MASON - else - mason_root_dir = false end end -- Existence stays re-checked: the dir only appears after the first install. From 06d7511ed954391b3beaee0e0afa8f1c989b5529 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 17 Jul 2026 12:15:23 +0800 Subject: [PATCH 24/35] refactor(tooling): use core.global.is_windows for the path separator CLAUDE.md mandates platform detection via core.global rather than ad-hoc vim.fn.has() checks; wrap_with_mason_path was the one holdout. --- lua/modules/utils/tools.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index b2222684..55670deb 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -163,7 +163,7 @@ function M.wrap_with_mason_path(fn) return fn(...) end local prev = vim.env.PATH or "" - local sep = vim.fn.has("win32") == 1 and ";" or ":" + local sep = require("core.global").is_windows and ";" or ":" -- No separator on an empty prev: a zero-length $PATH entry means CWD on POSIX. vim.env.PATH = prev ~= "" and (prev .. sep .. root .. "/bin") or (root .. "/bin") -- Keeps all return values while resetting $PATH before a throw propagates. From f736ddf69a0f3d2e9927f72336718c5721f9e514 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 17 Jul 2026 12:42:35 +0800 Subject: [PATCH 25/35] fix(tooling): flush settled warnings promptly and share the collector per title flush() previously withheld a fully-settled batch until the deadline timer fired, delaying warnings for no reason; it now emits as soon as the batch settles, coalescing same-tick batches into one notification via a scheduled flush that computes new names at emit time. Collectors are also shared per title so nvim-lint's per-filetype batches dedup against each other instead of fragmenting into one warning per filetype. --- lua/modules/utils/tools.lua | 131 ++++++++++++++++++++++-------------- 1 file changed, 82 insertions(+), 49 deletions(-) diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index 55670deb..b9907f21 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -226,6 +226,8 @@ local function missing_collector(title, timeout_ms) local pending = 0 local deadline_started = false local deadline_passed = false + local flush_scheduled = false + local announce_scheduled = false -- Dedup across both buckets; non-string names are tostring()'d, nil/empty dropped. local function normalize(name) @@ -257,49 +259,58 @@ local function missing_collector(title, timeout_ms) return reason and (name .. " — " .. reason) or name end - -- Emit names not yet notified. No-op while installs are pending unless the - -- deadline passed; `emitted` lets late failures still notify. - local function flush() - if pending > 0 and not deadline_passed then + -- Emit names not yet notified, coalesced per event-loop tick so batches that + -- settle together (a multi-filetype session restore) produce one notification. + -- Gated while installs are pending unless forced — done() forces because + -- everything recorded by then is final — or the deadline passed; the + -- "what's new" set is computed at emit time so anything recorded before the + -- scheduled callback runs rides along; `emitted` lets late failures still + -- notify. + local function flush(force) + if not force and pending > 0 and not deadline_passed then return end - local missing_new, unknown_new = {}, {} - for _, name in ipairs(missing) do - if not emitted[name] then - emitted[name] = true - missing_new[#missing_new + 1] = name - end - end - for _, name in ipairs(unknown) do - if not emitted[name] then - emitted[name] = true - unknown_new[#unknown_new + 1] = name - end - end - if #missing_new == 0 and #unknown_new == 0 then + if flush_scheduled 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 • ") - 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 - local message = table.concat(sections, "\n\n") + flush_scheduled = true vim.schedule(function() - vim.notify(message, vim.log.levels.WARN, { title = title }) + flush_scheduled = false + local missing_new, unknown_new = {}, {} + for _, name in ipairs(missing) do + if not emitted[name] then + emitted[name] = true + missing_new[#missing_new + 1] = name + end + end + for _, name in ipairs(unknown) do + if not emitted[name] then + emitted[name] = true + unknown_new[#unknown_new + 1] = name + 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 • ") + 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 @@ -389,20 +400,31 @@ local function missing_collector(title, timeout_ms) end end, done = function() - -- One INFO so a first launch shows progress instead of silently downloading. - if #queued > 0 then - 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 • ") - ) + -- One INFO so a first launch shows progress instead of silently + -- downloading. Coalesced per tick and drained after announcing: the + -- collector is shared across batches, so a later done() must not + -- re-announce earlier installs. + 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 - flush() + -- Names recorded by done() (unknown/broken/uninstallable) are final: + -- force past the pending gate so in-flight installs can't withhold them. + flush(true) end, } end @@ -482,6 +504,11 @@ function M.mason_root() return nil end +-- One collector per title: a subsystem that resolves in several batches +-- (nvim-lint batches by filetype) dedups and aggregates warnings across +-- batches instead of fragmenting one notification per batch. +local collectors_by_title = {} + ---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. @@ -517,7 +544,13 @@ function M.resolve(spec) ) and settings.tool_install_timeout or 300000 - local collector = missing_collector(spec.title, timeout_ms) + -- `false` keys a (defensive) titleless spec without poisoning the table with nil. + local collector_key = spec.title or false + local collector = collectors_by_title[collector_key] + if not collector then + collector = missing_collector(spec.title, timeout_ms) + collectors_by_title[collector_key] = collector + end -- The registry may be a value, nil, or a lazy thunk. Resolved (memoized) only -- on first need, so a fully-provisioned subsystem never loads Mason at all. From 417f135f8f03770bb31e95be0fb67e3578764c9b Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 17 Jul 2026 12:42:41 +0800 Subject: [PATCH 26/35] fix(lint): rebuild linter modules on late configure Some linter modules (golangcilint) run the binary at module-load time to compute args; when the probe required the module while the binary was still absent, that stale nil-args module stayed cached all session, so an auto-installed linter would lint with broken arguments. On a late configure, drop the module from package.loaded and the lint.linters override slot so the __index loader rebuilds it fresh, then re-apply local customizations (selene/markdownlint-cli2) via the new overrides table. --- lua/modules/configs/completion/nvim-lint.lua | 88 +++++++++++++++----- 1 file changed, 65 insertions(+), 23 deletions(-) diff --git a/lua/modules/configs/completion/nvim-lint.lua b/lua/modules/configs/completion/nvim-lint.lua index 5040f388..dc75d573 100644 --- a/lua/modules/configs/completion/nvim-lint.lua +++ b/lua/modules/configs/completion/nvim-lint.lua @@ -1,30 +1,50 @@ 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: stdin broken under bun's node shim (for-await yields empty). - -- Override to file-based mode and update parser for "path:line:col severity message" format. - lint.linters["markdownlint-cli2"].stdin = false - 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: stdin broken under bun's node shim (for-await yields empty). + -- Override to file-based mode and update parser for "path:line:col severity message" format. + ["markdownlint-cli2"] = function(linter) + linter.stdin = false + linter.args = { + "--config", + vim.fn.stdpath("config") .. "/.markdownlint.yml", + } + linter.stream = "stderr" + linter.parser = require("lint.parser").from_pattern( + "[^:]+:(%d+):(%d+) (%a+) (.+)", + { "lnum", "col", "severity", "message" }, + { ["error"] = vim.diagnostic.severity.ERROR, ["warning"] = vim.diagnostic.severity.WARN }, + { source = "markdownlint" } + ) + end, } - lint.linters["markdownlint-cli2"].stream = "stderr" - lint.linters["markdownlint-cli2"].parser = require("lint.parser").from_pattern( - "[^:]+:(%d+):(%d+) (%a+) (.+)", - { "lnum", "col", "severity", "message" }, - { ["error"] = vim.diagnostic.severity.ERROR, ["warning"] = vim.diagnostic.severity.WARN }, - { source = "markdownlint" } - ) + 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 `run:` blocks (actionlint -- skips those); standalone sh/bash comes from the shuck LSP server. No usable @@ -132,6 +152,23 @@ return function() linter.cmd = tools.wrap_off_path(cmd) end end + ---Rebuild a module-backed linter for a late configure: the probe may have + ---required its module while the binary was still absent, and some + ---(golangcilint) compute `args` by RUNNING the binary at module-load time — + ---that stale result would otherwise persist all session, so an auto-installed + ---linter would lint with broken arguments. + ---@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 + package.loaded[module] = nil + -- Also drop any explicit assignment (a wrapped factory from + -- rewrite_off_path) shadowing the lint.linters __index loader. + rawset(lint.linters, name, nil) + apply_override(name) + end ---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. @@ -179,6 +216,11 @@ return function() -- A non-string cmd means the linter resolves its command at runtime. return { binary = type(cmd) == "string" and cmd or nil } end, function(name) + if batch_done then + -- A late configure follows an install / registry refresh: rebuild the + -- module so load-time work sees the now-present binary. + refresh_linter(name) + end rewrite_off_path(name) if batch_done then -- No lint event follows a late configure: re-lint every loaded buffer From c38a16f8daa56a53622dcd44c15b8607c9b94c16 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 17 Jul 2026 12:53:43 +0800 Subject: [PATCH 27/35] fix(conform): probe function-form commands with conform's real context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-built probe ctx only carried buf/filename/dirname, but conform's build_context also supplies range and shiftwidth — a function-form command reading those fields threw at probe time and was misreported as a broken config. Borrow runner.build_context (pcall-guarded, it is a private API) and keep a hand-built fallback that mirrors the shiftwidth derivation. --- lua/modules/configs/completion/conform.lua | 39 ++++++++++++++++------ 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/lua/modules/configs/completion/conform.lua b/lua/modules/configs/completion/conform.lua index d5a916df..1b8de1da 100644 --- a/lua/modules/configs/completion/conform.lua +++ b/lua/modules/configs/completion/conform.lua @@ -172,6 +172,31 @@ return function() -- own registry. Runs synchronously before lazy.nvim replays BufWritePre so the -- off-$PATH rewrite is in place for the first format-on-save under Mason PATH = "skip". local tools = require("modules.utils.tools") + ---Ctx for evaluating a function-form command: conform's own build_context + ---when available (it is @private), else a hand-built mirror of its fields. + ---@param config table + ---@return table + local function probe_ctx(config) + local buf = vim.api.nvim_get_current_buf() + local ok, runner = pcall(require, "conform.runner") + if ok and type(runner.build_context) == "function" then + local ok_ctx, ctx = pcall(runner.build_context, buf, config) + if ok_ctx and type(ctx) == "table" then + return ctx + end + end + local filename = vim.api.nvim_buf_get_name(buf) + local shiftwidth = vim.bo[buf].shiftwidth + if shiftwidth == 0 then + shiftwidth = vim.bo[buf].tabstop + end + return { + buf = buf, + filename = filename, + dirname = vim.fs.dirname(filename), + shiftwidth = shiftwidth, + } + end tools.resolve_runtime_tools("conform.nvim", settings.formatter_deps, function(name) -- get_formatter_config is conform's @private API; if dropped, degrade to -- "resolves itself" rather than misreporting every formatter as unknown. @@ -187,16 +212,10 @@ return function() end if config then if type(config.command) == "function" then - -- Function-form command (e.g. builtin `from_node_modules`): evaluate it with - -- a hand-built ctx for a representative answer; per-buffer differences are - -- covered by configure's wrapper below. - local buf = vim.api.nvim_get_current_buf() - local filename = vim.api.nvim_buf_get_name(buf) - local ok_cmd, cmd = pcall(config.command, config, { - buf = buf, - filename = filename, - dirname = vim.fs.dirname(filename), - }) + -- Function-form command (e.g. builtin `from_node_modules`): evaluate it + -- for a representative answer; per-buffer differences are covered by + -- configure's wrapper below. + local ok_cmd, cmd = pcall(config.command, config, probe_ctx(config)) if not ok_cmd then return { broken = tostring(cmd) } end From 43c29e0b93734cf96f56aab8b70c6fa5da6855e0 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 17 Jul 2026 12:53:50 +0800 Subject: [PATCH 28/35] refactor(tooling): share the off-path command value rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit conform and nvim-lint carried a near-copy of the same string/function dispatch for rewriting a command to an absolute path under Mason PATH = "skip". Collapse it into tools.off_path_value (string rewritten or unchanged, function wrapped, other types untouched) and adopt it in nvim-lint's rewrite_off_path; conform follows in the next commit. The factory-linter branch stays per-consumer — its container and write-back semantics differ. --- lua/modules/configs/completion/nvim-lint.lua | 8 +------- lua/modules/utils/tools.lua | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/lua/modules/configs/completion/nvim-lint.lua b/lua/modules/configs/completion/nvim-lint.lua index dc75d573..b13e5eeb 100644 --- a/lua/modules/configs/completion/nvim-lint.lua +++ b/lua/modules/configs/completion/nvim-lint.lua @@ -144,13 +144,7 @@ return function() if type(linter) ~= "table" then return end - local cmd = linter.cmd - if type(cmd) == "string" then - linter.cmd = tools.off_path_command(cmd) or cmd - elseif type(cmd) == "function" then - -- Function cmd (e.g. oxlint): rewrite its result at lint time. - linter.cmd = tools.wrap_off_path(cmd) - end + linter.cmd = tools.off_path_value(linter.cmd) end ---Rebuild a module-backed linter for a late configure: the probe may have ---required its module while the binary was still absent, and some diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index b9907f21..a6dd7a81 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -151,6 +151,23 @@ function M.wrap_off_path(fn, field) end end +---Rewrite a command value to spawn by absolute path when off $PATH (the +---Mason `PATH = "skip"` case): a string comes back rewritten (or unchanged +---when the bare name already works), a function-form resolver comes back +---wrapped to rewrite its result at call time, anything else passes through +---untouched. +---@param cmd any +---@return any +function M.off_path_value(cmd) + if type(cmd) == "string" then + return M.off_path_command(cmd) or cmd + end + if type(cmd) == "function" then + return M.wrap_off_path(cmd) + end + return cmd +end + ---Wrap a function that spawns internally (e.g. a function LSP `cmd`) so it ---runs with Mason's bin dir APPENDED to $PATH — appended so $PATH still wins. ---$PATH is restored whether the fn returns or throws. From 109117668c39f5df711ce6527c8d9acbc538edb0 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 17 Jul 2026 12:54:12 +0800 Subject: [PATCH 29/35] refactor(conform): reuse the probe's formatter config in configure The configure callback re-called get_formatter_config and repeated the command-shape branching the probe had just done. Record the probe's config in probed_config and dispatch through tools.off_path_value, writing an override only when the command actually changed so an on-$PATH string keeps producing no override. --- lua/modules/configs/completion/conform.lua | 32 ++++++++-------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/lua/modules/configs/completion/conform.lua b/lua/modules/configs/completion/conform.lua index 1b8de1da..4150d863 100644 --- a/lua/modules/configs/completion/conform.lua +++ b/lua/modules/configs/completion/conform.lua @@ -172,6 +172,11 @@ return function() -- own registry. Runs synchronously before lazy.nvim replays BufWritePre so the -- off-$PATH rewrite is in place for the first format-on-save under Mason PATH = "skip". local tools = require("modules.utils.tools") + -- Probe-derived formatter configs, reused by the configure callback below so + -- it doesn't re-fetch and re-branch on work the probe just did. Safe across a + -- late configure: conform configs hold no load-time binary state — command is + -- a static string or a format-time function, unchanged by an install. + local probed_config = {} ---Ctx for evaluating a function-form command: conform's own build_context ---when available (it is @private), else a hand-built mirror of its fields. ---@param config table @@ -211,6 +216,7 @@ return function() return { broken = tostring(config) } end if config then + probed_config[name] = config if type(config.command) == "function" then -- Function-form command (e.g. builtin `from_node_modules`): evaluate it -- for a representative answer; per-buffer differences are covered by @@ -239,30 +245,16 @@ return function() conform.formatters[name] = tools.wrap_off_path(prev, "command") return end - if type(conform.get_formatter_config) ~= "function" then - return - end - local config = conform.get_formatter_config(name) + local config = probed_config[name] if not config then return end - if type(config.command) == "function" then - -- Function-form command resolves per buffer: wrap it to rewrite an off-$PATH - -- result at format time; (self, ctx) pass through. - conform.formatters[name] = vim.tbl_deep_extend( - "force", - type(prev) == "table" and prev or {}, - { command = tools.wrap_off_path(config.command) } - ) - return - end - if type(config.command) ~= "string" then - return - end - local abs = tools.off_path_command(config.command) - if abs then + -- An unchanged command means the bare name is on $PATH: skip the no-op + -- override. A function-form command always comes back wrapped. + local command = tools.off_path_value(config.command) + if command ~= config.command then conform.formatters[name] = - vim.tbl_deep_extend("force", type(prev) == "table" and prev or {}, { command = abs }) + vim.tbl_deep_extend("force", type(prev) == "table" and prev or {}, { command = command }) end end) From cb804def9c72c29c5a4156a844845e0f1c8af0e2 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 17 Jul 2026 13:03:19 +0800 Subject: [PATCH 30/35] refactor(tooling): merge the local-config booleans into one mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit binaryless_self_resolves and validates_config each had a single caller passing true and encoded the same concept — who owns resolution when no binary is found but a local config exists — while allowing a meaningless both-true combination nobody rejected. Replace them with one local_config_mode field (nil defers to phase 2, "resolves" trusts a binary-less config outright, "validates" lets the config try and falls back to an install); both branch predicates are preserved bit-for-bit. --- lua/modules/configs/tool/dap/init.lua | 2 +- lua/modules/utils/tools.lua | 34 +++++++++++++++++---------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/lua/modules/configs/tool/dap/init.lua b/lua/modules/configs/tool/dap/init.lua index ab63585a..ddc654f9 100644 --- a/lua/modules/configs/tool/dap/init.lua +++ b/lua/modules/configs/tool/dap/init.lua @@ -201,7 +201,7 @@ return function() has_local_config = has_client_config, -- Client configs self-validate, so try an existing config before the Mason -- install fallback — python resolves debugpy from a venv $PATH can't see. - validates_config = true, + local_config_mode = "validates", configure = configure_adapter, }) end diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index a6dd7a81..810683d8 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -529,7 +529,7 @@ local collectors_by_title = {} ---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_config` +--- 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. @@ -538,8 +538,12 @@ local collectors_by_title = {} ---Everything resolvable without an install configures synchronously — same ---tick, before lazy.nvim replays the trigger — and only installs defer; each ---dep and `configure` is pcall'd so one failure can't abort the rest. ----validates_config configs may only run non-blocking checks at config time ----(the pass is on the phase-1 hot path); process spawns defer to launch time. +---`local_config_mode` says who owns resolution when no binary is found but a +---local config exists (`has_local_config`): nil defers to phase 2, "resolves" +---trusts a binary-less config outright, "validates" lets the config try and +---falls back to an install on failure. Unknown values behave like nil. +---"validates" configs may only run non-blocking checks at config time (the +---pass is on the phase-1 hot path); process spawns defer to launch time. ---@param spec { --- title: string, --- deps: string[], @@ -548,8 +552,7 @@ local collectors_by_title = {} --- binaries_of: fun(name: string, pkg: table|nil): string[], --- unknown_of?: fun(name: string): boolean, --- has_local_config?: fun(name: string): boolean, ---- validates_config?: boolean, ---- binaryless_self_resolves?: boolean, +--- local_config_mode?: "resolves"|"validates", --- configure?: fun(name: string), ---} function M.resolve(spec) @@ -611,7 +614,7 @@ function M.resolve(spec) end end - -- Phase-1 validates_config failures, so phase 2 surfaces them without + -- Phase-1 "validates" failures, so phase 2 surfaces them without -- re-running the config; `false` = failed with no message. local validate_failed = {} @@ -626,7 +629,7 @@ function M.resolve(spec) end ---Phase 1 — configure a dep resolvable without the Mason registry ($PATH, - ---self-resolving local config, or a succeeding validates_config resolver). + ---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 @@ -636,15 +639,20 @@ function M.resolve(spec) do_configure(name) return true end - -- Gated on binaryless_self_resolves: a binary-less LSP server (e.g. jsonls) - -- still maps to a package by NAME and must reach phase 2 to install. - if #bins == 0 and spec.binaryless_self_resolves and spec.has_local_config and spec.has_local_config(name) then + -- Gated on "resolves": a binary-less LSP server (e.g. jsonls) still maps + -- to a package by NAME and must reach phase 2 to install. + if + #bins == 0 + and 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 config is its own resolver: let it try; on failure + -- A "validates" config is its own resolver: let it try; on failure -- remember the error for phase 2 instead of re-running it there. - if spec.validates_config and spec.has_local_config and spec.has_local_config(name) then + if spec.local_config_mode == "validates" and spec.has_local_config and spec.has_local_config(name) then local ok, reason = try_configure(name) if ok then return true @@ -878,7 +886,7 @@ function M.resolve_runtime_tools(title, deps, probe, configure) return i.known and i.binary == nil end, -- A binary-less runtime tool can't map to a package, so it self-resolves. - binaryless_self_resolves = true, + local_config_mode = "resolves", configure = function(name) -- A broken config must never configure: error with the probe's reason so -- the resolver marks the tool with it. From 8fb5f4a69776526e0d76d50a426aadff7b729316 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 17 Jul 2026 13:11:37 +0800 Subject: [PATCH 31/35] fix(lsp): drop the fragile traefik v2 ignore from yamlls schemas schemastore's ignore asserts at require time when the catalog drops or renames the named entry, which would take the whole yamlls spec down to a factory config and lose every schema. Accept the v2/v3 fileMatch overlap instead of carrying that risk. --- lua/modules/configs/completion/servers/yamlls.lua | 3 --- 1 file changed, 3 deletions(-) diff --git a/lua/modules/configs/completion/servers/yamlls.lua b/lua/modules/configs/completion/servers/yamlls.lua index 0942cb64..885d42e3 100644 --- a/lua/modules/configs/completion/servers/yamlls.lua +++ b/lua/modules/configs/completion/servers/yamlls.lua @@ -39,9 +39,6 @@ return { url = "https://www.schemastore.org/traefik-v3.json", }, }, - -- The built-in Traefik v2 entry's fileMatch overlaps the v3 extra above, - -- and schemastore's `replace` can't rename v2 -> v3. - ignore = { "Traefik v2" }, }), -- trace = { server = "debug" }, }, From 380147bbb5266a6e931315a8fa24c5c9d4166565 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 17 Jul 2026 13:11:37 +0800 Subject: [PATCH 32/35] refactor(lsp): remove the vestigial externally_managed mapping Its only entry pointed rust_analyzer at rustaceanvim, which is no longer part of this config: the plugin is not installed, rust_analyzer is not in lsp_deps, and no spec or user override exists. Drop the mapping, the handler early-return, and the stray-spec warning loop; if a self-managed LSP plugin returns, the declaration comes back alongside it. --- .../configs/completion/mason-lspconfig.lua | 39 ------------------- 1 file changed, 39 deletions(-) diff --git a/lua/modules/configs/completion/mason-lspconfig.lua b/lua/modules/configs/completion/mason-lspconfig.lua index c4f8c3b2..160e04e0 100644 --- a/lua/modules/configs/completion/mason-lspconfig.lua +++ b/lua/modules/configs/completion/mason-lspconfig.lua @@ -16,14 +16,6 @@ M.setup = function() return { "user.configs.lsp-servers." .. name, "completion.servers." .. name } end - local module_path = tools.module_path - - -- Server name -> plugin that owns it; these are configured by that plugin, never here. - ---@type table - local externally_managed = { - rust_analyzer = "mrcjkb/rustaceanvim", - } - vim.diagnostic.config({ signs = true, underline = true, @@ -104,10 +96,6 @@ M.setup = function() ---@param lsp_name string ---@return boolean registered @Whether a config was registered (and can be enabled). local function mason_lsp_handler(lsp_name) - if externally_managed[lsp_name] then - return false - end - local info = server_info(lsp_name) local ok, custom_handler = info.user_loaded, info.user_spec local default_handler = info.default_spec @@ -150,33 +138,6 @@ M.setup = function() return true end - -- Warn about a stray manual spec for an externally-managed server, whether or not - -- it is in `lsp_deps` (the resolver only visits deps, so the handler can't catch it). - for server, plugin in pairs(externally_managed) do - for _, module in ipairs(server_modules(server)) do - -- Presence check WITHOUT executing the module, so a spec that errors at load - -- is still reported. - local path = module_path(module) - if path then - vim.notify( - string.format( - "`%s` is configured independently via `%s`. To get rid of this warning,\n" - .. "please REMOVE the conflicting spec at `%s`\n" - .. "and configure `%s` using the appropriate init options provided by `%s` instead.", - server, - plugin, - path, - server, - plugin - ), - vim.log.levels.WARN, - { title = "nvim-lspconfig" } - ) - break - end - end - end - if mason_ok then -- lspconfig integration only; installs are driven by the shared resolver, -- not gated on Mason's installed set. From 258c7f5ae3df4bb8a5fe440e757ebe51a5492111 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 17 Jul 2026 13:32:03 +0800 Subject: [PATCH 33/35] fix(lsp): unmap the traefik v2 schema by url instead of losing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-establish the v2 exclusion dropped with the fragile ignore option: yaml.schemas() returns a plain { url = fileMatch } map, so removing the v2 url key disables it with zero assert risk — a catalog that drops v2 makes the removal a no-op, while ignore/replace both assert on a missing name. traefik.yml stays claimed by the v3 extra alone (the catalog's own Traefik v3 entry carries no fileMatch). --- .../configs/completion/servers/yamlls.lua | 52 +++++++++++-------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/lua/modules/configs/completion/servers/yamlls.lua b/lua/modules/configs/completion/servers/yamlls.lua index 885d42e3..d45132fd 100644 --- a/lua/modules/configs/completion/servers/yamlls.lua +++ b/lua/modules/configs/completion/servers/yamlls.lua @@ -1,4 +1,33 @@ -- https://github.com/neovim/nvim-lspconfig/blob/master/lsp/yamlls.lua +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", + url = "https://dlvdhr.github.io/gh-dash/configuration/gh-dash/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.yml", "traefik.yaml" }, + url = "https://www.schemastore.org/traefik-v3.json", + }, + }, +}) +-- Only Traefik v3 is in use here; unmap the catalog's v2 entry so it stops +-- claiming traefik.yml. Plain key removal: a catalog that drops v2 makes this +-- a no-op, unlike schemastore's `ignore`, which asserts on a missing name. +schemas["https://www.schemastore.org/traefik-v2.json"] = nil + return { single_file_support = true, debounce_text_changes = 150, @@ -18,28 +47,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", - }, - }, - }), + schemas = schemas, -- trace = { server = "debug" }, }, }, From e4b4ba9faeac77cc511cb231459a5d7eb3c36270 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 17 Jul 2026 13:36:25 +0800 Subject: [PATCH 34/35] style(lsp): collapse the traefik v3 filematch into a brace glob Match the azure-pipelines extra's .{yml,yaml} form. --- lua/modules/configs/completion/servers/yamlls.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/modules/configs/completion/servers/yamlls.lua b/lua/modules/configs/completion/servers/yamlls.lua index d45132fd..5fe63a51 100644 --- a/lua/modules/configs/completion/servers/yamlls.lua +++ b/lua/modules/configs/completion/servers/yamlls.lua @@ -18,7 +18,7 @@ local schemas = require("schemastore").yaml.schemas({ { name = "Traefik v3", description = "Traefik v3 static configuration", - fileMatch = { "traefik.yml", "traefik.yaml" }, + fileMatch = { "traefik.{yml,yaml}" }, url = "https://www.schemastore.org/traefik-v3.json", }, }, From 84e524b98a594b6122492f806e893a61a9a82f56 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Fri, 17 Jul 2026 13:36:43 +0800 Subject: [PATCH 35/35] style(lsp): widen the gh-dash filematch to a brace glob --- lua/modules/configs/completion/servers/yamlls.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/modules/configs/completion/servers/yamlls.lua b/lua/modules/configs/completion/servers/yamlls.lua index 5fe63a51..98aa72e2 100644 --- a/lua/modules/configs/completion/servers/yamlls.lua +++ b/lua/modules/configs/completion/servers/yamlls.lua @@ -10,7 +10,7 @@ local schemas = require("schemastore").yaml.schemas({ { name = "gh-dash config", description = "gh-dash config YAML schema", - fileMatch = "*/gh-dash/config.yml", + fileMatch = "*/gh-dash/config.{yml,yaml}", url = "https://dlvdhr.github.io/gh-dash/configuration/gh-dash/schema.json", }, -- The catalog's own "Traefik v3" entry carries no fileMatch, so this