Merge pull request #161 from levineuwirth/lsp-multi-root-affinity
feat(lsp): one server per detected project root (Arc 8 Stage 2)
This commit is contained in:
commit
46a1b8fc88
49
COHERENCE.md
49
COHERENCE.md
|
|
@ -120,9 +120,8 @@ asymmetry**, and **per-arc coherence debt**.
|
||||||
|
|
||||||
Coherence-shaped work already in flight at audit time: find-file /
|
Coherence-shaped work already in flight at audit time: find-file /
|
||||||
dired Stage 0 (`C-x C-f`, PR #162, `docs/dired-framing.md`), bottom
|
dired Stage 0 (`C-x C-f`, PR #162, `docs/dired-framing.md`), bottom
|
||||||
panel Stage 1 (merged #155), multi-root LSP affinity (branch
|
panel Stage 1 (merged #155), multi-root LSP affinity (PR #161), the
|
||||||
`lsp-multi-root-affinity`), the config registry foundation (merged
|
config registry foundation (merged #127).
|
||||||
#127).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -230,9 +229,37 @@ This directly contradicts the product thesis (§23): the "without
|
||||||
freezing" half is delivered; the "without becoming opaque" half is
|
freezing" half is delivered; the "without becoming opaque" half is
|
||||||
currently false for exactly the failures a new user will hit first.
|
currently false for exactly the failures a new user will hit first.
|
||||||
|
|
||||||
|
**The reporting channel the runtime believes it has does not exist.**
|
||||||
|
Fifteen call sites — `async.lua` (5), `syntax.lua` (4), and one each in
|
||||||
|
`lsp.lua`, `mcp.lua`, `fs.lua`, `editops.lua`, `autosave.lua`, and
|
||||||
|
`commands/default.lua` — report background failures through
|
||||||
|
`pmacs.error`, each guarded as `if pmacs.error then pmacs.error(...)`.
|
||||||
|
**`pmacs.error` is never defined in production** — the only assignment
|
||||||
|
in the tree is a test stub (`src/editor.rs:9881`), and
|
||||||
|
`type(pmacs.error)` is `nil` in a fresh `EditorState`. So every one of
|
||||||
|
those fifteen reports is dead: the guard makes the silence look
|
||||||
|
deliberate and keeps it from ever being noticed. `pmacs.errors` (plural,
|
||||||
|
`builtin/runtime/compile.lua:45`) is an unrelated namespace and is not
|
||||||
|
it. This is the silence asymmetry one level deeper than §1.2 first
|
||||||
|
recorded — not "the failure isn't surfaced" but "the surface was
|
||||||
|
written, guarded, and never built." Found while landing PR #161, which
|
||||||
|
nearly added a sixteenth; that one reports via
|
||||||
|
`pmacs.editor.set_status` (which exists) with the `pmacs.error` arm
|
||||||
|
riding along for when the channel is built.
|
||||||
|
|
||||||
**Rule to adopt:** anything that fails automatically must leave a
|
**Rule to adopt:** anything that fails automatically must leave a
|
||||||
user-visible trace with a named owner. A `pcall` around background
|
user-visible trace with a named owner. A `pcall` around background
|
||||||
wiring must log attributed failure, never discard it.
|
wiring must log attributed failure, never discard it. Corollary from the
|
||||||
|
above: report through a channel with a **test that observes it**, or the
|
||||||
|
guard is indistinguishable from the silence it was meant to fix.
|
||||||
|
|
||||||
|
**Frequency note (PR #161):** per-root server affinity means the
|
||||||
|
preconfigured-but-missing-server failure now fires **once per project
|
||||||
|
root** rather than once per language per session. The silence is
|
||||||
|
unchanged in kind; it is strictly more frequent. Surfacing it stays
|
||||||
|
Priority 1 work with its own framing — it is a user-visible product
|
||||||
|
behavior (what message, where, with what guidance), not a substrate fix
|
||||||
|
to smuggle into an affinity PR.
|
||||||
|
|
||||||
### 1.3 Ground truth: coherence debt compounds per-arc
|
### 1.3 Ground truth: coherence debt compounds per-arc
|
||||||
|
|
||||||
|
|
@ -726,12 +753,14 @@ per-subsystem conventions.**
|
||||||
nil/`"."`. Nothing owns the set {roots, servers, terminals, tasks,
|
nil/`"."`. Nothing owns the set {roots, servers, terminals, tasks,
|
||||||
layout} — which is why desktop-save under a daemon had nothing
|
layout} — which is why desktop-save under a daemon had nothing
|
||||||
principled to attach to (Q#DS9, §2 step 12).
|
principled to attach to (Q#DS9, §2 step 12).
|
||||||
- **First slice in flight**: the multi-root LSP server-affinity work
|
- **First slice landed (PR #161)**: the multi-root LSP server-affinity
|
||||||
(branch `lsp-multi-root-affinity`) makes *(language, found-root)* the
|
work makes *(language, found-root)* the server identity — the first
|
||||||
server identity — the first time a root functions as an identity key
|
time a root functions as an identity key rather than a spawn
|
||||||
rather than a spawn parameter. Note it is again per-subsystem: LSP
|
parameter. It also establishes the rule that a *fallback* root (the
|
||||||
learns roots; compile, search, index, and trust do not share the
|
file's own directory, when no marker was found) is deliberately **not**
|
||||||
object.
|
an identity, so markerless files keep sharing one server per language.
|
||||||
|
Note it is again per-subsystem: LSP learns roots; compile, search,
|
||||||
|
index, and trust do not share the object.
|
||||||
|
|
||||||
A workspace entity is a **model gap** (real arc), not wiring. It is also
|
A workspace entity is a **model gap** (real arc), not wiring. It is also
|
||||||
the prerequisite that keeps §8 (locations), §9 (task ownership), §11
|
the prerequisite that keeps §8 (locations), §9 (task ownership), §11
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,13 @@ pmacs.lsp = pmacs.lsp or {}
|
||||||
-- env (table) extra environment
|
-- env (table) extra environment
|
||||||
-- init_options (table) `initializationOptions`
|
-- init_options (table) `initializationOptions`
|
||||||
-- settings (table) answered to `workspace/configuration`
|
-- settings (table) answered to `workspace/configuration`
|
||||||
-- root (string) optional explicit project root; overrides
|
-- root (string|function) optional explicit project root; overrides
|
||||||
-- the `pmacs.project.detect` marker walk used
|
-- the `pmacs.project.detect` marker walk used
|
||||||
-- to set `rootUri`/`cwd` (see project_root_for)
|
-- to set `rootUri`/`cwd`. A `function(path) ->
|
||||||
|
-- string|nil` is resolved per file and
|
||||||
|
-- memoized per directory; returning nil
|
||||||
|
-- declines and falls through to the marker
|
||||||
|
-- walk (see project_root_for)
|
||||||
pmacs.lsp.config = pmacs.lsp.config or {}
|
pmacs.lsp.config = pmacs.lsp.config or {}
|
||||||
|
|
||||||
-- Default rust-analyzer config. Users replace any field from init.lua
|
-- Default rust-analyzer config. Users replace any field from init.lua
|
||||||
|
|
@ -507,34 +511,144 @@ end
|
||||||
-- the rest of the editor uses, honoring set_search_boundary,
|
-- the rest of the editor uses, honoring set_search_boundary,
|
||||||
-- 3. the file's own directory (a lone file still gets a sane root
|
-- 3. the file's own directory (a lone file still gets a sane root
|
||||||
-- rather than leaking the editor cwd).
|
-- rather than leaking the editor cwd).
|
||||||
-- This is single-root: it fixes which root the one per-language server
|
-- Returns `root, source`, where `source` is "config", "detected", or
|
||||||
-- uses, NOT one-server-per-root scoping (still deferred post-v0.1).
|
-- "fallback" — and nil alongside a nil root. The source matters because
|
||||||
|
-- only the first two mean a root was actually *found*; `ensure_server`
|
||||||
|
-- keys server affinity on those and treats the fallback as rootless.
|
||||||
|
--
|
||||||
|
-- `config[language].root` may be a `function(path) -> string|nil` as
|
||||||
|
-- well as a plain string, for languages whose root rule the shared
|
||||||
|
-- marker walk cannot express (an innermost-wins walk cannot find an
|
||||||
|
-- *outermost* marker). A resolver that returns nil declines, and
|
||||||
|
-- resolution falls through to the marker walk.
|
||||||
|
--
|
||||||
|
-- **A configured root — string or resolver return — MUST be a canonical
|
||||||
|
-- absolute path.** The `"detected"` arm is canonicalized for free
|
||||||
|
-- (`pmacs.project.detect` canonicalizes before walking), but a
|
||||||
|
-- configured one is fed to `file_uri_for` exactly as written, and the
|
||||||
|
-- affinity key is that URI. On macOS a resolver returning `/var/…` and
|
||||||
|
-- a detected `/private/var/…` are different keys for the same
|
||||||
|
-- directory, which silently yields two servers for one project. There
|
||||||
|
-- is no Lua-side canonicalizer to normalize this for you.
|
||||||
|
--
|
||||||
|
-- Resolver results are memoized per directory, because `ensure_server`
|
||||||
|
-- resolves the root on the *reuse* path as well as the spawn path — so
|
||||||
|
-- an unmemoized filesystem-walking resolver would re-walk on every
|
||||||
|
-- attach rather than once per project. The memo is keyed by the
|
||||||
|
-- resolver function itself, weakly: replacing `config[lang].root`
|
||||||
|
-- installs a new key and the old memo is collected, so a swapped
|
||||||
|
-- resolver can never serve a root the previous one computed.
|
||||||
|
local root_resolver_memo = setmetatable({}, { __mode = "k" })
|
||||||
|
|
||||||
|
local function resolve_root_fn(language, resolver, path)
|
||||||
|
local dir = dir_of(path)
|
||||||
|
if not dir then return nil end
|
||||||
|
local memo = root_resolver_memo[resolver]
|
||||||
|
if not memo then
|
||||||
|
memo = {}
|
||||||
|
root_resolver_memo[resolver] = memo
|
||||||
|
end
|
||||||
|
local hit = memo[dir]
|
||||||
|
-- `false` is the memoized form of "this resolver declined"; nil means
|
||||||
|
-- "not yet asked", so the two must stay distinguishable.
|
||||||
|
if hit ~= nil then
|
||||||
|
return hit or nil
|
||||||
|
end
|
||||||
|
local ok, resolved = pcall(resolver, path)
|
||||||
|
-- COHERENCE §1.2: background wiring must not DISCARD a failure. A
|
||||||
|
-- resolver that raises, or that returns something other than a string
|
||||||
|
-- or nil, is a config bug — and the memo below would otherwise bury
|
||||||
|
-- it permanently for this directory, so it is never observed again.
|
||||||
|
-- Returning nil is the documented decline and stays silent.
|
||||||
|
local failure
|
||||||
|
if not ok then
|
||||||
|
failure = "raised: " .. tostring(resolved)
|
||||||
|
elseif resolved ~= nil and type(resolved) ~= "string" then
|
||||||
|
failure = "returned a " .. type(resolved) .. "; want string or nil"
|
||||||
|
end
|
||||||
|
if failure then
|
||||||
|
local msg = string.format(
|
||||||
|
"LSP: %s root resolver for %s %s", language, dir, failure)
|
||||||
|
-- Report on the channel that EXISTS. `pmacs.error` is referenced by
|
||||||
|
-- fifteen guarded call sites across the runtime and is defined
|
||||||
|
-- nowhere in production (only by a test stub in `src/editor.rs`), so
|
||||||
|
-- `if pmacs.error then ...` alone would be a sixteenth report that
|
||||||
|
-- never fires — the unwired-guard shape, not a fix for it. The
|
||||||
|
-- status line is what lsp.lua already uses for every other LSP
|
||||||
|
-- error. The `pmacs.error` arm rides along so this upgrades for free
|
||||||
|
-- if that channel is ever built.
|
||||||
|
--
|
||||||
|
-- Both reports are pcall'd: a broken reporting channel must not turn
|
||||||
|
-- a declined root into a failed attach.
|
||||||
|
pcall(pmacs.editor.set_status, msg)
|
||||||
|
if pmacs.error then pcall(pmacs.error, msg) end
|
||||||
|
resolved = nil
|
||||||
|
end
|
||||||
|
if type(resolved) ~= "string" then resolved = nil end
|
||||||
|
memo[dir] = resolved or false
|
||||||
|
return resolved
|
||||||
|
end
|
||||||
|
|
||||||
local function project_root_for(language, path)
|
local function project_root_for(language, path)
|
||||||
local cfg = pmacs.lsp.config[language]
|
local cfg = pmacs.lsp.config[language]
|
||||||
if cfg and cfg.root then return cfg.root end
|
local configured = cfg and cfg.root
|
||||||
if not path then return nil end
|
-- Truthiness, not `~= nil`: `root = false` has always read as "unset",
|
||||||
|
-- and a `false` leaking through as a root would reach `file_uri_for`.
|
||||||
|
if configured and type(configured) ~= "function" then
|
||||||
|
return configured, "config"
|
||||||
|
end
|
||||||
|
if not path then return nil, nil end
|
||||||
|
if configured then
|
||||||
|
local resolved = resolve_root_fn(language, configured, path)
|
||||||
|
if resolved then return resolved, "config" end
|
||||||
|
end
|
||||||
local ok, det = pcall(pmacs.project.detect, path)
|
local ok, det = pcall(pmacs.project.detect, path)
|
||||||
if ok and det and det.root then return det.root end
|
if ok and det and det.root then return det.root, "detected" end
|
||||||
return dir_of(path)
|
return dir_of(path), "fallback"
|
||||||
end
|
end
|
||||||
|
|
||||||
local function ensure_server(language, path)
|
local function ensure_server(language, path)
|
||||||
local cfg = pmacs.lsp.config[language]
|
local cfg = pmacs.lsp.config[language]
|
||||||
if not cfg or not cfg.command then return nil end
|
if not cfg or not cfg.command then return nil end
|
||||||
-- Reuse an existing same-language server if one is up. Multi-root
|
-- Reuse an existing same-language server *serving the same root*.
|
||||||
-- scoping (one server per project root) ships post-v0.1, so the
|
-- One server per project root: `lake serve` is bound to one Lake
|
||||||
-- first file that attaches a given language fixes that server's
|
-- package and rust-analyzer/gopls to one workspace, so handing the
|
||||||
-- root; later files of the same language reuse it regardless of
|
-- second project's files to the first project's server yields
|
||||||
-- their own project (known, documented limitation).
|
-- unresolvable imports and empty diagnostics.
|
||||||
|
--
|
||||||
|
-- The affinity key is the root only when a root was actually FOUND
|
||||||
|
-- (config override or marker walk). `project_root_for` never returns
|
||||||
|
-- nil for a file that has a path — its last resort is the file's own
|
||||||
|
-- directory — so keying on the fallback would give every directory
|
||||||
|
-- of loose scratch files its own server, for every language: two
|
||||||
|
-- stray .py files in different directories would spawn two pyrights
|
||||||
|
-- where today they share one. The fallback therefore keys on nil.
|
||||||
|
--
|
||||||
|
-- Matching is on the spawned spec's `root_uri`, nil matching nil, so
|
||||||
|
-- the fallback spawn must pass `root_uri = nil` for the key and the
|
||||||
|
-- stored spec to agree. `cwd` still carries the directory and
|
||||||
|
-- `build_initialize` derives the identical `rootUri` from it when the
|
||||||
|
-- field is None (src/lsp.rs), so the initialize payload is unchanged
|
||||||
|
-- for that case — only what this loop matches on changes.
|
||||||
|
--
|
||||||
|
-- Consequence, deliberate: a server hand-spawned from `init.lua` with
|
||||||
|
-- only `cwd` set also reads back nil, so a root-bearing attach will
|
||||||
|
-- not adopt it. We cannot know which root it was meant to serve, and
|
||||||
|
-- guessing wrongly routes a project's files to the wrong server.
|
||||||
|
local root, source = project_root_for(language, path)
|
||||||
|
local key_uri = nil
|
||||||
|
if source == "config" or source == "detected" then
|
||||||
|
key_uri = file_uri_for(root)
|
||||||
|
end
|
||||||
for _, info in ipairs(pmacs.lsp.list()) do
|
for _, info in ipairs(pmacs.lsp.list()) do
|
||||||
if info.language_id == language and info.state then
|
if info.language_id == language and info.state
|
||||||
|
and info.root_uri == key_uri then
|
||||||
local kind = info.state.kind
|
local kind = info.state.kind
|
||||||
if kind ~= "crashed" and kind ~= "stopped" then
|
if kind ~= "crashed" and kind ~= "stopped" then
|
||||||
return info.id
|
return info.id
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
local root = project_root_for(language, path)
|
|
||||||
local ok, sid = pcall(pmacs.lsp.spawn, {
|
local ok, sid = pcall(pmacs.lsp.spawn, {
|
||||||
label = "default-" .. language,
|
label = "default-" .. language,
|
||||||
language_id = language,
|
language_id = language,
|
||||||
|
|
@ -544,7 +658,7 @@ local function ensure_server(language, path)
|
||||||
init_options = cfg.init_options,
|
init_options = cfg.init_options,
|
||||||
settings = cfg.settings,
|
settings = cfg.settings,
|
||||||
cwd = root,
|
cwd = root,
|
||||||
root_uri = root and file_uri_for(root) or nil,
|
root_uri = key_uri,
|
||||||
})
|
})
|
||||||
if ok then return sid end
|
if ok then return sid end
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -54,10 +54,11 @@ git status --short --branch
|
||||||
The `git log` command must expose `0dd16a5` or a newer intentional main.
|
The `git log` command must expose `0dd16a5` or a newer intentional main.
|
||||||
If it does not, stop and repair the remote/fetch configuration.
|
If it does not, stop and repair the remote/fetch configuration.
|
||||||
|
|
||||||
## Lean 4 lane (Arc 8) — Stage 1 IN REVIEW (PR #160)
|
## Lean 4 lane (Arc 8) — Stage 1 MERGED; Stage 2 IN REVIEW (PR #161)
|
||||||
|
|
||||||
- Portable branch: `githubsucks/lean4-stage1`, worked in the shared
|
- Stage 1 **merged as #160** (`main` @ `0827dd1`, 2026-07-25, one review
|
||||||
checkout (no sibling worktree), based on `githubsucks/main` @ `e745068`.
|
round, all twelve checks green). Branch `githubsucks/lean4-stage1`
|
||||||
|
retained; it was worked in the shared checkout (no sibling worktree).
|
||||||
- Approved framing: `docs/lean4-mode-framing.md` revision 4, committed as
|
- Approved framing: `docs/lean4-mode-framing.md` revision 4, committed as
|
||||||
the branch's first commit (`a382965`) after three review rounds. **Seven
|
the branch's first commit (`a382965`) after three review rounds. **Seven
|
||||||
stages**, 19 decisions (Q#LN1–19), 64 acceptance criteria. North star:
|
stages**, 19 decisions (Q#LN1–19), 64 acceptance criteria. North star:
|
||||||
|
|
@ -113,16 +114,79 @@ If it does not, stop and repair the remote/fetch configuration.
|
||||||
152; **isolated-config workspace sweep 3,150 across 90 suites**;
|
152; **isolated-config workspace sweep 3,150 across 90 suites**;
|
||||||
`git diff --check` clean. The sweep needs an isolated `XDG_CONFIG_HOME`
|
`git diff --check` clean. The sweep needs an isolated `XDG_CONFIG_HOME`
|
||||||
for the reason recorded in the bottom-panel lane below.
|
for the reason recorded in the bottom-panel lane below.
|
||||||
- **Stage 2 is multi-root LSP server affinity** — pure substrate, no Lean
|
### Stage 2 — multi-root LSP server affinity (Q#LN15)
|
||||||
content, and it changes `ensure_server`, which every LSP language
|
|
||||||
shares. It is sequenced next because Lean is the language that makes its
|
- Portable branch: `githubsucks/lsp-multi-root-affinity`, shared checkout,
|
||||||
absence a correctness failure rather than an inconvenience. Two
|
based on `githubsucks/main` @ `0827dd1`. Named for the substrate, not
|
||||||
corrections the framing already carries for it: `root` is computed at
|
for Lean: **the diff contains no Lean content**, because `ensure_server`
|
||||||
`lsp.lua:537`, **after** the reuse loop, so the fix must hoist it; and
|
is the one server-affinity function every LSP language shares and a
|
||||||
`project_root_for` never returns nil for a file with a path, so the
|
cross-cutting change to it must not be reviewable only as a Lean
|
||||||
affinity key must be the root only when a root was actually *detected*,
|
feature.
|
||||||
or markerless scratch files fragment into one server per directory for
|
- Three files, no protocol change: `src/lua_bindings/mod.rs` (the
|
||||||
every language.
|
`lsp.list()` row builder gains `root_uri` + `cwd`),
|
||||||
|
`builtin/runtime/lsp.lua` (`project_root_for` returns `root, source`;
|
||||||
|
`ensure_server` hoists it above the reuse loop and matches on it),
|
||||||
|
`tests/lsp_multi_root_acceptance.rs` (9 tests, acceptance 13–21).
|
||||||
|
- **The rule that keeps this from regressing every other language: the
|
||||||
|
affinity key is the root only when a root was actually FOUND.**
|
||||||
|
`project_root_for` never returns nil for a file with a path — its last
|
||||||
|
resort is the file's own directory — so a naive `(language_id, root)`
|
||||||
|
key gives every directory of loose scratch files its own server, for
|
||||||
|
every language. `source` is `"config" | "detected" | "fallback"` and
|
||||||
|
only the first two become a key.
|
||||||
|
- **Wire-identical for the fallback case, and that is provable rather
|
||||||
|
than hoped.** Matching is on the spawned spec's `root_uri` (nil matching
|
||||||
|
nil), so the fallback spawn passes `root_uri = nil`; `cwd` still carries
|
||||||
|
the directory and `build_initialize` derives the identical `rootUri`
|
||||||
|
from `cwd` when the field is None, using a percent-encoder with the same
|
||||||
|
allowed set as Lua's `file_uri_for`. `build_initialize` (`src/lsp.rs`)
|
||||||
|
is the **only** reader of `spec.root_uri` in the tree.
|
||||||
|
- Deliberate behavior change, asserted not discovered: a server
|
||||||
|
hand-spawned from `init.lua` with only `cwd` set also reads back nil, so
|
||||||
|
a root-bearing attach will not adopt it.
|
||||||
|
- `config[language].root` may now be a `function(path) -> string|nil`,
|
||||||
|
memoized per directory — needed because the hoist puts root resolution
|
||||||
|
on every attach rather than every spawn. The memo is keyed **weakly by
|
||||||
|
the resolver function itself**, so replacing `config[lang].root` cannot
|
||||||
|
serve a root the previous resolver computed. This is Q#LN8's
|
||||||
|
generalization landing early; the Lean resolver that uses it is Stage 3.
|
||||||
|
- Bite-verified three ways: 5/9 fail against the pre-change `lsp.lua`,
|
||||||
|
8/9 against the pre-change `mod.rs`, and — the one that matters most —
|
||||||
|
installing the naive always-key-on-root variant fails acceptance 20 and
|
||||||
|
21 exactly as Q#LN15 part 2 predicts. The four that survive the first
|
||||||
|
bite (13, 15, 16, 19) are the regression pins; passing on both sides is
|
||||||
|
their job.
|
||||||
|
- Every fixture sets `pmacs.project.set_search_boundary` at its own
|
||||||
|
tempdir root. Without it the marker walk climbs to the filesystem root
|
||||||
|
and a stray `.git` above the temp directory turns the markerless cases
|
||||||
|
into detected ones — the assertions would still pass while testing
|
||||||
|
nothing.
|
||||||
|
- **Found but not fixed here (pre-existing, own lane):** `ensure_server`
|
||||||
|
never forwards `cfg.restart` to `pmacs.lsp.spawn`, so a
|
||||||
|
`restart = "never"` in `pmacs.lsp.config[lang]` is silently dropped on
|
||||||
|
the auto-attach path. At least one existing test sets it believing it
|
||||||
|
takes effect. Out of scope for a PR whose acceptance 16 pins existing
|
||||||
|
attach behavior as unchanged.
|
||||||
|
- **Review round 1 addressed.** The blocker was process, not design: the
|
||||||
|
test file was committed *before* `cargo fmt` ran, so the fix sat
|
||||||
|
uncommitted in the working tree and the branch as pushed failed the
|
||||||
|
first gate. The reported "fmt clean" described the worktree, not the
|
||||||
|
branch — gate results are only meaningful when run against the pushed
|
||||||
|
tree. Also added the two pins review asked for (a **string** `config
|
||||||
|
.root` as an affinity key — acc17 only covered the function form; and
|
||||||
|
`root = false` reading as unset), each bite-verified against exactly
|
||||||
|
the mutation it targets and neither against the other. And documented
|
||||||
|
the canonicalization obligation: the `"detected"` arm is canonicalized
|
||||||
|
for free, a **configured** root is not, so on macOS a resolver
|
||||||
|
returning `/var/…` and a detected `/private/var/…` are different keys
|
||||||
|
for one directory. Stage 3's Lean resolver is the first real consumer,
|
||||||
|
so the obligation is written at the point of use.
|
||||||
|
- Verification on this branch: `cargo fmt --check` clean; strict
|
||||||
|
workspace Clippy clean; 1,826 default + 2,003 CRDT library tests;
|
||||||
|
multi-root 11/11; M4 121; statusline 7; completion popup 9; auto-pair
|
||||||
|
45; required GPU 155; **isolated-config workspace sweep 3,164 across 91
|
||||||
|
suites**; `git diff --check` clean. The sweep needs an isolated
|
||||||
|
`XDG_CONFIG_HOME` and `-- --skip basedpyright`.
|
||||||
|
|
||||||
## Dired lane — framing APPROVED; Stage 0 MERGED, Stage 1 next
|
## Dired lane — framing APPROVED; Stage 0 MERGED, Stage 1 next
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9923,12 +9923,26 @@ pub fn install_lsp(
|
||||||
let ids: Vec<LspServerId> = mgr.ids().collect();
|
let ids: Vec<LspServerId> = mgr.ids().collect();
|
||||||
let out = lua.create_table_with_capacity(ids.len(), 0)?;
|
let out = lua.create_table_with_capacity(ids.len(), 0)?;
|
||||||
for (i, id) in ids.iter().enumerate() {
|
for (i, id) in ids.iter().enumerate() {
|
||||||
let row = lua.create_table_with_capacity(0, 5)?;
|
let row = lua.create_table_with_capacity(0, 7)?;
|
||||||
row.set("id", LspServerIdLua(*id))?;
|
row.set("id", LspServerIdLua(*id))?;
|
||||||
if let Some(spec) = mgr.spec(*id) {
|
if let Some(spec) = mgr.spec(*id) {
|
||||||
row.set("label", spec.label.as_str())?;
|
row.set("label", spec.label.as_str())?;
|
||||||
row.set("language_id", spec.language_id.as_str())?;
|
row.set("language_id", spec.language_id.as_str())?;
|
||||||
row.set("command", spec.command.as_str())?;
|
row.set("command", spec.command.as_str())?;
|
||||||
|
// Server *affinity* fields. `root_uri` is the spec
|
||||||
|
// field verbatim — deliberately NOT the URI the
|
||||||
|
// server was initialized with, which `build_initialize`
|
||||||
|
// derives from `cwd` when the field is `None`. Lua's
|
||||||
|
// `ensure_server` matches on this exact value, so a
|
||||||
|
// server that never asked for a specific root must
|
||||||
|
// read back as nil rather than as its cwd; see the
|
||||||
|
// affinity-key comment in `builtin/runtime/lsp.lua`.
|
||||||
|
if let Some(root_uri) = spec.root_uri.as_deref() {
|
||||||
|
row.set("root_uri", root_uri)?;
|
||||||
|
}
|
||||||
|
if let Some(cwd) = spec.cwd.as_deref() {
|
||||||
|
row.set("cwd", cwd.display().to_string())?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Some(state) = mgr.state(*id) {
|
if let Some(state) = mgr.state(*id) {
|
||||||
row.set("state", lsp_state_to_lua(lua, state)?)?;
|
row.set("state", lsp_state_to_lua(lua, state)?)?;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,704 @@
|
||||||
|
//! Arc 8 Stage 2 acceptance — multi-root LSP server affinity.
|
||||||
|
//!
|
||||||
|
//! `docs/lean4-mode-framing.md` Q#LN15, acceptance 13–21.
|
||||||
|
//!
|
||||||
|
//! This suite deliberately contains **no Lean content**. `ensure_server`
|
||||||
|
//! (`builtin/runtime/lsp.lua`) is the single server-affinity function for
|
||||||
|
//! every LSP language in pmacs, so the change is exercised through the
|
||||||
|
//! four languages that already shipped attach paths — rust, python, go,
|
||||||
|
//! typescript — driven against `pmacs_fake_lsp` so nothing here needs a
|
||||||
|
//! real toolchain on PATH.
|
||||||
|
//!
|
||||||
|
//! Every fixture calls `pmacs.project.set_search_boundary` at its own
|
||||||
|
//! tempdir root. Without it the marker walk climbs to the filesystem
|
||||||
|
//! root, and a stray `.git` above the temp directory would silently turn
|
||||||
|
//! the "markerless" cases into detected ones — the assertions would still
|
||||||
|
//! pass while testing nothing.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use pmacs::editor::EditorState;
|
||||||
|
|
||||||
|
fn exec(state: &EditorState, source: &str) {
|
||||||
|
state.lua_host.lua().load(source.to_owned()).exec().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn eval<T: mlua::FromLuaMulti>(state: &EditorState, source: &str) -> T {
|
||||||
|
state.lua_host.lua().load(source.to_owned()).eval().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fake_lsp_path() -> String {
|
||||||
|
env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fresh editor with the shipped language configs cleared, so the only
|
||||||
|
/// server any test can spawn is the fake one it configures itself.
|
||||||
|
fn editor() -> EditorState {
|
||||||
|
let state = EditorState::new();
|
||||||
|
exec(&state, "pmacs.lsp.config = {}");
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lua_str(path: &Path) -> String {
|
||||||
|
path.display()
|
||||||
|
.to_string()
|
||||||
|
.replace('\\', "\\\\")
|
||||||
|
.replace('"', "\\\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirror of `file_uri_for` in `builtin/runtime/lsp.lua` and
|
||||||
|
/// `path_to_file_uri` in `src/lsp.rs`. Reimplemented rather than
|
||||||
|
/// imported so the test states the expected encoding independently of
|
||||||
|
/// the code under test.
|
||||||
|
fn file_uri(path: &Path) -> String {
|
||||||
|
let mut out = String::from("file://");
|
||||||
|
for ch in path.display().to_string().chars() {
|
||||||
|
match ch {
|
||||||
|
'a'..='z' | 'A'..='Z' | '0'..='9' | '/' | '-' | '_' | '.' | '~' | ':' => out.push(ch),
|
||||||
|
_ => {
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
let mut buf = [0u8; 4];
|
||||||
|
for byte in ch.encode_utf8(&mut buf).as_bytes() {
|
||||||
|
let _ = write!(out, "%{byte:02X}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
_dir: tempfile::TempDir,
|
||||||
|
root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
/// Canonicalized so the expected roots below compare equal to what
|
||||||
|
/// `pmacs.project.detect` returns (it canonicalizes before walking,
|
||||||
|
/// which matters on macOS where `/var` is a symlink to `/private/var`).
|
||||||
|
fn new() -> Self {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let root = std::fs::canonicalize(dir.path()).unwrap();
|
||||||
|
Self { _dir: dir, root }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(&self, rel: &str, contents: &str) -> PathBuf {
|
||||||
|
let path = self.root.join(rel);
|
||||||
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(&path, contents).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dir(&self, rel: &str) -> PathBuf {
|
||||||
|
self.root.join(rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bind(&self, state: &EditorState) {
|
||||||
|
exec(
|
||||||
|
state,
|
||||||
|
&format!(
|
||||||
|
"pmacs.project.set_search_boundary(\"{}\")",
|
||||||
|
lua_str(&self.root)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configure(state: &EditorState, language: &str) {
|
||||||
|
exec(
|
||||||
|
state,
|
||||||
|
&format!(
|
||||||
|
"pmacs.lsp.config.{language} = {{ command = \"{}\" }}",
|
||||||
|
fake_lsp_path()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open(state: &EditorState, path: &Path) {
|
||||||
|
exec(
|
||||||
|
state,
|
||||||
|
&format!("pmacs.buffer.find_or_open(\"{}\")", lua_str(path)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settle(state: &mut EditorState) {
|
||||||
|
for _ in 0..8 {
|
||||||
|
state.tick_processes();
|
||||||
|
state.tick_lsp();
|
||||||
|
std::thread::sleep(Duration::from_millis(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `language_id|root_uri|cwd|state` row per live server, sorted so
|
||||||
|
/// assertions do not depend on spawn order. Absent fields read as "".
|
||||||
|
fn rows(state: &EditorState) -> Vec<String> {
|
||||||
|
let joined: String = eval(
|
||||||
|
state,
|
||||||
|
r#"
|
||||||
|
local out = {}
|
||||||
|
for _, s in ipairs(pmacs.lsp.list()) do
|
||||||
|
out[#out + 1] = table.concat({
|
||||||
|
s.language_id or "",
|
||||||
|
s.root_uri or "",
|
||||||
|
s.cwd or "",
|
||||||
|
(s.state and s.state.kind) or "",
|
||||||
|
}, "|")
|
||||||
|
end
|
||||||
|
table.sort(out)
|
||||||
|
return table.concat(out, "\n")
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
if joined.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
joined.lines().map(str::to_owned).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status(state: &EditorState) -> String {
|
||||||
|
state.core.borrow().status.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count(state: &EditorState) -> usize {
|
||||||
|
let n: i64 = eval(state, "return #pmacs.lsp.list()");
|
||||||
|
usize::try_from(n).expect("server count is non-negative")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Acceptance 13 — `lsp.list()` rows carry `root_uri` and `cwd`.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acc13_list_rows_carry_root_uri_and_cwd() {
|
||||||
|
let fx = Fixture::new();
|
||||||
|
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
|
||||||
|
let file = fx.write("proj/src/main.rs", "fn main() {}\n");
|
||||||
|
let mut state = editor();
|
||||||
|
fx.bind(&state);
|
||||||
|
configure(&state, "rust");
|
||||||
|
open(&state, &file);
|
||||||
|
settle(&mut state);
|
||||||
|
|
||||||
|
let proj = fx.dir("proj");
|
||||||
|
let rows = rows(&state);
|
||||||
|
assert_eq!(rows.len(), 1, "{rows:?}");
|
||||||
|
let fields: Vec<&str> = rows[0].split('|').collect();
|
||||||
|
assert_eq!(fields[0], "rust");
|
||||||
|
assert_eq!(
|
||||||
|
fields[1],
|
||||||
|
file_uri(&proj),
|
||||||
|
"root_uri must be the project root"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fields[2],
|
||||||
|
proj.display().to_string(),
|
||||||
|
"cwd must be the root"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Acceptance 14 — two roots, same language, two servers.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acc14_two_project_roots_of_one_language_spawn_two_servers() {
|
||||||
|
let fx = Fixture::new();
|
||||||
|
fx.write("a/Cargo.toml", "[package]\nname = \"a\"\n");
|
||||||
|
fx.write("b/Cargo.toml", "[package]\nname = \"b\"\n");
|
||||||
|
let first = fx.write("a/src/main.rs", "fn main() {}\n");
|
||||||
|
let second = fx.write("b/src/main.rs", "fn main() {}\n");
|
||||||
|
let mut state = editor();
|
||||||
|
fx.bind(&state);
|
||||||
|
configure(&state, "rust");
|
||||||
|
open(&state, &first);
|
||||||
|
settle(&mut state);
|
||||||
|
open(&state, &second);
|
||||||
|
settle(&mut state);
|
||||||
|
|
||||||
|
let rows = rows(&state);
|
||||||
|
assert_eq!(rows.len(), 2, "one server per project root: {rows:?}");
|
||||||
|
let roots: Vec<&str> = rows.iter().map(|r| r.split('|').nth(1).unwrap()).collect();
|
||||||
|
assert!(
|
||||||
|
roots.contains(&file_uri(&fx.dir("a")).as_str()),
|
||||||
|
"{roots:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
roots.contains(&file_uri(&fx.dir("b")).as_str()),
|
||||||
|
"{roots:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Acceptance 15 — same root, two files, one server. The pre-change
|
||||||
|
// behavior, pinned so the fix cannot degrade into "always spawn".
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acc15_two_files_in_one_root_reuse_a_single_server() {
|
||||||
|
let fx = Fixture::new();
|
||||||
|
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
|
||||||
|
let first = fx.write("proj/src/main.rs", "fn main() {}\n");
|
||||||
|
let second = fx.write("proj/src/other.rs", "pub fn other() {}\n");
|
||||||
|
let mut state = editor();
|
||||||
|
fx.bind(&state);
|
||||||
|
configure(&state, "rust");
|
||||||
|
open(&state, &first);
|
||||||
|
settle(&mut state);
|
||||||
|
open(&state, &second);
|
||||||
|
settle(&mut state);
|
||||||
|
|
||||||
|
let rows = rows(&state);
|
||||||
|
assert_eq!(rows.len(), 1, "same root must reuse: {rows:?}");
|
||||||
|
assert_eq!(
|
||||||
|
rows[0].split('|').nth(1).unwrap(),
|
||||||
|
file_uri(&fx.dir("proj"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Acceptance 16 — per-language regression pin. The single-root case is
|
||||||
|
// all the shipped attach paths ever exercised; it must be untouched.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acc16_shipped_languages_are_unchanged_for_the_single_root_case() {
|
||||||
|
// (language id, project marker, two source files under it)
|
||||||
|
let cases: [(&str, &str, &str, &str); 4] = [
|
||||||
|
("rust", "Cargo.toml", "one.rs", "two.rs"),
|
||||||
|
("python", "pyproject.toml", "one.py", "two.py"),
|
||||||
|
("go", "go.mod", "one.go", "two.go"),
|
||||||
|
("typescript", "package.json", "one.ts", "two.ts"),
|
||||||
|
];
|
||||||
|
for (language, marker, first_name, second_name) in cases {
|
||||||
|
let fx = Fixture::new();
|
||||||
|
fx.write(&format!("proj/{marker}"), "{}\n");
|
||||||
|
let first = fx.write(&format!("proj/src/{first_name}"), "\n");
|
||||||
|
let second = fx.write(&format!("proj/src/{second_name}"), "\n");
|
||||||
|
let mut state = editor();
|
||||||
|
fx.bind(&state);
|
||||||
|
configure(&state, language);
|
||||||
|
open(&state, &first);
|
||||||
|
settle(&mut state);
|
||||||
|
open(&state, &second);
|
||||||
|
settle(&mut state);
|
||||||
|
|
||||||
|
let rows = rows(&state);
|
||||||
|
assert_eq!(
|
||||||
|
rows.len(),
|
||||||
|
1,
|
||||||
|
"{language}: expected one server, got {rows:?}"
|
||||||
|
);
|
||||||
|
let fields: Vec<&str> = rows[0].split('|').collect();
|
||||||
|
assert_eq!(fields[0], language, "{language}: language_id");
|
||||||
|
assert_eq!(
|
||||||
|
fields[1],
|
||||||
|
file_uri(&fx.dir("proj")),
|
||||||
|
"{language}: root must be the marker directory"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Acceptance 17 — hoist pin. `project_root_for` now runs on the *reuse*
|
||||||
|
// path, and a function-valued `root` is memoized per directory.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acc17_function_root_runs_on_the_reuse_path_and_memoizes_per_directory() {
|
||||||
|
let fx = Fixture::new();
|
||||||
|
let shared = fx.dir("shared");
|
||||||
|
std::fs::create_dir_all(&shared).unwrap();
|
||||||
|
let a1 = fx.write("one/a.rs", "fn a() {}\n");
|
||||||
|
let a2 = fx.write("one/b.rs", "fn b() {}\n");
|
||||||
|
let b1 = fx.write("two/c.rs", "fn c() {}\n");
|
||||||
|
let mut state = editor();
|
||||||
|
fx.bind(&state);
|
||||||
|
// A resolver that answers the same root for every directory: the
|
||||||
|
// second directory therefore REUSES the first directory's server,
|
||||||
|
// which is exactly the path the hoist put the resolver on.
|
||||||
|
exec(
|
||||||
|
&state,
|
||||||
|
&format!(
|
||||||
|
r#"
|
||||||
|
_G.ROOT_CALLS = 0
|
||||||
|
pmacs.lsp.config.rust = {{
|
||||||
|
command = "{}",
|
||||||
|
root = function(_)
|
||||||
|
_G.ROOT_CALLS = _G.ROOT_CALLS + 1
|
||||||
|
return "{}"
|
||||||
|
end,
|
||||||
|
}}
|
||||||
|
"#,
|
||||||
|
fake_lsp_path(),
|
||||||
|
lua_str(&shared)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
open(&state, &a1);
|
||||||
|
settle(&mut state);
|
||||||
|
assert_eq!(eval::<i64>(&state, "return _G.ROOT_CALLS"), 1, "spawn path");
|
||||||
|
|
||||||
|
// Same directory: served from the memo, so the count does not move.
|
||||||
|
open(&state, &a2);
|
||||||
|
settle(&mut state);
|
||||||
|
assert_eq!(
|
||||||
|
eval::<i64>(&state, "return _G.ROOT_CALLS"),
|
||||||
|
1,
|
||||||
|
"second file in the same directory must hit the memo"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Different directory: the resolver runs again — proving the reuse
|
||||||
|
// path resolves at all — but resolves to the same root, so no second
|
||||||
|
// server appears.
|
||||||
|
open(&state, &b1);
|
||||||
|
settle(&mut state);
|
||||||
|
assert_eq!(
|
||||||
|
eval::<i64>(&state, "return _G.ROOT_CALLS"),
|
||||||
|
2,
|
||||||
|
"a new directory must consult the resolver on the reuse path"
|
||||||
|
);
|
||||||
|
let rows = rows(&state);
|
||||||
|
assert_eq!(rows.len(), 1, "one resolved root, one server: {rows:?}");
|
||||||
|
assert_eq!(rows[0].split('|').nth(1).unwrap(), file_uri(&shared));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Acceptance 18 — a hand-spawned server carrying only `cwd` is not
|
||||||
|
// adopted by a root-bearing attach. A deliberate behavior change.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acc18_hand_spawned_server_without_root_uri_is_not_adopted() {
|
||||||
|
let fx = Fixture::new();
|
||||||
|
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
|
||||||
|
let file = fx.write("proj/src/main.rs", "fn main() {}\n");
|
||||||
|
let proj = fx.dir("proj");
|
||||||
|
let mut state = editor();
|
||||||
|
fx.bind(&state);
|
||||||
|
configure(&state, "rust");
|
||||||
|
// Exactly what an init.lua would write: cwd, no root_uri.
|
||||||
|
exec(
|
||||||
|
&state,
|
||||||
|
&format!(
|
||||||
|
r#"
|
||||||
|
pmacs.lsp.spawn({{
|
||||||
|
label = "hand-rolled",
|
||||||
|
language_id = "rust",
|
||||||
|
command = "{}",
|
||||||
|
cwd = "{}",
|
||||||
|
}})
|
||||||
|
"#,
|
||||||
|
fake_lsp_path(),
|
||||||
|
lua_str(&proj)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
settle(&mut state);
|
||||||
|
assert_eq!(count(&state), 1, "the hand-spawned server is up");
|
||||||
|
|
||||||
|
open(&state, &file);
|
||||||
|
settle(&mut state);
|
||||||
|
|
||||||
|
let rows = rows(&state);
|
||||||
|
assert_eq!(rows.len(), 2, "the attach must not adopt it: {rows:?}");
|
||||||
|
let roots: Vec<&str> = rows.iter().map(|r| r.split('|').nth(1).unwrap()).collect();
|
||||||
|
assert!(
|
||||||
|
roots.contains(&""),
|
||||||
|
"hand-spawned reads back nil: {roots:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
roots.contains(&file_uri(&proj).as_str()),
|
||||||
|
"the attach's own server carries the root: {roots:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Acceptance 19 — a dead server in the matching root is not reused.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acc19_stopped_server_in_the_matching_root_is_not_reused() {
|
||||||
|
let fx = Fixture::new();
|
||||||
|
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
|
||||||
|
let first = fx.write("proj/src/main.rs", "fn main() {}\n");
|
||||||
|
let second = fx.write("proj/src/other.rs", "pub fn other() {}\n");
|
||||||
|
let mut state = editor();
|
||||||
|
fx.bind(&state);
|
||||||
|
configure(&state, "rust");
|
||||||
|
open(&state, &first);
|
||||||
|
settle(&mut state);
|
||||||
|
let original: i64 = eval(&state, "return pmacs.lsp.list()[1].id:raw()");
|
||||||
|
|
||||||
|
exec(&state, "pmacs.lsp.stop(pmacs.lsp.list()[1].id)");
|
||||||
|
for _ in 0..200 {
|
||||||
|
settle(&mut state);
|
||||||
|
let dead: bool = eval(
|
||||||
|
&state,
|
||||||
|
r#"
|
||||||
|
for _, s in ipairs(pmacs.lsp.list()) do
|
||||||
|
local k = s.state and s.state.kind
|
||||||
|
if k == "stopped" or k == "crashed" then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
if dead {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open(&state, &second);
|
||||||
|
settle(&mut state);
|
||||||
|
let live: i64 = eval(
|
||||||
|
&state,
|
||||||
|
r#"
|
||||||
|
for _, s in ipairs(pmacs.lsp.list()) do
|
||||||
|
local k = s.state and s.state.kind
|
||||||
|
if k ~= "stopped" and k ~= "crashed" then
|
||||||
|
return s.id:raw()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return -1
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
assert_ne!(live, -1, "a replacement server must exist");
|
||||||
|
assert_ne!(live, original, "the dead server must not be reused");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Acceptance 20 — the loose-file pin (Q#LN15 part 2). This is the
|
||||||
|
// no-change case, and the one a naive `(language_id, root)` key breaks.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acc20_markerless_files_in_different_directories_share_one_server() {
|
||||||
|
let fx = Fixture::new();
|
||||||
|
let first = fx.write("loose_a/one.rs", "fn one() {}\n");
|
||||||
|
let second = fx.write("loose_b/two.rs", "fn two() {}\n");
|
||||||
|
let mut state = editor();
|
||||||
|
fx.bind(&state);
|
||||||
|
configure(&state, "rust");
|
||||||
|
open(&state, &first);
|
||||||
|
settle(&mut state);
|
||||||
|
open(&state, &second);
|
||||||
|
settle(&mut state);
|
||||||
|
|
||||||
|
let rows = rows(&state);
|
||||||
|
assert_eq!(
|
||||||
|
rows.len(),
|
||||||
|
1,
|
||||||
|
"loose files must keep sharing one server: {rows:?}"
|
||||||
|
);
|
||||||
|
let fields: Vec<&str> = rows[0].split('|').collect();
|
||||||
|
assert_eq!(fields[1], "", "the fallback root is not an affinity key");
|
||||||
|
assert_eq!(
|
||||||
|
fields[2],
|
||||||
|
fx.dir("loose_a").display().to_string(),
|
||||||
|
"cwd still carries the first file's directory"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Acceptance 21 — detected and fallback are different servers, and the
|
||||||
|
// fallback one still carries its directory as `cwd`.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn acc21_detected_root_and_markerless_file_get_different_servers() {
|
||||||
|
let fx = Fixture::new();
|
||||||
|
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
|
||||||
|
let inside = fx.write("proj/src/main.rs", "fn main() {}\n");
|
||||||
|
let loose = fx.write("loose/stray.rs", "fn stray() {}\n");
|
||||||
|
let mut state = editor();
|
||||||
|
fx.bind(&state);
|
||||||
|
configure(&state, "rust");
|
||||||
|
open(&state, &inside);
|
||||||
|
settle(&mut state);
|
||||||
|
open(&state, &loose);
|
||||||
|
settle(&mut state);
|
||||||
|
|
||||||
|
let rows = rows(&state);
|
||||||
|
assert_eq!(rows.len(), 2, "detected and fallback must differ: {rows:?}");
|
||||||
|
let detected = rows
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.split('|').nth(1).unwrap() == file_uri(&fx.dir("proj")))
|
||||||
|
.unwrap_or_else(|| panic!("no server rooted at the project: {rows:?}"));
|
||||||
|
assert_eq!(
|
||||||
|
detected.split('|').nth(2).unwrap(),
|
||||||
|
fx.dir("proj").display().to_string()
|
||||||
|
);
|
||||||
|
let fallback = rows
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.split('|').nth(1).unwrap().is_empty())
|
||||||
|
.unwrap_or_else(|| panic!("no rootless server: {rows:?}"));
|
||||||
|
assert_eq!(
|
||||||
|
fallback.split('|').nth(2).unwrap(),
|
||||||
|
fx.dir("loose").display().to_string(),
|
||||||
|
"the markerless server keeps the fallback directory as cwd"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Review-round-1 pins. Neither is a numbered acceptance criterion; both
|
||||||
|
// cover a branch the nine above leave untested.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A *string* `config.root` is an affinity key. acc17 covers the function
|
||||||
|
/// form; without this the `return configured, "config"` arm has no test.
|
||||||
|
///
|
||||||
|
/// The bite: both files sit in their own marked project, so if the config
|
||||||
|
/// arm were dropped they would key on their own detected roots and spawn
|
||||||
|
/// two servers. One server keyed on the configured root is only possible
|
||||||
|
/// if the override wins.
|
||||||
|
#[test]
|
||||||
|
fn config_string_root_overrides_detection_as_the_affinity_key() {
|
||||||
|
let fx = Fixture::new();
|
||||||
|
fx.write("a/Cargo.toml", "[package]\nname = \"a\"\n");
|
||||||
|
fx.write("b/Cargo.toml", "[package]\nname = \"b\"\n");
|
||||||
|
let first = fx.write("a/src/main.rs", "fn main() {}\n");
|
||||||
|
let second = fx.write("b/src/main.rs", "fn main() {}\n");
|
||||||
|
let shared = fx.dir("shared");
|
||||||
|
std::fs::create_dir_all(&shared).unwrap();
|
||||||
|
let mut state = editor();
|
||||||
|
fx.bind(&state);
|
||||||
|
exec(
|
||||||
|
&state,
|
||||||
|
&format!(
|
||||||
|
"pmacs.lsp.config.rust = {{ command = \"{}\", root = \"{}\" }}",
|
||||||
|
fake_lsp_path(),
|
||||||
|
lua_str(&shared)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
open(&state, &first);
|
||||||
|
settle(&mut state);
|
||||||
|
open(&state, &second);
|
||||||
|
settle(&mut state);
|
||||||
|
|
||||||
|
let rows = rows(&state);
|
||||||
|
assert_eq!(
|
||||||
|
rows.len(),
|
||||||
|
1,
|
||||||
|
"a configured root outranks both detected roots: {rows:?}"
|
||||||
|
);
|
||||||
|
let fields: Vec<&str> = rows[0].split('|').collect();
|
||||||
|
assert_eq!(fields[1], file_uri(&shared), "keyed on the configured root");
|
||||||
|
assert_eq!(fields[2], shared.display().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `root = false` reads as unset, as it always has. Defended in
|
||||||
|
/// `project_root_for` by a truthiness check rather than `~= nil`; this
|
||||||
|
/// pins the behavior instead of trusting the comment.
|
||||||
|
///
|
||||||
|
/// The bite: under a `~= nil` test the config arm would return
|
||||||
|
/// `false, "config"`, and `file_uri_for(false)` returns nil — so the file
|
||||||
|
/// would land on a rootless server instead of its detected project.
|
||||||
|
#[test]
|
||||||
|
fn config_root_false_reads_as_unset_and_detection_still_wins() {
|
||||||
|
let fx = Fixture::new();
|
||||||
|
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
|
||||||
|
let file = fx.write("proj/src/main.rs", "fn main() {}\n");
|
||||||
|
let mut state = editor();
|
||||||
|
fx.bind(&state);
|
||||||
|
exec(
|
||||||
|
&state,
|
||||||
|
&format!(
|
||||||
|
"pmacs.lsp.config.rust = {{ command = \"{}\", root = false }}",
|
||||||
|
fake_lsp_path()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
open(&state, &file);
|
||||||
|
settle(&mut state);
|
||||||
|
|
||||||
|
let rows = rows(&state);
|
||||||
|
assert_eq!(rows.len(), 1, "{rows:?}");
|
||||||
|
assert_eq!(
|
||||||
|
rows[0].split('|').nth(1).unwrap(),
|
||||||
|
file_uri(&fx.dir("proj")),
|
||||||
|
"`false` must not become a root; detection still wins"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// COHERENCE §1.2: background wiring must leave an attributed trace
|
||||||
|
/// rather than discard a failure. A throwing root resolver is a config
|
||||||
|
/// bug, and the per-directory memo would otherwise bury it permanently.
|
||||||
|
///
|
||||||
|
/// The bite: drop the reporting arm and `*errors*` stays empty while the
|
||||||
|
/// attach still succeeds — the exact silence §1.2 names as the canonical
|
||||||
|
/// anti-pattern, in the function it cites.
|
||||||
|
#[test]
|
||||||
|
fn a_throwing_root_resolver_leaves_an_attributed_trace() {
|
||||||
|
let fx = Fixture::new();
|
||||||
|
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
|
||||||
|
let file = fx.write("proj/src/main.rs", "fn main() {}\n");
|
||||||
|
let mut state = editor();
|
||||||
|
fx.bind(&state);
|
||||||
|
exec(
|
||||||
|
&state,
|
||||||
|
&format!(
|
||||||
|
r#"
|
||||||
|
pmacs.lsp.config.rust = {{
|
||||||
|
command = "{}",
|
||||||
|
root = function(_) error("resolver blew up") end,
|
||||||
|
}}
|
||||||
|
"#,
|
||||||
|
fake_lsp_path()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
open(&state, &file);
|
||||||
|
settle(&mut state);
|
||||||
|
|
||||||
|
let msg = status(&state);
|
||||||
|
assert!(
|
||||||
|
msg.contains("root resolver"),
|
||||||
|
"a raising resolver must leave an attributed trace; got: {msg:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
msg.contains("rust"),
|
||||||
|
"the trace must name the language that owns it; got: {msg:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
msg.contains("resolver blew up"),
|
||||||
|
"the underlying error text must survive; got: {msg:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ...and the failure must degrade to a decline, not a failed attach:
|
||||||
|
// detection still wins and the buffer still gets its server.
|
||||||
|
let rows = rows(&state);
|
||||||
|
assert_eq!(rows.len(), 1, "the attach must still succeed: {rows:?}");
|
||||||
|
assert_eq!(
|
||||||
|
rows[0].split('|').nth(1).unwrap(),
|
||||||
|
file_uri(&fx.dir("proj")),
|
||||||
|
"a declining resolver falls through to the marker walk"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The decline path stays silent. Without this, "report failures" could
|
||||||
|
/// be satisfied by reporting *every* resolution, which would spam
|
||||||
|
/// `*errors*` on every attach in a Lean project.
|
||||||
|
#[test]
|
||||||
|
fn a_resolver_returning_nil_declines_silently() {
|
||||||
|
let fx = Fixture::new();
|
||||||
|
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
|
||||||
|
let file = fx.write("proj/src/main.rs", "fn main() {}\n");
|
||||||
|
let mut state = editor();
|
||||||
|
fx.bind(&state);
|
||||||
|
exec(
|
||||||
|
&state,
|
||||||
|
&format!(
|
||||||
|
"pmacs.lsp.config.rust = {{ command = \"{}\", root = function(_) return nil end }}",
|
||||||
|
fake_lsp_path()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
open(&state, &file);
|
||||||
|
settle(&mut state);
|
||||||
|
|
||||||
|
let msg = status(&state);
|
||||||
|
assert!(
|
||||||
|
!msg.contains("root resolver"),
|
||||||
|
"returning nil is the documented decline, not a failure; got: {msg:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rows(&state)[0].split('|').nth(1).unwrap(),
|
||||||
|
file_uri(&fx.dir("proj"))
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue