From 53c107979d681d378b0f718640d034b1b204a5e6 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Thu, 2 Jul 2026 14:49:53 +0800 Subject: [PATCH 1/4] feat(utils): add discovery-first tool resolution helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce modules/utils/tools.lua: a shared layer that treats Mason as one installer backend rather than a hard requirement. Provides any_executable ($PATH probe), package_binaries (a package's declared binaries), and missing_collector — a per-subsystem aggregator that reports tools which could not be set up in a single deferred warning. The collector has two buckets: mark (a real tool that isn't available — install it) and mark_unknown (a name the installer registry doesn't recognize — a typo or outdated name to fix in config), rendered as separate cause-appropriate sections. track() performs the async install itself under pcall, so a package whose install() errors or returns a bad handle is recorded as missing instead of aborting the caller's resolution loop; its "closed" handler runs on the main loop via vim.schedule_wrap (Mason fires it from a fast event context) and pcalls recheck() so a throwing recheck can't leave the warning permanently suppressed. --- lua/modules/utils/tools.lua | 171 ++++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 lua/modules/utils/tools.lua diff --git a/lua/modules/utils/tools.lua b/lua/modules/utils/tools.lua new file mode 100644 index 00000000..2ed84f96 --- /dev/null +++ b/lua/modules/utils/tools.lua @@ -0,0 +1,171 @@ +-- Discovery-first tool resolution helpers (RFC: ayamir/nvimdots#1293). +-- +-- The config treats Mason as one *installer backend*, not a hard requirement. +-- For every external tool (LSP server, formatter, linter, DAP adapter) the +-- resolution order is: already on $PATH (system / Mason) → installable via +-- Mason → otherwise surfaced to the user. These helpers provide the shared +-- $PATH check and a per-subsystem warning aggregator so that "please install +-- this yourself" is reported once, not once per missing tool. +local M = {} + +---Return true if any of the given executable names is found on $PATH. +---@param names string|string[] @A single executable name or a list of them. +---@return boolean +function M.any_executable(names) + if type(names) == "string" then + names = { names } + end + for _, name in ipairs(names) do + if type(name) == "string" and name ~= "" and vim.fn.executable(name) == 1 then + return true + end + end + return false +end + +---Create a collector that aggregates tools which could not be set up automatically +---into a single deferred warning. This avoids spamming one notification per tool. +---Entries fall into two classes, rendered as separate sections so the guidance +---matches the cause: +--- * `mark` — a tool that couldn't be set up: not available (install it / +--- put it on $PATH) or its configuration failed. +--- * `mark_unknown` — a name the installer registry doesn't recognize (likely a +--- typo or an outdated name; may be a package, server, or +--- adapter name); the fix is to correct the config, not a +--- manual install. +--- +---Usage: +--- local c = tools.missing_collector("LSP") +--- c.mark("shuck") -- unresolved tool (sync) +--- c.mark_unknown("gpls") -- unknown / typo'd name (sync) +--- c.track(pkg, "gopls", recheck) -- async install; recheck() => available? +--- c.done() -- flush (handles the no-async case) +---@param title string @Notification title identifying the subsystem. +---@return { mark: fun(name: string), mark_unknown: fun(name: string), track: fun(pkg: table, name: string, recheck: fun(): boolean), done: fun() } +function M.missing_collector(title) + local missing = {} + local unknown = {} + local seen = {} + local pending = 0 + local flushed = false + + -- Record a name into a bucket once: ignore non-strings/empties and de-duplicate + -- (across both buckets) so the aggregated notification stays stable regardless + -- of how callers invoke it. + local function record(bucket, name) + if type(name) ~= "string" or name == "" or seen[name] then + return + end + seen[name] = true + bucket[#bucket + 1] = name + end + local function add(name) + record(missing, name) + end + local function add_unknown(name) + record(unknown, name) + end + + local function flush() + if flushed or pending > 0 then + return + end + flushed = true + if #missing == 0 and #unknown == 0 then + return + end + local sections = {} + if #missing > 0 then + table.sort(missing) + 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(missing, "\n • ") + end + if #unknown > 0 then + table.sort(unknown) + sections[#sections + 1] = "The following names are not recognized by the installer registry\n" + .. "(likely a typo or an outdated name) — correct or remove them from your\n" + .. "config:\n • " + .. table.concat(unknown, "\n • ") + end + local message = table.concat(sections, "\n\n") + vim.schedule(function() + vim.notify(message, vim.log.levels.WARN, { title = title }) + end) + end + + return { + ---Record a tool that could not be resolved (not installable / not confirmed). + mark = add, + ---Record a name the installer registry doesn't recognize (typo / outdated name). + mark_unknown = add_unknown, + ---Track an async Mason install for `pkg`; `recheck()` must report final + ---availability. The install call is guarded: if `pkg:install()` errors or + ---returns a handle without `:once`, the tool is recorded as missing instead + ---of aborting the caller's resolution loop (keeps "Mason is optional" robust). + track = function(pkg, name, recheck) + local ok, handle = pcall(function() + return pkg:install() + end) + if not ok or type(handle) ~= "table" or type(handle.once) ~= "function" then + add(name) + return + end + pending = pending + 1 + -- Mason fires "closed" from a luv callback (fast event context) where + -- Vim APIs used by recheck() (e.g. vim.fn.executable) are unsafe; run the + -- handler on the main loop via vim.schedule_wrap. recheck() is pcall'd and + -- pending is decremented unconditionally so a throwing recheck can't leave + -- pending stuck > 0 and permanently suppress the aggregated warning. + handle:once( + "closed", + vim.schedule_wrap(function() + local rc_ok, available = pcall(recheck) + if not rc_ok or not available then + add(name) + end + pending = pending - 1 + flush() + end) + ) + end, + ---Flush the aggregated warning once all tracked installs have settled. + done = function() + flush() + end, + } +end + +---Collect the executable name(s) a Mason package provides, from its spec. +---Falls back to the package name when the spec declares no `bin` table. +---@param pkg table @A mason-registry Package object. +---@param fallback string @Name to use when `pkg.spec.bin` 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 = { fallback } + end + return bins +end + +---Mason's package install root, or nil when Mason isn't available. Prefers the +---settings API (authoritative and present as soon as mason is required) over the +---`$MASON` env var, which is only set as a side effect of `mason.setup()` and may +---not be exported at all. +---@return string|nil +function M.mason_root() + local ok, settings = pcall(require, "mason.settings") + if ok and settings.current and type(settings.current.install_root_dir) == "string" then + return settings.current.install_root_dir + end + return vim.env.MASON +end + +return M From 5d739685a8fdb1ba65e7171a04605e1cdd6efcdd Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Thu, 2 Jul 2026 14:49:54 +0800 Subject: [PATCH 2/4] refactor(lsp): resolve language servers discovery-first Drive LSP server setup discovery-first via the shared tools helper: a server already on $PATH (system / Nix / Mason) is configured as-is; otherwise Mason installs it when it ships a package; a name the registry doesn't recognize is surfaced as an unknown-name config error. Make the mason-registry requires optional so a Mason-less setup still configures system-provided servers. Fold external_lsp_deps into lsp_deps (shuck now lives there) and clarify that nixd / nil_ls are resolved from $PATH first, else Mason-installed, since the resolver no longer needs a separate "configure but don't install" list. --- lua/core/settings.lua | 21 ++-- lua/modules/configs/completion/lsp.lua | 26 +--- .../configs/completion/mason-lspconfig.lua | 115 +++++++++++++----- .../configs/completion/servers/shuck.lua | 6 +- 4 files changed, 100 insertions(+), 68 deletions(-) diff --git a/lua/core/settings.lua b/lua/core/settings.lua index b4a8c812..9706b56d 100644 --- a/lua/core/settings.lua +++ b/lua/core/settings.lua @@ -84,18 +84,12 @@ settings["external_browser"] = "chrome-cli open" ---@type boolean settings["lsp_inlayhints"] = false --- LSPs installed outside Mason (e.g. via system package manager). --- These will be configured but not installed by Mason. --- Key: lspconfig server name, Value: executable name to check availability. ----@type table -settings["external_lsp_deps"] = { - nixd = "nixd", - nil_ls = "nil", - shuck = "shuck", -- shell linter/formatter/LSP (Rust); installed via mise, not Mason - -- dartls = "dart", -} - --- LSPs to install during bootstrap. +-- Language servers to enable. One flat list — no need to decide up front whether +-- a server is "Mason-managed" or "system-provided". At runtime each entry is +-- resolved discovery-first: if its binary is already on $PATH (system / Nix / +-- Mason) it is used as-is; otherwise Mason installs it when it ships a package; +-- otherwise the user is asked to install it manually. 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"] = { @@ -111,7 +105,10 @@ settings["lsp_deps"] = { "lua_ls", "marksman", "neocmake", + "nil_ls", -- Nix LSP; Nix-provided on $PATH (no Mason mapping, else flagged for manual install) + "nixd", -- Nix LSP; Nix-provided on $PATH (no Mason mapping, else flagged for manual install) "ruff", + "shuck", -- shell linter/formatter/LSP (Rust); installed via mise, not Mason "systemd_lsp", "terraformls", "tflint", diff --git a/lua/modules/configs/completion/lsp.lua b/lua/modules/configs/completion/lsp.lua index 866260b3..428974be 100644 --- a/lua/modules/configs/completion/lsp.lua +++ b/lua/modules/configs/completion/lsp.lua @@ -1,29 +1,9 @@ return function() + -- Server resolution (Mason-installed / on $PATH / installable / missing) is + -- handled centrally and discovery-first in `mason-lspconfig.setup`, driven by + -- the single `settings.lsp_deps` list. require("completion.mason-lspconfig").setup() - local opts = { - capabilities = require("modules.utils").get_lsp_capabilities(), - } - -- Configure LSPs that are not managed by Mason but are available in `nvim-lspconfig`. - -- Servers are defined in `settings.external_lsp_deps` as { server_name = "executable" }. - for lsp_name, exe in pairs(require("core.settings").external_lsp_deps) do - if vim.fn.executable(exe) == 1 then - local ok, _opts = pcall(require, "user.configs.lsp-servers." .. lsp_name) - if not ok then - local default_ok, default_opts = pcall(require, "completion.servers." .. lsp_name) - if default_ok then - _opts = default_opts - end - end - if type(_opts) == "table" then - local final_opts = vim.tbl_deep_extend("keep", _opts, opts) - require("modules.utils").register_server(lsp_name, final_opts) - else - require("modules.utils").register_server(lsp_name, opts) - end - end - end - 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..20dadbd5 100644 --- a/lua/modules/configs/completion/mason-lspconfig.lua +++ b/lua/modules/configs/completion/mason-lspconfig.lua @@ -2,14 +2,12 @@ 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, - }) + -- 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") vim.diagnostic.config({ signs = true, @@ -81,37 +79,94 @@ please REMOVE your LSP configuration (rust_analyzer.lua) from the `servers` dire 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 = {} + -- Map lspconfig server name -> Mason package name (only when Mason is present). + -- Used to tell whether a server has a Mason package at all, and to drive + -- installs. Build by hand if the builtin map is unavailable. + local lspconfig_to_package = {} + if mason_ok then + local mappings = mason_lspconfig.get_mappings() + lspconfig_to_package = (mappings and mappings.lspconfig_to_package) or {} + if vim.tbl_isempty(lspconfig_to_package) then 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 + local server = vim.tbl_get(spec, "neovim", "lspconfig") + if server then + lspconfig_to_package[server] = spec.name end end end - -- Figure out the package name and lookup - local name = type(pkg) == "string" and pkg or pkg.name - local srv = mappings[name] - if not srv then - return - end + -- Load mason-lspconfig for the lspconfig integration only. Installs are + -- driven explicitly below (discovery-first) so they degrade gracefully where + -- Mason can't help (BSD/NixOS/...), instead of being gated on the 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 - -- Invoke the handler - mason_lsp_handler(srv) + ---Resolve a server's launch binary for the $PATH probe. Prefer an explicit + ---`cmd` from the manual spec (user override, then repo default under + ---`completion/servers/`), then fall back to nvim-lspconfig's default `cmd`. + ---This way a server defined only via a local spec (with no nvim-lspconfig + ---entry) is still discoverable on $PATH. + ---@param name string + ---@return string|nil + local function server_binary(name) + for _, module in ipairs({ "user.configs.lsp-servers." .. name, "completion.servers." .. name }) do + local ok, spec = pcall(require, module) + if ok and type(spec) == "table" and type(spec.cmd) == "table" then + return spec.cmd[1] + end + end + local ok, config = pcall(function() + return vim.lsp.config[name] + end) + if ok and type(config) == "table" and type(config.cmd) == "table" then + return config.cmd[1] + end + return nil end - for _, pkg in ipairs(mason_registry.get_installed_package_names()) do - setup_lsp_for_package(pkg) + -- Discovery-first resolution per desired server: + -- 1. Mason-installed OR binary already on $PATH -> configure now. + -- 2. Not available but Mason ships a package -> install, configure next launch. + -- 3. No Mason package and not on $PATH -> ask the user to install it. + local collector = tools.missing_collector("LSP") + + for _, name in ipairs(lsp_deps) do + local pkg_name = mason_ok and lspconfig_to_package[name] or nil + local installed = pkg_name ~= nil and mason_registry.is_installed(pkg_name) + local binary = server_binary(name) + local on_path = binary ~= nil and vim.fn.executable(binary) == 1 + + if installed or on_path then + -- Guard the handler: a user/server spec that errors during setup would + -- otherwise abort the whole loop (skipping the remaining servers and + -- collector.done()). Degrade by marking just that server. + if not pcall(mason_lsp_handler, name) then + collector.mark(name) + end + elseif pkg_name ~= nil then + local ok, pkg = pcall(mason_registry.get_package, pkg_name) + if ok then + collector.track(pkg, name, function() + return pkg:is_installed() or (binary ~= nil and vim.fn.executable(binary) == 1) + end) + else + -- The server maps to a Mason package the registry doesn't have + -- (stale mapping / registry skew). The lookup that failed was for + -- `pkg_name`, so record that (annotated with the server) rather than + -- `name` — otherwise the warning wrongly implicates the user's + -- lsp_deps entry when the mapping/registry is at fault. + collector.mark_unknown(pkg_name == name and name or (pkg_name .. " (for " .. name .. ")")) + end + else + collector.mark(name) + end end + + collector.done() end return M diff --git a/lua/modules/configs/completion/servers/shuck.lua b/lua/modules/configs/completion/servers/shuck.lua index 95319fbe..74df1916 100644 --- a/lua/modules/configs/completion/servers/shuck.lua +++ b/lua/modules/configs/completion/servers/shuck.lua @@ -1,9 +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. +-- Installed via mise (`cargo:shuck-cli`), not Mason. It has no Mason package, so +-- its `lsp_deps` entry is resolved discovery-first from $PATH. cmd/filetypes/ +-- root_markers are declared here so it works regardless of nvim-lspconfig. -- -- Provides live diagnostics, code actions (incl. `source.fixAll.shuck`), -- suppression-code hover, and document/range formatting over LSP. From f8185f396dfcd1e479d32a26b06d274353f0429c Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Thu, 2 Jul 2026 14:49:54 +0800 Subject: [PATCH 3/4] refactor(mason): resolve formatters/linters discovery-first Only install formatters/linters that aren't already on $PATH, so systems that provide their own tools (NixOS/BSD) aren't nagged every startup. Unknown Mason package names (typo / removed) are reported via the collector's mark_unknown bucket so the warning points at the config, not a manual install. --- lua/modules/configs/completion/mason.lua | 36 +++++++++++++----------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/lua/modules/configs/completion/mason.lua b/lua/modules/configs/completion/mason.lua index 450cdefa..75dd6d67 100644 --- a/lua/modules/configs/completion/mason.lua +++ b/lua/modules/configs/completion/mason.lua @@ -27,35 +27,39 @@ M.setup = function() }, }) - -- Ensure formatters and linters are installed (only if mason loaded) + -- Ensure formatters and linters are available (only if mason loaded) local ok, registry = pcall(require, "mason-registry") if not ok then return end local settings = require("core.settings") + local tools = require("modules.utils.tools") local ensure_installed = vim.list_extend(vim.deepcopy(settings.formatter_deps), settings.linter_deps) + -- Discovery-first: only install what isn't already on $PATH, so systems that + -- provide their own tools (NixOS/BSD/...) aren't nagged every startup. What + -- Mason genuinely can't provide is collected into a single warning. + local collector = tools.missing_collector("Mason") + 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) + -- No such Mason package (typo / removed): Mason can't provide it and the + -- package name isn't a reliable executable to probe. Surface it as an + -- unknown name so the warning points at the config, not a manual install. + collector.mark_unknown(pkg_name) + else + local binaries = tools.package_binaries(pkg, pkg_name) + if not (pkg:is_installed() or tools.any_executable(binaries)) then + collector.track(pkg, pkg_name, function() + return pkg:is_installed() or tools.any_executable(binaries) + end) + end end end + + collector.done() end return M From f541073c7730d543235624760b0e0fb2ce66cdc5 Mon Sep 17 00:00:00 2001 From: charliie-dev Date: Thu, 2 Jul 2026 18:21:28 +0800 Subject: [PATCH 4/4] refactor(dap): resolve debug adapters discovery-first Resolve debug adapters discovery-first through the shared helper and make the mason-registry / mason-nvim-dap requires optional, so a Mason-less setup still configures adapters that resolve their own binary (client configs / $PATH). Configure an adapter as soon as it has a client config (its own discovery-first resolver) rather than waiting on the Mason install, so e.g. python debugging via system debugpy works this session. On a Mason-less setup, best-effort validate a table adapter's resolved command and surface it as missing if empty/not on $PATH (e.g. codelldb via vim.fn.exepath). Make the delve client Mason-agnostic: guard mason-registry, only auto-install when go-debug-adapter is neither installed nor on $PATH, return after kicking off an install, and error (marking delve missing) when the bundle or a `node` runtime can't be resolved. Resolve debugpy without Mason in the python client, probing python interpreters instead of assuming one. Resolve Mason's install root via mason.settings (with a $MASON fallback) rather than the env var alone. --- .../configs/tool/dap/clients/delve.lua | 49 +++++- .../configs/tool/dap/clients/python.lua | 41 +++++- lua/modules/configs/tool/dap/init.lua | 139 +++++++++++++++++- lua/modules/plugins/tool.lua | 5 + 4 files changed, 217 insertions(+), 17 deletions(-) diff --git a/lua/modules/configs/tool/dap/clients/delve.lua b/lua/modules/configs/tool/dap/clients/delve.lua index b1a70b3c..390c1a73 100644 --- a/lua/modules/configs/tool/dap/clients/delve.lua +++ b/lua/modules/configs/tool/dap/clients/delve.lua @@ -4,14 +4,28 @@ return function() local dap = require("dap") local utils = require("modules.utils.dap") - if not require("mason-registry").is_installed("go-debug-adapter") then + -- go-debug-adapter is a node-based adapter that in practice ships via Mason. + -- Mason is optional here: guard the registry require so a Mason-less setup + -- doesn't hard-error on the require itself. When the adapter genuinely can't be + -- resolved this config raises a clear error on purpose (see below) so the + -- resolver's pcall marks `delve` missing and surfaces it in the aggregated + -- warning — it does not silently leave the adapter unset. + local has_registry, registry = pcall(require, "mason-registry") + if has_registry and not registry.is_installed("go-debug-adapter") then + -- get_package throws when the name isn't in the registry (registry skew). + -- Guard it so that turns into a clear, actionable error the resolver's pcall + -- can surface, rather than a generic "missing" with the install path skipped. + local ok, go_dbg = pcall(registry.get_package, "go-debug-adapter") + if not ok then + error("go-debug-adapter is not in the Mason registry (registry outdated?); run :MasonUpdate") + end + vim.notify( "Automatically installing `go-debug-adapter` for go debugging", vim.log.levels.INFO, { title = "nvim-dap" } ) - local go_dbg = require("mason-registry").get_package("go-debug-adapter") go_dbg:install():once( "closed", vim.schedule_wrap(function() @@ -20,14 +34,39 @@ return function() end end) ) + -- The install was just kicked off (user informed via the INFO notification + -- above) and its bundle won't exist until it finishes. Configure on the next + -- launch rather than erroring below and being flagged "missing" while it's + -- already installing. + return end + -- Reached only when Mason is absent or go-debug-adapter is already installed. + -- Resolve the node bundle; if it isn't readable (Mason unavailable, or the + -- package reported installed but its bundle is missing/moved/corrupted), error + -- out instead of returning with `dap.adapters.go` unset. The resolver in + -- `tool/dap/init.lua` runs this under pcall, so throwing lets it mark `delve` + -- as missing rather than silently leaving Go debugging broken. + local mason_root = require("modules.utils.tools").mason_root() + local adapter_js = mason_root and (mason_root .. "/packages/go-debug-adapter/extension/dist/debugAdapter.js") + if not (adapter_js and vim.fn.filereadable(adapter_js) == 1) then + error( + "go-debug-adapter bundle not found" + .. (adapter_js and (" at " .. adapter_js) or " ($MASON is unset)") + .. "; install `go-debug-adapter` via Mason or provide its bundle on this path" + ) + end + -- The bundle is a Node script, so it needs `node` on $PATH. Validate it up + -- front and error out if absent: otherwise the adapter configures fine (so the + -- resolver won't flag `delve`) but debugging fails at runtime. Throwing lets + -- `tool/dap/init.lua`'s pcall surface it via the aggregated missing warning. + if vim.fn.executable("node") == 0 then + error("go-debug-adapter requires `node` on $PATH, which was not found") + end dap.adapters.go = { type = "executable", command = "node", - args = { - vim.env.MASON .. "/packages/go-debug-adapter" .. "/extension/dist/debugAdapter.js", - }, + args = { adapter_js }, } dap.configurations.go = { { diff --git a/lua/modules/configs/tool/dap/clients/python.lua b/lua/modules/configs/tool/dap/clients/python.lua index 688256b4..1684e7c1 100644 --- a/lua/modules/configs/tool/dap/clients/python.lua +++ b/lua/modules/configs/tool/dap/clients/python.lua @@ -4,7 +4,40 @@ return function() local dap = require("dap") local utils = require("modules.utils.dap") local is_windows = require("core.global").is_windows - local debugpy_root = vim.env.MASON .. "/packages/debugpy" + -- Mason's install root (nil when Mason isn't available); guard it so a + -- Mason-less setup falls through to $PATH / system python. + local mason_root = require("modules.utils.tools").mason_root() + local debugpy_root = mason_root and (mason_root .. "/packages/debugpy") or nil + + -- Resolve the debugpy adapter command discovery-first: prefer Mason's managed + -- venv, then a `debugpy-adapter` on $PATH, then a system python running the + -- debugpy module. This keeps python debugging working without Mason (BSD/NixOS), + -- where the hard-coded Mason path would otherwise point at a missing directory. + local function debugpy_command() + if debugpy_root then + local mason_python = is_windows and debugpy_root .. "/venv/Scripts/pythonw.exe" + or debugpy_root .. "/venv/bin/python" + if vim.fn.executable(mason_python) == 1 then + return mason_python, { "-m", "debugpy.adapter" } + end + end + if vim.fn.executable("debugpy-adapter") == 1 then + return "debugpy-adapter", {} + end + -- Last resort: a python interpreter on $PATH that can run debugpy. Probe + -- candidates rather than hard-coding one, so we don't hand nvim-dap a + -- command that isn't installed (e.g. pythonw.exe is often absent on a + -- Windows box that only has python.exe / python). + local candidates = is_windows and { "pythonw.exe", "python.exe", "python" } or { "python3", "python" } + for _, py in ipairs(candidates) do + if vim.fn.executable(py) == 1 then + return py, { "-m", "debugpy.adapter" } + end + end + -- None resolved; return the conventional interpreter name so a failure at + -- least names a real command rather than a windowless-only pythonw.exe. + return candidates[#candidates], { "-m", "debugpy.adapter" } + end dap.adapters.python = function(callback, config) if config.request == "attach" then @@ -17,11 +50,11 @@ return function() options = { source_filetype = "python" }, }) else + local command, args = debugpy_command() 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 diff --git a/lua/modules/configs/tool/dap/init.lua b/lua/modules/configs/tool/dap/init.lua index 1e998f8b..99a7c940 100644 --- a/lua/modules/configs/tool/dap/init.lua +++ b/lua/modules/configs/tool/dap/init.lua @@ -1,7 +1,10 @@ return function() local dap = require("dap") local dapui = require("dapui") - local mason_dap = require("mason-nvim-dap") + -- Mason is an optional installer backend: guard its require so a Mason-less + -- setup still configures adapters that resolve their own binary (client + -- configs / $PATH) instead of hard-erroring here. + 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() @@ -60,8 +63,11 @@ return function() ok, custom_handler = pcall(require, "tool.dap.clients." .. dap_name) end if not ok then - -- Default to use factory config for clients(s) that doesn't include a spec - mason_dap.default_setup(config) + -- Default to Mason's factory config for clients without a spec, only when + -- mason-nvim-dap is available. + if has_mason_dap then + mason_dap.default_setup(config) + end return elseif type(custom_handler) == "function" then -- Case where the protocol requires its own setup @@ -83,9 +89,126 @@ 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") + local tools = require("modules.utils.tools") + + -- Mason-driven bits (source/adapter mappings + install) are only available when + -- Mason is. Without it we still configure adapters that resolve their own binary + -- (client configs / $PATH). Either way we drive setup discovery-first rather than + -- letting mason-nvim-dap gate configuration on its 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, + }) + source_map = require("mason-nvim-dap.mappings.source") + adapters_map = require("mason-nvim-dap.mappings.adapters") + configs_map = require("mason-nvim-dap.mappings.configurations") + filetypes_map = require("mason-nvim-dap.mappings.filetypes") + end + + ---Does an explicit client config exist for this adapter (system-resolved)? + ---@param name string + ---@return boolean + local function has_client_config(name) + for _, module in ipairs({ "user.configs.dap-clients." .. name, "tool.dap.clients." .. name }) do + if pcall(require, module) then + return true + end + end + return false + end + + ---Configure an adapter via the shared handler (client config or default_setup). + ---@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 + + ---Best-effort launch command for a configured adapter, for a post-config + ---sanity check. Only table adapters expose a static command (executable-type + ---at `.command`, server-type at `.executable.command`); function adapters + ---resolve lazily at debug time, so they return nil (not checkable here). + ---@param adapter any @A `dap.adapters[...]` entry. + ---@return string|nil + local function adapter_command(adapter) + if type(adapter) ~= "table" then + return nil + end + if type(adapter.executable) == "table" then + return adapter.executable.command + end + return adapter.command + end + + -- Discovery-first resolution per desired adapter: + -- * Mason ships a package but it isn't available yet -> install (next launch). + -- * Available now (Mason-installed / on $PATH) or has a client config -> configure. + -- * Neither -> ask the user to install it. + -- nvim-dap has no uniform command registry like nvim-lspconfig, so $PATH + -- detection leans on the Mason package's declared binaries; client configs + -- without a Mason package resolve their own binary via `vim.fn.exepath`. + local collector = tools.missing_collector("DAP") + + for _, name in ipairs(settings.dap_deps) do + local pkg_name = source_map.nvim_dap_to_package[name] + local pkg + if pkg_name then + local ok, resolved = pcall(registry.get_package, pkg_name) + pkg = ok and resolved or nil + end + + local binaries = pkg ~= nil and tools.package_binaries(pkg, pkg_name) or nil + local installed = pkg ~= nil and pkg:is_installed() + local on_path = binaries ~= nil and tools.any_executable(binaries) + local available = installed or on_path + + -- Install via Mason when it ships a package that isn't available yet. The + -- install runs in the background and only updates the missing-tool warning + -- on completion — it does not configure the adapter. An adapter without a + -- client config is therefore configured on a later launch (once its binary + -- is available); client-config adapters are configured immediately below. + if pkg ~= nil and not available then + collector.track(pkg, name, function() + return pkg:is_installed() or tools.any_executable(binaries) + end) + end + + -- Configure now whenever we can: an explicit client config is the adapter's + -- own discovery-first resolver (Mason venv / $PATH / system), and an + -- available Mason adapter uses the default handler. This no longer waits on + -- the Mason install, so a client config that resolves its own binary (e.g. + -- python via system debugpy) works this session. Guarded so one failing + -- setup can't abort the rest. + if available or has_client_config(name) then + if not pcall(configure_adapter, name) then + collector.mark(name) + elseif pkg == nil then + -- Configured via a client config on a Mason-less setup (no install to + -- fall back on). A table adapter that resolves its command via $PATH + -- (e.g. codelldb through vim.fn.exepath) can silently end up with an + -- empty/unresolved command, giving neither a working adapter nor a + -- warning. Best-effort: if the adapter under this name has a static + -- command that isn't on $PATH, surface it as missing. + local cmd = adapter_command(dap.adapters[name]) + if cmd ~= nil and (cmd == "" or vim.fn.executable(cmd) == 0) then + collector.mark(name) + end + end + elseif pkg == nil then + -- No Mason package and no client config: nothing can set it up. + collector.mark(name) + end + end + + collector.done() end diff --git a/lua/modules/plugins/tool.lua b/lua/modules/plugins/tool.lua index a5105bb1..be9eb4d0 100644 --- a/lua/modules/plugins/tool.lua +++ b/lua/modules/plugins/tool.lua @@ -107,6 +107,11 @@ tool["mfussenegger/nvim-dap"] = { config = require("tool.dap"), dependencies = { { "jay-babu/mason-nvim-dap.nvim" }, + -- Intentionally NOT depending on mason.nvim here: its config runs the + -- formatter/linter resolver, which would fire as a side effect of merely + -- starting DAP. The DAP resolver pcalls `require("mason-registry")` and + -- degrades to $PATH/client-config resolution when Mason isn't loaded yet; + -- in practice nvim-lspconfig loads mason.nvim on BufReadPre well before DAP. { "rcarriga/nvim-dap-ui", dependencies = "nvim-neotest/nvim-nio",