diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index eb4bb25..9ace580 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -260,6 +260,197 @@ pmacs.lsp.config.yaml = pmacs.lsp.config.yaml or { }, } +-- LaTeX via `texlab`. Framing: +-- `docs/lsp-language-coverage-framing.md` §3 (the root) and Q#LX1 (no +-- settings). +-- +-- **No `pmacs.lsp.filetypes` entries ship for `.tex`/`.latex`/`.sty`/ +-- `.cls`, deliberately.** The bundled grammar already declares exactly +-- those extensions (`src/syntax.rs`, `name: "latex"`), and grammar +-- extension detection sits AHEAD of this map in +-- `detect_buffer_language` (`syntax.lua`) — modeline → grammar +-- extension → LSP filetype map → filename → shebang. So a `.tex` buffer +-- already resolves to `latex` and a map entry would be dead weight that +-- a later reader could mistake for the thing that made attach work. +-- +-- **No `settings` / `init_options` (Q#LX1).** texlab pulls its config +-- through `workspace/configuration` under a `texlab` section, which +-- pmacs answers; an absent section takes texlab's defaults. The two +-- candidates — build-on-save and forward-search — are both opinionated, +-- and forward-search additionally needs a configured viewer, so any +-- default would be wrong for most machines. Users override through the +-- same `init.lua` seam as every other entry here. +-- +-- Q#LX2 — the root, and why it cannot be `pmacs.project.detect`. +-- +-- **`.git` is deliberately NOT a marker.** texlab wants the *document* +-- root, not the repository root: a thesis inside a monorepo would +-- otherwise hand texlab the monorepo. This is the one entry where +-- copying the other fourteen's instinct is actively wrong — which is +-- also why this resolver must never return nil for a markerless file. +-- `project_root_for` falls through to `pmacs.project.detect` on a nil, +-- and that walk *does* include `.git`; returning the file's own +-- directory is what keeps the repository root out. +-- +-- The marker set is texlab's own, established by observation against +-- texlab 5.25.1 rather than assumed — `crates/distro/src/language.rs` +-- at that tag maps `.texlabroot`/`texlabroot` → `Root`, +-- `Tectonic.toml` → `Tectonic`, `.latexmkrc`/`latexmkrc` → `Latexmkrc`, +-- and `ProjectRoot::walk_and_find` (`crates/base-db/src/deps/root.rs`) +-- tests all three per ancestor directory, innermost wins. Matching that +-- set means pmacs hands texlab the directory texlab would itself pick. +-- +-- **texlab cannot pick it alone, which is what makes this resolver +-- load-bearing.** `walk_and_find` only sees markers belonging to +-- documents already in the workspace, and the workspace is built from +-- the folders the CLIENT supplies. Live LSP sessions confirmed it: with +-- `rootUri` at a `chapters/` subdirectory, no marker above it — +-- `.texlabroot` included — widened texlab's view, and its dependency +-- graph never reached the parent document; with `rootUri` at the marker +-- directory the parent resolved, marker or not. texlab honours the root +-- it is given and never corrects a too-narrow one, so whatever this +-- function returns *is* the project scope. +-- +-- Intra-directory precedence is unobservable here on purpose: the walk +-- returns a DIRECTORY, so two markers side by side yield the same +-- answer in either order. Only the innermost-ancestor rule matters. +-- +-- Scanning for `\documentclass` — the semantically correct notion of a +-- root document — is deliberately not done: it is a directory scan per +-- resolve with its own caching and invalidation questions. If the +-- marker walk proves insufficient in use, that is the next increment, +-- with evidence. +local LATEX_ROOT_MARKERS = { + ".texlabroot", "texlabroot", + "Tectonic.toml", + ".latexmkrc", "latexmkrc", +} + +-- Synchronous existence test. `pmacs.fs.stat` is unusable here: it +-- returns an awaitable handle, and this runs inside `ensure_server` <- +-- `attach_buffer` <- the `buffer.after-load` hook, where there is no +-- coroutine to await on. `io.open` is the only synchronous check, and +-- it is wrong in both directions on its own — it SUCCEEDS on a +-- directory, and requiring a non-nil read would reject an empty +-- `.texlabroot`, which is the normal way that marker is written. The +-- discriminator is `read`'s second return, exactly as `lean.lua` +-- establishes it: content -> no error; empty file -> nil, no error; +-- directory -> nil, "Is a directory"; missing -> `io.open` nil. +local function latex_marker_in(dir) + -- Joining, not testing: `/` is the one directory that already ends in + -- a separator, and `dir .. "/" .. name` would give `//name` — the + -- exactly-two-leading-slashes spelling POSIX leaves implementation- + -- defined. `/` became reachable here once the walk stopped treating + -- the filesystem root as off-limits, so the join has to say so. + local base = (dir == "/") and "" or dir + for _, name in ipairs(LATEX_ROOT_MARKERS) do + local f = io.open(base .. "/" .. name, "r") + if f then + local _, err = f:read(1) + f:close() + if err == nil then return true end + end + end + return false +end + +-- `/` is a directory like any other. The pattern below yields the EMPTY +-- string for a top-level directory (`/tmp` -> ``), and treating that as +-- "no parent" would make the filesystem root the one directory this walk +-- can never examine — the same root-is-special bug the boundary test +-- below had, from the other end. `/` itself matches nothing (no +-- non-separator component to strip), so the walk still terminates there. +-- This matches `walk_for_marker`'s use of `Path::ancestors` +-- (`src/project.rs`), which likewise ends at `/` inclusive. +local function latex_parent_of(dir) + local up = dir:match("^(.*)/[^/]+$") + if up == nil or up == dir then return nil end + if up == "" then return "/" end + return up +end + +-- The walk stops at `pmacs.project.search_boundary()`. Not politeness: +-- `detect_project_within` (`src/project.rs`) exists so a stray marker +-- above a temp fixture cannot leak into detection, and a Lua walk that +-- ignored the boundary would break that contract — and make this +-- resolver's own acceptance fixtures non-hermetic against any +-- `latexmkrc` sitting above the test's tempdir (R8's shape exactly). +-- +-- Containment is a question about PATH COMPONENTS, so it is answered by +-- comparing components. The previous string-prefix form +-- (`dir:sub(1, #boundary + 1) == boundary .. "/"`) silently disabled the +-- entire walk for a `/` boundary: the needle became `"//"`, which no +-- canonical path begins with, so every ancestor was judged out of +-- bounds, no marker was ever examined, and each chapter of a thesis got +-- its own server. Segment comparison makes the root boundary a boundary +-- with zero segments — containing everything, by construction rather +-- than by a special case — and absorbs a trailing separator for free. +-- +-- Both arguments are canonical absolute paths (`latex_root_for` +-- canonicalizes `dir`; `set_search_boundary` canonicalizes the boundary +-- at set time), so a leading-separator mismatch cannot arise. +local function latex_path_segments(path) + local segs = {} + for seg in path:gmatch("[^/]+") do + segs[#segs + 1] = seg + end + return segs +end + +local function latex_within_boundary(dir, boundary) + if not boundary then return true end + local want = latex_path_segments(boundary) + local have = latex_path_segments(dir) + if #have < #want then return false end + for i = 1, #want do + if have[i] ~= want[i] then return false end + end + return true +end + +-- Returns the INNERMOST ancestor holding a texlab root marker, or the +-- file's own directory when there is none. +-- +-- **The result is canonical, and must be.** A configured root reaches +-- `file_uri_for` verbatim and that URI is the server-affinity key +-- (#161); one document tree opened through a symlink and through its +-- real path would otherwise spawn two texlab processes. Canonicalizing +-- once up front suffices — every ancestor of a canonical path is itself +-- canonical, because the walk only strips trailing components. +-- +-- Declines (nil) only when there is no directory to vouch for: a +-- pathless buffer, or a canonicalize failure on a deleted file or +-- broken symlink. +local function latex_root_for(path) + if type(path) ~= "string" then return nil end + local dir = path:match("^(.*)/[^/]*$") + if not dir then return nil end + -- Same root-is-special trap as `latex_parent_of`: `/paper.tex` slices + -- to an EMPTY directory, which canonicalizes to nothing and would make + -- the resolver DECLINE — and a decline is the one path that reaches + -- `pmacs.project.detect`, whose walk includes `.git`. + if dir == "" then dir = "/" end + dir = pmacs.fs.canonicalize(dir) + if not dir then return nil end + local boundary + local ok, b = pcall(pmacs.project.search_boundary) + if ok then boundary = b end + -- The boundary is canonicalized at set time (`set_search_boundary`), + -- so comparing it against a canonical `dir` is apples to apples. + local cur = dir + while cur and latex_within_boundary(cur, boundary) do + if latex_marker_in(cur) then return cur end + cur = latex_parent_of(cur) + end + return dir +end + +pmacs.lsp.config.latex = pmacs.lsp.config.latex or { + command = "texlab", + args = {}, + root = latex_root_for, +} + -- LSP-side extension → language map, deliberately independent of the -- tree-sitter detection in `pmacs.parse`. Consulted only when -- `pmacs.parse.language_for_path` finds nothing (an extension with a diff --git a/docs/active-work.md b/docs/active-work.md index e0816fd..59783a1 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,10 +265,146 @@ also removed: this branch's "R8 NEEDS A LANE" investigation block, and durable facts are in the retired registry row and the handoff §6 census. -## `scripts/gate --protocol` build step — IMPLEMENTED at `49bc141`, RE-OPENED by review, witness CLOSED at `677fd25`. No PR yet +## LSP LaTeX coverage — IMPLEMENTED, gates green, no PR yet -**PR #229 OPEN** — https://github.com/levineuwirth/pmacs/pull/229, -opened at `93d557a`. **Held, not merged.** Its first CI run went red on +**Written with the lane's first commit**, per the standing correction +from #171 and #215. + +**Branch `lsp-latex-coverage`**, base `githubsucks/main` @ `4bc55e8` +(the #225 merge). **`githubsucks/lsp-latex-coverage` is the +authoritative tip** — the ref, not a SHA. Recover with +`git fetch githubsucks && git checkout lsp-latex-coverage`. + +- **Framing `docs/lsp-language-coverage-framing.md`, revision 3 — + IMPLEMENTATION AUTHORIZED 2026-08-09**, after a summary of its four + corrections rather than a findings round on the document itself. + Recorded that way deliberately: the §3 `.texlabroot` verification + caveat was live and binding, and was step zero of the work rather + than a footnote it could be read past. **It is now discharged — see + below.** **Revision 1 was UNTRACKED on `main` in one checkout** and + therefore did not travel; committing it here is the fix. +- **Scope: one `pmacs.lsp.config.latex` entry plus its root resolver.** + `texlab` 5.25.1 is installed and unused; a `.tex` buffer highlights + correctly and offers no completion, diagnostics, or go-to-definition. +- **Revision 2 found Slice 1 is SMALLER than revision 1 framed.** The + proposed `.tex`/`.latex`/`.sty`/`.cls` filetype mappings are + redundant: the grammar already carries exactly those extensions + (`src/syntax.rs:1111`), grammar-extension detection sits **ahead** of + the LSP filetype map in the precedence chain + (`docs/latex-grammar-math-substrate-framing.md:166-171`), and + `lsp.lua:267-270` calls that map "mainly the LSP-only fallback". The + two systems cannot disagree, because the grammar's extension list is + what drives detection. +- **Two other corrections.** `haskell-language-server` **is** installed + on this machine — revision 1 said it was not, which was the whole + basis of its Slice 1 / Slice 2 split. And Q#LX3's deferral argument + read `COHERENCE.md:1669` ("first slice in flight") when `:124` and + `:867` both record multi-root affinity as **merged (#161)**; that + line contradicts the same document twice and wants a separate fix. +- **Q#LX2 (the LaTeX root) is answered.** An upward marker walk through + `config.latex.root`, which already accepts a resolver function + (`lsp.lua:543`), falling back to the file's own directory. + **`.git` is deliberately excluded**: a repo root is the wrong answer + for LaTeX, and it is the one place copying the other fourteen + entries' instinct is actively wrong. +- **STEP ZERO IS DISCHARGED — §3's `.texlabroot` caveat, by + observation.** Marker 1 **ships**, and the framing's premise for it + was corrected in the process. + - **`.texlabroot` is a real texlab marker.** texlab v5.25.1's + `crates/distro/src/language.rs` maps `.texlabroot`/`texlabroot` → + Root, `Tectonic.toml` → Tectonic, `.latexmkrc`/`latexmkrc` → + Latexmkrc; `ProjectRoot::walk_and_find` + (`crates/base-db/src/deps/root.rs`) walks ancestors testing all + three, innermost wins. The shipped marker set is **texlab's own**, + including the bare `texlabroot`/`latexmkrc` spellings the framing + did not list. + - **But texlab cannot apply that walk to fix a root pmacs gets + wrong**, which is the correction that matters. Each arm searches + `workspace.iter()` — documents ALREADY LOADED — and the workspace + comes from the folders the CLIENT supplies. Hand-driven LSP + sessions confirmed it: with `rootUri` at a `chapters/` + subdirectory, no marker above it (`.texlabroot` included) widened + texlab's view and its dependency graph never reached the parent + document; with `rootUri` at the marker directory the parent + resolved, marker present or not. **texlab honours the root it is + handed and never corrects a too-narrow one**, so what + `config.latex.root` returns *is* the project scope. That makes the + resolver the whole value of the lane rather than a nicety. + - **`args = {}` is also observed**, not assumed: bare `texlab` + answers `initialize` with `TexLab 5.25.1` over stdio, so the `run` + subcommand is not needed. + - **§3 said the wrong thing and has been corrected — `b5eaf27` IS + revision 3.** It framed marker 1 as conditional on texlab honouring + the `.texlabroot` *file*, when the operative fact is that texlab + honours the *client-supplied root* and never widens it. The caveat + was discharged by observation, and revision 3 records what that + established. Nothing about §3 is outstanding. +- **`.git` exclusion needed more than omitting it from the list.** + `project_root_for` falls through to `pmacs.project.detect` when a + resolver returns nil, and **that** walk includes `.git` — so a + resolver declining on a markerless file would hand texlab the + repository root by the back door. The resolver therefore never + declines for a file that has a directory. Pinned end to end through + attach, with the same fixture asserting the shared detector really + would have answered the repo root. +- **Commit `a9ef37f`** — `builtin/runtime/lsp.lua` plus + `tests/lsp_latex_acceptance.rs` (14 tests, one per §6 bullet plus the + boundary and decline cases). No `settings`/`init_options` (Q#LX1); no + filetype mappings (§2, asserted both ways). +- **Gates: ALL GREEN** via + `./scripts/gate --acceptance lsp_latex_acceptance` — fmt, clippy, + lib, lib-crdt, the new suite, m4, gpu, the workspace sweep (115 + suites, zero failures), diff-check. No `--protocol` — a config entry, + no wire. +- **Seven mutations each fail the suite**: resolver declining on no + marker (6 tests), no marker walk (4), a redundant `filetypes.tex` + (1), boundary ignored (1), `io.open` truthiness so a directory counts + as a marker (1), marker set narrowed (4), command renamed with + opinionated settings added (1). +- **The boundary has now been the interesting part twice, and the + second time it was a real defect (fixed in review).** First it was + hermeticity — every fixture sets `set_search_boundary` at its own + tempdir because R8's shape (a stray `latexmkrc` above the tempdir) + would make the markerless assertions pass while testing nothing. + Then review found `latex_within_boundary` answering a PATH question + with string arithmetic: `dir:sub(1, #boundary + 1) == boundary .. "/"` + compares against `"//"` when the boundary is `/`, which no canonical + path matches, so a root boundary judged **every** ancestor out of + bounds, ran no marker walk at all, and gave each chapter of a thesis + its own server — the lane's headline behaviour silently off, with + every shipped test still green because each one clamps to a tempdir. + The same trap sat at the other end (`/` was never a walk candidate, + and `/paper.tex` sliced to an empty directory and declined into the + `.git`-aware detector). Now segment comparison throughout: the root + is a boundary with zero segments, contained by construction rather + than by a special case. Pinned by an ATTACH-level test under a `/` + boundary — two chapters, one server, marker root — and the + hermeticity property asserts **both** directions, since "stops at the + boundary" is also satisfied by a walk that never runs. Suite is 16 + tests. **A reader + deciding whether to trust this resolver should read it as: the marker + set and the `.git` exclusion were settled by observation and are + solid; the boundary arithmetic around them was not, and is the place + to look first if roots come back wrong.** +- **Trap for the next agent in this worktree:** this machine exports a + shared `CARGO_TARGET_DIR`, so a bare `cargo test` compiles against a + sibling worktree's artifacts and fails with errors from code that is + not in this tree. Use `scripts/gate`, or + `CARGO_TARGET_DIR="$(./scripts/gate --print-target-dir)"` for ad-hoc + runs. `scripts/gate`'s own header documents this; the failure looks + like a broken branch, which is why it is recorded here. +- **No PR opened**, by instruction. + +## `scripts/gate --protocol` build step — **MERGED as #229** (`7cf4653`) + +**MERGED as PR #229** — https://github.com/levineuwirth/pmacs/pull/229, +at `3b10f9d`, 14/14 CI green including both macOS legs. `main` is now +`7cf4653`. *(This lane still awaits Rule 4 retirement — its durable +facts belong in the handoff before the entry is removed. Corrected here +only because the previous text said "Held, not merged", which the merge +falsified; the retirement itself is not this lane's work.)* + +**History, retained:** opened at `93d557a`. Its first CI run went red on `Test (macos-latest / lua54)`; the rerun turned that selector green and went red on a **different** one. Both are recorded as **U4** and **U5** in `docs/ci-red-signatures.md`, as separate incidents per the matching @@ -466,6 +602,7 @@ authoritative tip** — the ref, not a SHA. Recover with emission, an aborting runner, the build folded into `sweep-crdt`, and — added in the second round — a **rename of either** the build or the sweep step each fail the suite. +||||||| parent of 72bbb96 (docs: LSP LaTeX coverage framing revision 2, on a branch at last) ## QoL arc retirement — PR #224 OPEN (docs only) diff --git a/docs/lsp-language-coverage-framing.md b/docs/lsp-language-coverage-framing.md new file mode 100644 index 0000000..7a6b55e --- /dev/null +++ b/docs/lsp-language-coverage-framing.md @@ -0,0 +1,273 @@ +# LSP language coverage: LaTeX (and the Haskell/OCaml question) + +**Status: revision 3. Implemented at `d79afdc`; step zero discharged by observation, and its result corrected two things this document had wrong.** + +*Recorded precisely: the user authorized dispatch after a summary of +revision 2's four corrections, rather than returning findings on the +document as they did for the other lanes. The §3 verification caveat is +therefore still live and binding — it is step zero, not a footnote.* + +**Revision 2 corrects three facts revision 1 got wrong or stale, and +answers the question revision 1 named as most likely to make the entry +wrong in practice.** Haskell's server *is* installed; Slice 1 is +**smaller** than framed because the extension wiring already exists; +Q#LX3's deferral argument rests on a `COHERENCE.md` line the same +document contradicts twice; and Q#LX2 (the LaTeX root) now has a +proposal rather than a shrug. + +**Revision 1 was untracked, on `main`, in one checkout.** Per the +handoff's own rule — work is portable only after it is committed and +pushed — it did not travel. That is fixed by this branch. + +--- + +## 0. What prompted this + +An audit of the host machine against `builtin/runtime/lsp.lua`. pmacs +configures LSP for fourteen languages — verified exactly, by extracting +the `pmacs.lsp.config.*` keys: + + bash c cmake cpp cuda dockerfile go json lua + python rust toml yaml zig + +**Lean is NOT among the gaps, and revision 1's first draft wrongly said +it was.** Lean 4 has `builtin/runtime/lean.lua`, `lean_abbrev.lua` and +`lean_input.lua` (all three present), an `arborium-lean` grammar, and +comment/typed-edit integration — Arc 8 Stages 1–4b, merged. The error +came from grepping `lsp.lua` alone, which is the wrong place to look +for a language that earned its own module. + +## 1. The gap, re-measured + +| Language | tree-sitter | LSP config | Server on this machine | +|---|---|---|---| +| LaTeX | ✅ grammar + `builtin/queries/latex/highlights.scm` | ❌ | **`texlab` 5.25.1 — installed** | +| Haskell | ❌ | ❌ | **`haskell-language-server` — INSTALLED** | +| OCaml | ❌ | ❌ | `ocaml`/`opam`/`dune` yes, `ocaml-lsp-server` **absent** | + +**Correction: revision 1 said Haskell's server was missing.** Both +`haskell-language-server` and `haskell-language-server-wrapper` are on +this machine. That collapses revision 1's Slice 1 / Slice 2 split, +which rested on "Slice 2 needs servers installed first" — only OCaml +does now. + +LaTeX remains the sharp case: the grammar work landed, so a `.tex` +buffer highlights correctly **and** offers no completion, no +diagnostics, no go-to-definition, while `texlab` sits on disk unused. + +## 2. Ground truth — Slice 1 is smaller than revision 1 claimed + +Revision 1 proposed "one `pmacs.lsp.config.latex` entry, **plus** +filetype mappings for `.tex`/`.latex`/`.sty`/`.cls`, matching the +grammar's existing extension set so highlighting and LSP agree on what +a LaTeX file is." + +**The filetype mappings are redundant, and the rationale describes a +problem that cannot occur.** Three facts, read rather than assumed: + +- **The grammar already carries exactly those extensions.** + `src/syntax.rs:1110-1112` — `name: "latex"`, + `extensions: &["tex", "latex", "sty", "cls"]`. +- **Grammar-extension detection sits AHEAD of the LSP filetype map.** + The merged `docs/latex-grammar-math-substrate-framing.md:166-171` + states the chain — *modeline → grammar extension → LSP filetype map → + filename map → shebang* — and concludes that adding those extensions + "**wires the whole chain with no Lua edit**". +- **The filetype map is explicitly a fallback.** `lsp.lua:267-270`: + "Every language with an LSP config now also ships a grammar, so this + is mainly the **LSP-only fallback** that keeps a language id stable if + a grammar is ever dropped, plus the seam for user-added mappings." + +So a `.tex` buffer **already** resolves to language `latex`. They +cannot disagree, because the grammar's extension list *is* what drives +detection. + +**Slice 1 is therefore one thing: the `pmacs.lsp.config.latex` entry** +(plus its root resolver, §3). Filetype-map entries may still be added +as the documented drop-a-grammar fallback, but that is belt-and-braces +and should be labelled as such rather than sold as making two systems +agree. + +## 3. Q#LX2 — the LaTeX project root **(answered in rev 2)** + +Revision 1 called this "the question most likely to make the entry +wrong in practice" and left it open. It is the difference between +texlab serving a multi-file thesis and serving isolated files, so it is +the whole value of the lane for the stated use case. + +**The mechanism exists.** `pmacs.lsp.config..root` accepts a +string **or a resolver function**, resolved through `resolve_root_fn` +(`lsp.lua:543`) with per-resolver memoization, and on the *reuse* path +as well as the spawn path (`:535`). A configured root "MUST be a +canonical absolute path" (`:525`). So this is a config entry, not new +machinery. + +*My vote: **an upward marker walk with an explicit precedence, falling +back to the file's own directory.*** In order: + +1. **`.texlabroot`** — if texlab honours it (see the verification + caveat below), an explicit user-placed marker should win over + everything inferred. +2. **`latexmkrc` / `.latexmkrc`** — a build config is a strong, + deliberate signal of a document root. +3. **`Tectonic.toml`** — the same for tectonic projects. +4. **The file's own directory**, as the fallback. + +**Deliberately NOT in the walk: `.git`.** A repository root is the +wrong answer for LaTeX — texlab wants the *document* root, and a thesis +inside a monorepo would otherwise get the monorepo. This is the one +place where copying the other fourteen entries' instinct would be +actively wrong. + +**And omitting it is NOT sufficient — revision 2 stopped one step +short.** `project_root_for` falls through to `pmacs.project.detect` +when a resolver returns `nil`, and **that** walk lists `.git` among its +markers (`src/project.rs:184`). So a resolver that politely declined on +a markerless file would hand texlab the monorepo **by the back door**, +with the exclusion looking correct at every line you would think to +read. The resolver therefore **never declines** for a file with a +directory, and the pin is end-to-end through attach — with the same +fixture asserting the shared detector really would have answered the +repository root, so the test cannot pass vacuously. + +**Deliberately NOT proposed: scanning for `\documentclass`.** That is +the semantically correct notion of a root document, and it is a +directory scan on every resolve, with its own caching and invalidation +questions. If the marker walk proves insufficient in use, that is the +next increment — with evidence. + +**CAVEAT DISCHARGED (revision 3), and the premise behind it was wrong +in a way that raises the lane's stakes.** + +Established by driving a hand-written LSP client against `texlab run` +and reading texlab's source at the exact installed tag `v5.25.1`: + +- **`.texlabroot` is real**, and so is a wider marker set than this + document listed. `crates/distro/src/language.rs` maps + `.texlabroot`/**`texlabroot`** → Root, `Tectonic.toml` → Tectonic, + `.latexmkrc`/**`latexmkrc`** → Latexmkrc, and + `ProjectRoot::walk_and_find` walks ancestors testing all three, + **innermost winning**. The implementation ships texlab's own set, + including the bare spellings §3 omitted. +- **But texlab cannot rescue a root we get wrong.** Every arm of that + walk searches `workspace.iter()` — documents *already loaded* — and + the workspace is built from the folders **the client supplies**. + Observed directly: with `rootUri` at `chapters/`, no ancestor marker + (`.texlabroot` included) widened texlab's view, and its dependency + graph never reached the parent document; with `rootUri` at the marker + directory, the parent resolved whether or not a marker was present. + +**So `config.latex.root` IS the project scope.** Revision 2 framed the +resolver as choosing between plausible roots that texlab might refine. +It does not refine. The resolver is the whole value of the lane for a +multi-file thesis, not a nicety — which is the opposite of how §2's +"Slice 1 is one config entry" reads, and worth stating plainly. + +*(Also observed rather than assumed: bare `texlab` answers `initialize` +over stdio, so `args = {}` is correct and the `run` subcommand is +unnecessary.)* + +## 4. Open questions + +### Q#LX1 — does `texlab` need `settings` or `init_options`? + +It pulls configuration via `workspace/configuration` under a `texlab` +section, which pmacs answers (#13). An empty section takes defaults, as +the Go entry does for gopls. + +*My vote: **ship nothing.*** Build-on-save and forward-search are the +two candidates and both are opinionated; forward-search additionally +needs a configured viewer, so a default would be wrong for most +machines. Users override through the same `init.lua` seam as the other +fourteen. + +### Q#LX4 — do Haskell and OCaml belong in this lane at all? *(renumbered — see below)* + +With HLS installed, Haskell is now the same shape as LaTeX: one entry, +no new dependency. **But the argument against it never rested on the +dependency.** The `.hs` files here are `levineuwirth.org`'s Hakyll +generator, edited rarely; HLS is version-coupled to GHC and is a large +resident process for a language touched a few times a year. + +*My vote: **LaTeX only in this lane.*** Add Haskell when there is use +evidence, which is a one-line change at that point. OCaml needs +`ocaml-lsp-server` via opam (not packaged for Arch) and is not close. + +**Renumbered from Q#HS1 deliberately.** The merged +`docs/latex-grammar-math-substrate-framing.md` already uses **Q#LX2** +for a different question — its grammar vendoring source (`:83`) — so +revision 1's Q#LX2 collided with a live ID in the same language area. +This document's LaTeX questions are Q#LX1 and the root question in §3; +the language-scope question takes Q#LX4 to avoid a second collision. + +### Q#LX3 — does this touch multi-root LSP affinity? — **RESOLVED, and revision 1 read a stale line** + +Revision 1 called this "the one item that could argue for deferring +Slice 1", on the basis that multi-root affinity was in flight. + +**It merged as PR #161.** `COHERENCE.md:124` lists it among landed +coherence work, and `:867` says "First slice landed (PR #161)". Only +`:1669` still says "first slice in flight" — and that line contradicts +the other two **within the same document**. + +So the deferral argument dissolves: a LaTeX entry keyed like the +existing servers rides the convention that already landed. **The +`COHERENCE.md:1669` inconsistency is real and should be fixed**, but by +whoever next touches §20 — not smuggled into this lane. + +## 5. Coherence impact (§20) + +- **Journey steps touched: none.** This adds a row to an existing + registry; no new surface, keybinding, or panel. +- **Interaction islands: none added.** +- **Config registry adoption: yes, and only that.** One entry in the + existing `pmacs.lsp.config` table, overridable from `init.lua` by the + same mechanism as the fourteen already there. +- **Background-work attribution (§9): unchanged, and NOT improved.** + texlab spawns under the existing LSP supervision path with no new + lifecycle — but it is another process that appears in `*lsp*` and + whose requests appear in `*workers*` with nothing joining them. The + worker-identity lane owns that; this lane neither helps nor worsens + it. +- **§20 classification: WIRING, not model.** It surfaces machinery that + already exists rather than adding a runtime entity — and §2 shows it + is *more* purely wiring than revision 1 thought. + +## 6. Verification + +- **A `.tex` buffer attaches texlab**, witnessed end to end rather than + by asserting the config table's contents. +- **Detection is unchanged**: `.tex`/`.latex`/`.sty`/`.cls` still + resolve to `latex` via the grammar path (§2), asserted so that a + later "helpful" filetype-map addition cannot be mistaken for the + thing that made it work. +- **The root resolver returns the marker directory**, witnessed on a + fixture with a `latexmkrc` above a `chapters/` subdirectory — the + thesis shape, which is the case a file-directory root gets wrong. +- **It falls back to the file's own directory** with no marker present. +- **`.git` does NOT become the root** (§3) — a fixture with a + repository above a document directory, asserting the document + directory wins. This is the case where copying the other entries' + instinct is wrong, so it is pinned. +- **A missing `texlab` surfaces guidance**, through the existing + spawn-failure path (#204) — asserted, not assumed, since that path is + what makes the failure honest. +- **Fixtures bound project detection** with + `pmacs.project.set_search_boundary`. R8 was a fixture letting + detection escape into the developer's environment; a LaTeX root + fixture is exactly that hazard's shape. + +**What this will NOT prove:** that texlab resolves multi-file `\input` +graphs correctly (that is texlab's job, not pmacs's), or that Haskell +and OCaml work (Q#LX4). + +## 7. Not in scope + +New tree-sitter grammars — Haskell and OCaml would have LSP without +highlighting, a real asymmetry that must be stated in the PR rather +than discovered by a user. Any change to Lean, which needs none. +Math/typesetting work (`#172` owns it). Any change to the LSP +spawn-failure surface (#204). Scanning for `\documentclass` to find a +root document (§3). Fixing `COHERENCE.md:1669`'s stale multi-root line +(Q#LX3) — real, but another lane's edit. Haskell and OCaml entries +(Q#LX4). diff --git a/tests/lsp_latex_acceptance.rs b/tests/lsp_latex_acceptance.rs new file mode 100644 index 0000000..4ccd138 --- /dev/null +++ b/tests/lsp_latex_acceptance.rs @@ -0,0 +1,699 @@ +// tests/lsp_latex_acceptance.rs --- LSP language coverage: LaTeX. + +//! `docs/lsp-language-coverage-framing.md` §6, one test per bullet. +//! +//! The lane ships exactly one thing: `pmacs.lsp.config.latex`, command +//! `texlab`, with a function-valued `root` that walks up for texlab's +//! own project markers and stops at the document directory. Two pins +//! are load-bearing and the rest guard the boundary around them: +//! +//! * the resolver returns the MARKER directory for a thesis whose +//! chapters live in a subdirectory — the case a file-directory root +//! gets wrong; and +//! * `.git` NEVER becomes the root. This is the one entry where +//! copying the other fourteen's instinct is actively wrong, and the +//! failure mode is subtle: the resolver does not exclude `.git` by +//! omitting it from its marker list, it excludes it by never +//! declining, because `project_root_for` falls through to +//! `pmacs.project.detect` on a nil and *that* walk includes `.git`. +//! So the pin is end to end through attach, not just on the +//! resolver's return. +//! +//! **Every fixture calls `pmacs.project.set_search_boundary` at its own +//! tempdir root.** R8 was a fixture letting detection escape into the +//! developer's environment, and a LaTeX root fixture is precisely that +//! hazard's shape: a stray `latexmkrc` or `.git` anywhere above the +//! temp directory would otherwise turn the markerless cases into marked +//! ones, and the assertions would still pass while testing nothing. +//! +//! **Attach fixtures point the command at `pmacs_fake_lsp`, and the +//! missing-server fixture at a path asserted not to exist.** The shipped +//! default is `texlab`, which is genuinely installed on the development +//! machine — a suite that relied on either its presence or its absence +//! would behave differently here and in CI. + +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(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 configs intact — this suite is about +/// the shipped `latex` entry, so it cannot clear the table the way the +/// multi-root suite does. +fn editor() -> EditorState { + EditorState::new_with_roots(&crate::iso::roots()) +} + +fn lua_str(path: &Path) -> String { + path.display() + .to_string() + .replace('\\', "\\\\") + .replace('"', "\\\"") +} + +/// Mirror of `file_uri_for` in `builtin/runtime/lsp.lua`. 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, because the resolver canonicalizes before walking + /// (`/var` is a symlink to `/private/var` on macOS) and the expected + /// roots below have to compare equal to what it returns. + 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 mkdir(&self, rel: &str) -> PathBuf { + let path = self.root.join(rel); + std::fs::create_dir_all(&path).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) + ), + ); + // The boundary is the whole hermeticity story for this suite, so + // assert it took rather than trusting the call. + let seen: String = eval(state, "return pmacs.project.search_boundary() or \"\""); + assert_eq!( + seen, + self.root.display().to_string(), + "fixture precondition: the search boundary must be this tempdir" + ); + } +} + +/// Call the SHIPPED resolver directly. +fn resolve_root(state: &EditorState, file: &Path) -> Option { + let got: Option = eval( + state, + &format!("return pmacs.lsp.config.latex.root(\"{}\")", lua_str(file)), + ); + got +} + +/// Repoint only the command, preserving the shipped `root` resolver — +/// which is the thing under test. +fn point_command_at(state: &EditorState, command: &str) { + exec( + state, + &format!("pmacs.lsp.config.latex.command = {command:?}"), + ); +} + +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. +fn rows(state: &EditorState) -> Vec { + 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() +} + +const DOC: &str = "\\documentclass{article}\n\\begin{document}\nhi\n\\end{document}\n"; + +// --------------------------------------------------------------------------- +// §6 — the shipped entry. Command `texlab`, and NOTHING opinionated +// (Q#LX1: no `settings`, no `init_options`). +// --------------------------------------------------------------------------- + +#[test] +fn latex_entry_ships_texlab_with_a_resolver_and_no_opinionated_config() { + let state = editor(); + let command: String = eval(&state, "return pmacs.lsp.config.latex.command"); + assert_eq!( + command, "texlab", + "the shipped LaTeX server is texlab, invoked bare — the binary \ + serves LSP over stdio with no subcommand" + ); + + // Q#LX1. Build-on-save and forward-search are both opinionated and + // forward-search needs a configured viewer, so an empty section + // takes texlab's defaults through the `workspace/configuration` + // answer pmacs already gives. + let has_settings: bool = eval(&state, "return pmacs.lsp.config.latex.settings ~= nil"); + assert!(!has_settings, "Q#LX1: no `settings` may ship"); + let has_init: bool = eval(&state, "return pmacs.lsp.config.latex.init_options ~= nil"); + assert!(!has_init, "Q#LX1: no `init_options` may ship"); + + let root_kind: String = eval(&state, "return type(pmacs.lsp.config.latex.root)"); + assert_eq!( + root_kind, "function", + "the root must be a resolver — the shared marker walk cannot \ + express a LaTeX root, because it would include .git" + ); +} + +// --------------------------------------------------------------------------- +// §6 — detection is unchanged: `.tex`/`.latex`/`.sty`/`.cls` resolve to +// `latex` through the GRAMMAR path, ahead of the LSP filetype map. +// +// Pinned so that a later "helpful" filetype-map addition cannot be +// mistaken for the thing that made attach work. Revision 2 of the +// framing exists because revision 1 proposed exactly that addition. +// --------------------------------------------------------------------------- + +#[test] +fn latex_extensions_resolve_through_the_grammar_not_the_lsp_filetype_map() { + let state = editor(); + for ext in ["tex", "latex", "sty", "cls"] { + let language: Option = eval( + &state, + &format!("return pmacs.parse.language_for_path(\"/tmp/doc.{ext}\")"), + ); + assert_eq!( + language.as_deref(), + Some("latex"), + ".{ext} must resolve to `latex` via the bundled grammar" + ); + + // And the map is empty for it, so the assertion above cannot be + // being satisfied by a filetype entry. + let mapped: Option = + eval(&state, &format!("return pmacs.lsp.filetypes[\"{ext}\"]")); + assert_eq!( + mapped, None, + "no `pmacs.lsp.filetypes.{ext}` ships: the grammar already \ + carries the extension and sits ahead of this map in \ + detect_buffer_language" + ); + } +} + +// --------------------------------------------------------------------------- +// §6 — LOAD-BEARING: the resolver returns the MARKER directory, on the +// thesis shape (marker above a `chapters/` subdirectory). This is +// exactly the case a file-directory root gets wrong. +// --------------------------------------------------------------------------- + +#[test] +fn latex_root_is_the_marker_directory_for_a_thesis_with_chapters() { + // texlab's own marker set, from `crates/distro/src/language.rs` at + // v5.25.1: `.texlabroot`/`texlabroot` -> Root, `Tectonic.toml` -> + // Tectonic, `.latexmkrc`/`latexmkrc` -> Latexmkrc. + for marker in [ + ".texlabroot", + "texlabroot", + "Tectonic.toml", + ".latexmkrc", + "latexmkrc", + ] { + let fx = Fixture::new(); + let state = editor(); + fx.bind(&state); + // Empty, because `.texlabroot` is normally written empty and + // existence — not content — is the marker semantics. + fx.write(&format!("thesis/{marker}"), ""); + fx.write("thesis/thesis.tex", DOC); + let chapter = fx.write("thesis/chapters/one.tex", "\\section{One}\n"); + + assert_eq!( + resolve_root(&state, &chapter).as_deref(), + Some(fx.dir("thesis").display().to_string().as_str()), + "{marker}: the root must be the marker directory, not the \ + chapter's own directory" + ); + } +} + +#[test] +fn latex_root_takes_the_innermost_marker_when_markers_nest() { + let fx = Fixture::new(); + let state = editor(); + fx.bind(&state); + fx.write("outer/latexmkrc", ""); + fx.write("outer/inner/Tectonic.toml", ""); + let doc = fx.write("outer/inner/chapters/one.tex", "\\section{One}\n"); + + assert_eq!( + resolve_root(&state, &doc).as_deref(), + Some(fx.dir("outer/inner").display().to_string().as_str()), + "innermost ancestor wins, matching texlab's own \ + ProjectRoot::walk_and_find" + ); +} + +#[test] +fn latex_root_ignores_a_marker_that_is_a_directory() { + // `io.open` succeeds on a directory, so a bare truthiness test would + // accept `latexmkrc/` as a marker. The read-error discriminator is + // what rejects it; without this pin that subtlety is unguarded. + let fx = Fixture::new(); + let state = editor(); + fx.bind(&state); + fx.mkdir("proj/latexmkrc"); + let doc = fx.write("proj/chapters/one.tex", "\\section{One}\n"); + + assert_eq!( + resolve_root(&state, &doc).as_deref(), + Some(fx.dir("proj/chapters").display().to_string().as_str()), + "a DIRECTORY named latexmkrc is not a marker" + ); +} + +// --------------------------------------------------------------------------- +// §6 — it falls back to the file's own directory with no marker present. +// --------------------------------------------------------------------------- + +#[test] +fn latex_root_falls_back_to_the_files_own_directory() { + let fx = Fixture::new(); + let state = editor(); + fx.bind(&state); + let doc = fx.write("loose/note.tex", DOC); + + assert_eq!( + resolve_root(&state, &doc).as_deref(), + Some(fx.dir("loose").display().to_string().as_str()), + "a markerless document roots at its own directory" + ); +} + +// --------------------------------------------------------------------------- +// §6 — LOAD-BEARING: `.git` does NOT become the root. +// +// Both halves matter. The resolver must not return the repository root, +// AND it must not DECLINE — a nil falls through to +// `pmacs.project.detect`, whose marker walk does include `.git`, so a +// declining resolver would hand texlab the monorepo by the back door. +// The second assertion is therefore end to end through attach. +// --------------------------------------------------------------------------- + +#[test] +fn latex_root_is_never_a_git_repository_root() { + let fx = Fixture::new(); + let state = editor(); + fx.bind(&state); + // A repository ABOVE a document directory — the thesis-inside-a- + // monorepo shape. + fx.mkdir("repo/.git"); + fx.write("repo/README.md", "monorepo\n"); + let doc = fx.write("repo/paper/paper.tex", DOC); + + assert_eq!( + resolve_root(&state, &doc).as_deref(), + Some(fx.dir("repo/paper").display().to_string().as_str()), + "the document directory wins: texlab wants the DOCUMENT root, \ + and a thesis in a monorepo must not get the monorepo" + ); + + // The same fixture proves `pmacs.project.detect` really would have + // answered the repository root, so the assertion above is not + // vacuous. + let detected: Option = eval( + &state, + &format!( + "local ok, d = pcall(pmacs.project.detect, \"{}\")\n\ + if ok and d then return d.root end\n\ + return nil", + lua_str(&doc) + ), + ); + assert_eq!( + detected.as_deref(), + Some(fx.dir("repo").display().to_string().as_str()), + "fixture precondition: the shared detector DOES answer the \ + repository root here — that is what the resolver must avoid" + ); +} + +#[test] +fn a_tex_buffer_in_a_git_repo_attaches_at_the_document_directory() { + let fx = Fixture::new(); + let mut state = editor(); + fx.bind(&state); + point_command_at(&state, &fake_lsp_path()); + fx.mkdir("repo/.git"); + let doc = fx.write("repo/paper/paper.tex", DOC); + open(&state, &doc); + settle(&mut state); + + let rows = rows(&state); + assert_eq!(rows.len(), 1, "one latex server: {rows:?}"); + let fields: Vec<&str> = rows[0].split('|').collect(); + assert_eq!(fields[0], "latex"); + assert_eq!( + fields[1], + file_uri(&fx.dir("repo/paper")), + "root_uri must be the document directory, NOT the repository root" + ); + assert_eq!( + fields[2], + fx.dir("repo/paper").display().to_string(), + "cwd must be the document directory" + ); +} + +// --------------------------------------------------------------------------- +// §6 — a `.tex` buffer attaches the LaTeX server, witnessed end to end +// rather than by asserting the config table's contents. +// --------------------------------------------------------------------------- + +#[test] +fn a_tex_buffer_attaches_the_latex_server_at_the_marker_root() { + let fx = Fixture::new(); + let mut state = editor(); + fx.bind(&state); + point_command_at(&state, &fake_lsp_path()); + fx.write("thesis/latexmkrc", ""); + let chapter = fx.write("thesis/chapters/one.tex", "\\section{One}\n"); + open(&state, &chapter); + settle(&mut state); + + let rows = rows(&state); + assert_eq!(rows.len(), 1, "expected one latex server: {rows:?}"); + let fields: Vec<&str> = rows[0].split('|').collect(); + assert_eq!( + fields[0], "latex", + "the buffer must resolve to language `latex` and attach" + ); + assert_eq!( + fields[1], + file_uri(&fx.dir("thesis")), + "the attached server's root is the marker directory" + ); +} + +#[test] +fn two_chapters_of_one_thesis_share_a_single_server() { + // The marker walk's whole point: without it each chapter directory + // would be its own root and texlab would serve isolated files. + let fx = Fixture::new(); + let mut state = editor(); + fx.bind(&state); + point_command_at(&state, &fake_lsp_path()); + fx.write("thesis/latexmkrc", ""); + let one = fx.write("thesis/chapters/one.tex", "\\section{One}\n"); + let two = fx.write("thesis/appendix/two.tex", "\\section{Two}\n"); + open(&state, &one); + settle(&mut state); + open(&state, &two); + settle(&mut state); + + let rows = rows(&state); + assert_eq!( + rows.len(), + 1, + "both chapters share the thesis root, so one server: {rows:?}" + ); + assert_eq!( + rows[0].split('|').nth(1).unwrap(), + file_uri(&fx.dir("thesis")) + ); +} + +#[test] +fn two_chapters_share_one_server_under_a_root_search_boundary() { + // A `/` boundary is "clamp nothing", spelled as a path — and it used + // to disable the marker walk OUTRIGHT. The containment test was + // string arithmetic (`dir:sub(1, #boundary + 1) == boundary .. "/"`), + // so a `/` boundary asked whether each ancestor began with `"//"`, + // which no canonical path does. Every ancestor was judged out of + // bounds, no marker was ever examined, and each chapter got its own + // root — the lane's headline behaviour, silently off, with the + // predicate's unit-level answers all still looking plausible. + // + // Pinned through ATTACH because that is where the symptom lives: two + // texlab processes for one thesis, not a wrong string. + // + // Still hermetic despite the unclamped boundary: innermost marker + // wins, and `thesis/` has one, so no `latexmkrc` above the tempdir + // can change the answer. + let fx = Fixture::new(); + let mut state = editor(); + exec(&state, "pmacs.project.set_search_boundary(\"/\")"); + let seen: String = eval(&state, "return pmacs.project.search_boundary() or \"\""); + assert_eq!( + seen, "/", + "fixture precondition: the boundary must be the filesystem root" + ); + point_command_at(&state, &fake_lsp_path()); + fx.write("thesis/latexmkrc", ""); + let one = fx.write("thesis/chapters/one.tex", "\\section{One}\n"); + let two = fx.write("thesis/appendix/two.tex", "\\section{Two}\n"); + open(&state, &one); + settle(&mut state); + open(&state, &two); + settle(&mut state); + + let rows = rows(&state); + assert_eq!( + rows.len(), + 1, + "a root boundary must behave like any other boundary: both \ + chapters resolve to the thesis root, so ONE server: {rows:?}" + ); + assert_eq!( + rows[0].split('|').nth(1).unwrap(), + file_uri(&fx.dir("thesis")), + "and that one server is rooted at the marker directory" + ); +} + +#[test] +fn two_markerless_documents_in_different_directories_do_not_share_a_server() { + // The complement of the pin above: the fallback is the file's own + // directory, so unrelated loose documents keep separate scopes + // rather than collapsing into one rootless server. + let fx = Fixture::new(); + let mut state = editor(); + fx.bind(&state); + point_command_at(&state, &fake_lsp_path()); + let one = fx.write("a/one.tex", DOC); + let two = fx.write("b/two.tex", DOC); + open(&state, &one); + settle(&mut state); + open(&state, &two); + settle(&mut state); + + let rows = rows(&state); + assert_eq!(rows.len(), 2, "one server per document directory: {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:?}" + ); +} + +// --------------------------------------------------------------------------- +// §6 — a missing `texlab` surfaces guidance through the existing +// spawn-failure path (#204). Asserted, not assumed. +// --------------------------------------------------------------------------- + +#[test] +fn a_missing_texlab_surfaces_installation_guidance() { + let fx = Fixture::new(); + let mut state = editor(); + fx.bind(&state); + // A path that cannot exist, asserted — texlab IS installed on the + // development machine, so relying on its absence would make this + // vacuous here and meaningful only in CI. + let absent = fx.dir("no-such-bin/texlab"); + assert!( + !absent.exists(), + "fixture precondition: {} must not exist", + absent.display() + ); + point_command_at(&state, &absent.display().to_string()); + let doc = fx.write("paper/paper.tex", DOC); + open(&state, &doc); + settle(&mut state); + + assert!(rows(&state).is_empty(), "nothing may have started"); + let status = status(&state); + assert!( + status.contains("did not start") && status.contains("latex"), + "the spawn-failure path must name the language: {status:?}" + ); + assert!( + status.contains("pmacs.lsp.config.latex.command"), + "the guidance must name the override seam: {status:?}" + ); + + // And the failure is recorded, not just flashed. + let recorded: bool = eval( + &state, + "for _, f in ipairs(pmacs.lsp.spawn_failures()) do\n\ + if f.language == \"latex\" then return true end\n\ + end\n\ + return false", + ); + assert!(recorded, "M-x lsp.status must carry the latex failure"); +} + +// --------------------------------------------------------------------------- +// The resolver declines only when there is no directory to vouch for. +// A decline is the one path that reaches `pmacs.project.detect`, so its +// preconditions are worth pinning. +// --------------------------------------------------------------------------- + +#[test] +fn latex_root_declines_for_a_non_string_or_pathless_argument() { + let state = editor(); + let nil_arg: Option = eval(&state, "return pmacs.lsp.config.latex.root(nil)"); + assert_eq!(nil_arg, None, "a pathless buffer declines"); + let bare: Option = eval( + &state, + "return pmacs.lsp.config.latex.root(\"noslash.tex\")", + ); + assert_eq!(bare, None, "a name with no directory component declines"); +} + +#[test] +fn latex_root_walk_stops_at_the_search_boundary() { + // R8's shape, pinned directly: a marker ABOVE the boundary must be + // invisible, or every markerless assertion in this file is hostage + // to the developer's filesystem. + let fx = Fixture::new(); + let state = editor(); + // Marker at the tempdir root, boundary set BELOW it. + fx.write("latexmkrc", ""); + let inner = fx.mkdir("inner"); + fx.write("inner/chapters/one.tex", "\\section{One}\n"); + exec( + &state, + &format!("pmacs.project.set_search_boundary(\"{}\")", lua_str(&inner)), + ); + + let doc = fx.dir("inner/chapters/one.tex"); + assert_eq!( + resolve_root(&state, &doc).as_deref(), + Some(fx.dir("inner/chapters").display().to_string().as_str()), + "the walk must not climb past the search boundary to reach the \ + marker above it" + ); + + // The other direction, and it is not decoration: "stops at the + // boundary" is also satisfied by a walk that never runs at all — + // which is precisely what a `/` boundary used to produce. So assert + // that within the boundary the walk still CLIMBS, and that the + // boundary directory itself is a candidate (inclusive, matching + // `set_search_boundary`'s documented contract). + fx.write("inner/.texlabroot", ""); + assert_eq!( + resolve_root(&state, &doc).as_deref(), + Some(inner.display().to_string().as_str()), + "a marker AT the boundary directory is found, and the walk \ + climbs out of `chapters/` to reach it" + ); +} + +#[test] +fn latex_root_for_a_document_at_the_filesystem_root_is_the_root() { + // The same root-is-special trap one level up: `/paper.tex` slices to + // an EMPTY directory string, which canonicalizes to nothing, so the + // resolver DECLINED — and a decline is the one path that falls + // through to `pmacs.project.detect`, whose walk includes `.git`. + // Hermetic: the boundary is this fixture's tempdir, so `/` is out of + // bounds, no marker is examined, and the answer is the directory + // itself regardless of what sits at the filesystem root. + let fx = Fixture::new(); + let state = editor(); + fx.bind(&state); + let doc = Path::new("/pmacs-lsp-latex-no-such-document.tex"); + assert!( + !doc.exists(), + "fixture precondition: {} must not exist", + doc.display() + ); + + assert_eq!( + resolve_root(&state, doc).as_deref(), + Some("/"), + "a document at the filesystem root roots at `/`; it must not \ + decline into the shared `.git`-aware detector" + ); +} + +#[path = "common/iso.rs"] +mod iso;