Skip to content

feat: discovery-first tool resolution (make Mason optional)#21

Open
charliie-dev wants to merge 44 commits into
mainfrom
feat/discovery-first-tooling
Open

feat: discovery-first tool resolution (make Mason optional)#21
charliie-dev wants to merge 44 commits into
mainfrom
feat/discovery-first-tooling

Conversation

@charliie-dev

@charliie-dev charliie-dev commented Jun 30, 2026

Copy link
Copy Markdown
Owner

Feasibility POC — RFC ayamir/nvimdots#1293

Implements the "separate tool discovery from tool installation" idea from this comment: Mason becomes one installer backend, not a hard requirement. On FreeBSD/NixOS/etc. — where Mason's prebuilt binaries don't run — system-provided tools are used directly, with no manual symlinking and no startup notification spam.

No new toggle, no OS detection. Behaviour is driven purely by availability.

Resolution model (per tool)

0. unrecognized name ─────────────────► "fix your config" guidance (never installs)
1. binary on $PATH (system / Mason) ──► configure now, same tick
2. Mason ships a package ─────────────► install in background, configure on completion
3. self-validating local config ──────► configure now; its error() joins the warning
4. neither ───────────────────────────► aggregate into ONE "please install" warning

Applied to LSP, DAP, formatters, and linters through one shared loop (modules/utils/tools.lua). Everything resolvable without an install registers synchronously (lazy-load trigger replay still works); only installs defer.

Changes

