diff --git a/lua/core/settings.lua b/lua/core/settings.lua index b4a8c812..63e9eb0e 100644 --- a/lua/core/settings.lua +++ b/lua/core/settings.lua @@ -57,7 +57,8 @@ settings["disabled_plugins"] = {} -- These settings will override the defaults during initialization. -- Parameters will auto-complete as you type. -- Example: { sky = "#04A5E5" } ----@type palette[] +---@type palette +---@diagnostic disable-next-line: missing-fields settings["palette_overwrite"] = {} -- Set the colorscheme here. @@ -84,23 +85,19 @@ settings["external_browser"] = "chrome-cli open" ---@type boolean settings["lsp_inlayhints"] = false --- LSPs installed outside Mason (e.g. via system package manager). --- These will be configured but not installed by Mason. --- Key: lspconfig server name, Value: executable name to check availability. ----@type table -settings["external_lsp_deps"] = { - nixd = "nixd", - nil_ls = "nil", - shuck = "shuck", -- shell linter/formatter/LSP (Rust); installed via mise, not Mason - -- dartls = "dart", -} - --- LSPs to install during bootstrap. +-- Language servers to enable, resolved discovery-first at runtime: binary on $PATH is +-- used as-is; else Mason installs it when it ships a package; else an aggregated warning +-- asks you to provision it. Names whose filetypes lspconfig knows resolve on that +-- filetype's FIRST buffer (a late sweep classifies the rest once per session); names +-- with user overrides, repo modules that override filetypes, or no filetype data +-- resolve on the first file open. See `modules.utils.tools` and +-- `completion/mason-lspconfig.lua`. -- Full list: https://github.com/neovim/nvim-lspconfig/tree/master/lsp ---@type string[] settings["lsp_deps"] = { "bashls", "clangd", + -- "dartls", -- Dart LSP (ships with the Dart SDK) "dockerls", "gh_actions_ls", -- "gitlab_ci_ls", @@ -111,7 +108,10 @@ settings["lsp_deps"] = { "lua_ls", "marksman", "neocmake", + "nil_ls", -- Nix LSP; the Nix-provisioned $PATH binary is preferred + "nixd", -- Nix LSP (Rust); provisioned from Nix ($PATH) "ruff", + "shuck", -- shell linter/formatter/LSP (Rust); installed via mise by choice ($PATH wins) "systemd_lsp", "terraformls", "tflint", @@ -120,39 +120,56 @@ settings["lsp_deps"] = { "zuban", } --- Formatters to install during bootstrap (Mason package names). --- These are managed by Mason and used by conform.nvim. +-- Formatters to resolve when conform.nvim lazy-loads (first BufWritePre / +-- :Format). conform formatter names, resolved discovery-first like lsp_deps. ---@type string[] settings["formatter_deps"] = { "beautysh", "clang-format", - "cmakelang", + "cmake_format", "fixjson", "gofumpt", "goimports", "mdsf", + "nixfmt", -- Nix formatter; prefer the $PATH binary (Nix) "prettier", "superhtml", "shellharden", + "statix", -- Nix linter, its `fix` mode doubles as a conform formatter; from Nix ($PATH) "stylua", } --- Linters to install during bootstrap (Mason package names). --- These are managed by Mason and used by nvim-lint. +-- Linters to resolve discovery-first (nvim-lint linter names). A name mapped +-- to a filetype resolves on that filetype's FIRST matching event after +-- nvim-lint lazy-loads (the resolve-only FileType autocmd or a lint event) — +-- nothing is installed or warned about before such a buffer opens; unmapped +-- names (typos, manual-only linters) get an immediate deferred pass instead. ---@type string[] settings["linter_deps"] = { "actionlint", + "deadnix", -- Nix dead-code linter; prefer the $PATH binary (Nix) "hadolint", "markdownlint-cli2", "oxlint", -- "rumdl", -- markdownlint Rust rewrite; waiting for rule coverage to mature - "golangci-lint", + "golangcilint", "selene", "shellcheck", + "shuck", -- shell linter for yaml.github `run:` blocks; installed via mise by choice + "statix", -- Nix linter; prefer the $PATH binary (Nix) "systemdlint", + "zsh", -- `zsh -n` syntax check via the system shell itself } --- Debug Adapter Protocol (DAP) clients to install and configure during bootstrap. +-- Deadline (ms) for background Mason work before the aggregated missing-tool warning +-- flushes anyway. Gates each tracked install (its own window) AND the registry refresh +-- wait; late completions still recover. Missing or non-positive values fall back to +-- the resolver's DEFAULT_TOOL_INSTALL_TIMEOUT_MS in `modules/utils/tools.lua`. +---@type number +settings["tool_install_timeout"] = 300000 + +-- DAP adapters to enable (mason-nvim-dap adapter names), resolved +-- discovery-first when nvim-dap lazy-loads (first :Dap* command or debug keymap). -- Supported DAPs: https://github.com/jay-babu/mason-nvim-dap.nvim/blob/main/lua/mason-nvim-dap/mappings/source.lua ---@type string[] settings["dap_deps"] = { @@ -242,4 +259,58 @@ 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. +if merged.external_lsp_deps ~= nil then + -- The removed setting was a MAP of server name -> executable name, but a + -- stale override can survive in any shape: classify before advising so the + -- guidance never presents numeric indices as keys, never drops entries a + -- half-migrated LIST residue still carries, and always names the final + -- step (deleting the dead key). Neither group suppresses the other. + local string_keys, list_items = {}, {} + if type(merged.external_lsp_deps) == "table" then + for k, v in pairs(merged.external_lsp_deps) do + if type(k) == "string" then + string_keys[#string_keys + 1] = k + elseif type(k) == "number" and type(v) == "string" then + list_items[#list_items + 1] = v + end + end + table.sort(string_keys) + table.sort(list_items) + end + local guidance + if #string_keys > 0 and #list_items > 0 then + guidance = "Move its KEYS (" + .. table.concat(string_keys, ", ") + .. ") AND its list entries (" + .. table.concat(list_items, ", ") + .. ")\n— all of them server names — into `lsp_deps`, then delete `external_lsp_deps`." + elseif #string_keys > 0 then + guidance = "Move its KEYS (" + .. table.concat(string_keys, ", ") + .. ") — the server names, not the\n" + .. "executable values — into `lsp_deps`, then delete `external_lsp_deps`." + elseif #list_items > 0 then + guidance = "It now holds a LIST (" + .. table.concat(list_items, ", ") + .. ") — those are already the\n" + .. "server names; move them into `lsp_deps` and delete `external_lsp_deps`." + else + guidance = "It is empty or not a map — delete `external_lsp_deps` from user/settings.lua." + end + -- (Scheduled: the notifier plugin isn't loaded this early; the default + -- notify still lands in :messages.) + vim.schedule(function() + vim.notify( + "`external_lsp_deps` was removed: non-Mason servers are now discovered\nfrom $PATH. " .. guidance, + vim.log.levels.WARN, + { title = "core.settings" } + ) + end) +end + +return merged diff --git a/lua/modules/configs/completion/conform.lua b/lua/modules/configs/completion/conform.lua index 2a52dfa2..074fbd46 100644 --- a/lua/modules/configs/completion/conform.lua +++ b/lua/modules/configs/completion/conform.lua @@ -40,6 +40,19 @@ return function() return false end + ---The gates a buffer must pass before an automatic format, grouped into one + ---named predicate for the format_on_save callback's readability. The + ---format_on_save SETTING is not re-checked here: its gate lives at the + ---single place the callback is installed (format_on_save = enabled and …). + ---@param bufnr integer + ---@return boolean + local function autoformat_allowed(bufnr) + return block_list[vim.bo[bufnr].filetype] ~= true + and not is_disabled_workspace(bufnr) + and not vim.g.disable_autoformat + and not vim.b[bufnr].disable_autoformat + end + ---Format only git-modified lines using gitsigns hunks + conform range format ---@param bufnr integer ---@return boolean @true if modifications were formatted @@ -87,6 +100,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 +147,11 @@ 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: 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" }, @@ -141,22 +159,10 @@ return function() }, }, format_on_save = format_on_save_enabled and function(bufnr) - -- Check disabled filetypes - if block_list[vim.bo[bufnr].filetype] == true then - return - end - - -- Check disabled workspaces - if is_disabled_workspace(bufnr) then - return - end - - -- Check global toggle - if vim.g.disable_autoformat or vim.b[bufnr].disable_autoformat then + if not autoformat_allowed(bufnr) then return end - -- Format only modified lines if enabled if format_modifications_only then if format_modifications(bufnr) then return @@ -168,6 +174,58 @@ return function() end or false, }) + -- Resolve `formatter_deps` (conform formatter names) discovery-first against + -- conform's own registry, so a missing formatter is installed / reported. + -- The probe only drives install/warn — nothing on the save path reads it — + -- so `defer` moves the resolve off the BufWritePre tick that lazy-loaded + -- conform; the resolver itself keeps the same-tick guarantee that Mason's + -- bin dir is on $PATH before the replayed save's spawns. + tools.resolve_runtime_tools("conform.nvim", settings.formatter_deps, function(name) + -- get_formatter_config is conform's @private API; if it vanishes, every + -- formatter is UNVERIFIABLE — report unresolved with the reason (missing + -- bucket, immediate flush) instead of silently classifying them all as + -- self-resolving, which would turn off installs and warnings wholesale. + local conform = require("conform") + if type(conform.get_formatter_config) ~= "function" then + return { + unresolved = true, + reason = "conform.get_formatter_config is unavailable (conform API drift?) — formatters cannot be verified", + } + end + -- get_formatter_config runs a function-form override directly, so pcall keeps a + -- throwing override (a broken config) from being misread as an unknown name. + local ok, config, err = pcall(conform.get_formatter_config, name) + if not ok then + return { broken = tostring(config) } + end + if config then + -- A function-form command resolves per buffer at format time (e.g. the + -- builtin from_node_modules): treat it as self-resolving rather than + -- evaluating it for a representative binary — a node_modules command + -- shouldn't map to a Mason install anyway. + if type(config.command) == "function" then + return { binary = nil } + end + return { binary = config.command } + end + -- (nil, err) is a real formatter with a broken config; bare nil is an unknown name. + if type(err) == "string" then + return { broken = err } + end + -- A function-form override may legitimately return nil for the + -- probe-time buffer (this probe runs on a scheduled tick against + -- whatever buffer happens to be current): its existence proves the + -- name real, but nothing is verifiable — report it unresolved + -- (missing bucket, tailored reason) instead of a typo or a silent pass. + local overrides = conform.formatters + if type(overrides) == "table" and type(overrides[name]) == "function" then + -- The reason rides on the probe result: the phrasing is conform's, + -- not the shared resolver's (nvim-lint shares resolve_runtime_tools). + return { unresolved = true, reason = "config resolves per buffer and could not be verified at startup" } + end + return nil + end, nil, { defer = true }) + -- User commands vim.api.nvim_create_user_command("Format", function(args) local range = nil @@ -226,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) diff --git a/lua/modules/configs/completion/lsp.lua b/lua/modules/configs/completion/lsp.lua index 866260b3..bf0fc1b6 100644 --- a/lua/modules/configs/completion/lsp.lua +++ b/lua/modules/configs/completion/lsp.lua @@ -1,30 +1,18 @@ return function() + -- Handler/probe machinery first (no discovery yet): sets up the read + -- trigger the override pass below relies on. require("completion.mason-lspconfig").setup() - local opts = { - capabilities = require("modules.utils").get_lsp_capabilities(), - } - -- Configure LSPs that are not managed by Mason but are available in `nvim-lspconfig`. - -- Servers are defined in `settings.external_lsp_deps` as { server_name = "executable" }. - for lsp_name, exe in pairs(require("core.settings").external_lsp_deps) do - if vim.fn.executable(exe) == 1 then - local ok, _opts = pcall(require, "user.configs.lsp-servers." .. lsp_name) - if not ok then - local default_ok, default_opts = pcall(require, "completion.servers." .. lsp_name) - if default_ok then - _opts = default_opts - end - end - if type(_opts) == "table" then - local final_opts = vim.tbl_deep_extend("keep", _opts, opts) - require("modules.utils").register_server(lsp_name, final_opts) - else - require("modules.utils").register_server(lsp_name, opts) - end - end - end + -- Run `user.configs.lsp` with its vim.lsp.config registrations recorded: a + -- mid-session install registers after this point, and the replay keeps the + -- user's overrides on top regardless of timing. + -- `user.configs.lsp-servers.` remains the richer per-server hook. + require("completion.mason-lspconfig").run_user_lsp_overrides() - pcall(require, "user.configs.lsp") + -- Discovery LAST (Mason-installed / on $PATH / installable / missing, + -- driven by `settings.lsp_deps`): user runtime registrations above must be + -- visible to the unknown/binary classification. + require("completion.mason-lspconfig").resolve_deps() -- Start LSPs pcall(vim.cmd.LspStart) diff --git a/lua/modules/configs/completion/mason-lspconfig.lua b/lua/modules/configs/completion/mason-lspconfig.lua index fa3d2a4e..7c19e57e 100644 --- a/lua/modules/configs/completion/mason-lspconfig.lua +++ b/lua/modules/configs/completion/mason-lspconfig.lua @@ -1,15 +1,177 @@ local M = {} -M.setup = function() - local lsp_deps = require("core.settings").lsp_deps - local mason_registry = require("mason-registry") - local mason_lspconfig = require("mason-lspconfig") - - require("modules.utils").load_plugin("mason-lspconfig", { - ensure_installed = lsp_deps, - -- Skip auto enable because we are loading language servers lazily - automatic_enable = false, +-- vim.lsp.config registrations from `user.configs.lsp` (name -> ordered list +-- of { cfg } merges / { replace = true, cfg } assignments): EVERY configure +-- replays them, since its repo-spec registration would otherwise force-merge +-- over the user's keys — a post-install late configure is merely the latest +-- timing this covers. +local user_lsp_configs = {} + +-- Bridge set by setup(): read-triggered registration (fun(real, name)) so a +-- `user.configs.lsp` read of a not-yet-registered lsp_deps server sees the +-- post-registration view regardless of install timing. +local registration_trigger = nil +-- Bridge set by setup(): drops a server's cached probe when a user op or a +-- registration changes what the truth source resolves for it. +local server_info_invalidate = nil +-- Bridge set by setup(): the discovery pass, run AFTER `user.configs.lsp` so +-- its runtime registrations are visible to the unknown/binary classification. +local resolve_deps = nil +-- Bridge set by setup(): resolves every still-deferred per-filetype batch — +-- the parity sweep's target and a manual escape hatch. +local resolve_remaining = nil +-- User overrides run once per session (see run_user_lsp_overrides' doc): +-- a second call is refused loudly. +local overrides_ran = false + +---Run `user.configs.lsp` with vim.lsp.config proxied to record per-server +---registrations. Reads forward to the real table — after triggering the dep's +---real registration first, so a read-modify-write override snapshots the same +---base whether or not the server was installed yet. "*" is not recorded (core +---merges it at read time); the real table is always restored right after the +---pcall'd require (same silent-if-missing behavior as before). +--- +---Supported surface for the user module: per-name operations only — +---`vim.lsp.config[name]` reads, `vim.lsp.config(name, cfg)` calls and +---`vim.lsp.config[name] = cfg` assignments (record/replay and the read +---trigger exist for exactly these). Enumeration (pairs over the proxy, +---core-private `_configs`) is out of contract: its visible set depends on +---registration order by nature. +--- +---Once per session: the ops write through to the real table, so a rerun +---could not rebuild from a clean slate; changes need a restart. +function M.run_user_lsp_overrides() + if overrides_ran then + vim.notify( + "user LSP overrides are applied once per session; restart Neovim to apply changes", + vim.log.levels.WARN, + { title = "nvim-lspconfig" } + ) + return + end + overrides_ran = true + local real = vim.lsp.config + 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) + 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 } } + -- The op may have changed what the truth source resolves (cmd + -- included): drop the cached probe. + if server_info_invalidate then + server_info_invalidate(key) + end + end + end, + __call = function(_, name, cfg) + real(name, cfg) + if type(name) == "string" and name ~= "*" and type(cfg) == "table" then + local list = user_lsp_configs[name] or {} + list[#list + 1] = { cfg = cfg } + user_lsp_configs[name] = list + if server_info_invalidate then + server_info_invalidate(name) + end + end + end, }) + vim.lsp.config = proxy + pcall(require, "user.configs.lsp") + vim.lsp.config = real +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 + +---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() + end +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") + + -- lsp_deps as a set: the read trigger below only acts on names this config + -- actually manages. + local deps_set = {} + -- Parenthesized: split_dep_names' second return must not reach ipairs. + for _, name in ipairs((tools.split_dep_names(settings.lsp_deps))) do + deps_set[name] = true + end + -- Servers whose registration already ran (configure() or a read trigger): + -- configure() skips re-registration, so a registration triggered before the + -- user's ops can never be re-asserted over them later. + local registered = {} + + ---Ordered server-spec modules for a server: user override, then repo default. + ---@param name string + ---@return string[] + local function server_modules(name) + return { "user.configs.lsp-servers." .. name, "completion.servers." .. name } + end + + ---cmd → probeable binary: a table cmd's argv[0], nil otherwise (a function + ---cmd resolves its own launch). The ONE extraction rule for every + ---classification site in server_info/unknown_of below. + ---@param cmd any + ---@return string|nil + local function binary_of_cmd(cmd) + return type(cmd) == "table" and cmd[1] or nil + end + + -- 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 export below is the + -- anti-rot hook that keeps it honest against servers/*.lua. + local eager_ft_override_modules = { + gopls = true, + harper_ls = true, + ruff = true, + shuck = true, + terraformls = true, + 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 + -- 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 -- keep in sync with nvim-lint's parity sweep vim.diagnostic.config({ signs = true, @@ -21,96 +183,497 @@ M.setup = function() local opts = { capabilities = require("modules.utils").get_lsp_capabilities(), } - ---A handler to setup all servers defined under `completion/servers/*.lua` + + ---Probe and cache a server's spec once. `binary` = first table-cmd entry in + ---precedence order (user, repo, lspconfig); nil for a function/absent cmd. + ---Post-registration the resolved config's cmd wins unconditionally (a + ---registered cmd is what enable() will spawn). + local server_info_cache = {} + ---@class mason_lspconfig.ServerInfo + ---@field has_module boolean + ---@field binary string|nil + ---@field known_lspconfig boolean + ---@field self_resolving boolean|nil @Resolved cmd is a function owned by the config: no Mason classification. + ---@field user_loaded boolean + ---@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 + 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" then + info.binary = binary_of_cmd(user_spec.cmd) + end + elseif user_exists then + info.has_module = true + info.broken_reason = user_reason + or string.format("failed to load `%s` (see the earlier error notification)", modules[1]) + end + -- Load the repo preset only when usable (no override, or as merge base under a + -- table override): a function-form override replaces it wholesale. + -- POLICY (unified since the discovery-first branch, including the formerly + -- "external" servers nil_ls/nixd/shuck): a TABLE override MERGES over the + -- repo preset ("write what you change"); full replacement is expressed as + -- a function-form override. + 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" then + info.binary = binary_of_cmd(spec.cmd) + end + elseif exists then + info.has_module = true + -- Under a valid user TABLE override a broken preset is merely the + -- optional merge base: degrade to {} (already notified once by + -- load_module_or_report) instead of disabling the server. + if not 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 + 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 + ---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 + info.binary = binary_of_cmd(config.cmd) + if type(config.cmd) ~= "table" then + info.self_resolving = true + end + end + elseif info.binary == nil or not info.has_module then + -- Pre-registration the module spec is the better predictor of the + -- upcoming stored registration (an lspconfig rtp default must not + -- shadow a module's custom path); consult the truth source only for + -- what the modules couldn't answer. Any cmd (even a function) + -- proves the name real (keeps jsonls out of the unknown bucket). + local config = resolved_config() + if config and config.cmd ~= nil then + info.known_lspconfig = true + if info.binary == nil then + info.binary = binary_of_cmd(config.cmd) + end + end + end + server_info_cache[name] = info + return info + end + server_info_invalidate = function(name) + server_info_cache[name] = nil + end + + ---Register (not enable) a server's config, reusing the spec server_info() + ---loaded; raises on a broken/misshapen spec so the resolver aggregates the + ---reason. vim.lsp.enable() runs later in configure(). ---@param lsp_name string local function mason_lsp_handler(lsp_name) - -- rust_analyzer is configured using mrcjkb/rustaceanvim - -- warn users if they have set it up manually - if lsp_name == "rust_analyzer" then - local config_exist = pcall(require, "completion.servers." .. lsp_name) - if config_exist then - vim.notify( - [[ -`rust_analyzer` is configured independently via `mrcjkb/rustaceanvim`. To get rid of this warning, -please REMOVE your LSP configuration (rust_analyzer.lua) from the `servers` directory and configure -`rust_analyzer` using the appropriate init options provided by `rustaceanvim` instead.]], - vim.log.levels.WARN, - { title = "nvim-lspconfig" } - ) + local info = server_info(lsp_name) + -- No-fall-through contract, enforced by the ONE shared implementation + -- (tools.usable_or_raise): a broken or wrong-shaped config must never + -- read as success — that would suppress both the warning and the + -- install fallback. Precedence was decided by server_info + -- (info.spec / info.merge_base). + local spec = tools.usable_or_raise(info.spec, info.broken_reason, { + label = "server config", + expected = "a fun(opts) or a table", + shapes = { ["function"] = true, table = true }, + }) + if spec == nil then + -- Default to use factory config for server(s) that doesn't include a spec + vim.lsp.config(lsp_name, opts) + elseif type(spec) == "function" then + -- Server owns its setup; it must call vim.lsp.config() itself (see + -- clangd.lua for an example). + spec(opts) + else + vim.lsp.config(lsp_name, vim.tbl_deep_extend("force", opts, info.merge_base or {}, spec)) + end + end + + if mason_ok then + -- lspconfig integration only; installs are driven by the shared resolver, + -- not gated on Mason's installed set. + require("modules.utils").load_plugin("mason-lspconfig", { + ensure_installed = {}, + -- Skip auto enable because we are loading language servers lazily + automatic_enable = false, + }) + end + + -- lspconfig server name -> Mason package name. Re-fetched while 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 + -- 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 + -- 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 + -- The mapping derives from the registry specs: a registry update must not + -- leave the first non-empty snapshot frozen. SYNCHRONOUS clear on purpose — + -- the emit-order rationale lives at tools.lua's update:success handler + -- (the one place that fact is argued). 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. + ---@param name string + ---@return boolean + local function has_local_config(name) + local info = server_info(name) + return info.binary == nil and (info.has_module or info.known_lspconfig) + end + + ---Typo/outdated name vs valid-but-uninstalled: a Mason mapping, a repo/user server + ---module, or a built-in lspconfig config all mean the name is real. + ---@param name string + ---@return boolean + local function unknown_of(name) + if package_of(name) then + return false + end + local info = server_info(name) + if info.has_module or info.known_lspconfig then + return false + end + -- A runtime registration may have landed after the probe cached its + -- negative (the user read the name first, then wrote a cmd): re-consult + -- the truth source before the typo verdict, upgrading the cache in place. + local 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 then + info.binary = binary_of_cmd(config.cmd) end - return + return false end + return true + end - local ok, custom_handler = pcall(require, "user.configs.lsp-servers." .. lsp_name) - local default_ok, default_handler = pcall(require, "completion.servers." .. lsp_name) - -- Use preset if there is no user definition - if not ok then - ok, custom_handler = default_ok, default_handler + ---Register a server (unless a read trigger already did), then enable it (a + ---bare Mason `cmd` spawns fine: the resolver put Mason's bin dir on $PATH). + local function configure(name) + if not registered[name] then + mason_lsp_handler(name) + registered[name] = true + -- The probe cached pre-registration state: rebuild so later readers + -- (group-1 retry/event matchers) see the resolved cmd. + server_info_cache[name] = nil end + -- Replay recorded `user.configs.lsp` registrations on top: discovery + -- runs AFTER the user module (lsp.lua orders setup → overrides → + -- resolve), so the repo-spec registration above just force-merged over + -- any write-only override's keys — this replay restores them on every + -- configure. Only the read-triggered path (registration before + -- recording) makes it a natural no-op. + for _, entry in ipairs(user_lsp_configs[name] or {}) do + if entry.replace then + vim.lsp.config[name] = entry.cfg + else + vim.lsp.config(name, entry.cfg) + end + end + vim.lsp.enable(name) + end - if not ok then - -- Default to use factory config for server(s) that doesn't include a spec - require("modules.utils").register_server(lsp_name, opts) - elseif type(custom_handler) == "function" then - -- Case where language server requires its own setup - -- Be sure to call `vim.lsp.config()` within the setup function. - -- Refer to |vim.lsp.config()| for documentation. - -- For an example, see `clangd.lua`. - custom_handler(opts) - vim.lsp.enable(lsp_name) - elseif type(custom_handler) == "table" then - require("modules.utils").register_server( - lsp_name, - vim.tbl_deep_extend( - "force", - opts, - type(default_handler) == "table" and default_handler or {}, - custom_handler - ) - ) + -- 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 + -- The handler's own server_info ran pre-registration (a function-form + -- spec registers its cmd mid-trigger, bypassing the proxy hooks): + -- rebuild so the probe reflects the resolved, registered cmd. + server_info_cache[name] = nil else - vim.notify( - string.format( - "Failed to setup [%s].\n\nServer definition under `completion/servers` must return\neither a fun(opts) or a table (got '%s' instead)", - lsp_name, - type(custom_handler) - ), - vim.log.levels.ERROR, - { title = "nvim-lspconfig" } - ) - end - end - - ---A simplified mimic of 's `setup_handlers` callback. - ---Invoked for each Mason package (name or `Package` object) to configure its language server. - ---@param pkg string|{name: string} Either the package name (string) or a Package object - local function setup_lsp_for_package(pkg) - -- First try to grab the builtin mappings - local mappings = mason_lspconfig.get_mappings().package_to_lspconfig - -- If empty or nil, build it by hand - if not mappings or vim.tbl_isempty(mappings) then - mappings = {} - for _, spec in ipairs(mason_registry.get_all_package_specs()) do - local lspconfig = vim.tbl_get(spec, "neovim", "lspconfig") - if lspconfig then - mappings[spec.name] = lspconfig - end - end + store[name] = before end + end + + ---One resolve pass over a batch of deps. The collector aggregates by + ---title across batches; each batch gets its own session/pending record + ---(retry_pending walks all of them). + ---@param deps any[] + local function resolve_batch(deps) + tools.resolve({ + title = "LSP", + deps = deps, + -- A 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, + -- Phase 2 (registry classification: full mappings decode + package + -- hydration) moves off the BufReadPre tick. Safe because a late + -- configure's vim.lsp.enable() attaches already-open matching + -- buffers (documented semantics — :h vim.lsp.enable()), so the one + -- same-tick beneficiary — a Mason-installed server whose binary + -- name differs from the probe — still reaches the triggering + -- buffer. + defer_phase2 = true, + }) + end - -- 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 + -- 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 - -- Invoke the handler - mason_lsp_handler(srv) + 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 - for _, pkg in ipairs(mason_registry.get_installed_package_names()) do - setup_lsp_for_package(pkg) + -- The discovery pass itself runs via M.resolve_deps() AFTER + -- run_user_lsp_overrides() (see lsp.lua): user runtime registrations must + -- exist before the unknown/binary classification. It partitions the RAW + -- dep list: invalid entries stay in the immediate batch verbatim (the + -- resolver's own re-scan reports them); user-overridden names (either + -- hook) 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 immediate = {} + deferred_by_ft = {} + handed_off = {} + -- 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 + 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 + -- 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 + -- 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 diff --git a/lua/modules/configs/completion/mason.lua b/lua/modules/configs/completion/mason.lua index 450cdefa..e0af453e 100644 --- a/lua/modules/configs/completion/mason.lua +++ b/lua/modules/configs/completion/mason.lua @@ -27,34 +27,14 @@ M.setup = function() }, }) - -- Ensure formatters and linters are installed (only if mason loaded) - local ok, registry = pcall(require, "mason-registry") - if not ok then - return - end + -- Formatter/linter resolution lives in conform.lua and nvim-lint.lua (against their + -- own registrations); Mason here is UI-only / lazy install fallback. - 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 + -- 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 diff --git a/lua/modules/configs/completion/nvim-lint.lua b/lua/modules/configs/completion/nvim-lint.lua index e4b88eef..3fff8edc 100644 --- a/lua/modules/configs/completion/nvim-lint.lua +++ b/lua/modules/configs/completion/nvim-lint.lua @@ -1,37 +1,60 @@ 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: 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 = { + "--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 + local function apply_override(name) + local apply = overrides[name] + if not apply then + return + end + local linter = lint.linters[name] + if type(linter) == "table" then + apply(linter) + end + end + for name in pairs(overrides) do + apply_override(name) + end - -- shuck: lints shell embedded in GitHub Actions workflows (the `run:` blocks). - -- Complements actionlint, which validates workflow syntax/expressions but not - -- the embedded shell. Standalone sh/bash/zsh diagnostics already come from the - -- shuck LSP server (see servers/shuck.lua), so shuck is only added to - -- `yaml.github` here, not to `sh`/`zsh`. `shuck check` has no working stdin - -- mode (it needs a project root), so run file-based and parse JSON output. + -- shuck: lints shell embedded in GitHub Actions `run:` blocks with rules + -- beyond actionlint's shellcheck pass-through; standalone sh/bash comes + -- from the shuck LSP server. No usable stdin mode (needs a project root), + -- so run file-based and parse JSON. lint.linters.shuck = { name = "shuck", cmd = "shuck", @@ -73,7 +96,7 @@ return function() end, } - lint.linters_by_ft = { + local by_ft = { dockerfile = { "hadolint" }, go = { "golangcilint" }, lua = { "selene" }, @@ -88,11 +111,292 @@ 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 block at load (see refresh_linter below). + local tools = require("modules.utils.tools") + -- No factory-wrapper machinery here on purpose: both overridden linters + -- (selene, markdownlint-cli2) are plain-table modules upstream, so a + -- per-call wrapper would be unreachable speculation. If upstream ever + -- converts one to a factory, the override silently stops applying (the + -- probe's factory branch still resolves the linter) — revisit then. + ---Rebuild a module-backed linter for a late configure: some (golangcilint) + ---compute `args` by RUNNING their binary at module-load time, and a result + ---computed while the binary was absent would persist all session. + ---Deliberately SYNCHRONOUS inside the resolver's configure (F24 evaluated + ---and rejected deferral): the hand-off pending gate and the aggregated + ---warning both key off configure's synchronous success/failure — deferring + ---the reload would report success before the rebuild happened. + ---@param name string + local function refresh_linter(name) + local module = "lint.linters." .. name + if not tools.module_path(module) then + return -- defined inline (e.g. shuck), not module-backed: nothing to reload + end + local prev = lint.linters[name] + package.loaded[module] = nil + -- Also drop any explicit assignment (e.g. a prior error-path restore + -- below) shadowing the lint.linters __index loader. + rawset(lint.linters, name, nil) + -- Require it OURSELVES: the __index loader pcall-swallows a reload + -- failure into nil, which would read as configure success and clear + -- the hand-off pending gate over a stale linter. + local ok, err = pcall(require, module) + if not ok then + -- Restore — stale args beat a deleted linter (asserts on try_lint) — + -- then raise so the resolver keeps the name pending and reported. + rawset(lint.linters, name, prev) + tools.raise_verbatim("linter reload failed after install: " .. tostring(err)) + end + apply_override(name) + end + ---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 + -- 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) } + end + -- The retry loaded (nondeterministic loader): pick up the fresh value. + linter = lint.linters[name] + if linter == nil then + return nil + end + end + if type(linter) == "function" then + -- A throwing factory is a broken config, not an unknown name. + local ok, resolved = pcall(linter) + if not ok then + return { broken = tostring(resolved) } + end + linter = resolved + end + if type(linter) ~= "table" then + return nil + end + local cmd = linter.cmd + if type(cmd) == "function" then + local ok, resolved = pcall(cmd) + if not ok then + return { broken = tostring(resolved) } + end + cmd = resolved + end + -- A non-string cmd means the linter resolves its command at runtime. + return { binary = type(cmd) == "string" and cmd or nil } + end, function(name, late) + if late then + -- Rebuild so load-time work sees the just-installed binary + -- (refresh_linter re-applies the local override itself). + refresh_linter(name) + else + apply_override(name) + end + if late then + -- No lint event follows a late configure: re-lint every loaded + -- buffer whose filetype maps to this linter. + 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 raw_deps = require("core.settings").linter_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 + mapped[name] = true + end + end + local pending, immediate = {}, {} + for _, name in ipairs(deps) do + if mapped[name] then + pending[name] = true + else + immediate[#immediate + 1] = name + end + end + -- Entries split_dep_names drops (non-string / empty) are config mistakes: + -- forward them raw so the resolver's own sweep reports them in the unknown + -- bucket, identically to the other consumers. + for _, entry in ipairs(invalid_deps) do + immediate[#immediate + 1] = entry + 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, }) + + -- 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 module loads (see + -- refresh_linter above) must see the same environment when the warm + -- phase loads them first instead. + tools.ensure_mason_on_path() + -- Warm the __index module loads one per tick (bounds any blocking + -- load to a single tick), then resolve everything still pending in + -- ONE batch so the missing-tools warning stays aggregated. Loader + -- errors are swallowed here on purpose: resolve_batch's probe + -- re-derives broken-vs-unknown. + local index = 0 + local function step() + if not sweep_live() then + return + end + index = index + 1 + if index <= #names then + local name = names[index] + if pending[name] then -- skip names an event already claimed + pcall(function() + local _ = lint.linters[name] + end) + end + vim.schedule(step) + return + end + local batch = {} + for _, name in ipairs(names) do + if pending[name] then + pending[name] = nil + batch[#batch + 1] = name + end + end + if #batch > 0 then + resolve_batch(batch) -- order-preserving subset of sorted `names` + end + end + step() + end + + vim.defer_fn(function() + if not sweep_live() or next(pending) == nil then + return + end + -- Arm, don't run: CursorHold(I) 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 diff --git a/lua/modules/configs/completion/servers/shuck.lua b/lua/modules/configs/completion/servers/shuck.lua index 95319fbe..e000c4de 100644 --- a/lua/modules/configs/completion/servers/shuck.lua +++ b/lua/modules/configs/completion/servers/shuck.lua @@ -1,17 +1,11 @@ -- shuck: Rust shell linter/formatter/language server. -- https://ewhauser.github.io/shuck/docs/lsp/ --- --- Installed via mise (`cargo:shuck-cli`), not Mason, so it is wired through --- `settings.external_lsp_deps` rather than `lsp_deps`. shuck is not shipped in --- nvim-lspconfig either, so cmd/filetypes/root_markers must be declared here. --- --- Provides live diagnostics, code actions (incl. `source.fixAll.shuck`), --- suppression-code hover, and document/range formatting over LSP. --- --- `zsh` is intentionally excluded: shuck's zsh dialect still misparses some --- zsh-isms (e.g. path literals inside `[(I)...]` subscripts) and emits false --- positives, so zsh files are left to `zsh -n` (nvim-lint). Re-add "zsh" here --- once shuck's zsh support is solid. +-- Installed via mise (`cargo:shuck-cli`) by choice — Mason ships a shuck +-- package now, but under discovery-first the $PATH copy wins, so the +-- resolver enables it from the `shuck` binary (fix = `mise install`). +-- `zsh` is intentionally excluded: shuck's zsh dialect (as of v0.0.4x, +-- 2026-07) still misparses some zsh-isms and emits false positives, so zsh +-- stays on `zsh -n` (nvim-lint). return { cmd = { "shuck", "server" }, filetypes = { "sh", "bash", "ksh" }, diff --git a/lua/modules/configs/completion/servers/yamlls.lua b/lua/modules/configs/completion/servers/yamlls.lua index f8052abf..8d9df2a0 100644 --- a/lua/modules/configs/completion/servers/yamlls.lua +++ b/lua/modules/configs/completion/servers/yamlls.lua @@ -1,4 +1,208 @@ -- https://github.com/neovim/nvim-lspconfig/blob/master/lsp/yamlls.lua +-- 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 = { + { + name = "azure-pipelines", + description = "azure-pipelines YAML schema", + fileMatch = { "azure-pipelines.{yml,yaml}" }, + url = "https://raw.githubusercontent.com/microsoft/azure-pipelines-vscode/master/service-schema.json", + }, + { + name = "gh-dash config", + description = "gh-dash config YAML schema", + fileMatch = "*/gh-dash/config.{yml,yaml}", + -- The DOCUMENTED canonical form (docs + astro site config use the + -- bare host; the www alias is just today's hosting redirect target). + url = "https://gh-dash.dev/schema.json", + }, + -- The catalog's own "Traefik v3" entry carries no fileMatch, so this + -- extra is what actually claims the static-config files for v3. + { + name = "Traefik v3", + description = "Traefik v3 static configuration", + fileMatch = traefik_v3_files, + url = traefik_v3_url, + }, + }, +}) + +---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[] expanded +---@return boolean truncated +local function expand_braces(glob) + 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, truncated +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 +-- 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. 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 claims +---@return boolean beyond +local function claims_same_files(glob) + if type(glob) ~= "string" then + return false, false + end + local base = glob:match("([^/\\]+)$") or glob + 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, 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. 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 check_glob(fileMatch) then + schemas[url] = nil + end + elseif type(fileMatch) == "table" then + local kept = {} + for _, glob in ipairs(fileMatch) do + if not check_glob(glob) then + kept[#kept + 1] = glob + end + end + schemas[url] = #kept > 0 and kept or nil + 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, debounce_text_changes = 150, @@ -18,37 +222,7 @@ return { -- Avoid TypeError: Cannot read properties of undefined (reading 'length') url = "", }, - schemas = require("schemastore").yaml.schemas({ - extra = { - { - name = "azure-pipelines", - description = "azure-pipelines YAML schema", - fileMatch = { "azure-pipelines.{yml,yaml}" }, - url = "https://raw.githubusercontent.com/microsoft/azure-pipelines-vscode/master/service-schema.json", - }, - { - name = "gh-dash config", - description = "gh-dash config YAML schema", - fileMatch = "*/gh-dash/config.yml", - url = "https://dlvdhr.github.io/gh-dash/configuration/gh-dash/schema.json", - }, - { - name = "Traefik v3", - description = "Traefik v3 static configuration", - fileMatch = { "traefik.yml", "traefik.yaml" }, - url = "https://www.schemastore.org/traefik-v3.json", - }, - }, - -- Override built-in Traefik v2 with v3 - replace = { - ["Traefik v2"] = { - name = "Traefik v3", - description = "Traefik v3 static configuration", - fileMatch = { "traefik.yml", "traefik.yaml" }, - url = "https://www.schemastore.org/traefik-v3.json", - }, - }, - }), + schemas = schemas, -- trace = { server = "debug" }, }, }, diff --git a/lua/modules/configs/tool/dap/clients/codelldb.lua b/lua/modules/configs/tool/dap/clients/codelldb.lua index 64124c61..355fd7ba 100644 --- a/lua/modules/configs/tool/dap/clients/codelldb.lua +++ b/lua/modules/configs/tool/dap/clients/codelldb.lua @@ -4,13 +4,18 @@ return function() local utils = require("modules.utils.dap") local is_windows = require("core.global").is_windows + -- Self-validate at config time (validate FIRST — contract: tool/dap/init.lua + -- resolver spec): launch AND attach both spawn the local binary, so + -- unlike delve/python nothing is worth registering without it — error if missing. + local command = + require("modules.utils.tools").exepath_or_error("codelldb", "install it via Mason or your package manager") dap.adapters.codelldb = { type = "server", port = "${port}", executable = { - command = vim.fn.exepath("codelldb"), -- Find codelldb on $PATH + command = command, args = { "--port", "${port}" }, - detached = is_windows and false or true, + detached = not is_windows, }, } dap.configurations.c = { diff --git a/lua/modules/configs/tool/dap/clients/delve.lua b/lua/modules/configs/tool/dap/clients/delve.lua index b1a70b3c..d7d5b75a 100644 --- a/lua/modules/configs/tool/dap/clients/delve.lua +++ b/lua/modules/configs/tool/dap/clients/delve.lua @@ -1,34 +1,81 @@ --- 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 (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) + 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 + -- 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 = host, + port = port, + }) + else + -- Resolve lazily so a dlv installed after config load is picked up without reconfiguring. + local command = require("modules.utils.tools").exepath_or_error( + "dlv", + "install delve via Mason or your package manager" + ) + callback({ + type = "server", + port = "${port}", + executable = { + command = command, + args = { "dap", "-l", "127.0.0.1:${port}" }, + detached = not is_windows, + }, + }) + end end - dap.adapters.go = { - type = "executable", - command = "node", - args = { - vim.env.MASON .. "/packages/go-debug-adapter" .. "/extension/dist/debugAdapter.js", - }, - } + -- Register under both names: the configurations below use `type = "go"`, while + -- mason-nvim-dap / other integrations reference the adapter as `delve`. + dap.adapters.go = delve_adapter + dap.adapters.delve = delve_adapter + dap.configurations.go = { { type = "go", @@ -37,7 +84,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 +96,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 +108,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 +120,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 +132,17 @@ return function() cwd = "${workspaceFolder}", program = "./${relativeFileDirname}", console = "integratedTerminal", - dlvToolPath = vim.fn.exepath("dlv"), mode = "test", showLog = true, showRegisters = true, stopOnEntry = false, }, } + + -- Availability check LAST (contract: tool/dap/init.lua resolver spec); the raise + -- is the provisioning signal — the attach-capable adapters above stay registered. + require("modules.utils.tools").exepath_or_error( + "dlv", + "local `dlv dap` launch is unavailable until installed (remote attach still works)" + ) end diff --git a/lua/modules/configs/tool/dap/clients/lldb.lua b/lua/modules/configs/tool/dap/clients/lldb.lua index 8461c77a..e3ba1bc7 100644 --- a/lua/modules/configs/tool/dap/clients/lldb.lua +++ b/lua/modules/configs/tool/dap/clients/lldb.lua @@ -3,9 +3,19 @@ return function() local dap = require("dap") local utils = require("modules.utils.dap") + -- Opt-in preset (not in default dap_deps; codelldb covers C-family). Self-validate + -- so the resolver surfaces `lldb` (validate FIRST — contract: + -- tool/dap/init.lua resolver spec). LLVM 18 renamed `lldb-vscode` -> + -- `lldb-dap`; probe the new name first, keep the old for distros still + -- shipping it. + local command = require("modules.utils.tools").exepath_or_error( + { "lldb-dap", "lldb-vscode" }, + "install it via your package manager (ships with LLVM/lldb)" + ) + dap.adapters.lldb = { type = "executable", - command = vim.fn.exepath("lldb-vscode"), -- Find lldb-vscode on $PATH + command = command, } dap.configurations.c = { { diff --git a/lua/modules/configs/tool/dap/clients/python.lua b/lua/modules/configs/tool/dap/clients/python.lua index 688256b4..3a33b9bc 100644 --- a/lua/modules/configs/tool/dap/clients/python.lua +++ b/lua/modules/configs/tool/dap/clients/python.lua @@ -1,10 +1,142 @@ -- 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") + local tools = require("modules.utils.tools") local is_windows = require("core.global").is_windows - local debugpy_root = vim.env.MASON .. "/packages/debugpy" + -- Interpreter candidates probed for a debugpy-capable python; the config-time + -- availability check and the launch path share one cascade (debugpy_command). + local py_candidates = is_windows and { "pythonw.exe", "python.exe", "python" } or { "python3", "python" } + + -- Platform-specific venv interpreter layout, in one place. + ---@param root string @A virtualenv root directory. + ---@return string @Absolute path to the venv's python interpreter. + local function venv_python(root) + return is_windows and root .. "/Scripts/pythonw.exe" or root .. "/bin/python" + end + + -- Resolve the debugpy command discovery-first. The higher-priority source + -- (the `debugpy-adapter` shim) is re-probed every call, so a mid-session + -- install takes over; the cache is consulted LAST purely to spare the + -- blocking import probe, and a cached hit re-checks existence. (A `pip + -- uninstall` that keeps the interpreter fails only at import time — accepted.) + local resolved + -- Negative session cache for the import probe, 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 all raise sites so the install guidance can't drift. + local no_debugpy = "debugpy not found: no `debugpy-adapter` shim on $PATH or in Mason's bin dir,\n" + .. "and no python able to import debugpy; install debugpy via Mason (`:Mason`)\n" + .. "or your package manager" + + -- Fast, non-blocking probes only (executable()/exepath()): the + -- `debugpy-adapter` shim, then the cached last resolution. Spawns nothing + -- itself; the import probe lives in the full cascade below and runs only + -- when these probes miss. The shim is the SUPPORTED Mason surface — it is + -- declared by the debugpy package spec and exists exactly when the managed + -- venv does, so no probe hardcodes Mason's packages//venv layout. + -- (Accepted nuance: on Windows the shim spawns via .cmd instead of + -- pythonw.exe, so no console-window suppression on that path.) + local function fast_command() + -- find_executable reaches a Mason shim even before mason.setup() puts its + -- bin dir on $PATH; spawn by absolute path (the bare name wouldn't launch). + local adapter = tools.find_executable("debugpy-adapter") + if adapter then + return found(adapter, {}) + end + -- Cache LAST: it holds whichever source last resolved — possibly a + -- lower-priority import-probe result — and probing the managed sources + -- above first is what lets a mid-session install win. + if resolved then + if vim.fn.executable(resolved.command) == 1 then + return resolved.command, resolved.args + end + -- The cached binary vanished; drop it and re-run the cascade. + resolved = nil + end + return nil + end + + -- Full cascade: fast probes, then a system python that can import debugpy. + -- The import probe spawns a short-lived python, but only when the fast probes + -- miss. Both the launch path and the config-time availability check at the + -- bottom use this cascade, so the two resolutions agree by construction. + local function debugpy_command() + local command, args = fast_command() + if command then + return command, args + end + if import_probe_failed_at 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). + local probed = {} + for _, py in ipairs(py_candidates) do + -- Confirm the module actually imports, not just that the interpreter exists. + -- Dedup by resolved path so `python`→`python3` symlinks spawn only once. + if vim.fn.executable(py) == 1 then + local abs = vim.fn.exepath(py) + local real = (abs ~= "" and vim.uv.fs_realpath(abs)) or abs + -- Key by candidate name when exepath came back empty, so one empty + -- result can't blanket-skip the remaining candidates. + local key = real ~= "" and real or py + if not probed[key] then + probed[key] = true + local probe_cmd = abs ~= "" and abs or py + -- vim.system, not vim.fn.system: the exit code stays local + -- instead of round-tripping through the v:shell_error global + -- (repo convention — core/pack.lua, servers/gopls.lua). + -- Bounded wait: this runs synchronously on the :Dap* setup + -- tick, and a hung interpreter (dead network FS, broken + -- shim) would otherwise freeze the editor. On timeout the + -- probe is SIGKILLed with code 124 — a failed probe, so + -- resolution falls through to the remaining candidates. + if vim.system({ probe_cmd, "-c", "import debugpy" }):wait(5000).code == 0 then + -- Spawn by the probed exepath, not its realpath: a venv python is a + -- symlink and pyvenv.cfg discovery precedes symlink resolution. + return found(probe_cmd, { "-m", "debugpy.adapter" }) + end + end + end + end + import_probe_failed_at = vim.uv.hrtime() + return nil + end dap.adapters.python = function(callback, config) if config.request == "attach" then @@ -17,58 +149,148 @@ 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 end dap.configurations.python = { { - -- The first three options are required by nvim-dap - type = "python", -- the type here established the link to the adapter definition: `dap.adapters.python` + type = "python", -- links to the adapter definition: `dap.adapters.python` request = "launch", name = "Debug", - -- Options below are for debugpy, see https://github.com/microsoft/debugpy/wiki/Debug-configuration-settings for supported options console = "integratedTerminal", program = utils.input_file_path(), pythonPath = function() local venv = vim.env.CONDA_PREFIX if venv then - return is_windows and venv .. "/Scripts/pythonw.exe" or venv .. "/bin/python" + return venv_python(venv) else return is_windows and "pythonw.exe" or "python3" end end, }, { - -- NOTE: This setting is for people using venv type = "python", request = "launch", name = "Debug (using venv)", - -- Options below are for debugpy, see https://github.com/microsoft/debugpy/wiki/Debug-configuration-settings for supported options console = "integratedTerminal", program = utils.input_file_path(), pythonPath = function() -- Prefer the venv that is defined by the designated environment variable. local cwd, venv = vim.uv.cwd(), vim.env.VIRTUAL_ENV - local python = venv and (is_windows and venv .. "/Scripts/pythonw.exe" or venv .. "/bin/python") or "" + local python = venv and venv_python(venv) or "" if vim.fn.executable(python) == 1 then return python end - -- Otherwise, fall back to check if there are any local venvs available. - venv = vim.uv.fs_stat(cwd .. "/venv") and cwd .. "/venv" or cwd .. "/.venv" - python = is_windows and venv .. "/Scripts/pythonw.exe" or venv .. "/bin/python" - if vim.fn.executable(python) == 1 then - return python - else - return is_windows and "pythonw.exe" or "python3" + -- Otherwise fall back to any local venv — guarded: vim.uv.cwd() + -- returns nil when the process working directory was deleted. + if cwd then + venv = vim.fn.isdirectory(cwd .. "/venv") == 1 and cwd .. "/venv" or cwd .. "/.venv" + python = venv_python(venv) + if vim.fn.executable(python) == 1 then + return python + end end + return is_windows and "pythonw.exe" or "python3" end, }, } + + -- 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. + -- 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 diff --git a/lua/modules/configs/tool/dap/init.lua b/lua/modules/configs/tool/dap/init.lua index 1e998f8b..c74db5bd 100644 --- a/lua/modules/configs/tool/dap/init.lua +++ b/lua/modules/configs/tool/dap/init.lua @@ -1,11 +1,56 @@ return function() local dap = require("dap") local dapui = require("dapui") - local mason_dap = require("mason-nvim-dap") local icons = { dap = require("modules.utils.icons").get("dap") } local colors = require("modules.utils").get_palette() local mappings = require("tool.dap.dap-keymap") + local tools = require("modules.utils.tools") + + ---Ordered client-config modules for an adapter: user override, then repo preset. + ---@param name string + ---@return string[] + local function client_modules(name) + return { "user.configs.dap-clients." .. name, "tool.dap.clients." .. name } + end + + local client_config_cache = {} + ---Load the first usable client config (user override, then repo preset), + ---memoized per adapter: the resolver consults it from several predicates + ---(has_local_config, unknown_of) plus the handler, and an uncached miss + ---would re-run a failed require each time. First success wins outright (no + ---merge-base semantics); a higher-precedence exists-but-broken candidate's + ---reason survives alongside a lower-precedence success, so the handler can + ---refuse to fall past a broken override (usable_or_raise raises it). + ---@param name string + ---@return any value, string|nil broken_reason, boolean any_exists, boolean user_won + local function load_client_config(name) + local cached = client_config_cache[name] + if not cached then + cached = { value = nil, broken_reason = nil, any_exists = false, user_won = false } + local modules = client_modules(name) + for index, module in ipairs(modules) do + local ok, value, exists, reason = tools.load_module_or_report(module, "nvim-dap") + if ok then + cached.value = value + -- Recorded here, where `modules` is already built — the + -- handler must not rebuild the list per call just to ask + -- whether the user candidate won. + cached.user_won = index == 1 + cached.any_exists = true + break + end + if exists then + cached.any_exists = true + if cached.broken_reason == nil then + cached.broken_reason = reason + end + end + end + client_config_cache[name] = cached + end + return cached.value, cached.broken_reason, cached.any_exists, cached.user_won + end -- Initialize debug hooks _G._debugging = false @@ -50,42 +95,272 @@ return function() ) vim.fn.sign_define("DapLogPoint", { text = icons.dap.LogPoint, texthl = "DapLogPoint", linehl = "", numhl = "" }) - ---A handler to setup all clients defined under `tool/dap/clients/*.lua` - ---@param config table - local function mason_dap_handler(config) - local dap_name = config.name - local ok, custom_handler = pcall(require, "user.configs.dap-clients." .. dap_name) - if not ok then - -- Use preset if there is no user definition - ok, custom_handler = pcall(require, "tool.dap.clients." .. dap_name) + -- Everything Mason-flavored loads LAZILY: a session where every adapter + -- self-validates from $PATH — with no user overrides — must not load + -- mason.nvim / mason-nvim-dap on the :Dap* tick. First use (the factory + -- fallback, a phase-2 classification, or a user override's opts + -- materialization) goes through lazy.nvim's module loader; absence + -- degrades to client-config/$PATH resolution as before. + local mason_dap = nil + local function mason_dap_mod() + if mason_dap == nil then + 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 - if not ok then - -- Default to use factory config for clients(s) that doesn't include a spec - mason_dap.default_setup(config) - return - elseif type(custom_handler) == "function" then - -- Case where the protocol requires its own setup - -- Make sure to set - -- * dap.adpaters. = { your config } + return mason_dap or nil + end + -- mason-nvim-dap private internals, not a public API: guard each require so + -- drift degrades to client-config/$PATH resolution instead of aborting. + -- Failed requires are RECORDED: with presence evidence they are upstream + -- API drift worth naming; in a Mason-less session the same failures are + -- expected silence (mason_evidence below tells the two apart). + local mapping_drift = {} + local mapping_sibling_ok = false + local mapping_drift_warned = false + local function map_or_empty(mod, default) + local ok, m = pcall(require, mod) + if ok and type(m) == "table" then + mapping_sibling_ok = true + return m + end + mapping_drift[#mapping_drift + 1] = mod:match("[^.]+$") + return default + end + ---Mason presence evidence without forcing a load: a sibling mapping module + ---loaded, the parent is already in package.loaded, or its file is + ---locatable on the search paths (rtp'd by a failed lazy require attempt). + local function mason_evidence() + return mapping_sibling_ok + or package.loaded["mason-nvim-dap"] ~= nil + or tools.module_path("mason-nvim-dap") ~= nil + end + local mason_maps_cache = nil + local function mason_maps() + if not mason_maps_cache then + mason_maps_cache = { + source = map_or_empty("mason-nvim-dap.mappings.source", {}), + adapters = map_or_empty("mason-nvim-dap.mappings.adapters", {}), + configurations = map_or_empty("mason-nvim-dap.mappings.configurations", {}), + filetypes = map_or_empty("mason-nvim-dap.mappings.filetypes", {}), + } + -- The module may load with the indexed field renamed/removed; normalize + -- so package_of/unknown_of below never index nil. + if type(mason_maps_cache.source.nvim_dap_to_package) ~= "table" then + mason_maps_cache.source.nvim_dap_to_package = {} + end + end + return mason_maps_cache + end + + ---A handler to setup all clients defined under `tool/dap/clients/*.lua`. + ---The factory branch and a user override's opts materialization load + ---mason-nvim-dap; a repo zero-arg client (every adapter in this repo) + ---configures without loading Mason. + ---@param dap_name string + local function mason_dap_handler(dap_name) + local custom_handler, broken_reason, _, user_won = load_client_config(dap_name) + -- No-fall-through contract, enforced by the ONE shared implementation + -- (tools.usable_or_raise): a broken or wrong-shaped config must never + -- read as success — that would suppress both the warning and the + -- install fallback. + custom_handler = tools.usable_or_raise(custom_handler, broken_reason, { + label = "client config", + expected = "a fun(opts)", + shapes = { ["function"] = true }, + }) + if custom_handler == nil then + -- No client config: fall back to Mason's factory config, erroring + -- (level 0) so the resolver reports failures. + local m = mason_dap_mod() + if not m then + error( + string.format( + "no client config for `%s` and mason-nvim-dap is unavailable for a default setup", + dap_name + ), + 0 + ) + end + local 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. + -- No drift CLAIM on the nil lookup itself — upstream legitimately + -- ships source-only names (js/javadbg/elixir…) with no default + -- adapter; the remedy is the same either way. Only a recorded + -- module-level require failure is named as drift. + if config.adapters == nil then + local drift = #mapping_drift > 0 + and (" — mapping modules failed to load: " .. table.concat(mapping_drift, ", ") .. " (mason-nvim-dap API drift?)") + or "" + error( + string.format( + "no client config for `%s`; mason-nvim-dap can install its package but ships no\n" + .. "default adapter setup for it — add a client config (`tool/dap/clients/%s.lua`\n" + .. "or `user.configs.dap-clients.%s`)%s", + dap_name, + dap_name, + dap_name, + drift + ), + 0 + ) + end + -- Partial mappings drift can hand us configurations without + -- filetypes: upstream's default_setup guards configurations + -- (`or {}`) but ipairs()es filetypes whenever configurations are + -- non-empty — that combination raises ipairs(nil). Normalize both + -- halves defensively. + if type(config.configurations) ~= "table" then + config.configurations = {} + end + if type(config.filetypes) ~= "table" then + config.filetypes = {} + end + m.default_setup(config) + else + -- Function form (the only other shape usable_or_raise lets through): + -- the protocol owns its setup. Make sure to set + -- * dap.adapters. = { your config } -- * dap.configurations. = { your config } -- See `codelldb.lua` for a concrete example. - custom_handler(config) - else - vim.notify( - string.format( - "Failed to setup [%s].\n\nClient definition under `tool/dap/clients` must return\na fun(opts) (got '%s' instead)", - config.name, - type(custom_handler) - ), - vim.log.levels.ERROR, - { title = "nvim-dap" } - ) + if user_won then + -- User-authored override: the historical contract is a PLAIN + -- table — pairs()/tbl_deep_extend must see the mapping fields, + -- which a lazy __index proxy cannot provide (LuaJIT has no + -- __pairs). Costs an eager mason_maps() load only for adapters + -- the user explicitly overrode. + local 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 - require("modules.utils").load_plugin("mason-nvim-dap", { - ensure_installed = require("core.settings").dap_deps, - automatic_installation = false, - handlers = { mason_dap_handler }, + local settings = require("core.settings") + + ---Does an explicit client config exist for this adapter (system-resolved)? + ---A config that exists but fails to load still counts — treating it as + ---absent would misread a broken config as an unknown adapter name. + ---@param name string + ---@return boolean + local function has_client_config(name) + local value, _, any_exists = load_client_config(name) + return value ~= nil or any_exists + end + + -- Discovery-first resolution, shared with LSP and formatters/linters. nvim-dap + -- has no command registry like nvim-lspconfig, so $PATH detection leans on the + -- Mason package's declared binaries; configs without a package resolve their own. + tools.resolve({ + title = "DAP", + deps = settings.dap_deps, + -- The shared lazy thunk (tools.default_registry): a fully-provisioned + -- setup never loads mason-registry. + registry = tools.default_registry, + package_of = function(name) + return mason_maps().source.nvim_dap_to_package[name] + end, + binaries_of = function(name, pkg) + if pkg ~= nil then + return tools.package_binaries(pkg, name) + end + -- No Package, no probe: adapter names are not binary names (`delve` + -- ships `dlv`). Client configs self-validate. + return {} + end, + ---Typo/outdated name vs valid-but-unprovisioned. A client config or mapping means + ---the name is real; an empty map (Mason absent) is untrusted to avoid false typos. + unknown_of = function(name) + if has_client_config(name) then + return false + end + local src = mason_maps().source.nvim_dap_to_package + if next(src) == nil then + return false + end + return src[name] == nil + end, + has_local_config = has_client_config, + ---A drifted mappings.source makes package_of return nil, so the name + ---dead-ends in the resolver's reason-less missing branch — name the + ---recorded drift there. Only with Mason evidence: absence is not drift. + missing_reason_of = function(name) + if has_client_config(name) or #mapping_drift == 0 or not mason_evidence() then + return nil + end + return "mason-nvim-dap mapping modules failed to load (" + .. table.concat(mapping_drift, ", ") + .. ") — cannot derive its Mason package (upstream API drift?)" + end, + -- Client configs self-validate, so try an existing config before the Mason + -- install fallback — python resolves debugpy from a venv $PATH can't see. + -- The raise on a missing launch binary is the provisioning signal. + -- + -- CANONICAL availability contract for dap/clients/*.lua (the raise-LAST + -- clients carry pointer notes back here; keep the two patterns in sync). + -- Shape enforcement itself lives in tools.usable_or_raise; a + -- metadata-declared contract ({ attach_capable, binaries }) was + -- considered and rejected — it would churn the fun(opts) user-override + -- interface for four clients. Revisit if the client count grows. + -- * validate FIRST (top of config): launch AND attach both spawn the local + -- binary, so nothing is worth registering without it — codelldb, lldb. + -- * raise LAST (bottom of config): attach needs no local binary, so the + -- attach-capable adapters are registered BEFORE the check and stay + -- registered when it raises; the raise only signals provisioning (the + -- warning reasons say remote attach still works) — delve, python. + local_config_mode = "validates", + configure = mason_dap_handler, }) end diff --git a/lua/modules/plugins/tool.lua b/lua/modules/plugins/tool.lua index a5105bb1..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 -- ---------------------------------------------------------------------- @@ -106,7 +94,6 @@ tool["mfussenegger/nvim-dap"] = { }, config = require("tool.dap"), dependencies = { - { "jay-babu/mason-nvim-dap.nvim" }, { "rcarriga/nvim-dap-ui", dependencies = "nvim-neotest/nvim-nio", @@ -115,6 +102,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, } diff --git a/lua/modules/utils/init.lua b/lua/modules/utils/init.lua index 6a87aa47..9e991259 100644 --- a/lua/modules/utils/init.lua +++ b/lua/modules/utils/init.lua @@ -269,19 +269,6 @@ function M.get_lsp_capabilities() ) end ----Setup and enable a language server in one call. ----@param server string @Name of the language server ----@param config? vim.lsp.Config @Optional config to apply -function M.register_server(server, config) - vim.validate("server", server, "string", false) - vim.validate("config", config, "table", true) - - if config then - vim.lsp.config(server, config) - end - vim.lsp.enable(server) -end - ---Convert number (0/1) to boolean ---@param value number @The value to check ---@return boolean|nil @Returns nil if failed diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua new file mode 100644 index 00000000..77266f8e --- /dev/null +++ b/lua/modules/utils/tools.lua @@ -0,0 +1,1363 @@ +-- Discovery-first tool resolution (RFC: ayamir/nvimdots#1293): $PATH → Mason +-- install → aggregated missing-tool warning, with Mason as an optional backend +-- and `M.resolve` as the shared loop. +-- +-- Only mason.setup() (lazy-loaded) puts Mason's bin dir on $PATH, so resolve() +-- keeps it there first (`M.ensure_mason_on_path`, idempotent membership +-- check) — appended, not prepended, so a system copy still wins. A bare-name +-- spawn that bypasses `M.resolve` must call `M.ensure_mason_on_path()` itself +-- or use `M.find_executable`. +local M = {} + +-- Fallback when `settings.tool_install_timeout` is missing or non-positive; +-- lua/core/settings.lua's doc comment references this constant by name. +local DEFAULT_TOOL_INSTALL_TIMEOUT_MS = 300000 + +---Split a caller-supplied name spec into valid names and the raw entries +---dropped: string → singleton; table → non-string/empty entries collected +---into `invalid` (config mistakes the caller may report), nil holes skipped +---(maxn, so a hole can't truncate the list); anything else → no names. The +---ONE definition of what counts as a valid dep entry. +---@param names any @Executable name(s) as callers pass them. +---@return string[] valid +---@return any[] invalid @Raw non-nil dropped entries, in order. +local function split_dep_names(names) + if type(names) == "string" then + return { names }, {} + end + if type(names) ~= "table" then + return {}, {} + end + local out, invalid = {}, {} + for i = 1, table.maxn(names) do + local name = names[i] + if type(name) == "string" and name ~= "" then + out[#out + 1] = name + elseif name ~= nil then + invalid[#invalid + 1] = name + end + end + return out, invalid +end +M.split_dep_names = split_dep_names + +---Keep a value only when it is a string: probe/config results are untrusted +---external shapes, and every non-string sanitizes to nil. +---@param value any +---@return string|nil +local function str_or_nil(value) + return type(value) == "string" and value or nil +end + +---Locate the first of the given executables: $PATH, then Mason's bin dir +---(reachable before mason.setup() prepends it). First-found by design: +---callers pass alternates for one tool, not a list of required binaries. +---@param names string|string[] @Executable name(s), probed in order. +---@return string|nil @Absolute path of the first name found, or nil. +function M.find_executable(names) + names = (split_dep_names(names)) + for _, name in ipairs(names) do + local path = vim.fn.exepath(name) + if path ~= "" then + return path + end + end + local root = M.mason_root() + if root then + for _, name in ipairs(names) do + -- exepath() also resolves the Windows `.cmd` shim extension. + local path = vim.fn.exepath(root .. "/bin/" .. name) + if path ~= "" then + return path + end + end + end + return nil +end + +-- Hits only: rtp grows as lazy.nvim loads plugins, so a cached miss would go +-- stale mid-session. +local module_path_cache = {} + +---Locate a module file on the paths require() uses, without executing it: +---package.path plus the runtimepath loader (covers `user.*`). +---@param module string +---@return string|nil +function M.module_path(module) + if module_path_cache[module] then + return module_path_cache[module] + end + 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 + +---Enforce the no-fall-through contract for a locally loaded config: a broken +---candidate raises its reason verbatim (it must never read as success to the +---resolver — that would suppress both the warning and the install fallback), +---and a loaded value outside the accepted shapes raises a labeled shape +---error. Returns the value (nil = no config exists) once safe to consume. +---@param value any @The loaded config value (nil when no candidate exists). +---@param broken_reason string|nil @From a candidate-loader (DAP's load_client_config / LSP's server_info). +---@param opts { label: string, expected: string, shapes: table } +---@return any value +function M.usable_or_raise(value, broken_reason, opts) + if broken_reason then + M.raise_verbatim(broken_reason) + end + if value ~= nil and not opts.shapes[type(value)] then + M.raise_verbatim(string.format("%s must return %s (got `%s`)", opts.label, opts.expected, type(value))) + end + return value +end + +---Keep Mason's bin dir on $PATH (see the file header). Idempotent via the +---exact-entry membership check each call (one plain find over $PATH): a +---set-once flag would skip exactly the cases the recheck exists for — a +---$PATH something overwrote mid-session, and a root that switched once +---mason.setup applied a different one (see mason_root). APPEND-ONLY on +---purpose: identical strings in an externally mutable list admit no robust +---ownership proof, so nothing is ever removed — and per Mason PATH mode +---(mason 2.x semantics) nothing needs to be: the default prepend puts the +---new root's bin ahead of any stale appended entry on a switch, and append +---mode's tail ordering is the user's own choice (same-name shadowing until +---restart accepted). KNOWN DEVIATION: mason's `PATH = "skip"` is NOT honored +---here — deliberately. The resolver's availability verdicts (find_executable, +---consulted by every resolve()) and its consumers' bare-name spawns +---(conform/nvim-lint commands, LSP cmds) must AGREE, and this append is what +---keeps them agreeing; honoring skip would classify a Mason-only tool as +---available while its spawn fails, turning the aggregated report into a lie. +---Honoring skip coherently requires absolute-command configuration across +---every bare-spawn consumer — a cross-cutting change tracked as follow-up +---work, out of this module's scope. +function M.ensure_mason_on_path() + local root = M.mason_root() + if not root then + return + end + local bin = root .. "/bin" + local sep = require("core.global").is_windows and ";" or ":" + local path = vim.env.PATH or "" + -- Exact-entry membership check (mason.setup may have prepended it): wrap + -- both sides in the separator; plain find — $PATH may hold magic chars. + if not (sep .. path .. sep):find(sep .. bin .. sep, 1, true) then + vim.env.PATH = path ~= "" and (path .. sep .. bin) or bin + end +end + +---Lazy mason-registry thunk for resolve() specs — the ONE copy (dap and the +---runtime-tools resolver share it, so a change to the load guard cannot +---drift between consumers). Passed as a spec `registry` value: phase 1 never +---calls it — phase 2 and the retry paths resolve it only once leftovers +---exist, so a fully-provisioned setup never loads mason-registry. +---@return table|nil +function M.default_registry() + local ok, resolved = pcall(require, "mason-registry") + return ok and resolved or nil +end + +---Resolve the first of the given executable names, or `error()` with the +---install hint — at level 0 so the message carries no "file:line:" prefix. +---@param names string|string[] @Executable name(s), probed in order. +---@param hint string @Actionable install guidance appended to the error. +---@return string @Absolute path of the first name found. +function M.exepath_or_error(names, hint) + local path = M.find_executable(names) + if path then + return path + end + local shown = (split_dep_names(names)) + local label = #shown > 0 and table.concat(shown, "/") or "" + error(string.format("%s not found on $PATH or in Mason's bin dir; %s", label, hint), 0) +end + +-- Real chunkname prefixes error() prepends, most specific first. A lazy +-- catch-all would also eat the leading prose of a prefix-less level-0 raise +-- whose message contains ":: " (e.g. "parse failed at 12:30: ..."). +local CHUNK_PREFIXES = { + '^%[string "[^\n]*"%]:%d+: ', + "^%[C%]:%d+: ", + "^[^\n]-%.lua:%d+: ", +} + +---Normalize a pcall-captured error: a raise_verbatim sentinel yields its +---reason untouched; a string loses the "chunkname:line: " prefix error() adds +---(only shapes that ARE chunknames — a custom non-.lua chunk keeps its +---prefix, which is more information, never less); anything else is dropped. +---@param err any +---@return string|nil +local function error_reason(err) + if type(err) == "table" and type(err.reason) == "string" then + return err.reason + end + if type(err) ~= "string" then + return nil + end + for _, pattern in ipairs(CHUNK_PREFIXES) do + local stripped, hits = err:gsub(pattern, "") + if hits > 0 then + return stripped + end + end + return err +end + +-- Private brand for raise_verbatim errors: only sentinels carrying this key +-- count as config-layer failures — a config that happens to throw its own +-- `{ reason = ... }` table stays an ordinary error. +local VERBATIM = {} + +---Raise a config failure whose message must reach the warning verbatim: it may +---itself start with a "path:line:" that position stripping would eat. The +---resolver also treats this sentinel as a config-layer error: a "validates" +---local config raising it is reported immediately, never answered with an +---install (an install can't fix a broken config). +---@param reason string +function M.raise_verbatim(reason) + -- selene: allow(incorrect_standard_library_use) -- error() does accept any value + error({ reason = reason, [VERBATIM] = true }) +end + +---Aggregate tools that could not be set up into one deferred warning, in two +---sections: `mark` (install it / config failed) and `mark_unknown` +---(unrecognized name — fix the config, don't install). +---@class ToolCollector +---@field mark fun(name: string, reason?: string, provisional?: boolean) @Record an unresolved tool; +--- a provisional reason is a placeholder a later concrete failure may replace. +---@field mark_unknown fun(name: string) @Record an unrecognized name (typo / outdated / unsupported). +---@field track fun(pkg: table, name: string, recheck: fun(): boolean, on_ready?: fun(), fail_reason?: string) +---@field is_unsettled fun(name: string): boolean @Whether an in-flight tracked install still owns the name. +---@field 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 }); each entry owns a + -- defer_fn timer that settles it if the closed callback never does. + local unsettled = {} + 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) + 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, is_provisional) + name = normalize(name) + 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) + 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) + 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() and per-install timers force: + -- their records are final); `emitted` lets late failures still notify. + function flush(force) + if not force and next(unsettled) ~= nil then + return + end + if flush_scheduled then + return + end + flush_scheduled = true + vim.schedule(function() + flush_scheduled = false + 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 • ") + .. "\n\nMason installs are picked up automatically; after installing a tool\n" + .. "outside Mason, run :ToolsRetry or restart Neovim." + end + if #unknown_new > 0 then + table.sort(unknown_new) + sections[#sections + 1] = "The following names are not recognized (likely a typo, or an outdated\n" + .. "or unsupported name) — correct or remove them from your config:\n • " + .. table.concat(unknown_new, "\n • ") + end + vim.notify(table.concat(sections, "\n\n"), vim.log.levels.WARN, { title = title }) + end) + end + + return { + mark = add, + mark_unknown = add_unknown, + ---An install tracked here whose "closed" callback hasn't run yet (and + ---whose deadline hasn't settled it): such a name is owned by that + ---callback, so hand-off retries leave it alone. + is_unsettled = function(name) + return unsettled[name] ~= nil + end, + 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 creates the entry: a + -- repeat (re-resolve pass / shared-handle piggyback) must not add a + -- second settle against one install. `track` is only ever called + -- with a non-empty string name (start_install), so `unsettled` + -- membership IS the whole accounting — the flush gate reads it. + local first_track = unsettled[name] == nil + if first_track then + unsettled[name] = { reason = fail_reason or false } + -- One timer per tracked install, settling only its own entry + -- (a no-op when the closed callback got there first), so a + -- late-tracked install always keeps its full window. Without + -- a configured timeout the entry settles via "closed" only. + if type(timeout_ms) == "number" and timeout_ms > 0 then + vim.defer_fn(function() + local entry = unsettled[name] + if entry == nil then + return + end + unsettled[name] = nil + -- The generic note is a placeholder a later concrete + -- failure may upgrade (see add()). + add( + name, + type(entry.reason) == "string" and entry.reason + or "Mason install did not finish within the timeout (check :Mason for progress)", + entry.reason == false + ) + -- This entry is final: force past the unsettled gate. + flush(true) + end, timeout_ms) + end + end + -- "closed" fires in a fast event context: hop to the main loop. recheck/ + -- on_ready are pcall'd so a throw can't suppress flush; clearing the + -- entry is a no-op when the deadline already settled this install, + -- but on_ready still runs. + local registered, reg_err = pcall( + handle.once, + handle, + "closed", + vim.schedule_wrap(function() + unsettled[name] = nil + local rc_ok, available = pcall(recheck) + if rc_ok and available then + if type(on_ready) == "function" then + -- A configure throw after the install must still reach the warning. + 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 + flush() + end) + ) + -- once() threw: undo only what THIS track added, or a failed piggyback + -- would clear another caller's in-flight entry. + if not registered then + if first_track then + unsettled[name] = nil + end + add(name, fail_reason or error_reason(reg_err)) + elseif started_here and type(name) == "string" and name ~= "" then + -- Announce only self-started installs, only once registration stuck. + queued[#queued + 1] = name + end + end, + done = function() + -- One coalesced INFO per batch so a first launch shows progress; + -- drained after announcing so a later done() can't re-announce. + if #queued > 0 and not announce_scheduled then + announce_scheduled = true + vim.schedule(function() + announce_scheduled = false + if #queued == 0 then + return + end + table.sort(queued) + local message = string.format( + "Installing %d tool(s) via Mason in the background; each is configured\n" + .. "automatically once its install finishes (relaunch if one isn't picked up):\n • %s", + #queued, + table.concat(queued, "\n • ") + ) + queued = {} + vim.notify(message, vim.log.levels.INFO, { title = title }) + end) + end + -- done()'s records are final: force past the unsettled gate. + flush(true) + end, + } +end + +---Whether the registry's source specs are all on disk. On API drift err toward +---false: resolve() then refreshes redundantly (a no-op), and package_for_binary +---rebuilds instead of freezing — both err toward correctness. +---@param registry table|nil @The mason-registry module (or nil). +---@return boolean +local function registry_bootstrapped(registry) + if not registry or registry.sources == nil then + return false + end + local ok, all_installed = pcall(function() + return registry.sources:is_all_installed() + end) + if not ok then + return false + end + return all_installed == true +end + +-- Frozen bin -> package index (see package_for_binary below). +local bin_to_package = nil +-- Unfrozen scans stay re-buildable by design (self-heal after a late +-- refresh), but within ONE event-loop tick nothing can change: memoize per +-- tick so a batch of lookups decodes registry.json once, not N times. +local unfrozen_index = nil + +---Find the Mason package shipping the given binary, via a lazily-built +---bin -> package index over the registry specs: tool name, binary, and +---package name may all differ (cmake_format -> cmake-format -> cmakelang). +---@param registry table @The mason-registry module. +---@param binary string @Executable name to look up. +---@return string|nil @Mason package name shipping that binary, or nil. +local function package_for_binary(registry, binary) + -- Freeze the index only once the registry is FULLY bootstrapped: + -- get_all_package_specs() silently skips uninstalled sources, and a frozen + -- partial index would outlive resolve()'s re-refresh self-heal. (A source + -- appended at runtime after the freeze is out of scope.) + if bin_to_package == nil then + if unfrozen_index then + return unfrozen_index[binary] + end + local index = {} + local populated = false + local ok, specs = pcall(registry.get_all_package_specs) + if ok and type(specs) == "table" then + for _, spec in ipairs(specs) do + if type(spec) == "table" and type(spec.name) == "string" and type(spec.bin) == "table" then + for bin_name in pairs(spec.bin) do + if index[bin_name] == nil then + index[bin_name] = spec.name + populated = true + end + end + end + end + end + if populated and registry_bootstrapped(registry) then + bin_to_package = index + else + -- Partial/uncertain scan: consult what was found, keep it only for + -- the current tick. + unfrozen_index = index + vim.schedule(function() + unfrozen_index = nil + end) + return index[binary] + end + end + return bin_to_package[binary] +end + +---Executable name(s) a Mason package provides, from its spec. Without a `bin` +---table prefer `pkg.name`: the subsystem name can differ (python -> debugpy). +---@param pkg table @A mason-registry Package object. +---@param fallback string @Last-resort name when even `pkg.name` is absent. +---@return string[] +function M.package_binaries(pkg, fallback) + local bins = (type(pkg.spec) == "table" and type(pkg.spec.bin) == "table") and vim.tbl_keys(pkg.spec.bin) or {} + -- tbl_keys order is unspecified; sort so multi-bin probes stay stable. + table.sort(bins) + if #bins == 0 then + bins = { type(pkg.name) == "string" and pkg.name or fallback } + end + return bins +end + +---Mason's install root WITHOUT loading Mason (this runs from every resolve()): +---a set-up mason.settings always wins — checked LIVE each call, because the +---module may load long after the first resolve cached a fallback and call +---timing must not invert the priority; gated on mason.has_setup because +---mason-registry requires mason.settings at load with the DEFAULTS, and only +---setup() applies a user's custom root — then 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). 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 +-- hit-only cache: it merely gates the default-dir guess below, and a user +-- adding user/configs/mason mid-session needs a restart for a new root +-- anyway — so unlike a general module miss, staleness here is harmless. +local mason_override_absent = nil +function M.mason_root() + local mason = package.loaded["mason"] + local settings = package.loaded["mason.settings"] + if + type(mason) == "table" + and mason.has_setup == true + and type(settings) == "table" + and type(settings.current) == "table" + and type(settings.current.install_root_dir) == "string" + then + -- Presence decides the branch, existence decides the return: a + -- configured-but-not-yet-created root yields nil, it must NOT fall + -- back to a wrong root. + local root = settings.current.install_root_dir + return vim.uv.fs_stat(root) and root or nil + end + 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_override_absent and vim.uv.fs_stat(guess) then + return guess + end + return nil +end + +-- One collector per title so a subsystem that resolves in batches (nvim-lint, +-- per filetype) aggregates one warning instead of one per batch. +local collectors_by_title = {} + +-- Install hand-off: resolve() sessions whose deps are still waiting for a tool +-- to appear. Each entry pairs a spec with its pending set so a later Mason +-- install event or :ToolsRetry can finish the configure without a restart. +local sessions = {} + +---Deregister a session once nothing is pending (leak-free bookkeeping). Sole +---owner of removal — called from every path that empties a pending set, so +---iterations over `sessions` never race a second remover. +---@param session table +local function drop_session_if_done(session) + if next(session.pending) ~= nil then + return + end + for index = #sessions, 1, -1 do + if sessions[index] == session then + table.remove(sessions, index) + return + end + end +end + +---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; each subscription pcall'd SEPARATELY +-- and flagged SEPARATELY (mason-registry API-drift guard): with one shared +-- flag, a throw from the second subscription after the first registered +-- would leave the flag unset, and the next attach would register the install +-- handler TWICE — duplicate retry_pending walks on every later install. A +-- still-false half retries on the next attach; no events at all = status +-- quo: the tool is picked up on the next launch. +local install_events_attached = false +local update_events_attached = false + +---A pending name's declared bare binaries, probed on $PATH / Mason's bin — +---the first (cheapest) availability evidence BOTH install hand-off paths +---share (the Mason install event and :ToolsRetry). The is_unsettled +---ownership guard deliberately stays at each call site: the two paths +---interleave DIFFERENT extra evidence around this probe (the event handler +---checks the installed package's name first; retry layers registry-derived +---binaries and the validates re-run after), so only the probe itself is the +---common core. +---@param session table +---@param name string +---@return boolean +local function pending_bins_available(session, name) + local bins_ok, bins = pcall(session.spec.binaries_of, name, nil) + return bins_ok and M.find_executable(bins) ~= nil +end + +---Subscribe to Mason install successes so a package installed mid-session by +---ANY means (resolver-started, :MasonInstall, the :Mason UI) finishes the +---pending configure of every subsystem that waited for it. Never require()s +---mason-registry itself — callers pass a registry they already hold, keeping +---"a fully-provisioned setup never loads Mason" intact. +---@param registry table|nil @The mason-registry module (or nil). +function M.attach_registry_events(registry) + if install_events_attached and update_events_attached then + return + end + if type(registry) ~= "table" or type(registry.on) ~= "function" then + return + end + if not install_events_attached then + install_events_attached = pcall(function() + registry:on( + "package:install:success", + -- The emitter fires from the install handle's lifecycle (fast event + -- context): hop to the main loop before touching consumer configs. + vim.schedule_wrap(function(pkg) + local pkg_name = type(pkg) == "table" and type(pkg.name) == "string" and pkg.name or nil + if not pkg_name then + return + end + retry_pending(function(session, name) + -- An install still in flight is owned by its closed callback. + if session.is_unsettled(name) then + return false + end + local ok, mapped = pcall(session.spec.package_of, name, registry) + if ok and mapped == pkg_name then + return true + end + return pending_bins_available(session, name) + end) + end) + ) + end) + end + if not update_events_attached then + update_events_attached = pcall(function() + registry:on("update:success", function() + -- SYNCHRONOUS on purpose: mason emits this inside the update + -- success path, before callers' callbacks run — a scheduled clear + -- would leave a same-tick window still reading the stale frozen + -- index. Clearing two locals is pure Lua, fast-event-safe; the + -- next lookup rebuilds (and re-freezes on a bootstrapped registry). + -- Were upstream ever to emit this asynchronously instead, the + -- sync clear degrades to one harmless extra rebuild. + bin_to_package = nil + unfrozen_index = nil + end) + end) + end +end + +---Re-attempt every pending dep whose tool has since appeared — the manual +---hand-off entry (:ToolsRetry) for installs done outside Mason (mise/nix/npm). +function M.retry_missing() + retry_pending(function(session, name) + -- An install still in flight is owned by its closed callback. + if session.is_unsettled(name) then + return false + end + local spec = session.spec + if pending_bins_available(session, name) then + return true + end + -- Function-cmd LSP servers (jsonls/yamlls) declare no bare binary: + -- derive it from the Mason package spec even when the binary itself was + -- installed outside Mason. + local registry = session.resolve_registry() + if registry then + local ok, pkg_name = pcall(spec.package_of, name, registry) + if ok and type(pkg_name) == "string" then + local pkg_ok, pkg = pcall(registry.get_package, pkg_name) + if pkg_ok and type(pkg) == "table" then + local pkg_bins_ok, pkg_bins = pcall(spec.binaries_of, name, pkg) + if pkg_bins_ok and M.find_executable(pkg_bins) ~= nil then + return true + end + end + end + end + -- Only self-VALIDATING configs may retry on config evidence alone: their + -- configure re-checks the tool and a still-missing one fails the attempt, + -- keeping the name pending. A generic local config (LSP) would instead + -- enable a server whose binary is still absent. + if spec.local_config_mode ~= "validates" or type(spec.has_local_config) ~= "function" then + return false + end + local has_ok, has = pcall(spec.has_local_config, name) + return has_ok and has == true + end) +end + +---Shared discovery-first resolution loop. For each entry in `spec.deps`: +--- 0. Unrecognized name (`unknown_of`) -> fix the config; never install. +--- 1. Available (Mason-installed / on $PATH) -> configure now. +--- 2. Mason package exists but not available yet -> a "validates" local +--- config tries first; else configure if its declared bins are on $PATH; +--- else install, then configure on completion. +--- 3. No Mason package but a local config exists -> configure now. +--- 4. Otherwise -> aggregated warning. +--- +---Everything resolvable without an install configures on the load tick (before +---lazy.nvim replays the trigger); each dep is pcall-isolated. `configure` gets +---`late = true` after resolve() returned (deferred phase 2, install completion, +---async refresh) — no replayed trigger backs such a call, so the consumer must +---re-drive its own event if one is needed. +---`local_config_mode` (for a binary-less local config): nil defers to phase 2, +---"resolves" trusts it outright, "validates" lets it try and installs on +---failure — keep "validates" checks cheap (phase 1 runs on the load tick); a +---bounded probe spawn on the miss path is acceptable when it keeps config-time +---and launch-time resolution in agreement. +---@param spec { +--- title: string, +--- deps: string[], +--- registry: table|nil, +--- package_of: fun(name: string, registry: table|nil): string|nil, +--- binaries_of: fun(name: string, pkg: table|nil): string[], +--- unknown_of?: fun(name: string): boolean, +--- has_local_config?: fun(name: string): boolean, +--- local_config_mode?: "resolves"|"validates", +--- unresolvable_of?: fun(name: string): string|nil, +--- missing_reason_of?: fun(name: string): string|nil, +--- configure?: fun(name: string, late: boolean), +--- defer_phase2?: boolean, +--- defer?: boolean, +---} +---`unresolvable_of` (optional): a non-nil reason short-circuits the name in +---phase 1 — marked missing with that reason and flushed immediately, never +---classified against the registry (the one exception to "phase 1 never marks +---missing"). +---`missing_reason_of` (optional): consulted only for the final reason-less +---missing mark in phase 2 (no package, no local config) — a string return +---annotates the aggregated warning; errors and non-strings are ignored. +---`defer` (optional): move the WHOLE resolve off the caller's tick. The +---resolver still pays ensure_mason_on_path synchronously — deferring must not +---lose the same-tick guarantee that a replayed trigger's bare Mason spawns +---can resolve — and phase-1 configures inside the scheduled run still count +---as trigger-backed (`late = false`). +function M.resolve(spec) + -- Every call site owns a configure (the resolver's whole job funnels into + -- it): a missing one is a programmer error — fail at the call site instead + -- of silently "succeeding" every configure. + assert(type(spec.configure) == "function", "tools.resolve: spec.configure must be a function") + local ok_settings, settings = pcall(require, "core.settings") + local timeout_ms = ( + ok_settings + and type(settings.tool_install_timeout) == "number" + and settings.tool_install_timeout > 0 + ) + and settings.tool_install_timeout + or DEFAULT_TOOL_INSTALL_TIMEOUT_MS + local collector = collectors_by_title[spec.title] + if not collector then + collector = missing_collector(spec.title, timeout_ms) + collectors_by_title[spec.title] = collector + end + + -- False once run() returns: a configure running later (deferred phase 2, + -- install completion, async refresh) has no replayed trigger behind it and + -- gets `late = true`. + local synchronous = true + + -- This call's hand-off record: phase-2 leftovers park in `pending` until a + -- configure SUCCEEDS, so the Mason install event or :ToolsRetry can finish + -- them later; registered in `sessions` only when leftovers exist. + local session = { + title = spec.title, + spec = spec, + pending = {}, + is_unsettled = collector.is_unsettled, + ---The spec's registry (value or lazy thunk), resolved on demand; a + ---drifted non-table result normalizes to nil (degrade, never crash). + resolve_registry = function() + local registry = spec.registry + if type(registry) == "function" then + local ok, resolved = pcall(registry) + registry = ok and resolved or nil + end + return type(registry) == "table" and registry or nil + end, + } + + ---Configure one tool; false plus the cleaned error when the config threw. + ---The third result flags a BRANDED raise_verbatim sentinel — a config-layer + ---error an install can't fix. Any other thrown value (including a config's + ---own `{ reason = ... }` table) stays an ordinary failure. + local function try_configure(name) + local ok, err = pcall(spec.configure, name, not synchronous) + if ok then + return true + end + return false, error_reason(err), type(err) == "table" and err[VERBATIM] == true + end + + -- Configure one tool, surfacing a config-time error in the aggregated + -- warning. Late calls race (install completion, the registry install event, + -- :ToolsRetry, a late refresh finish): `pending` is the gate — the first + -- SUCCESS clears it and later arrivals skip; a failure keeps the name + -- recoverable. Returns true only when the configure ran and succeeded. + local function do_configure(name) + if not synchronous and session.pending[name] == nil then + return false + end + local ok, reason = try_configure(name) + 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. One record per failed name: `reason` (string or nil for a + -- message-less failure) and `config_error` (a raise_verbatim config-layer + -- error an install can't fix). Record EXISTENCE is the failure mark. + local validates = {} + ---@param name string + ---@return string|nil + local function validate_reason(name) + local record = validates[name] + return record and record.reason or nil + end + + -- Install `pkg`, configure on completion; failures keep the phase-1 reason. + local function start_install(pkg, name) + local reason = validate_reason(name) + collector.track(pkg, name, function() + return pkg:is_installed() or M.find_executable(spec.binaries_of(name, pkg)) ~= nil + end, function() + do_configure(name) + end, reason) + end + + ---Phase 1 — configure a dep resolvable without the Mason registry ($PATH, + ---self-resolving local config, or a succeeding "validates" resolver). + ---False = needs the registry (phase 2). Never marks missing, stays same-tick. + ---@param name string + ---@return boolean handled + local function configure_available(name) + if M.find_executable(spec.binaries_of(name, nil)) ~= nil then + 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, config_error = try_configure(name) + if ok then + return true + end + validates[name] = { + reason = str_or_nil(reason), + config_error = config_error == true, + } + end + return false + end + + ---Phase 2 — resolve a dep against the now-ready registry: configure an + ---installed package, install a missing one, or mark it missing/unknown. + ---@param name string + ---@param registry table|nil + local function resolve_missing(name, registry) + -- Judged after the refresh: unknown_of may consult the Mason mapping. + if spec.unknown_of and spec.unknown_of(name) then + -- A typo can't be fixed by an install: drop it from the hand-off set. + session.pending[name] = nil + collector.mark_unknown(name) + return + end + + local pkg, pkg_unknown = nil, false + local pkg_name = spec.package_of(name, registry) + if pkg_name and registry then + local ok, resolved = pcall(registry.get_package, pkg_name) + if ok then + pkg = resolved + else + -- Stale mapping; reported only if nothing below resolves the tool. + pkg_unknown = true + end + end + + -- The config already failed this pass. A raise_verbatim failure is a + -- config-layer error an install can't fix: report it immediately. Only + -- a provisioning failure (missing binary) is answered with an install. + if validates[name] ~= nil then + if not validates[name].config_error and pkg ~= nil and not pkg:is_installed() then + start_install(pkg, name) -- annotates the failure reason itself + else + 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 + -- A stale mapping can't be fixed by an install either: drop it from + -- the hand-off set like the unknown_of branch above. + session.pending[name] = nil + collector.mark_unknown(pkg_name == name and name or (pkg_name .. " (for " .. name .. ")")) + else + -- An optional spec hook may supply a concrete reason for an + -- otherwise reason-less miss (e.g. DAP naming recorded mapping + -- drift that made package_of return nil); a hook throw must not + -- break the mark itself. + local reason + if type(spec.missing_reason_of) == "function" then + local hook_ok, hook_reason = pcall(spec.missing_reason_of, name) + reason = (hook_ok and type(hook_reason) == "string") and hook_reason or nil + end + collector.mark(name, reason) + end + end + + local function run() + -- Make Mason's bin dir resolvable before any probe or spawn (see file header). + M.ensure_mason_on_path() + -- ONE normalization policy for every consumer (see split_dep_names): + -- non-table deps degrade to nothing to resolve, and dropped entries + -- (non-string / empty — config mistakes) surface in the unknown bucket + -- instead of vanishing or flowing into module-name concatenation. + local deps, invalid_entries = split_dep_names(spec.deps) + for _, entry in ipairs(invalid_entries) do + collector.mark_unknown(entry == "" and '""' or entry) + end + -- Phase 1: configure everything resolvable without the registry. + local visited = {} + local unresolved = {} + local finalized = false + for _, name in ipairs(deps) do + -- Dedup (a duplicate would double-install); pcall isolates each dep. + if not visited[name] then + visited[name] = true + -- A name the spec knows it can never resolve (e.g. a conform + -- function-form override that yields nothing at probe time) is + -- final here: tailored reason, no phase 2, no registry. + local unresolvable = nil + if spec.unresolvable_of then + local ok, reason = pcall(spec.unresolvable_of, name) + unresolvable = (ok and 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 + 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 — via session.resolve_registry; phase 1 never touches it, so a + -- fully-provisioned subsystem with no leftovers never loads Mason), + -- refreshing a never-bootstrapped one first. + local registry = session.resolve_registry() + -- The registry is loaded anyway: make sure mid-session installs hand off. + if registry then + M.attach_registry_events(registry) + end + local function finish() + for _, name in ipairs(unresolved) do + local ok, err = pcall(resolve_missing, name, registry) + if not ok then + collector.mark(name, error_reason(err)) + end + end + collector.done() + -- Unknown-name removals don't pass through do_configure: sweep here + -- so a fully-classified session doesn't linger in `sessions`. + drop_session_if_done(session) + end + if registry and type(registry.refresh) == "function" and not registry_bootstrapped(registry) then + -- A stalled refresh() never calls back: arm a REPORTING deadline. It + -- does not cancel a late refresh — finish() still runs on completion, + -- its re-marks absorbed by 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) + -- The generic refresh note is a placeholder: a later concrete + -- failure (or a late unknown classification) may replace it. + collector.mark( + name, + reason or "Mason registry refresh did not complete (cannot classify or auto-install)", + reason == nil + ) + end + collector.done() + end, timeout_ms) + -- pcall guards a synchronous throw; the callback arrives in a fast event context. + local ok = pcall(registry.refresh, function() + if vim.in_fast_event() then + vim.schedule(on_refreshed) + else + on_refreshed() + end + end) + if not ok then + on_refreshed() + end + elseif spec.defer_phase2 then + -- Opt-in consumers (runtime tools, LSP) move the full registry spec + -- decode off the lazy-load trigger's tick; their late configures + -- have their own catch-up paths (enable() attaches open buffers, + -- lint re-runs itself). + vim.schedule(finish) + else + -- DAP configures IN phase 2, and a cmd-triggered lazy-load replays + -- synchronously right after config: finish on this tick. + finish() + end + end + + if spec.defer then + M.ensure_mason_on_path() + vim.schedule(function() + run() + synchronous = false + end) + else + run() + synchronous = false + end +end + +---Discovery-first resolution for a subsystem whose own runtime registrations +---are the ground truth (conform, nvim-lint). `probe(name)` returns: +--- * nil -> unknown name (typo) -> fix config. +--- * { binary = "x" } -> the tool invokes executable "x". +--- * { binary = nil } -> the tool resolves its own command at runtime. +--- * { unresolved = true } -> the name is real but its command can't be +--- verified at probe time (e.g. a per-buffer +--- function override): reported missing with a +--- tailored reason — never a typo, never an install. +--- * { broken = "reason" } -> config exists but errors: surfaced with the +--- reason — never typo guidance, never an install. +---Mason is only the lazy install fallback, reverse-looked-up from the binary. +---@param title string @Notification title identifying the subsystem. +---@param deps string[] @Tool names as the subsystem knows them. +---@param probe fun(name: string): { binary: string|nil, broken: string|nil, unresolved: boolean|nil }|nil +---@param configure? fun(name: string, late: boolean) @Optional: run for each available/local tool +--- (e.g. rewrite its command to an absolute path while Mason's bin dir is +--- still off $PATH). +---@param opts? { defer?: boolean } @`defer` moves the whole resolve off the +--- caller's tick (see M.resolve); the $PATH guarantee stays synchronous. +function M.resolve_runtime_tools(title, deps, probe, configure, opts) + local cache = {} + local function info(name) + if cache[name] == nil then + local ok, result = pcall(probe, name) + if ok and type(result) == "table" then + cache[name] = { + known = true, + binary = str_or_nil(result.binary), + broken = str_or_nil(result.broken), + unresolved = result.unresolved == true, + -- Consumer-specific phrasing for the unresolved warning + -- stays in the consumer's probe, not in this shared helper. + reason = str_or_nil(result.reason), + } + else + -- A probe error is treated as unknown: either way, fix the config entry. + cache[name] = { known = false } + end + end + return cache[name] + end + + M.resolve({ + title = title, + deps = deps, + registry = M.default_registry, + package_of = function(name, registry) + local binary = info(name).binary + if not registry or not binary then + return nil + end + return package_for_binary(registry, binary) + end, + binaries_of = function(name) + local binary = info(name).binary + return binary and { binary } or {} + end, + unknown_of = function(name) + return not info(name).known + end, + has_local_config = function(name) + -- A broken config counts as local so configure below surfaces its + -- reason; an unresolved one does NOT (nothing verifiable to trust). + local i = info(name) + return i.known and i.binary == nil and not i.unresolved + end, + unresolvable_of = function(name) + local i = info(name) + if i.unresolved then + return i.reason or "config could not be verified at startup" + end + end, + -- A binary-less runtime tool can't map to a package, so it self-resolves. + local_config_mode = "resolves", + defer_phase2 = true, + defer = type(opts) == "table" and opts.defer == true or nil, + configure = function(name, late) + -- A broken config must never configure: raise its reason verbatim (it + -- may carry the broken file's own "path:line:"). + local reason = info(name).broken + if reason then + M.raise_verbatim(reason) + end + if type(configure) == "function" then + return configure(name, late) + end + end, + }) +end + +-- Manual hand-off entry for installs Mason can't announce (mise/nix/npm); +-- pcall so a re-source of this module can't fail on the existing command. +pcall(vim.api.nvim_create_user_command, "ToolsRetry", function() + M.retry_missing() +end, { desc = "Retry configuring missing tools (pick up installs done outside Mason)" }) + +return M 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