From 03923957029e38ed9d7438c41f7fc8f8cd496211 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 10:43:05 +0800 Subject: [PATCH 01/71] feat(tooling): add a shared discovery-first tool resolver every external tool now resolves $PATH -> mason install -> one aggregated missing-tool warning, with mason as an optional backend. modules.utils.tools provides the shared loop (resolve / resolve_runtime_tools), a per-title collector with per-install timeout windows and a recoverable registry-refresh deadline, module loaders that distinguish missing from broken, and mason root / $PATH helpers that never force mason to load. vim.yml teaches selene the luajit-provided package.searchpath. --- lua/modules/utils/tools.lua | 960 ++++++++++++++++++++++++++++++++++++ vim.yml | 10 + 2 files changed, 970 insertions(+) create mode 100644 lua/modules/utils/tools.lua diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua new file mode 100644 index 00000000..93bbe00c --- /dev/null +++ b/lua/modules/utils/tools.lua @@ -0,0 +1,960 @@ +-- Discovery-first tool resolution (RFC: ayamir/nvimdots#1293): $PATH → Mason +-- install → aggregated missing-tool warning, with Mason as an optional backend +-- and `M.resolve` as the shared loop. +-- +-- Only mason.setup() (lazy-loaded) puts Mason's bin dir on $PATH, so resolve() +-- APPENDS it once first (`M.ensure_mason_on_path`) — appended, not prepended, +-- so a system copy still wins. A bare-name spawn that bypasses `M.resolve` +-- must call `M.ensure_mason_on_path()` itself or use `M.find_executable`. +local M = {} + +---Valid executable names from a caller-supplied spec: string → singleton; +---table → non-string/empty entries dropped, nil holes skipped (maxn, so a +---hole can't truncate the list); anything else → no names. +---@param names any @Executable name(s) as callers pass them. +---@return string[] +local function normalize_names(names) + if type(names) == "string" then + return { names } + end + if type(names) ~= "table" then + return {} + end + local out = {} + for i = 1, table.maxn(names) do + local name = names[i] + if type(name) == "string" and name ~= "" then + out[#out + 1] = name + end + end + return out +end +-- Public alias for dep-list consumers. +M.normalize_names = normalize_names + +---Locate the first of the given executables: $PATH, then Mason's bin dir +---(reachable before mason.setup() prepends it). First-found by design: +---callers pass alternates for one tool, not a list of required binaries. +---@param names string|string[] @Executable name(s), probed in order. +---@return string|nil @Absolute path of the first name found, or nil. +function M.find_executable(names) + names = normalize_names(names) + for _, name in ipairs(names) do + local path = vim.fn.exepath(name) + if path ~= "" then + return path + end + end + local root = M.mason_root() + if root then + for _, name in ipairs(names) do + -- exepath() also resolves the Windows `.cmd` shim extension. + local path = vim.fn.exepath(root .. "/bin/" .. name) + if path ~= "" then + return path + end + end + end + return nil +end + +-- Hits only: rtp grows as lazy.nvim loads plugins, so a cached miss would go +-- stale mid-session. +local module_path_cache = {} + +---Locate a module file on the paths require() uses, without executing it: +---package.path plus the runtimepath loader (covers `user.*`). +---@param module string +---@return string|nil +function M.module_path(module) + if module_path_cache[module] then + return module_path_cache[module] + end + local path = package.searchpath(module, package.path) + if not path then + -- vim.loader.find covers foo.lua and foo/init.lua without an rtp glob. + local found = vim.loader.find(module)[1] + path = found and found.modpath or nil + end + module_path_cache[module] = path + return path +end + +-- First load error per module: a re-require after a failed load only says +-- "previous error loading module", so the original message must be kept. +local broken_module_reason = {} + +---Require a module, distinguishing "missing" from "exists but broken": a file +---present on the search paths that throws at load is notified (once) and +---keeps `exists` true, so callers don't misread it as an unknown name. +---@param module string +---@param title? string @Notification title for broken-module load errors. +---@return boolean ok @Whether the require succeeded. +---@return any value @The module's value when ok, else nil. +---@return boolean exists @Whether the module file exists on the search paths. +---@return string|nil reason @The load error when the module exists but is broken. +function M.load_module_or_report(module, title) + local ok, value = pcall(require, module) + if ok then + return true, value, true + end + if not M.module_path(module) then + return false, nil, false + end + local reason = broken_module_reason[module] + if not reason then + reason = tostring(value) + broken_module_reason[module] = reason + vim.notify( + string.format("Failed to load `%s`:\n%s", module, reason), + vim.log.levels.ERROR, + { title = title or "tools" } + ) + end + return false, nil, true, reason +end + +---Load the first candidate module that returns a non-nil value (highest +---precedence first; no merge-base semantics — spec-merging consumers like the +---LSP server_info don't use this). A broken/nil-returning candidate keeps +---`any_exists` true and its reason is returned even alongside a +---lower-precedence success, so callers can refuse to fall past a broken override. +---@param modules string[] @Module names, highest precedence first. +---@param title? string @Notification title for broken-module load errors. +---@param nil_reason? string @Format (%s = module name) for the loaded-but-returned-nil case. +---@return any value @First usable module value, or nil. +---@return string|nil broken_reason @Highest-precedence exists-but-unusable reason. +---@return boolean any_exists @Whether any candidate exists on the search paths. +function M.load_first_usable(modules, title, nil_reason) + local broken_reason, any_exists = nil, false + for _, module in ipairs(modules) do + local ok, value, exists, reason = M.load_module_or_report(module, title) + if ok and value ~= nil then + return value, broken_reason, true + end + if exists then + any_exists = true + if broken_reason == nil then + broken_reason = reason or string.format(nil_reason or "`%s` returned no value", module) + end + end + end + return nil, broken_reason, any_exists +end + +local mason_path_added = false + +---Append Mason's bin dir to $PATH once (see the file header) — appended so a +---system copy still wins. Idempotent; while the Mason root is still unknown +---the flag stays unset so a later call retries. +function M.ensure_mason_on_path() + if mason_path_added then + return + end + local root = M.mason_root() + if not root then + return + end + local bin = root .. "/bin" + local sep = require("core.global").is_windows and ";" or ":" + local path = vim.env.PATH or "" + -- Exact-entry membership check (mason.setup may have prepended it): wrap + -- both sides in the separator; plain find — $PATH may hold magic chars. + if not (sep .. path .. sep):find(sep .. bin .. sep, 1, true) then + vim.env.PATH = path ~= "" and (path .. sep .. bin) or bin + end + mason_path_added = true +end + +---Resolve the first of the given executable names, or `error()` with the +---install hint — at level 0 so the message carries no "file:line:" prefix. +---@param names string|string[] @Executable name(s), probed in order. +---@param hint string @Actionable install guidance appended to the error. +---@return string @Absolute path of the first name found. +function M.exepath_or_error(names, hint) + local path = M.find_executable(names) + if path then + return path + end + local shown = normalize_names(names) + local label = #shown > 0 and table.concat(shown, "/") or "" + error(string.format("%s not found on $PATH or in Mason's bin dir; %s", label, hint), 0) +end + +---Normalize a pcall-captured error: a raise_verbatim sentinel yields its +---reason untouched; a string loses the "chunkname:line: " prefix error() adds; +---anything else is dropped. +---@param err any +---@return string|nil +local function error_reason(err) + if type(err) == "table" and type(err.reason) == "string" then + return err.reason + end + if type(err) ~= "string" then + return nil + end + return (err:gsub("^[^\n]-:%d+: ", "")) +end + +---Raise a config failure whose message must reach the warning verbatim: it may +---itself start with a "path:line:" that position stripping would eat. +---@param reason string +function M.raise_verbatim(reason) + -- selene: allow(incorrect_standard_library_use) -- error() does accept any value + error({ reason = reason }) +end + +---Aggregate tools that could not be set up into one deferred warning, in two +---sections: `mark` (install it / config failed) and `mark_unknown` +---(unrecognized name — fix the config, don't install). +---@class ToolCollector +---@field mark fun(name: string, reason?: string) @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) +---@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 = {} + -- Tracked installs whose "closed" callback hasn't run yet + -- (name -> { reason = phase-1 fail_reason or false, expires_at = uv ms }); + -- the chained deadline settles each entry once its own window elapses. + local unsettled = {} + 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) + 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 names not yet notified, coalesced per event-loop tick. Gated while + -- installs are pending unless forced (done() forces: its records are final) + -- or the deadline passed; `emitted` lets late failures still notify. + local function flush(force) + if not force and pending > 0 and not deadline_passed then + return + end + if flush_scheduled then + return + end + flush_scheduled = true + vim.schedule(function() + 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 + + -- One timer, chained: each fire settles only the entries whose OWN window + -- (expires_at) elapsed, then re-arms for the earliest remaining expiry — a + -- late-tracked install keeps its full timeout. `deadline_started` stays + -- true across the whole chain. + local function arm_deadline(delay) + deadline_started = true + -- A live timer backs the coalescing gate and guarantees the deferred flush. + deadline_passed = false + vim.defer_fn(function() + vim.uv.update_time() + local now = vim.uv.now() + -- Settle expired installs here: their "closed" callback may never run, + -- and it is the only other place that records and decrements `pending`. + -- A late settle stays silent (`seen` dedups) but still configures. + -- Snapshot the keys first so `unsettled` isn't mutated mid-pairs. + local next_expiry = nil + for _, hung in ipairs(vim.tbl_keys(unsettled)) do + local entry = unsettled[hung] + if entry.expires_at <= now then + add( + hung, + type(entry.reason) == "string" and entry.reason + or "Mason install did not finish within the timeout (check :Mason for progress)" + ) + pending = pending - 1 + unsettled[hung] = nil + elseif next_expiry == nil or entry.expires_at < next_expiry then + next_expiry = entry.expires_at + end + end + if next_expiry ~= nil and next_expiry ~= math.huge then + -- Report the expired batch now (its marks are final, so forcing past + -- the pending gate is sound); chain to the earliest remaining expiry. + flush(true) + arm_deadline(math.max(next_expiry - now, 50)) + else + deadline_passed = true + flush() + -- Between chains no timer is live, so the gate must stay open or a + -- late-recorded name would never flush; the next track re-arms. + deadline_started = false + end + end, delay) + end + + return { + mark = add, + mark_unknown = add_unknown, + track = function(pkg, name, recheck, on_ready, fail_reason) + -- Attach to an in-flight OPEN install handle instead of starting a + -- duplicate (install() asserts; once("closed") never fires on a closed one). + local handle + if type(pkg) == "table" and type(pkg.get_install_handle) == "function" then + local ok, opt = pcall(function() + return pkg:get_install_handle() + end) + if ok and type(opt) == "table" and type(opt.if_present) == "function" then + opt:if_present(function(h) + local ok_closed, closed = pcall(function() + return h:is_closed() + end) + if ok_closed and not closed then + handle = h + end + end) + end + end + + -- Only a self-started install is announced in the "Installing N tool(s)" INFO. + local started_here = handle == nil + if not handle then + local ok, h = pcall(function() + return pkg:install() + end) + if not ok or type(h) ~= "table" or type(h.once) ~= "function" then + -- A synchronous install() throw is the only failure detail there is. + add(name, fail_reason or (not ok and error_reason(h) or nil)) + return + end + handle = h + elseif type(handle.once) ~= "function" then + -- Shared handle isn't usable (unexpected shape); mark rather than hang. + add(name, fail_reason) + return + end + + -- Only the first in-flight track for a name touches the accounting: a + -- repeat (re-resolve pass / shared-handle piggyback) must not increment + -- `pending` twice against one settle. + local first_track = name == nil or unsettled[name] == nil + if first_track then + pending = pending + 1 + if name ~= nil then + vim.uv.update_time() + unsettled[name] = { + reason = fail_reason or false, + -- math.huge when no deadline is configured: never expires. + expires_at = vim.uv.now() + + ((type(timeout_ms) == "number" and timeout_ms > 0) and timeout_ms or math.huge), + } + end + end + -- Arm when no timer is live; installs tracked during a live chain are + -- reached by its re-arms. + if not deadline_started and type(timeout_ms) == "number" and timeout_ms > 0 then + arm_deadline(timeout_ms) + end + -- "closed" fires in a fast event context: hop to the main loop. recheck/ + -- on_ready are pcall'd so a throw can't suppress flush; skip the decrement + -- when the deadline already settled this install, but still run on_ready. + local registered, reg_err = pcall( + handle.once, + handle, + "closed", + vim.schedule_wrap(function() + local counted = name == nil or unsettled[name] ~= nil + if name ~= nil then + unsettled[name] = nil + end + local rc_ok, available = pcall(recheck) + if rc_ok and available then + if type(on_ready) == "function" then + -- A configure throw after the install must still reach the warning. + local ready_ok, ready_err = pcall(on_ready) + if not ready_ok then + add(name, error_reason(ready_err) or fail_reason) + end + end + else + add(name, fail_reason) + end + if counted then + pending = pending - 1 + end + flush() + end) + ) + -- once() threw: undo only what THIS track added, or a failed piggyback + -- would decrement pending / clear another caller's in-flight entry. + if not registered then + if first_track then + pending = pending - 1 + if name ~= nil then + unsettled[name] = nil + end + end + add(name, fail_reason or error_reason(reg_err)) + elseif started_here and type(name) == "string" and name ~= "" then + -- Announce only self-started installs, only once registration stuck. + queued[#queued + 1] = name + end + end, + done = function() + -- One coalesced INFO per batch so a first launch shows progress; + -- drained after announcing so a later done() can't re-announce. + if #queued > 0 and not announce_scheduled then + announce_scheduled = true + vim.schedule(function() + announce_scheduled = false + if #queued == 0 then + return + end + table.sort(queued) + local message = string.format( + "Installing %d tool(s) via Mason in the background; each is configured\n" + .. "automatically once its install finishes (relaunch if one isn't picked up):\n • %s", + #queued, + table.concat(queued, "\n • ") + ) + queued = {} + vim.notify(message, vim.log.levels.INFO, { title = title }) + end) + end + -- done()'s records are final: force past the pending gate. + flush(true) + end, + } +end + +---Whether the registry's source specs are all on disk. On API drift err toward +---false: resolve() then refreshes redundantly (a no-op), and package_for_binary +---rebuilds instead of freezing — both err toward correctness. +---@param registry table|nil @The mason-registry module (or nil). +---@return boolean +local function registry_bootstrapped(registry) + if not registry or registry.sources == nil then + return false + end + local ok, all_installed = pcall(function() + return registry.sources:is_all_installed() + end) + if not ok then + return false + end + return all_installed == true +end + +---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) + -- Freeze the index only once the registry is FULLY bootstrapped: + -- get_all_package_specs() silently skips uninstalled sources, and a frozen + -- partial index would outlive resolve()'s re-refresh self-heal. (A source + -- appended at runtime after the freeze is out of scope.) + if bin_to_package == nil then + local index = {} + local populated = false + local ok, specs = pcall(registry.get_all_package_specs) + if ok and type(specs) == "table" then + for _, spec in ipairs(specs) do + if type(spec) == "table" and type(spec.name) == "string" and type(spec.bin) == "table" then + for bin_name in pairs(spec.bin) do + if index[bin_name] == nil then + index[bin_name] = spec.name + populated = true + end + end + end + end + end + if populated and registry_bootstrapped(registry) then + bin_to_package = index + else + -- Partial/uncertain scan: consult what was found, but don't cache it. + return index[binary] + end + end + return bin_to_package[binary] +end + +---Executable name(s) a Mason package provides, from its spec. Without a `bin` +---table prefer `pkg.name`: the subsystem name can differ (python -> debugpy). +---@param pkg table @A mason-registry Package object. +---@param fallback string @Last-resort name when even `pkg.name` is absent. +---@return string[] +function M.package_binaries(pkg, fallback) + local bins = (type(pkg.spec) == "table" and type(pkg.spec.bin) == "table") and vim.tbl_keys(pkg.spec.bin) or {} + -- tbl_keys order is unspecified; sort so multi-bin probes stay stable. + table.sort(bins) + if #bins == 0 then + bins = { type(pkg.name) == "string" and pkg.name or fallback } + end + return bins +end + +---Mason's install root WITHOUT loading Mason (this runs from every resolve()): +---a loaded mason.settings, then $MASON, then the default data-dir guess. The +---guess only counts when no `user.configs.mason` override exists (the one +---supported home for a custom root) and is never cached; a cached root is +---re-checked for existence each call (the dir appears after the first install). +---@return string|nil +local mason_root_dir = nil +function M.mason_root() + if not mason_root_dir then + local settings = package.loaded["mason.settings"] + if + type(settings) == "table" + and type(settings.current) == "table" + 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 + -- Never require() mason.settings here — it lazy-loads all of mason.nvim + -- during a pure discovery probe, defeating "Mason optional". + local guess = vim.fn.stdpath("data") .. "/mason" + if not M.module_path("user.configs.mason") and vim.uv.fs_stat(guess) then + return guess + end + end + end + if mason_root_dir and vim.uv.fs_stat(mason_root_dir) then + return mason_root_dir + end + return nil +end + +-- One collector per title so a subsystem that resolves in batches (nvim-lint, +-- per filetype) aggregates one warning instead of one per batch. +local collectors_by_title = {} + +---Shared discovery-first resolution loop. For each entry in `spec.deps`: +--- 0. Unrecognized name (`unknown_of`) -> fix the config; never install. +--- 1. Available (Mason-installed / on $PATH) -> configure now. +--- 2. Mason package exists but not available yet -> a "validates" local +--- config tries first; else configure if its declared bins are on $PATH; +--- else install, then configure on completion. +--- 3. No Mason package but a local config exists -> configure now. +--- 4. Otherwise -> aggregated warning. +--- +---Everything resolvable without an install configures on the load tick (before +---lazy.nvim replays the trigger); each dep is pcall-isolated. `configure` gets +---`late = true` after resolve() returned (deferred phase 2, install completion, +---async refresh) — no replayed trigger backs such a call, so the consumer must +---re-drive its own event if one is needed. +---`local_config_mode` (for a binary-less local config): nil defers to phase 2, +---"resolves" trusts it outright, "validates" lets it try and installs on +---failure — keep "validates" checks cheap (phase 1 runs on the load tick); a +---bounded probe spawn on the miss path is acceptable when it keeps config-time +---and launch-time resolution in agreement. +---@param spec { +--- title: string, +--- deps: string[], +--- registry: table|nil, +--- package_of: fun(name: string, registry: table|nil): string|nil, +--- binaries_of: fun(name: string, pkg: table|nil): string[], +--- unknown_of?: fun(name: string): boolean, +--- has_local_config?: fun(name: string): boolean, +--- local_config_mode?: "resolves"|"validates", +--- configure?: fun(name: string, late: boolean), +--- defer_phase2?: boolean, +---} +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 = collectors_by_title[spec.title] + if not collector then + collector = missing_collector(spec.title, timeout_ms) + collectors_by_title[spec.title] = collector + end + + -- False once run() returns: a configure running later (deferred phase 2, + -- install completion, async refresh) has no replayed trigger behind it and + -- gets `late = true`. + local synchronous = true + + ---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 + end + local ok, err = pcall(spec.configure, name, not synchronous) + if ok then + return true + end + return false, error_reason(err) + end + + -- 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 + collector.mark(name, reason) + end + end + + -- Phase-1 "validates" failures, surfaced by phase 2 without re-running the + -- config; `false` = failed with no message. + local validate_failed = {} + ---String reason or nil — the one decoder of the false-vs-string encoding. + ---@param name string + ---@return string|nil + local function validate_reason(name) + local reason = validate_failed[name] + return type(reason) == "string" and reason or nil + end + + -- Install `pkg`, configure on completion; failures keep the phase-1 reason. + local function start_install(pkg, name) + local reason = validate_reason(name) + collector.track(pkg, name, function() + return pkg:is_installed() or M.find_executable(spec.binaries_of(name, pkg)) ~= nil + end, function() + do_configure(name) + end, reason) + end + + ---Phase 1 — configure a dep resolvable without the Mason registry ($PATH, + ---self-resolving local config, or a succeeding "validates" resolver). + ---False = needs the registry (phase 2). Never marks missing, stays same-tick. + ---@param name string + ---@return boolean handled + local function configure_available(name) + if M.find_executable(spec.binaries_of(name, nil)) ~= nil then + do_configure(name) + return true + end + -- "resolves" only: a binary-less LSP server (jsonls) still maps to a + -- package by NAME and must reach phase 2 to install. + if spec.local_config_mode == "resolves" and spec.has_local_config and spec.has_local_config(name) then + do_configure(name) + return true + end + -- A "validates" config is its own resolver: let it try; remember a + -- failure for phase 2 instead of re-running it there. + if spec.local_config_mode == "validates" and spec.has_local_config and spec.has_local_config(name) then + local ok, reason = try_configure(name) + if ok then + return true + end + validate_failed[name] = reason or false + end + return false + end + + ---Phase 2 — resolve a dep against the now-ready registry: configure an + ---installed package, install a missing one, or mark it missing/unknown. + ---@param name string + ---@param registry table|nil + local function resolve_missing(name, registry) + -- Judged after the refresh: unknown_of may consult the Mason mapping. + if spec.unknown_of and spec.unknown_of(name) then + collector.mark_unknown(name) + return + end + + local pkg, pkg_unknown = nil, false + local pkg_name = spec.package_of(name, registry) + if pkg_name and registry then + local ok, resolved = pcall(registry.get_package, pkg_name) + if ok then + pkg = resolved + else + -- Stale mapping; reported only if nothing below resolves the tool. + pkg_unknown = true + end + end + + -- The config already failed this pass; only an install changes 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, validate_reason(name)) + end + return + end + + -- Installed via Mason but its binary name differs from the phase-1 probe. + if pkg ~= nil and pkg:is_installed() then + do_configure(name) + return + end + + -- The package's declared binaries are already on $PATH: system-provided, + -- don't install a duplicate (covers function-cmd servers like jsonls). + 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: local config self-validates, else mark missing/unknown. + if spec.has_local_config and spec.has_local_config(name) then + do_configure(name) + elseif pkg_unknown and #spec.binaries_of(name, nil) == 0 then + collector.mark_unknown(pkg_name == name and name or (pkg_name .. " (for " .. name .. ")")) + else + collector.mark(name) + end + end + + local function run() + -- Make Mason's bin dir resolvable before any probe or spawn (see file header). + M.ensure_mason_on_path() + -- A non-table deps (user override gone wrong) degrades 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); pcall isolates each dep. + 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, error_reason(handled)) + elseif handled ~= true then + unresolved[#unresolved + 1] = name + end + end + end + + -- Nothing left to install: Mason stays unloaded. + if #unresolved == 0 then + collector.done() + return + end + + -- Phase 2: resolve leftovers against the registry (value, nil, or lazy + -- thunk — only called here, so a fully-provisioned subsystem never loads + -- Mason), refreshing a never-bootstrapped one first. + local registry = spec.registry + if type(registry) == "function" then + local ok, resolved = pcall(registry) + registry = ok and resolved or nil + end + local function finish() + for _, name in ipairs(unresolved) do + local ok, err = pcall(resolve_missing, name, registry) + if not ok then + collector.mark(name, error_reason(err)) + end + end + collector.done() + end + if registry and type(registry.refresh) == "function" and not registry_bootstrapped(registry) then + -- A stalled refresh() never calls back: arm a REPORTING deadline. It + -- does not cancel a late refresh — finish() still runs on completion, + -- its re-marks absorbed by seen/emitted dedup. + local finished = false + local function on_refreshed() + if finished then + return + end + finished = true + finish() + end + vim.defer_fn(function() + if finished then + return + end + for _, name in ipairs(unresolved) do + local reason = validate_reason(name) + or "Mason registry refresh did not complete (cannot classify or auto-install)" + collector.mark(name, reason) + end + collector.done() + end, timeout_ms) + -- pcall guards a synchronous throw; the callback arrives in a fast event context. + local ok = pcall(registry.refresh, function() + if vim.in_fast_event() then + vim.schedule(on_refreshed) + else + on_refreshed() + end + end) + if not ok then + on_refreshed() + end + elseif spec.defer_phase2 then + -- Runtime-tool phase 2 only classifies/installs: move the full registry + -- spec decode off the lazy-load trigger's tick. + vim.schedule(finish) + else + -- LSP/DAP configure IN phase 2, and a cmd-triggered lazy-load replays + -- synchronously right after config: finish on this tick. + finish() + end + end + + run() + synchronous = false +end + +---Discovery-first resolution for a subsystem whose own runtime registrations +---are the ground truth (conform, nvim-lint). `probe(name)` returns: +--- * nil -> unknown name (typo) -> fix config. +--- * { binary = "x" } -> the tool invokes executable "x". +--- * { binary = nil } -> the tool resolves its own command at runtime. +--- * { 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 +---@param configure? fun(name: string, late: boolean) @Optional: run for each available/local tool +--- (e.g. rewrite its command to an absolute path while Mason's bin dir is +--- still off $PATH). +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, + broken = type(result.broken) == "string" and result.broken or nil, + } + else + -- A probe error is treated as unknown: either way, fix the config entry. + cache[name] = { known = false } + end + end + return cache[name] + end + + M.resolve({ + title = title, + deps = deps, + -- 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 + 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) + -- 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 map to a package, so it self-resolves. + local_config_mode = "resolves", + defer_phase2 = true, + configure = function(name, late) + -- A broken config must never configure: raise its reason verbatim (it + -- may carry the broken file's own "path:line:"). + local reason = info(name).broken + if reason then + M.raise_verbatim(reason) + end + if type(configure) == "function" then + return configure(name, late) + end + end, + }) +end + +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 8316907bb731d4ae27a7fb706d39d95fbd39f797 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 10:43:05 +0800 Subject: [PATCH 02/71] feat(settings): move dep lists to resolver-native names lsp/formatter/linter/dap deps are now the names their own subsystem knows (conform formatter names, nvim-lint linter names), resolved discovery-first at runtime instead of installed as mason package names at bootstrap. external_lsp_deps folds into lsp_deps (nil_ls, nixd, shuck), nix tooling joins the lists, and tool_install_timeout bounds background mason work before the aggregated warning. --- lua/core/settings.lua | 46 +++++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/lua/core/settings.lua b/lua/core/settings.lua index b4a8c812..ac8d2464 100644 --- a/lua/core/settings.lua +++ b/lua/core/settings.lua @@ -84,23 +84,15 @@ settings["external_browser"] = "chrome-cli open" ---@type boolean settings["lsp_inlayhints"] = false --- LSPs installed outside Mason (e.g. via system package manager). --- These will be configured but not installed by Mason. --- Key: lspconfig server name, Value: executable name to check availability. ----@type table -settings["external_lsp_deps"] = { - nixd = "nixd", - nil_ls = "nil", - shuck = "shuck", -- shell linter/formatter/LSP (Rust); installed via mise, not Mason - -- dartls = "dart", -} - --- LSPs to install during bootstrap. +-- Language servers to enable, resolved discovery-first at runtime: binary on $PATH is +-- used as-is; else Mason installs it when it ships a package; else an aggregated warning +-- asks you to provision it. 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 +103,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", @@ -120,39 +115,52 @@ settings["lsp_deps"] = { "zuban", } --- Formatters to install during bootstrap (Mason package names). --- These are managed by Mason and used by conform.nvim. +-- Formatters to resolve when conform.nvim lazy-loads (first BufWritePre / +-- :Format). conform formatter names, resolved discovery-first like lsp_deps. ---@type string[] settings["formatter_deps"] = { "beautysh", "clang-format", - "cmakelang", + "cmake_format", "fixjson", "gofumpt", "goimports", "mdsf", + "nixfmt", -- Nix formatter; prefer the $PATH binary (Nix) "prettier", "superhtml", "shellharden", + "statix", -- Nix linter, its `fix` mode doubles as a conform formatter; from Nix ($PATH) "stylua", } --- Linters to install during bootstrap (Mason package names). --- These are managed by Mason and used by nvim-lint. +-- Linters to resolve when nvim-lint lazy-loads (first BufReadPost). nvim-lint +-- linter names, resolved discovery-first like formatter_deps. ---@type string[] settings["linter_deps"] = { "actionlint", + "deadnix", -- Nix dead-code linter; prefer the $PATH binary (Nix) "hadolint", "markdownlint-cli2", "oxlint", -- "rumdl", -- markdownlint Rust rewrite; waiting for rule coverage to mature - "golangci-lint", + "golangcilint", "selene", "shellcheck", + "shuck", -- shell linter for yaml.github `run:` blocks; no Mason package, installed via mise + "statix", -- Nix linter; prefer the $PATH binary (Nix) "systemdlint", + "zsh", -- `zsh -n` syntax check via the system shell itself } --- Debug Adapter Protocol (DAP) clients to install and configure during bootstrap. +-- Deadline (ms) for background Mason work before the aggregated missing-tool warning +-- flushes anyway. Gates each tracked install (its own window) AND the registry refresh +-- wait; late completions still recover. Non-positive values use the default. +---@type number +settings["tool_install_timeout"] = 300000 + +-- DAP adapters to enable (mason-nvim-dap adapter names), resolved +-- discovery-first when nvim-dap lazy-loads (first :Dap* command). -- 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"] = { From 4e933b337e9b341b2ca99b1f844be8f7a5fa9bd9 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 10:43:05 +0800 Subject: [PATCH 03/71] feat(lsp): resolve language servers discovery-first mason-lspconfig setup now drives the shared resolver over lsp_deps: server specs are probed and cached once (user override, repo preset, lspconfig's lsp/*.lua via one pre-globbed mirror), binaries on $PATH configure synchronously, mason installs the rest, and broken configs raise instead of falling through. user.configs.lsp registrations are recorded and replayed after post-install configures so overrides win regardless of install timing; a valid user table override survives a broken repo preset. register_server is gone - registration and enabling live in the resolver's configure. --- lua/modules/configs/completion/lsp.lua | 31 +- .../configs/completion/mason-lspconfig.lua | 354 ++++++++++++++---- .../configs/completion/servers/shuck.lua | 14 +- lua/modules/utils/init.lua | 13 - 4 files changed, 295 insertions(+), 117 deletions(-) diff --git a/lua/modules/configs/completion/lsp.lua b/lua/modules/configs/completion/lsp.lua index 866260b3..d2f37107 100644 --- a/lua/modules/configs/completion/lsp.lua +++ b/lua/modules/configs/completion/lsp.lua @@ -1,30 +1,13 @@ return function() + -- 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() - 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 - - pcall(require, "user.configs.lsp") + -- Run `user.configs.lsp` with its vim.lsp.config registrations recorded: a + -- mid-session install registers after this point, and the replay keeps the + -- user's overrides on top regardless of timing. + -- `user.configs.lsp-servers.` remains the richer per-server hook. + require("completion.mason-lspconfig").run_user_lsp_overrides() -- Start LSPs pcall(vim.cmd.LspStart) diff --git a/lua/modules/configs/completion/mason-lspconfig.lua b/lua/modules/configs/completion/mason-lspconfig.lua index fa3d2a4e..1fa30607 100644 --- a/lua/modules/configs/completion/mason-lspconfig.lua +++ b/lua/modules/configs/completion/mason-lspconfig.lua @@ -1,15 +1,80 @@ local M = {} -M.setup = function() - local lsp_deps = require("core.settings").lsp_deps - local mason_registry = require("mason-registry") - local mason_lspconfig = require("mason-lspconfig") - - require("modules.utils").load_plugin("mason-lspconfig", { - ensure_installed = lsp_deps, - -- Skip auto enable because we are loading language servers lazily - automatic_enable = false, +-- vim.lsp.config registrations from `user.configs.lsp` (name -> ordered list +-- of { cfg } merges / { replace = true, cfg } assignments): a POST-INSTALL +-- late configure replays them, since its repo-spec registration would +-- otherwise force-merge over the user's keys (install-timing-dependent). +local user_lsp_configs = {} + +---Run `user.configs.lsp` with vim.lsp.config proxied to record per-server +---registrations. Reads forward to the real table; "*" is not recorded (core +---merges it at read time); the real table is always restored right after the +---pcall'd require (same silent-if-missing behavior as before). +function M.run_user_lsp_overrides() + local real = vim.lsp.config + vim.lsp.config = setmetatable({}, { + __index = function(_, key) + return real[key] + end, + __newindex = function(_, key, value) + real[key] = value + if type(key) == "string" and key ~= "*" then + -- Assignment replaces the whole config: supersede earlier recordings. + user_lsp_configs[key] = { { replace = true, cfg = value } } + end + end, + __call = function(_, name, cfg) + real(name, cfg) + if type(name) == "string" and name ~= "*" and type(cfg) == "table" then + local list = user_lsp_configs[name] or {} + list[#list + 1] = { cfg = cfg } + user_lsp_configs[name] = list + end + end, }) + pcall(require, "user.configs.lsp") + vim.lsp.config = real +end + +M.setup = function() + 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 + + -- name -> lsp/.lua paths in rtp order, built by ONE glob: reading + -- vim.lsp.config[name] for a not-yet-enabled server rescans the rtp per name. + local lsp_runtime_files = nil + ---@param name string + ---@return string[]|nil + local function lsp_files_of(name) + if lsp_runtime_files == nil then + lsp_runtime_files = {} + for _, path in ipairs(vim.api.nvim_get_runtime_file("lsp/*.lua", true)) do + -- Single segment only: a nested lsp//.lua is not a server. + local server = path:match("[/\\]lsp[/\\]([^/\\]+)%.lua$") + if server then + local files = lsp_runtime_files[server] + if not files then + files = {} + lsp_runtime_files[server] = files + end + files[#files + 1] = path + end + end + end + return lsp_runtime_files[name] + end vim.diagnostic.config({ signs = true, @@ -21,45 +86,148 @@ M.setup = function() local opts = { capabilities = require("modules.utils").get_lsp_capabilities(), } - ---A handler to setup all servers defined under `completion/servers/*.lua` - ---@param lsp_name string - local function mason_lsp_handler(lsp_name) - -- rust_analyzer is configured using mrcjkb/rustaceanvim - -- warn users if they have set it up manually - if lsp_name == "rust_analyzer" then - local config_exist = pcall(require, "completion.servers." .. lsp_name) - if config_exist then - vim.notify( - [[ -`rust_analyzer` is configured independently via `mrcjkb/rustaceanvim`. To get rid of this warning, -please REMOVE your LSP configuration (rust_analyzer.lua) from the `servers` directory and configure -`rust_analyzer` using the appropriate init options provided by `rustaceanvim` instead.]], - vim.log.levels.WARN, - { title = "nvim-lspconfig" } - ) + + ---Probe and cache a server's spec once. `binary` = first table-cmd entry in + ---precedence order (user, repo, lspconfig); nil for a function/absent cmd. + local server_info_cache = {} + ---@class mason_lspconfig.ServerInfo + ---@field has_module boolean + ---@field binary string|nil + ---@field known_lspconfig boolean + ---@field user_loaded boolean + ---@field user_spec any + ---@field default_loaded boolean + ---@field default_spec any + ---@field broken_reason string|nil + ---@param name string + ---@return mason_lspconfig.ServerInfo + local function server_info(name) + local cached = server_info_cache[name] + if cached then + return cached + end + local info = { + has_module = false, + binary = nil, + known_lspconfig = false, + user_loaded = false, + user_spec = nil, + default_loaded = false, + default_spec = nil, + broken_reason = nil, + } + local modules = server_modules(name) + -- A spec that exists but throws at load is a broken config, not a typo: + -- `exists` keeps it out of the unknown bucket, and the reason makes + -- mason_lsp_handler refuse to fall through past it. + local user_ok, user_spec, user_exists, user_reason = tools.load_module_or_report(modules[1], "nvim-lspconfig") + if user_ok then + info.has_module = true + 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 user_exists then + info.has_module = true + info.broken_reason = user_reason + or string.format("failed to load `%s` (see the earlier error notification)", modules[1]) + end + -- Load the repo preset only when usable (no override, or as merge base under a + -- table override): a function-form override replaces it wholesale. + if not user_ok or type(user_spec) == "table" then + local ok, spec, exists, reason = tools.load_module_or_report(modules[2], "nvim-lspconfig") + if ok then + info.has_module = true + 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 exists then + info.has_module = true + -- Under a valid user TABLE override a broken preset is merely the + -- optional merge base: degrade to {} (already notified once by + -- load_module_or_report) instead of disabling the server. + if not info.user_loaded and info.broken_reason == nil then + info.broken_reason = reason + or string.format("failed to load `%s` (see the earlier error notification)", modules[2]) + end end - return end + -- nvim-lspconfig's built-in config: a table cmd yields the launch binary; + -- any cmd (even a function) proves the name real (keeps jsonls out of the + -- unknown bucket). Read only when it can still change the outcome. + if info.binary == nil or not info.has_module then + -- Mirror vim.lsp.config's file resolution (later rtp files win) from + -- the pre-globbed map instead of its per-name rescan. `*` defaults and + -- pure-runtime registrations are not merged — this only feeds the + -- binary probe; registration still reads vim.lsp.config[name]. + local files = lsp_files_of(name) + if files then + local merged = {} + for _, path in ipairs(files) do + local ok, chunk = pcall(loadfile, path) + if ok and chunk then + local ok_run, config = pcall(chunk) + if ok_run and type(config) == "table" then + merged = vim.tbl_deep_extend("force", merged, config) + end + end + end + if merged.cmd ~= nil then + info.known_lspconfig = true + if info.binary == nil and type(merged.cmd) == "table" then + info.binary = merged.cmd[1] + end + end + elseif not info.has_module then + -- Last chance before the typo bucket: a pure-runtime registration is + -- looked up only for names nothing else proved real, so the per-name + -- rescan stays normally zero. + 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 + end + end + server_info_cache[name] = info + return info + end - local ok, custom_handler = pcall(require, "user.configs.lsp-servers." .. lsp_name) - local default_ok, default_handler = pcall(require, "completion.servers." .. lsp_name) + ---Register (not enable) a server's config, reusing the spec server_info() + ---loaded; raises on a broken/misshapen spec so the resolver aggregates the + ---reason. vim.lsp.enable() runs later in configure(). + ---@param lsp_name string + local function mason_lsp_handler(lsp_name) + local info = server_info(lsp_name) + -- A broken config must not fall through to a lower-precedence spec or + -- the factory config: that would read as success and suppress both the + -- warning and the install fallback. + if info.broken_reason then + tools.raise_verbatim(info.broken_reason) + end + 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`. + -- Server owns its setup; it must call vim.lsp.config() itself (see + -- clangd.lua for an example). 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", @@ -69,49 +237,97 @@ please REMOVE your LSP configuration (rust_analyzer.lua) from the `servers` dire ) ) else - vim.notify( - string.format( - "Failed to setup [%s].\n\nServer definition under `completion/servers` must return\neither a fun(opts) or a table (got '%s' instead)", - lsp_name, - type(custom_handler) - ), - vim.log.levels.ERROR, - { title = "nvim-lspconfig" } + tools.raise_verbatim( + string.format("server config must return a fun(opts) or a table (got `%s`)", type(custom_handler)) ) end end - ---A simplified mimic of 's `setup_handlers` callback. - ---Invoked for each Mason package (name or `Package` object) to configure its language server. - ---@param pkg string|{name: string} Either the package name (string) or a Package object - local function setup_lsp_for_package(pkg) - -- First try to grab the builtin mappings - local mappings = mason_lspconfig.get_mappings().package_to_lspconfig - -- If empty or nil, build it by hand - if not mappings or vim.tbl_isempty(mappings) then - mappings = {} - for _, spec in ipairs(mason_registry.get_all_package_specs()) do - local lspconfig = vim.tbl_get(spec, "neovim", "lspconfig") - if lspconfig then - mappings[spec.name] = lspconfig - end - end - end + 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 - -- 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 + -- lspconfig server name -> Mason package name. Re-fetched while empty: + -- get_mappings() returns {} on a never-bootstrapped registry. + 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 - -- Invoke the handler - mason_lsp_handler(srv) + ---Use a manual/built-in spec as fallback only when its binary can't be probed + ---statically (function/absent `cmd`); with a known binary the $PATH check decides. + ---@param name string + ---@return boolean + local function has_local_config(name) + local info = server_info(name) + return info.binary == nil and (info.has_module or info.known_lspconfig) end - for _, pkg in ipairs(mason_registry.get_installed_package_names()) do - setup_lsp_for_package(pkg) + ---Typo/outdated name vs valid-but-uninstalled: a Mason mapping, a repo/user server + ---module, or a built-in lspconfig config all mean the name is real. + ---@param name string + ---@return boolean + local function unknown_of(name) + if package_of(name) then + return false + end + local info = server_info(name) + return not info.has_module and not info.known_lspconfig end + + ---Register a server, then enable it (a bare Mason `cmd` spawns fine: the + ---resolver put Mason's bin dir on $PATH). + local function configure(name) + mason_lsp_handler(name) + -- Replay recorded `user.configs.lsp` registrations on top: a post-install + -- configure runs after that module did, and the repo spec would otherwise + -- force-merge over the user's keys. Synchronous configures precede the + -- user module, so the replay is a natural no-op there. + for _, entry in ipairs(user_lsp_configs[name] or {}) do + if entry.replace then + vim.lsp.config[name] = entry.cfg + else + vim.lsp.config(name, entry.cfg) + end + end + vim.lsp.enable(name) + end + + tools.resolve({ + title = "LSP", + deps = settings.lsp_deps, + -- A value, not a thunk: mason-registry was already required at setup top. + 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 (jsonls): probe the package's declared bins so a + -- system copy is found instead of installing a duplicate. + if pkg ~= nil then + return tools.package_binaries(pkg, name) + end + return {} + end, + unknown_of = unknown_of, + has_local_config = has_local_config, + configure = configure, + }) 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" }, 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 From 229ec3c685c56710d636a2864eff4c5bcc0bb57f Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 10:43:05 +0800 Subject: [PATCH 04/71] feat(conform): resolve formatters discovery-first formatter_deps resolve against conform's own registry via get_formatter_config (function-form commands self-resolve; broken configs surface their reason), with mason as the reverse-looked-up install fallback. the probe runs off the first save tick - only ensure_mason_on_path stays synchronous - and the autoformat gates are grouped into one predicate. --- lua/modules/configs/completion/conform.lua | 74 ++++++++++++++++++---- 1 file changed, 60 insertions(+), 14 deletions(-) diff --git a/lua/modules/configs/completion/conform.lua b/lua/modules/configs/completion/conform.lua index 2a52dfa2..5ce36aec 100644 --- a/lua/modules/configs/completion/conform.lua +++ b/lua/modules/configs/completion/conform.lua @@ -40,6 +40,18 @@ return function() return false end + ---The gates a buffer must pass before an automatic format, grouped into one + ---named predicate for the format_on_save callback's readability. + ---@param bufnr integer + ---@return boolean + local function autoformat_allowed(bufnr) + return format_on_save_enabled + and block_list[vim.bo[bufnr].filetype] ~= true + and not is_disabled_workspace(bufnr) + and not vim.g.disable_autoformat + and not vim.b[bufnr].disable_autoformat + end + ---Format only git-modified lines using gitsigns hunks + conform range format ---@param bufnr integer ---@return boolean @true if modifications were formatted @@ -87,6 +99,8 @@ return function() return true end + local tools = require("modules.utils.tools") + require("modules.utils").load_plugin("conform", { default_format_opts = { timeout_ms = format_timeout, @@ -132,8 +146,9 @@ return function() args = { "fix", "--stdin" }, stdin = true, }, - -- prettier: stdin mode does not work under bun's node shim, - -- so use --write (file-based) mode instead. + -- prettier: stdin is broken under bun's node shim, so use --write on + -- conform's temp copy (`stdin = false` points $FILENAME at a + -- `.conform.$RANDOM.*` copy; the real file is never touched). prettier = { command = "prettier", args = { "--write", "$FILENAME" }, @@ -141,18 +156,8 @@ return function() }, }, format_on_save = format_on_save_enabled and function(bufnr) - -- Check disabled filetypes - if block_list[vim.bo[bufnr].filetype] == true then - return - end - - -- Check disabled workspaces - if is_disabled_workspace(bufnr) then - return - end - - -- Check global toggle - if vim.g.disable_autoformat or vim.b[bufnr].disable_autoformat then + -- Disabled filetypes, disabled workspaces, and the global/buffer toggles + if not autoformat_allowed(bufnr) then return end @@ -168,6 +173,47 @@ return function() end or false, }) + -- Make Mason's bin dir resolvable BEFORE the replayed save formats — a bare + -- Mason formatter binary must spawn — so no per-formatter command rewrite is + -- needed. Idempotent; the resolver calls it again itself. + tools.ensure_mason_on_path() + -- Resolve `formatter_deps` (conform formatter names) discovery-first against + -- conform's own registry, so a missing formatter is installed / reported. + -- The probe only drives install/warn — nothing on the save path reads it — + -- so the whole resolve moves off the BufWritePre tick that lazy-loaded + -- conform instead of delaying the first save behind it. + vim.schedule(function() + 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. + local conform = require("conform") + if type(conform.get_formatter_config) ~= "function" then + return { binary = nil } + end + -- get_formatter_config runs a function-form override directly, so pcall keeps a + -- throwing override (a broken config) from being misread as an unknown name. + local ok, config, err = pcall(conform.get_formatter_config, name) + if not ok then + return { broken = tostring(config) } + end + if config then + -- A function-form command resolves per buffer at format time (e.g. the + -- builtin from_node_modules): treat it as self-resolving rather than + -- evaluating it for a representative binary — a node_modules command + -- shouldn't map to a Mason install anyway. + if type(config.command) == "function" then + return { binary = nil } + end + return { binary = config.command } + end + -- (nil, err) is a real formatter with a broken config; bare nil is an unknown name. + if type(err) == "string" then + return { broken = err } + end + return nil + end) + end) + -- User commands vim.api.nvim_create_user_command("Format", function(args) local range = nil From a72c7666cbde8538e31808e978b4799abb9c34c9 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 10:43:05 +0800 Subject: [PATCH 05/71] feat(lint): resolve linters discovery-first linter_deps resolve against nvim-lint's own registry, batched by filetype off the lint events so load-heavy linter modules (golangcilint) are only required when their filetype appears. local overrides are re-applied through idempotent factory wrappers, a late install rebuilds the module so load-time probing sees the new binary and re-lints affected buffers, and zsh joins via zsh -n. --- lua/modules/configs/completion/nvim-lint.lua | 276 ++++++++++++++++--- 1 file changed, 243 insertions(+), 33 deletions(-) diff --git a/lua/modules/configs/completion/nvim-lint.lua b/lua/modules/configs/completion/nvim-lint.lua index e4b88eef..76257593 100644 --- a/lua/modules/configs/completion/nvim-lint.lua +++ b/lua/modules/configs/completion/nvim-lint.lua @@ -1,37 +1,58 @@ 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" } - ) + ---@param name string + ---@param linter? table @Apply to this table instead of the registry entry — + --- a factory linter's value only exists per call, so its wrapper below + --- passes the returned table in directly. + local function apply_override(name, linter) + local apply = overrides[name] + if not apply then + return + end + linter = linter or lint.linters[name] + if type(linter) == "table" then + apply(linter) + end + end + for name in pairs(overrides) do + apply_override(name) + end - -- shuck: lints shell embedded in GitHub Actions workflows (the `run:` blocks). - -- Complements actionlint, which validates workflow syntax/expressions but not - -- the embedded shell. Standalone sh/bash/zsh diagnostics already come from the - -- shuck LSP server (see servers/shuck.lua), so shuck is only added to - -- `yaml.github` here, not to `sh`/`zsh`. `shuck check` has no working stdin - -- mode (it needs a project root), so run file-based and parse JSON output. + -- shuck: lints shell embedded in GitHub Actions `run:` blocks (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", @@ -73,7 +94,7 @@ return function() end, } - lint.linters_by_ft = { + local by_ft = { dockerfile = { "hadolint" }, go = { "golangcilint" }, lua = { "selene" }, @@ -88,11 +109,200 @@ return function() ["yaml.github"] = { "actionlint", "shuck" }, zsh = { "zsh" }, } + lint.linters_by_ft = by_ft + + -- Filetype -> linter names via nvim-lint's own (private) resolution while it + -- survives, so the two can't drift; else the exact by_ft entry — every + -- compound filetype this config lints is an explicit key. + local function linters_for_ft(ft) + local ok, names = pcall(lint._resolve_linter_by_ft, ft) + if ok and type(names) == "table" then + return names + end + return by_ft[ft] or {} + end - vim.api.nvim_create_autocmd({ "BufWritePost", "BufReadPost", "InsertLeave" }, { + -- Resolve `linter_deps` discovery-first against nvim-lint's own registry, + -- batched BY FILETYPE off the lint events below: the probe requires the + -- linter module, and some (golangcilint) run blocking system calls at load. + local tools = require("modules.utils.tools") + -- Wrappers installed by reapply_factory_override; the identity check keeps + -- reapplication idempotent (no stacking), while a fresh factory — e.g. after + -- refresh_linter reloads the module — is wrapped anew. + local factory_wrappers = {} + ---Re-apply local overrides to a factory linter's per-call table (the + ---setup-time apply_override loop only reaches table linters). No-op for a + ---table linter and for a factory without an override. + ---@param name string + local function reapply_factory_override(name) + if not overrides[name] then + return + end + local linter = lint.linters[name] + -- Skip a non-factory, or a linter we've already wrapped (idempotent). + if type(linter) ~= "function" or linter == factory_wrappers[name] then + return + end + local factory = linter + local wrapper = function(...) + local out = factory(...) + if type(out) == "table" then + apply_override(name, out) + end + return out + end + factory_wrappers[name] = wrapper + lint.linters[name] = wrapper + end + ---Rebuild a module-backed linter for a late configure: some (golangcilint) + ---compute `args` by RUNNING their binary at module-load time, and a result + ---computed while the binary was absent would persist all session. + ---@param name string + local function refresh_linter(name) + local module = "lint.linters." .. name + if not tools.module_path(module) then + return -- defined inline (e.g. shuck), not module-backed: nothing to reload + end + local prev = lint.linters[name] + package.loaded[module] = nil + -- Also drop any explicit assignment (a wrapped factory) shadowing the + -- lint.linters __index loader; the read below re-requires the module. + rawset(lint.linters, name, nil) + local fresh = lint.linters[name] + if fresh == nil then + -- Nothing regenerated the linter (loader gone or reload throws): + -- restore — stale args beat a deleted linter (asserts on try_lint). + rawset(lint.linters, name, prev) + end + 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. + ---@param names string[] + local function resolve_batch(names) + tools.resolve_runtime_tools("nvim-lint", names, function(name) + -- Read — and possibly lazy-load — the linter (the __index loader + -- requires the module; Mason's bin dir is already on $PATH). + local linter = lint.linters[name] + if linter == nil then + -- The metatable swallows loader errors: a module that exists but + -- throws is a broken config, not a typo. Re-require re-throws. + local module = "lint.linters." .. name + if not tools.module_path(module) then + return nil -- unknown linter name (typo / not registered) + end + local ok, err = pcall(require, module) + if not ok then + return { broken = tostring(err) } + end + -- The retry loaded (nondeterministic loader): pick up the fresh value. + linter = lint.linters[name] + if linter == nil then + return nil + end + end + if type(linter) == "function" then + -- A throwing factory is a broken config, not an unknown name. + local ok, resolved = pcall(linter) + if not ok then + return { broken = tostring(resolved) } + end + linter = resolved + end + if type(linter) ~= "table" then + return nil + end + local cmd = linter.cmd + if type(cmd) == "function" then + local ok, resolved = pcall(cmd) + if not ok then + return { broken = tostring(resolved) } + end + cmd = resolved + end + -- A non-string cmd means the linter resolves its command at runtime. + return { binary = type(cmd) == "string" and cmd or nil } + end, function(name, late) + if late then + -- Rebuild so load-time work sees the just-installed binary. + refresh_linter(name) + end + reapply_factory_override(name) + if late then + -- No lint event follows a late configure: re-lint every loaded + -- buffer whose filetype maps to this linter. + vim.schedule(function() + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if + vim.api.nvim_buf_is_loaded(buf) + and vim.tbl_contains(linters_for_ft(vim.bo[buf].filetype), name) + then + vim.api.nvim_buf_call(buf, function() + pcall(function() + lint.try_lint(nil, { ignore_errors = true }) + end) + end) + end + end + end) + end + end) + end + + -- 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 = tools.normalize_names(require("core.settings").linter_deps) + local mapped = {} + for _, names in pairs(by_ft) do + for _, name in ipairs(names) do + mapped[name] = true + end + end + local pending, immediate = {}, {} + for _, name in ipairs(deps) do + if mapped[name] then + pending[name] = true + else + immediate[#immediate + 1] = name + end + end + if #immediate > 0 then + vim.schedule(function() + resolve_batch(immediate) + end) + end + + local ft_done = {} + local function ensure_resolved(ft) + if ft == "" or ft_done[ft] then + return + end + ft_done[ft] = true + local batch = {} + for _, name in ipairs(linters_for_ft(ft)) do + if pending[name] then + pending[name] = nil + batch[#batch + 1] = name + end + end + if #batch > 0 then + resolve_batch(batch) + end + end + + -- FileType is resolve-only, no lint: it covers the buffer whose BufReadPost + -- loaded this plugin — lazy.nvim replays that event before filetype + -- detection, so the lint callback sees an empty filetype there. + vim.api.nvim_create_autocmd({ "BufWritePost", "BufReadPost", "InsertLeave", "FileType" }, { group = vim.api.nvim_create_augroup("NvimLint", { clear = true }), - callback = function() - lint.try_lint(nil, { ignore_errors = true }) + callback = function(args) + -- Resolve this filetype's deps before the lint they gate, so the first + -- lint already spawns by absolute path when Mason's bin dir is off $PATH. + ensure_resolved(vim.bo[args.buf].filetype) + if args.event ~= "FileType" then + lint.try_lint(nil, { ignore_errors = true }) + end end, }) end From 5642ed3981a157fac8930d0294cae09ed768180e Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 10:43:05 +0800 Subject: [PATCH 06/71] feat(dap): resolve debug adapters discovery-first dap_deps resolve through the shared loop with mason-nvim-dap degraded to a mappings provider; client configs self-validate under one canonical contract (validate-first for codelldb/lldb, register-attach-then-raise for delve/python) so remote attach survives a missing local binary while the raise drives the install fallback. delve moves to dlv dap directly, python resolves debugpy through one cascade shared by config-time and launch (mason venv, adapter shim, cache last, bounded import probe), and client configs load once through a memoized first-usable loader. --- .../configs/tool/dap/clients/codelldb.lua | 8 +- .../configs/tool/dap/clients/delve.lua | 86 ++++++--- lua/modules/configs/tool/dap/clients/lldb.lua | 10 +- .../configs/tool/dap/clients/python.lua | 122 +++++++++++- lua/modules/configs/tool/dap/init.lua | 182 ++++++++++++++++-- lua/modules/plugins/tool.lua | 2 + 6 files changed, 351 insertions(+), 59 deletions(-) diff --git a/lua/modules/configs/tool/dap/clients/codelldb.lua b/lua/modules/configs/tool/dap/clients/codelldb.lua index 64124c61..70b2a1b1 100644 --- a/lua/modules/configs/tool/dap/clients/codelldb.lua +++ b/lua/modules/configs/tool/dap/clients/codelldb.lua @@ -4,13 +4,17 @@ return function() local utils = require("modules.utils.dap") local is_windows = require("core.global").is_windows + -- 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 = { 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..77d55295 100644 --- a/lua/modules/configs/tool/dap/clients/delve.lua +++ b/lua/modules/configs/tool/dap/clients/delve.lua @@ -1,34 +1,64 @@ --- https://github.com/mfussenegger/nvim-dap/wiki/Debug-Adapter-installation#go +-- https://github.com/mfussenegger/nvim-dap/wiki/Debug-Adapter-installation#go-using-delve-directly -- https://github.com/golang/vscode-go/blob/master/docs/debugging.md return function() local dap = require("dap") local utils = require("modules.utils.dap") + local is_windows = require("core.global").is_windows - if not require("mason-registry").is_installed("go-debug-adapter") then - vim.notify( - "Automatically installing `go-debug-adapter` for go debugging", - vim.log.levels.INFO, - { title = "nvim-dap" } - ) + -- Use delve's built-in DAP server (`dlv dap`) directly, no separate go-debug-adapter. + -- `dlv` resolves lazily on the spawn path: remote attach needs no local binary, so + -- the adapter registers even when it's absent. - local go_dbg = require("mason-registry").get_package("go-debug-adapter") - go_dbg:install():once( - "closed", - vim.schedule_wrap(function() - if go_dbg:is_installed() then - vim.notify("Successfully installed `go-debug-adapter`", vim.log.levels.INFO, { title = "nvim-dap" }) + ---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 errors rather than silently + -- falling back 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 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 +67,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 +79,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 +91,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 +103,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 +115,17 @@ return function() cwd = "${workspaceFolder}", program = "./${relativeFileDirname}", console = "integratedTerminal", - dlvToolPath = vim.fn.exepath("dlv"), mode = "test", showLog = true, showRegisters = true, stopOnEntry = false, }, } + + -- Availability check LAST (contract: tool/dap/init.lua resolver spec); the raise + -- is the provisioning signal — the attach-capable adapters above stay registered. + require("modules.utils.tools").exepath_or_error( + "dlv", + "local `dlv dap` launch is unavailable until installed (remote attach still works)" + ) end diff --git a/lua/modules/configs/tool/dap/clients/lldb.lua b/lua/modules/configs/tool/dap/clients/lldb.lua index 8461c77a..6ce14c42 100644 --- a/lua/modules/configs/tool/dap/clients/lldb.lua +++ b/lua/modules/configs/tool/dap/clients/lldb.lua @@ -3,9 +3,17 @@ return function() local dap = require("dap") local utils = require("modules.utils.dap") + -- Opt-in preset (not in default dap_deps; codelldb covers C-family). Self-validate + -- so the resolver surfaces `lldb`. 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..dfa979fa 100644 --- a/lua/modules/configs/tool/dap/clients/python.lua +++ b/lua/modules/configs/tool/dap/clients/python.lua @@ -3,8 +3,102 @@ return function() local dap = require("dap") local utils = require("modules.utils.dap") + local tools = require("modules.utils.tools") local is_windows = require("core.global").is_windows - local debugpy_root = vim.env.MASON .. "/packages/debugpy" + -- Interpreter candidates probed for a debugpy-capable python; the config-time + -- availability check and the launch path share one cascade (debugpy_command). + local py_candidates = is_windows and { "pythonw.exe", "python.exe", "python" } or { "python3", "python" } + + -- Platform-specific venv interpreter layout, in one place. + ---@param root string @A virtualenv root directory. + ---@return string @Absolute path to the venv's python interpreter. + local function venv_python(root) + return is_windows and root .. "/Scripts/pythonw.exe" or root .. "/bin/python" + end + + -- Resolve the debugpy command discovery-first. Higher-priority sources + -- (Mason venv, adapter shim) are re-probed every call, so a mid-session + -- install takes over; the cache is consulted LAST purely to spare the + -- blocking import probe, and a cached hit re-checks existence. (A `pip + -- uninstall` that keeps the interpreter fails only at import time — accepted.) + local resolved + local function found(command, args) + resolved = { command = command, args = args } + return command, args + end + -- One copy for both raise sites so the install guidance can't drift. + local no_debugpy = "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" + + -- Fast, non-blocking probes only (executable()/exepath()): Mason's managed + -- venv → `debugpy-adapter` on $PATH. Spawns nothing itself; the import probe + -- lives in the full cascade below and runs only when these probes miss. + local function fast_command() + -- Re-derive the Mason root each call so a debugpy installed mid-session is + -- picked up: capturing it at config load would freeze it to nil whenever + -- Mason wasn't resolvable on the first :Dap (its dir not yet created). + local mason_root = tools.mason_root() + if mason_root then + local mason_python = venv_python(mason_root .. "/packages/debugpy/venv") + if vim.fn.executable(mason_python) == 1 then + return found(mason_python, { "-m", "debugpy.adapter" }) + end + end + -- find_executable reaches a Mason shim even before mason.setup() puts its + -- bin dir on $PATH; spawn by absolute path (the bare name wouldn't launch). + local adapter = tools.find_executable("debugpy-adapter") + if adapter then + return found(adapter, {}) + end + -- Cache LAST: it holds whichever source last resolved — possibly a + -- lower-priority import-probe result — and probing the managed sources + -- above first is what lets a mid-session install win. + if resolved then + if vim.fn.executable(resolved.command) == 1 then + return resolved.command, resolved.args + end + -- The cached binary vanished; drop it and re-run the cascade. + resolved = nil + end + return nil + end + + -- Full cascade: fast probes, then a system python that can import debugpy. + -- The import probe spawns a short-lived python, but only when the fast probes + -- miss. Both the launch path and the config-time availability check at the + -- bottom use this cascade, so the two resolutions agree by construction. + local function debugpy_command() + local command, args = fast_command() + if command then + return command, args + end + -- Last resort: probe interpreter candidates rather than hard-coding one + -- (pythonw.exe is often absent on a Windows box with only python.exe). + local probed = {} + for _, py in ipairs(py_candidates) do + -- Confirm the module actually imports, not just that the interpreter exists. + -- Dedup by resolved path so `python`→`python3` symlinks spawn only once. + if vim.fn.executable(py) == 1 then + local abs = vim.fn.exepath(py) + local real = (abs ~= "" and vim.uv.fs_realpath(abs)) or abs + -- Key by candidate name when exepath came back empty, so one empty + -- result can't blanket-skip the remaining candidates. + local key = real ~= "" and real or py + if not probed[key] then + probed[key] = true + local probe_cmd = abs ~= "" and abs or py + vim.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(probe_cmd, { "-m", "debugpy.adapter" }) + end + end + end + end + return nil + end dap.adapters.python = function(callback, config) if config.request == "attach" then @@ -17,11 +111,15 @@ return function() options = { source_filetype = "python" }, }) else + -- Launch path only: attach uses the server branch above and needs no local debugpy. + local command, args = debugpy_command() + if not command then + error(no_debugpy, 0) + end callback({ type = "executable", - command = is_windows and debugpy_root .. "/venv/Scripts/pythonw.exe" - or debugpy_root .. "/venv/bin/python", - args = { "-m", "debugpy.adapter" }, + command = command, + args = args, options = { source_filetype = "python" }, }) end @@ -38,7 +136,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 +153,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 +169,12 @@ return function() end, }, } + + -- Availability check LAST (contract: tool/dap/init.lua resolver spec); the + -- raise is the provisioning signal — the attach adapters stay registered. + -- A capable system python passes (no needless install); the bounded import + -- probe spawns only when the fast probes miss. + if not debugpy_command() then + error(no_debugpy .. " (remote attach works regardless)", 0) + end end diff --git a/lua/modules/configs/tool/dap/init.lua b/lua/modules/configs/tool/dap/init.lua index 1e998f8b..cffbb198 100644 --- a/lua/modules/configs/tool/dap/init.lua +++ b/lua/modules/configs/tool/dap/init.lua @@ -1,11 +1,42 @@ 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 + + local client_config_cache = {} + ---Load the first usable client config (user override, then repo preset) via + ---the shared first-usable loader, memoized per adapter: the resolver consults + ---it from several predicates (has_local_config, unknown_of) plus the handler, + ---and an uncached miss would re-run a failed require each time. + ---@param name string + ---@return any value, string|nil broken_reason, boolean any_exists + local function load_client_config(name) + local cached = client_config_cache[name] + if not cached then + local value, broken_reason, any_exists = tools.load_first_usable( + client_modules(name), + "nvim-dap", + "client config `%s` returned no value (must return a function)" + ) + cached = { value = value, broken_reason = broken_reason, any_exists = any_exists } + client_config_cache[name] = cached + end + return cached.value, cached.broken_reason, cached.any_exists + end -- Initialize debug hooks _G._debugging = false @@ -54,38 +85,147 @@ 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) + local custom_handler, broken_reason = load_client_config(dap_name) + -- A broken config must not fall through to the repo preset or Mason's + -- factory setup: that would read as success and suppress both the warning + -- and the install fallback (same contract as mason_lsp_handler). + if broken_reason then + tools.raise_verbatim(broken_reason) end - if not ok then - -- Default to use factory config for clients(s) that doesn't include a spec + if custom_handler == nil then + -- No client config: fall back to Mason's factory config, erroring + -- (level 0) so the resolver reports failures. + 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 + -- default_setup silently no-ops on a nil adapter config: error instead. + 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) - return 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) else - vim.notify( - string.format( - "Failed to setup [%s].\n\nClient definition under `tool/dap/clients` must return\na fun(opts) (got '%s' instead)", - config.name, - type(custom_handler) - ), - vim.log.levels.ERROR, - { title = "nvim-dap" } + -- Raise, don't notify-and-return: a normal return reads as success to + -- the resolver and suppresses the warning + install fallback. + tools.raise_verbatim( + string.format("client config must return a fun(opts) (got `%s`)", type(custom_handler)) ) 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)? + ---A config that exists but fails to load still counts — treating it as + ---absent would misread a broken config as an unknown adapter name. + ---@param name string + ---@return boolean + local function has_client_config(name) + local value, _, any_exists = load_client_config(name) + return value ~= nil or any_exists + end + + ---Configure an adapter via the shared handler; client configs self-validate. + ---@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 + + -- 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, + -- A value, not a thunk: mason-registry was already required at config top. + registry = has_registry and registry or nil, + package_of = function(name) + 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, no probe: adapter names are not binary names (`delve` + -- ships `dlv`). Client configs self-validate. + return {} + end, + ---Typo/outdated name vs valid-but-unprovisioned. A client config or mapping means + ---the name is real; an empty map (Mason absent) is untrusted to avoid false typos. + unknown_of = function(name) + if has_client_config(name) then + return false + end + 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 try an existing config before the Mason + -- install fallback — python resolves debugpy from a venv $PATH can't see. + -- The raise on a missing launch binary is the provisioning signal. + -- + -- CANONICAL availability contract for dap/clients/*.lua (referenced by the + -- one-line notes in each client; keep the two patterns in sync here): + -- * validate FIRST (top of config): launch AND attach both spawn the local + -- binary, so nothing is worth registering without it — codelldb, lldb. + -- * raise LAST (bottom of config): attach needs no local binary, so the + -- attach-capable adapters are registered BEFORE the check and stay + -- registered when it raises; the raise only signals provisioning (the + -- warning reasons say remote attach still works) — delve, python. + local_config_mode = "validates", + configure = configure_adapter, }) end diff --git a/lua/modules/plugins/tool.lua b/lua/modules/plugins/tool.lua index a5105bb1..5ad7aa4f 100644 --- a/lua/modules/plugins/tool.lua +++ b/lua/modules/plugins/tool.lua @@ -106,6 +106,8 @@ tool["mfussenegger/nvim-dap"] = { }, config = require("tool.dap"), dependencies = { + -- 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", From 7bb267868d28b99ffd2e535c06f9eb7fee17249a Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 10:43:06 +0800 Subject: [PATCH 07/71] refactor(mason): drop the bootstrap ensure-install loop formatter/linter resolution lives in conform.lua and nvim-lint.lua against their own registrations; mason here is ui-only / lazy install fallback. --- lua/modules/configs/completion/mason.lua | 31 ++---------------------- 1 file changed, 2 insertions(+), 29 deletions(-) diff --git a/lua/modules/configs/completion/mason.lua b/lua/modules/configs/completion/mason.lua index 450cdefa..fc8eb4cf 100644 --- a/lua/modules/configs/completion/mason.lua +++ b/lua/modules/configs/completion/mason.lua @@ -27,35 +27,8 @@ 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 in conform.lua and nvim-lint.lua (against their + -- own registrations); Mason here is UI-only / lazy install fallback. end return M From 9ed2a050d521ab475b1f538a7ab3c875c9119938 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 10:43:06 +0800 Subject: [PATCH 08/71] refactor(lsp): resolve the traefik schema conflict by file pattern the catalog's traefik v2 entry claims the same files as our v3 extra; traefik globs are stripped from every other schema, matched on the file pattern rather than v2's catalog name/url so a catalog rename can't let v2 re-claim them. the gh-dash schema moves to its current home. --- .../configs/completion/servers/yamlls.lua | 82 ++++++++++++------- 1 file changed, 51 insertions(+), 31 deletions(-) diff --git a/lua/modules/configs/completion/servers/yamlls.lua b/lua/modules/configs/completion/servers/yamlls.lua index f8052abf..4625fbdc 100644 --- a/lua/modules/configs/completion/servers/yamlls.lua +++ b/lua/modules/configs/completion/servers/yamlls.lua @@ -1,4 +1,54 @@ -- https://github.com/neovim/nvim-lspconfig/blob/master/lsp/yamlls.lua +-- Our own Traefik v3 schema URL, referenced by the extra below and by the +-- traefik-claim cleanup after it. +local traefik_v3_url = "https://www.schemastore.org/traefik-v3.json" +local schemas = require("schemastore").yaml.schemas({ + extra = { + { + name = "azure-pipelines", + description = "azure-pipelines YAML schema", + fileMatch = { "azure-pipelines.{yml,yaml}" }, + url = "https://raw.githubusercontent.com/microsoft/azure-pipelines-vscode/master/service-schema.json", + }, + { + name = "gh-dash config", + description = "gh-dash config YAML schema", + fileMatch = "*/gh-dash/config.{yml,yaml}", + url = "https://www.gh-dash.dev/schema.json", + }, + -- The catalog's own "Traefik v3" entry carries no fileMatch, so this + -- extra is what actually claims the static-config files for v3. + { + name = "Traefik v3", + description = "Traefik v3 static configuration", + fileMatch = { "traefik.{yml,yaml}" }, + url = traefik_v3_url, + }, + }, +}) +-- traefik.{yml,yaml} belong to our Traefik v3 extra alone: strip traefik globs +-- from every OTHER schema (the catalog's v2 entry claims the same files). +-- Matched on the file pattern, not v2's name/URL, so a catalog rename can't let +-- v2 re-claim them. Keys snapshotted so `schemas` isn't mutated mid-pairs. +for _, url in ipairs(vim.tbl_keys(schemas)) do + local fileMatch = schemas[url] + if url ~= traefik_v3_url then + if type(fileMatch) == "string" then + if fileMatch:find("traefik", 1, true) then + schemas[url] = nil + end + elseif type(fileMatch) == "table" then + local kept = {} + for _, glob in ipairs(fileMatch) do + if not (type(glob) == "string" and glob:find("traefik", 1, true)) then + kept[#kept + 1] = glob + end + end + schemas[url] = #kept > 0 and kept or nil + end + end +end + return { single_file_support = true, debounce_text_changes = 150, @@ -18,37 +68,7 @@ return { -- Avoid TypeError: Cannot read properties of undefined (reading 'length') url = "", }, - schemas = require("schemastore").yaml.schemas({ - extra = { - { - name = "azure-pipelines", - description = "azure-pipelines YAML schema", - fileMatch = { "azure-pipelines.{yml,yaml}" }, - url = "https://raw.githubusercontent.com/microsoft/azure-pipelines-vscode/master/service-schema.json", - }, - { - name = "gh-dash config", - description = "gh-dash config YAML schema", - fileMatch = "*/gh-dash/config.yml", - url = "https://dlvdhr.github.io/gh-dash/configuration/gh-dash/schema.json", - }, - { - name = "Traefik v3", - description = "Traefik v3 static configuration", - fileMatch = { "traefik.yml", "traefik.yaml" }, - url = "https://www.schemastore.org/traefik-v3.json", - }, - }, - -- Override built-in Traefik v2 with v3 - replace = { - ["Traefik v2"] = { - name = "Traefik v3", - description = "Traefik v3 static configuration", - fileMatch = { "traefik.yml", "traefik.yaml" }, - url = "https://www.schemastore.org/traefik-v3.json", - }, - }, - }), + schemas = schemas, -- trace = { server = "debug" }, }, }, From 5cf756d261a2d04d830cee131517bc7f6230d358 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 14:01:40 +0800 Subject: [PATCH 09/71] feat(tooling): hand off mid-session installs to pending configures resolve() now parks phase-2 leftovers in a per-call session; do_configure gates late paths on the pending set (at most one successful configure per name, a failure stays recoverable). a package:install:success subscription (attached from resolve phase 2 and mason setup, never force-loading mason) plus a package-aware :ToolsRetry finish those deps when the tool appears later via :MasonInstall, the :Mason ui, or an install outside mason, so a restart is no longer required; the aggregated warning says so --- lua/modules/configs/completion/mason.lua | 7 + lua/modules/utils/tools.lua | 196 ++++++++++++++++++++++- 2 files changed, 200 insertions(+), 3 deletions(-) diff --git a/lua/modules/configs/completion/mason.lua b/lua/modules/configs/completion/mason.lua index fc8eb4cf..e0af453e 100644 --- a/lua/modules/configs/completion/mason.lua +++ b/lua/modules/configs/completion/mason.lua @@ -29,6 +29,13 @@ M.setup = function() -- Formatter/linter resolution lives in conform.lua and nvim-lint.lua (against their -- own registrations); Mason here is UI-only / lazy install fallback. + + -- A user-driven install (:MasonInstall / the :Mason UI) must finish any + -- pending resolver hand-off even when no resolver ever loaded the registry. + local ok, registry = pcall(require, "mason-registry") + if ok then + require("modules.utils.tools").attach_registry_events(registry) + end end return M diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index 93bbe00c..063b2aca 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -302,6 +302,8 @@ local function missing_collector(title, timeout_ms) .. "Install them / ensure they are on $PATH, or check their configuration\n" .. "for errors:\n • " .. table.concat(lines, "\n • ") + .. "\n\nMason installs are picked up automatically; after installing a tool\n" + .. "outside Mason, run :ToolsRetry or restart Neovim." end if #unknown_new > 0 then table.sort(unknown_new) @@ -361,6 +363,12 @@ local function missing_collector(title, timeout_ms) return { mark = add, mark_unknown = add_unknown, + ---An install tracked here whose "closed" callback hasn't run yet (and + ---whose deadline hasn't settled it): such a name is owned by that + ---callback, so hand-off retries leave it alone. + is_unsettled = function(name) + return unsettled[name] ~= nil + end, track = function(pkg, name, recheck, on_ready, fail_reason) -- Attach to an in-flight OPEN install handle instead of starting a -- duplicate (install() asserts; once("closed") never fires on a closed one). @@ -600,6 +608,136 @@ end -- per filetype) aggregates one warning instead of one per batch. local collectors_by_title = {} +-- Install hand-off: resolve() sessions whose deps are still waiting for a tool +-- to appear. Each entry pairs a spec with its pending set so a later Mason +-- install event or :ToolsRetry can finish the configure without a restart. +local sessions = {} + +---Deregister a session once nothing is pending (leak-free bookkeeping). Sole +---owner of removal — called from every path that empties a pending set, so +---iterations over `sessions` never race a second remover. +---@param session table +local function drop_session_if_done(session) + if next(session.pending) ~= nil then + return + end + for index = #sessions, 1, -1 do + if sessions[index] == session then + table.remove(sessions, index) + return + end + end +end + +---Gated late-configure across sessions: for every pending name accepted by +---`eligible`, run the session's configure — at most one SUCCESS per name (the +---pending set is the gate; a failure keeps the name recoverable) — and report +---each subsystem's batch in one INFO. An emptied session drops out via the +---configure path itself (drop_session_if_done); descending order keeps the +---iteration safe across that removal. +---@param eligible fun(session: table, name: string): boolean +local function retry_pending(eligible) + local configured_by_title = {} + for index = #sessions, 1, -1 do + local session = sessions[index] + for _, name in ipairs(vim.tbl_keys(session.pending)) do + -- Re-check pending: an earlier name's configure may have consumed it. + if session.pending[name] ~= nil and eligible(session, name) and session.configure(name) then + local bucket = configured_by_title[session.title] or {} + bucket[#bucket + 1] = name + configured_by_title[session.title] = bucket + end + end + end + for title, names in pairs(configured_by_title) do + table.sort(names) + vim.notify("Configured after install: " .. table.concat(names, ", "), vim.log.levels.INFO, { title = title }) + end +end + +-- Attached once per session lifetime; pcall guards mason-registry API drift +-- (no events = status quo: the tool is picked up on the next launch). +local registry_events_attached = false + +---Subscribe to Mason install successes so a package installed mid-session by +---ANY means (resolver-started, :MasonInstall, the :Mason UI) finishes the +---pending configure of every subsystem that waited for it. Never require()s +---mason-registry itself — callers pass a registry they already hold, keeping +---"a fully-provisioned setup never loads Mason" intact. +---@param registry table|nil @The mason-registry module (or nil). +function M.attach_registry_events(registry) + if registry_events_attached or type(registry) ~= "table" or type(registry.on) ~= "function" then + return + end + local attached = pcall(function() + registry:on( + "package:install:success", + -- The emitter fires from the install handle's lifecycle (fast event + -- context): hop to the main loop before touching consumer configs. + vim.schedule_wrap(function(pkg) + local pkg_name = type(pkg) == "table" and type(pkg.name) == "string" and pkg.name or nil + if not pkg_name then + return + end + retry_pending(function(session, name) + -- An install still in flight is owned by its closed callback. + if session.is_unsettled(name) then + return false + end + local ok, mapped = pcall(session.spec.package_of, name, registry) + if ok and mapped == pkg_name then + return true + end + local bins_ok, bins = pcall(session.spec.binaries_of, name, nil) + return bins_ok and M.find_executable(bins) ~= nil + end) + end) + ) + end) + registry_events_attached = attached +end + +---Re-attempt every pending dep whose tool has since appeared — the manual +---hand-off entry (:ToolsRetry) for installs done outside Mason (mise/nix/npm). +function M.retry_missing() + retry_pending(function(session, name) + -- An install still in flight is owned by its closed callback. + if session.is_unsettled(name) then + return false + end + local spec = session.spec + local bins_ok, bins = pcall(spec.binaries_of, name, nil) + if bins_ok and M.find_executable(bins) ~= nil then + return true + end + -- Function-cmd LSP servers (jsonls/yamlls) declare no bare binary: + -- derive it from the Mason package spec even when the binary itself was + -- installed outside Mason. + local registry = session.resolve_registry() + if registry then + local ok, pkg_name = pcall(spec.package_of, name, registry) + if ok and type(pkg_name) == "string" then + local pkg_ok, pkg = pcall(registry.get_package, pkg_name) + if pkg_ok and type(pkg) == "table" then + local pkg_bins_ok, pkg_bins = pcall(spec.binaries_of, name, pkg) + if pkg_bins_ok and M.find_executable(pkg_bins) ~= nil then + return true + end + end + end + end + -- Only self-VALIDATING configs may retry on config evidence alone: their + -- configure re-checks the tool and a still-missing one fails the attempt, + -- keeping the name pending. A generic local config (LSP) would instead + -- enable a server whose binary is still absent. + if spec.local_config_mode ~= "validates" or type(spec.has_local_config) ~= "function" then + return false + end + local has_ok, has = pcall(spec.has_local_config, name) + return has_ok and has == true + end) +end + ---Shared discovery-first resolution loop. For each entry in `spec.deps`: --- 0. Unrecognized name (`unknown_of`) -> fix the config; never install. --- 1. Available (Mason-installed / on $PATH) -> configure now. @@ -651,6 +789,25 @@ function M.resolve(spec) -- gets `late = true`. local synchronous = true + -- This call's hand-off record: phase-2 leftovers park in `pending` until a + -- configure SUCCEEDS, so the Mason install event or :ToolsRetry can finish + -- them later; registered in `sessions` only when leftovers exist. + local session = { + title = spec.title, + spec = spec, + pending = {}, + is_unsettled = collector.is_unsettled, + ---The spec's registry (value or lazy thunk), resolved on demand. + resolve_registry = function() + local registry = spec.registry + if type(registry) == "function" then + local ok, resolved = pcall(registry) + registry = ok and resolved or nil + end + return type(registry) == "table" and registry or nil + end, + } + ---Configure one tool; false plus the cleaned error when the config threw. local function try_configure(name) if type(spec.configure) ~= "function" then @@ -663,13 +820,25 @@ function M.resolve(spec) return false, error_reason(err) end - -- Configure one tool, surfacing a config-time error in the aggregated warning. + -- Configure one tool, surfacing a config-time error in the aggregated + -- warning. Late calls race (install completion, the registry install event, + -- :ToolsRetry, a late refresh finish): `pending` is the gate — the first + -- SUCCESS clears it and later arrivals skip; a failure keeps the name + -- recoverable. Returns true only when the configure ran and succeeded. local function do_configure(name) + if not synchronous and session.pending[name] == nil then + return false + end local ok, reason = try_configure(name) - if not ok then - collector.mark(name, reason) + if ok then + session.pending[name] = nil + drop_session_if_done(session) + return true end + collector.mark(name, reason) + return false end + session.configure = do_configure -- Phase-1 "validates" failures, surfaced by phase 2 without re-running the -- config; `false` = failed with no message. @@ -727,6 +896,8 @@ function M.resolve(spec) local function resolve_missing(name, registry) -- Judged after the refresh: unknown_of may consult the Mason mapping. if spec.unknown_of and spec.unknown_of(name) then + -- A typo can't be fixed by an install: drop it from the hand-off set. + session.pending[name] = nil collector.mark_unknown(name) return end @@ -776,6 +947,9 @@ function M.resolve(spec) if spec.has_local_config and spec.has_local_config(name) then do_configure(name) elseif pkg_unknown and #spec.binaries_of(name, nil) == 0 then + -- A stale mapping can't be fixed by an install either: drop it from + -- the hand-off set like the unknown_of branch above. + session.pending[name] = nil collector.mark_unknown(pkg_name == name and name or (pkg_name .. " (for " .. name .. ")")) else collector.mark(name) @@ -804,6 +978,7 @@ function M.resolve(spec) collector.mark(name, error_reason(handled)) elseif handled ~= true then unresolved[#unresolved + 1] = name + session.pending[name] = true end end end @@ -813,6 +988,8 @@ function M.resolve(spec) collector.done() return end + -- Leftovers exist: expose them to the install hand-off paths. + sessions[#sessions + 1] = session -- Phase 2: resolve leftovers against the registry (value, nil, or lazy -- thunk — only called here, so a fully-provisioned subsystem never loads @@ -822,6 +999,10 @@ function M.resolve(spec) local ok, resolved = pcall(registry) registry = ok and resolved or nil end + -- The registry is loaded anyway: make sure mid-session installs hand off. + if registry then + M.attach_registry_events(registry) + end local function finish() for _, name in ipairs(unresolved) do local ok, err = pcall(resolve_missing, name, registry) @@ -830,6 +1011,9 @@ function M.resolve(spec) end end collector.done() + -- Unknown-name removals don't pass through do_configure: sweep here + -- so a fully-classified session doesn't linger in `sessions`. + drop_session_if_done(session) end if registry and type(registry.refresh) == "function" and not registry_bootstrapped(registry) then -- A stalled refresh() never calls back: arm a REPORTING deadline. It @@ -957,4 +1141,10 @@ function M.resolve_runtime_tools(title, deps, probe, configure) }) end +-- Manual hand-off entry for installs Mason can't announce (mise/nix/npm); +-- pcall so a re-source of this module can't fail on the existing command. +pcall(vim.api.nvim_create_user_command, "ToolsRetry", function() + M.retry_missing() +end, { desc = "Retry configuring missing tools (pick up installs done outside Mason)" }) + return M From cf90b270f7a73908ff79abb8a9ca50a3736ac2c1 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 14:35:29 +0800 Subject: [PATCH 10/71] fix(lsp): make user override reads install-timing independent a user.configs.lsp read of a not-yet-installed server used to snapshot a config missing the repo preset and capabilities, and the post-install replay re-asserted that stale snapshot over the late registration. the proxy now performs read-triggered real registration (transactional: the _configs entry is rolled back when a spec raises mid-registration, and the trigger disables itself if core drops _configs), configure() skips re-registration once a name is registered, and the merge-under table-override policy is stated where the preset is loaded --- .../configs/completion/mason-lspconfig.lua | 79 +++++++++++++++++-- 1 file changed, 71 insertions(+), 8 deletions(-) diff --git a/lua/modules/configs/completion/mason-lspconfig.lua b/lua/modules/configs/completion/mason-lspconfig.lua index 1fa30607..4ab8c988 100644 --- a/lua/modules/configs/completion/mason-lspconfig.lua +++ b/lua/modules/configs/completion/mason-lspconfig.lua @@ -6,14 +6,31 @@ local M = {} -- otherwise force-merge over the user's keys (install-timing-dependent). local user_lsp_configs = {} +-- Bridge set by setup(): read-triggered registration (fun(real, name)) so a +-- `user.configs.lsp` read of a not-yet-registered lsp_deps server sees the +-- post-registration view regardless of install timing. +local registration_trigger = nil + ---Run `user.configs.lsp` with vim.lsp.config proxied to record per-server ----registrations. Reads forward to the real table; "*" is not recorded (core +---registrations. Reads forward to the real table — after triggering the dep's +---real registration first, so a read-modify-write override snapshots the same +---base whether or not the server was installed yet. "*" is not recorded (core ---merges it at read time); the real table is always restored right after the ---pcall'd require (same silent-if-missing behavior as before). function M.run_user_lsp_overrides() local real = vim.lsp.config - vim.lsp.config = setmetatable({}, { + local proxy + proxy = setmetatable({}, { __index = function(_, key) + if registration_trigger and type(key) == "string" and key ~= "*" then + -- The handler (and a function-form spec) registers through the + -- GLOBAL vim.lsp.config: it must be the real table while the + -- trigger runs, or the registration would be recorded as user + -- ops / re-enter this proxy. pcall guarantees the restore. + vim.lsp.config = real + pcall(registration_trigger, real, key) + vim.lsp.config = proxy + end return real[key] end, __newindex = function(_, key, value) @@ -32,6 +49,7 @@ function M.run_user_lsp_overrides() end end, }) + vim.lsp.config = proxy pcall(require, "user.configs.lsp") vim.lsp.config = real end @@ -45,6 +63,17 @@ M.setup = function() local mason_ok = has_registry and has_mlsp local tools = require("modules.utils.tools") + -- lsp_deps as a set: the read trigger below only acts on names this config + -- actually manages. + local deps_set = {} + for _, name in ipairs(tools.normalize_names(settings.lsp_deps)) do + deps_set[name] = true + end + -- Servers whose registration already ran (configure() or a read trigger): + -- configure() skips re-registration, so a registration triggered before the + -- user's ops can never be re-asserted over them later. + local registered = {} + ---Ordered server-spec modules for a server: user override, then repo default. ---@param name string ---@return string[] @@ -135,6 +164,10 @@ M.setup = function() end -- Load the repo preset only when usable (no override, or as merge base under a -- table override): a function-form override replaces it wholesale. + -- POLICY (unified since the discovery-first branch, including the formerly + -- "external" servers nil_ls/nixd/shuck): a TABLE override MERGES over the + -- repo preset ("write what you change"); full replacement is expressed as + -- a function-form override. if not user_ok or type(user_spec) == "table" then local ok, spec, exists, reason = tools.load_module_or_report(modules[2], "nvim-lspconfig") if ok then @@ -288,14 +321,19 @@ M.setup = function() return not info.has_module and not info.known_lspconfig end - ---Register a server, then enable it (a bare Mason `cmd` spawns fine: the - ---resolver put Mason's bin dir on $PATH). + ---Register a server (unless a read trigger already did), then enable it (a + ---bare Mason `cmd` spawns fine: the resolver put Mason's bin dir on $PATH). local function configure(name) - mason_lsp_handler(name) + if not registered[name] then + mason_lsp_handler(name) + registered[name] = true + end -- Replay recorded `user.configs.lsp` registrations on top: a post-install - -- configure runs after that module did, and the repo spec would otherwise - -- force-merge over the user's keys. Synchronous configures precede the - -- user module, so the replay is a natural no-op there. + -- configure may register AFTER that module ran (write-only overrides get + -- no read trigger), and the repo spec would otherwise force-merge over + -- the user's keys. Synchronous configures precede the user module, and a + -- read-triggered registration precedes the recording — both make this a + -- natural no-op. for _, entry in ipairs(user_lsp_configs[name] or {}) do if entry.replace then vim.lsp.config[name] = entry.cfg @@ -306,6 +344,31 @@ M.setup = function() vim.lsp.enable(name) end + -- Read-triggered registration (transactional). Any spec form is covered by + -- simply running the real registration: the function-form contract is + -- "only registers via vim.lsp.config()" (see clangd.lua), so running it + -- early is registration, not behavior. A failing registration is rolled + -- back so the read never leaks a partial config; `registered` stays false + -- and the resolver's configure() path reports the raise as usual. + registration_trigger = function(real, name) + if not deps_set[name] or registered[name] then + return + end + -- Core keeps stored registrations in `vim.lsp.config._configs`; without + -- it (API drift) the trigger degrades to the status-quo read — no + -- half-transaction. + local store = rawget(real, "_configs") + if type(store) ~= "table" then + return + end + local before = vim.deepcopy(store[name]) + if pcall(mason_lsp_handler, name) then + registered[name] = true + else + store[name] = before + end + end + tools.resolve({ title = "LSP", deps = settings.lsp_deps, From cfeefa4a8a36df56f322fcb6f9182626b97e335c Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 14:59:27 +0800 Subject: [PATCH 11/71] fix(lint): surface the real load error for a broken linter module the lint.linters __index swallows the first failed require and leaves luajit's loader sentinel behind, so the probe's retry only reported "loop or previous error loading module". clear package.loaded before the retry so it re-throws the original error with its own path:line --- lua/modules/configs/completion/nvim-lint.lua | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lua/modules/configs/completion/nvim-lint.lua b/lua/modules/configs/completion/nvim-lint.lua index 76257593..981ad40f 100644 --- a/lua/modules/configs/completion/nvim-lint.lua +++ b/lua/modules/configs/completion/nvim-lint.lua @@ -192,6 +192,11 @@ return function() if not tools.module_path(module) then return nil -- unknown linter name (typo / not registered) end + -- The swallowed require left the loader sentinel behind: retrying + -- as-is only yields "loop or previous error loading module". + -- Clear it so the retry re-throws the ORIGINAL error (the module + -- never finished loading, so no side effects run twice). + package.loaded[module] = nil local ok, err = pcall(require, module) if not ok then return { broken = tostring(err) } From 8a911df7b1e76e0591ad86877304ae87fe850cda Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 14:59:27 +0800 Subject: [PATCH 12/71] fix(tooling): keep concrete failure reasons out of the dedup sink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the collector's seen-dedup silently discarded any failure arriving after a name was settled with a placeholder reason, and a validates config-shape error still triggered a mason install that cannot fix it. placeholder reasons (generic install timeout, registry-refresh note, reason-less marks) are now provisional: a later concrete failure upgrades the line and re-notifies, and a late unknown classification migrates the name from the missing to the unknown bucket. the chained deadline timer is replaced by one defer_fn per tracked install (flush gate reduces to pending > 0), and raise_verbatim carries a private brand so only real config-layer sentinels suppress the install fallback — a config throwing its own { reason = ... } table still installs --- lua/modules/utils/tools.lua | 194 +++++++++++++++++++++--------------- 1 file changed, 116 insertions(+), 78 deletions(-) diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index 063b2aca..09761de2 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -196,19 +196,28 @@ local function error_reason(err) return (err:gsub("^[^\n]-:%d+: ", "")) end +-- Private brand for raise_verbatim errors: only sentinels carrying this key +-- count as config-layer failures — a config that happens to throw its own +-- `{ reason = ... }` table stays an ordinary error. +local VERBATIM = {} + ---Raise a config failure whose message must reach the warning verbatim: it may ----itself start with a "path:line:" that position stripping would eat. +---itself start with a "path:line:" that position stripping would eat. The +---resolver also treats this sentinel as a config-layer error: a "validates" +---local config raising it is reported immediately, never answered with an +---install (an install can't fix a broken config). ---@param reason string function M.raise_verbatim(reason) -- selene: allow(incorrect_standard_library_use) -- error() does accept any value - error({ reason = reason }) + error({ reason = reason, [VERBATIM] = true }) end ---Aggregate tools that could not be set up into one deferred warning, in two ---sections: `mark` (install it / config failed) and `mark_unknown` ---(unrecognized name — fix the config, don't install). ---@class ToolCollector ----@field mark fun(name: string, reason?: string) @Record an unresolved tool (optionally with a reason). +---@field mark fun(name: string, reason?: string, provisional?: boolean) @Record an unresolved tool; +--- a provisional reason is a placeholder a later concrete failure may replace. ---@field mark_unknown fun(name: string) @Record an unrecognized name (typo / outdated / unsupported). ---@field track fun(pkg: table, name: string, recheck: fun(): boolean, on_ready?: fun(), fail_reason?: string) ---@field done fun() @Flush the aggregated warning once all tracked installs settle. @@ -223,14 +232,18 @@ local function missing_collector(title, timeout_ms) local seen = {} local emitted = {} -- Tracked installs whose "closed" callback hasn't run yet - -- (name -> { reason = phase-1 fail_reason or false, expires_at = uv ms }); - -- the chained deadline settles each entry once its own window elapses. + -- (name -> { reason = phase-1 fail_reason or false }); each entry owns a + -- defer_fn timer that settles it if the closed callback never does. local unsettled = {} local pending = 0 - local deadline_started = false - local deadline_passed = false local flush_scheduled = false local announce_scheduled = false + -- Names whose recorded reason is a placeholder (generic install-timeout / + -- registry-refresh note): a later concrete failure may upgrade the reason + -- or move the name to the unknown bucket. + local provisional = {} + -- Forward declaration: add/add_unknown re-flush after an upgrade/migration. + local flush -- Dedup across both buckets; non-string names are tostring()'d, nil/empty dropped. local function normalize(name) @@ -247,14 +260,56 @@ local function missing_collector(title, timeout_ms) bucket[#bucket + 1] = name return true end - local function add(name, reason) + local function add(name, reason, is_provisional) name = normalize(name) - if record(missing, name) and type(reason) == "string" and reason ~= "" then + if record(missing, name) then + if type(reason) == "string" and reason ~= "" then + reasons[name] = reason + end + if is_provisional then + provisional[name] = true + end + return + end + -- Already recorded: a concrete reason may replace a placeholder one + -- (generic timeout/refresh note, or a reason-less mark) and re-notify. + -- The first REAL reason wins; later ones never overwrite it. + if + name ~= nil + and type(reason) == "string" + and reason ~= "" + and reason ~= reasons[name] + and (reasons[name] == nil or provisional[name]) + then reasons[name] = reason + provisional[name] = is_provisional or nil + emitted[name] = nil + flush() end end local function add_unknown(name) - record(unknown, normalize(name)) + name = normalize(name) + if record(unknown, name) then + return + end + -- Bucket migration: a name parked in `missing` under a placeholder + -- reason (registry-refresh timeout) may classify as unknown once the + -- late refresh completes — move it so a typo doesn't keep stale + -- install guidance. Entries with a real reason never migrate. + if name == nil or not (provisional[name] or reasons[name] == nil) then + return + end + for index, existing in ipairs(missing) do + if existing == name then + table.remove(missing, index) + reasons[name] = nil + provisional[name] = nil + unknown[#unknown + 1] = name + emitted[name] = nil + flush() + return + end + end end local function render(name) @@ -263,10 +318,10 @@ local function missing_collector(title, timeout_ms) end -- Emit names not yet notified, coalesced per event-loop tick. Gated while - -- installs are pending unless forced (done() forces: its records are final) - -- or the deadline passed; `emitted` lets late failures still notify. - local function flush(force) - if not force and pending > 0 and not deadline_passed then + -- installs are pending unless forced (done() and per-install timers force: + -- their records are final); `emitted` lets late failures still notify. + function flush(force) + if not force and pending > 0 then return end if flush_scheduled then @@ -315,51 +370,6 @@ local function missing_collector(title, timeout_ms) end) end - -- One timer, chained: each fire settles only the entries whose OWN window - -- (expires_at) elapsed, then re-arms for the earliest remaining expiry — a - -- late-tracked install keeps its full timeout. `deadline_started` stays - -- true across the whole chain. - local function arm_deadline(delay) - deadline_started = true - -- A live timer backs the coalescing gate and guarantees the deferred flush. - deadline_passed = false - vim.defer_fn(function() - vim.uv.update_time() - local now = vim.uv.now() - -- Settle expired installs here: their "closed" callback may never run, - -- and it is the only other place that records and decrements `pending`. - -- A late settle stays silent (`seen` dedups) but still configures. - -- Snapshot the keys first so `unsettled` isn't mutated mid-pairs. - local next_expiry = nil - for _, hung in ipairs(vim.tbl_keys(unsettled)) do - local entry = unsettled[hung] - if entry.expires_at <= now then - add( - hung, - type(entry.reason) == "string" and entry.reason - or "Mason install did not finish within the timeout (check :Mason for progress)" - ) - pending = pending - 1 - unsettled[hung] = nil - elseif next_expiry == nil or entry.expires_at < next_expiry then - next_expiry = entry.expires_at - end - end - if next_expiry ~= nil and next_expiry ~= math.huge then - -- Report the expired batch now (its marks are final, so forcing past - -- the pending gate is sound); chain to the earliest remaining expiry. - flush(true) - arm_deadline(math.max(next_expiry - now, 50)) - else - deadline_passed = true - flush() - -- Between chains no timer is live, so the gate must stay open or a - -- late-recorded name would never flush; the next track re-arms. - deadline_started = false - end - end, delay) - end - return { mark = add, mark_unknown = add_unknown, @@ -414,20 +424,33 @@ local function missing_collector(title, timeout_ms) if first_track then pending = pending + 1 if name ~= nil then - vim.uv.update_time() - unsettled[name] = { - reason = fail_reason or false, - -- math.huge when no deadline is configured: never expires. - expires_at = vim.uv.now() - + ((type(timeout_ms) == "number" and timeout_ms > 0) and timeout_ms or math.huge), - } + unsettled[name] = { reason = fail_reason or false } + -- One timer per tracked install, settling only its own entry + -- (a no-op when the closed callback got there first), so a + -- late-tracked install always keeps its full window. Without + -- a configured timeout the entry settles via "closed" only. + if type(timeout_ms) == "number" and timeout_ms > 0 then + vim.defer_fn(function() + local entry = unsettled[name] + if entry == nil then + return + end + unsettled[name] = nil + -- The generic note is a placeholder a later concrete + -- failure may upgrade (see add()). + add( + name, + type(entry.reason) == "string" and entry.reason + or "Mason install did not finish within the timeout (check :Mason for progress)", + entry.reason == false + ) + pending = pending - 1 + -- This entry is final: force past the pending gate. + flush(true) + end, timeout_ms) + end end end - -- Arm when no timer is live; installs tracked during a live chain are - -- reached by its re-arms. - if not deadline_started and type(timeout_ms) == "number" and timeout_ms > 0 then - arm_deadline(timeout_ms) - end -- "closed" fires in a fast event context: hop to the main loop. recheck/ -- on_ready are pcall'd so a throw can't suppress flush; skip the decrement -- when the deadline already settled this install, but still run on_ready. @@ -809,6 +832,9 @@ function M.resolve(spec) } ---Configure one tool; false plus the cleaned error when the config threw. + ---The third result flags a BRANDED raise_verbatim sentinel — a config-layer + ---error an install can't fix. Any other thrown value (including a config's + ---own `{ reason = ... }` table) stays an ordinary failure. local function try_configure(name) if type(spec.configure) ~= "function" then return true @@ -817,7 +843,7 @@ function M.resolve(spec) if ok then return true end - return false, error_reason(err) + return false, error_reason(err), type(err) == "table" and err[VERBATIM] == true end -- Configure one tool, surfacing a config-time error in the aggregated @@ -841,8 +867,10 @@ function M.resolve(spec) session.configure = do_configure -- Phase-1 "validates" failures, surfaced by phase 2 without re-running the - -- config; `false` = failed with no message. + -- config; `false` = failed with no message. Names whose failure was a + -- raise_verbatim config error are flagged separately: installs can't fix those. local validate_failed = {} + local validate_config_error = {} ---String reason or nil — the one decoder of the false-vs-string encoding. ---@param name string ---@return string|nil @@ -880,11 +908,14 @@ function M.resolve(spec) -- A "validates" config is its own resolver: let it try; remember a -- failure for phase 2 instead of re-running it there. if spec.local_config_mode == "validates" and spec.has_local_config and spec.has_local_config(name) then - local ok, reason = try_configure(name) + local ok, reason, config_error = try_configure(name) if ok then return true end validate_failed[name] = reason or false + if config_error then + validate_config_error[name] = true + end end return false end @@ -914,9 +945,11 @@ function M.resolve(spec) end end - -- The config already failed this pass; only an install changes the outcome. + -- The config already failed this pass. A raise_verbatim failure is a + -- config-layer error an install can't fix: report it immediately. Only + -- a provisioning failure (missing binary) is answered with an install. if validate_failed[name] ~= nil then - if pkg ~= nil and not pkg:is_installed() then + if not validate_config_error[name] and pkg ~= nil and not pkg:is_installed() then start_install(pkg, name) -- annotates the failure reason itself else collector.mark(name, validate_reason(name)) @@ -1033,8 +1066,13 @@ function M.resolve(spec) end for _, name in ipairs(unresolved) do local reason = validate_reason(name) - or "Mason registry refresh did not complete (cannot classify or auto-install)" - collector.mark(name, reason) + -- The generic refresh note is a placeholder: a later concrete + -- failure (or a late unknown classification) may replace it. + collector.mark( + name, + reason or "Mason registry refresh did not complete (cannot classify or auto-install)", + reason == nil + ) end collector.done() end, timeout_ms) From c2fa84b05fd238a3d8445d8938849e08d90d17f8 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 16:03:46 +0800 Subject: [PATCH 13/71] fix(lsp): consult vim.lsp.config as the server discovery truth source server_info hand-mirrored the lsp/*.lua rtp resolution and its elseif kept the real vim.lsp.config fallback from running, so a legitimately registered server (settings-only rtp file plus runtime cmd) landed in the typo bucket. the mirror is deleted in favor of reading the resolved config directly, the discovery pass moves behind user.configs.lsp (setup -> overrides -> resolve_deps) so user runtime registrations are visible to classification, unknown_of re-consults the truth source before a typo verdict, cache invalidation covers user ops and both registration points with a registered/user-cmd binary authority rule, a registered or user function cmd counts as self-resolving (never classified against a mason package), and user overrides are applied once per session (a rerun warns to restart instead of rebuilding on top of written-through state) --- lua/modules/configs/completion/lsp.lua | 9 +- .../configs/completion/mason-lspconfig.lua | 240 ++++++++++++------ 2 files changed, 163 insertions(+), 86 deletions(-) diff --git a/lua/modules/configs/completion/lsp.lua b/lua/modules/configs/completion/lsp.lua index d2f37107..bf0fc1b6 100644 --- a/lua/modules/configs/completion/lsp.lua +++ b/lua/modules/configs/completion/lsp.lua @@ -1,6 +1,6 @@ return function() - -- Server resolution (Mason-installed / on $PATH / installable / missing) is handled - -- discovery-first in `mason-lspconfig.setup`, driven by `settings.lsp_deps`. + -- Handler/probe machinery first (no discovery yet): sets up the read + -- trigger the override pass below relies on. require("completion.mason-lspconfig").setup() -- Run `user.configs.lsp` with its vim.lsp.config registrations recorded: a @@ -9,6 +9,11 @@ return function() -- `user.configs.lsp-servers.` remains the richer per-server hook. require("completion.mason-lspconfig").run_user_lsp_overrides() + -- Discovery LAST (Mason-installed / on $PATH / installable / missing, + -- driven by `settings.lsp_deps`): user runtime registrations above must be + -- visible to the unknown/binary classification. + require("completion.mason-lspconfig").resolve_deps() + -- Start LSPs pcall(vim.cmd.LspStart) end diff --git a/lua/modules/configs/completion/mason-lspconfig.lua b/lua/modules/configs/completion/mason-lspconfig.lua index 4ab8c988..a24aabfe 100644 --- a/lua/modules/configs/completion/mason-lspconfig.lua +++ b/lua/modules/configs/completion/mason-lspconfig.lua @@ -10,6 +10,16 @@ local user_lsp_configs = {} -- `user.configs.lsp` read of a not-yet-registered lsp_deps server sees the -- post-registration view regardless of install timing. local registration_trigger = nil +-- Bridge set by setup(): drops a server's cached probe when a user op or a +-- registration changes what the truth source resolves for it. +local server_info_invalidate = nil +-- Bridge set by setup(): the discovery pass, run AFTER `user.configs.lsp` so +-- its runtime registrations are visible to the unknown/binary classification. +local resolve_deps = nil +-- User overrides run once per session: lsp.lua's config function is the only +-- caller and lazy.nvim runs it once; a rerun could not undo the first pass's +-- write-through ops anyway, so a second call is refused loudly instead. +local overrides_ran = false ---Run `user.configs.lsp` with vim.lsp.config proxied to record per-server ---registrations. Reads forward to the real table — after triggering the dep's @@ -17,7 +27,26 @@ local registration_trigger = nil ---base whether or not the server was installed yet. "*" is not recorded (core ---merges it at read time); the real table is always restored right after the ---pcall'd require (same silent-if-missing behavior as before). +--- +---Supported surface for the user module: per-name operations only — +---`vim.lsp.config[name]` reads, `vim.lsp.config(name, cfg)` calls and +---`vim.lsp.config[name] = cfg` assignments (record/replay and the read +---trigger exist for exactly these). Enumeration (pairs over the proxy, +---core-private `_configs`) is out of contract: its visible set depends on +---registration order by nature. +--- +---Once per session: the ops write through to the real table, so a rerun +---could not rebuild from a clean slate; changes need a restart. function M.run_user_lsp_overrides() + if overrides_ran then + vim.notify( + "user LSP overrides are applied once per session; restart Neovim to apply changes", + vim.log.levels.WARN, + { title = "nvim-lspconfig" } + ) + return + end + overrides_ran = true local real = vim.lsp.config local proxy proxy = setmetatable({}, { @@ -38,6 +67,11 @@ function M.run_user_lsp_overrides() if type(key) == "string" and key ~= "*" then -- Assignment replaces the whole config: supersede earlier recordings. user_lsp_configs[key] = { { replace = true, cfg = value } } + -- The op may have changed what the truth source resolves (cmd + -- included): drop the cached probe. + if server_info_invalidate then + server_info_invalidate(key) + end end end, __call = function(_, name, cfg) @@ -46,6 +80,9 @@ function M.run_user_lsp_overrides() local list = user_lsp_configs[name] or {} list[#list + 1] = { cfg = cfg } user_lsp_configs[name] = list + if server_info_invalidate then + server_info_invalidate(name) + end end end, }) @@ -54,6 +91,16 @@ function M.run_user_lsp_overrides() vim.lsp.config = real end +---The discovery pass, split out of setup(): lsp.lua runs it AFTER +---`user.configs.lsp`, so runtime registrations from the user module exist +---before the unknown/binary classification judges the deps. No-op until +---setup() has installed the pass. +function M.resolve_deps() + if resolve_deps then + resolve_deps() + end +end + M.setup = function() local settings = require("core.settings") -- Mason is an optional installer backend: guard its requires so a Mason-less @@ -81,30 +128,6 @@ M.setup = function() return { "user.configs.lsp-servers." .. name, "completion.servers." .. name } end - -- name -> lsp/.lua paths in rtp order, built by ONE glob: reading - -- vim.lsp.config[name] for a not-yet-enabled server rescans the rtp per name. - local lsp_runtime_files = nil - ---@param name string - ---@return string[]|nil - local function lsp_files_of(name) - if lsp_runtime_files == nil then - lsp_runtime_files = {} - for _, path in ipairs(vim.api.nvim_get_runtime_file("lsp/*.lua", true)) do - -- Single segment only: a nested lsp//.lua is not a server. - local server = path:match("[/\\]lsp[/\\]([^/\\]+)%.lua$") - if server then - local files = lsp_runtime_files[server] - if not files then - files = {} - lsp_runtime_files[server] = files - end - files[#files + 1] = path - end - end - end - return lsp_runtime_files[name] - end - vim.diagnostic.config({ signs = true, underline = true, @@ -123,6 +146,7 @@ M.setup = function() ---@field has_module boolean ---@field binary string|nil ---@field known_lspconfig boolean + ---@field self_resolving boolean|nil @Resolved cmd is a function owned by the config: no Mason classification. ---@field user_loaded boolean ---@field user_spec any ---@field default_loaded boolean @@ -188,50 +212,60 @@ M.setup = function() end end end - -- nvim-lspconfig's built-in config: a table cmd yields the launch binary; - -- any cmd (even a function) proves the name real (keeps jsonls out of the - -- unknown bucket). Read only when it can still change the outcome. - if info.binary == nil or not info.has_module then - -- Mirror vim.lsp.config's file resolution (later rtp files win) from - -- the pre-globbed map instead of its per-name rescan. `*` defaults and - -- pure-runtime registrations are not merged — this only feeds the - -- binary probe; registration still reads vim.lsp.config[name]. - local files = lsp_files_of(name) - if files then - local merged = {} - for _, path in ipairs(files) do - local ok, chunk = pcall(loadfile, path) - if ok and chunk then - local ok_run, config = pcall(chunk) - if ok_run and type(config) == "table" then - merged = vim.tbl_deep_extend("force", merged, config) - end - end - end - if merged.cmd ~= nil then - info.known_lspconfig = true - if info.binary == nil and type(merged.cmd) == "table" then - info.binary = merged.cmd[1] - end + ---The truth source: vim.lsp.config[name] resolves '*', rtp lsp/.lua + ---files and stored registrations exactly the way enable() will consume + ---them; nil = no name-specific source at all (a pure '*' config cannot + ---make a name known). The per-name rtp rescan only happens for names + ---this cache hasn't answered yet — bounded by lsp_deps. + local function resolved_config() + local ok, config = pcall(function() + return vim.lsp.config[name] + end) + return (ok and type(config) == "table") and config or nil + end + ---Whether the recorded user ops explicitly set a cmd: the replay + ---guarantees that cmd is the enable-time winner. + local user_sets_cmd = false + for _, entry in ipairs(user_lsp_configs[name] or {}) do + if type(entry.cfg) == "table" and entry.cfg.cmd ~= nil then + user_sets_cmd = true + end + end + if registered[name] or user_sets_cmd then + -- Registered (or user-overridden cmd): the resolved config IS what + -- enable() will spawn — it outranks the module-derived binary. A + -- non-table cmd (function) means the config owns its own launch: + -- flag it so Mason never classifies/installs against it. + local config = resolved_config() + if config and config.cmd ~= nil then + info.known_lspconfig = true + if type(config.cmd) == "table" then + info.binary = config.cmd[1] + else + info.binary = nil + info.self_resolving = true end - elseif not info.has_module then - -- Last chance before the typo bucket: a pure-runtime registration is - -- looked up only for names nothing else proved real, so the per-name - -- rescan stays normally zero. - 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 + elseif info.binary == nil or not info.has_module then + -- Pre-registration the module spec is the better predictor of the + -- upcoming stored registration (an lspconfig rtp default must not + -- shadow a module's custom path); consult the truth source only for + -- what the modules couldn't answer. Any cmd (even a function) + -- proves the name real (keeps jsonls out of the unknown bucket). + local config = resolved_config() + if config 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 end server_info_cache[name] = info return info end + server_info_invalidate = function(name) + server_info_cache[name] = nil + end ---Register (not enable) a server's config, reusing the spec server_info() ---loaded; raises on a broken/misshapen spec so the resolver aggregates the @@ -293,6 +327,11 @@ M.setup = function() if not mason_ok then return nil end + -- A registered/user function cmd owns its own launch: never classify + -- it against a Mason package (no install fallback for it). + if server_info(name).self_resolving then + return nil + end if lspconfig_to_package == nil or next(lspconfig_to_package) == nil then local mappings = mason_lspconfig.get_mappings() lspconfig_to_package = (mappings and mappings.lspconfig_to_package) or {} @@ -318,7 +357,23 @@ M.setup = function() return false end local info = server_info(name) - return not info.has_module and not info.known_lspconfig + if info.has_module or info.known_lspconfig then + return false + end + -- A runtime registration may have landed after the probe cached its + -- negative (the user read the name first, then wrote a cmd): re-consult + -- the truth source before the typo verdict, upgrading the cache in place. + local 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 + return false + end + return true end ---Register a server (unless a read trigger already did), then enable it (a @@ -327,6 +382,9 @@ M.setup = function() if not registered[name] then mason_lsp_handler(name) registered[name] = true + -- The probe cached pre-registration state: rebuild so later readers + -- (group-1 retry/event matchers) see the resolved cmd. + server_info_cache[name] = nil end -- Replay recorded `user.configs.lsp` registrations on top: a post-install -- configure may register AFTER that module ran (write-only overrides get @@ -364,33 +422,47 @@ M.setup = function() local before = vim.deepcopy(store[name]) if pcall(mason_lsp_handler, name) then registered[name] = true + -- The handler's own server_info ran pre-registration (a function-form + -- spec registers its cmd mid-trigger, bypassing the proxy hooks): + -- rebuild so the probe reflects the resolved, registered cmd. + server_info_cache[name] = nil else store[name] = before end end - tools.resolve({ - title = "LSP", - deps = settings.lsp_deps, - -- A value, not a thunk: mason-registry was already required at setup top. - 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 (jsonls): probe the package's declared bins so a - -- system copy is found instead of installing a duplicate. - if pkg ~= nil then - return tools.package_binaries(pkg, name) - end - return {} - end, - unknown_of = unknown_of, - has_local_config = has_local_config, - configure = configure, - }) + -- The discovery pass itself runs via M.resolve_deps() AFTER + -- run_user_lsp_overrides() (see lsp.lua): user runtime registrations must + -- exist before the unknown/binary classification. + resolve_deps = function() + tools.resolve({ + title = "LSP", + deps = settings.lsp_deps, + -- A value, not a thunk: mason-registry was already required at setup top. + registry = mason_ok and mason_registry or nil, + package_of = package_of, + binaries_of = function(name, pkg) + local info = server_info(name) + if info.binary then + return { info.binary } + end + -- A registered/user function cmd resolves its own launch: probing + -- Mason bins would misread it as installable/missing. + if info.self_resolving then + return {} + end + -- Function-cmd MODULE server (jsonls): probe the package's declared + -- bins so a system copy is found instead of installing a duplicate. + if pkg ~= nil then + return tools.package_binaries(pkg, name) + end + return {} + end, + unknown_of = unknown_of, + has_local_config = has_local_config, + configure = configure, + }) + end end return M From b74e53d8e4dd583ad648e7becd8c0ea8b2cece25 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 16:40:24 +0800 Subject: [PATCH 14/71] fix(tooling): report invalid dep entries and unresolvable tools honestly run() now normalizes spec.deps through normalize_names for every consumer and surfaces the dropped entries (non-string / empty) in the unknown bucket instead of letting them vanish (nvim-lint) or flow into module-name concatenation as garbage (lsp/dap/conform); nvim-lint forwards its raw invalid entries so all four subsystems report one mistake identically. a new probe state { unresolved = true } and spec predicate unresolvable_of short-circuit names that are proven real but unverifiable at probe time: marked missing with a tailored reason in phase 1 and flushed immediately, never classified against the registry (a stalled refresh cannot delay or mislabel them) --- lua/modules/configs/completion/nvim-lint.lua | 14 +++- lua/modules/utils/tools.lua | 74 +++++++++++++++----- 2 files changed, 71 insertions(+), 17 deletions(-) diff --git a/lua/modules/configs/completion/nvim-lint.lua b/lua/modules/configs/completion/nvim-lint.lua index 981ad40f..62660586 100644 --- a/lua/modules/configs/completion/nvim-lint.lua +++ b/lua/modules/configs/completion/nvim-lint.lua @@ -257,7 +257,8 @@ return function() -- 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 = tools.normalize_names(require("core.settings").linter_deps) + local raw_deps = require("core.settings").linter_deps + local deps = tools.normalize_names(raw_deps) local mapped = {} for _, names in pairs(by_ft) do for _, name in ipairs(names) do @@ -272,6 +273,17 @@ return function() immediate[#immediate + 1] = name end end + -- Entries normalize_names drops (non-string / empty) are config mistakes: + -- forward them raw so the resolver's own sweep reports them in the unknown + -- bucket, identically to the other consumers. + if type(raw_deps) == "table" then + for i = 1, table.maxn(raw_deps) do + local entry = raw_deps[i] + if entry ~= nil and (type(entry) ~= "string" or entry == "") then + immediate[#immediate + 1] = entry + end + end + end if #immediate > 0 then vim.schedule(function() resolve_batch(immediate) diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index 09761de2..8ae2be5a 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -789,9 +789,14 @@ end --- unknown_of?: fun(name: string): boolean, --- has_local_config?: fun(name: string): boolean, --- local_config_mode?: "resolves"|"validates", +--- unresolvable_of?: fun(name: string): string|nil, --- configure?: fun(name: string, late: boolean), --- defer_phase2?: boolean, ---} +---`unresolvable_of` (optional): a non-nil reason short-circuits the name in +---phase 1 — marked missing with that reason and flushed immediately, never +---classified against the registry (the one exception to "phase 1 never marks +---missing"). function M.resolve(spec) local ok_settings, settings = pcall(require, "core.settings") local timeout_ms = ( @@ -992,29 +997,55 @@ function M.resolve(spec) local function run() -- Make Mason's bin dir resolvable before any probe or spawn (see file header). M.ensure_mason_on_path() - -- A non-table deps (user override gone wrong) degrades to "nothing to resolve". - if type(spec.deps) ~= "table" then - collector.done() - return + -- ONE normalization policy for every consumer (see normalize_names): + -- non-table deps degrade to nothing to resolve, and dropped entries + -- (non-string / empty — config mistakes) surface in the unknown bucket + -- instead of vanishing or flowing into module-name concatenation. + local deps = normalize_names(spec.deps) + if type(spec.deps) == "table" then + for i = 1, table.maxn(spec.deps) do + local entry = spec.deps[i] + if entry ~= nil and (type(entry) ~= "string" or entry == "") then + collector.mark_unknown(entry == "" and '""' or entry) + end + end 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] + local finalized = false + for _, name in ipairs(deps) do -- Dedup (a duplicate would double-install); pcall isolates each dep. - if name ~= nil and not visited[name] then + if not visited[name] then visited[name] = true - local ok, handled = pcall(configure_available, name) - if not ok then - collector.mark(name, error_reason(handled)) - elseif handled ~= true then - unresolved[#unresolved + 1] = name - session.pending[name] = true + -- A name the spec knows it can never resolve (e.g. a conform + -- function-form override that yields nothing at probe time) is + -- final here: tailored reason, no phase 2, no registry. + local unresolvable = nil + if spec.unresolvable_of then + local ok, reason = pcall(spec.unresolvable_of, name) + unresolvable = (ok and type(reason) == "string") and reason or nil + end + if unresolvable then + collector.mark(name, unresolvable) + finalized = true + else + local ok, handled = pcall(configure_available, name) + if not ok then + collector.mark(name, error_reason(handled)) + elseif handled ~= true then + unresolved[#unresolved + 1] = name + session.pending[name] = true + end end end end + -- Final phase-1 marks must not wait behind installs or a stalled + -- registry refresh elsewhere in the batch: flush them now (`emitted` + -- dedups the later done()). + if finalized then + collector.done() + end -- Nothing left to install: Mason stays unloaded. if #unresolved == 0 then @@ -1107,6 +1138,10 @@ end --- * nil -> unknown name (typo) -> fix config. --- * { binary = "x" } -> the tool invokes executable "x". --- * { binary = nil } -> the tool resolves its own command at runtime. +--- * { unresolved = true } -> the name is real but its command can't be +--- verified at probe time (e.g. a per-buffer +--- function override): reported missing with a +--- tailored reason — never a typo, never an install. --- * { broken = "reason" } -> config exists but errors: surfaced with the --- reason — never typo guidance, never an install. ---Mason is only the lazy install fallback, reverse-looked-up from the binary. @@ -1126,6 +1161,7 @@ function M.resolve_runtime_tools(title, deps, probe, configure) known = true, binary = type(result.binary) == "string" and result.binary or nil, broken = type(result.broken) == "string" and result.broken or nil, + unresolved = result.unresolved == true, } else -- A probe error is treated as unknown: either way, fix the config entry. @@ -1158,9 +1194,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 so configure below surfaces its reason. + -- A broken config counts as local so configure below surfaces its + -- reason; an unresolved one does NOT (nothing verifiable to trust). local i = info(name) - return i.known and i.binary == nil + return i.known and i.binary == nil and not i.unresolved + end, + unresolvable_of = function(name) + if info(name).unresolved then + return "config resolves per buffer and could not be verified at startup" + end end, -- A binary-less runtime tool can't map to a package, so it self-resolves. local_config_mode = "resolves", From a75823abd1eaf7a5a1323a27639288dbdd46a120 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 16:40:24 +0800 Subject: [PATCH 15/71] fix(conform): stop reporting buffer-conditional overrides as typos a function-form formatters[name] override is evaluated against whichever buffer is current on the scheduled resolve tick, so a legitimate nil return landed the name in the typo bucket nondeterministically. the override's existence now proves the name real: the probe reports it unresolved (missing bucket, tailored reason) instead of a typo or a silent pass --- lua/modules/configs/completion/conform.lua | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lua/modules/configs/completion/conform.lua b/lua/modules/configs/completion/conform.lua index 5ce36aec..74a35190 100644 --- a/lua/modules/configs/completion/conform.lua +++ b/lua/modules/configs/completion/conform.lua @@ -210,6 +210,15 @@ return function() if type(err) == "string" then return { broken = err } end + -- A function-form override may legitimately return nil for the + -- probe-time buffer (this probe runs on a scheduled tick against + -- whatever buffer happens to be current): its existence proves the + -- name real, but nothing is verifiable — report it unresolved + -- (missing bucket, tailored reason) instead of a typo or a silent pass. + local overrides = conform.formatters + if type(overrides) == "table" and type(overrides[name]) == "function" then + return { unresolved = true } + end return nil end) end) From d67d14eb3a7f19e454538d33e64d1dfb68f9a4ba Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 16:40:24 +0800 Subject: [PATCH 16/71] fix(lsp): scope the traefik schema prune to the claimed files the cleanup stripped fileMatch globs from any schema whose glob merely contained the substring traefik, which would silently drop unrelated schemas (.traefik.yml plugin manifests, traefik-dynamic.*) as the catalog evolves. the conflict set now derives from the v3 extra's own fileMatch via brace expansion and compares glob basenames, so only claims on the exact static-config files are removed --- .../configs/completion/servers/yamlls.lua | 63 ++++++++++++++++--- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/lua/modules/configs/completion/servers/yamlls.lua b/lua/modules/configs/completion/servers/yamlls.lua index 4625fbdc..a3affa0c 100644 --- a/lua/modules/configs/completion/servers/yamlls.lua +++ b/lua/modules/configs/completion/servers/yamlls.lua @@ -1,7 +1,8 @@ -- https://github.com/neovim/nvim-lspconfig/blob/master/lsp/yamlls.lua --- Our own Traefik v3 schema URL, referenced by the extra below and by the --- traefik-claim cleanup after it. +-- Our own Traefik v3 schema URL and claimed files, referenced by the extra +-- below and by the traefik-claim cleanup after it. local traefik_v3_url = "https://www.schemastore.org/traefik-v3.json" +local traefik_v3_files = { "traefik.{yml,yaml}" } local schemas = require("schemastore").yaml.schemas({ extra = { { @@ -21,26 +22,70 @@ local schemas = require("schemastore").yaml.schemas({ { name = "Traefik v3", description = "Traefik v3 static configuration", - fileMatch = { "traefik.{yml,yaml}" }, + fileMatch = traefik_v3_files, url = traefik_v3_url, }, }, }) --- traefik.{yml,yaml} belong to our Traefik v3 extra alone: strip traefik globs --- from every OTHER schema (the catalog's v2 entry claims the same files). --- Matched on the file pattern, not v2's name/URL, so a catalog rename can't let --- v2 re-claim them. Keys snapshotted so `schemas` isn't mutated mid-pairs. + +---Expand one `{a,b,...}` alternation segment — the only brace form these +---globs use; a brace-less glob expands to itself. +---@param glob string +---@return string[] +local function expand_braces(glob) + local head, alts, tail = glob:match("^(.-){([^{}]+)}(.-)$") + if not head then + return { glob } + end + local out = {} + for alt in (alts .. ","):gmatch("([^,]*),") do + out[#out + 1] = head .. alt .. tail + end + return out +end + +-- The files our v3 extra claims, expanded: the ONLY globs other schemas may +-- not keep. Derived from the extra itself (no mirror of catalog naming). +local claimed = {} +for _, glob in ipairs(traefik_v3_files) do + for _, expanded in ipairs(expand_braces(glob)) do + claimed[expanded] = true + end +end +---A glob conflicts only when its basename expands to a claimed file: v2's +---`traefik.yml`/`traefik.yaml` (and `**/`-prefixed forms) do; merely +---traefik-NAMED globs (.traefik.yml plugin manifests, traefik-dynamic.*) +---do not and keep their schemas. +---@param glob any +---@return boolean +local function claims_same_files(glob) + if type(glob) ~= "string" then + return false + end + local base = glob:match("([^/\\]+)$") or glob + for _, expanded in ipairs(expand_braces(base)) do + if claimed[expanded] then + return true + end + end + return false +end + +-- traefik.{yml,yaml} belong to our Traefik v3 extra alone: strip exactly those +-- claims from every OTHER schema (the catalog's v2 entry). Matched on the file +-- pattern, not v2's name/URL, so a catalog rename can't let v2 re-claim them. +-- Keys snapshotted so `schemas` isn't mutated mid-pairs. for _, url in ipairs(vim.tbl_keys(schemas)) do local fileMatch = schemas[url] if url ~= traefik_v3_url then if type(fileMatch) == "string" then - if fileMatch:find("traefik", 1, true) then + if claims_same_files(fileMatch) then schemas[url] = nil end elseif type(fileMatch) == "table" then local kept = {} for _, glob in ipairs(fileMatch) do - if not (type(glob) == "string" and glob:find("traefik", 1, true)) then + if not claims_same_files(glob) then kept[#kept + 1] = glob end end From 5e1c478aca222aa172619c69c18e639d40876ea7 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 16:40:24 +0800 Subject: [PATCH 17/71] fix(dap): survive partial mason-nvim-dap mapping drift the factory fallback guarded a nil adapter config but handed configurations/filetypes to default_setup unchecked, so a partially drifted mapping set (configurations present, filetypes missing) raised a raw ipairs(nil) error into the aggregated warning. both fields are normalized to empty tables first: the present half still registers, the missing half degrades silently, matching map_or_empty's policy --- lua/modules/configs/tool/dap/init.lua | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lua/modules/configs/tool/dap/init.lua b/lua/modules/configs/tool/dap/init.lua index cffbb198..8b95e1ae 100644 --- a/lua/modules/configs/tool/dap/init.lua +++ b/lua/modules/configs/tool/dap/init.lua @@ -114,6 +114,16 @@ return function() 0 ) end + -- Partial mappings drift can hand us configurations without + -- filetypes (or vice versa); default_setup ipairs() both + -- unconditionally. Degrade to whatever half is present instead of + -- a raw ipairs(nil) raise. + if type(config.configurations) ~= "table" then + config.configurations = {} + end + if type(config.filetypes) ~= "table" then + config.filetypes = {} + end mason_dap.default_setup(config) elseif type(custom_handler) == "function" then -- Case where the protocol requires its own setup From 252ebd57f758d95e2ae67b10e64c59e5e7741137 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 16:55:31 +0800 Subject: [PATCH 18/71] refactor(tooling): sink the no-fall-through config contract into tools the broken-config/wrong-shape raise policy lived twice (dap and lsp handlers, kept in sync by cross-referencing comments) and the lsp handler re-derived user-vs-default precedence that server_info had already computed. tools.usable_or_raise is now the one implementation of the contract (verbatim broken raise + labeled shape raise, wording unchanged), server_info exposes spec/merge_base so the handler is a plain shape switch, and load_first_usable drops its unreachable loaded-but-nil branch and nil_reason parameter (require never yields nil for a successful load). the dap client metadata redesign was evaluated and rejected in place --- .../configs/completion/mason-lspconfig.lua | 56 +++++++++---------- lua/modules/configs/tool/dap/init.lua | 39 ++++++------- lua/modules/utils/tools.lua | 42 ++++++++++---- 3 files changed, 76 insertions(+), 61 deletions(-) diff --git a/lua/modules/configs/completion/mason-lspconfig.lua b/lua/modules/configs/completion/mason-lspconfig.lua index a24aabfe..c7aaa9ba 100644 --- a/lua/modules/configs/completion/mason-lspconfig.lua +++ b/lua/modules/configs/completion/mason-lspconfig.lua @@ -151,6 +151,8 @@ M.setup = function() ---@field user_spec any ---@field default_loaded boolean ---@field default_spec any + ---@field spec any @Winning local spec (user precedence, else repo default). + ---@field merge_base table|nil @Repo default table under a user TABLE override (merge-under policy). ---@field broken_reason string|nil ---@param name string ---@return mason_lspconfig.ServerInfo @@ -212,6 +214,17 @@ M.setup = function() end end end + -- Precedence, decided ONCE here (the handler consumes these fields + -- instead of re-deriving it): user wins; a user TABLE override merges + -- over the repo default (merge_base), any other user shape replaces it. + if info.user_loaded then + info.spec = info.user_spec + if type(info.user_spec) == "table" and type(info.default_spec) == "table" then + info.merge_base = info.default_spec + end + elseif info.default_loaded then + info.spec = info.default_spec + end ---The truth source: vim.lsp.config[name] resolves '*', rtp lsp/.lua ---files and stored registrations exactly the way enable() will consume ---them; nil = no name-specific source at all (a pure '*' config cannot @@ -273,40 +286,25 @@ M.setup = function() ---@param lsp_name string local function mason_lsp_handler(lsp_name) local info = server_info(lsp_name) - -- A broken config must not fall through to a lower-precedence spec or - -- the factory config: that would read as success and suppress both the - -- warning and the install fallback. - if info.broken_reason then - tools.raise_verbatim(info.broken_reason) - end - 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 = info.default_loaded, info.default_spec - end - - if not ok then + -- No-fall-through contract, enforced by the ONE shared implementation + -- (tools.usable_or_raise, also used by mason_dap_handler): a broken or + -- wrong-shaped config must never read as success — that would suppress + -- both the warning and the install fallback. Precedence was decided by + -- server_info (info.spec / info.merge_base). + local spec = tools.usable_or_raise(info.spec, info.broken_reason, { + label = "server config", + expected = "a fun(opts) or a table", + shapes = { ["function"] = true, table = true }, + }) + if spec == nil then -- Default to use factory config for server(s) that doesn't include a spec vim.lsp.config(lsp_name, opts) - elseif type(custom_handler) == "function" then + elseif type(spec) == "function" then -- Server owns its setup; it must call vim.lsp.config() itself (see -- clangd.lua for an example). - custom_handler(opts) - elseif type(custom_handler) == "table" then - vim.lsp.config( - lsp_name, - vim.tbl_deep_extend( - "force", - opts, - type(default_handler) == "table" and default_handler or {}, - custom_handler - ) - ) + spec(opts) else - tools.raise_verbatim( - string.format("server config must return a fun(opts) or a table (got `%s`)", type(custom_handler)) - ) + vim.lsp.config(lsp_name, vim.tbl_deep_extend("force", opts, info.merge_base or {}, spec)) end end diff --git a/lua/modules/configs/tool/dap/init.lua b/lua/modules/configs/tool/dap/init.lua index 8b95e1ae..3e27fdbd 100644 --- a/lua/modules/configs/tool/dap/init.lua +++ b/lua/modules/configs/tool/dap/init.lua @@ -27,11 +27,7 @@ return function() local function load_client_config(name) local cached = client_config_cache[name] if not cached then - local value, broken_reason, any_exists = tools.load_first_usable( - client_modules(name), - "nvim-dap", - "client config `%s` returned no value (must return a function)" - ) + local value, broken_reason, any_exists = tools.load_first_usable(client_modules(name), "nvim-dap") cached = { value = value, broken_reason = broken_reason, any_exists = any_exists } client_config_cache[name] = cached end @@ -86,12 +82,15 @@ return function() local function mason_dap_handler(config) local dap_name = config.name local custom_handler, broken_reason = load_client_config(dap_name) - -- A broken config must not fall through to the repo preset or Mason's - -- factory setup: that would read as success and suppress both the warning - -- and the install fallback (same contract as mason_lsp_handler). - if broken_reason then - tools.raise_verbatim(broken_reason) - end + -- No-fall-through contract, enforced by the ONE shared implementation + -- (tools.usable_or_raise, also used by mason_lsp_handler): a broken or + -- wrong-shaped config must never read as success — that would suppress + -- both the warning and the install fallback. + custom_handler = tools.usable_or_raise(custom_handler, broken_reason, { + label = "client config", + expected = "a fun(opts)", + shapes = { ["function"] = true }, + }) if custom_handler == nil then -- No client config: fall back to Mason's factory config, erroring -- (level 0) so the resolver reports failures. @@ -125,19 +124,13 @@ return function() config.filetypes = {} end mason_dap.default_setup(config) - elseif type(custom_handler) == "function" then - -- Case where the protocol requires its own setup - -- Make sure to set + else + -- Function form (the only other shape usable_or_raise lets through): + -- the protocol owns its setup. Make sure to set -- * dap.adapters. = { your config } -- * dap.configurations. = { your config } -- See `codelldb.lua` for a concrete example. custom_handler(config) - else - -- Raise, don't notify-and-return: a normal return reads as success to - -- the resolver and suppresses the warning + install fallback. - tools.raise_verbatim( - string.format("client config must return a fun(opts) (got `%s`)", type(custom_handler)) - ) end end @@ -228,7 +221,11 @@ return function() -- The raise on a missing launch binary is the provisioning signal. -- -- CANONICAL availability contract for dap/clients/*.lua (referenced by the - -- one-line notes in each client; keep the two patterns in sync here): + -- one-line notes in each client; keep the two patterns in sync here). + -- Shape enforcement itself lives in tools.usable_or_raise; a + -- metadata-declared contract ({ attach_capable, binaries }) was + -- considered and rejected — it would churn the fun(opts) user-override + -- interface for four clients. Revisit if the client count grows. -- * validate FIRST (top of config): launch AND attach both spawn the local -- binary, so nothing is worth registering without it — codelldb, lldb. -- * raise LAST (bottom of config): attach needs no local binary, so the diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index 8ae2be5a..798e9c27 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -114,34 +114,54 @@ function M.load_module_or_report(module, title) return false, nil, true, reason end ----Load the first candidate module that returns a non-nil value (highest ----precedence first; no merge-base semantics — spec-merging consumers like the ----LSP server_info don't use this). A broken/nil-returning candidate keeps ----`any_exists` true and its reason is returned even alongside a ----lower-precedence success, so callers can refuse to fall past a broken override. +---Load the first candidate module that loads (highest precedence first; no +---merge-base semantics — spec-merging consumers like the LSP server_info +---don't use this). A broken candidate keeps `any_exists` true and its reason +---is returned even alongside a lower-precedence success, so callers can +---refuse to fall past a broken override. Shape checks are the caller's job +---(see `usable_or_raise`): require() never yields nil for a successful load — +---a module without a return statement loads as `true`. ---@param modules string[] @Module names, highest precedence first. ---@param title? string @Notification title for broken-module load errors. ----@param nil_reason? string @Format (%s = module name) for the loaded-but-returned-nil case. ----@return any value @First usable module value, or nil. ----@return string|nil broken_reason @Highest-precedence exists-but-unusable reason. +---@return any value @First loaded module value, or nil. +---@return string|nil broken_reason @Highest-precedence exists-but-broken reason. ---@return boolean any_exists @Whether any candidate exists on the search paths. -function M.load_first_usable(modules, title, nil_reason) +function M.load_first_usable(modules, title) local broken_reason, any_exists = nil, false for _, module in ipairs(modules) do local ok, value, exists, reason = M.load_module_or_report(module, title) - if ok and value ~= nil then + if ok then return value, broken_reason, true end if exists then any_exists = true if broken_reason == nil then - broken_reason = reason or string.format(nil_reason or "`%s` returned no value", module) + broken_reason = reason end end end return nil, broken_reason, any_exists end +---Enforce the no-fall-through contract for a locally loaded config: a broken +---candidate raises its reason verbatim (it must never read as success to the +---resolver — that would suppress both the warning and the install fallback), +---and a loaded value outside the accepted shapes raises a labeled shape +---error. Returns the value (nil = no config exists) once safe to consume. +---@param value any @The loaded config value (nil when no candidate exists). +---@param broken_reason string|nil @From load_first_usable / server_info. +---@param opts { label: string, expected: string, shapes: table } +---@return any value +function M.usable_or_raise(value, broken_reason, opts) + if broken_reason then + M.raise_verbatim(broken_reason) + end + if value ~= nil and not opts.shapes[type(value)] then + M.raise_verbatim(string.format("%s must return %s (got `%s`)", opts.label, opts.expected, type(value))) + end + return value +end + local mason_path_added = false ---Append Mason's bin dir to $PATH once (see the file header) — appended so a From df782e237a044ef3c135be738231a0fbbf23ebd8 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 17:27:49 +0800 Subject: [PATCH 19/71] perf(tooling): move deferral ownership into the resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit conform wrapped its whole resolve in vim.schedule and had to remember to pre-call ensure_mason_on_path itself — a resolver-internal pairing enforced only by a comment. resolve()/resolve_runtime_tools now accept a defer option that pays the same-tick $PATH guarantee synchronously and schedules the rest, with phase-1 configures still counting as trigger-backed exactly like the caller-side wrapper did --- lua/modules/configs/completion/conform.lua | 83 ++++++++++------------ lua/modules/utils/tools.lua | 24 ++++++- 2 files changed, 60 insertions(+), 47 deletions(-) diff --git a/lua/modules/configs/completion/conform.lua b/lua/modules/configs/completion/conform.lua index 74a35190..b2cfec9c 100644 --- a/lua/modules/configs/completion/conform.lua +++ b/lua/modules/configs/completion/conform.lua @@ -173,55 +173,50 @@ return function() end or false, }) - -- Make Mason's bin dir resolvable BEFORE the replayed save formats — a bare - -- Mason formatter binary must spawn — so no per-formatter command rewrite is - -- needed. Idempotent; the resolver calls it again itself. - tools.ensure_mason_on_path() -- Resolve `formatter_deps` (conform formatter names) discovery-first against -- conform's own registry, so a missing formatter is installed / reported. -- The probe only drives install/warn — nothing on the save path reads it — - -- so the whole resolve moves off the BufWritePre tick that lazy-loaded - -- conform instead of delaying the first save behind it. - vim.schedule(function() - 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. - local conform = require("conform") - if type(conform.get_formatter_config) ~= "function" then + -- so `defer` moves the resolve off the BufWritePre tick that lazy-loaded + -- conform; the resolver itself keeps the same-tick guarantee that Mason's + -- bin dir is on $PATH before the replayed save's spawns. + 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. + local conform = require("conform") + if type(conform.get_formatter_config) ~= "function" then + return { binary = nil } + end + -- get_formatter_config runs a function-form override directly, so pcall keeps a + -- throwing override (a broken config) from being misread as an unknown name. + local ok, config, err = pcall(conform.get_formatter_config, name) + if not ok then + return { broken = tostring(config) } + end + if config then + -- A function-form command resolves per buffer at format time (e.g. the + -- builtin from_node_modules): treat it as self-resolving rather than + -- evaluating it for a representative binary — a node_modules command + -- shouldn't map to a Mason install anyway. + if type(config.command) == "function" then return { binary = nil } end - -- get_formatter_config runs a function-form override directly, so pcall keeps a - -- throwing override (a broken config) from being misread as an unknown name. - local ok, config, err = pcall(conform.get_formatter_config, name) - if not ok then - return { broken = tostring(config) } - end - if config then - -- A function-form command resolves per buffer at format time (e.g. the - -- builtin from_node_modules): treat it as self-resolving rather than - -- evaluating it for a representative binary — a node_modules command - -- shouldn't map to a Mason install anyway. - if type(config.command) == "function" then - return { binary = nil } - end - return { binary = config.command } - end - -- (nil, err) is a real formatter with a broken config; bare nil is an unknown name. - if type(err) == "string" then - return { broken = err } - end - -- A function-form override may legitimately return nil for the - -- probe-time buffer (this probe runs on a scheduled tick against - -- whatever buffer happens to be current): its existence proves the - -- name real, but nothing is verifiable — report it unresolved - -- (missing bucket, tailored reason) instead of a typo or a silent pass. - local overrides = conform.formatters - if type(overrides) == "table" and type(overrides[name]) == "function" then - return { unresolved = true } - end - return nil - end) - end) + return { binary = config.command } + end + -- (nil, err) is a real formatter with a broken config; bare nil is an unknown name. + if type(err) == "string" then + return { broken = err } + end + -- A function-form override may legitimately return nil for the + -- probe-time buffer (this probe runs on a scheduled tick against + -- whatever buffer happens to be current): its existence proves the + -- name real, but nothing is verifiable — report it unresolved + -- (missing bucket, tailored reason) instead of a typo or a silent pass. + local overrides = conform.formatters + if type(overrides) == "table" and type(overrides[name]) == "function" then + return { unresolved = true } + end + return nil + end, nil, { defer = true }) -- User commands vim.api.nvim_create_user_command("Format", function(args) diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index 798e9c27..2a708822 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -812,11 +812,18 @@ end --- unresolvable_of?: fun(name: string): string|nil, --- configure?: fun(name: string, late: boolean), --- defer_phase2?: boolean, +--- defer?: boolean, ---} ---`unresolvable_of` (optional): a non-nil reason short-circuits the name in ---phase 1 — marked missing with that reason and flushed immediately, never ---classified against the registry (the one exception to "phase 1 never marks ---missing"). +---`defer` (optional): move the WHOLE resolve off the caller's tick. The +---resolver still pays ensure_mason_on_path synchronously — deferring must not +---lose the same-tick guarantee that a replayed trigger's bare Mason spawns +---can resolve — and phase-1 configures inside the scheduled run still count +---as trigger-backed (`late = false`), exactly like a caller-side +---vim.schedule wrapper did. function M.resolve(spec) local ok_settings, settings = pcall(require, "core.settings") local timeout_ms = ( @@ -1149,8 +1156,16 @@ function M.resolve(spec) end end - run() - synchronous = false + if spec.defer then + M.ensure_mason_on_path() + vim.schedule(function() + run() + synchronous = false + end) + else + run() + synchronous = false + end end ---Discovery-first resolution for a subsystem whose own runtime registrations @@ -1171,7 +1186,9 @@ end ---@param configure? fun(name: string, late: boolean) @Optional: run for each available/local tool --- (e.g. rewrite its command to an absolute path while Mason's bin dir is --- still off $PATH). -function M.resolve_runtime_tools(title, deps, probe, configure) +---@param opts? { defer?: boolean } @`defer` moves the whole resolve off the +--- caller's tick (see M.resolve); the $PATH guarantee stays synchronous. +function M.resolve_runtime_tools(title, deps, probe, configure, opts) local cache = {} local function info(name) if cache[name] == nil then @@ -1227,6 +1244,7 @@ function M.resolve_runtime_tools(title, deps, probe, configure) -- A binary-less runtime tool can't map to a package, so it self-resolves. local_config_mode = "resolves", defer_phase2 = true, + defer = type(opts) == "table" and opts.defer == true or nil, configure = function(name, late) -- A broken config must never configure: raise its reason verbatim (it -- may carry the broken file's own "path:line:"). From a20707b48fc0d681143e6af807073c6f378aea0c Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 17:27:49 +0800 Subject: [PATCH 20/71] perf(dap): keep mason off the :Dap tick for provisioned sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dap/init eagerly required mason-registry (loading all of mason.nvim) and four mason-nvim-dap mapping tables on the interactive command tick, even when every adapter self-validates from $PATH. mason-nvim-dap moves out of nvim-dap's dependencies into its own lazy spec, the registry becomes a thunk, and mappings/setup load on first use — only the factory fallback and phase-2 classification. the client opts table keeps its historical mapping fields through a lazy __index, so a user override reading opts.adapters still gets them on demand --- lua/modules/configs/tool/dap/init.lua | 135 +++++++++++++++----------- lua/modules/plugins/tool.lua | 12 ++- 2 files changed, 89 insertions(+), 58 deletions(-) diff --git a/lua/modules/configs/tool/dap/init.lua b/lua/modules/configs/tool/dap/init.lua index 3e27fdbd..43caa1f1 100644 --- a/lua/modules/configs/tool/dap/init.lua +++ b/lua/modules/configs/tool/dap/init.lua @@ -1,9 +1,6 @@ return function() local dap = require("dap") local dapui = require("dapui") - -- 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() @@ -77,10 +74,56 @@ return function() ) vim.fn.sign_define("DapLogPoint", { text = icons.dap.LogPoint, texthl = "DapLogPoint", linehl = "", numhl = "" }) - ---A handler to setup all clients defined under `tool/dap/clients/*.lua` - ---@param config table - local function mason_dap_handler(config) - local dap_name = config.name + -- Everything Mason-flavored loads LAZILY: a session where every adapter + -- self-validates from $PATH must not load mason.nvim / mason-nvim-dap on + -- the :Dap* tick. First use (the factory fallback or a phase-2 + -- classification) goes through lazy.nvim's module loader; absence degrades + -- to client-config/$PATH resolution as before. + local mason_dap = nil + local function mason_dap_mod() + if mason_dap == nil then + local ok, m = pcall(require, "mason-nvim-dap") + if ok and type(m) == "table" then + require("modules.utils").load_plugin("mason-nvim-dap", { + ensure_installed = {}, + automatic_installation = false, + }) + mason_dap = m + else + mason_dap = false + end + end + return mason_dap or nil + end + -- mason-nvim-dap private internals, not a public API: guard each require so + -- drift degrades to client-config/$PATH resolution instead of aborting. + local function map_or_empty(mod, default) + local ok, m = pcall(require, mod) + return (ok and type(m) == "table") and m or default + end + local mason_maps_cache = nil + local function mason_maps() + if not mason_maps_cache then + mason_maps_cache = { + source = map_or_empty("mason-nvim-dap.mappings.source", {}), + adapters = map_or_empty("mason-nvim-dap.mappings.adapters", {}), + configurations = map_or_empty("mason-nvim-dap.mappings.configurations", {}), + filetypes = map_or_empty("mason-nvim-dap.mappings.filetypes", {}), + } + -- The module may load with the indexed field renamed/removed; normalize + -- so package_of/unknown_of below never index nil. + if type(mason_maps_cache.source.nvim_dap_to_package) ~= "table" then + mason_maps_cache.source.nvim_dap_to_package = {} + end + end + return mason_maps_cache + end + + ---A handler to setup all clients defined under `tool/dap/clients/*.lua`. + ---Only the factory branch touches mason-nvim-dap: a client-config'd adapter + ---(every adapter in this repo) configures without loading Mason. + ---@param dap_name string + local function mason_dap_handler(dap_name) local custom_handler, broken_reason = load_client_config(dap_name) -- No-fall-through contract, enforced by the ONE shared implementation -- (tools.usable_or_raise, also used by mason_lsp_handler): a broken or @@ -94,7 +137,8 @@ return function() if custom_handler == nil then -- No client config: fall back to Mason's factory config, erroring -- (level 0) so the resolver reports failures. - if not has_mason_dap then + local m = mason_dap_mod() + if not m then error( string.format( "no client config for `%s` and mason-nvim-dap is unavailable for a default setup", @@ -103,6 +147,13 @@ return function() 0 ) end + local map = mason_maps() + local config = { + name = dap_name, + adapters = map.adapters[dap_name], + configurations = map.configurations[dap_name], + filetypes = map.filetypes[dap_name], + } -- default_setup silently no-ops on a nil adapter config: error instead. if config.adapters == nil then error( @@ -123,47 +174,28 @@ return function() if type(config.filetypes) ~= "table" then config.filetypes = {} end - mason_dap.default_setup(config) + m.default_setup(config) else -- Function form (the only other shape usable_or_raise lets through): -- the protocol owns its setup. Make sure to set -- * dap.adapters. = { your config } -- * dap.configurations. = { your config } -- See `codelldb.lua` for a concrete example. - custom_handler(config) + -- The opts table keeps the historical contract (name + the Mason + -- mapping fields) without the eager Mason cost: mapping fields load + -- on first ACCESS, so the zero-arg repo clients never touch Mason + -- while a user override reading opts.adapters still gets them. + custom_handler(setmetatable({ name = dap_name }, { + __index = function(_, key) + local map = mason_maps()[key] + return type(map) == "table" and map[dap_name] or nil + end, + })) end end 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)? ---A config that exists but fails to load still counts — treating it as ---absent would misread a broken config as an unknown adapter name. @@ -174,27 +206,19 @@ return function() return value ~= nil or any_exists end - ---Configure an adapter via the shared handler; client configs self-validate. - ---@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 - -- 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, - -- A value, not a thunk: mason-registry was already required at config top. - registry = has_registry and registry or nil, + -- 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 + end, package_of = function(name) - return source_map.nvim_dap_to_package[name] + return mason_maps().source.nvim_dap_to_package[name] end, binaries_of = function(name, pkg) if pkg ~= nil then @@ -210,10 +234,11 @@ return function() if has_client_config(name) then return false end - if next(source_map.nvim_dap_to_package) == nil then + local src = mason_maps().source.nvim_dap_to_package + if next(src) == nil then return false end - return source_map.nvim_dap_to_package[name] == nil + return src[name] == nil end, has_local_config = has_client_config, -- Client configs self-validate, so try an existing config before the Mason @@ -233,6 +258,6 @@ return function() -- registered when it raises; the raise only signals provisioning (the -- warning reasons say remote attach still works) — delve, python. local_config_mode = "validates", - configure = configure_adapter, + configure = mason_dap_handler, }) end diff --git a/lua/modules/plugins/tool.lua b/lua/modules/plugins/tool.lua index 5ad7aa4f..71b1b431 100644 --- a/lua/modules/plugins/tool.lua +++ b/lua/modules/plugins/tool.lua @@ -106,9 +106,6 @@ tool["mfussenegger/nvim-dap"] = { }, config = require("tool.dap"), dependencies = { - -- 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", dependencies = "nvim-neotest/nvim-nio", @@ -117,6 +114,15 @@ tool["mfussenegger/nvim-dap"] = { }, } +-- mason-nvim-dap only supplies the adapter -> package mappings; the DAP resolver +-- degrades to $PATH discovery when it (or mason.nvim) is absent. Deliberately +-- NOT a dependency of nvim-dap: lazy.nvim loads dependencies together with the +-- parent, and a fully provisioned session must not pay Mason on the :Dap* +-- tick — the DAP config requires it on demand (module loader) instead. +tool["jay-babu/mason-nvim-dap.nvim"] = { + lazy = true, +} + tool["trixnz/sops.nvim"] = { lazy = false, } From 4a33b305d9acdcadfa89ecd1af897dfb2a638af8 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 17:27:49 +0800 Subject: [PATCH 21/71] perf(lsp): defer registry classification off the first file open phase 2 (the full mason mappings decode plus package hydration) ran synchronously on the BufReadPre lazy-load tick whenever any lsp dep was unresolved, adding 10-20ms to the session's first file. it now runs deferred: vim.lsp.enable() attaches already-open matching buffers (verified on this nvim), so the one same-tick beneficiary still reaches the triggering buffer --- lua/modules/configs/completion/mason-lspconfig.lua | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lua/modules/configs/completion/mason-lspconfig.lua b/lua/modules/configs/completion/mason-lspconfig.lua index c7aaa9ba..5476ad34 100644 --- a/lua/modules/configs/completion/mason-lspconfig.lua +++ b/lua/modules/configs/completion/mason-lspconfig.lua @@ -459,6 +459,13 @@ M.setup = function() unknown_of = unknown_of, has_local_config = has_local_config, configure = configure, + -- Phase 2 (registry classification: full mappings decode + package + -- hydration) moves off the BufReadPre tick. Safe because a late + -- configure's vim.lsp.enable() attaches already-open matching + -- buffers (verified on this nvim), so the one same-tick + -- beneficiary — a Mason-installed server whose binary name differs + -- from the probe — still reaches the triggering buffer. + defer_phase2 = true, }) end end From 88be21ca0d888aff5085eeea28aab20848e7b0cc Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 17:47:32 +0800 Subject: [PATCH 22/71] perf(dap): cache the negative debugpy import probe for the session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit only successful resolution was cached, so every launch attempt while debugpy stayed missing re-ran the blocking python import spawns. a closure flag now short-circuits after one failed round; every recovery path already bypasses or resets it — the fast probes run first each call (a mason install takes over there), a resolver re-configure rebuilds the closure, and :ToolsRetry re-runs the client config for a pip-installed debugpy --- lua/modules/configs/tool/dap/clients/python.lua | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lua/modules/configs/tool/dap/clients/python.lua b/lua/modules/configs/tool/dap/clients/python.lua index dfa979fa..4ee1e66b 100644 --- a/lua/modules/configs/tool/dap/clients/python.lua +++ b/lua/modules/configs/tool/dap/clients/python.lua @@ -64,6 +64,14 @@ return function() return nil end + -- Negative session cache for the import probe: a session where debugpy + -- stays missing must not re-pay the blocking interpreter spawns on every + -- launch attempt. Every recovery path bypasses or resets it: the fast + -- probes run FIRST each call (a Mason install takes over there), a + -- resolver re-configure rebuilds this whole closure, and :ToolsRetry + -- re-runs the client config for a pip-installed debugpy. + local import_probe_failed = false + -- Full cascade: fast probes, then a system python that can import debugpy. -- The import probe spawns a short-lived python, but only when the fast probes -- miss. Both the launch path and the config-time availability check at the @@ -73,6 +81,9 @@ return function() if command then return command, args end + if import_probe_failed then + return nil + end -- Last resort: probe interpreter candidates rather than hard-coding one -- (pythonw.exe is often absent on a Windows box with only python.exe). local probed = {} @@ -97,6 +108,7 @@ return function() end end end + import_probe_failed = true return nil end From b543856a11be3bbef40f57782e57cc50db69809e Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 17:47:32 +0800 Subject: [PATCH 23/71] perf(tooling): memoize hot registry and mason-root lookups package_for_binary re-read and re-decoded the whole registry.json on every call while the registry was not fully bootstrapped; the unfrozen index is now kept for a single event-loop tick (nothing can change within one), so a batch of lookups decodes once and the re-refresh self-heal is preserved. mason_root's guess path re-ran the user-override module search on every $PATH miss and recheck; that existence check is memoized (it only gates the default-dir guess), while the fs_stat recheck stays per call --- lua/modules/utils/tools.lua | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index 2a708822..94a5bbd6 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -568,12 +568,19 @@ end ---@param binary string @Executable name to look up. ---@return string|nil @Mason package name shipping that binary, or nil. local bin_to_package = nil +-- Unfrozen scans stay re-buildable by design (self-heal after a late +-- refresh), but within ONE event-loop tick nothing can change: memoize per +-- tick so a batch of lookups decodes registry.json once, not N times. +local unfrozen_index = nil local function package_for_binary(registry, binary) -- Freeze the index only once the registry is FULLY bootstrapped: -- get_all_package_specs() silently skips uninstalled sources, and a frozen -- partial index would outlive resolve()'s re-refresh self-heal. (A source -- appended at runtime after the freeze is out of scope.) if bin_to_package == nil then + if unfrozen_index then + return unfrozen_index[binary] + end local index = {} local populated = false local ok, specs = pcall(registry.get_all_package_specs) @@ -592,7 +599,12 @@ local function package_for_binary(registry, binary) if populated and registry_bootstrapped(registry) then bin_to_package = index else - -- Partial/uncertain scan: consult what was found, but don't cache it. + -- Partial/uncertain scan: consult what was found, keep it only for + -- the current tick. + unfrozen_index = index + vim.schedule(function() + unfrozen_index = nil + end) return index[binary] end end @@ -621,6 +633,11 @@ end ---re-checked for existence each call (the dir appears after the first install). ---@return string|nil local mason_root_dir = nil +-- The user-override existence check is memoized separately from module_path's +-- hit-only cache: it merely gates the default-dir guess below, and a user +-- adding user/configs/mason mid-session needs a restart for a new root +-- anyway — so unlike a general module miss, staleness here is harmless. +local mason_override_absent = nil function M.mason_root() if not mason_root_dir then local settings = package.loaded["mason.settings"] @@ -636,7 +653,10 @@ function M.mason_root() -- Never require() mason.settings here — it lazy-loads all of mason.nvim -- during a pure discovery probe, defeating "Mason optional". local guess = vim.fn.stdpath("data") .. "/mason" - if not M.module_path("user.configs.mason") and vim.uv.fs_stat(guess) then + if mason_override_absent == nil then + mason_override_absent = M.module_path("user.configs.mason") == nil + end + if mason_override_absent and vim.uv.fs_stat(guess) then return guess end end From 4958f0fd4a3d3090b3de08128c5fc6b84c2d8192 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 17:47:32 +0800 Subject: [PATCH 24/71] fix(lint): propagate a failed post-install linter reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refresh_linter read the rebuilt linter through lint.linters' __index, whose pcall swallows a reload failure into nil — the old linter was silently restored and configure reported success, clearing the hand-off pending gate over stale state. the reload now requires the module itself and raises the real reason on failure (the resolver keeps the name pending and the warning carries it); the synchronous reload inside configure is documented as deliberate — deferring it would report success before the rebuild happened --- lua/modules/configs/completion/nvim-lint.lua | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/lua/modules/configs/completion/nvim-lint.lua b/lua/modules/configs/completion/nvim-lint.lua index 62660586..ee2dad2b 100644 --- a/lua/modules/configs/completion/nvim-lint.lua +++ b/lua/modules/configs/completion/nvim-lint.lua @@ -157,6 +157,10 @@ return function() ---Rebuild a module-backed linter for a late configure: some (golangcilint) ---compute `args` by RUNNING their binary at module-load time, and a result ---computed while the binary was absent would persist all session. + ---Deliberately SYNCHRONOUS inside the resolver's configure (F24 evaluated + ---and rejected deferral): the hand-off pending gate and the aggregated + ---warning both key off configure's synchronous success/failure — deferring + ---the reload would report success before the rebuild happened. ---@param name string local function refresh_linter(name) local module = "lint.linters." .. name @@ -166,13 +170,17 @@ return function() local prev = lint.linters[name] package.loaded[module] = nil -- Also drop any explicit assignment (a wrapped factory) shadowing the - -- lint.linters __index loader; the read below re-requires the module. + -- lint.linters __index loader. rawset(lint.linters, name, nil) - local fresh = lint.linters[name] - if fresh == nil then - -- Nothing regenerated the linter (loader gone or reload throws): - -- restore — stale args beat a deleted linter (asserts on try_lint). + -- Require it OURSELVES: the __index loader pcall-swallows a reload + -- failure into nil, which would read as configure success and clear + -- the hand-off pending gate over a stale linter. + local ok, err = pcall(require, module) + if not ok then + -- Restore — stale args beat a deleted linter (asserts on try_lint) — + -- then raise so the resolver keeps the name pending and reported. rawset(lint.linters, name, prev) + tools.raise_verbatim("linter reload failed after install: " .. tostring(err)) end apply_override(name) end From ee9aafab36ab6aace3929a1ff2cfbd9827486b55 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 18:02:41 +0800 Subject: [PATCH 25/71] refactor(lint): remove the unreachable factory-wrapper machinery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit both overridden linters (selene, markdownlint-cli2) are plain-table modules upstream, so reapply_factory_override's non-function early-return always fired and the per-call wrapper plus apply_override's second parameter were ~30 lines of speculation. sync configures apply the override directly; late configures rely on refresh_linter, which re-applies it itself. if upstream ever converts one to a factory the override degrades to not-applied (documented) — the probe's factory branch still resolves the linter --- lua/modules/configs/completion/nvim-lint.lua | 46 +++++--------------- 1 file changed, 11 insertions(+), 35 deletions(-) diff --git a/lua/modules/configs/completion/nvim-lint.lua b/lua/modules/configs/completion/nvim-lint.lua index ee2dad2b..7aeebb8a 100644 --- a/lua/modules/configs/completion/nvim-lint.lua +++ b/lua/modules/configs/completion/nvim-lint.lua @@ -33,15 +33,12 @@ return function() end, } ---@param name string - ---@param linter? table @Apply to this table instead of the registry entry — - --- a factory linter's value only exists per call, so its wrapper below - --- passes the returned table in directly. - local function apply_override(name, linter) + local function apply_override(name) local apply = overrides[name] if not apply then return end - linter = linter or lint.linters[name] + local linter = lint.linters[name] if type(linter) == "table" then apply(linter) end @@ -126,34 +123,11 @@ return function() -- batched BY FILETYPE off the lint events below: the probe requires the -- linter module, and some (golangcilint) run blocking system calls at load. local tools = require("modules.utils.tools") - -- Wrappers installed by reapply_factory_override; the identity check keeps - -- reapplication idempotent (no stacking), while a fresh factory — e.g. after - -- refresh_linter reloads the module — is wrapped anew. - local factory_wrappers = {} - ---Re-apply local overrides to a factory linter's per-call table (the - ---setup-time apply_override loop only reaches table linters). No-op for a - ---table linter and for a factory without an override. - ---@param name string - local function reapply_factory_override(name) - if not overrides[name] then - return - end - local linter = lint.linters[name] - -- Skip a non-factory, or a linter we've already wrapped (idempotent). - if type(linter) ~= "function" or linter == factory_wrappers[name] then - return - end - local factory = linter - local wrapper = function(...) - local out = factory(...) - if type(out) == "table" then - apply_override(name, out) - end - return out - end - factory_wrappers[name] = wrapper - lint.linters[name] = wrapper - end + -- No factory-wrapper machinery here on purpose: both overridden linters + -- (selene, markdownlint-cli2) are plain-table modules upstream, so a + -- per-call wrapper would be unreachable speculation. If upstream ever + -- converts one to a factory, the override silently stops applying (the + -- probe's factory branch still resolves the linter) — revisit then. ---Rebuild a module-backed linter for a late configure: some (golangcilint) ---compute `args` by RUNNING their binary at module-load time, and a result ---computed while the binary was absent would persist all session. @@ -238,10 +212,12 @@ return function() return { binary = type(cmd) == "string" and cmd or nil } end, function(name, late) if late then - -- Rebuild so load-time work sees the just-installed binary. + -- Rebuild so load-time work sees the just-installed binary + -- (refresh_linter re-applies the local override itself). refresh_linter(name) + else + apply_override(name) end - reapply_factory_override(name) if late then -- No lint event follows a late configure: re-lint every loaded -- buffer whose filetype maps to this linter. From 535b8e97ecc5444b81069d1a1df58a415c07c30f Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 18:02:41 +0800 Subject: [PATCH 26/71] refactor(dap): probe debugpy through the supported mason surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the fast path hardcoded mason's packages/debugpy/venv layout — the same class of internal knowledge this branch already deleted from delve — while the next probe resolves the spec-declared debugpy-adapter shim that exists exactly when the venv does. the venv probe is gone (accepted nuance: windows spawns via the .cmd shim, no pythonw console suppression), the hint wording follows, and the import probe now uses vim.system():wait().code per repo convention instead of round-tripping through v:shell_error --- .../configs/tool/dap/clients/python.lua | 35 +++++++++---------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/lua/modules/configs/tool/dap/clients/python.lua b/lua/modules/configs/tool/dap/clients/python.lua index 4ee1e66b..c9893b15 100644 --- a/lua/modules/configs/tool/dap/clients/python.lua +++ b/lua/modules/configs/tool/dap/clients/python.lua @@ -16,8 +16,8 @@ return function() return is_windows and root .. "/Scripts/pythonw.exe" or root .. "/bin/python" end - -- Resolve the debugpy command discovery-first. Higher-priority sources - -- (Mason venv, adapter shim) are re-probed every call, so a mid-session + -- Resolve the debugpy command discovery-first. The higher-priority source + -- (the `debugpy-adapter` shim) is re-probed every call, so a mid-session -- install takes over; the cache is consulted LAST purely to spare the -- blocking import probe, and a cached hit re-checks existence. (A `pip -- uninstall` that keeps the interpreter fails only at import time — accepted.) @@ -27,24 +27,19 @@ return function() return command, args end -- One copy for both raise sites so the install guidance can't drift. - local no_debugpy = "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" + local no_debugpy = "debugpy not found: no `debugpy-adapter` shim on $PATH or in Mason's bin dir,\n" + .. "and no python able to import debugpy; install debugpy via Mason (`:Mason`)\n" .. "or your package manager" - -- Fast, non-blocking probes only (executable()/exepath()): Mason's managed - -- venv → `debugpy-adapter` on $PATH. Spawns nothing itself; the import probe - -- lives in the full cascade below and runs only when these probes miss. + -- Fast, non-blocking probes only (executable()/exepath()): the + -- `debugpy-adapter` shim, then the cached last resolution. Spawns nothing + -- itself; the import probe lives in the full cascade below and runs only + -- when these probes miss. The shim is the SUPPORTED Mason surface — it is + -- declared by the debugpy package spec and exists exactly when the managed + -- venv does, so no probe hardcodes Mason's packages//venv layout. + -- (Accepted nuance: on Windows the shim spawns via .cmd instead of + -- pythonw.exe, so no console-window suppression on that path.) local function fast_command() - -- Re-derive the Mason root each call so a debugpy installed mid-session is - -- picked up: capturing it at config load would freeze it to nil whenever - -- Mason wasn't resolvable on the first :Dap (its dir not yet created). - local mason_root = tools.mason_root() - if mason_root then - local mason_python = venv_python(mason_root .. "/packages/debugpy/venv") - if vim.fn.executable(mason_python) == 1 then - return found(mason_python, { "-m", "debugpy.adapter" }) - end - end -- find_executable reaches a Mason shim even before mason.setup() puts its -- bin dir on $PATH; spawn by absolute path (the bare name wouldn't launch). local adapter = tools.find_executable("debugpy-adapter") @@ -99,8 +94,10 @@ return function() 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 + -- vim.system, not vim.fn.system: the exit code stays local + -- instead of round-tripping through the v:shell_error global + -- (repo convention — core/pack.lua, servers/gopls.lua). + if vim.system({ probe_cmd, "-c", "import debugpy" }):wait().code == 0 then -- Spawn by the probed exepath, not its realpath: a venv python is a -- symlink and pyvenv.cfg discovery precedes symlink resolution. return found(probe_cmd, { "-m", "debugpy.adapter" }) From 89618212a3c62805fa1d97ac58e43639814a5ce8 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 18:02:41 +0800 Subject: [PATCH 27/71] refactor(conform): drop the dead format-on-save re-check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit autoformat_allowed re-tested format_on_save_enabled, but its only caller is installed exclusively when that setting is truthy — the condition could never be false there and pinned the gate in two places. the setting's gate now lives solely where the callback is installed, and the predicate says so --- lua/modules/configs/completion/conform.lua | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lua/modules/configs/completion/conform.lua b/lua/modules/configs/completion/conform.lua index b2cfec9c..6b1b2292 100644 --- a/lua/modules/configs/completion/conform.lua +++ b/lua/modules/configs/completion/conform.lua @@ -41,12 +41,13 @@ return function() end ---The gates a buffer must pass before an automatic format, grouped into one - ---named predicate for the format_on_save callback's readability. + ---named predicate for the format_on_save callback's readability. The + ---format_on_save SETTING is not re-checked here: its gate lives at the + ---single place the callback is installed (format_on_save = enabled and …). ---@param bufnr integer ---@return boolean local function autoformat_allowed(bufnr) - return format_on_save_enabled - and block_list[vim.bo[bufnr].filetype] ~= true + return block_list[vim.bo[bufnr].filetype] ~= true and not is_disabled_workspace(bufnr) and not vim.g.disable_autoformat and not vim.b[bufnr].disable_autoformat From 1f1d5b7ebae58a0e0a83ac115956e4aef123e815 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 18:14:06 +0800 Subject: [PATCH 28/71] fix(settings): warn when a stale external_lsp_deps override survives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the discovery-first refactor removed the setting, but a user settings.lua still defining it merges cleanly into the table and feeds nothing — its servers vanish with no notification, since the aggregated warning only covers lsp_deps names. the merge tail now detects the removed key and schedules one warning that lists the map's keys and says to move the server names (not the executable values) into lsp_deps --- lua/core/settings.lua | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/lua/core/settings.lua b/lua/core/settings.lua index ac8d2464..866c74cd 100644 --- a/lua/core/settings.lua +++ b/lua/core/settings.lua @@ -250,4 +250,35 @@ settings["dashboard_image"] = { [[⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠿⠿⢿⠿⠷⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀]], } -return require("modules.utils").extend_config(settings, "user.settings") +local merged = require("modules.utils").extend_config(settings, "user.settings") + +-- Migration guard: the discovery-first refactor removed this key; a stale +-- user/settings.lua would merge it in and feed nothing — its servers would +-- vanish without a word. (Scheduled: the notifier plugin isn't loaded this +-- early; the default notify still lands in :messages.) +if merged.external_lsp_deps ~= nil then + -- The removed setting was a MAP of server name -> executable name: tell + -- the user to move the KEYS and list them (moving a value like "nil" + -- instead of the key "nil_ls" would land an invalid lsp_deps entry). + local keys = {} + if type(merged.external_lsp_deps) == "table" then + for server in pairs(merged.external_lsp_deps) do + keys[#keys + 1] = tostring(server) + end + table.sort(keys) + end + local names = #keys > 0 and (" (" .. table.concat(keys, ", ") .. ")") or "" + vim.schedule(function() + vim.notify( + "`external_lsp_deps` was removed: non-Mason servers are now discovered\n" + .. "from $PATH. Move its KEYS" + .. names + .. " — the server names, not the\n" + .. "executable values — into `lsp_deps` in user/settings.lua.", + vim.log.levels.WARN, + { title = "core.settings" } + ) + end) +end + +return merged From 953b2be9fa148e2980f43d4120873766b564d27c Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 18:14:06 +0800 Subject: [PATCH 29/71] docs(settings): correct the linter_deps resolution timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the comment promised resolution when nvim-lint lazy-loads (first BufReadPost), but a by_ft-mapped linter only resolves on its filetype's first matching event — the resolve-only FileType autocmd or a lint event — so a tool like hadolint is neither installed nor warned about until a dockerfile buffer opens; only unmapped names get the immediate deferred pass --- lua/core/settings.lua | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lua/core/settings.lua b/lua/core/settings.lua index 866c74cd..e0ea28ba 100644 --- a/lua/core/settings.lua +++ b/lua/core/settings.lua @@ -134,8 +134,11 @@ settings["formatter_deps"] = { "stylua", } --- Linters to resolve when nvim-lint lazy-loads (first BufReadPost). nvim-lint --- linter names, resolved discovery-first like formatter_deps. +-- Linters to resolve discovery-first (nvim-lint linter names). A name mapped +-- to a filetype resolves on that filetype's FIRST matching event after +-- nvim-lint lazy-loads (the resolve-only FileType autocmd or a lint event) — +-- nothing is installed or warned about before such a buffer opens; unmapped +-- names (typos, manual-only linters) get an immediate deferred pass instead. ---@type string[] settings["linter_deps"] = { "actionlint", From 798151ce1eccef7c9081abb36219d6b952874c90 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 18:23:44 +0800 Subject: [PATCH 30/71] docs(tooling): include the unresolved state in the probe annotation the contract list and the implementation both know the probe may return { unresolved = true }, but the @param type still described the pre-group-5 shape, misleading lua-ls-driven tooling --- 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 94a5bbd6..b400fbfe 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -1202,7 +1202,7 @@ end ---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 +---@param probe fun(name: string): { binary: string|nil, broken: string|nil, unresolved: boolean|nil }|nil ---@param configure? fun(name: string, late: boolean) @Optional: run for each available/local tool --- (e.g. rewrite its command to an absolute path while Mason's bin dir is --- still off $PATH). From 0c0a436d4be6821c818495f266f9243665314149 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 19:29:09 +0800 Subject: [PATCH 31/71] fix(lsp): use the documented gh-dash schema url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the official docs and the site config consistently publish https://gh-dash.dev/schema.json; the www form we pointed at is only the current hosting redirect target, so the documented url is the stable contract (today it 301s to www — the dependency direction still favors what the project documents) --- lua/modules/configs/completion/servers/yamlls.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lua/modules/configs/completion/servers/yamlls.lua b/lua/modules/configs/completion/servers/yamlls.lua index a3affa0c..34b4fb17 100644 --- a/lua/modules/configs/completion/servers/yamlls.lua +++ b/lua/modules/configs/completion/servers/yamlls.lua @@ -15,7 +15,9 @@ local schemas = require("schemastore").yaml.schemas({ name = "gh-dash config", description = "gh-dash config YAML schema", fileMatch = "*/gh-dash/config.{yml,yaml}", - url = "https://www.gh-dash.dev/schema.json", + -- The DOCUMENTED canonical form (docs + astro site config use the + -- bare host; the www alias is just today's hosting redirect target). + url = "https://gh-dash.dev/schema.json", }, -- The catalog's own "Traefik v3" entry carries no fileMatch, so this -- extra is what actually claims the static-config files for v3. From 17dfa3a2514f8c51cbb56152d683799b04d4a2d3 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 22:20:28 +0800 Subject: [PATCH 32/71] fix(dap): re-probe debugpy on a ttl with a finite retry budget the config-time negative probe latched for the whole closure lifetime, so a pip-installed debugpy (no mason shim) was never picked up by later launches. re-probe on a launch attempt after a 10s window, at most 3 rounds per miss-epoch, then the latch is permanent again (keeps the round-2 f19 bound of finite blocking spawns per session). any successful resolution resets the epoch in found(), so a later vanished resolved command re-probes with a fresh budget instead of latching on spent state. --- .../configs/tool/dap/clients/python.lua | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/lua/modules/configs/tool/dap/clients/python.lua b/lua/modules/configs/tool/dap/clients/python.lua index c9893b15..7c99efa8 100644 --- a/lua/modules/configs/tool/dap/clients/python.lua +++ b/lua/modules/configs/tool/dap/clients/python.lua @@ -22,8 +22,25 @@ return function() -- blocking import probe, and a cached hit re-checks existence. (A `pip -- uninstall` that keeps the interpreter fails only at import time — accepted.) local resolved + -- Negative session cache for the import probe, TTL'd with a finite budget: + -- launch attempts inside the window, or after the budget is spent, stay + -- spawn-free (a session where debugpy stays missing pays a bounded total, + -- then never again). An attempt after the window re-probes while budget + -- remains, so a pip-installed debugpy is picked up without :ToolsRetry in + -- the common install-then-retry flow. A success closes the miss-epoch + -- (found() resets the state) so a later vanished `resolved` starts a fresh + -- budget. Faster recovery paths stay: the fast probes run FIRST each call + -- (a Mason install takes over there), a resolver re-configure rebuilds this + -- whole closure, and :ToolsRetry re-runs the client config. + local IMPORT_PROBE_TTL_NS = 10 * 1000 * 1000 * 1000 -- 10s between re-probes + local IMPORT_PROBE_BUDGET = 3 -- post-failure re-probe rounds per miss-epoch + local import_probe_failed_at = nil + local import_probe_retries = 0 local function found(command, args) resolved = { command = command, args = args } + -- Any successful resolution closes the current miss-epoch. + import_probe_failed_at = nil + import_probe_retries = 0 return command, args end -- One copy for both raise sites so the install guidance can't drift. @@ -59,14 +76,6 @@ return function() return nil end - -- Negative session cache for the import probe: a session where debugpy - -- stays missing must not re-pay the blocking interpreter spawns on every - -- launch attempt. Every recovery path bypasses or resets it: the fast - -- probes run FIRST each call (a Mason install takes over there), a - -- resolver re-configure rebuilds this whole closure, and :ToolsRetry - -- re-runs the client config for a pip-installed debugpy. - local import_probe_failed = false - -- Full cascade: fast probes, then a system python that can import debugpy. -- The import probe spawns a short-lived python, but only when the fast probes -- miss. Both the launch path and the config-time availability check at the @@ -76,8 +85,14 @@ return function() if command then return command, args end - if import_probe_failed then - return nil + if import_probe_failed_at then + if import_probe_retries >= IMPORT_PROBE_BUDGET then + return nil -- budget spent: the latch is permanent for this epoch + end + if vim.uv.hrtime() - import_probe_failed_at < IMPORT_PROBE_TTL_NS then + return nil -- inside the window: no respawn + end + import_probe_retries = import_probe_retries + 1 end -- Last resort: probe interpreter candidates rather than hard-coding one -- (pythonw.exe is often absent on a Windows box with only python.exe). @@ -105,7 +120,7 @@ return function() end end end - import_probe_failed = true + import_probe_failed_at = vim.uv.hrtime() return nil end From 89201abbd21cd74cb283501979e131016696ee36 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 22:56:24 +0800 Subject: [PATCH 33/71] fix(dap): restore enumerable opts for user overrides and name mapping drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the factory-branch opts proxy only exposed `name` as a real key, so a user override enumerating opts (vim.tbl_deep_extend / pairs) silently lost the mason mapping fields the pre-branch plain table carried. user-authored overrides now get a materialized plain table (load_first_usable reports the winning module); repo clients keep the lazy proxy so a provisioned session still never loads mason on the :Dap tick. failed mason-nvim-dap mapping requires are now recorded and surfaced where they previously vanished: the resolver's reason-less missing branch (new missing_reason_of spec hook — a drifted mappings.source dead-ends there), the factory nil-adapter error (actionable client-config guidance; no drift claim for legitimate source-only names like js/elixir), and a one-shot warn when a materialized override field is nil. all three sites are gated on mason presence evidence (sibling mapping load, package.loaded, module_path) so a mason-less setup keeps its silent degradation. --- lua/modules/configs/tool/dap/init.lua | 117 ++++++++++++++++++++++---- lua/modules/utils/tools.lua | 18 +++- 2 files changed, 115 insertions(+), 20 deletions(-) diff --git a/lua/modules/configs/tool/dap/init.lua b/lua/modules/configs/tool/dap/init.lua index 43caa1f1..d25631ca 100644 --- a/lua/modules/configs/tool/dap/init.lua +++ b/lua/modules/configs/tool/dap/init.lua @@ -20,15 +20,15 @@ return function() ---it from several predicates (has_local_config, unknown_of) plus the handler, ---and an uncached miss would re-run a failed require each time. ---@param name string - ---@return any value, string|nil broken_reason, boolean any_exists + ---@return any value, string|nil broken_reason, boolean any_exists, string|nil winner local function load_client_config(name) local cached = client_config_cache[name] if not cached then - local value, broken_reason, any_exists = tools.load_first_usable(client_modules(name), "nvim-dap") - cached = { value = value, broken_reason = broken_reason, any_exists = any_exists } + local value, broken_reason, any_exists, winner = tools.load_first_usable(client_modules(name), "nvim-dap") + cached = { value = value, broken_reason = broken_reason, any_exists = any_exists, winner = winner } client_config_cache[name] = cached end - return cached.value, cached.broken_reason, cached.any_exists + return cached.value, cached.broken_reason, cached.any_exists, cached.winner end -- Initialize debug hooks @@ -97,9 +97,28 @@ return function() end -- mason-nvim-dap private internals, not a public API: guard each require so -- drift degrades to client-config/$PATH resolution instead of aborting. + -- Failed requires are RECORDED: with presence evidence they are upstream + -- API drift worth naming; in a Mason-less session the same failures are + -- expected silence (mason_evidence below tells the two apart). + local mapping_drift = {} + local mapping_sibling_ok = false + local mapping_drift_warned = false local function map_or_empty(mod, default) local ok, m = pcall(require, mod) - return (ok and type(m) == "table") and m or default + if ok and type(m) == "table" then + mapping_sibling_ok = true + return m + end + mapping_drift[#mapping_drift + 1] = mod:match("[^.]+$") + return default + end + ---Mason presence evidence without forcing a load: a sibling mapping module + ---loaded, the parent is already in package.loaded, or its file is + ---locatable on the search paths (rtp'd by a failed lazy require attempt). + local function mason_evidence() + return mapping_sibling_ok + or package.loaded["mason-nvim-dap"] ~= nil + or tools.module_path("mason-nvim-dap") ~= nil end local mason_maps_cache = nil local function mason_maps() @@ -124,7 +143,7 @@ return function() ---(every adapter in this repo) configures without loading Mason. ---@param dap_name string local function mason_dap_handler(dap_name) - local custom_handler, broken_reason = load_client_config(dap_name) + local custom_handler, broken_reason, _, winner = load_client_config(dap_name) -- No-fall-through contract, enforced by the ONE shared implementation -- (tools.usable_or_raise, also used by mason_lsp_handler): a broken or -- wrong-shaped config must never read as success — that would suppress @@ -155,11 +174,23 @@ return function() filetypes = map.filetypes[dap_name], } -- default_setup silently no-ops on a nil adapter config: error instead. + -- No drift CLAIM on the nil lookup itself — upstream legitimately + -- ships source-only names (js/javadbg/elixir…) with no default + -- adapter; the remedy is the same either way. Only a recorded + -- module-level require failure is named as drift. if config.adapters == nil then + local drift = #mapping_drift > 0 + and (" — mapping modules failed to load: " .. table.concat(mapping_drift, ", ") .. " (mason-nvim-dap API drift?)") + or "" error( string.format( - "no client config for `%s` and mason-nvim-dap has no adapter definition for it", - dap_name + "no client config for `%s`; mason-nvim-dap can install its package but ships no\n" + .. "default adapter setup for it — add a client config (`tool/dap/clients/%s.lua`\n" + .. "or `user.configs.dap-clients.%s`)%s", + dap_name, + dap_name, + dap_name, + drift ), 0 ) @@ -181,16 +212,55 @@ return function() -- * dap.adapters. = { your config } -- * dap.configurations. = { your config } -- See `codelldb.lua` for a concrete example. - -- The opts table keeps the historical contract (name + the Mason - -- mapping fields) without the eager Mason cost: mapping fields load - -- on first ACCESS, so the zero-arg repo clients never touch Mason - -- while a user override reading opts.adapters still gets them. - custom_handler(setmetatable({ name = dap_name }, { - __index = function(_, key) - local map = mason_maps()[key] - return type(map) == "table" and map[dap_name] or nil - end, - })) + if winner == client_modules(dap_name)[1] then + -- User-authored override: the historical contract is a PLAIN + -- table — pairs()/tbl_deep_extend must see the mapping fields, + -- which a lazy __index proxy cannot provide (LuaJIT has no + -- __pairs). Costs an eager mason_maps() load only for adapters + -- the user explicitly overrode. + local map = mason_maps() + local opts = { + name = dap_name, + adapters = map.adapters[dap_name], + configurations = map.configurations[dap_name], + filetypes = map.filetypes[dap_name], + } + -- Module-level drift would vanish here: the override "succeeds", + -- so neither the factory error nor the resolver's missing path + -- runs. One WARN per session, only with Mason evidence (absence + -- is not drift) — the override itself still runs: a + -- self-sufficient one keeps working. A nil field WITHOUT module + -- drift is the legitimate source-only shape: silent. + if + not mapping_drift_warned + and #mapping_drift > 0 + and (opts.adapters == nil or opts.configurations == nil or opts.filetypes == nil) + and mason_evidence() + then + mapping_drift_warned = true + vim.notify( + string.format( + "mason-nvim-dap mapping modules failed to load: %s — Mason-derived fields\n" + .. "in the `%s` override opts may be nil (upstream API drift?)", + table.concat(mapping_drift, ", "), + dap_name + ), + vim.log.levels.WARN, + { title = "nvim-dap" } + ) + end + custom_handler(opts) + else + -- Repo clients are zero-arg and must not ENUMERATE opts: the + -- lazy proxy keeps Mason off the :Dap tick for provisioned + -- sessions; mapping fields resolve on first ACCESS only. + custom_handler(setmetatable({ name = dap_name }, { + __index = function(_, key) + local map = mason_maps()[key] + return type(map) == "table" and map[dap_name] or nil + end, + })) + end end end @@ -241,6 +311,17 @@ return function() return src[name] == nil end, has_local_config = has_client_config, + ---A drifted mappings.source makes package_of return nil, so the name + ---dead-ends in the resolver's reason-less missing branch — name the + ---recorded drift there. Only with Mason evidence: absence is not drift. + missing_reason_of = function(name) + if has_client_config(name) or #mapping_drift == 0 or not mason_evidence() then + return nil + end + return "mason-nvim-dap mapping modules failed to load (" + .. table.concat(mapping_drift, ", ") + .. ") — cannot derive its Mason package (upstream API drift?)" + end, -- Client configs self-validate, so try an existing config before the Mason -- install fallback — python resolves debugpy from a venv $PATH can't see. -- The raise on a missing launch binary is the provisioning signal. diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index b400fbfe..552bf8f2 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -126,12 +126,13 @@ end ---@return any value @First loaded module value, or nil. ---@return string|nil broken_reason @Highest-precedence exists-but-broken reason. ---@return boolean any_exists @Whether any candidate exists on the search paths. +---@return string|nil winner @The module name that loaded, when value is non-nil. function M.load_first_usable(modules, title) local broken_reason, any_exists = nil, false for _, module in ipairs(modules) do local ok, value, exists, reason = M.load_module_or_report(module, title) if ok then - return value, broken_reason, true + return value, broken_reason, true, module end if exists then any_exists = true @@ -830,6 +831,7 @@ end --- has_local_config?: fun(name: string): boolean, --- local_config_mode?: "resolves"|"validates", --- unresolvable_of?: fun(name: string): string|nil, +--- missing_reason_of?: fun(name: string): string|nil, --- configure?: fun(name: string, late: boolean), --- defer_phase2?: boolean, --- defer?: boolean, @@ -838,6 +840,9 @@ end ---phase 1 — marked missing with that reason and flushed immediately, never ---classified against the registry (the one exception to "phase 1 never marks ---missing"). +---`missing_reason_of` (optional): consulted only for the final reason-less +---missing mark in phase 2 (no package, no local config) — a string return +---annotates the aggregated warning; errors and non-strings are ignored. ---`defer` (optional): move the WHOLE resolve off the caller's tick. The ---resolver still pays ensure_mason_on_path synchronously — deferring must not ---lose the same-tick guarantee that a replayed trigger's bare Mason spawns @@ -1037,7 +1042,16 @@ function M.resolve(spec) session.pending[name] = nil collector.mark_unknown(pkg_name == name and name or (pkg_name .. " (for " .. name .. ")")) else - collector.mark(name) + -- An optional spec hook may supply a concrete reason for an + -- otherwise reason-less miss (e.g. DAP naming recorded mapping + -- drift that made package_of return nil); a hook throw must not + -- break the mark itself. + local reason + if type(spec.missing_reason_of) == "function" then + local hook_ok, hook_reason = pcall(spec.missing_reason_of, name) + reason = (hook_ok and type(hook_reason) == "string") and hook_reason or nil + end + collector.mark(name, reason) end end From 4260acd1abfa5341c5bfcf8c4d4825bacb14afbd Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 23:08:43 +0800 Subject: [PATCH 34/71] fix(settings): classify external_lsp_deps residue before advising MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the migration warning assumed a map residue: a half-migrated list surfaced its numeric indices as "KEYS (1, 2)", an empty table produced a warning with nothing actionable, and no shape ever said the dead key itself must go. classify the residue first — map keys, list entries, both (neither group may suppress the other, or a listed server is silently lost on migration), or nothing — and always end with the delete-the-key step. --- lua/core/settings.lua | 50 ++++++++++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/lua/core/settings.lua b/lua/core/settings.lua index e0ea28ba..0cdb86f2 100644 --- a/lua/core/settings.lua +++ b/lua/core/settings.lua @@ -260,24 +260,48 @@ local merged = require("modules.utils").extend_config(settings, "user.settings") -- vanish without a word. (Scheduled: the notifier plugin isn't loaded this -- early; the default notify still lands in :messages.) if merged.external_lsp_deps ~= nil then - -- The removed setting was a MAP of server name -> executable name: tell - -- the user to move the KEYS and list them (moving a value like "nil" - -- instead of the key "nil_ls" would land an invalid lsp_deps entry). - local keys = {} + -- The removed setting was a MAP of server name -> executable name, but a + -- stale override can survive in any shape: classify before advising so the + -- guidance never presents numeric indices as keys, never drops entries a + -- half-migrated LIST residue still carries, and always names the final + -- step (deleting the dead key). Neither group suppresses the other. + local string_keys, list_items = {}, {} if type(merged.external_lsp_deps) == "table" then - for server in pairs(merged.external_lsp_deps) do - keys[#keys + 1] = tostring(server) + for k, v in pairs(merged.external_lsp_deps) do + if type(k) == "string" then + string_keys[#string_keys + 1] = k + elseif type(k) == "number" and type(v) == "string" then + list_items[#list_items + 1] = v + end end - table.sort(keys) + table.sort(string_keys) + table.sort(list_items) end - local names = #keys > 0 and (" (" .. table.concat(keys, ", ") .. ")") or "" + local guidance + if #string_keys > 0 and #list_items > 0 then + guidance = "Move its KEYS (" + .. table.concat(string_keys, ", ") + .. ") AND its list entries (" + .. table.concat(list_items, ", ") + .. ")\n— all of them server names — into `lsp_deps`, then delete `external_lsp_deps`." + elseif #string_keys > 0 then + guidance = "Move its KEYS (" + .. table.concat(string_keys, ", ") + .. ") — the server names, not the\n" + .. "executable values — into `lsp_deps`, then delete `external_lsp_deps`." + elseif #list_items > 0 then + guidance = "It now holds a LIST (" + .. table.concat(list_items, ", ") + .. ") — those are already the\n" + .. "server names; move them into `lsp_deps` and delete `external_lsp_deps`." + else + guidance = "It is empty or not a map — delete `external_lsp_deps` from user/settings.lua." + end + -- (Scheduled: the notifier plugin isn't loaded this early; the default + -- notify still lands in :messages.) vim.schedule(function() vim.notify( - "`external_lsp_deps` was removed: non-Mason servers are now discovered\n" - .. "from $PATH. Move its KEYS" - .. names - .. " — the server names, not the\n" - .. "executable values — into `lsp_deps` in user/settings.lua.", + "`external_lsp_deps` was removed: non-Mason servers are now discovered\nfrom $PATH. " .. guidance, vim.log.levels.WARN, { title = "core.settings" } ) From 9f40f9e4ba949320d7ae86aa6394d2cef83f16a8 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 23:09:05 +0800 Subject: [PATCH 35/71] fix(tooling): strip only real chunkname prefixes from error reasons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the anchored lazy pattern ate the leading prose of any prefix-less level-0 raise whose message contains ":: " — error("parse failed at 12:30: bad token", 0) reached the aggregated warning as just "bad token". strip only shapes that are actual chunknames ([string ...], [C]:N:, a path ending in .lua), first match wins; a custom non-lua chunk keeps its prefix, which is more information, never less. --- lua/modules/utils/tools.lua | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index 552bf8f2..2f2dd54a 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -202,9 +202,19 @@ function M.exepath_or_error(names, hint) error(string.format("%s not found on $PATH or in Mason's bin dir; %s", label, hint), 0) end +-- Real chunkname prefixes error() prepends, most specific first. A lazy +-- catch-all would also eat the leading prose of a prefix-less level-0 raise +-- whose message contains ":: " (e.g. "parse failed at 12:30: ..."). +local CHUNK_PREFIXES = { + '^%[string "[^\n]*"%]:%d+: ', + "^%[C%]:%d+: ", + "^[^\n]-%.lua:%d+: ", +} + ---Normalize a pcall-captured error: a raise_verbatim sentinel yields its ----reason untouched; a string loses the "chunkname:line: " prefix error() adds; ----anything else is dropped. +---reason untouched; a string loses the "chunkname:line: " prefix error() adds +---(only shapes that ARE chunknames — a custom non-.lua chunk keeps its +---prefix, which is more information, never less); anything else is dropped. ---@param err any ---@return string|nil local function error_reason(err) @@ -214,7 +224,13 @@ local function error_reason(err) if type(err) ~= "string" then return nil end - return (err:gsub("^[^\n]-:%d+: ", "")) + for _, pattern in ipairs(CHUNK_PREFIXES) do + local stripped, hits = err:gsub(pattern, "") + if hits > 0 then + return stripped + end + end + return err end -- Private brand for raise_verbatim errors: only sentinels carrying this key From 0c244731fce80aaae26c06371b0e9975b4d70759 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 23:09:05 +0800 Subject: [PATCH 36/71] refactor(tooling): carry the unresolved reason on the probe result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_runtime_tools hardcoded conform's "resolves per buffer" phrasing in its unresolvable_of while nvim-lint shares the helper; the probe result now carries an optional reason and the shared code falls back to a neutral "config could not be verified at startup". conform supplies its own wording on the probe return — user-visible text is unchanged. --- lua/modules/configs/completion/conform.lua | 4 +++- lua/modules/utils/tools.lua | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/lua/modules/configs/completion/conform.lua b/lua/modules/configs/completion/conform.lua index 6b1b2292..dba5545e 100644 --- a/lua/modules/configs/completion/conform.lua +++ b/lua/modules/configs/completion/conform.lua @@ -214,7 +214,9 @@ return function() -- (missing bucket, tailored reason) instead of a typo or a silent pass. local overrides = conform.formatters if type(overrides) == "table" and type(overrides[name]) == "function" then - return { unresolved = true } + -- The reason rides on the probe result: the phrasing is conform's, + -- not the shared resolver's (nvim-lint shares resolve_runtime_tools). + return { unresolved = true, reason = "config resolves per buffer and could not be verified at startup" } end return nil end, nil, { defer = true }) diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index 2f2dd54a..cb87aa2b 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -1249,6 +1249,9 @@ function M.resolve_runtime_tools(title, deps, probe, configure, opts) binary = type(result.binary) == "string" and result.binary or nil, broken = type(result.broken) == "string" and result.broken or nil, unresolved = result.unresolved == true, + -- Consumer-specific phrasing for the unresolved warning + -- stays in the consumer's probe, not in this shared helper. + reason = type(result.reason) == "string" and result.reason or nil, } else -- A probe error is treated as unknown: either way, fix the config entry. @@ -1287,8 +1290,9 @@ function M.resolve_runtime_tools(title, deps, probe, configure, opts) return i.known and i.binary == nil and not i.unresolved end, unresolvable_of = function(name) - if info(name).unresolved then - return "config resolves per buffer and could not be verified at startup" + local i = info(name) + if i.unresolved then + return i.reason or "config could not be verified at startup" end end, -- A binary-less runtime tool can't map to a package, so it self-resolves. From 5a6aaf37b906ba68e55a95cf52442835b6381fbb Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 23:28:41 +0800 Subject: [PATCH 37/71] fix(conform): report a vanished get_formatter_config as unresolved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the probe's guard returned {binary=nil} when conform drops its @private get_formatter_config, silently classifying every formatter as self-resolving — no install, no warning, provisioning off wholesale. report unresolved with a reason naming the missing api instead: every formatter_deps entry lands in the missing bucket via the phase-1 unresolvable_of short-circuit, loud and actionable. --- lua/modules/configs/completion/conform.lua | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lua/modules/configs/completion/conform.lua b/lua/modules/configs/completion/conform.lua index dba5545e..4eb56f11 100644 --- a/lua/modules/configs/completion/conform.lua +++ b/lua/modules/configs/completion/conform.lua @@ -181,11 +181,16 @@ return function() -- conform; the resolver itself keeps the same-tick guarantee that Mason's -- bin dir is on $PATH before the replayed save's spawns. 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. + -- get_formatter_config is conform's @private API; if it vanishes, every + -- formatter is UNVERIFIABLE — report unresolved with the reason (missing + -- bucket, immediate flush) instead of silently classifying them all as + -- self-resolving, which would turn off installs and warnings wholesale. local conform = require("conform") if type(conform.get_formatter_config) ~= "function" then - return { binary = nil } + return { + unresolved = true, + reason = "conform.get_formatter_config is unavailable (conform API drift?) — formatters cannot be verified", + } end -- get_formatter_config runs a function-form override directly, so pcall keeps a -- throwing override (a broken config) from being misread as an unknown name. From 41c1dbae1eee984941e218074358ad5253ba415e Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 23:28:41 +0800 Subject: [PATCH 38/71] fix(lsp): bound the traefik prune's brace matcher and fail closed past it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit expand_braces was single-group: a multi-group v2 claim like {traefik,traefik-static}.{yml,yaml} slipped the prune and both schemas claimed traefik.yml silently. the claim check now judges leaves during a bounded expansion (cap 64, no cartesian blow-up on the startup path; a claimed leaf short-circuits even when the product exceeds the cap), and a glob still beyond the matcher that mentions a claimed stem is dropped rather than trusted — with a scheduled warn listing the dropped globs so over-pruning stays reviewable. stem-less unjudgeable globs stay kept and silent. --- .../configs/completion/servers/yamlls.lua | 149 +++++++++++++++--- 1 file changed, 128 insertions(+), 21 deletions(-) diff --git a/lua/modules/configs/completion/servers/yamlls.lua b/lua/modules/configs/completion/servers/yamlls.lua index 34b4fb17..8d9df2a0 100644 --- a/lua/modules/configs/completion/servers/yamlls.lua +++ b/lua/modules/configs/completion/servers/yamlls.lua @@ -30,20 +30,34 @@ local schemas = require("schemastore").yaml.schemas({ }, }) ----Expand one `{a,b,...}` alternation segment — the only brace form these ----globs use; a brace-less glob expands to itself. +---Bounded worklist expansion of `{a,b,...}` alternations: multi-group globs +---flatten to their full product, capped so a pathological upstream glob can't +---allocate a Cartesian blow-up on the startup path. `truncated` (cap hit) +---means the matcher could not judge the glob; a brace-less glob expands to +---itself. (The claimed-file check below judges leaves DURING expansion and +---does not use this list form — this stays for building `claimed` itself.) +local BRACE_EXPANSION_CAP = 64 ---@param glob string ----@return string[] +---@return string[] expanded +---@return boolean truncated local function expand_braces(glob) - local head, alts, tail = glob:match("^(.-){([^{}]+)}(.-)$") - if not head then - return { glob } - end - local out = {} - for alt in (alts .. ","):gmatch("([^,]*),") do - out[#out + 1] = head .. alt .. tail + local work, out, truncated = { glob }, {}, false + while #work > 0 and not truncated do + local current = table.remove(work) + local head, alts, tail = current:match("^(.-){([^{}]+)}(.-)$") + if not head then + out[#out + 1] = current + else + for alt in (alts .. ","):gmatch("([^,]*),") do + if #work + #out >= BRACE_EXPANSION_CAP then + truncated = true + break + end + work[#work + 1] = head .. alt .. tail + end + end end - return out + return out, truncated end -- The files our v3 extra claims, expanded: the ONLY globs other schemas may @@ -54,40 +68,116 @@ for _, glob in ipairs(traefik_v3_files) do claimed[expanded] = true end end +-- Stems of the claimed files ("traefik"), derived from our own extra — the +-- sentinel gate below never mirrors catalog naming. A glob beyond the +-- matcher that never mentions a stem cannot literally claim a stemmed file +-- under exact-basename matching (wildcards were never in scope). +local claimed_stems = {} +for expanded in pairs(claimed) do + local stem = expanded:match("^[^.]+") + if stem and stem ~= "" then + claimed_stems[stem:lower()] = true + end +end +---@param glob string +---@return boolean +local function mentions_claimed_stem(glob) + local lower = glob:lower() + for stem in pairs(claimed_stems) do + if lower:find(stem, 1, true) then + return true + end + end + return false +end + ---A glob conflicts only when its basename expands to a claimed file: v2's ---`traefik.yml`/`traefik.yaml` (and `**/`-prefixed forms) do; merely ---traefik-NAMED globs (.traefik.yml plugin manifests, traefik-dynamic.*) ----do not and keep their schemas. +---do not and keep their schemas. Leaves are judged DURING the bounded +---expansion, so a claimed file short-circuits `claims` even when the full +---product would blow the cap. The second result flags a glob BEYOND the +---matcher (cap hit before a claim was found, or unbalanced braces): the +---caller must not trust the false and decides fail-closed. ---@param glob any ----@return boolean +---@return boolean claims +---@return boolean beyond local function claims_same_files(glob) if type(glob) ~= "string" then - return false + return false, false end local base = glob:match("([^/\\]+)$") or glob - for _, expanded in ipairs(expand_braces(base)) do - if claimed[expanded] then - return true + local work, seen, beyond = { base }, 0, false + while #work > 0 do + local current = table.remove(work) + local head, alts, tail = current:match("^(.-){([^{}]+)}(.-)$") + if not head then + if claimed[current] then + return true, beyond + end + if current:find("{", 1, true) or current:find("}", 1, true) then + beyond = true + end + else + for alt in (alts .. ","):gmatch("([^,]*),") do + local candidate = head .. alt .. tail + if candidate:find("{", 1, true) then + seen = seen + 1 + if seen > BRACE_EXPANSION_CAP then + return false, true + end + work[#work + 1] = candidate + elseif claimed[candidate] then + -- Judged immediately: no cap gate may starve a claim check. + return true, beyond + else + seen = seen + 1 + if seen > BRACE_EXPANSION_CAP then + return false, true + end + if candidate:find("}", 1, true) then + beyond = true + end + end + end end end - return false + return false, beyond end -- traefik.{yml,yaml} belong to our Traefik v3 extra alone: strip exactly those -- claims from every OTHER schema (the catalog's v2 entry). Matched on the file -- pattern, not v2's name/URL, so a catalog rename can't let v2 re-claim them. --- Keys snapshotted so `schemas` isn't mutated mid-pairs. +-- Keys snapshotted so `schemas` isn't mutated mid-pairs. Globs the bounded +-- matcher could not judge (and that mention a claimed stem) are collected for +-- the sentinel below instead of being silently trusted as non-conflicting. +local dropped_unjudgeable = {} +local function check_glob(glob) + local claims, beyond = claims_same_files(glob) + if claims then + return true + end + -- FAIL CLOSED: a glob the matcher cannot judge that mentions a claimed + -- stem may still cover the claimed files — drop it (the same conflict- + -- removal-wins trade-off as a mixed multi-group glob) and warn below so + -- over-pruning stays reviewable. + if beyond and mentions_claimed_stem(glob) then + dropped_unjudgeable[#dropped_unjudgeable + 1] = glob + return true + end + return false +end for _, url in ipairs(vim.tbl_keys(schemas)) do local fileMatch = schemas[url] if url ~= traefik_v3_url then if type(fileMatch) == "string" then - if claims_same_files(fileMatch) then + if check_glob(fileMatch) then schemas[url] = nil end elseif type(fileMatch) == "table" then local kept = {} for _, glob in ipairs(fileMatch) do - if not claims_same_files(glob) then + if not check_glob(glob) then kept[#kept + 1] = glob end end @@ -95,6 +185,23 @@ for _, url in ipairs(vim.tbl_keys(schemas)) do end end end +-- Capability sentinel: upstream shapes past the matcher's limit failed +-- CLOSED (dropped above) so the v2/v3 conflict cannot survive — say so, and +-- name the globs so over-pruning stays reviewable. Scheduled: this module +-- loads on the first-file-open tick, possibly before the notifier. +if #dropped_unjudgeable > 0 then + table.sort(dropped_unjudgeable) + vim.schedule(function() + vim.notify( + "yamlls schema prune: dropped fileMatch globs the bounded brace matcher\n" + .. "could not judge (they mention a claimed file's stem) — review if a\n" + .. "wanted schema lost files:\n • " + .. table.concat(dropped_unjudgeable, "\n • "), + vim.log.levels.WARN, + { title = "completion.servers.yamlls" } + ) + end) +end return { single_file_support = true, From 8cf62af7862f5546aa2b8854f851f2af1e868631 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 23:56:17 +0800 Subject: [PATCH 39/71] perf(lsp): resolve language servers per filetype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit phase 1 walked every lsp_deps entry through server_info on the first BufReadPre tick, loading all ~18 server modules regardless of the opened filetype — including jsonls/yamlls whose bodies deepcopy the 1362-entry schemastore catalog twice. resolve_deps now partitions the raw dep list: invalid entries pass through verbatim (the resolver's re-scan keeps reporting them); names with either user hook (a user.configs.lsp-servers module or recorded user.configs.lsp ops) and the repo modules that override filetypes (eager set, exported for the consistency harness) keep today's load-tick semantics; the rest defer to per-filetype buckets from vim.lsp.config[name].filetypes, resolved on that filetype's first buffer via a self-cleaning FileType autocmd plus an already-loaded-buffer scan. a 120s one-shot sweep (exported as resolve_remaining) classifies whatever never opened, keeping the missing-tool warning and install parity once per session. opening a lua file now loads only lua_ls's module; jsonls/yamlls stay unloaded until a json/yaml buffer appears. --- .../configs/completion/mason-lspconfig.lua | 180 +++++++++++++++++- 1 file changed, 175 insertions(+), 5 deletions(-) diff --git a/lua/modules/configs/completion/mason-lspconfig.lua b/lua/modules/configs/completion/mason-lspconfig.lua index 5476ad34..cec243ce 100644 --- a/lua/modules/configs/completion/mason-lspconfig.lua +++ b/lua/modules/configs/completion/mason-lspconfig.lua @@ -16,6 +16,9 @@ local server_info_invalidate = nil -- Bridge set by setup(): the discovery pass, run AFTER `user.configs.lsp` so -- its runtime registrations are visible to the unknown/binary classification. local resolve_deps = nil +-- Bridge set by setup(): resolves every still-deferred per-filetype batch — +-- the parity sweep's target and a manual escape hatch. +local resolve_remaining = nil -- User overrides run once per session: lsp.lua's config function is the only -- caller and lazy.nvim runs it once; a rerun could not undo the first pass's -- write-through ops anyway, so a second call is refused loudly instead. @@ -101,6 +104,14 @@ function M.resolve_deps() end end +---Resolve every per-filetype batch still deferred (the parity sweep calls +---this once per session; also a manual escape hatch and the harness hook). +function M.resolve_remaining() + if resolve_remaining then + resolve_remaining() + end +end + M.setup = function() local settings = require("core.settings") -- Mason is an optional installer backend: guard its requires so a Mason-less @@ -128,6 +139,28 @@ M.setup = function() return { "user.configs.lsp-servers." .. name, "completion.servers." .. name } end + -- Repo server modules that OVERRIDE `filetypes`: their ft semantics live in + -- the module, not in lspconfig defaults, so the per-filetype partition must + -- resolve them on the load tick (shuck adds ksh — deferring it by + -- lspconfig's bash/sh/zsh would strand a ksh-only session until the sweep). + -- This declares a property of OUR OWN modules; the ft_override_consistency + -- harness scenario asserts it stays in sync with servers/*.lua. + local eager_ft_override_modules = { + gopls = true, + harper_ls = true, + ruff = true, + shuck = true, + terraformls = true, + tflint = true, + tombi = true, + } + M.eager_ft_override_modules = eager_ft_override_modules + + -- Late parity sweep: every lsp_deps entry is classified at most this long + -- after resolve_deps even if its filetype never opens (missing-tool + -- warnings and installs still happen once per session, off any hot path). + local SWEEP_DELAY_MS = 120000 + vim.diagnostic.config({ signs = true, underline = true, @@ -429,13 +462,14 @@ M.setup = function() end end - -- The discovery pass itself runs via M.resolve_deps() AFTER - -- run_user_lsp_overrides() (see lsp.lua): user runtime registrations must - -- exist before the unknown/binary classification. - resolve_deps = function() + ---One resolve pass over a batch of deps. The collector aggregates by + ---title across batches; each batch gets its own session/pending record + ---(retry_pending walks all of them). + ---@param deps any[] + local function resolve_batch(deps) tools.resolve({ title = "LSP", - deps = settings.lsp_deps, + deps = deps, -- A value, not a thunk: mason-registry was already required at setup top. registry = mason_ok and mason_registry or nil, package_of = package_of, @@ -468,6 +502,142 @@ M.setup = function() defer_phase2 = true, }) end + + -- Per-filetype deferral state, (re)built by each resolve_deps run: + -- ft -> names still waiting, plus a hand-off set so a multi-ft name + -- resolves exactly once. + local deferred_by_ft = {} + local handed_off = {} + local perft_group = nil + + ---Hand a filetype's bucket to the resolver (no-op when already consumed). + ---@param ft string + local function resolve_ft(ft) + local bucket = deferred_by_ft[ft] + if not bucket then + return + end + deferred_by_ft[ft] = nil + local batch = {} + for _, name in ipairs(bucket) do + if not handed_off[name] then + handed_off[name] = true + batch[#batch + 1] = name + end + end + if #batch > 0 then + resolve_batch(batch) + end + if next(deferred_by_ft) == nil and perft_group then + pcall(vim.api.nvim_del_augroup_by_id, perft_group) + perft_group = nil + end + end + + resolve_remaining = function() + local fts = vim.tbl_keys(deferred_by_ft) + local batch = {} + for _, ft in ipairs(fts) do + for _, name in ipairs(deferred_by_ft[ft]) do + if not handed_off[name] then + handed_off[name] = true + batch[#batch + 1] = name + end + end + deferred_by_ft[ft] = nil + end + if perft_group then + pcall(vim.api.nvim_del_augroup_by_id, perft_group) + perft_group = nil + end + if #batch > 0 then + resolve_batch(batch) + end + end + + -- The discovery pass itself runs via M.resolve_deps() AFTER + -- run_user_lsp_overrides() (see lsp.lua): user runtime registrations must + -- exist before the unknown/binary classification. It partitions the RAW + -- dep list: invalid entries stay in the immediate batch verbatim (the + -- resolver's own re-scan reports them); user-overridden names (either + -- hook) and ft-override modules keep today's load-tick semantics; only + -- names whose filetypes come from NON-user sources (rtp lsp/ files, + -- default registrations) defer to their filetype's first buffer, with the + -- sweep as the parity backstop. + resolve_deps = function() + local raw = type(settings.lsp_deps) == "table" and settings.lsp_deps or {} + local immediate = {} + deferred_by_ft = {} + handed_off = {} + for index = 1, table.maxn(raw) do + local entry = raw[index] + if type(entry) ~= "string" or entry == "" then + if entry ~= nil then + immediate[#immediate + 1] = entry + end + else + local fts = nil + local user_hooked = tools.module_path(server_modules(entry)[1]) ~= nil + or (user_lsp_configs[entry] and #user_lsp_configs[entry] > 0) + if not eager_ft_override_modules[entry] and not user_hooked then + local ok, config = pcall(function() + return vim.lsp.config[entry] + end) + if ok and type(config) == "table" and type(config.filetypes) == "table" then + fts = config.filetypes + end + end + local placed = false + if fts then + for _, ft in ipairs(fts) do + if type(ft) == "string" and ft ~= "" then + local bucket = deferred_by_ft[ft] or {} + bucket[#bucket + 1] = entry + deferred_by_ft[ft] = bucket + placed = true + end + end + end + if not placed then + immediate[#immediate + 1] = entry + end + end + end + + -- Immediate batch first: same-tick semantics identical to the old + -- whole-list resolve for everything that must not defer. + if #immediate > 0 then + resolve_batch(immediate) + end + + if next(deferred_by_ft) ~= nil then + perft_group = vim.api.nvim_create_augroup("MasonLspPerFtResolve", { clear = true }) + vim.api.nvim_create_autocmd("FileType", { + group = perft_group, + pattern = vim.tbl_keys(deferred_by_ft), + callback = function(args) + resolve_ft(args.match) + end, + desc = "lsp: resolve this filetype's language servers", + }) + -- Already-loaded buffers (including the one whose BufReadPre + -- triggered this whole config, if its FileType already fired): + -- resolve their filetypes on this same tick. + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_loaded(buf) then + local ft = vim.bo[buf].filetype + if ft ~= "" and deferred_by_ft[ft] then + resolve_ft(ft) + end + end + end + if next(deferred_by_ft) ~= nil then + vim.defer_fn(function() + resolve_remaining() + end, SWEEP_DELAY_MS) + end + end + end end return M From 9f56985bd29c920a4cd2860854153ce50313ceb9 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Mon, 20 Jul 2026 23:56:17 +0800 Subject: [PATCH 40/71] docs(settings): describe the per-filetype lsp_deps timing --- lua/core/settings.lua | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lua/core/settings.lua b/lua/core/settings.lua index 0cdb86f2..4e1831b8 100644 --- a/lua/core/settings.lua +++ b/lua/core/settings.lua @@ -86,7 +86,11 @@ settings["lsp_inlayhints"] = false -- 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`. +-- asks you to provision it. Names whose filetypes lspconfig knows resolve on that +-- filetype's FIRST buffer (a late sweep classifies the rest once per session); names +-- with user overrides, repo modules that override filetypes, or no filetype data +-- resolve on the first file open. See `modules.utils.tools` and +-- `completion/mason-lspconfig.lua`. -- Full list: https://github.com/neovim/nvim-lspconfig/tree/master/lsp ---@type string[] settings["lsp_deps"] = { From 25bf650080da847273c062146697d2ce478e8ac6 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 01:08:09 +0800 Subject: [PATCH 41/71] fix(tooling): drop the bin-to-package index on registry updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package_for_binary froze its bin->package index once the registry was bootstrapped, with no invalidation: a mid-session registry update adding a package left the reverse lookup answering stale nils until restart. attach_registry_events now also subscribes update:success and clears both index tiers synchronously — mason emits the event before update callbacks run, so a scheduled clear would leave a same-tick stale window; clearing two locals is pure lua and fast-event-safe. the next lookup rebuilds and re-freezes against the fresh specs. --- lua/modules/utils/tools.lua | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index cb87aa2b..6402472a 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -773,6 +773,15 @@ function M.attach_registry_events(registry) end) end) ) + registry:on("update:success", function() + -- SYNCHRONOUS on purpose: mason emits this inside the update + -- success path, before callers' callbacks run — a scheduled clear + -- would leave a same-tick window still reading the stale frozen + -- index. Clearing two locals is pure Lua, fast-event-safe; the + -- next lookup rebuilds (and re-freezes on a bootstrapped registry). + bin_to_package = nil + unfrozen_index = nil + end) end) registry_events_attached = attached end From 39936df321c8eec10f126dd86901e53ae1d389f9 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 01:08:09 +0800 Subject: [PATCH 42/71] fix(lsp): refetch the mason mapping after a registry update lspconfig_to_package froze its first non-empty get_mappings() snapshot; a mapping appearing later (registry update, partial-bootstrap edge) stayed invisible for the session. setup now subscribes update:success (pcall guarded, synchronous pure-lua clear) so package_of refetches on the next consult. --- lua/modules/configs/completion/mason-lspconfig.lua | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lua/modules/configs/completion/mason-lspconfig.lua b/lua/modules/configs/completion/mason-lspconfig.lua index cec243ce..91e164c3 100644 --- a/lua/modules/configs/completion/mason-lspconfig.lua +++ b/lua/modules/configs/completion/mason-lspconfig.lua @@ -369,6 +369,18 @@ M.setup = function() end return lspconfig_to_package[name] end + -- The mapping derives from the registry specs: a registry update must not + -- leave the first non-empty snapshot frozen. SYNCHRONOUS (pure-Lua clear, + -- fast-event-safe): mason emits update:success before update callbacks + -- run, so a scheduled clear would leave a same-tick stale window. Once + -- per setup; pcall guards mason-registry API drift. + if mason_ok and type(mason_registry.on) == "function" then + pcall(function() + mason_registry:on("update:success", function() + lspconfig_to_package = nil + end) + end) + end ---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. From d87a337a50ce58e8bc4bbc1665fc92506597eaa4 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 01:21:16 +0800 Subject: [PATCH 43/71] refactor(tooling): consolidate resolver bookkeeping into single sources four verifier-confirmed equivalences, no behavior change: - the collector's pending counter moved in lockstep with the unsettled set on every path; the flush gate now reads next(unsettled) and the dead nil-name branches are gone (track is only ever called with a string name) - phase-1 validate outcomes collapse from two parallel maps plus a false-vs-string sentinel into one validates[name] record whose existence is the failure mark - split_dep_names(valid, invalid) is now the one definition of a valid dep entry; run() and nvim-lint consume the invalid list instead of inversely re-scanning raw deps (normalize_names stays a single-return wrapper, so the public alias's arity is unchanged) - resolve() asserts spec.configure is a function (every call site passes one) and the timeout fallback is a named constant cross-referenced from the settings doc comment --- lua/core/settings.lua | 3 +- lua/modules/configs/completion/nvim-lint.lua | 13 +- lua/modules/utils/tools.lua | 166 ++++++++++--------- 3 files changed, 90 insertions(+), 92 deletions(-) diff --git a/lua/core/settings.lua b/lua/core/settings.lua index 4e1831b8..f46b8d40 100644 --- a/lua/core/settings.lua +++ b/lua/core/settings.lua @@ -162,7 +162,8 @@ settings["linter_deps"] = { -- Deadline (ms) for background Mason work before the aggregated missing-tool warning -- flushes anyway. Gates each tracked install (its own window) AND the registry refresh --- wait; late completions still recover. Non-positive values use the default. +-- wait; late completions still recover. Missing or non-positive values fall back to +-- the resolver's DEFAULT_TOOL_INSTALL_TIMEOUT_MS (300000) in `modules/utils/tools.lua`. ---@type number settings["tool_install_timeout"] = 300000 diff --git a/lua/modules/configs/completion/nvim-lint.lua b/lua/modules/configs/completion/nvim-lint.lua index 7aeebb8a..27771fcb 100644 --- a/lua/modules/configs/completion/nvim-lint.lua +++ b/lua/modules/configs/completion/nvim-lint.lua @@ -242,7 +242,7 @@ return function() -- Names mapped in `by_ft` resolve lazily on their filetype's first lint -- event; the rest (typos, manual-only linters) get a deferred immediate pass. local raw_deps = require("core.settings").linter_deps - local deps = tools.normalize_names(raw_deps) + local deps, invalid_deps = tools.split_dep_names(raw_deps) local mapped = {} for _, names in pairs(by_ft) do for _, name in ipairs(names) do @@ -257,16 +257,11 @@ return function() immediate[#immediate + 1] = name end end - -- Entries normalize_names drops (non-string / empty) are config mistakes: + -- Entries split_dep_names drops (non-string / empty) are config mistakes: -- forward them raw so the resolver's own sweep reports them in the unknown -- bucket, identically to the other consumers. - if type(raw_deps) == "table" then - for i = 1, table.maxn(raw_deps) do - local entry = raw_deps[i] - if entry ~= nil and (type(entry) ~= "string" or entry == "") then - immediate[#immediate + 1] = entry - end - end + for _, entry in ipairs(invalid_deps) do + immediate[#immediate + 1] = entry end if #immediate > 0 then vim.schedule(function() diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index 6402472a..81b9548d 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -8,26 +8,44 @@ -- must call `M.ensure_mason_on_path()` itself or use `M.find_executable`. local M = {} ----Valid executable names from a caller-supplied spec: string → singleton; ----table → non-string/empty entries dropped, nil holes skipped (maxn, so a ----hole can't truncate the list); anything else → no names. +-- Fallback when `settings.tool_install_timeout` is missing or non-positive; +-- lua/core/settings.lua's doc comment references this constant by name. +local DEFAULT_TOOL_INSTALL_TIMEOUT_MS = 300000 + +---Split a caller-supplied name spec into valid names and the raw entries +---dropped: string → singleton; table → non-string/empty entries collected +---into `invalid` (config mistakes the caller may report), nil holes skipped +---(maxn, so a hole can't truncate the list); anything else → no names. The +---ONE definition of what counts as a valid dep entry. ---@param names any @Executable name(s) as callers pass them. ----@return string[] -local function normalize_names(names) +---@return string[] valid +---@return any[] invalid @Raw non-nil dropped entries, in order. +local function split_dep_names(names) if type(names) == "string" then - return { names } + return { names }, {} end if type(names) ~= "table" then - return {} + return {}, {} end - local out = {} + local out, invalid = {}, {} for i = 1, table.maxn(names) do local name = names[i] if type(name) == "string" and name ~= "" then out[#out + 1] = name + elseif name ~= nil then + invalid[#invalid + 1] = name end end - return out + return out, invalid +end +M.split_dep_names = split_dep_names + +---Valid names only — a single-return wrapper (the parentheses truncate), so +---the public alias's arity never changes under vararg-position calls. +---@param names any +---@return string[] +local function normalize_names(names) + return (split_dep_names(names)) end -- Public alias for dep-list consumers. M.normalize_names = normalize_names @@ -272,7 +290,6 @@ local function missing_collector(title, timeout_ms) -- (name -> { reason = phase-1 fail_reason or false }); each entry owns a -- defer_fn timer that settles it if the closed callback never does. local unsettled = {} - local pending = 0 local flush_scheduled = false local announce_scheduled = false -- Names whose recorded reason is a placeholder (generic install-timeout / @@ -358,7 +375,7 @@ local function missing_collector(title, timeout_ms) -- installs are pending unless forced (done() and per-install timers force: -- their records are final); `emitted` lets late failures still notify. function flush(force) - if not force and pending > 0 then + if not force and next(unsettled) ~= nil then return end if flush_scheduled then @@ -454,52 +471,48 @@ local function missing_collector(title, timeout_ms) return end - -- Only the first in-flight track for a name touches the accounting: a - -- repeat (re-resolve pass / shared-handle piggyback) must not increment - -- `pending` twice against one settle. - local first_track = name == nil or unsettled[name] == nil + -- Only the first in-flight track for a name creates the entry: a + -- repeat (re-resolve pass / shared-handle piggyback) must not add a + -- second settle against one install. `track` is only ever called + -- with a non-empty string name (start_install), so `unsettled` + -- membership IS the whole accounting — the flush gate reads it. + local first_track = unsettled[name] == nil if first_track then - pending = pending + 1 - if name ~= nil then - unsettled[name] = { reason = fail_reason or false } - -- One timer per tracked install, settling only its own entry - -- (a no-op when the closed callback got there first), so a - -- late-tracked install always keeps its full window. Without - -- a configured timeout the entry settles via "closed" only. - if type(timeout_ms) == "number" and timeout_ms > 0 then - vim.defer_fn(function() - local entry = unsettled[name] - if entry == nil then - return - end - unsettled[name] = nil - -- The generic note is a placeholder a later concrete - -- failure may upgrade (see add()). - add( - name, - type(entry.reason) == "string" and entry.reason - or "Mason install did not finish within the timeout (check :Mason for progress)", - entry.reason == false - ) - pending = pending - 1 - -- This entry is final: force past the pending gate. - flush(true) - end, timeout_ms) - end + unsettled[name] = { reason = fail_reason or false } + -- One timer per tracked install, settling only its own entry + -- (a no-op when the closed callback got there first), so a + -- late-tracked install always keeps its full window. Without + -- a configured timeout the entry settles via "closed" only. + if type(timeout_ms) == "number" and timeout_ms > 0 then + vim.defer_fn(function() + local entry = unsettled[name] + if entry == nil then + return + end + unsettled[name] = nil + -- The generic note is a placeholder a later concrete + -- failure may upgrade (see add()). + add( + name, + type(entry.reason) == "string" and entry.reason + or "Mason install did not finish within the timeout (check :Mason for progress)", + entry.reason == false + ) + -- This entry is final: force past the unsettled gate. + flush(true) + end, timeout_ms) end end -- "closed" fires in a fast event context: hop to the main loop. recheck/ - -- on_ready are pcall'd so a throw can't suppress flush; skip the decrement - -- when the deadline already settled this install, but still run on_ready. + -- on_ready are pcall'd so a throw can't suppress flush; clearing the + -- entry is a no-op when the deadline already settled this install, + -- but on_ready still runs. local registered, reg_err = pcall( handle.once, handle, "closed", vim.schedule_wrap(function() - local counted = name == nil or unsettled[name] ~= nil - if name ~= nil then - unsettled[name] = nil - end + unsettled[name] = nil local rc_ok, available = pcall(recheck) if rc_ok and available then if type(on_ready) == "function" then @@ -512,20 +525,14 @@ local function missing_collector(title, timeout_ms) else add(name, fail_reason) end - if counted then - pending = pending - 1 - end flush() end) ) -- once() threw: undo only what THIS track added, or a failed piggyback - -- would decrement pending / clear another caller's in-flight entry. + -- would clear another caller's in-flight entry. if not registered then if first_track then - pending = pending - 1 - if name ~= nil then - unsettled[name] = nil - end + unsettled[name] = nil end add(name, fail_reason or error_reason(reg_err)) elseif started_here and type(name) == "string" and name ~= "" then @@ -554,7 +561,7 @@ local function missing_collector(title, timeout_ms) vim.notify(message, vim.log.levels.INFO, { title = title }) end) end - -- done()'s records are final: force past the pending gate. + -- done()'s records are final: force past the unsettled gate. flush(true) end, } @@ -875,6 +882,10 @@ end ---as trigger-backed (`late = false`), exactly like a caller-side ---vim.schedule wrapper did. function M.resolve(spec) + -- Every call site owns a configure (the resolver's whole job funnels into + -- it): a missing one is a programmer error — fail at the call site instead + -- of silently "succeeding" every configure. + assert(type(spec.configure) == "function", "tools.resolve: spec.configure must be a function") local ok_settings, settings = pcall(require, "core.settings") local timeout_ms = ( ok_settings @@ -882,7 +893,7 @@ function M.resolve(spec) and settings.tool_install_timeout > 0 ) and settings.tool_install_timeout - or 300000 + or DEFAULT_TOOL_INSTALL_TIMEOUT_MS local collector = collectors_by_title[spec.title] if not collector then collector = missing_collector(spec.title, timeout_ms) @@ -918,9 +929,6 @@ function M.resolve(spec) ---error an install can't fix. Any other thrown value (including a config's ---own `{ reason = ... }` table) stays an ordinary failure. local function try_configure(name) - if type(spec.configure) ~= "function" then - return true - end local ok, err = pcall(spec.configure, name, not synchronous) if ok then return true @@ -949,16 +957,15 @@ function M.resolve(spec) session.configure = do_configure -- Phase-1 "validates" failures, surfaced by phase 2 without re-running the - -- config; `false` = failed with no message. Names whose failure was a - -- raise_verbatim config error are flagged separately: installs can't fix those. - local validate_failed = {} - local validate_config_error = {} - ---String reason or nil — the one decoder of the false-vs-string encoding. + -- config. One record per failed name: `reason` (string or nil for a + -- message-less failure) and `config_error` (a raise_verbatim config-layer + -- error an install can't fix). Record EXISTENCE is the failure mark. + local validates = {} ---@param name string ---@return string|nil local function validate_reason(name) - local reason = validate_failed[name] - return type(reason) == "string" and reason or nil + local record = validates[name] + return record and record.reason or nil end -- Install `pkg`, configure on completion; failures keep the phase-1 reason. @@ -994,10 +1001,10 @@ function M.resolve(spec) if ok then return true end - validate_failed[name] = reason or false - if config_error then - validate_config_error[name] = true - end + validates[name] = { + reason = type(reason) == "string" and reason or nil, + config_error = config_error == true, + } end return false end @@ -1030,8 +1037,8 @@ function M.resolve(spec) -- The config already failed this pass. A raise_verbatim failure is a -- config-layer error an install can't fix: report it immediately. Only -- a provisioning failure (missing binary) is answered with an install. - if validate_failed[name] ~= nil then - if not validate_config_error[name] and pkg ~= nil and not pkg:is_installed() then + if validates[name] ~= nil then + if not validates[name].config_error and pkg ~= nil and not pkg:is_installed() then start_install(pkg, name) -- annotates the failure reason itself else collector.mark(name, validate_reason(name)) @@ -1087,14 +1094,9 @@ function M.resolve(spec) -- non-table deps degrade to nothing to resolve, and dropped entries -- (non-string / empty — config mistakes) surface in the unknown bucket -- instead of vanishing or flowing into module-name concatenation. - local deps = normalize_names(spec.deps) - if type(spec.deps) == "table" then - for i = 1, table.maxn(spec.deps) do - local entry = spec.deps[i] - if entry ~= nil and (type(entry) ~= "string" or entry == "") then - collector.mark_unknown(entry == "" and '""' or entry) - end - end + local deps, invalid_entries = split_dep_names(spec.deps) + for _, entry in ipairs(invalid_entries) do + collector.mark_unknown(entry == "" and '""' or entry) end -- Phase 1: configure everything resolvable without the registry. local visited = {} From f9d111460d483f2d7f376e5905b47e2b40fbec7d Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 01:21:25 +0800 Subject: [PATCH 44/71] refactor(dap): inline the single-caller client-config loader tools.load_first_usable had exactly one caller: dap's memoized load_client_config. the two-candidate loop now lives at that call site on top of the public load_module_or_report, preserving the contract exactly (first success wins, a higher-precedence broken candidate's reason survives alongside a lower-precedence success, any_exists accumulates); the export and its docblock are gone. --- lua/modules/configs/tool/dap/init.lua | 29 +++++++++++++++++++----- lua/modules/utils/tools.lua | 32 +-------------------------- 2 files changed, 24 insertions(+), 37 deletions(-) diff --git a/lua/modules/configs/tool/dap/init.lua b/lua/modules/configs/tool/dap/init.lua index d25631ca..994cd6d1 100644 --- a/lua/modules/configs/tool/dap/init.lua +++ b/lua/modules/configs/tool/dap/init.lua @@ -15,17 +15,34 @@ return function() end local client_config_cache = {} - ---Load the first usable client config (user override, then repo preset) via - ---the shared first-usable loader, memoized per adapter: the resolver consults - ---it from several predicates (has_local_config, unknown_of) plus the handler, - ---and an uncached miss would re-run a failed require each time. + ---Load the first usable client config (user override, then repo preset), + ---memoized per adapter: the resolver consults it from several predicates + ---(has_local_config, unknown_of) plus the handler, and an uncached miss + ---would re-run a failed require each time. First success wins outright (no + ---merge-base semantics); a higher-precedence exists-but-broken candidate's + ---reason survives alongside a lower-precedence success, so the handler can + ---refuse to fall past a broken override (usable_or_raise raises it). ---@param name string ---@return any value, string|nil broken_reason, boolean any_exists, string|nil winner local function load_client_config(name) local cached = client_config_cache[name] if not cached then - local value, broken_reason, any_exists, winner = tools.load_first_usable(client_modules(name), "nvim-dap") - cached = { value = value, broken_reason = broken_reason, any_exists = any_exists, winner = winner } + cached = { value = nil, broken_reason = nil, any_exists = false, winner = nil } + for _, module in ipairs(client_modules(name)) do + local ok, value, exists, reason = tools.load_module_or_report(module, "nvim-dap") + if ok then + cached.value = value + cached.winner = module + cached.any_exists = true + break + end + if exists then + cached.any_exists = true + if cached.broken_reason == nil then + cached.broken_reason = reason + end + end + end client_config_cache[name] = cached end return cached.value, cached.broken_reason, cached.any_exists, cached.winner diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index 81b9548d..820ab0af 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -132,43 +132,13 @@ function M.load_module_or_report(module, title) return false, nil, true, reason end ----Load the first candidate module that loads (highest precedence first; no ----merge-base semantics — spec-merging consumers like the LSP server_info ----don't use this). A broken candidate keeps `any_exists` true and its reason ----is returned even alongside a lower-precedence success, so callers can ----refuse to fall past a broken override. Shape checks are the caller's job ----(see `usable_or_raise`): require() never yields nil for a successful load — ----a module without a return statement loads as `true`. ----@param modules string[] @Module names, highest precedence first. ----@param title? string @Notification title for broken-module load errors. ----@return any value @First loaded module value, or nil. ----@return string|nil broken_reason @Highest-precedence exists-but-broken reason. ----@return boolean any_exists @Whether any candidate exists on the search paths. ----@return string|nil winner @The module name that loaded, when value is non-nil. -function M.load_first_usable(modules, title) - local broken_reason, any_exists = nil, false - for _, module in ipairs(modules) do - local ok, value, exists, reason = M.load_module_or_report(module, title) - if ok then - return value, broken_reason, true, module - end - if exists then - any_exists = true - if broken_reason == nil then - broken_reason = reason - end - end - end - return nil, broken_reason, any_exists -end - ---Enforce the no-fall-through contract for a locally loaded config: a broken ---candidate raises its reason verbatim (it must never read as success to the ---resolver — that would suppress both the warning and the install fallback), ---and a loaded value outside the accepted shapes raises a labeled shape ---error. Returns the value (nil = no config exists) once safe to consume. ---@param value any @The loaded config value (nil when no candidate exists). ----@param broken_reason string|nil @From load_first_usable / server_info. +---@param broken_reason string|nil @From a candidate-loader (DAP's load_client_config / LSP's server_info). ---@param opts { label: string, expected: string, shapes: table } ---@return any value function M.usable_or_raise(value, broken_reason, opts) From 3712ef3253426f5890eb94ef0182f1a2bdf649c6 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 11:02:00 +0800 Subject: [PATCH 45/71] fix(lint): add an idle-gated parity sweep for never-opened filetypes a linter_deps name mapped in by_ft resolved only on its filetype's first lint event: a session that never opened that filetype neither installed nor reported the tool, and no resolver session existed for :ToolsRetry or Mason install events to pick up. mason-lspconfig has a 120s parity sweep and conform resolves everything immediately; nvim-lint alone had no backstop. the 120s timer only arms a one-shot CursorHold(I) gate so the sweep never lands mid-keystroke-burst; the gate warms linter module loads one per tick (golangcilint blocks at load, F24 keeps the probe synchronous) after ensure_mason_on_path() so PATH-sensitive load-time state matches the event path; pending stays owned by ensure_resolved through the warm window and the final drain resolves only still-unclaimed names in one aggregated batch; a vim.g generation token plus the augroup clear keep a re-sourced config's stale closures inert. --- lua/modules/configs/completion/nvim-lint.lua | 93 ++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/lua/modules/configs/completion/nvim-lint.lua b/lua/modules/configs/completion/nvim-lint.lua index 27771fcb..08178dfa 100644 --- a/lua/modules/configs/completion/nvim-lint.lua +++ b/lua/modules/configs/completion/nvim-lint.lua @@ -301,4 +301,97 @@ return function() end end, }) + + -- Parity backstop, mirroring mason-lspconfig's sweep intent: a mapped + -- linter whose filetype never fires a lint event this session must still + -- be resolved (installed or reported) instead of silently skipped. + -- + -- Shape, driven by two constraints: + -- * The probe requires linter modules and golangcilint blocks at load + -- (deliberate, see refresh_linter above), so the sweep must not land + -- mid-editing: the 120s timer only ARMS a one-shot CursorHold(I) idle + -- gate, and the gate warms module loads one per tick before a single + -- aggregated resolve. + -- * A config re-source strands this closure with a stale `pending` + -- table: every async hop re-checks a vim.g generation token (survives + -- both re-source flavors), and the gate autocmd dies with the + -- augroup's clear=true above. + local SWEEP_DELAY_MS = 120000 -- keep in sync with mason-lspconfig's parity sweep + local gen = (vim.g._nvimlint_sweep_gen or 0) + 1 + vim.g._nvimlint_sweep_gen = gen + local function sweep_live() + return vim.g._nvimlint_sweep_gen == gen + end + + local function run_sweep() + -- Snapshot WITHOUT draining: `pending` stays owned by ensure_resolved + -- until the moment of resolution, so a filetype opened mid-warm still + -- takes its normal first-event path (resolve before its first lint). + local names = {} + for name in pairs(pending) do + names[#names + 1] = name + end + if #names == 0 then + return + end + table.sort(names) -- pairs order is nondeterministic; keep the warning stable + -- The event path loads linter modules inside tools.resolve(), AFTER it + -- put Mason's bin dir on $PATH. PATH-sensitive modules (golangcilint + -- RUNS its binary at load to compute args) must see the same + -- environment when the warm phase loads them first instead. + tools.ensure_mason_on_path() + -- Warm the __index module loads one per tick (bounds any blocking + -- load to a single tick), then resolve everything still pending in + -- ONE batch so the missing-tools warning stays aggregated. Loader + -- errors are swallowed here on purpose: resolve_batch's probe + -- re-derives broken-vs-unknown. + local index = 0 + local function step() + if not sweep_live() then + return + end + index = index + 1 + if index <= #names then + local name = names[index] + if pending[name] then -- skip names an event already claimed + pcall(function() + local _ = lint.linters[name] + end) + end + vim.schedule(step) + return + end + local batch = {} + for _, name in ipairs(names) do + if pending[name] then + pending[name] = nil + batch[#batch + 1] = name + end + end + if #batch > 0 then + resolve_batch(batch) -- order-preserving subset of sorted `names` + end + end + step() + end + + vim.defer_fn(function() + if not sweep_live() or next(pending) == nil then + return + end + -- Arm, don't run: CursorHold(I) is the first real idle window, so the + -- module loads never land mid-keystroke-burst. One-shot across BOTH + -- events via del_autocmd — a callback returning true only drops the + -- registration of the event that fired, leaving the sibling armed. + local gate = nil + gate = vim.api.nvim_create_autocmd({ "CursorHold", "CursorHoldI" }, { + group = "NvimLint", + callback = function() + pcall(vim.api.nvim_del_autocmd, gate) + if sweep_live() then + run_sweep() + end + end, + }) + end, SWEEP_DELAY_MS) end From e6f08f8a5c1b616df2b6e7ee9c65e09acf810f2d Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 11:51:46 +0800 Subject: [PATCH 46/71] fix(dap): raise spawn-free at config time when mason can provision debugpy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the config-time availability check ran the full debugpy cascade, including the blocking vim.system():wait() import probe, on the shared :Dap tick where the resolver validates every dap_deps client — debugging go paid 200-500ms for python's probe whenever the debugpy-adapter shim was absent. the first shimless check now raises without spawning when the raise can actually provision: both mason.nvim and mason-nvim-dap.nvim are lazy-managed with their code on disk, the mapper still derives a package for python, and mason-registry resolves that package — every link pcall-guarded and degrading to the probe path on any drift. the raise is one-shot per process (module-level latch): if provisioning does not produce the shim, every later validates run falls back to the bounded import probe, so installed-but-shimless and mason-less pip setups keep their recovery and a capable system python still passes without a false missing-warning. launch-path semantics (ttl/budget negative cache) are untouched. --- .../configs/tool/dap/clients/python.lua | 97 ++++++++++++++++++- 1 file changed, 93 insertions(+), 4 deletions(-) diff --git a/lua/modules/configs/tool/dap/clients/python.lua b/lua/modules/configs/tool/dap/clients/python.lua index 7c99efa8..11db30d1 100644 --- a/lua/modules/configs/tool/dap/clients/python.lua +++ b/lua/modules/configs/tool/dap/clients/python.lua @@ -1,5 +1,14 @@ -- https://github.com/mfussenegger/nvim-dap/wiki/Debug-Adapter-installation#python -- https://github.com/microsoft/debugpy/wiki/Debug-configuration-settings + +-- One optimistic spawn-free provisioning raise per process (see the +-- availability check at the bottom). Module-level on purpose: every configure +-- call (validates, :ToolsRetry, install events, late configure) rebuilds the +-- closure below, and the fallback-to-probe contract needs state that survives +-- re-runs. A config reload (package.loaded wipe) resets it, consistent with +-- re-source semantics elsewhere. +local provision_raise_used = false + return function() local dap = require("dap") local utils = require("modules.utils.dap") @@ -194,11 +203,91 @@ return function() }, } + -- Spawn-free evidence that a raise will reach a provisioner. No Mason-root + -- check on purpose: a clean bootstrap has no data dir yet (the raise is + -- what creates it), a custom root is unknowable before Mason loads, and a + -- stale dir proves nothing — the evidence that matters is that BOTH halves + -- of the provisioning pipeline are managed and present: mason.nvim (the + -- installer) and mason-nvim-dap.nvim (the python→debugpy mapper) are + -- independent lazy specs (plugins/completion.lua vs plugins/tool.lua), and + -- a managed spec whose code is not on disk cannot provision this session. + -- lazy.core.config is resident (lazy.nvim bootstraps this config); reading + -- its spec table loads no plugin. + local function mason_provisionable() + -- Guard the whole read, not just the require: a drifted module that + -- returns a non-table (an empty module body makes require return + -- `true`) must degrade to the probe path, never throw before the + -- latch/probe below run. + local ok, lazy_config = pcall(require, "lazy.core.config") + if not ok or type(lazy_config) ~= "table" or type(lazy_config.plugins) ~= "table" then + return false + end + -- Entries are untrusted for the same reason: a drifted spec shape must + -- degrade to false, never throw before the latch/probe run. + local mason = lazy_config.plugins["mason.nvim"] + local mapper = lazy_config.plugins["mason-nvim-dap.nvim"] + if + not ( + type(mason) == "table" + and type(mapper) == "table" + and type(mason.dir) == "string" + and vim.uv.fs_stat(mason.dir) ~= nil + and type(mapper.dir) == "string" + and vim.uv.fs_stat(mapper.dir) ~= nil + ) + then + return false + end + -- The raise can only provision if the mapper still derives a package + -- for this adapter — checked as "maps to SOME non-empty string", not a + -- hard-coded package name (an upstream rename must not rot this gate). + -- Loading Mason modules here keeps the ':Dap tick stays Mason-free' + -- rule intact in spirit: the gate only runs shimless, where the raise + -- path's phase 2 loads the same modules moments later on this very + -- resolution; any drift degrades to the probe path. + local map_ok, source = pcall(require, "mason-nvim-dap.mappings.source") + if + not ( + map_ok + and type(source) == "table" + and type(source.nvim_dap_to_package) == "table" + and type(source.nvim_dap_to_package.python) == "string" + and source.nvim_dap_to_package.python ~= "" + ) + then + return false + end + -- ...and only if the registry can actually resolve that package: a + -- stale mapping (or an unrefreshed/broken registry) would make the + -- raise dead-end in a mark instead of an install, so those states + -- keep the probe path. + local reg_ok, registry = pcall(require, "mason-registry") + if not reg_ok or type(registry) ~= "table" or type(registry.has_package) ~= "function" then + return false + end + local has_ok, has = pcall(registry.has_package, source.nvim_dap_to_package.python) + return has_ok and has == true + end + -- Availability check LAST (contract: tool/dap/init.lua resolver spec); the -- raise is the provisioning signal — the attach adapters stay registered. - -- A capable system python passes (no needless install); the bounded import - -- probe spawns only when the fast probes miss. - if not debugpy_command() then - error(no_debugpy .. " (remote attach works regardless)", 0) + -- The FIRST shimless check with a provisioner present raises WITHOUT the + -- blocking import probe: probing a system python here would freeze the + -- shared :Dap tick that validates the OTHER dap clients too (debugging Go + -- paid for python's probe), and when the raise leads to an install the + -- probe was pure waste. One shot per process: if provisioning did not + -- produce the shim (package installed but broken, registry down), every + -- later validates run — :ToolsRetry, install events, late configure — + -- falls back to the bounded import probe below, so a capable system + -- python still validates and clears pending state. A capable system + -- python on a Mason-less setup passes as before (no needless install). + if not fast_command() then + if not provision_raise_used and mason_provisionable() then + provision_raise_used = true + error(no_debugpy .. " (remote attach works regardless)", 0) + end + if not debugpy_command() then + error(no_debugpy .. " (remote attach works regardless)", 0) + end end end From 0e7e391176c31f5b11a8a900baca94f6b5425232 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 11:51:46 +0800 Subject: [PATCH 47/71] fix(dap): guard the venv fallback against a nil cwd vim.uv.cwd() returns nil when the process working directory has been deleted; the "Debug (using venv)" pythonPath concatenated it unchecked and aborted the launch with a concat error. a dead cwd now degrades to the interpreter-name fallback. --- lua/modules/configs/tool/dap/clients/python.lua | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/lua/modules/configs/tool/dap/clients/python.lua b/lua/modules/configs/tool/dap/clients/python.lua index 11db30d1..413fa069 100644 --- a/lua/modules/configs/tool/dap/clients/python.lua +++ b/lua/modules/configs/tool/dap/clients/python.lua @@ -191,14 +191,16 @@ return function() return python end - -- Otherwise, fall back to check if there are any local venvs available. - 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 - return is_windows and "pythonw.exe" or "python3" + -- Otherwise fall back to any local venv — guarded: vim.uv.cwd() + -- returns nil when the process working directory was deleted. + if cwd then + venv = vim.fn.isdirectory(cwd .. "/venv") == 1 and cwd .. "/venv" or cwd .. "/.venv" + python = venv_python(venv) + if vim.fn.executable(python) == 1 then + return python + end end + return is_windows and "pythonw.exe" or "python3" end, }, } From 84eddddfca5ee40ebf4f1dbd73d9a567278b9a69 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 12:01:53 +0800 Subject: [PATCH 48/71] fix(lsp): guard get_mappings against mason-lspconfig api drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package_of called mason_lspconfig.get_mappings() bare while every sibling registry touch in the file is pcall'd; a drifted or missing get_mappings threw into the resolver's outer pcall, error-marking every off-$PATH server with a raw lua error string. the read is now pcall'd and typed down to mappings.lspconfig_to_package being a table, degrading to the empty map — $PATH-only classification with the normal missing/unknown report, re-fetch-while-empty semantics preserved. --- lua/modules/configs/completion/mason-lspconfig.lua | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/lua/modules/configs/completion/mason-lspconfig.lua b/lua/modules/configs/completion/mason-lspconfig.lua index 91e164c3..4cfb1aec 100644 --- a/lua/modules/configs/completion/mason-lspconfig.lua +++ b/lua/modules/configs/completion/mason-lspconfig.lua @@ -364,8 +364,17 @@ M.setup = function() 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 {} + -- pcall like every other registry touch in this file: get_mappings + -- is mason-lspconfig-v2 surface, and drift must degrade to + -- $PATH-only classification (empty map → nil → the plain + -- missing/unknown report), not throw a raw Lua error into the + -- resolver's error-mark. The empty map keeps the re-fetch-while- + -- empty semantics: a persistently drifted call re-degrades per + -- lookup instead of freezing a bad shape. + local ok, mappings = pcall(mason_lspconfig.get_mappings) + lspconfig_to_package = (ok and type(mappings) == "table" and type(mappings.lspconfig_to_package) == "table") + and mappings.lspconfig_to_package + or {} end return lspconfig_to_package[name] end From e14f4457007b6c19b6fb554d958d30bc80a71cc7 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 12:01:53 +0800 Subject: [PATCH 49/71] fix(tooling): flag each registry event subscription separately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit attach_registry_events wrapped both registry:on subscriptions in one pcall deriving one flag: a drift throw from the second subscription after the first registered left the flag false, and the next attach registered the install handler a second time — duplicate retry_pending walks on every later install. each subscription now has its own pcall and its own flag: a succeeded half never re-registers, a failed half retries on the next attach, and total failure keeps the status quo. callback bodies unchanged. --- lua/modules/utils/tools.lua | 84 +++++++++++++++++++++---------------- 1 file changed, 49 insertions(+), 35 deletions(-) diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index 820ab0af..bed2af5e 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -712,9 +712,15 @@ local function retry_pending(eligible) end end --- Attached once per session lifetime; pcall guards mason-registry API drift --- (no events = status quo: the tool is picked up on the next launch). -local registry_events_attached = false +-- Attached once per session lifetime; each subscription pcall'd SEPARATELY +-- and flagged SEPARATELY (mason-registry API-drift guard): with one shared +-- flag, a throw from the second subscription after the first registered +-- would leave the flag unset, and the next attach would register the install +-- handler TWICE — duplicate retry_pending walks on every later install. A +-- still-false half retries on the next attach; no events at all = status +-- quo: the tool is picked up on the next launch. +local install_events_attached = false +local update_events_attached = false ---Subscribe to Mason install successes so a package installed mid-session by ---ANY means (resolver-started, :MasonInstall, the :Mason UI) finishes the @@ -723,44 +729,52 @@ local registry_events_attached = false ---"a fully-provisioned setup never loads Mason" intact. ---@param registry table|nil @The mason-registry module (or nil). function M.attach_registry_events(registry) - if registry_events_attached or type(registry) ~= "table" or type(registry.on) ~= "function" then + if install_events_attached and update_events_attached then return end - local attached = pcall(function() - registry:on( - "package:install:success", - -- The emitter fires from the install handle's lifecycle (fast event - -- context): hop to the main loop before touching consumer configs. - vim.schedule_wrap(function(pkg) - local pkg_name = type(pkg) == "table" and type(pkg.name) == "string" and pkg.name or nil - if not pkg_name then - return - end - retry_pending(function(session, name) - -- An install still in flight is owned by its closed callback. - if session.is_unsettled(name) then - return false - end - local ok, mapped = pcall(session.spec.package_of, name, registry) - if ok and mapped == pkg_name then - return true + if type(registry) ~= "table" or type(registry.on) ~= "function" then + return + end + if not install_events_attached then + install_events_attached = pcall(function() + registry:on( + "package:install:success", + -- The emitter fires from the install handle's lifecycle (fast event + -- context): hop to the main loop before touching consumer configs. + vim.schedule_wrap(function(pkg) + local pkg_name = type(pkg) == "table" and type(pkg.name) == "string" and pkg.name or nil + if not pkg_name then + return end - local bins_ok, bins = pcall(session.spec.binaries_of, name, nil) - return bins_ok and M.find_executable(bins) ~= nil + retry_pending(function(session, name) + -- An install still in flight is owned by its closed callback. + if session.is_unsettled(name) then + return false + end + local ok, mapped = pcall(session.spec.package_of, name, registry) + if ok and mapped == pkg_name then + return true + end + local bins_ok, bins = pcall(session.spec.binaries_of, name, nil) + return bins_ok and M.find_executable(bins) ~= nil + end) end) + ) + end) + end + if not update_events_attached then + update_events_attached = pcall(function() + registry:on("update:success", function() + -- SYNCHRONOUS on purpose: mason emits this inside the update + -- success path, before callers' callbacks run — a scheduled clear + -- would leave a same-tick window still reading the stale frozen + -- index. Clearing two locals is pure Lua, fast-event-safe; the + -- next lookup rebuilds (and re-freezes on a bootstrapped registry). + bin_to_package = nil + unfrozen_index = nil end) - ) - registry:on("update:success", function() - -- SYNCHRONOUS on purpose: mason emits this inside the update - -- success path, before callers' callbacks run — a scheduled clear - -- would leave a same-tick window still reading the stale frozen - -- index. Clearing two locals is pure Lua, fast-event-safe; the - -- next lookup rebuilds (and re-freezes on a bootstrapped registry). - bin_to_package = nil - unfrozen_index = nil end) - end) - registry_events_attached = attached + end end ---Re-attempt every pending dep whose tool has since appeared — the manual From 84c58554597661b2271151af2acfee145f4acc8c Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 12:01:53 +0800 Subject: [PATCH 50/71] fix(dap): validate the delve remote-attach host shape the remote-attach branch validated port strictly but passed config.host through untouched, so a non-string or empty host surfaced as an opaque connection failure instead of the clear config-time error the port already gets. host now mirrors the port contract for shape only (hosts are free-form, so no format guessing): nil keeps the 127.0.0.1 default, anything else must be a non-empty string. --- lua/modules/configs/tool/dap/clients/delve.lua | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/lua/modules/configs/tool/dap/clients/delve.lua b/lua/modules/configs/tool/dap/clients/delve.lua index 77d55295..c5c10016 100644 --- a/lua/modules/configs/tool/dap/clients/delve.lua +++ b/lua/modules/configs/tool/dap/clients/delve.lua @@ -31,9 +31,25 @@ return function() end port = n end + -- Same contract as `port` above: a malformed value errors at config + -- time instead of surfacing as an opaque connection failure. Hosts + -- are free-form (hostname/IPv4/IPv6), so only the shape is checked. + local host = "127.0.0.1" + if config.host ~= nil then + if type(config.host) ~= "string" or config.host == "" then + error( + string.format( + "delve remote attach: invalid `host` %s (want a non-empty string)", + vim.inspect(config.host) + ), + 0 + ) + end + host = config.host + end callback({ type = "server", - host = config.host or "127.0.0.1", + host = host, port = port, }) else From c9ac3d0943fe8fb5e20d0d5b8a494827f998ec30 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 12:41:22 +0800 Subject: [PATCH 51/71] fix(tooling): let a set-up mason.settings root win over a cached $MASON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mason_root evaluated its sources once: a pre-mason-load first call with $MASON set cached the env value permanently, so the documented higher-priority mason.settings.install_root_dir was never adopted once mason loaded — call timing inverted the priority. worse, a loaded-but- not-set-up mason.settings (mason-registry requires it with the DEFAULTS; only setup() applies a custom root) could outrank $MASON. the settings source is now read live each call, gated on mason.has_setup: presence decides the branch, existence decides the return (a configured-but-uncreated root yields nil, never a fallback to a wrong root). $MASON stays cached as mason_env_root; the data-dir guess and the user-override memo are unchanged. --- lua/modules/utils/tools.lua | 66 +++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index bed2af5e..90dce429 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -621,42 +621,52 @@ function M.package_binaries(pkg, fallback) end ---Mason's install root WITHOUT loading Mason (this runs from every resolve()): ----a loaded mason.settings, then $MASON, then the default data-dir guess. The ----guess only counts when no `user.configs.mason` override exists (the one ----supported home for a custom root) and is never cached; a cached root is ----re-checked for existence each call (the dir appears after the first install). +---a set-up mason.settings always wins — checked LIVE each call, because the +---module may load long after the first resolve cached a fallback and call +---timing must not invert the priority; gated on mason.has_setup because +---mason-registry requires mason.settings at load with the DEFAULTS, and only +---setup() applies a user's custom root — then a cached $MASON, then the +---default data-dir guess. The guess only counts when no `user.configs.mason` +---override exists (the one supported home for a custom root) and is never +---cached; every returned root is re-checked for existence each call (the dir +---appears after the first install). ---@return string|nil -local mason_root_dir = nil +local mason_env_root = nil -- The user-override existence check is memoized separately from module_path's -- hit-only cache: it merely gates the default-dir guess below, and a user -- adding user/configs/mason mid-session needs a restart for a new root -- anyway — so unlike a general module miss, staleness here is harmless. local mason_override_absent = nil function M.mason_root() - if not mason_root_dir then - local settings = package.loaded["mason.settings"] - if - type(settings) == "table" - and type(settings.current) == "table" - 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 - -- Never require() mason.settings here — it lazy-loads all of mason.nvim - -- during a pure discovery probe, defeating "Mason optional". - local guess = vim.fn.stdpath("data") .. "/mason" - if mason_override_absent == nil then - mason_override_absent = M.module_path("user.configs.mason") == nil - end - if mason_override_absent and vim.uv.fs_stat(guess) then - return guess - end - end + local mason = package.loaded["mason"] + local settings = package.loaded["mason.settings"] + if + type(mason) == "table" + and mason.has_setup == true + and type(settings) == "table" + and type(settings.current) == "table" + and type(settings.current.install_root_dir) == "string" + then + -- Presence decides the branch, existence decides the return: a + -- configured-but-not-yet-created root yields nil, it must NOT fall + -- back to a wrong root. + local root = settings.current.install_root_dir + return vim.uv.fs_stat(root) and root or nil + end + if not mason_env_root and type(vim.env.MASON) == "string" and vim.env.MASON ~= "" then + mason_env_root = vim.env.MASON + end + if mason_env_root then + return vim.uv.fs_stat(mason_env_root) and mason_env_root or nil + end + -- Never require() mason.settings here — it lazy-loads all of mason.nvim + -- during a pure discovery probe, defeating "Mason optional". + local guess = vim.fn.stdpath("data") .. "/mason" + if mason_override_absent == nil then + mason_override_absent = M.module_path("user.configs.mason") == nil end - if mason_root_dir and vim.uv.fs_stat(mason_root_dir) then - return mason_root_dir + if mason_override_absent and vim.uv.fs_stat(guess) then + return guess end return nil end From ea74de8ebcd3910355d60265a16e616193623d61 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 12:41:22 +0800 Subject: [PATCH 52/71] fix(tooling): re-check $PATH membership on every ensure_mason_on_path call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the mason_path_added flag short-circuited before the membership check, so a $PATH overwritten mid-session was never healed and a root that switched once mason.setup applied a different one never got its bin appended. the flag is gone: the exact-entry membership check each call is the idempotence (one plain find per resolve run). append-only on purpose: identical strings in an externally mutable list admit no robust ownership proof, so nothing is ever removed — and per mason PATH mode nothing needs to be (prepend re-orders, append is tail ordering by choice). mason PATH="skip" remains a documented deviation: the append keeps availability classification and bare-name spawns in agreement; honoring skip coherently needs absolute-command configuration in every consumer, tracked as follow-up. --- lua/modules/utils/tools.lua | 38 +++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index 90dce429..ead450aa 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -3,9 +3,10 @@ -- and `M.resolve` as the shared loop. -- -- Only mason.setup() (lazy-loaded) puts Mason's bin dir on $PATH, so resolve() --- APPENDS it once first (`M.ensure_mason_on_path`) — appended, not prepended, --- so a system copy still wins. A bare-name spawn that bypasses `M.resolve` --- must call `M.ensure_mason_on_path()` itself or use `M.find_executable`. +-- keeps it there first (`M.ensure_mason_on_path`, idempotent membership +-- check) — appended, not prepended, so a system copy still wins. A bare-name +-- spawn that bypasses `M.resolve` must call `M.ensure_mason_on_path()` itself +-- or use `M.find_executable`. local M = {} -- Fallback when `settings.tool_install_timeout` is missing or non-positive; @@ -151,15 +152,29 @@ function M.usable_or_raise(value, broken_reason, opts) return value end -local mason_path_added = false - ----Append Mason's bin dir to $PATH once (see the file header) — appended so a ----system copy still wins. Idempotent; while the Mason root is still unknown ----the flag stays unset so a later call retries. +---Keep Mason's bin dir on $PATH (see the file header) — appended so a system +---copy still wins. Idempotent via the exact-entry membership check each call +---(one plain find over $PATH): a set-once flag would skip exactly the cases +---the recheck exists for — a $PATH something overwrote mid-session, and a +---root that switched once mason.setup applied a different one (see +---mason_root). APPEND-ONLY on purpose: identical strings in an externally +---mutable list admit no robust ownership proof, so nothing is ever removed — +---and per Mason PATH mode nothing needs to be: under the default +---`PATH = "prepend"` mason.setup itself prepends the new root's bin on a +---switch, out-preceding any stale appended entry for same-named shims (the +---stale tail then only serves old-root-only names); under `PATH = "append"` +---the user chose tail ordering, so a pre-switch stale entry preceding the +---post-switch one is that mode's own semantics (same-name shadowing until +---restart accepted). KNOWN DEVIATION: mason's `PATH = "skip"` is NOT honored +---here — deliberately. The resolver's availability verdicts (find_executable, +---consulted by every resolve()) and its consumers' bare-name spawns +---(conform/nvim-lint commands, LSP cmds) must AGREE, and this append is what +---keeps them agreeing; honoring skip would classify a Mason-only tool as +---available while its spawn fails, turning the aggregated report into a lie. +---Honoring skip coherently requires absolute-command configuration across +---every bare-spawn consumer — a cross-cutting change tracked as follow-up +---work, out of this module's scope. function M.ensure_mason_on_path() - if mason_path_added then - return - end local root = M.mason_root() if not root then return @@ -172,7 +187,6 @@ function M.ensure_mason_on_path() if not (sep .. path .. sep):find(sep .. bin .. sep, 1, true) then vim.env.PATH = path ~= "" and (path .. sep .. bin) or bin end - mason_path_added = true end ---Resolve the first of the given executable names, or `error()` with the From 87ad59424777a5c54d41216b5289cda907073eda Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 12:50:31 +0800 Subject: [PATCH 53/71] refactor(tooling): share the lazy mason-registry thunk across resolvers the "never load mason-registry on a provisioned setup" thunk existed as byte-identical copies (code and comment) in the dap resolver spec and in resolve_runtime_tools. tools.default_registry is now the one copy both specs reference, so a change to the load guard cannot drift between consumers. spec.registry semantics unchanged: still a value-or-thunk, still only invoked in phase 2. --- lua/modules/configs/tool/dap/init.lua | 8 +++----- lua/modules/utils/tools.lua | 16 +++++++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/lua/modules/configs/tool/dap/init.lua b/lua/modules/configs/tool/dap/init.lua index 994cd6d1..9d182bb5 100644 --- a/lua/modules/configs/tool/dap/init.lua +++ b/lua/modules/configs/tool/dap/init.lua @@ -299,11 +299,9 @@ return function() tools.resolve({ title = "DAP", deps = settings.dap_deps, - -- 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 - end, + -- The shared lazy thunk (tools.default_registry): a fully-provisioned + -- setup never loads mason-registry. + registry = tools.default_registry, package_of = function(name) return mason_maps().source.nvim_dap_to_package[name] end, diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index ead450aa..bc2984de 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -189,6 +189,16 @@ function M.ensure_mason_on_path() end end +---Lazy mason-registry thunk for resolve() specs — the ONE copy (dap and the +---runtime-tools resolver share it, so a change to the load guard cannot +---drift between consumers). Passed as a spec `registry` value: only phase 2 +---calls it, so a fully-provisioned setup never loads mason-registry. +---@return table|nil +function M.default_registry() + local ok, resolved = pcall(require, "mason-registry") + return ok and resolved or nil +end + ---Resolve the first of the given executable names, or `error()` with the ---install hint — at level 0 so the message carries no "file:line:" prefix. ---@param names string|string[] @Executable name(s), probed in order. @@ -1283,11 +1293,7 @@ function M.resolve_runtime_tools(title, deps, probe, configure, opts) M.resolve({ title = title, deps = deps, - -- 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 - end, + registry = M.default_registry, package_of = function(name, registry) local binary = info(name).binary if not registry or not binary then From 8daf5be9da17d5174dbfbde4b5068ec215bcbfa4 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 12:50:31 +0800 Subject: [PATCH 54/71] fix(tooling): normalize a drifted registry thunk result in phase 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run() re-inlined the thunk resolution that session.resolve_registry already owns, and the inline copy lacked the final table check: a drifted thunk returning a non-table crashed phase 2 outright ("attempt to index local registry (a boolean value)"), aborting the whole resolve pass. run() now resolves through the session helper, so a non-table result degrades to the nil-registry path — a clean aggregated missing report instead of a crash. --- lua/modules/utils/tools.lua | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index bc2984de..24129d75 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -1162,13 +1162,11 @@ function M.resolve(spec) sessions[#sessions + 1] = session -- Phase 2: resolve leftovers against the registry (value, nil, or lazy - -- thunk — only called here, so a fully-provisioned subsystem never loads - -- Mason), refreshing a never-bootstrapped one first. - local registry = spec.registry - if type(registry) == "function" then - local ok, resolved = pcall(registry) - registry = ok and resolved or nil - end + -- thunk — resolved through session.resolve_registry, and only here, so + -- a fully-provisioned subsystem never loads Mason), refreshing a + -- never-bootstrapped one first. The helper also normalizes a drifted + -- non-table result to nil instead of letting it crash phase 2. + local registry = session.resolve_registry() -- The registry is loaded anyway: make sure mid-session installs hand off. if registry then M.attach_registry_events(registry) From 6242a03dcb635d74f42b1f70a14d9f42be274288 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 14:08:40 +0800 Subject: [PATCH 55/71] refactor(lsp): classify lsp_deps through split_dep_names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_deps hand-rolled the valid/invalid dep classification with table.maxn although tools.split_dep_names is the one definition of what counts as a valid dep entry and nvim-lint already delegates to it. the container-shape policy stays here (a non-table lsp_deps is dropped whole, exactly as before); invalid entries now land at the immediate batch's tail instead of interleaved — unobservable, the collector sorts its report and classification is per-name. --- .../configs/completion/mason-lspconfig.lua | 63 ++++++++++--------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/lua/modules/configs/completion/mason-lspconfig.lua b/lua/modules/configs/completion/mason-lspconfig.lua index 4cfb1aec..3a129cc2 100644 --- a/lua/modules/configs/completion/mason-lspconfig.lua +++ b/lua/modules/configs/completion/mason-lspconfig.lua @@ -586,43 +586,46 @@ M.setup = function() -- default registrations) defer to their filetype's first buffer, with the -- sweep as the parity backstop. resolve_deps = function() - local raw = type(settings.lsp_deps) == "table" and settings.lsp_deps or {} local immediate = {} deferred_by_ft = {} handed_off = {} - for index = 1, table.maxn(raw) do - local entry = raw[index] - if type(entry) ~= "string" or entry == "" then - if entry ~= nil then - immediate[#immediate + 1] = entry + -- Container-shape policy stays here (a non-table lsp_deps is dropped + -- whole, exactly as before — split_dep_names would treat a bare string + -- as a singleton dep); only the per-entry valid/invalid rule is + -- delegated to the one definition (tools.split_dep_names). + local valid, invalid = tools.split_dep_names(type(settings.lsp_deps) == "table" and settings.lsp_deps or {}) + for _, entry in ipairs(valid) do + local fts = nil + local user_hooked = tools.module_path(server_modules(entry)[1]) ~= nil + or (user_lsp_configs[entry] and #user_lsp_configs[entry] > 0) + if not eager_ft_override_modules[entry] and not user_hooked then + local ok, config = pcall(function() + return vim.lsp.config[entry] + end) + if ok and type(config) == "table" and type(config.filetypes) == "table" then + fts = config.filetypes end - else - local fts = nil - local user_hooked = tools.module_path(server_modules(entry)[1]) ~= nil - or (user_lsp_configs[entry] and #user_lsp_configs[entry] > 0) - if not eager_ft_override_modules[entry] and not user_hooked then - local ok, config = pcall(function() - return vim.lsp.config[entry] - end) - if ok and type(config) == "table" and type(config.filetypes) == "table" then - fts = config.filetypes - end - end - local placed = false - if fts then - for _, ft in ipairs(fts) do - if type(ft) == "string" and ft ~= "" then - local bucket = deferred_by_ft[ft] or {} - bucket[#bucket + 1] = entry - deferred_by_ft[ft] = bucket - placed = true - end + end + local placed = false + if fts then + for _, ft in ipairs(fts) do + if type(ft) == "string" and ft ~= "" then + local bucket = deferred_by_ft[ft] or {} + bucket[#bucket + 1] = entry + deferred_by_ft[ft] = bucket + placed = true end end - if not placed then - immediate[#immediate + 1] = entry - end end + if not placed then + immediate[#immediate + 1] = entry + end + end + -- Entries split_dep_names drops (non-string / empty) are config + -- mistakes: forward them raw so the resolver's own re-scan reports + -- them in the unknown bucket — same pattern as nvim-lint. + for _, entry in ipairs(invalid) do + immediate[#immediate + 1] = entry end -- Immediate batch first: same-tick semantics identical to the old From aad58a483509a5ae7ebe4844529e53c04c00ab43 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 14:08:40 +0800 Subject: [PATCH 56/71] refactor(lsp): extract the one cmd-to-binary rule the "table cmd -> cmd[1] as the probeable binary" extraction existed five times across server_info and the cache invalidator with varying outer guards. binary_of_cmd is now the single rule; the self_resolving branch keeps {} distinct from a function cmd (nil binary without the flag), byte-equivalent to the old structure. --- .../configs/completion/mason-lspconfig.lua | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/lua/modules/configs/completion/mason-lspconfig.lua b/lua/modules/configs/completion/mason-lspconfig.lua index 3a129cc2..4643f96d 100644 --- a/lua/modules/configs/completion/mason-lspconfig.lua +++ b/lua/modules/configs/completion/mason-lspconfig.lua @@ -139,6 +139,15 @@ M.setup = function() return { "user.configs.lsp-servers." .. name, "completion.servers." .. name } end + ---cmd → probeable binary: a table cmd's argv[0], nil otherwise (a function + ---cmd resolves its own launch). The ONE extraction rule for every + ---classification site in server_info/invalidate below. + ---@param cmd any + ---@return string|nil + local function binary_of_cmd(cmd) + return type(cmd) == "table" and cmd[1] or nil + end + -- Repo server modules that OVERRIDE `filetypes`: their ft semantics live in -- the module, not in lspconfig defaults, so the per-filetype partition must -- resolve them on the load tick (shuck adds ksh — deferring it by @@ -213,8 +222,8 @@ M.setup = function() 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] + if type(user_spec) == "table" then + info.binary = binary_of_cmd(user_spec.cmd) end elseif user_exists then info.has_module = true @@ -233,8 +242,8 @@ M.setup = function() 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] + if info.binary == nil and type(spec) == "table" then + info.binary = binary_of_cmd(spec.cmd) end elseif exists then info.has_module = true @@ -285,10 +294,8 @@ M.setup = function() local config = resolved_config() if config and config.cmd ~= nil then info.known_lspconfig = true - if type(config.cmd) == "table" then - info.binary = config.cmd[1] - else - info.binary = nil + info.binary = binary_of_cmd(config.cmd) + if type(config.cmd) ~= "table" then info.self_resolving = true end end @@ -301,8 +308,8 @@ M.setup = function() local config = resolved_config() if config and config.cmd ~= nil then info.known_lspconfig = true - if info.binary == nil and type(config.cmd) == "table" then - info.binary = config.cmd[1] + if info.binary == nil then + info.binary = binary_of_cmd(config.cmd) end end end @@ -420,8 +427,8 @@ M.setup = function() 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] + if info.binary == nil then + info.binary = binary_of_cmd(config.cmd) end return false end From 6c6fe322ec9e241d74c61b73fcfc5aa720abbec8 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 14:08:40 +0800 Subject: [PATCH 57/71] refactor(tooling): share the pending-binaries probe across hand-off paths the mason install-event handler and :ToolsRetry both probed a pending name's declared bare binaries with the same pcall(binaries_of) + find_executable core. pending_bins_available is now that one core; the is_unsettled ownership guards stay per call site because the two paths interleave different extra evidence around the probe, and the evidence order is unchanged. --- lua/modules/utils/tools.lua | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index 24129d75..15d44ea1 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -762,6 +762,22 @@ local update_events_attached = false ---mason-registry itself — callers pass a registry they already hold, keeping ---"a fully-provisioned setup never loads Mason" intact. ---@param registry table|nil @The mason-registry module (or nil). +---A pending name's declared bare binaries, probed on $PATH / Mason's bin — +---the first (cheapest) availability evidence BOTH install hand-off paths +---share (the Mason install event and :ToolsRetry). The is_unsettled +---ownership guard deliberately stays at each call site: the two paths +---interleave DIFFERENT extra evidence around this probe (the event handler +---checks the installed package's name first; retry layers registry-derived +---binaries and the validates re-run after), so only the probe itself is the +---common core. +---@param session table +---@param name string +---@return boolean +local function pending_bins_available(session, name) + local bins_ok, bins = pcall(session.spec.binaries_of, name, nil) + return bins_ok and M.find_executable(bins) ~= nil +end + function M.attach_registry_events(registry) if install_events_attached and update_events_attached then return @@ -789,8 +805,7 @@ function M.attach_registry_events(registry) if ok and mapped == pkg_name then return true end - local bins_ok, bins = pcall(session.spec.binaries_of, name, nil) - return bins_ok and M.find_executable(bins) ~= nil + return pending_bins_available(session, name) end) end) ) @@ -820,8 +835,7 @@ function M.retry_missing() return false end local spec = session.spec - local bins_ok, bins = pcall(spec.binaries_of, name, nil) - if bins_ok and M.find_executable(bins) ~= nil then + if pending_bins_available(session, name) then return true end -- Function-cmd LSP servers (jsonls/yamlls) declare no bare binary: From fc0080696f0f54c40a82a23c5a09ed6fa0264fa7 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 14:08:40 +0800 Subject: [PATCH 58/71] refactor(tooling): extract the string-or-nil sanitizer the type(x) == "string" and x or nil sanitization of untrusted probe/config fields appeared four times (the validates record and the three runtime-tools probe-result fields). str_or_nil is now the single sanitizer; the collector's non-empty-string variant is a different rule and stays inline. --- lua/modules/utils/tools.lua | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index 15d44ea1..37893c91 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -51,6 +51,14 @@ end -- Public alias for dep-list consumers. M.normalize_names = normalize_names +---Keep a value only when it is a string: probe/config results are untrusted +---external shapes, and every non-string sanitizes to nil. +---@param value any +---@return string|nil +local function str_or_nil(value) + return type(value) == "string" and value or nil +end + ---Locate the first of the given executables: $PATH, then Mason's bin dir ---(reachable before mason.setup() prepends it). First-found by design: ---callers pass alternates for one tool, not a list of required binaries. @@ -1034,7 +1042,7 @@ function M.resolve(spec) return true end validates[name] = { - reason = type(reason) == "string" and reason or nil, + reason = str_or_nil(reason), config_error = config_error == true, } end @@ -1287,12 +1295,12 @@ function M.resolve_runtime_tools(title, deps, probe, configure, opts) if ok and type(result) == "table" then cache[name] = { known = true, - binary = type(result.binary) == "string" and result.binary or nil, - broken = type(result.broken) == "string" and result.broken or nil, + binary = str_or_nil(result.binary), + broken = str_or_nil(result.broken), unresolved = result.unresolved == true, -- Consumer-specific phrasing for the unresolved warning -- stays in the consumer's probe, not in this shared helper. - reason = type(result.reason) == "string" and result.reason or nil, + reason = str_or_nil(result.reason), } else -- A probe error is treated as unknown: either way, fix the config entry. From 96563175d59cf15d1ecf8189669692566ea224c1 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 15:37:03 +0800 Subject: [PATCH 59/71] refactor(tooling): drop the normalize_names wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the single-return wrapper (round-3 g8) protected vararg-position callers from split_dep_names' second return, but every live call site truncates fine without it. reader audit at removal time: - repo (rg 'normalize_names' lua/): the wrapper, its M alias, two internal single-assignment calls (find_executable, exepath_or_error), one external call site (mason-lspconfig lsp_deps set, ipairs position, now parenthesized), one comment reference (updated). - fleet harnesses (rg over /tmp/*smoke*/run.lua): zero hits. - user overlay: lua/user/ exists (gitignored surface) and is currently empty — zero files, zero possible readers. callers use split_dep_names directly; the one vararg-position site keeps its truncation via parentheses. --- .../configs/completion/mason-lspconfig.lua | 3 ++- lua/modules/utils/tools.lua | 16 +++------------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/lua/modules/configs/completion/mason-lspconfig.lua b/lua/modules/configs/completion/mason-lspconfig.lua index 4643f96d..2b21badd 100644 --- a/lua/modules/configs/completion/mason-lspconfig.lua +++ b/lua/modules/configs/completion/mason-lspconfig.lua @@ -124,7 +124,8 @@ M.setup = function() -- lsp_deps as a set: the read trigger below only acts on names this config -- actually manages. local deps_set = {} - for _, name in ipairs(tools.normalize_names(settings.lsp_deps)) do + -- Parenthesized: split_dep_names' second return must not reach ipairs. + for _, name in ipairs((tools.split_dep_names(settings.lsp_deps))) do deps_set[name] = true end -- Servers whose registration already ran (configure() or a read trigger): diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index 37893c91..6bf90224 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -41,16 +41,6 @@ local function split_dep_names(names) end M.split_dep_names = split_dep_names ----Valid names only — a single-return wrapper (the parentheses truncate), so ----the public alias's arity never changes under vararg-position calls. ----@param names any ----@return string[] -local function normalize_names(names) - return (split_dep_names(names)) -end --- Public alias for dep-list consumers. -M.normalize_names = normalize_names - ---Keep a value only when it is a string: probe/config results are untrusted ---external shapes, and every non-string sanitizes to nil. ---@param value any @@ -65,7 +55,7 @@ end ---@param names string|string[] @Executable name(s), probed in order. ---@return string|nil @Absolute path of the first name found, or nil. function M.find_executable(names) - names = normalize_names(names) + names = (split_dep_names(names)) for _, name in ipairs(names) do local path = vim.fn.exepath(name) if path ~= "" then @@ -217,7 +207,7 @@ function M.exepath_or_error(names, hint) if path then return path end - local shown = normalize_names(names) + local shown = (split_dep_names(names)) local label = #shown > 0 and table.concat(shown, "/") or "" error(string.format("%s not found on $PATH or in Mason's bin dir; %s", label, hint), 0) end @@ -1130,7 +1120,7 @@ function M.resolve(spec) local function run() -- Make Mason's bin dir resolvable before any probe or spawn (see file header). M.ensure_mason_on_path() - -- ONE normalization policy for every consumer (see normalize_names): + -- ONE normalization policy for every consumer (see split_dep_names): -- non-table deps degrade to nothing to resolve, and dropped entries -- (non-string / empty — config mistakes) surface in the unknown bucket -- instead of vanishing or flowing into module-name concatenation. From 364256771d1d99bf81495444a03bb71c28189fe9 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 15:37:03 +0800 Subject: [PATCH 60/71] docs(tooling): label mason_root as the tested root-priority contract the export has no external runtime caller today; it stays public as the tested contract of the has_setup-gated root-priority semantics, pinned directly by the g4-latch and collector harnesses. the docstring now says so, making the surface self-justifying in source. --- lua/modules/utils/tools.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index 6bf90224..49162e3b 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -651,7 +651,9 @@ end ---default data-dir guess. The guess only counts when no `user.configs.mason` ---override exists (the one supported home for a custom root) and is never ---cached; every returned root is re-checked for existence each call (the dir ----appears after the first install). +---appears after the first install). Public as the TESTED CONTRACT of these +---priority semantics (the g4-latch and collector harnesses pin it directly); +---no external runtime caller today. ---@return string|nil local mason_env_root = nil -- The user-override existence check is memoized separately from module_path's From 6601542b69652766b1a8d7ae01d62dafa5719853 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 15:37:03 +0800 Subject: [PATCH 61/71] refactor(dap): return the override verdict instead of the winning module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the handler rebuilt the 2-element client_modules table on every call only to compare its first entry against load_client_config's winner string. the loader now records user_won at cache-build time, where the module list already exists, and returns the boolean instead — winner had no other consumer. --- lua/modules/configs/tool/dap/init.lua | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/lua/modules/configs/tool/dap/init.lua b/lua/modules/configs/tool/dap/init.lua index 9d182bb5..bee31b44 100644 --- a/lua/modules/configs/tool/dap/init.lua +++ b/lua/modules/configs/tool/dap/init.lua @@ -23,16 +23,20 @@ return function() ---reason survives alongside a lower-precedence success, so the handler can ---refuse to fall past a broken override (usable_or_raise raises it). ---@param name string - ---@return any value, string|nil broken_reason, boolean any_exists, string|nil winner + ---@return any value, string|nil broken_reason, boolean any_exists, boolean user_won local function load_client_config(name) local cached = client_config_cache[name] if not cached then - cached = { value = nil, broken_reason = nil, any_exists = false, winner = nil } - for _, module in ipairs(client_modules(name)) do + cached = { value = nil, broken_reason = nil, any_exists = false, user_won = false } + local modules = client_modules(name) + for index, module in ipairs(modules) do local ok, value, exists, reason = tools.load_module_or_report(module, "nvim-dap") if ok then cached.value = value - cached.winner = module + -- Recorded here, where `modules` is already built — the + -- handler must not rebuild the list per call just to ask + -- whether the user candidate won. + cached.user_won = index == 1 cached.any_exists = true break end @@ -45,7 +49,7 @@ return function() end client_config_cache[name] = cached end - return cached.value, cached.broken_reason, cached.any_exists, cached.winner + return cached.value, cached.broken_reason, cached.any_exists, cached.user_won end -- Initialize debug hooks @@ -160,7 +164,7 @@ return function() ---(every adapter in this repo) configures without loading Mason. ---@param dap_name string local function mason_dap_handler(dap_name) - local custom_handler, broken_reason, _, winner = load_client_config(dap_name) + local custom_handler, broken_reason, _, user_won = load_client_config(dap_name) -- No-fall-through contract, enforced by the ONE shared implementation -- (tools.usable_or_raise, also used by mason_lsp_handler): a broken or -- wrong-shaped config must never read as success — that would suppress @@ -229,7 +233,7 @@ return function() -- * dap.adapters. = { your config } -- * dap.configurations. = { your config } -- See `codelldb.lua` for a concrete example. - if winner == client_modules(dap_name)[1] then + if user_won then -- User-authored override: the historical contract is a PLAIN -- table — pairs()/tbl_deep_extend must see the mapping fields, -- which a lazy __index proxy cannot provide (LuaJIT has no From bff4a1220a72ef2f82aa8bb332fb989866812c34 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 15:37:03 +0800 Subject: [PATCH 62/71] docs(lsp): label the harness-facing exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_remaining's docstring claimed the parity sweep calls the export; the sweep calls the local upvalue — the export is the manual escape hatch and the harness hook, and now says exactly that. the eager_ft_override_modules export gains its test-hook label (read by the ft_override_consistency harness scenario; no runtime reader), so both surfaces are self-justifying in source. --- lua/modules/configs/completion/mason-lspconfig.lua | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lua/modules/configs/completion/mason-lspconfig.lua b/lua/modules/configs/completion/mason-lspconfig.lua index 2b21badd..21fe8508 100644 --- a/lua/modules/configs/completion/mason-lspconfig.lua +++ b/lua/modules/configs/completion/mason-lspconfig.lua @@ -104,8 +104,8 @@ function M.resolve_deps() end end ----Resolve every per-filetype batch still deferred (the parity sweep calls ----this once per session; also a manual escape hatch and the harness hook). +---Resolve every per-filetype batch still deferred. Manual escape hatch and +---the harness hook (the in-file parity sweep calls the LOCAL directly). function M.resolve_remaining() if resolve_remaining then resolve_remaining() @@ -164,6 +164,8 @@ M.setup = function() tflint = true, tombi = true, } + -- Test hook: read by the ft_override_consistency harness scenario + -- (anti-rot); no runtime reader. M.eager_ft_override_modules = eager_ft_override_modules -- Late parity sweep: every lsp_deps entry is classified at most this long From 2fa8a0d2b6c27802ce1463d2876d574036a11d5b Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 16:11:55 +0800 Subject: [PATCH 63/71] docs(tooling): de-rot and reattach the resolver docstrings Reattach the attach_registry_events and package_for_binary docstrings to their actual definitions, add the missing is_unsettled field to the ToolCollector class annotation, and update the phase-2 comments for LSP's defer_phase2 adoption. Compress the PATH-mode narrative behind a mason 2.x semantics anchor, note the async-emit immunity on the update handler, and state that default_registry is only resolved once leftovers exist. The mason_root test-hook label now explains WHY it stays public instead of naming harness scenarios. --- lua/modules/utils/tools.lua | 82 +++++++++++++++++++------------------ 1 file changed, 43 insertions(+), 39 deletions(-) diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua index 49162e3b..77266f8e 100644 --- a/lua/modules/utils/tools.lua +++ b/lua/modules/utils/tools.lua @@ -150,19 +150,16 @@ function M.usable_or_raise(value, broken_reason, opts) return value end ----Keep Mason's bin dir on $PATH (see the file header) — appended so a system ----copy still wins. Idempotent via the exact-entry membership check each call ----(one plain find over $PATH): a set-once flag would skip exactly the cases ----the recheck exists for — a $PATH something overwrote mid-session, and a ----root that switched once mason.setup applied a different one (see ----mason_root). APPEND-ONLY on purpose: identical strings in an externally ----mutable list admit no robust ownership proof, so nothing is ever removed — ----and per Mason PATH mode nothing needs to be: under the default ----`PATH = "prepend"` mason.setup itself prepends the new root's bin on a ----switch, out-preceding any stale appended entry for same-named shims (the ----stale tail then only serves old-root-only names); under `PATH = "append"` ----the user chose tail ordering, so a pre-switch stale entry preceding the ----post-switch one is that mode's own semantics (same-name shadowing until +---Keep Mason's bin dir on $PATH (see the file header). Idempotent via the +---exact-entry membership check each call (one plain find over $PATH): a +---set-once flag would skip exactly the cases the recheck exists for — a +---$PATH something overwrote mid-session, and a root that switched once +---mason.setup applied a different one (see mason_root). APPEND-ONLY on +---purpose: identical strings in an externally mutable list admit no robust +---ownership proof, so nothing is ever removed — and per Mason PATH mode +---(mason 2.x semantics) nothing needs to be: the default prepend puts the +---new root's bin ahead of any stale appended entry on a switch, and append +---mode's tail ordering is the user's own choice (same-name shadowing until ---restart accepted). KNOWN DEVIATION: mason's `PATH = "skip"` is NOT honored ---here — deliberately. The resolver's availability verdicts (find_executable, ---consulted by every resolve()) and its consumers' bare-name spawns @@ -189,8 +186,9 @@ end ---Lazy mason-registry thunk for resolve() specs — the ONE copy (dap and the ---runtime-tools resolver share it, so a change to the load guard cannot ----drift between consumers). Passed as a spec `registry` value: only phase 2 ----calls it, so a fully-provisioned setup never loads mason-registry. +---drift between consumers). Passed as a spec `registry` value: phase 1 never +---calls it — phase 2 and the retry paths resolve it only once leftovers +---exist, so a fully-provisioned setup never loads mason-registry. ---@return table|nil function M.default_registry() local ok, resolved = pcall(require, "mason-registry") @@ -267,6 +265,7 @@ end --- a provisional reason is a placeholder a later concrete failure may replace. ---@field mark_unknown fun(name: string) @Record an unrecognized name (typo / outdated / unsupported). ---@field track fun(pkg: table, name: string, recheck: fun(): boolean, on_ready?: fun(), fail_reason?: string) +---@field is_unsettled fun(name: string): boolean @Whether an in-flight tracked install still owns the name. ---@field 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. @@ -577,17 +576,19 @@ local function registry_bootstrapped(registry) return all_installed == true end +-- Frozen bin -> package index (see package_for_binary below). +local bin_to_package = nil +-- Unfrozen scans stay re-buildable by design (self-heal after a late +-- refresh), but within ONE event-loop tick nothing can change: memoize per +-- tick so a batch of lookups decodes registry.json once, not N times. +local unfrozen_index = nil + ---Find the Mason package shipping the given binary, via a lazily-built ---bin -> package index over the registry specs: tool name, binary, and ---package name may all differ (cmake_format -> cmake-format -> cmakelang). ---@param registry table @The mason-registry module. ---@param binary string @Executable name to look up. ---@return string|nil @Mason package name shipping that binary, or nil. -local bin_to_package = nil --- Unfrozen scans stay re-buildable by design (self-heal after a late --- refresh), but within ONE event-loop tick nothing can change: memoize per --- tick so a batch of lookups decodes registry.json once, not N times. -local unfrozen_index = nil local function package_for_binary(registry, binary) -- Freeze the index only once the registry is FULLY bootstrapped: -- get_all_package_specs() silently skips uninstalled sources, and a frozen @@ -651,9 +652,9 @@ end ---default data-dir guess. The guess only counts when no `user.configs.mason` ---override exists (the one supported home for a custom root) and is never ---cached; every returned root is re-checked for existence each call (the dir ----appears after the first install). Public as the TESTED CONTRACT of these ----priority semantics (the g4-latch and collector harnesses pin it directly); ----no external runtime caller today. +---appears after the first install). Kept public deliberately so these +---priority semantics stay independently testable from outside the module +---(test hook); no external runtime caller today. ---@return string|nil local mason_env_root = nil -- The user-override existence check is memoized separately from module_path's @@ -756,12 +757,6 @@ end local install_events_attached = false local update_events_attached = false ----Subscribe to Mason install successes so a package installed mid-session by ----ANY means (resolver-started, :MasonInstall, the :Mason UI) finishes the ----pending configure of every subsystem that waited for it. Never require()s ----mason-registry itself — callers pass a registry they already hold, keeping ----"a fully-provisioned setup never loads Mason" intact. ----@param registry table|nil @The mason-registry module (or nil). ---A pending name's declared bare binaries, probed on $PATH / Mason's bin — ---the first (cheapest) availability evidence BOTH install hand-off paths ---share (the Mason install event and :ToolsRetry). The is_unsettled @@ -778,6 +773,12 @@ local function pending_bins_available(session, name) return bins_ok and M.find_executable(bins) ~= nil end +---Subscribe to Mason install successes so a package installed mid-session by +---ANY means (resolver-started, :MasonInstall, the :Mason UI) finishes the +---pending configure of every subsystem that waited for it. Never require()s +---mason-registry itself — callers pass a registry they already hold, keeping +---"a fully-provisioned setup never loads Mason" intact. +---@param registry table|nil @The mason-registry module (or nil). function M.attach_registry_events(registry) if install_events_attached and update_events_attached then return @@ -819,6 +820,8 @@ function M.attach_registry_events(registry) -- would leave a same-tick window still reading the stale frozen -- index. Clearing two locals is pure Lua, fast-event-safe; the -- next lookup rebuilds (and re-freezes on a bootstrapped registry). + -- Were upstream ever to emit this asynchronously instead, the + -- sync clear degrades to one harmless extra rebuild. bin_to_package = nil unfrozen_index = nil end) @@ -911,8 +914,7 @@ end ---resolver still pays ensure_mason_on_path synchronously — deferring must not ---lose the same-tick guarantee that a replayed trigger's bare Mason spawns ---can resolve — and phase-1 configures inside the scheduled run still count ----as trigger-backed (`late = false`), exactly like a caller-side ----vim.schedule wrapper did. +---as trigger-backed (`late = false`). function M.resolve(spec) -- Every call site owns a configure (the resolver's whole job funnels into -- it): a missing one is a programmer error — fail at the call site instead @@ -945,7 +947,8 @@ function M.resolve(spec) spec = spec, pending = {}, is_unsettled = collector.is_unsettled, - ---The spec's registry (value or lazy thunk), resolved on demand. + ---The spec's registry (value or lazy thunk), resolved on demand; a + ---drifted non-table result normalizes to nil (degrade, never crash). resolve_registry = function() local registry = spec.registry if type(registry) == "function" then @@ -1176,10 +1179,9 @@ function M.resolve(spec) sessions[#sessions + 1] = session -- Phase 2: resolve leftovers against the registry (value, nil, or lazy - -- thunk — resolved through session.resolve_registry, and only here, so - -- a fully-provisioned subsystem never loads Mason), refreshing a - -- never-bootstrapped one first. The helper also normalizes a drifted - -- non-table result to nil instead of letting it crash phase 2. + -- thunk — via session.resolve_registry; phase 1 never touches it, so a + -- fully-provisioned subsystem with no leftovers never loads Mason), + -- refreshing a never-bootstrapped one first. local registry = session.resolve_registry() -- The registry is loaded anyway: make sure mid-session installs hand off. if registry then @@ -1237,11 +1239,13 @@ function M.resolve(spec) on_refreshed() end elseif spec.defer_phase2 then - -- Runtime-tool phase 2 only classifies/installs: move the full registry - -- spec decode off the lazy-load trigger's tick. + -- Opt-in consumers (runtime tools, LSP) move the full registry spec + -- decode off the lazy-load trigger's tick; their late configures + -- have their own catch-up paths (enable() attaches open buffers, + -- lint re-runs itself). vim.schedule(finish) else - -- LSP/DAP configure IN phase 2, and a cmd-triggered lazy-load replays + -- DAP configures IN phase 2, and a cmd-triggered lazy-load replays -- synchronously right after config: finish on this tick. finish() end From e04e565233054f013a3539be87e466d621378774 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 16:11:55 +0800 Subject: [PATCH 64/71] docs(lsp): de-rot the replay, sweep, and shuck comments Rewrite the user_lsp_configs replay comments around the actual ordering guarantee (discovery runs after the user module), fix the stale server_info/invalidate reference, and swap the per-machine verification claim for the documented :h vim.lsp.enable() semantics. Mark SWEEP_DELAY_MS as kept in sync with nvim-lint's parity sweep. shuck's header now records mise as a deliberate choice: Mason ships a shuck package these days, but the $PATH copy wins under discovery-first; the zsh-dialect caveat gains a version anchor so it can expire. --- .../configs/completion/mason-lspconfig.lua | 57 ++++++++++--------- .../configs/completion/servers/shuck.lua | 10 ++-- 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/lua/modules/configs/completion/mason-lspconfig.lua b/lua/modules/configs/completion/mason-lspconfig.lua index 21fe8508..7c19e57e 100644 --- a/lua/modules/configs/completion/mason-lspconfig.lua +++ b/lua/modules/configs/completion/mason-lspconfig.lua @@ -1,9 +1,10 @@ local M = {} -- vim.lsp.config registrations from `user.configs.lsp` (name -> ordered list --- of { cfg } merges / { replace = true, cfg } assignments): a POST-INSTALL --- late configure replays them, since its repo-spec registration would --- otherwise force-merge over the user's keys (install-timing-dependent). +-- of { cfg } merges / { replace = true, cfg } assignments): EVERY configure +-- replays them, since its repo-spec registration would otherwise force-merge +-- over the user's keys — a post-install late configure is merely the latest +-- timing this covers. local user_lsp_configs = {} -- Bridge set by setup(): read-triggered registration (fun(real, name)) so a @@ -19,9 +20,8 @@ local resolve_deps = nil -- Bridge set by setup(): resolves every still-deferred per-filetype batch — -- the parity sweep's target and a manual escape hatch. local resolve_remaining = nil --- User overrides run once per session: lsp.lua's config function is the only --- caller and lazy.nvim runs it once; a rerun could not undo the first pass's --- write-through ops anyway, so a second call is refused loudly instead. +-- User overrides run once per session (see run_user_lsp_overrides' doc): +-- a second call is refused loudly. local overrides_ran = false ---Run `user.configs.lsp` with vim.lsp.config proxied to record per-server @@ -142,7 +142,7 @@ M.setup = function() ---cmd → probeable binary: a table cmd's argv[0], nil otherwise (a function ---cmd resolves its own launch). The ONE extraction rule for every - ---classification site in server_info/invalidate below. + ---classification site in server_info/unknown_of below. ---@param cmd any ---@return string|nil local function binary_of_cmd(cmd) @@ -153,8 +153,8 @@ M.setup = function() -- the module, not in lspconfig defaults, so the per-filetype partition must -- resolve them on the load tick (shuck adds ksh — deferring it by -- lspconfig's bash/sh/zsh would strand a ksh-only session until the sweep). - -- This declares a property of OUR OWN modules; the ft_override_consistency - -- harness scenario asserts it stays in sync with servers/*.lua. + -- This declares a property of OUR OWN modules; the export below is the + -- anti-rot hook that keeps it honest against servers/*.lua. local eager_ft_override_modules = { gopls = true, harper_ls = true, @@ -171,7 +171,7 @@ M.setup = function() -- Late parity sweep: every lsp_deps entry is classified at most this long -- after resolve_deps even if its filetype never opens (missing-tool -- warnings and installs still happen once per session, off any hot path). - local SWEEP_DELAY_MS = 120000 + local SWEEP_DELAY_MS = 120000 -- keep in sync with nvim-lint's parity sweep vim.diagnostic.config({ signs = true, @@ -186,6 +186,8 @@ M.setup = function() ---Probe and cache a server's spec once. `binary` = first table-cmd entry in ---precedence order (user, repo, lspconfig); nil for a function/absent cmd. + ---Post-registration the resolved config's cmd wins unconditionally (a + ---registered cmd is what enable() will spawn). local server_info_cache = {} ---@class mason_lspconfig.ServerInfo ---@field has_module boolean @@ -330,10 +332,10 @@ M.setup = function() local function mason_lsp_handler(lsp_name) local info = server_info(lsp_name) -- No-fall-through contract, enforced by the ONE shared implementation - -- (tools.usable_or_raise, also used by mason_dap_handler): a broken or - -- wrong-shaped config must never read as success — that would suppress - -- both the warning and the install fallback. Precedence was decided by - -- server_info (info.spec / info.merge_base). + -- (tools.usable_or_raise): a broken or wrong-shaped config must never + -- read as success — that would suppress both the warning and the + -- install fallback. Precedence was decided by server_info + -- (info.spec / info.merge_base). local spec = tools.usable_or_raise(info.spec, info.broken_reason, { label = "server config", expected = "a fun(opts) or a table", @@ -389,10 +391,10 @@ M.setup = function() return lspconfig_to_package[name] end -- The mapping derives from the registry specs: a registry update must not - -- leave the first non-empty snapshot frozen. SYNCHRONOUS (pure-Lua clear, - -- fast-event-safe): mason emits update:success before update callbacks - -- run, so a scheduled clear would leave a same-tick stale window. Once - -- per setup; pcall guards mason-registry API drift. + -- leave the first non-empty snapshot frozen. SYNCHRONOUS clear on purpose — + -- the emit-order rationale lives at tools.lua's update:success handler + -- (the one place that fact is argued). Once per setup; pcall guards + -- mason-registry API drift. if mason_ok and type(mason_registry.on) == "function" then pcall(function() mason_registry:on("update:success", function() @@ -448,12 +450,12 @@ M.setup = function() -- (group-1 retry/event matchers) see the resolved cmd. server_info_cache[name] = nil end - -- Replay recorded `user.configs.lsp` registrations on top: a post-install - -- configure may register AFTER that module ran (write-only overrides get - -- no read trigger), and the repo spec would otherwise force-merge over - -- the user's keys. Synchronous configures precede the user module, and a - -- read-triggered registration precedes the recording — both make this a - -- natural no-op. + -- Replay recorded `user.configs.lsp` registrations on top: discovery + -- runs AFTER the user module (lsp.lua orders setup → overrides → + -- resolve), so the repo-spec registration above just force-merged over + -- any write-only override's keys — this replay restores them on every + -- configure. Only the read-triggered path (registration before + -- recording) makes it a natural no-op. for _, entry in ipairs(user_lsp_configs[name] or {}) do if entry.replace then vim.lsp.config[name] = entry.cfg @@ -527,9 +529,10 @@ M.setup = function() -- Phase 2 (registry classification: full mappings decode + package -- hydration) moves off the BufReadPre tick. Safe because a late -- configure's vim.lsp.enable() attaches already-open matching - -- buffers (verified on this nvim), so the one same-tick - -- beneficiary — a Mason-installed server whose binary name differs - -- from the probe — still reaches the triggering buffer. + -- buffers (documented semantics — :h vim.lsp.enable()), so the one + -- same-tick beneficiary — a Mason-installed server whose binary + -- name differs from the probe — still reaches the triggering + -- buffer. defer_phase2 = true, }) end diff --git a/lua/modules/configs/completion/servers/shuck.lua b/lua/modules/configs/completion/servers/shuck.lua index d617bbd1..e000c4de 100644 --- a/lua/modules/configs/completion/servers/shuck.lua +++ b/lua/modules/configs/completion/servers/shuck.lua @@ -1,9 +1,11 @@ -- shuck: Rust shell linter/formatter/language server. -- https://ewhauser.github.io/shuck/docs/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 and emits false positives, so zsh stays on `zsh -n` (nvim-lint). +-- Installed via mise (`cargo:shuck-cli`) by choice — Mason ships a shuck +-- package now, but under discovery-first the $PATH copy wins, so the +-- resolver enables it from the `shuck` binary (fix = `mise install`). +-- `zsh` is intentionally excluded: shuck's zsh dialect (as of v0.0.4x, +-- 2026-07) still misparses some zsh-isms and emits false positives, so zsh +-- stays on `zsh -n` (nvim-lint). return { cmd = { "shuck", "server" }, filetypes = { "sh", "bash", "ksh" }, From fe9d466b6a76f2a9aa4c74559ea6f7dee8959b25 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 16:11:55 +0800 Subject: [PATCH 65/71] docs(lint): de-rot the override and sweep comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The markdownlint-cli2 override rationale dated from the bun node-shim era; record that mise ships real node now (kept pending re-evaluation) and NOTE the known limitation that the pattern drops col-less findings (e.g. MD012). Correct the shuck comment: actionlint DOES lint run: blocks via its shellcheck pass-through — shuck complements it. Consolidate the golangcilint blocking-load fact onto refresh_linter and point the other two sites at it; the rawset comment no longer cites the removed factory-wrapper machinery. --- lua/modules/configs/completion/nvim-lint.lua | 27 ++++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/lua/modules/configs/completion/nvim-lint.lua b/lua/modules/configs/completion/nvim-lint.lua index 08178dfa..3fff8edc 100644 --- a/lua/modules/configs/completion/nvim-lint.lua +++ b/lua/modules/configs/completion/nvim-lint.lua @@ -15,8 +15,12 @@ return function() "-", } 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: file-based mode + stderr parser date from the bun + -- node-shim era (stdin's for-await yielded empty); mise ships real node + -- now, so stdin likely works again — kept pending re-evaluation. + -- NOTE (known limitation): the pattern requires "path:line:col", but + -- upstream omits the column when unknown (e.g. MD012), so those + -- findings are silently dropped. ["markdownlint-cli2"] = function(linter) linter.stdin = false linter.args = { @@ -47,9 +51,10 @@ return function() 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 - -- stdin mode (needs a project root), so run file-based and parse JSON. + -- shuck: lints shell embedded in GitHub Actions `run:` blocks with rules + -- beyond actionlint's shellcheck pass-through; standalone sh/bash comes + -- from the shuck LSP server. No usable stdin mode (needs a project root), + -- so run file-based and parse JSON. lint.linters.shuck = { name = "shuck", cmd = "shuck", @@ -121,7 +126,7 @@ return function() -- Resolve `linter_deps` discovery-first against nvim-lint's own registry, -- batched BY FILETYPE off the lint events below: the probe requires the - -- linter module, and some (golangcilint) run blocking system calls at load. + -- linter module, and some block at load (see refresh_linter below). local tools = require("modules.utils.tools") -- No factory-wrapper machinery here on purpose: both overridden linters -- (selene, markdownlint-cli2) are plain-table modules upstream, so a @@ -143,8 +148,8 @@ return function() end local prev = lint.linters[name] package.loaded[module] = nil - -- Also drop any explicit assignment (a wrapped factory) shadowing the - -- lint.linters __index loader. + -- Also drop any explicit assignment (e.g. a prior error-path restore + -- below) shadowing the lint.linters __index loader. rawset(lint.linters, name, nil) -- Require it OURSELVES: the __index loader pcall-swallows a reload -- failure into nil, which would read as configure success and clear @@ -336,9 +341,9 @@ return function() end table.sort(names) -- pairs order is nondeterministic; keep the warning stable -- The event path loads linter modules inside tools.resolve(), AFTER it - -- put Mason's bin dir on $PATH. PATH-sensitive modules (golangcilint - -- RUNS its binary at load to compute args) must see the same - -- environment when the warm phase loads them first instead. + -- put Mason's bin dir on $PATH. PATH-sensitive module loads (see + -- refresh_linter above) must see the same environment when the warm + -- phase loads them first instead. tools.ensure_mason_on_path() -- Warm the __index module loads one per tick (bounds any blocking -- load to a single tick), then resolve everything still pending in From 31f20ecdca4bbf94ab16a59eb09c1a806d7ffd0d Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 16:11:55 +0800 Subject: [PATCH 66/71] docs(conform): de-rot the prettier override and drop restating comments The prettier --write-on-temp-copy rationale dated from the bun node-shim era; record that mise ships real node now (kept pending re-evaluation). Drop the two comments restating the format_on_save code and the null-ls migration attribution. --- lua/modules/configs/completion/conform.lua | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lua/modules/configs/completion/conform.lua b/lua/modules/configs/completion/conform.lua index 4eb56f11..074fbd46 100644 --- a/lua/modules/configs/completion/conform.lua +++ b/lua/modules/configs/completion/conform.lua @@ -147,9 +147,11 @@ return function() args = { "fix", "--stdin" }, stdin = true, }, - -- prettier: stdin is broken under bun's node shim, so use --write on - -- conform's temp copy (`stdin = false` points $FILENAME at a - -- `.conform.$RANDOM.*` copy; the real file is never touched). + -- prettier: the --write-on-temp-copy shape dates from the bun + -- node-shim era (stdin was broken); mise ships real node now, so + -- stdin likely works again — kept pending re-evaluation. `stdin = + -- false` points $FILENAME at a `.conform.$RANDOM.*` copy; the real + -- file is never touched. prettier = { command = "prettier", args = { "--write", "$FILENAME" }, @@ -157,12 +159,10 @@ return function() }, }, format_on_save = format_on_save_enabled and function(bufnr) - -- Disabled filetypes, disabled workspaces, and the global/buffer toggles if not autoformat_allowed(bufnr) then return end - -- Format only modified lines if enabled if format_modifications_only then if format_modifications(bufnr) then return @@ -284,7 +284,7 @@ return function() end end, { nargs = 1, complete = "filetype" }) - -- Auto stop shell LSPs for .env files (migrated from null-ls config). + -- Auto stop shell LSPs for .env files. -- Both bashls and shuck attach to .env's `sh` filetype and only add noise there. vim.api.nvim_create_autocmd("LspAttach", { callback = function(event) From e181a2386b8ea5acab63c974177e27b8ae11a3b6 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 16:11:55 +0800 Subject: [PATCH 67/71] docs(dap): correct the loader and adapter comments Name the user-override materialization as the second mason-nvim-dap load site, correct the upstream default_setup ipairs claim, and point the validate-order notes in every client back at the canonical contract in init.lua. delve: 38697 is the nvim-dap wiki's example port, not a delve default. lldb: the lldb-dap rename landed in LLVM 18, not 15. python: cover all raise sites and drop two boilerplate configuration comments. --- .../configs/tool/dap/clients/codelldb.lua | 3 +- .../configs/tool/dap/clients/delve.lua | 5 +-- lua/modules/configs/tool/dap/clients/lldb.lua | 6 ++-- .../configs/tool/dap/clients/python.lua | 8 ++--- lua/modules/configs/tool/dap/init.lua | 31 ++++++++++--------- 5 files changed, 28 insertions(+), 25 deletions(-) diff --git a/lua/modules/configs/tool/dap/clients/codelldb.lua b/lua/modules/configs/tool/dap/clients/codelldb.lua index 70b2a1b1..355fd7ba 100644 --- a/lua/modules/configs/tool/dap/clients/codelldb.lua +++ b/lua/modules/configs/tool/dap/clients/codelldb.lua @@ -4,7 +4,8 @@ return function() local utils = require("modules.utils.dap") local is_windows = require("core.global").is_windows - -- Self-validate at config time: launch AND attach both spawn the local binary, so + -- Self-validate at config time (validate FIRST — contract: tool/dap/init.lua + -- resolver spec): launch AND attach both spawn the local binary, so -- unlike delve/python nothing is worth registering without it — error if missing. local command = require("modules.utils.tools").exepath_or_error("codelldb", "install it via Mason or your package manager") diff --git a/lua/modules/configs/tool/dap/clients/delve.lua b/lua/modules/configs/tool/dap/clients/delve.lua index c5c10016..d7d5b75a 100644 --- a/lua/modules/configs/tool/dap/clients/delve.lua +++ b/lua/modules/configs/tool/dap/clients/delve.lua @@ -15,8 +15,9 @@ 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 errors rather than silently - -- falling back to delve's default 38697. + -- Default when unset (38697 is the conventional example port from the + -- nvim-dap wiki, not a delve default), but a malformed user port + -- errors rather than silently falling back to it. local port = 38697 if config.port ~= nil then local n = tonumber(config.port) diff --git a/lua/modules/configs/tool/dap/clients/lldb.lua b/lua/modules/configs/tool/dap/clients/lldb.lua index 6ce14c42..e3ba1bc7 100644 --- a/lua/modules/configs/tool/dap/clients/lldb.lua +++ b/lua/modules/configs/tool/dap/clients/lldb.lua @@ -4,8 +4,10 @@ return function() local utils = require("modules.utils.dap") -- Opt-in preset (not in default dap_deps; codelldb covers C-family). Self-validate - -- so the resolver surfaces `lldb`. LLVM 15 renamed `lldb-vscode` -> `lldb-dap`; - -- probe the new name first, keep the old for distros still shipping it. + -- so the resolver surfaces `lldb` (validate FIRST — contract: + -- tool/dap/init.lua resolver spec). LLVM 18 renamed `lldb-vscode` -> + -- `lldb-dap`; probe the new name first, keep the old for distros still + -- shipping it. local command = require("modules.utils.tools").exepath_or_error( { "lldb-dap", "lldb-vscode" }, "install it via your package manager (ships with LLVM/lldb)" diff --git a/lua/modules/configs/tool/dap/clients/python.lua b/lua/modules/configs/tool/dap/clients/python.lua index 413fa069..6a9f5183 100644 --- a/lua/modules/configs/tool/dap/clients/python.lua +++ b/lua/modules/configs/tool/dap/clients/python.lua @@ -52,7 +52,7 @@ return function() import_probe_retries = 0 return command, args end - -- One copy for both raise sites so the install guidance can't drift. + -- One copy for all raise sites so the install guidance can't drift. local no_debugpy = "debugpy not found: no `debugpy-adapter` shim on $PATH or in Mason's bin dir,\n" .. "and no python able to import debugpy; install debugpy via Mason (`:Mason`)\n" .. "or your package manager" @@ -159,11 +159,9 @@ return function() end dap.configurations.python = { { - -- The first three options are required by nvim-dap - type = "python", -- the type here established the link to the adapter definition: `dap.adapters.python` + type = "python", -- links to the adapter definition: `dap.adapters.python` request = "launch", name = "Debug", - -- Options below are for debugpy, see https://github.com/microsoft/debugpy/wiki/Debug-configuration-settings for supported options console = "integratedTerminal", program = utils.input_file_path(), pythonPath = function() @@ -176,11 +174,9 @@ return function() end, }, { - -- NOTE: This setting is for people using venv type = "python", request = "launch", name = "Debug (using venv)", - -- Options below are for debugpy, see https://github.com/microsoft/debugpy/wiki/Debug-configuration-settings for supported options console = "integratedTerminal", program = utils.input_file_path(), pythonPath = function() diff --git a/lua/modules/configs/tool/dap/init.lua b/lua/modules/configs/tool/dap/init.lua index bee31b44..c74db5bd 100644 --- a/lua/modules/configs/tool/dap/init.lua +++ b/lua/modules/configs/tool/dap/init.lua @@ -96,10 +96,11 @@ return function() vim.fn.sign_define("DapLogPoint", { text = icons.dap.LogPoint, texthl = "DapLogPoint", linehl = "", numhl = "" }) -- Everything Mason-flavored loads LAZILY: a session where every adapter - -- self-validates from $PATH must not load mason.nvim / mason-nvim-dap on - -- the :Dap* tick. First use (the factory fallback or a phase-2 - -- classification) goes through lazy.nvim's module loader; absence degrades - -- to client-config/$PATH resolution as before. + -- self-validates from $PATH — with no user overrides — must not load + -- mason.nvim / mason-nvim-dap on the :Dap* tick. First use (the factory + -- fallback, a phase-2 classification, or a user override's opts + -- materialization) goes through lazy.nvim's module loader; absence + -- degrades to client-config/$PATH resolution as before. local mason_dap = nil local function mason_dap_mod() if mason_dap == nil then @@ -160,15 +161,16 @@ return function() end ---A handler to setup all clients defined under `tool/dap/clients/*.lua`. - ---Only the factory branch touches mason-nvim-dap: a client-config'd adapter - ---(every adapter in this repo) configures without loading Mason. + ---The factory branch and a user override's opts materialization load + ---mason-nvim-dap; a repo zero-arg client (every adapter in this repo) + ---configures without loading Mason. ---@param dap_name string local function mason_dap_handler(dap_name) local custom_handler, broken_reason, _, user_won = load_client_config(dap_name) -- No-fall-through contract, enforced by the ONE shared implementation - -- (tools.usable_or_raise, also used by mason_lsp_handler): a broken or - -- wrong-shaped config must never read as success — that would suppress - -- both the warning and the install fallback. + -- (tools.usable_or_raise): a broken or wrong-shaped config must never + -- read as success — that would suppress both the warning and the + -- install fallback. custom_handler = tools.usable_or_raise(custom_handler, broken_reason, { label = "client config", expected = "a fun(opts)", @@ -217,9 +219,10 @@ return function() ) end -- Partial mappings drift can hand us configurations without - -- filetypes (or vice versa); default_setup ipairs() both - -- unconditionally. Degrade to whatever half is present instead of - -- a raw ipairs(nil) raise. + -- filetypes: upstream's default_setup guards configurations + -- (`or {}`) but ipairs()es filetypes whenever configurations are + -- non-empty — that combination raises ipairs(nil). Normalize both + -- halves defensively. if type(config.configurations) ~= "table" then config.configurations = {} end @@ -345,8 +348,8 @@ return function() -- install fallback — python resolves debugpy from a venv $PATH can't see. -- The raise on a missing launch binary is the provisioning signal. -- - -- CANONICAL availability contract for dap/clients/*.lua (referenced by the - -- one-line notes in each client; keep the two patterns in sync here). + -- CANONICAL availability contract for dap/clients/*.lua (the raise-LAST + -- clients carry pointer notes back here; keep the two patterns in sync). -- Shape enforcement itself lives in tools.usable_or_raise; a -- metadata-declared contract ({ attach_capable, binaries }) was -- considered and rejected — it would churn the fun(opts) user-override From 26e0618a49a2609befd8bf60b94238458219f44b Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 16:11:55 +0800 Subject: [PATCH 68/71] docs(settings): de-rot dep-list comments and fix the palette type palette_overwrite is a name -> palette map, not a list: fix the @type. Soften the nil_ls/nixd/shuck notes into provisioning-preference statements instead of Mason-registry claims that rot as the registry changes, drop the duplicated timeout literal and scheduled-notify rationale, and note the debug keymaps as a dap lazy-load trigger. --- lua/core/settings.lua | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/lua/core/settings.lua b/lua/core/settings.lua index f46b8d40..c7cd0110 100644 --- a/lua/core/settings.lua +++ b/lua/core/settings.lua @@ -57,7 +57,7 @@ settings["disabled_plugins"] = {} -- These settings will override the defaults during initialization. -- Parameters will auto-complete as you type. -- Example: { sky = "#04A5E5" } ----@type palette[] +---@type palette settings["palette_overwrite"] = {} -- Set the colorscheme here. @@ -107,10 +107,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) + "nil_ls", -- Nix LSP; the Nix-provisioned $PATH binary is preferred + "nixd", -- Nix LSP (Rust); provisioned from Nix ($PATH) "ruff", - "shuck", -- shell linter/formatter/LSP (Rust); no Mason package, installed via mise + "shuck", -- shell linter/formatter/LSP (Rust); installed via mise by choice ($PATH wins) "systemd_lsp", "terraformls", "tflint", @@ -154,7 +154,7 @@ settings["linter_deps"] = { "golangcilint", "selene", "shellcheck", - "shuck", -- shell linter for yaml.github `run:` blocks; no Mason package, installed via mise + "shuck", -- shell linter for yaml.github `run:` blocks; installed via mise by choice "statix", -- Nix linter; prefer the $PATH binary (Nix) "systemdlint", "zsh", -- `zsh -n` syntax check via the system shell itself @@ -163,12 +163,12 @@ settings["linter_deps"] = { -- Deadline (ms) for background Mason work before the aggregated missing-tool warning -- flushes anyway. Gates each tracked install (its own window) AND the registry refresh -- wait; late completions still recover. Missing or non-positive values fall back to --- the resolver's DEFAULT_TOOL_INSTALL_TIMEOUT_MS (300000) in `modules/utils/tools.lua`. +-- the resolver's DEFAULT_TOOL_INSTALL_TIMEOUT_MS in `modules/utils/tools.lua`. ---@type number settings["tool_install_timeout"] = 300000 -- DAP adapters to enable (mason-nvim-dap adapter names), resolved --- discovery-first when nvim-dap lazy-loads (first :Dap* command). +-- discovery-first when nvim-dap lazy-loads (first :Dap* command or debug keymap). -- Supported DAPs: https://github.com/jay-babu/mason-nvim-dap.nvim/blob/main/lua/mason-nvim-dap/mappings/source.lua ---@type string[] settings["dap_deps"] = { @@ -262,8 +262,7 @@ local merged = require("modules.utils").extend_config(settings, "user.settings") -- Migration guard: the discovery-first refactor removed this key; a stale -- user/settings.lua would merge it in and feed nothing — its servers would --- vanish without a word. (Scheduled: the notifier plugin isn't loaded this --- early; the default notify still lands in :messages.) +-- vanish without a word. if merged.external_lsp_deps ~= nil then -- The removed setting was a MAP of server name -> executable name, but a -- stale override can survive in any shape: classify before advising so the From e92954cce13c8003c79ee553b3a43dde611fd0dd Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 16:11:55 +0800 Subject: [PATCH 69/71] chore(tool): drop the commented-out remote-nvim spec The dead block referenced telescope, which left this config in 2026-03. --- lua/modules/plugins/tool.lua | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/lua/modules/plugins/tool.lua b/lua/modules/plugins/tool.lua index 71b1b431..f46b2ed0 100644 --- a/lua/modules/plugins/tool.lua +++ b/lua/modules/plugins/tool.lua @@ -76,18 +76,6 @@ tool["aaronhallaert/advanced-git-search.nvim"] = { }, } --- tool["amitds1997/remote-nvim.nvim"] = { --- lazy = true, --- version = "*", --- cmd = { "RemoteStart", "RemoteStop", "RemoteInfo", "RemoteCleanup", "RemoteConfigDel", "RemoteLog" }, --- dependencies = { --- "nvim-lua/plenary.nvim", --- "MunifTanjim/nui.nvim", --- "nvim-telescope/telescope.nvim", --- }, --- config = true, --- } - ---------------------------------------------------------------------- -- DAP Plugins -- ---------------------------------------------------------------------- From 6df23543001c217e2de86dfbba0f5780d6630f39 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 17:09:26 +0800 Subject: [PATCH 70/71] docs(settings): suppress missing-fields on the partial palette override palette_overwrite is a PARTIAL override merged via tbl_extend(force), but the palette class declares every field required, so the corrected @type made lua_ls flag the empty default with missing-fields. Keep the class annotation (it drives the advertised key completion) and disable that one diagnostic on the assignment; verified with lua-language-server --check that the suppression line must sit directly above the assignment. --- lua/core/settings.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lua/core/settings.lua b/lua/core/settings.lua index c7cd0110..63e9eb0e 100644 --- a/lua/core/settings.lua +++ b/lua/core/settings.lua @@ -58,6 +58,7 @@ settings["disabled_plugins"] = {} -- Parameters will auto-complete as you type. -- Example: { sky = "#04A5E5" } ---@type palette +---@diagnostic disable-next-line: missing-fields settings["palette_overwrite"] = {} -- Set the colorscheme here. From c53bd6e0f1c5764eb8805d32455b466f74b27e17 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Tue, 21 Jul 2026 17:42:54 +0800 Subject: [PATCH 71/71] fix(dap): bound the debugpy import probe with a timeout The probe runs synchronously on the :Dap* setup tick; a hung interpreter (dead network FS, broken shim) would block the editor indefinitely. wait(5000) SIGKILLs the probe on timeout with exit code 124 (:h SystemObj:wait()), which the existing non-zero check already treats as a failed probe, so resolution falls through to the remaining candidates. --- lua/modules/configs/tool/dap/clients/python.lua | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lua/modules/configs/tool/dap/clients/python.lua b/lua/modules/configs/tool/dap/clients/python.lua index 6a9f5183..3a33b9bc 100644 --- a/lua/modules/configs/tool/dap/clients/python.lua +++ b/lua/modules/configs/tool/dap/clients/python.lua @@ -121,7 +121,12 @@ return function() -- vim.system, not vim.fn.system: the exit code stays local -- instead of round-tripping through the v:shell_error global -- (repo convention — core/pack.lua, servers/gopls.lua). - if vim.system({ probe_cmd, "-c", "import debugpy" }):wait().code == 0 then + -- Bounded wait: this runs synchronously on the :Dap* setup + -- tick, and a hung interpreter (dead network FS, broken + -- shim) would otherwise freeze the editor. On timeout the + -- probe is SIGKILLed with code 124 — a failed probe, so + -- resolution falls through to the remaining candidates. + if vim.system({ probe_cmd, "-c", "import debugpy" }):wait(5000).code == 0 then -- Spawn by the probed exepath, not its realpath: a venv python is a -- symlink and pyvenv.cfg discovery precedes symlink resolution. return found(probe_cmd, { "-m", "debugpy.adapter" })