Area File What
Helper modules/utils/tools.lua (new) Shared resolution loop: $PATH + Mason-bin-dir probe, install-then-configure tracking, bin→package reverse lookup, per-subsystem warning aggregator (one message, not N) with a configurable deadline (settings.tool_install_timeout)
LSP mason-lspconfig.lua, lsp.lua Cascade resolver driven by lsp_deps; external_lsp_deps merged into lsp_deps; function-cmd servers (e.g. jsonls) probed via the Mason package's declared bins; off-$PATH cmds rewritten to absolute paths before vim.lsp.enable() (covers Mason PATH = "skip")
Formatter/Linter conform.lua, nvim-lint.lua, mason.lua Deps resolved against each plugin's own runtime registry (the ground truth), Mason package reverse-looked-up by binary; mason.lua's ensure_installed loop removed (UI-only now)
DAP dap/init.lua, dap/clients/* Self-driven over dap_deps; mason-nvim-dap's private mappings guarded; client configs self-validate and resolve binaries lazily on the spawn path, so remote attach works without local binaries

settings.lua

  • lsp_deps: absorbs nil_ls, nixd, and shuck — one list, every entry is wanted. Package-less servers (nixd, shuck) are enabled from their $PATH binary; when absent they join the aggregated warning (the fix is provisioning via home-manager/mise, not Mason)
  • external_lsp_deps: removed
  • formatter_deps / linter_deps: entries are now the subsystem's tool names, matching lsp_deps semantics (cmakelangcmake_format, golangci-lintgolangcilint); the Mason package name is derived, not configured
  • tool_install_timeout (new): deadline before the aggregated warning is flushed despite unsettled installs

Verification

  • CI: stylua + selene + nix flake check, green.
  • Headless smoke: config loads and attaches lua_ls via the new resolver, no errors.
  • Copilot review loop run to convergence (all threads resolved, latest round: no new comments).

Known POC limitations

  • DAP $PATH detection leans on the Mason package's declared binaries (nvim-dap has no uniform command registry); adapters without a mason-nvim-dap mapping need a client config.
  • Not yet tested on an actual BSD/NixOS host — that's the next feasibility step.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements a discovery-first tool resolution model so that tools already available on $PATH (system-provided or Mason-provided) are used directly, while Mason becomes an optional installer backend rather than a hard runtime gate. This supports environments like NixOS/FreeBSD where Mason-installed binaries may be unavailable or undesirable.

Changes:

  • Add shared helpers for $PATH probing, Mason spec.bin extraction, and aggregated “missing tools” warnings.
  • Rework LSP and DAP setup to be driven by desired dependency lists with discovery-first resolution and deferred installs.
  • Update Mason bootstrap for formatters/linters to only install when the tool isn’t already on $PATH, aggregating missing-tool warnings.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
lua/modules/utils/tools.lua New helper module for executable discovery + aggregated missing-tool warnings.
lua/modules/configs/tool/dap/init.lua Switch DAP setup to discovery-first resolution instead of mason-nvim-dap’s installed-only handler gating.
lua/modules/configs/completion/servers/shuck.lua Update server comment to reflect the new discovery-first resolution model.
lua/modules/configs/completion/mason.lua Only install formatter/linter packages when not already available on $PATH; aggregate missing warnings.
lua/modules/configs/completion/mason-lspconfig.lua Drive LSP setup via discovery-first resolution + deferred installs, using mason-lspconfig mappings.
lua/modules/configs/completion/lsp.lua Remove external_lsp_deps wiring; rely on centralized discovery-first LSP setup.
lua/core/settings.lua Collapse external/system LSP list into unified lsp_deps list with discovery-first semantics.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lua/modules/configs/completion/mason-lspconfig.lua Outdated
Comment thread lua/core/settings.lua Outdated
Comment thread lua/modules/configs/tool/dap/init.lua Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Comment thread lua/modules/configs/completion/mason-lspconfig.lua Outdated
Comment thread lua/modules/configs/tool/dap/init.lua Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Comment thread lua/modules/configs/tool/dap/init.lua Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Comment thread lua/core/settings.lua Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Comment thread lua/modules/utils/tools.lua Outdated
Comment thread lua/modules/configs/tool/dap/init.lua Outdated
Comment thread lua/modules/configs/tool/dap/init.lua Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.

Comment thread lua/modules/configs/completion/mason-lspconfig.lua Outdated
Comment thread lua/modules/configs/completion/mason.lua Outdated
Comment thread lua/core/settings.lua Outdated
Comment thread lua/modules/utils/tools.lua Outdated
Comment thread lua/modules/utils/tools.lua Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Comment thread lua/modules/utils/tools.lua Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Comment thread lua/modules/utils/tools.lua Outdated
conform wrapped its whole resolve in vim.schedule and had to remember
to pre-call ensure_mason_on_path itself — a resolver-internal pairing
enforced only by a comment. resolve()/resolve_runtime_tools now accept
a defer option that pays the same-tick $PATH guarantee synchronously
and schedules the rest, with phase-1 configures still counting as
trigger-backed exactly like the caller-side wrapper did
dap/init eagerly required mason-registry (loading all of mason.nvim)
and four mason-nvim-dap mapping tables on the interactive command
tick, even when every adapter self-validates from $PATH. mason-nvim-dap
moves out of nvim-dap's dependencies into its own lazy spec, the
registry becomes a thunk, and mappings/setup load on first use — only
the factory fallback and phase-2 classification. the client opts table
keeps its historical mapping fields through a lazy __index, so a user
override reading opts.adapters still gets them on demand
phase 2 (the full mason mappings decode plus package hydration) ran
synchronously on the BufReadPre lazy-load tick whenever any lsp dep
was unresolved, adding 10-20ms to the session's first file. it now
runs deferred: vim.lsp.enable() attaches already-open matching buffers
(verified on this nvim), so the one same-tick beneficiary still
reaches the triggering buffer
only successful resolution was cached, so every launch attempt while
debugpy stayed missing re-ran the blocking python import spawns. a
closure flag now short-circuits after one failed round; every recovery
path already bypasses or resets it — the fast probes run first each
call (a mason install takes over there), a resolver re-configure
rebuilds the closure, and :ToolsRetry re-runs the client config for a
pip-installed debugpy
package_for_binary re-read and re-decoded the whole registry.json on
every call while the registry was not fully bootstrapped; the unfrozen
index is now kept for a single event-loop tick (nothing can change
within one), so a batch of lookups decodes once and the re-refresh
self-heal is preserved. mason_root's guess path re-ran the
user-override module search on every $PATH miss and recheck; that
existence check is memoized (it only gates the default-dir guess),
while the fs_stat recheck stays per call
refresh_linter read the rebuilt linter through lint.linters' __index,
whose pcall swallows a reload failure into nil — the old linter was
silently restored and configure reported success, clearing the
hand-off pending gate over stale state. the reload now requires the
module itself and raises the real reason on failure (the resolver
keeps the name pending and the warning carries it); the synchronous
reload inside configure is documented as deliberate — deferring it
would report success before the rebuild happened
both overridden linters (selene, markdownlint-cli2) are plain-table
modules upstream, so reapply_factory_override's non-function
early-return always fired and the per-call wrapper plus
apply_override's second parameter were ~30 lines of speculation.
sync configures apply the override directly; late configures rely on
refresh_linter, which re-applies it itself. if upstream ever converts
one to a factory the override degrades to not-applied (documented) —
the probe's factory branch still resolves the linter
the fast path hardcoded mason's packages/debugpy/venv layout — the
same class of internal knowledge this branch already deleted from
delve — while the next probe resolves the spec-declared
debugpy-adapter shim that exists exactly when the venv does. the venv
probe is gone (accepted nuance: windows spawns via the .cmd shim, no
pythonw console suppression), the hint wording follows, and the
import probe now uses vim.system():wait().code per repo convention
instead of round-tripping through v:shell_error
autoformat_allowed re-tested format_on_save_enabled, but its only
caller is installed exclusively when that setting is truthy — the
condition could never be false there and pinned the gate in two
places. the setting's gate now lives solely where the callback is
installed, and the predicate says so
the discovery-first refactor removed the setting, but a user
settings.lua still defining it merges cleanly into the table and feeds
nothing — its servers vanish with no notification, since the
aggregated warning only covers lsp_deps names. the merge tail now
detects the removed key and schedules one warning that lists the map's
keys and says to move the server names (not the executable values)
into lsp_deps
the comment promised resolution when nvim-lint lazy-loads (first
BufReadPost), but a by_ft-mapped linter only resolves on its
filetype's first matching event — the resolve-only FileType autocmd or
a lint event — so a tool like hadolint is neither installed nor warned
about until a dockerfile buffer opens; only unmapped names get the
immediate deferred pass

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 1 comment.

Comment thread lua/modules/utils/tools.lua
the contract list and the implementation both know the probe may
return { unresolved = true }, but the @param type still described the
pre-group-5 shape, misleading lua-ls-driven tooling

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 1 comment.

Comment thread lua/modules/configs/completion/servers/yamlls.lua Outdated
the official docs and the site config consistently publish
https://gh-dash.dev/schema.json; the www form we pointed at is only
the current hosting redirect target, so the documented url is the
stable contract (today it 301s to www — the dependency direction
still favors what the project documents)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.

the config-time negative probe latched for the whole closure lifetime, so a
pip-installed debugpy (no mason shim) was never picked up by later launches.

re-probe on a launch attempt after a 10s window, at most 3 rounds per
miss-epoch, then the latch is permanent again (keeps the round-2 f19 bound
of finite blocking spawns per session). any successful resolution resets the
epoch in found(), so a later vanished resolved command re-probes with a
fresh budget instead of latching on spent state.
… drift

the factory-branch opts proxy only exposed `name` as a real key, so a user
override enumerating opts (vim.tbl_deep_extend / pairs) silently lost the
mason mapping fields the pre-branch plain table carried. user-authored
overrides now get a materialized plain table (load_first_usable reports the
winning module); repo clients keep the lazy proxy so a provisioned session
still never loads mason on the :Dap tick.

failed mason-nvim-dap mapping requires are now recorded and surfaced where
they previously vanished: the resolver's reason-less missing branch (new
missing_reason_of spec hook — a drifted mappings.source dead-ends there),
the factory nil-adapter error (actionable client-config guidance; no drift
claim for legitimate source-only names like js/elixir), and a one-shot warn
when a materialized override field is nil. all three sites are gated on
mason presence evidence (sibling mapping load, package.loaded, module_path)
so a mason-less setup keeps its silent degradation.
the migration warning assumed a map residue: a half-migrated list surfaced
its numeric indices as "KEYS (1, 2)", an empty table produced a warning with
nothing actionable, and no shape ever said the dead key itself must go.

classify the residue first — map keys, list entries, both (neither group may
suppress the other, or a listed server is silently lost on migration), or
nothing — and always end with the delete-the-key step.
the anchored lazy pattern ate the leading prose of any prefix-less level-0
raise whose message contains ":<digits>: " — error("parse failed at 12:30:
bad token", 0) reached the aggregated warning as just "bad token".

strip only shapes that are actual chunknames ([string ...], [C]:N:, a path
ending in .lua), first match wins; a custom non-lua chunk keeps its prefix,
which is more information, never less.
resolve_runtime_tools hardcoded conform's "resolves per buffer" phrasing in
its unresolvable_of while nvim-lint shares the helper; the probe result now
carries an optional reason and the shared code falls back to a neutral
"config could not be verified at startup". conform supplies its own wording
on the probe return — user-visible text is unchanged.
the probe's guard returned {binary=nil} when conform drops its @Private
get_formatter_config, silently classifying every formatter as self-resolving
— no install, no warning, provisioning off wholesale.

report unresolved with a reason naming the missing api instead: every
formatter_deps entry lands in the missing bucket via the phase-1
unresolvable_of short-circuit, loud and actionable.
…t it

expand_braces was single-group: a multi-group v2 claim like
{traefik,traefik-static}.{yml,yaml} slipped the prune and both schemas
claimed traefik.yml silently.

the claim check now judges leaves during a bounded expansion (cap 64, no
cartesian blow-up on the startup path; a claimed leaf short-circuits even
when the product exceeds the cap), and a glob still beyond the matcher that
mentions a claimed stem is dropped rather than trusted — with a scheduled
warn listing the dropped globs so over-pruning stays reviewable. stem-less
unjudgeable globs stay kept and silent.
phase 1 walked every lsp_deps entry through server_info on the first
BufReadPre tick, loading all ~18 server modules regardless of the opened
filetype — including jsonls/yamlls whose bodies deepcopy the 1362-entry
schemastore catalog twice.

resolve_deps now partitions the raw dep list: invalid entries pass through
verbatim (the resolver's re-scan keeps reporting them); names with either
user hook (a user.configs.lsp-servers module or recorded user.configs.lsp
ops) and the repo modules that override filetypes (eager set, exported for
the consistency harness) keep today's load-tick semantics; the rest defer
to per-filetype buckets from vim.lsp.config[name].filetypes, resolved on
that filetype's first buffer via a self-cleaning FileType autocmd plus an
already-loaded-buffer scan. a 120s one-shot sweep (exported as
resolve_remaining) classifies whatever never opened, keeping the
missing-tool warning and install parity once per session.

opening a lua file now loads only lua_ls's module; jsonls/yamlls stay
unloaded until a json/yaml buffer appears.
package_for_binary froze its bin->package index once the registry was
bootstrapped, with no invalidation: a mid-session registry update adding a
package left the reverse lookup answering stale nils until restart.

attach_registry_events now also subscribes update:success and clears both
index tiers synchronously — mason emits the event before update callbacks
run, so a scheduled clear would leave a same-tick stale window; clearing
two locals is pure lua and fast-event-safe. the next lookup rebuilds and
re-freezes against the fresh specs.
lspconfig_to_package froze its first non-empty get_mappings() snapshot; a
mapping appearing later (registry update, partial-bootstrap edge) stayed
invisible for the session. setup now subscribes update:success (pcall
guarded, synchronous pure-lua clear) so package_of refetches on the next
consult.
four verifier-confirmed equivalences, no behavior change:

- the collector's pending counter moved in lockstep with the unsettled set
  on every path; the flush gate now reads next(unsettled) and the dead
  nil-name branches are gone (track is only ever called with a string name)
- phase-1 validate outcomes collapse from two parallel maps plus a
  false-vs-string sentinel into one validates[name] record whose existence
  is the failure mark
- split_dep_names(valid, invalid) is now the one definition of a valid dep
  entry; run() and nvim-lint consume the invalid list instead of inversely
  re-scanning raw deps (normalize_names stays a single-return wrapper, so
  the public alias's arity is unchanged)
- resolve() asserts spec.configure is a function (every call site passes
  one) and the timeout fallback is a named constant cross-referenced from
  the settings doc comment
tools.load_first_usable had exactly one caller: dap's memoized
load_client_config. the two-candidate loop now lives at that call site on
top of the public load_module_or_report, preserving the contract exactly
(first success wins, a higher-precedence broken candidate's reason survives
alongside a lower-precedence success, any_exists accumulates); the export
and its docblock are gone.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

lua/modules/configs/completion/nvim-lint.lua:152

  • refresh_linter() re-requires lint.linters.<name> directly but never writes the returned module back into lint.linters[name]. In nvim-lint, the caching rawset(lint.linters, name, linter) happens in the lint.linters metatable __index, which this bypasses—so a successful require() can still leave lint.linters[name] nil and break later try_lint calls.
		rawset(lint.linters, name, nil)
		-- Require it OURSELVES: the __index loader pcall-swallows a reload
		-- failure into nil, which would read as configure success and clear
		-- the hand-off pending gate over a stale linter.
		local ok, err = pcall(require, module)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants