diff --git a/lua/core/settings.lua b/lua/core/settings.lua index b4a8c812..7ac826d0 100644 --- a/lua/core/settings.lua +++ b/lua/core/settings.lua @@ -84,23 +84,15 @@ settings["external_browser"] = "chrome-cli open" ---@type boolean settings["lsp_inlayhints"] = false --- LSPs installed outside Mason (e.g. via system package manager). --- These will be configured but not installed by Mason. --- Key: lspconfig server name, Value: executable name to check availability. ----@type table -settings["external_lsp_deps"] = { - nixd = "nixd", - nil_ls = "nil", - shuck = "shuck", -- shell linter/formatter/LSP (Rust); installed via mise, not Mason - -- dartls = "dart", -} - --- LSPs to install during bootstrap. +-- Language servers to enable, resolved discovery-first at runtime: binary on $PATH is +-- used as-is; else Mason installs it when it ships a package; else an aggregated warning +-- asks you to provision it. See `modules.utils.tools` and `completion/mason-lspconfig.lua`. -- Full list: https://github.com/neovim/nvim-lspconfig/tree/master/lsp ---@type string[] settings["lsp_deps"] = { "bashls", "clangd", + -- "dartls", -- Dart LSP (ships with the Dart SDK) "dockerls", "gh_actions_ls", -- "gitlab_ci_ls", @@ -111,7 +103,10 @@ settings["lsp_deps"] = { "lua_ls", "marksman", "neocmake", + "nil_ls", -- Nix LSP; prefer the $PATH binary (Nix), else Mason installs it (package `nil`) + "nixd", -- Nix LSP (Rust); no Mason package, comes from Nix ($PATH) "ruff", + "shuck", -- shell linter/formatter/LSP (Rust); no Mason package, installed via mise "systemd_lsp", "terraformls", "tflint", @@ -120,13 +115,13 @@ settings["lsp_deps"] = { "zuban", } --- Formatters to install during bootstrap (Mason package names). --- These are managed by Mason and used by conform.nvim. +-- Formatters to resolve when conform.nvim lazy-loads (first BufWritePre / +-- :Format). conform formatter names, resolved discovery-first like lsp_deps. ---@type string[] settings["formatter_deps"] = { "beautysh", "clang-format", - "cmakelang", + "cmake_format", "fixjson", "gofumpt", "goimports", @@ -137,8 +132,8 @@ settings["formatter_deps"] = { "stylua", } --- Linters to install during bootstrap (Mason package names). --- These are managed by Mason and used by nvim-lint. +-- Linters to resolve when nvim-lint lazy-loads (first BufReadPost). nvim-lint +-- linter names, resolved discovery-first like formatter_deps. ---@type string[] settings["linter_deps"] = { "actionlint", @@ -146,13 +141,20 @@ settings["linter_deps"] = { "markdownlint-cli2", "oxlint", -- "rumdl", -- markdownlint Rust rewrite; waiting for rule coverage to mature - "golangci-lint", + "golangcilint", "selene", "shellcheck", "systemdlint", } --- Debug Adapter Protocol (DAP) clients to install and configure during bootstrap. +-- Deadline (ms) for background Mason work before the aggregated missing-tool warning is +-- flushed anyway, so one stalled download can't suppress it all session. A finished +-- install past the deadline is still configured; non-positive values use the default. +---@type number +settings["tool_install_timeout"] = 300000 + +-- DAP adapters to enable (mason-nvim-dap adapter names), resolved +-- discovery-first when nvim-dap lazy-loads (first :Dap* command). -- Supported DAPs: https://github.com/jay-babu/mason-nvim-dap.nvim/blob/main/lua/mason-nvim-dap/mappings/source.lua ---@type string[] settings["dap_deps"] = { diff --git a/lua/modules/configs/completion/conform.lua b/lua/modules/configs/completion/conform.lua index 2a52dfa2..4150d863 100644 --- a/lua/modules/configs/completion/conform.lua +++ b/lua/modules/configs/completion/conform.lua @@ -168,6 +168,96 @@ return function() end or false, }) + -- Resolve `formatter_deps` (conform formatter names) discovery-first against conform's + -- own registry. Runs synchronously before lazy.nvim replays BufWritePre so the + -- off-$PATH rewrite is in place for the first format-on-save under Mason PATH = "skip". + local tools = require("modules.utils.tools") + -- Probe-derived formatter configs, reused by the configure callback below so + -- it doesn't re-fetch and re-branch on work the probe just did. Safe across a + -- late configure: conform configs hold no load-time binary state — command is + -- a static string or a format-time function, unchanged by an install. + local probed_config = {} + ---Ctx for evaluating a function-form command: conform's own build_context + ---when available (it is @private), else a hand-built mirror of its fields. + ---@param config table + ---@return table + local function probe_ctx(config) + local buf = vim.api.nvim_get_current_buf() + local ok, runner = pcall(require, "conform.runner") + if ok and type(runner.build_context) == "function" then + local ok_ctx, ctx = pcall(runner.build_context, buf, config) + if ok_ctx and type(ctx) == "table" then + return ctx + end + end + local filename = vim.api.nvim_buf_get_name(buf) + local shiftwidth = vim.bo[buf].shiftwidth + if shiftwidth == 0 then + shiftwidth = vim.bo[buf].tabstop + end + return { + buf = buf, + filename = filename, + dirname = vim.fs.dirname(filename), + shiftwidth = shiftwidth, + } + end + tools.resolve_runtime_tools("conform.nvim", settings.formatter_deps, function(name) + -- get_formatter_config is conform's @private API; if dropped, degrade to + -- "resolves itself" rather than misreporting every formatter as unknown. + local conform = require("conform") + if type(conform.get_formatter_config) ~= "function" then + return { binary = nil } + end + -- get_formatter_config runs a function-form override directly, so pcall keeps a + -- throwing override (a broken config) from being misread as an unknown name. + local ok, config, err = pcall(conform.get_formatter_config, name) + if not ok then + return { broken = tostring(config) } + end + if config then + probed_config[name] = config + if type(config.command) == "function" then + -- Function-form command (e.g. builtin `from_node_modules`): evaluate it + -- for a representative answer; per-buffer differences are covered by + -- configure's wrapper below. + local ok_cmd, cmd = pcall(config.command, config, probe_ctx(config)) + if not ok_cmd then + return { broken = tostring(cmd) } + end + return { binary = type(cmd) == "string" and cmd or nil } + end + return { binary = config.command } + end + -- (nil, err) is a real formatter with a broken config; bare nil is an unknown name. + if type(err) == "string" then + return { broken = err } + end + return nil + end, function(name) + -- Under Mason PATH = "skip" the binary is off $PATH; rewrite it to an absolute + -- path so conform doesn't spawn a bare name that fails. No-op on $PATH. + local conform = require("conform") + local prev = conform.formatters[name] + if type(prev) == "function" then + -- Function-form override resolves per buffer: wrap it to rewrite an off-$PATH + -- result, preserving the override's args/cwd. + conform.formatters[name] = tools.wrap_off_path(prev, "command") + return + end + local config = probed_config[name] + if not config then + return + end + -- An unchanged command means the bare name is on $PATH: skip the no-op + -- override. A function-form command always comes back wrapped. + local command = tools.off_path_value(config.command) + if command ~= config.command then + conform.formatters[name] = + vim.tbl_deep_extend("force", type(prev) == "table" and prev or {}, { command = command }) + end + end) + -- User commands vim.api.nvim_create_user_command("Format", function(args) local range = nil diff --git a/lua/modules/configs/completion/lsp.lua b/lua/modules/configs/completion/lsp.lua index 866260b3..5be17e71 100644 --- a/lua/modules/configs/completion/lsp.lua +++ b/lua/modules/configs/completion/lsp.lua @@ -1,29 +1,11 @@ return function() + -- Server resolution (Mason-installed / on $PATH / installable / missing) is handled + -- discovery-first in `mason-lspconfig.setup`, driven by `settings.lsp_deps`. require("completion.mason-lspconfig").setup() - local opts = { - capabilities = require("modules.utils").get_lsp_capabilities(), - } - -- Configure LSPs that are not managed by Mason but are available in `nvim-lspconfig`. - -- Servers are defined in `settings.external_lsp_deps` as { server_name = "executable" }. - for lsp_name, exe in pairs(require("core.settings").external_lsp_deps) do - if vim.fn.executable(exe) == 1 then - local ok, _opts = pcall(require, "user.configs.lsp-servers." .. lsp_name) - if not ok then - local default_ok, default_opts = pcall(require, "completion.servers." .. lsp_name) - if default_ok then - _opts = default_opts - end - end - if type(_opts) == "table" then - local final_opts = vim.tbl_deep_extend("keep", _opts, opts) - require("modules.utils").register_server(lsp_name, final_opts) - else - require("modules.utils").register_server(lsp_name, opts) - end - end - end - + -- A server Mason installs mid-session registers only when its install finishes (after + -- this point), so a `vim.lsp.config` override here gets overwritten by the repo spec. + -- Overrides that must always win belong in `user.configs.lsp-servers.`. pcall(require, "user.configs.lsp") -- Start LSPs diff --git a/lua/modules/configs/completion/mason-lspconfig.lua b/lua/modules/configs/completion/mason-lspconfig.lua index fa3d2a4e..160e04e0 100644 --- a/lua/modules/configs/completion/mason-lspconfig.lua +++ b/lua/modules/configs/completion/mason-lspconfig.lua @@ -1,15 +1,20 @@ 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") + 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") - require("modules.utils").load_plugin("mason-lspconfig", { - ensure_installed = lsp_deps, - -- Skip auto enable because we are loading language servers lazily - automatic_enable = false, - }) + ---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 vim.diagnostic.config({ signs = true, @@ -21,45 +26,95 @@ M.setup = function() local opts = { capabilities = require("modules.utils").get_lsp_capabilities(), } - ---A handler to setup all servers defined under `completion/servers/*.lua` - ---@param lsp_name string - local function mason_lsp_handler(lsp_name) - -- rust_analyzer is configured using mrcjkb/rustaceanvim - -- warn users if they have set it up manually - if lsp_name == "rust_analyzer" then - local config_exist = pcall(require, "completion.servers." .. lsp_name) - if config_exist then - vim.notify( - [[ -`rust_analyzer` is configured independently via `mrcjkb/rustaceanvim`. To get rid of this warning, -please REMOVE your LSP configuration (rust_analyzer.lua) from the `servers` directory and configure -`rust_analyzer` using the appropriate init options provided by `rustaceanvim` instead.]], - vim.log.levels.WARN, - { title = "nvim-lspconfig" } - ) + + ---Probe and cache a server's spec once (resolver predicates and mason_lsp_handler + ---both read it). `binary` prefers a table `cmd` (user override, repo default, then + ---nvim-lspconfig's); nil for a function/absent cmd that resolves its command at launch. + local server_info_cache = {} + ---@param name string + ---@return { has_module: boolean, binary: string|nil, known_lspconfig: boolean, user_loaded: boolean, user_spec: any, default_loaded: boolean, default_spec: any } + local function server_info(name) + local cached = server_info_cache[name] + if cached then + return cached + end + local info = { + has_module = false, + binary = nil, + known_lspconfig = false, + user_loaded = false, + user_spec = nil, + default_loaded = false, + default_spec = nil, + } + local modules = server_modules(name) + -- A spec that exists but throws at load is a broken config, not a typo: `exists` + -- keeps the name counted as a module (out of the unknown-name bucket). + local user_ok, user_spec, user_exists = tools.load_module_or_report(modules[1], "nvim-lspconfig") + if user_ok then + info.has_module = true + info.user_loaded = true + info.user_spec = user_spec + if type(user_spec) == "table" and type(user_spec.cmd) == "table" then + info.binary = user_spec.cmd[1] end - return + elseif user_exists then + info.has_module = true end + -- Load the repo preset only when usable (no override, or as merge base under a + -- table override): a function-form override replaces it wholesale. + if not user_ok or type(user_spec) == "table" then + local ok, spec, exists = tools.load_module_or_report(modules[2], "nvim-lspconfig") + if ok then + info.has_module = true + info.default_loaded = true + info.default_spec = spec + if info.binary == nil and type(spec) == "table" and type(spec.cmd) == "table" then + info.binary = spec.cmd[1] + end + elseif exists then + info.has_module = true + end + end + -- nvim-lspconfig's built-in config: a table cmd yields the launch binary; any cmd + -- (even a function) proves a real server, keeping e.g. jsonls out of unknown names. + local ok, config = pcall(function() + return vim.lsp.config[name] + end) + if ok and type(config) == "table" and config.cmd ~= nil then + info.known_lspconfig = true + if info.binary == nil and type(config.cmd) == "table" then + info.binary = config.cmd[1] + end + end + server_info_cache[name] = info + return info + end - local ok, custom_handler = pcall(require, "user.configs.lsp-servers." .. lsp_name) - local default_ok, default_handler = pcall(require, "completion.servers." .. lsp_name) + ---Register (not enable) a server's config from `completion/servers/*.lua`, reusing + ---the spec server_info() loaded. vim.lsp.enable() runs later in configure(). + ---@param lsp_name string + ---@return boolean registered @Whether a config was registered (and can be enabled). + local function mason_lsp_handler(lsp_name) + local info = server_info(lsp_name) + local ok, custom_handler = info.user_loaded, info.user_spec + local default_handler = info.default_spec -- Use preset if there is no user definition if not ok then - ok, custom_handler = default_ok, default_handler + ok, custom_handler = info.default_loaded, info.default_spec end if not ok then -- Default to use factory config for server(s) that doesn't include a spec - require("modules.utils").register_server(lsp_name, opts) + vim.lsp.config(lsp_name, opts) elseif type(custom_handler) == "function" then -- Case where language server requires its own setup -- Be sure to call `vim.lsp.config()` within the setup function. -- Refer to |vim.lsp.config()| for documentation. -- For an example, see `clangd.lua`. custom_handler(opts) - vim.lsp.enable(lsp_name) elseif type(custom_handler) == "table" then - require("modules.utils").register_server( + vim.lsp.config( lsp_name, vim.tbl_deep_extend( "force", @@ -78,40 +133,106 @@ please REMOVE your LSP configuration (rust_analyzer.lua) from the `servers` dire vim.log.levels.ERROR, { title = "nvim-lspconfig" } ) + return false end + return true end - ---A simplified mimic of 's `setup_handlers` callback. - ---Invoked for each Mason package (name or `Package` object) to configure its language server. - ---@param pkg string|{name: string} Either the package name (string) or a Package object - local function setup_lsp_for_package(pkg) - -- First try to grab the builtin mappings - local mappings = mason_lspconfig.get_mappings().package_to_lspconfig - -- If empty or nil, build it by hand - if not mappings or vim.tbl_isempty(mappings) then - mappings = {} - for _, spec in ipairs(mason_registry.get_all_package_specs()) do - local lspconfig = vim.tbl_get(spec, "neovim", "lspconfig") - if lspconfig then - mappings[spec.name] = lspconfig - end - end - end + if mason_ok then + -- lspconfig integration only; installs are driven by the shared resolver, + -- not gated on Mason's installed set. + require("modules.utils").load_plugin("mason-lspconfig", { + ensure_installed = {}, + -- Skip auto enable because we are loading language servers lazily + automatic_enable = false, + }) + end - -- Figure out the package name and lookup - local name = type(pkg) == "string" and pkg or pkg.name - local srv = mappings[name] - if not srv then - return + -- lspconfig server name -> Mason package name. Re-fetched while empty: get_mappings() + -- returns {} on a never-bootstrapped registry, and caching that would freeze it. + local lspconfig_to_package = nil + local function package_of(name) + if not mason_ok then + return nil + end + if lspconfig_to_package == nil or next(lspconfig_to_package) == nil then + local mappings = mason_lspconfig.get_mappings() + lspconfig_to_package = (mappings and mappings.lspconfig_to_package) or {} end + return lspconfig_to_package[name] + end - -- Invoke the handler - mason_lsp_handler(srv) + ---Use a manual/built-in spec as fallback only when its binary can't be probed + ---statically (function/absent `cmd`); with a known binary the $PATH check decides. + ---@param name string + ---@return boolean + local function has_local_config(name) + local info = server_info(name) + return info.binary == nil and (info.has_module or info.known_lspconfig) end - for _, pkg in ipairs(mason_registry.get_installed_package_names()) do - setup_lsp_for_package(pkg) + ---Typo/outdated name vs valid-but-uninstalled: a Mason mapping, a repo/user server + ---module, or a built-in lspconfig config all mean the name is real. + ---@param name string + ---@return boolean + local function unknown_of(name) + if package_of(name) then + return false + end + local info = server_info(name) + return not info.has_module and not info.known_lspconfig end + + ---Register a server, rewrite its resolved `cmd[1]` to an absolute path when off $PATH + ---(Mason `PATH = "skip"`), and only THEN enable it: vim.lsp.enable() synchronously + ---fires FileType autocmds for open buffers, which would spawn the still-bare name. + local function configure(name) + if not mason_lsp_handler(name) then + return + end + local ok, config = pcall(function() + return vim.lsp.config[name] + end) + if ok and type(config) == "table" and type(config.cmd) == "table" then + local abs = tools.off_path_command(config.cmd[1]) + if abs then + -- vim.lsp.config replaces list values wholesale; pass a full copied cmd. + local cmd = vim.deepcopy(config.cmd) + cmd[1] = abs + vim.lsp.config(name, { cmd = cmd }) + end + elseif ok and type(config) == "table" and type(config.cmd) == "function" then + -- A function cmd spawns internally and can't be rewritten; under Mason + -- PATH = "skip" its bare name would ENOENT. Run it with Mason's bin on $PATH. + vim.lsp.config(name, { cmd = tools.wrap_with_mason_path(config.cmd) }) + end + vim.lsp.enable(name) + end + + tools.resolve({ + title = "LSP", + deps = settings.lsp_deps, + -- Lazy thunk: only consulted when a dep actually needs the registry in phase 2. + registry = function() + return mason_ok and mason_registry or nil + end, + package_of = package_of, + binaries_of = function(name, pkg) + local binary = server_info(name).binary + if binary then + return { binary } + end + -- Function-cmd server (e.g. jsonls): probe the Mason package's declared bins + -- so a system-provided copy is found instead of installing a duplicate. + if pkg ~= nil then + return tools.package_binaries(pkg, name) + end + return {} + end, + unknown_of = unknown_of, + has_local_config = has_local_config, + configure = configure, + }) end return M diff --git a/lua/modules/configs/completion/mason.lua b/lua/modules/configs/completion/mason.lua index 450cdefa..fc8eb4cf 100644 --- a/lua/modules/configs/completion/mason.lua +++ b/lua/modules/configs/completion/mason.lua @@ -27,35 +27,8 @@ M.setup = function() }, }) - -- Ensure formatters and linters are installed (only if mason loaded) - local ok, registry = pcall(require, "mason-registry") - if not ok then - return - end - - local settings = require("core.settings") - local ensure_installed = vim.list_extend(vim.deepcopy(settings.formatter_deps), settings.linter_deps) - - for _, pkg_name in ipairs(ensure_installed) do - local pkg_ok, pkg = pcall(registry.get_package, pkg_name) - if not pkg_ok then - vim.notify( - string.format("[Mason] Package '%s' not found in registry", pkg_name), - vim.log.levels.WARN, - { title = "Mason" } - ) - elseif not pkg:is_installed() then - pkg:install():once("closed", function() - if not pkg:is_installed() then - vim.notify( - string.format("[Mason] Failed to install '%s'", pkg_name), - vim.log.levels.WARN, - { title = "Mason" } - ) - end - end) - end - end + -- Formatter/linter resolution lives in conform.lua and nvim-lint.lua (against their + -- own registrations); Mason here is UI-only / lazy install fallback. end return M diff --git a/lua/modules/configs/completion/nvim-lint.lua b/lua/modules/configs/completion/nvim-lint.lua index e4b88eef..b13e5eeb 100644 --- a/lua/modules/configs/completion/nvim-lint.lua +++ b/lua/modules/configs/completion/nvim-lint.lua @@ -1,37 +1,54 @@ return function() local lint = require("lint") - -- selene: stdin mode uses process CWD to find selene.toml, which may not be the nvim - -- config dir. Pass --config explicitly so vim.yml is always found. - lint.linters.selene.args = { - "--display-style", - "json", - "--config", - vim.fn.stdpath("config") .. "/selene.toml", - "-", - } - - -- markdownlint-cli2: stdin broken under bun's node shim (for-await yields empty). - -- Override to file-based mode and update parser for "path:line:col severity message" format. - lint.linters["markdownlint-cli2"].stdin = false - lint.linters["markdownlint-cli2"].args = { - "--config", - vim.fn.stdpath("config") .. "/.markdownlint.yml", + -- Local customizations of upstream linter modules, as functions so they can + -- be re-applied when refresh_linter below rebuilds a module after an install. + local overrides = { + -- selene: stdin mode uses process CWD to find selene.toml, which may not be the + -- nvim config dir. Pass --config explicitly so vim.yml is always found. + selene = function(linter) + linter.args = { + "--display-style", + "json", + "--config", + vim.fn.stdpath("config") .. "/selene.toml", + "-", + } + end, + -- markdownlint-cli2: stdin broken under bun's node shim (for-await yields empty). + -- Override to file-based mode and update parser for "path:line:col severity message" format. + ["markdownlint-cli2"] = function(linter) + linter.stdin = false + linter.args = { + "--config", + vim.fn.stdpath("config") .. "/.markdownlint.yml", + } + linter.stream = "stderr" + linter.parser = require("lint.parser").from_pattern( + "[^:]+:(%d+):(%d+) (%a+) (.+)", + { "lnum", "col", "severity", "message" }, + { ["error"] = vim.diagnostic.severity.ERROR, ["warning"] = vim.diagnostic.severity.WARN }, + { source = "markdownlint" } + ) + end, } - lint.linters["markdownlint-cli2"].stream = "stderr" - lint.linters["markdownlint-cli2"].parser = require("lint.parser").from_pattern( - "[^:]+:(%d+):(%d+) (%a+) (.+)", - { "lnum", "col", "severity", "message" }, - { ["error"] = vim.diagnostic.severity.ERROR, ["warning"] = vim.diagnostic.severity.WARN }, - { source = "markdownlint" } - ) + local function apply_override(name) + local apply = overrides[name] + if not apply then + return + end + local linter = lint.linters[name] + if type(linter) == "table" then + apply(linter) + end + end + for name in pairs(overrides) do + apply_override(name) + end - -- shuck: lints shell embedded in GitHub Actions workflows (the `run:` blocks). - -- Complements actionlint, which validates workflow syntax/expressions but not - -- the embedded shell. Standalone sh/bash/zsh diagnostics already come from the - -- shuck LSP server (see servers/shuck.lua), so shuck is only added to - -- `yaml.github` here, not to `sh`/`zsh`. `shuck check` has no working stdin - -- mode (it needs a project root), so run file-based and parse JSON output. + -- shuck: lints shell embedded in GitHub Actions `run:` blocks (actionlint + -- skips those); standalone sh/bash comes from the shuck LSP server. No usable + -- stdin mode (needs a project root), so run file-based and parse JSON. lint.linters.shuck = { name = "shuck", cmd = "shuck", @@ -73,7 +90,7 @@ return function() end, } - lint.linters_by_ft = { + local by_ft = { dockerfile = { "hadolint" }, go = { "golangcilint" }, lua = { "selene" }, @@ -88,11 +105,198 @@ return function() ["yaml.github"] = { "actionlint", "shuck" }, zsh = { "zsh" }, } + lint.linters_by_ft = by_ft + + -- Mirror nvim-lint's private _resolve_linter_by_ft: exact filetype key + -- first, else the union over its `.`-separated segments. + local function linters_for_ft(ft) + local exact = by_ft[ft] + if exact then + return exact + end + local union, seen = {}, {} + for _, part in ipairs(vim.split(ft, ".", { plain = true })) do + for _, name in ipairs(by_ft[part] or {}) do + if not seen[name] then + seen[name] = true + union[#union + 1] = name + end + end + end + return union + end + + -- Resolve `linter_deps` discovery-first against nvim-lint's own registry. + -- Resolution is batched BY FILETYPE off the lint events below: the probe + -- requires the linter module, and some (golangcilint) run blocking system + -- calls and sniff the cwd (GOMOD) at module-load time. + local tools = require("modules.utils.tools") + ---Rewrite a linter's cmd to an absolute path: under Mason PATH = "skip" the + ---binary is installed but off $PATH. No-op on $PATH. + ---@param name string + local function rewrite_off_path(name) + local linter = lint.linters[name] + if type(linter) == "function" then + -- Factory linter: wrap it and rewrite the cmd of the table it returns. + lint.linters[name] = tools.wrap_off_path(linter, "cmd") + return + end + if type(linter) ~= "table" then + return + end + linter.cmd = tools.off_path_value(linter.cmd) + end + ---Rebuild a module-backed linter for a late configure: the probe may have + ---required its module while the binary was still absent, and some + ---(golangcilint) compute `args` by RUNNING the binary at module-load time — + ---that stale result would otherwise persist all session, so an auto-installed + ---linter would lint with broken arguments. + ---@param name string + local function refresh_linter(name) + local module = "lint.linters." .. name + if not tools.module_path(module) then + return -- defined inline (e.g. shuck), not module-backed: nothing to reload + end + package.loaded[module] = nil + -- Also drop any explicit assignment (a wrapped factory from + -- rewrite_off_path) shadowing the lint.linters __index loader. + rawset(lint.linters, name, nil) + apply_override(name) + end + ---Resolve one batch of linter names. Synchronous configures land before the + ---lint that triggered the batch; late configures (install completion / + ---post-refresh) have no later lint event and re-lint buffers themselves. + ---@param names string[] + local function resolve_batch(names) + local batch_done = false + tools.resolve_runtime_tools("nvim-lint", names, function(name) + local linter = lint.linters[name] + if linter == nil then + -- The lint.linters metatable swallows loader errors: a module that + -- exists but throws is a broken config, not a typo. Re-require re-throws. + local module = "lint.linters." .. name + if not tools.module_path(module) then + return nil -- unknown linter name (typo / not registered) + end + local ok, err = pcall(require, module) + if not ok then + return { broken = tostring(err) } + end + -- The retry loaded (nondeterministic loader): pick up the fresh value. + linter = lint.linters[name] + if linter == nil then + return nil + end + end + if type(linter) == "function" then + -- A throwing factory is a broken config, not an unknown name. + local ok, resolved = pcall(linter) + if not ok then + return { broken = tostring(resolved) } + end + linter = resolved + end + if type(linter) ~= "table" then + return nil + end + local cmd = linter.cmd + if type(cmd) == "function" then + local ok, resolved = pcall(cmd) + if not ok then + return { broken = tostring(resolved) } + end + cmd = resolved + end + -- A non-string cmd means the linter resolves its command at runtime. + return { binary = type(cmd) == "string" and cmd or nil } + end, function(name) + if batch_done then + -- A late configure follows an install / registry refresh: rebuild the + -- module so load-time work sees the now-present binary. + refresh_linter(name) + end + rewrite_off_path(name) + if batch_done then + -- No lint event follows a late configure: re-lint every loaded buffer + -- whose filetype maps to this linter, not just the current one. + vim.schedule(function() + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if + 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) + batch_done = true + end - vim.api.nvim_create_autocmd({ "BufWritePost", "BufReadPost", "InsertLeave" }, { + -- Names mapped in `by_ft` resolve lazily on their filetype's first lint + -- event; the rest (typos, manual-only linters) get a deferred immediate pass. + local deps = require("core.settings").linter_deps + if type(deps) ~= "table" then + deps = {} + end + local mapped = {} + for _, names in pairs(by_ft) do + for _, name in ipairs(names) do + mapped[name] = true + end + end + local pending, immediate = {}, {} + for i = 1, table.maxn(deps) do + local name = deps[i] + if name ~= nil then + if mapped[name] then + pending[name] = true + else + immediate[#immediate + 1] = name + end + end + end + 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 under Mason PATH = "skip". + ensure_resolved(vim.bo[args.buf].filetype) + if args.event ~= "FileType" then + lint.try_lint(nil, { ignore_errors = true }) + end end, }) end diff --git a/lua/modules/configs/completion/servers/shuck.lua b/lua/modules/configs/completion/servers/shuck.lua index 95319fbe..d617bbd1 100644 --- a/lua/modules/configs/completion/servers/shuck.lua +++ b/lua/modules/configs/completion/servers/shuck.lua @@ -1,17 +1,9 @@ -- shuck: Rust shell linter/formatter/language server. -- https://ewhauser.github.io/shuck/docs/lsp/ --- --- Installed via mise (`cargo:shuck-cli`), not Mason, so it is wired through --- `settings.external_lsp_deps` rather than `lsp_deps`. shuck is not shipped in --- nvim-lspconfig either, so cmd/filetypes/root_markers must be declared here. --- --- Provides live diagnostics, code actions (incl. `source.fixAll.shuck`), --- suppression-code hover, and document/range formatting over LSP. --- +-- No Mason package — installed via mise (`cargo:shuck-cli`); the resolver +-- enables it from the `shuck` binary on $PATH (fix = `mise install`). -- `zsh` is intentionally excluded: shuck's zsh dialect still misparses some --- zsh-isms (e.g. path literals inside `[(I)...]` subscripts) and emits false --- positives, so zsh files are left to `zsh -n` (nvim-lint). Re-add "zsh" here --- once shuck's zsh support is solid. +-- zsh-isms and emits false positives, so zsh stays on `zsh -n` (nvim-lint). return { cmd = { "shuck", "server" }, filetypes = { "sh", "bash", "ksh" }, diff --git a/lua/modules/configs/completion/servers/yamlls.lua b/lua/modules/configs/completion/servers/yamlls.lua index f8052abf..98aa72e2 100644 --- a/lua/modules/configs/completion/servers/yamlls.lua +++ b/lua/modules/configs/completion/servers/yamlls.lua @@ -1,4 +1,33 @@ -- https://github.com/neovim/nvim-lspconfig/blob/master/lsp/yamlls.lua +local schemas = require("schemastore").yaml.schemas({ + extra = { + { + name = "azure-pipelines", + description = "azure-pipelines YAML schema", + fileMatch = { "azure-pipelines.{yml,yaml}" }, + url = "https://raw.githubusercontent.com/microsoft/azure-pipelines-vscode/master/service-schema.json", + }, + { + name = "gh-dash config", + description = "gh-dash config YAML schema", + fileMatch = "*/gh-dash/config.{yml,yaml}", + url = "https://dlvdhr.github.io/gh-dash/configuration/gh-dash/schema.json", + }, + -- The catalog's own "Traefik v3" entry carries no fileMatch, so this + -- extra is what actually claims the static-config files for v3. + { + name = "Traefik v3", + description = "Traefik v3 static configuration", + fileMatch = { "traefik.{yml,yaml}" }, + url = "https://www.schemastore.org/traefik-v3.json", + }, + }, +}) +-- Only Traefik v3 is in use here; unmap the catalog's v2 entry so it stops +-- claiming traefik.yml. Plain key removal: a catalog that drops v2 makes this +-- a no-op, unlike schemastore's `ignore`, which asserts on a missing name. +schemas["https://www.schemastore.org/traefik-v2.json"] = nil + return { single_file_support = true, debounce_text_changes = 150, @@ -18,37 +47,7 @@ return { -- Avoid TypeError: Cannot read properties of undefined (reading 'length') url = "", }, - schemas = require("schemastore").yaml.schemas({ - extra = { - { - name = "azure-pipelines", - description = "azure-pipelines YAML schema", - fileMatch = { "azure-pipelines.{yml,yaml}" }, - url = "https://raw.githubusercontent.com/microsoft/azure-pipelines-vscode/master/service-schema.json", - }, - { - name = "gh-dash config", - description = "gh-dash config YAML schema", - fileMatch = "*/gh-dash/config.yml", - url = "https://dlvdhr.github.io/gh-dash/configuration/gh-dash/schema.json", - }, - { - name = "Traefik v3", - description = "Traefik v3 static configuration", - fileMatch = { "traefik.yml", "traefik.yaml" }, - url = "https://www.schemastore.org/traefik-v3.json", - }, - }, - -- 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..70b2a1b1 100644 --- a/lua/modules/configs/tool/dap/clients/codelldb.lua +++ b/lua/modules/configs/tool/dap/clients/codelldb.lua @@ -4,13 +4,17 @@ return function() local utils = require("modules.utils.dap") local is_windows = require("core.global").is_windows + -- Self-validate at config time: launch AND attach both spawn the local binary, so + -- unlike delve/python nothing is worth registering without it — error if missing. + local command = + require("modules.utils.tools").exepath_or_error("codelldb", "install it via Mason or your package manager") dap.adapters.codelldb = { type = "server", port = "${port}", executable = { - command = vim.fn.exepath("codelldb"), -- Find codelldb on $PATH + command = command, args = { "--port", "${port}" }, - detached = is_windows and false or true, + detached = not is_windows, }, } dap.configurations.c = { diff --git a/lua/modules/configs/tool/dap/clients/delve.lua b/lua/modules/configs/tool/dap/clients/delve.lua index b1a70b3c..c1cf862e 100644 --- a/lua/modules/configs/tool/dap/clients/delve.lua +++ b/lua/modules/configs/tool/dap/clients/delve.lua @@ -1,34 +1,64 @@ --- https://github.com/mfussenegger/nvim-dap/wiki/Debug-Adapter-installation#go +-- https://github.com/mfussenegger/nvim-dap/wiki/Debug-Adapter-installation#go-using-delve-directly -- https://github.com/golang/vscode-go/blob/master/docs/debugging.md return function() local dap = require("dap") local utils = require("modules.utils.dap") + local is_windows = require("core.global").is_windows - if not require("mason-registry").is_installed("go-debug-adapter") then - vim.notify( - "Automatically installing `go-debug-adapter` for go debugging", - vim.log.levels.INFO, - { title = "nvim-dap" } - ) + -- Use delve's built-in DAP server (`dlv dap`) directly, no separate go-debug-adapter. + -- `dlv` resolves lazily on the spawn path: remote attach needs no local binary, so + -- the adapter registers even when it's absent. - local go_dbg = require("mason-registry").get_package("go-debug-adapter") - go_dbg:install():once( - "closed", - vim.schedule_wrap(function() - if go_dbg:is_installed() then - vim.notify("Successfully installed `go-debug-adapter`", vim.log.levels.INFO, { title = "nvim-dap" }) + ---A function adapter (over a static table) so a remote `attach` config can + ---connect to an already-running `dlv dap` instead of spawning a local one. + ---@param callback fun(adapter: table) + ---@param config table + local function delve_adapter(callback, config) + if config.request == "attach" and config.mode == "remote" then + -- Default when unset, but a malformed user port errors rather than silently + -- falling back to delve's default 38697. + local port = 38697 + if config.port ~= nil then + local n = tonumber(config.port) + if not n or n ~= math.floor(n) or n < 1 or n > 65535 then + error( + string.format( + "delve remote attach: invalid `port` %s (want an integer 1-65535)", + vim.inspect(config.port) + ), + 0 + ) end - end) - ) + port = n + end + callback({ + type = "server", + host = config.host or "127.0.0.1", + port = port, + }) + else + -- Resolve lazily so a dlv installed after config load is picked up without reconfiguring. + local command = require("modules.utils.tools").exepath_or_error( + "dlv", + "install delve via Mason or your package manager" + ) + callback({ + type = "server", + port = "${port}", + executable = { + command = command, + args = { "dap", "-l", "127.0.0.1:${port}" }, + detached = not is_windows, + }, + }) + end end - dap.adapters.go = { - type = "executable", - command = "node", - args = { - vim.env.MASON .. "/packages/go-debug-adapter" .. "/extension/dist/debugAdapter.js", - }, - } + -- Register under both names: the configurations below use `type = "go"`, while + -- mason-nvim-dap / other integrations reference the adapter as `delve`. + dap.adapters.go = delve_adapter + dap.adapters.delve = delve_adapter + dap.configurations.go = { { type = "go", @@ -37,7 +67,6 @@ return function() cwd = "${workspaceFolder}", program = utils.input_file_path(), console = "integratedTerminal", - dlvToolPath = vim.fn.exepath("dlv"), showLog = true, showRegisters = true, stopOnEntry = false, @@ -50,7 +79,6 @@ return function() program = utils.input_file_path(), args = utils.input_args(), console = "integratedTerminal", - dlvToolPath = vim.fn.exepath("dlv"), showLog = true, showRegisters = true, stopOnEntry = false, @@ -63,7 +91,6 @@ return function() program = utils.input_exec_path(), args = utils.input_args(), console = "integratedTerminal", - dlvToolPath = vim.fn.exepath("dlv"), mode = "exec", showLog = true, showRegisters = true, @@ -76,7 +103,6 @@ return function() cwd = "${workspaceFolder}", program = utils.input_file_path(), console = "integratedTerminal", - dlvToolPath = vim.fn.exepath("dlv"), mode = "test", showLog = true, showRegisters = true, @@ -89,11 +115,16 @@ return function() cwd = "${workspaceFolder}", program = "./${relativeFileDirname}", console = "integratedTerminal", - dlvToolPath = vim.fn.exepath("dlv"), mode = "test", showLog = true, showRegisters = true, stopOnEntry = false, }, } + + -- Availability check LAST so remote attach keeps what was registered above. + require("modules.utils.tools").exepath_or_error( + "dlv", + "local `dlv dap` launch is unavailable until installed (remote attach still works)" + ) end diff --git a/lua/modules/configs/tool/dap/clients/lldb.lua b/lua/modules/configs/tool/dap/clients/lldb.lua index 8461c77a..6ce14c42 100644 --- a/lua/modules/configs/tool/dap/clients/lldb.lua +++ b/lua/modules/configs/tool/dap/clients/lldb.lua @@ -3,9 +3,17 @@ return function() local dap = require("dap") local utils = require("modules.utils.dap") + -- Opt-in preset (not in default dap_deps; codelldb covers C-family). Self-validate + -- so the resolver surfaces `lldb`. LLVM 15 renamed `lldb-vscode` -> `lldb-dap`; + -- probe the new name first, keep the old for distros still shipping it. + local command = require("modules.utils.tools").exepath_or_error( + { "lldb-dap", "lldb-vscode" }, + "install it via your package manager (ships with LLVM/lldb)" + ) + dap.adapters.lldb = { type = "executable", - command = vim.fn.exepath("lldb-vscode"), -- Find lldb-vscode on $PATH + command = command, } dap.configurations.c = { { diff --git a/lua/modules/configs/tool/dap/clients/python.lua b/lua/modules/configs/tool/dap/clients/python.lua index 688256b4..712d0573 100644 --- a/lua/modules/configs/tool/dap/clients/python.lua +++ b/lua/modules/configs/tool/dap/clients/python.lua @@ -3,8 +3,81 @@ return function() local dap = require("dap") local utils = require("modules.utils.dap") + local tools = require("modules.utils.tools") local is_windows = require("core.global").is_windows - local debugpy_root = vim.env.MASON .. "/packages/debugpy" + local mason_root = tools.mason_root() + local debugpy_root = mason_root and (mason_root .. "/packages/debugpy") or nil + + -- Platform-specific venv interpreter layout, in one place. + ---@param root string @A virtualenv root directory. + ---@return string @Absolute path to the venv's python interpreter. + local function venv_python(root) + return is_windows and root .. "/Scripts/pythonw.exe" or root .. "/bin/python" + end + + -- Resolve the debugpy command discovery-first via a fast and a full cascade. Only + -- a successful resolution is cached, so a mid-session install is still picked up. + local resolved + local function found(command, args) + resolved = { command = command, args = args } + return command, args + end + + -- Fast, non-blocking probes only (executable()/exepath()): Mason's managed + -- venv → `debugpy-adapter` on $PATH. Safe on the config-time resolve path. + local function fast_command() + if resolved then + return resolved.command, resolved.args + end + if debugpy_root then + local mason_python = venv_python(debugpy_root .. "/venv") + if vim.fn.executable(mason_python) == 1 then + return found(mason_python, { "-m", "debugpy.adapter" }) + end + end + -- find_executable covers a Mason shim under PATH = "skip"; spawn by its + -- absolute path (the off-$PATH shim wouldn't launch by bare name). + local adapter = tools.find_executable("debugpy-adapter") + if adapter then + return found(adapter, {}) + end + return nil + end + + -- Full cascade: fast probes, then a system python that can import debugpy. The + -- import probe blocks, so this is for a user-triggered launch, not config time. + local function debugpy_command() + local command, args = fast_command() + if command then + return command, args + end + -- Last resort: probe interpreter candidates rather than hard-coding one + -- (pythonw.exe is often absent on a Windows box with only python.exe). + local candidates = is_windows and { "pythonw.exe", "python.exe", "python" } or { "python3", "python" } + local probed = {} + for _, py in ipairs(candidates) do + -- Confirm the module actually imports, not just that the interpreter exists. + -- Dedup by resolved path so `python`→`python3` symlinks spawn only once. + if vim.fn.executable(py) == 1 then + local abs = vim.fn.exepath(py) + local real = (abs ~= "" and vim.uv.fs_realpath(abs)) or abs + -- Key by candidate name when exepath came back empty, so one empty + -- result can't blanket-skip the remaining candidates. + local key = real ~= "" and real or py + if not probed[key] then + probed[key] = true + local probe_cmd = abs ~= "" and abs or py + vim.fn.system({ probe_cmd, "-c", "import debugpy" }) + if vim.v.shell_error == 0 then + -- Spawn by the probed exepath, not its realpath: a venv python is a + -- symlink and pyvenv.cfg discovery precedes symlink resolution. + return found(probe_cmd, { "-m", "debugpy.adapter" }) + end + end + end + end + return nil + end dap.adapters.python = function(callback, config) if config.request == "attach" then @@ -17,11 +90,20 @@ 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( + "debugpy not found: no Mason venv, no `debugpy-adapter` on $PATH or in Mason's bin\n" + .. "dir, and no python able to import debugpy; install debugpy via Mason (`:Mason`)\n" + .. "or your package manager", + 0 + ) + end callback({ type = "executable", - command = is_windows and debugpy_root .. "/venv/Scripts/pythonw.exe" - or debugpy_root .. "/venv/bin/python", - args = { "-m", "debugpy.adapter" }, + command = command, + args = args, options = { source_filetype = "python" }, }) end @@ -38,7 +120,7 @@ return function() pythonPath = function() local venv = vim.env.CONDA_PREFIX if venv then - return is_windows and venv .. "/Scripts/pythonw.exe" or venv .. "/bin/python" + return venv_python(venv) else return is_windows and "pythonw.exe" or "python3" end @@ -55,14 +137,14 @@ return function() pythonPath = function() -- Prefer the venv that is defined by the designated environment variable. local cwd, venv = vim.uv.cwd(), vim.env.VIRTUAL_ENV - local python = venv and (is_windows and venv .. "/Scripts/pythonw.exe" or venv .. "/bin/python") or "" + local python = venv and venv_python(venv) or "" if vim.fn.executable(python) == 1 then return python end -- Otherwise, fall back to check if there are any local venvs available. - venv = vim.uv.fs_stat(cwd .. "/venv") and cwd .. "/venv" or cwd .. "/.venv" - python = is_windows and venv .. "/Scripts/pythonw.exe" or venv .. "/bin/python" + venv = vim.fn.isdirectory(cwd .. "/venv") == 1 and cwd .. "/venv" or cwd .. "/.venv" + python = venv_python(venv) if vim.fn.executable(python) == 1 then return python else @@ -71,4 +153,17 @@ return function() end, }, } + + -- Availability check LAST so remote attach keeps what was registered above. Runs + -- on the config-time resolve path, so fast probes only — the blocking import probe + -- waits for launch. Cached on success; uncached on failure to catch a later install. + if not fast_command() then + error( + "debugpy not found: no Mason venv and no `debugpy-adapter` on $PATH or in Mason's bin dir;\n" + .. "install debugpy via Mason (`:Mason`) or your package manager — a python able to\n" + .. "`import debugpy` is still probed at launch, so local launch may work regardless\n" + .. "(remote attach always works)", + 0 + ) + end end diff --git a/lua/modules/configs/tool/dap/init.lua b/lua/modules/configs/tool/dap/init.lua index 1e998f8b..ddc654f9 100644 --- a/lua/modules/configs/tool/dap/init.lua +++ b/lua/modules/configs/tool/dap/init.lua @@ -1,11 +1,21 @@ return function() local dap = require("dap") local dapui = require("dapui") - local mason_dap = require("mason-nvim-dap") + -- Mason is optional: a Mason-less setup still configures adapters that + -- resolve their own binary (client configs / $PATH). + local has_mason_dap, mason_dap = pcall(require, "mason-nvim-dap") local icons = { dap = require("modules.utils.icons").get("dap") } local colors = require("modules.utils").get_palette() local mappings = require("tool.dap.dap-keymap") + local tools = require("modules.utils.tools") + + ---Ordered client-config modules for an adapter: user override, then repo preset. + ---@param name string + ---@return string[] + local function client_modules(name) + return { "user.configs.dap-clients." .. name, "tool.dap.clients." .. name } + end -- Initialize debug hooks _G._debugging = false @@ -54,19 +64,35 @@ return function() ---@param config table local function mason_dap_handler(config) local dap_name = config.name - local ok, custom_handler = pcall(require, "user.configs.dap-clients." .. dap_name) - if not ok then - -- Use preset if there is no user definition - ok, custom_handler = pcall(require, "tool.dap.clients." .. dap_name) - end - if not ok then - -- Default to use factory config for clients(s) that doesn't include a spec + local custom_handler = tools.first_module(client_modules(dap_name), "nvim-dap") + if custom_handler == nil then + -- No client config: fall back to Mason's factory config, erroring (level 0) + -- so the shared resolver reports failures instead of reading them as success. + if not has_mason_dap then + error( + string.format( + "no client config for `%s` and mason-nvim-dap is unavailable for a default setup", + dap_name + ), + 0 + ) + end + -- default_setup silently no-ops on a nil adapter config, so error rather + -- than register nothing while the resolver reads the call as success. + if config.adapters == nil then + error( + string.format( + "no client config for `%s` and mason-nvim-dap has no adapter definition for it", + dap_name + ), + 0 + ) + end mason_dap.default_setup(config) - return elseif type(custom_handler) == "function" then -- Case where the protocol requires its own setup -- Make sure to set - -- * dap.adpaters. = { your config } + -- * dap.adapters. = { your config } -- * dap.configurations. = { your config } -- See `codelldb.lua` for a concrete example. custom_handler(config) @@ -83,9 +109,99 @@ return function() end end - require("modules.utils").load_plugin("mason-nvim-dap", { - ensure_installed = require("core.settings").dap_deps, - automatic_installation = false, - handlers = { mason_dap_handler }, + local settings = require("core.settings") + + -- Mason-driven bits (mappings + install) only exist when Mason does; setup + -- stays discovery-first either way, not gated on mason-nvim-dap's installed set. + local has_registry, registry = pcall(require, "mason-registry") + local mason_ok = has_mason_dap and has_registry + local source_map = { nvim_dap_to_package = {} } + local adapters_map, configs_map, filetypes_map = {}, {}, {} + if mason_ok then + require("modules.utils").load_plugin("mason-nvim-dap", { + ensure_installed = {}, + automatic_installation = false, + }) + -- mason-nvim-dap private internals, not a public API: guard each require so + -- drift degrades to client-config/$PATH resolution instead of aborting. + local function map_or_empty(mod, default) + local ok, m = pcall(require, mod) + return (ok and type(m) == "table") and m or default + end + source_map = map_or_empty("mason-nvim-dap.mappings.source", {}) + adapters_map = map_or_empty("mason-nvim-dap.mappings.adapters", {}) + configs_map = map_or_empty("mason-nvim-dap.mappings.configurations", {}) + filetypes_map = map_or_empty("mason-nvim-dap.mappings.filetypes", {}) + -- The module may load with the indexed field renamed/removed; normalize so + -- package_of/binaries_of below never index nil. + if type(source_map.nvim_dap_to_package) ~= "table" then + source_map.nvim_dap_to_package = {} + end + end + + ---Does an explicit client config exist for this adapter (system-resolved)? + ---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) + for _, module in ipairs(client_modules(name)) do + local ok, value, exists = tools.load_module_or_report(module, "nvim-dap") + if (ok and value ~= nil) or exists then + return true + end + end + return false + end + + ---Configure an adapter via the shared handler; client configs self-validate. + ---@param name string + local function configure_adapter(name) + mason_dap_handler({ + name = name, + adapters = adapters_map[name], + configurations = configs_map[name], + filetypes = filetypes_map[name], + }) + end + + -- Discovery-first resolution, shared with LSP and formatters/linters. nvim-dap + -- has no command registry like nvim-lspconfig, so $PATH detection leans on the + -- Mason package's declared binaries; configs without a package resolve their own. + tools.resolve({ + title = "DAP", + deps = settings.dap_deps, + -- Registry alone can install a mapped adapter's package, so a setup without + -- mason-nvim-dap still auto-installs. Lazy thunk: only consulted in phase 2. + registry = function() + return has_registry and registry or nil + end, + package_of = function(name) + return source_map.nvim_dap_to_package[name] + end, + binaries_of = function(name, pkg) + if pkg ~= nil then + return tools.package_binaries(pkg, name) + end + -- No Package object, no probe: adapter names are not binary names (`delve` + -- ships `dlv`, `cpptools` ships `OpenDebugAD7`). Client configs self-validate. + return {} + end, + ---Typo/outdated name vs valid-but-unprovisioned. A client config or mapping means + ---the name is real; an empty map (Mason absent) is untrusted to avoid false typos. + unknown_of = function(name) + if has_client_config(name) then + return false + end + if next(source_map.nvim_dap_to_package) == nil then + return false + end + return source_map.nvim_dap_to_package[name] == nil + end, + has_local_config = has_client_config, + -- Client configs self-validate, so try an existing config before the Mason + -- install fallback — python resolves debugpy from a venv $PATH can't see. + local_config_mode = "validates", + configure = configure_adapter, }) end diff --git a/lua/modules/plugins/tool.lua b/lua/modules/plugins/tool.lua index a5105bb1..5ad7aa4f 100644 --- a/lua/modules/plugins/tool.lua +++ b/lua/modules/plugins/tool.lua @@ -106,6 +106,8 @@ tool["mfussenegger/nvim-dap"] = { }, config = require("tool.dap"), dependencies = { + -- mason-nvim-dap only supplies the adapter -> package mappings; the DAP resolver + -- degrades to $PATH discovery when it (or mason.nvim) is absent. { "jay-babu/mason-nvim-dap.nvim" }, { "rcarriga/nvim-dap-ui", 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..810683d8 --- /dev/null +++ b/lua/modules/utils/tools.lua @@ -0,0 +1,904 @@ +-- Discovery-first tool resolution (RFC: ayamir/nvimdots#1293): every external +-- tool resolves $PATH → Mason install → aggregated missing-tool warning, with +-- Mason as an optional backend and `M.resolve` as the shared loop. +local M = {} + +---Locate the first of the given executables: on $PATH, then in Mason's bin +---dir (covers Mason installs with `PATH = "skip"`). First-found by design: +---callers pass alternates for one tool, not a list of required binaries. +---@param names string|string[] @Executable name(s), probed in order. +---@return string|nil @Absolute path of the first name found, or nil. +function M.find_executable(names) + if type(names) == "string" then + names = { names } + end + -- exepath() throws on non-strings; maxn so a nil hole doesn't truncate the search. + if type(names) ~= "table" then + return nil + end + local last = table.maxn(names) + for i = 1, last do + local name = names[i] + if type(name) == "string" and name ~= "" then + local path = vim.fn.exepath(name) + if path ~= "" then + return path + end + end + end + local root = M.mason_root() + if root then + for i = 1, last do + local name = names[i] + if type(name) == "string" and name ~= "" then + -- exepath() also resolves the Windows `.cmd` shim extension. + local path = vim.fn.exepath(root .. "/bin/" .. name) + if path ~= "" then + return path + end + end + end + end + return nil +end + +-- Hits only: resolve() re-queries the same candidate modules across phases, but +-- a miss must stay uncached — rtp grows as lazy.nvim loads plugins, so a +-- remembered "absent" would go stale mid-session. +local module_path_cache = {} + +---Locate a module file on the paths require() uses, without executing it: +---package.path plus the runtimepath loader (covers `user.*`). +---@param module string +---@return string|nil +function M.module_path(module) + if module_path_cache[module] then + return module_path_cache[module] + end + local path = package.searchpath(module, package.path) + if not path then + -- The loader's per-directory index covers `foo.lua` and `foo/init.lua` + -- in one lookup, without globbing the whole runtimepath. + local found = vim.loader.find(module)[1] + path = found and found.modpath or nil + end + module_path_cache[module] = path + return path +end + +-- Modules already reported broken, so multi-site lookups notify once per module. +local broken_module_reported = {} + +---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. +function M.load_module_or_report(module, title) + local ok, value = pcall(require, module) + if ok then + return true, value, true + end + if not M.module_path(module) then + return false, nil, false + end + if not broken_module_reported[module] then + broken_module_reported[module] = true + vim.notify( + string.format("Failed to load `%s`:\n%s", module, value), + vim.log.levels.ERROR, + { title = title or "tools" } + ) + end + return false, nil, true +end + +---Require the first loadable module from an ordered candidate list (user +---override before repo default); nil when none load. A candidate that exists +---but throws is notified (once) and skipped. +---@param candidates string[] @Module names, highest precedence first. +---@param title? string @Notification title for broken-module load errors. +---@return any|nil @The first module's value, or nil if none loaded. +function M.first_module(candidates, title) + for _, module in ipairs(candidates) do + local ok, value = M.load_module_or_report(module, title) + if ok and value ~= nil then + return value + end + end + return nil +end + +---Absolute path for a binary that is off $PATH but in Mason's bin dir (the +---`PATH = "skip"` case); nil when the bare name already works or nothing +---resolves. +---@param binary string @The bare executable name a tool is configured to launch. +---@return string|nil @Absolute path to spawn by, or nil to keep the bare name. +function M.off_path_command(binary) + if type(binary) ~= "string" or binary == "" or vim.fn.executable(binary) == 1 then + return nil + end + return M.find_executable(binary) +end + +---Wrap a function-form command resolver so its result is rewritten to an +---absolute path when off $PATH. With `field` the result is a table holding +---the command at that key (copied before rewriting — it may be shared); +---without, the result is the command string itself. +---@param fn function +---@param field? string @Key holding the command when the result is a table. +---@return function +function M.wrap_off_path(fn, field) + return function(...) + local out = fn(...) + if field == nil then + if type(out) == "string" then + return M.off_path_command(out) or out + end + return out + end + if type(out) == "table" and type(out[field]) == "string" then + local abs = M.off_path_command(out[field]) + if abs then + out = vim.tbl_extend("force", {}, out) + out[field] = abs + end + end + return out + end +end + +---Rewrite a command value to spawn by absolute path when off $PATH (the +---Mason `PATH = "skip"` case): a string comes back rewritten (or unchanged +---when the bare name already works), a function-form resolver comes back +---wrapped to rewrite its result at call time, anything else passes through +---untouched. +---@param cmd any +---@return any +function M.off_path_value(cmd) + if type(cmd) == "string" then + return M.off_path_command(cmd) or cmd + end + if type(cmd) == "function" then + return M.wrap_off_path(cmd) + end + return cmd +end + +---Wrap a function that spawns internally (e.g. a function LSP `cmd`) so it +---runs with Mason's bin dir APPENDED to $PATH — appended so $PATH still wins. +---$PATH is restored whether the fn returns or throws. +---@param fn function +---@return function +function M.wrap_with_mason_path(fn) + return function(...) + local root = M.mason_root() + if not root then + return fn(...) + end + local prev = vim.env.PATH or "" + local sep = require("core.global").is_windows and ";" or ":" + -- No separator on an empty prev: a zero-length $PATH entry means CWD on POSIX. + vim.env.PATH = prev ~= "" and (prev .. sep .. root .. "/bin") or (root .. "/bin") + -- Keeps all return values while resetting $PATH before a throw propagates. + local function restore(ok, ...) + vim.env.PATH = prev + if not ok then + error((...), 0) + end + return ... + end + return restore(pcall(fn, ...)) + end +end + +---Resolve the first of the given executable names, 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) + if type(names) == "string" then + names = { names } + end + local path = M.find_executable(names) + if path then + return path + end + -- table.concat throws on non-strings/nil holes; render only valid entries. + local shown = {} + if type(names) == "table" then + for i = 1, table.maxn(names) do + local name = names[i] + if type(name) == "string" and name ~= "" then + shown[#shown + 1] = name + end + end + end + local label = #shown > 0 and table.concat(shown, "/") or "" + error(string.format("%s not found on $PATH or in Mason's bin dir; %s", label, hint), 0) +end + +---Aggregate tools that could not be set up into one deferred warning, in two +---sections: `mark` (install it / config failed) and `mark_unknown` +---(unrecognized name — fix the config, don't install). +---@class ToolCollector +---@field mark fun(name: string, reason?: string) @Record an unresolved tool (optionally with a reason). +---@field mark_unknown fun(name: string) @Record an unrecognized name (typo / outdated / unsupported). +---@field track fun(pkg: table, name: string, recheck: fun(): boolean, on_ready?: fun(), fail_reason?: string) @Track an async Mason install. +---@field done fun() @Flush the aggregated warning once all tracked installs settle. +---@param title string @Notification title identifying the subsystem. +---@param timeout_ms? number @Deadline before the warning is flushed despite unsettled installs. +---@return ToolCollector +local function missing_collector(title, timeout_ms) + local missing = {} + local reasons = {} + local unknown = {} + local queued = {} + local seen = {} + local emitted = {} + local pending = 0 + local deadline_started = false + local deadline_passed = false + local flush_scheduled = false + local announce_scheduled = false + + -- Dedup across both buckets; non-string names are tostring()'d, nil/empty dropped. + local function normalize(name) + if name == nil or name == "" then + return nil + end + return type(name) == "string" and name or tostring(name) + end + local function record(bucket, name) + if name == nil or seen[name] then + return false + end + seen[name] = true + bucket[#bucket + 1] = name + return true + end + local function add(name, reason) + name = normalize(name) + if record(missing, name) and type(reason) == "string" and reason ~= "" then + reasons[name] = reason + end + end + local function add_unknown(name) + record(unknown, normalize(name)) + end + + local function render(name) + local reason = reasons[name] + return reason and (name .. " — " .. reason) or name + end + + -- Emit names not yet notified, coalesced per event-loop tick so batches that + -- settle together (a multi-filetype session restore) produce one notification. + -- Gated while installs are pending unless forced — done() forces because + -- everything recorded by then is final — or the deadline passed; the + -- "what's new" set is computed at emit time so anything recorded before the + -- scheduled callback runs rides along; `emitted` lets late failures still + -- notify. + local function flush(force) + if not force and pending > 0 and not deadline_passed then + return + end + if flush_scheduled then + return + end + flush_scheduled = true + vim.schedule(function() + flush_scheduled = false + local missing_new, unknown_new = {}, {} + for _, name in ipairs(missing) do + if not emitted[name] then + emitted[name] = true + missing_new[#missing_new + 1] = name + end + end + for _, name in ipairs(unknown) do + if not emitted[name] then + emitted[name] = true + unknown_new[#unknown_new + 1] = name + end + end + if #missing_new == 0 and #unknown_new == 0 then + return + end + local sections = {} + if #missing_new > 0 then + table.sort(missing_new) + local lines = {} + for _, name in ipairs(missing_new) do + lines[#lines + 1] = render(name) + end + sections[#sections + 1] = "The following tools could not be set up automatically.\n" + .. "Install them / ensure they are on $PATH, or check their configuration\n" + .. "for errors:\n • " + .. table.concat(lines, "\n • ") + end + if #unknown_new > 0 then + table.sort(unknown_new) + sections[#sections + 1] = "The following names are not recognized (likely a typo, or an outdated\n" + .. "or unsupported name) — correct or remove them from your config:\n • " + .. table.concat(unknown_new, "\n • ") + end + vim.notify(table.concat(sections, "\n\n"), vim.log.levels.WARN, { title = title }) + end) + end + + return { + mark = add, + mark_unknown = add_unknown, + track = function(pkg, name, recheck, on_ready, fail_reason) + -- Attach to an in-flight install instead of starting a duplicate (mason's + -- install() asserts not is_installing()). The handle outlives its install, + -- so only an OPEN one counts: once("closed") never fires on a closed one. + local handle + if type(pkg) == "table" and type(pkg.get_install_handle) == "function" then + local ok, opt = pcall(function() + 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 + add(name, fail_reason) + 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 + + pending = pending + 1 + -- Arm the deadline on the first tracked install so one that never closes + -- can't suppress the aggregated warning all session. + if not deadline_started and type(timeout_ms) == "number" and timeout_ms > 0 then + deadline_started = true + vim.defer_fn(function() + deadline_passed = true + flush() + end, timeout_ms) + end + -- Queue only real strings (done() sorts `queued`) from self-started + -- installs; `queued_here` lets the once()-throw path pop the right entry. + local queued_here = started_here and type(name) == "string" and name ~= "" + if queued_here then + queued[#queued + 1] = name + end + -- "closed" fires in a fast event context: hop to the main loop. pending is + -- decremented unconditionally so a throwing callback can't suppress flush. + local registered = pcall( + handle.once, + handle, + "closed", + vim.schedule_wrap(function() + local rc_ok, available = pcall(recheck) + if rc_ok and available then + if type(on_ready) == "function" then + pcall(on_ready) + end + else + add(name, fail_reason) + end + pending = pending - 1 + flush() + end) + ) + -- once() threw: no callback will ever decrement, so undo the accounting. + if not registered then + pending = pending - 1 + if queued_here then + queued[#queued] = nil + end + add(name, fail_reason) + end + end, + done = function() + -- One INFO so a first launch shows progress instead of silently + -- downloading. Coalesced per tick and drained after announcing: the + -- collector is shared across batches, so a later done() must not + -- re-announce earlier installs. + if #queued > 0 and not announce_scheduled then + announce_scheduled = true + vim.schedule(function() + announce_scheduled = false + if #queued == 0 then + return + end + table.sort(queued) + local message = string.format( + "Installing %d tool(s) via Mason in the background; each is configured\n" + .. "automatically once its install finishes (relaunch if one isn't picked up):\n • %s", + #queued, + table.concat(queued, "\n • ") + ) + queued = {} + vim.notify(message, vim.log.levels.INFO, { title = title }) + end) + end + -- Names recorded by done() (unknown/broken/uninstallable) are final: + -- force past the pending gate so in-flight installs can't withhold them. + flush(true) + end, + } +end + +---Find the Mason package shipping the given binary, via a lazily-built +---bin -> package index over the registry specs: tool name, binary, and +---package name may all differ (cmake_format -> cmake-format -> cmakelang). +---@param registry table @The mason-registry module. +---@param binary string @Executable name to look up. +---@return string|nil @Mason package name shipping that binary, or nil. +local bin_to_package = nil +local function package_for_binary(registry, binary) + -- Cache only a populated index: a not-yet-bootstrapped registry scans empty, + -- and caching that would disable the install fallback all session. + if bin_to_package == nil then + local index = {} + local populated = false + local ok, specs = pcall(registry.get_all_package_specs) + if ok and type(specs) == "table" then + for _, spec in ipairs(specs) do + if type(spec) == "table" and type(spec.name) == "string" and type(spec.bin) == "table" then + for bin_name in pairs(spec.bin) do + if index[bin_name] == nil then + index[bin_name] = spec.name + populated = true + end + end + end + end + end + if populated then + bin_to_package = index + else + return nil + end + end + return bin_to_package[binary] +end + +---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 = {} + if type(pkg.spec) == "table" and type(pkg.spec.bin) == "table" then + for bin_name, _ in pairs(pkg.spec.bin) do + bins[#bins + 1] = bin_name + end + end + if #bins == 0 then + bins = { type(pkg.name) == "string" and pkg.name or fallback } + end + return bins +end + +---Mason's install root, or nil when Mason isn't available. Settings API first +---($MASON is only set by mason.setup()). Only a resolved root is cached: +---absence is re-probed each call, so a mason.nvim that appears later (e.g. a +---mid-session `:Lazy install`) is still picked up by every discovery path. +---@return string|nil +local mason_root_dir = nil +function M.mason_root() + if not mason_root_dir then + local ok, settings = pcall(require, "mason.settings") + if ok and settings.current and type(settings.current.install_root_dir) == "string" then + mason_root_dir = settings.current.install_root_dir + elseif type(vim.env.MASON) == "string" and vim.env.MASON ~= "" then + mason_root_dir = vim.env.MASON + end + end + -- Existence stays re-checked: the dir only appears after the first install. + if mason_root_dir and vim.uv.fs_stat(mason_root_dir) then + return mason_root_dir + end + return nil +end + +-- One collector per title: a subsystem that resolves in several batches +-- (nvim-lint batches by filetype) dedups and aggregates warnings across +-- batches instead of fragmenting one notification per batch. +local collectors_by_title = {} + +---Shared discovery-first resolution loop. For each entry in `spec.deps`: +--- 0. Unrecognized name (`unknown_of`) -> fix the config; never install. +--- 1. Available (Mason-installed / on $PATH) -> configure now. +--- 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 synchronously — same +---tick, before lazy.nvim replays the trigger — and only installs defer; each +---dep and `configure` is pcall'd so one failure can't abort the rest. +---`local_config_mode` says who owns resolution when no binary is found but a +---local config exists (`has_local_config`): nil defers to phase 2, "resolves" +---trusts a binary-less config outright, "validates" lets the config try and +---falls back to an install on failure. Unknown values behave like nil. +---"validates" configs may only run non-blocking checks at config time (the +---pass is on the phase-1 hot path); process spawns defer to launch time. +---@param spec { +--- title: string, +--- deps: string[], +--- registry: table|nil, +--- package_of: fun(name: string): string|nil, +--- binaries_of: fun(name: string, pkg: table|nil): string[], +--- unknown_of?: fun(name: string): boolean, +--- has_local_config?: fun(name: string): boolean, +--- local_config_mode?: "resolves"|"validates", +--- configure?: fun(name: string), +---} +function M.resolve(spec) + local ok_settings, settings = pcall(require, "core.settings") + local timeout_ms = ( + ok_settings + and type(settings.tool_install_timeout) == "number" + and settings.tool_install_timeout > 0 + ) + and settings.tool_install_timeout + or 300000 + -- `false` keys a (defensive) titleless spec without poisoning the table with nil. + local collector_key = spec.title or false + local collector = collectors_by_title[collector_key] + if not collector then + collector = missing_collector(spec.title, timeout_ms) + collectors_by_title[collector_key] = collector + end + + -- The registry may be a value, nil, or a lazy thunk. Resolved (memoized) only + -- on first need, so a fully-provisioned subsystem never loads Mason at all. + local registry_source = spec.registry + local registry_pending = type(registry_source) == "function" + local registry_value = not registry_pending and registry_source or nil + local function get_registry() + if registry_pending then + registry_pending = false + local ok, resolved = pcall(registry_source) + registry_value = ok and resolved or nil + end + return registry_value + end + + -- Strip the "chunkname:line: " prefix a bare error(msg) adds. + local function strip_position(err) + if type(err) ~= "string" then + return nil + end + return (err:gsub("^[^\n]-:%d+: ", "")) + end + + ---Configure one tool; false plus the cleaned error when the config threw. + local function try_configure(name) + if type(spec.configure) ~= "function" then + return true + end + local ok, err = pcall(spec.configure, name) + if ok then + return true + end + return false, strip_position(err) + end + + -- Configure one tool, surfacing a config-time error in the aggregated warning. + local function do_configure(name) + local ok, reason = try_configure(name) + if not ok then + collector.mark(name, reason) + end + end + + -- Phase-1 "validates" failures, so phase 2 surfaces them without + -- re-running the config; `false` = failed with no message. + local validate_failed = {} + + -- Install `pkg`, configure on completion; failures keep the phase-1 reason. + local function start_install(pkg, name) + local reason = type(validate_failed[name]) == "string" and validate_failed[name] or nil + collector.track(pkg, name, function() + 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) + local bins = spec.binaries_of(name, nil) or {} + if M.find_executable(bins) ~= nil then + do_configure(name) + return true + end + -- Gated on "resolves": a binary-less LSP server (e.g. jsonls) still maps + -- to a package by NAME and must reach phase 2 to install. + if + #bins == 0 + and spec.local_config_mode == "resolves" + and spec.has_local_config + and spec.has_local_config(name) + then + do_configure(name) + return true + end + -- A "validates" config is its own resolver: let it try; on failure + -- remember the error for phase 2 instead of re-running it there. + if spec.local_config_mode == "validates" and spec.has_local_config and spec.has_local_config(name) then + local ok, reason = try_configure(name) + if ok then + return true + end + validate_failed[name] = reason or false + end + return false + end + + ---Phase 2 — resolve a dep against the now-ready registry: configure an + ---installed package, install a missing one, or mark it missing/unknown. + ---@param name string + ---@param registry table|nil + local function resolve_missing(name, registry) + -- Judged after the registry refresh: unknown_of may consult the Mason + -- mapping, which is empty on a never-bootstrapped registry. + if spec.unknown_of and spec.unknown_of(name) then + collector.mark_unknown(name) + return + end + + local bins = spec.binaries_of(name, nil) or {} + local pkg, pkg_unknown = nil, false + local pkg_name = spec.package_of(name, registry) + if pkg_name and registry then + local ok, resolved = pcall(registry.get_package, pkg_name) + if ok then + pkg = resolved + else + -- Stale mapping; reported only if nothing below resolves the tool. + pkg_unknown = true + end + end + + -- The config already ran and failed this pass; re-running would fail + -- identically. Only an install can change the outcome. + if validate_failed[name] ~= nil then + if pkg ~= nil and not pkg:is_installed() then + start_install(pkg, name) -- annotates the failure reason itself + else + collector.mark(name, type(validate_failed[name]) == "string" and validate_failed[name] or nil) + end + return + end + + -- Installed via Mason but its binary name differs from the phase-1 probe. + if pkg ~= nil and pkg:is_installed() then + do_configure(name) + 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 #bins == 0 then + collector.mark_unknown(pkg_name == name and name or (pkg_name .. " (for " .. name .. ")")) + else + collector.mark(name) + end + end + + -- Whether the registry specs are on disk. On API drift err toward refreshing: + -- a redundant refresh is a no-op, a skipped one loses every auto-install. + local function registry_bootstrapped(registry) + if not registry or registry.sources == nil then + return false + end + local ok, all_installed = pcall(function() + return registry.sources:is_all_installed() + end) + if not ok then + return false + end + return all_installed == true + end + + local function run() + -- A non-table deps (user override gone wrong) degrades to "nothing to resolve". + if type(spec.deps) ~= "table" then + collector.done() + return + end + -- Phase 1: configure everything resolvable without the registry. + local visited = {} + local unresolved = {} + -- maxn, not ipairs: a nil hole must skip a slot, not end resolution. + for i = 1, table.maxn(spec.deps) do + local name = spec.deps[i] + -- Dedup (a duplicate would double-install); pcall isolates each dep. + if name ~= nil and not visited[name] then + visited[name] = true + local ok, handled = pcall(configure_available, name) + if not ok then + collector.mark(name, strip_position(handled)) + elseif handled ~= true then + unresolved[#unresolved + 1] = name + end + end + end + + -- Nothing left to install: Mason stays unloaded. + if #unresolved == 0 then + collector.done() + return + end + + -- Phase 2: resolve the leftovers against the registry, refreshing a + -- never-bootstrapped one first (nothing works without its specs on disk). + local registry = get_registry() + local function finish() + for _, name in ipairs(unresolved) do + local ok, err = pcall(resolve_missing, name, registry) + if not ok then + collector.mark(name, strip_position(err)) + end + end + collector.done() + end + if registry and type(registry.refresh) == "function" and not registry_bootstrapped(registry) then + -- A stalled refresh() never calls back: arm a deadline that reports the + -- leftovers; `settled` makes callback and deadline mutually exclusive. + local settled = false + local function on_refreshed() + if settled then + return + end + settled = true + finish() + end + vim.defer_fn(function() + if settled then + return + end + settled = true + for _, name in ipairs(unresolved) do + local reason = type(validate_failed[name]) == "string" and validate_failed[name] + or "Mason registry refresh did not complete (cannot classify or auto-install)" + collector.mark(name, reason) + end + collector.done() + end, timeout_ms) + -- pcall 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 + else + finish() + end + end + + run() +end + +---Discovery-first resolution for a subsystem whose own runtime registrations +---are the ground truth (conform, nvim-lint). `probe(name)` returns: +--- * nil -> unknown name (typo) -> fix config. +--- * { binary = "x" } -> the tool invokes executable "x". +--- * { binary = nil } -> the tool resolves its own command at runtime. +--- * { broken = "reason" } -> config exists but errors: surfaced with the +--- reason — never typo guidance, never an install. +---Mason is only the lazy install fallback, reverse-looked-up from the binary. +---@param title string @Notification title identifying the subsystem. +---@param deps string[] @Tool names as the subsystem knows them. +---@param probe fun(name: string): { binary: string|nil, broken: string|nil }|nil +---@param configure? fun(name: string) @Optional: run for each available/local tool +--- (e.g. rewrite its command to an absolute path under Mason `PATH = "skip"`). +function M.resolve_runtime_tools(title, deps, probe, configure) + local cache = {} + local function info(name) + if cache[name] == nil then + local ok, result = pcall(probe, name) + if ok and type(result) == "table" then + cache[name] = { + known = true, + binary = type(result.binary) == "string" and result.binary or nil, + broken = type(result.broken) == "string" and result.broken or nil, + } + else + -- A probe error is treated as unknown: either way, fix the config entry. + cache[name] = { known = false } + end + end + return cache[name] + end + + M.resolve({ + title = title, + deps = deps, + -- Lazy thunk so a fully-provisioned setup never loads mason-registry. + registry = function() + local ok, resolved = pcall(require, "mason-registry") + return ok and resolved or nil + end, + package_of = function(name, registry) + local binary = info(name).binary + if not registry or not binary then + return nil + end + return package_for_binary(registry, binary) + end, + binaries_of = function(name) + local binary = info(name).binary + return binary and { binary } or {} + end, + unknown_of = function(name) + return not info(name).known + end, + has_local_config = function(name) + -- A broken config counts as local so configure below surfaces its reason. + local i = info(name) + return i.known and i.binary == nil + end, + -- A binary-less runtime tool can't map to a package, so it self-resolves. + local_config_mode = "resolves", + configure = function(name) + -- A broken config must never configure: error with the probe's reason so + -- the resolver marks the tool with it. + local reason = info(name).broken + if reason then + error(reason, 0) + end + if type(configure) == "function" then + return configure(name) + end + end, + }) +end + +return M diff --git a/vim.yml b/vim.yml index 72501f58..2504ced1 100644 --- a/vim.yml +++ b/vim.yml @@ -6,3 +6,13 @@ globals: any: true _debugging: any: true + # LuaJIT ships the Lua 5.2 extension package.searchpath; the lua51 base + # selene std lib doesn't know it. + package.searchpath: + args: + - type: string + - type: string + - type: string + required: false + - type: string + required: false