From 7992054153628ee292531e949712c0527c58b42b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 16:28:53 +0200 Subject: [PATCH 1/6] docs: LSP LaTeX coverage framing revision 2, on a branch at last Revision 1 was untracked, on main, in a single checkout. By the handoff own rule --- work is portable only after it is committed and pushed --- it did not travel. Committing it here is the first fix. Three factual corrections, all from checking rather than reading. haskell-language-server IS installed on this machine, along with its wrapper. Revision 1 said it was not, and that claim was the entire basis of its Slice 1 / Slice 2 split. Only OCaml still lacks a server. The argument for leaving Haskell out survives, but on use evidence --- the .hs files are a rarely-edited Hakyll generator --- not on dependency cost. Slice 1 is smaller than framed. Revision 1 proposed filetype mappings for .tex/.latex/.sty/.cls "so highlighting and LSP agree on what a LaTeX file is". They already agree, by construction: the grammar carries exactly those extensions at src/syntax.rs:1111, grammar-extension detection sits AHEAD of the LSP filetype map in the precedence chain per the merged grammar framing at :166-171, and lsp.lua:267-270 calls the filetype map "mainly the LSP-only fallback". The real missing piece is one config entry. Q#LX3 deferral argument read a stale line. COHERENCE.md:124 and :867 both record multi-root LSP affinity as merged in #161; only :1669 still says "first slice in flight", contradicting the same document twice. The one item revision 1 said could justify deferring therefore dissolves. The COHERENCE.md inconsistency is real and is left for whoever next touches section 20 rather than smuggled in here. Q#LX2, which revision 1 called the question most likely to make the entry wrong in practice, is now answered rather than shrugged at. An upward marker walk through config.latex.root, which already accepts a resolver function. .git is deliberately excluded: a repository root is the wrong answer for LaTeX, since texlab wants the document root, and this is the one place where copying the other fourteen entries instinct would be actively wrong. The .texlabroot marker is marked UNVERIFIED and blocking. texlab 5.25.1 is installed and its version and CLI were checked directly, but the CLI exposes only run and inverse-search, so its LSP-level behaviour was not established. Confirm against a live session before implementing. Same discipline the gate-protocol-build lane applies to its own precondition: the thing the design rests on gets observed, not reasoned about. Also renumbered Q#HS1 to Q#LX4, because revision 1 Q#LX2 collided with a live question ID in the merged grammar framing for the same language. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 58 ++++++- docs/lsp-language-coverage-framing.md | 238 ++++++++++++++++++++++++++ 2 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 docs/lsp-language-coverage-framing.md diff --git a/docs/active-work.md b/docs/active-work.md index e0816fd..62f6afb 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,10 +265,61 @@ 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 — BRANCHED, framing in review -**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 2**, in + review. **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, with a caveat that must be + discharged first.** An upward marker walk — `.texlabroot`, + `latexmkrc`/`.latexmkrc`, `Tectonic.toml`, then the file's own + directory — through `config.latex.root`, which already accepts a + resolver function (`lsp.lua:543`). **`.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. + **Whether texlab honours `.texlabroot` is UNVERIFIED** — only its + version and CLI were checked, and the CLI exposes no such surface. + Confirm against a live session before implementing; if it is not a + real marker, the walk starts at `latexmkrc`. +- **Gates:** `scripts/gate --acceptance `. No + `--protocol` — a config entry, no wire. + +## `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 +517,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..67bf343 --- /dev/null +++ b/docs/lsp-language-coverage-framing.md @@ -0,0 +1,238 @@ +# LSP language coverage: LaTeX (and the Haskell/OCaml question) + +**Status: framing pass, revision 2. Pre-implementation. Awaiting +approval.** + +**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. + +**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. + +**VERIFICATION CAVEAT, stated rather than buried.** `texlab 5.25.1` is +installed and its version and CLI surface were checked directly. Its +**LSP-level** behaviour — whether it honours `.texlabroot`, and how it +resolves multi-file projects from a root URI — was **not** verified +here; the CLI exposes only `run` / `inverse-search`, so this needs a +live session. **Marker 1 is provisional and must be confirmed against a +running texlab before implementation**, exactly as the sibling +`gate-protocol-build` lane requires its precondition to be observed +rather than reasoned about. If `.texlabroot` is not a real marker, it +drops and the walk starts at `latexmkrc`. + +## 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). From 75f5f97582850e952ec670be33208a5d726b1eee Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 17:28:52 +0200 Subject: [PATCH 2/6] docs: authorize LSP LaTeX implementation, precisely Recorded as "implementation authorized" rather than "approved", because that is what happened: the user authorized dispatch after a summary of revision 2 four corrections, not after returning findings on the document the way they did for the other four lanes. The distinction matters for one reason. Section 3 .texlabroot caveat is unverified and blocking --- texlab 5.25.1 is installed and its version and CLI were checked, but the CLI exposes only run and inverse-search, so its LSP-level behaviour was never established. A framing marked plainly "approved" invites a reader to treat that caveat as settled prose. It is step zero of the work. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 8 ++++++-- docs/lsp-language-coverage-framing.md | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 62f6afb..7be4ce1 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -275,8 +275,12 @@ from #171 and #215. 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 2**, in - review. **Revision 1 was UNTRACKED on `main` in one checkout** and +- **Framing `docs/lsp-language-coverage-framing.md`, revision 2 — + 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 is still live and binding**, and is step zero of the work, not + a footnote it can be read past. **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 diff --git a/docs/lsp-language-coverage-framing.md b/docs/lsp-language-coverage-framing.md index 67bf343..e7e1f5c 100644 --- a/docs/lsp-language-coverage-framing.md +++ b/docs/lsp-language-coverage-framing.md @@ -1,7 +1,11 @@ # LSP language coverage: LaTeX (and the Haskell/OCaml question) -**Status: framing pass, revision 2. Pre-implementation. Awaiting -approval.** +**Status: revision 2, IMPLEMENTATION AUTHORIZED 2026-08-09.** + +*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 From e4c6421612778323e9d5a14324932347e80505f2 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 18:00:29 +0200 Subject: [PATCH 3/6] feat(lsp): LaTeX via texlab, with a document-root resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Framing: `docs/lsp-language-coverage-framing.md` (revision 2, §3 and §6). One `pmacs.lsp.config.latex` entry — command `texlab`, args none, a function-valued `root`, and deliberately nothing else. STEP ZERO: the §3 caveat, discharged by observation. Revision 2 marked marker 1 (`.texlabroot`) UNVERIFIED and blocking: only texlab's version and CLI had been checked, and the CLI exposes just `run` / `inverse-search`. Driving a minimal LSP client against `texlab run` by hand, plus reading texlab's own source at the exact installed tag, settles it — and corrects the premise underneath it. `.texlabroot` IS a real marker. `crates/distro/src/language.rs` at v5.25.1 maps `.texlabroot`/`texlabroot` -> Root, `Tectonic.toml` -> Tectonic, `.latexmkrc`/`latexmkrc` -> Latexmkrc, and `ProjectRoot::walk_and_find` (`crates/base-db/src/deps/root.rs`) walks ancestors testing all three, innermost wins. So the shipped marker set is texlab's own rather than a plausible-looking guess, and marker 1 stays. But texlab CANNOT apply that walk to rescue a root pmacs gets wrong. Each arm of `walk_and_find` searches `workspace.iter()` — documents already loaded — and the workspace is built from the folders the CLIENT supplies. Live sessions confirm 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. That inverts the significance of the resolver rather than weakening it: whatever `config.latex.root` returns *is* the project scope. It is the whole value of the lane, not a nicety. Also observed, because the entry depends on it: bare `texlab` serves LSP over stdio — `initialize` returns `TexLab 5.25.1` with no subcommand — so `args = {}` is right and `run` is not needed. WHY `.git` IS EXCLUDED, AND WHY THAT IS NOT AN OMISSION. texlab wants the document root; a thesis inside a monorepo must not get the monorepo. The subtlety is that leaving `.git` out of the marker list does not achieve this on its own. `project_root_for` falls through to `pmacs.project.detect` when a resolver returns nil, and that walk does include `.git` — so a resolver that declined 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: no marker means the file's own directory, which is also framing marker 4. The acceptance pins this end to end through attach, not just on the resolver's return, and asserts in the same fixture that the shared detector really would have answered the repository root. NO FILETYPE MAPPINGS, per revision 2 §2 — verified, not inherited. `src/syntax.rs` already declares `name: "latex"` with `extensions: ["tex", "latex", "sty", "cls"]`, and grammar-extension detection sits ahead of the LSP filetype map in `detect_buffer_language` (`syntax.lua`). A `.tex` buffer already resolves to `latex`. The suite asserts both halves — the extensions resolve, and `pmacs.lsp.filetypes` is empty for them — so a later "helpful" addition cannot be mistaken for the thing that made attach work. Q#LX1: no `settings`, no `init_options`. Build-on-save and forward-search are both opinionated and forward-search needs a configured viewer. Fixtures bound detection with `pmacs.project.set_search_boundary` and assert the boundary took — R8's hazard is exactly this fixture's shape, and one test pins the walk stopping at the boundary directly. Attach fixtures use `pmacs_fake_lsp`, and the missing-server fixture an asserted-absent path: texlab is installed on this machine, so relying on either its presence or its absence would behave differently here and in CI. Verification: fourteen tests, one per §6 bullet plus the boundary and decline cases. 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). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- builtin/runtime/lsp.lua | 143 ++++++++ tests/lsp_latex_acceptance.rs | 610 ++++++++++++++++++++++++++++++++++ 2 files changed, 753 insertions(+) create mode 100644 tests/lsp_latex_acceptance.rs diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index eb4bb25..e0a7e81 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -260,6 +260,149 @@ 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) + for _, name in ipairs(LATEX_ROOT_MARKERS) do + local f = io.open(dir .. "/" .. name, "r") + if f then + local _, err = f:read(1) + f:close() + if err == nil then return true end + end + end + return false +end + +local function latex_parent_of(dir) + local up = dir:match("^(.*)/[^/]+$") + if up == nil or up == dir or up == "" then return nil 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). +local function latex_within_boundary(dir, boundary) + if not boundary then return true end + return dir == boundary or dir:sub(1, #boundary + 1) == boundary .. "/" +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 + 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/tests/lsp_latex_acceptance.rs b/tests/lsp_latex_acceptance.rs new file mode 100644 index 0000000..e62a03e --- /dev/null +++ b/tests/lsp_latex_acceptance.rs @@ -0,0 +1,610 @@ +// 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_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" + ); +} + +#[path = "common/iso.rs"] +mod iso; From 22d72fa7f703f6fcd2453e1cef8deee63a276d02 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 18:02:02 +0200 Subject: [PATCH 4/6] docs: record the LaTeX lane as implemented, step zero discharged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section-local edit to the lane's own block in `docs/active-work.md`. The heading moves from "BRANCHED, framing in review" to "IMPLEMENTED, gates green, no PR yet", and the §3 `.texlabroot` caveat is written up as discharged rather than merely resolved: what texlab actually does, how it was observed, and the one place the framing now reads stale. The substantive finding recorded here is not "the marker works". It is that texlab's own root walk only sees markers belonging to documents already in its workspace, and the workspace comes from the folders the client supplies — so texlab honours the root pmacs hands it and never corrects a too-narrow one. That makes `config.latex.root` the project scope rather than a hint, which is worth carrying forward whether or not anyone rereads the framing. Also recorded: the shared-`CARGO_TARGET_DIR` trap, because a bare `cargo test` in this worktree fails with compile errors from a sibling lane's code and reads exactly like a broken branch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 89 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 72 insertions(+), 17 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 7be4ce1..d279c33 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -265,7 +265,7 @@ 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. -## LSP LaTeX coverage — BRANCHED, framing in review +## LSP LaTeX coverage — IMPLEMENTED, gates green, no PR yet **Written with the lane's first commit**, per the standing correction from #171 and #215. @@ -278,9 +278,10 @@ authoritative tip** — the ref, not a SHA. Recover with - **Framing `docs/lsp-language-coverage-framing.md`, revision 2 — 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 is still live and binding**, and is step zero of the work, not - a footnote it can be read past. **Revision 1 was UNTRACKED on `main` in one checkout** and + 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 @@ -300,19 +301,73 @@ authoritative tip** — the ref, not a SHA. Recover with 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, with a caveat that must be - discharged first.** An upward marker walk — `.texlabroot`, - `latexmkrc`/`.latexmkrc`, `Tectonic.toml`, then the file's own - directory — through `config.latex.root`, which already accepts a - resolver function (`lsp.lua:543`). **`.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. - **Whether texlab honours `.texlabroot` is UNVERIFIED** — only its - version and CLI were checked, and the CLI exposes no such surface. - Confirm against a live session before implementing; if it is not a - real marker, the walk starts at `latexmkrc`. -- **Gates:** `scripts/gate --acceptance `. No - `--protocol` — a config entry, no wire. +- **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 therefore reads slightly stale** — it frames marker 1 as + conditional on texlab honouring the file, when the operative fact + is that texlab honours the *client-supplied root*. Worth a revision + 3 by whoever next touches the document; not smuggled into this + lane's commit. +- **`.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). +- **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`) From 12b7ee58d83a580a588455281c675f05d7789e3d Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 18:04:21 +0200 Subject: [PATCH 5/6] docs: LaTeX framing revision 3 --- step zero corrected the premise The .texlabroot caveat is discharged, and discharging it falsified the reasoning behind it. .texlabroot is real, and texlab marker set is wider than this document listed --- the bare texlabroot and latexmkrc spellings count too, and the walk takes the innermost. The implementation ships texlab own set. But the caveat framed marker 1 as conditional on texlab honouring the FILE, which was the wrong question. Every arm of texlab ancestor walk searches documents already loaded, and that workspace is built from the folders the CLIENT supplies. Observed: with rootUri at chapters/, no ancestor marker widened texlab view and its dependency graph never reached the parent document; with rootUri at the marker directory the parent resolved either way. So texlab honours the root it is handed and never corrects a too-narrow one. config.latex.root IS the project scope, which makes the resolver the whole value of the lane for a multi-file thesis rather than a nicety --- the opposite of how section 2 "Slice 1 is one config entry" reads. Second correction, and this one was a real trap. Revision 2 said .git is deliberately excluded from the walk and stopped there. Omitting it is not sufficient: project_root_for falls through to pmacs.project.detect when a resolver returns nil, and that walk lists .git among its markers at src/project.rs:184. 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 never declines for a file with a directory, and the fixture asserts the shared detector really would have answered the repo root so the pin cannot pass vacuously. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 2 +- docs/lsp-language-coverage-framing.md | 53 +++++++++++++++++++++------ 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index d279c33..3327aad 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -275,7 +275,7 @@ from #171 and #215. 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 2 — +- **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 diff --git a/docs/lsp-language-coverage-framing.md b/docs/lsp-language-coverage-framing.md index e7e1f5c..7a6b55e 100644 --- a/docs/lsp-language-coverage-framing.md +++ b/docs/lsp-language-coverage-framing.md @@ -1,6 +1,6 @@ # LSP language coverage: LaTeX (and the Haskell/OCaml question) -**Status: revision 2, IMPLEMENTATION AUTHORIZED 2026-08-09.** +**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 @@ -119,22 +119,53 @@ 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. -**VERIFICATION CAVEAT, stated rather than buried.** `texlab 5.25.1` is -installed and its version and CLI surface were checked directly. Its -**LSP-level** behaviour — whether it honours `.texlabroot`, and how it -resolves multi-file projects from a root URI — was **not** verified -here; the CLI exposes only `run` / `inverse-search`, so this needs a -live session. **Marker 1 is provisional and must be confirmed against a -running texlab before implementation**, exactly as the sibling -`gate-protocol-build` lane requires its precondition to be observed -rather than reasoned about. If `.texlabroot` is not a real marker, it -drops and the walk starts at `latexmkrc`. +**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 From 9a1e11c0c8ac99ef05fadc089b6e83455c201707 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 9 Aug 2026 19:31:13 +0200 Subject: [PATCH 6/6] fix(lsp): a `/` search boundary no longer disables the LaTeX marker walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `latex_within_boundary` answered a question about path COMPONENTS with string arithmetic: dir:sub(1, #boundary + 1) == boundary .. "/" With a `/` boundary the needle is `"//"`, which no canonical path begins with. Every ancestor was therefore judged out of bounds, the marker walk never examined a single directory, and each chapter of a thesis got its own root — two texlab processes for one document tree. The lane's headline behaviour, silently off, with all fourteen shipped tests still green because every one of them clamps the boundary to its own tempdir. Fixed by comparing segments rather than characters, so the root is a boundary with zero segments — containing everything by construction instead of by a special case, and tolerating a trailing separator for free. The same root-is-special trap sat at two other points on the same path and is closed with it: * `latex_parent_of` returned nil for a top-level directory, making `/` the one directory the walk could never examine — the identical bug from the far end. It now yields `/`, matching `walk_for_marker`'s `Path::ancestors` on the Rust side, and still terminates because `/` has no component to strip. * `latex_root_for` sliced `/paper.tex` to an EMPTY directory, which canonicalizes to nothing and made the resolver DECLINE — and a decline is the one path that falls through to `pmacs.project.detect`, whose walk includes `.git`. A document at the filesystem root now roots at `/`. * `latex_marker_in`'s join is guarded for `dir == "/"`, which this change makes reachable for the first time; the naive form produces `//name`, the one spelling POSIX leaves implementation-defined. Two new pins plus a strengthened one, 16 tests: * `two_chapters_share_one_server_under_a_root_search_boundary` — the defect end to end through ATTACH, not on the predicate, because the symptom is two servers rather than a wrong string. Restoring the old comparison fails exactly this test, with the two-server output. * `latex_root_walk_stops_at_the_search_boundary` now asserts BOTH directions. "Stops at the boundary" is also satisfied by a walk that never runs — which is precisely what a `/` boundary produced — so the hermeticity property (R8's shape: a stray `latexmkrc` above the tempdir must stay invisible) is now paired with the walk still climbing to, and examining, the boundary directory itself. * `latex_root_for_a_document_at_the_filesystem_root_is_the_root`. Also corrects `docs/active-work.md`: §3 no longer awaits a revision 3 — `b5eaf27` IS revision 3 — and the lane entry now records that boundary handling has been this resolver's interesting part twice, so a reader weighing whether to trust it knows where to look first. Gates: ALL GREEN via `./scripts/gate --acceptance lsp_latex_acceptance`. `/tmp` on this machine is a near-full tmpfs and three grep tests fail there with `QuotaExceeded`; the green run used `TMPDIR=/home/jeans/build/pmacs-gate-tmp/lsp-latex`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- builtin/runtime/lsp.lua | 54 +++++++++++++++++++-- docs/active-work.md | 36 ++++++++++++-- tests/lsp_latex_acceptance.rs | 89 +++++++++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 8 deletions(-) diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index e0a7e81..9ace580 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -337,8 +337,14 @@ local LATEX_ROOT_MARKERS = { -- 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(dir .. "/" .. name, "r") + local f = io.open(base .. "/" .. name, "r") if f then local _, err = f:read(1) f:close() @@ -348,9 +354,18 @@ local function latex_marker_in(dir) 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 or up == "" then return nil end + if up == nil or up == dir then return nil end + if up == "" then return "/" end return up end @@ -360,9 +375,37 @@ end -- 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 - return dir == boundary or dir:sub(1, #boundary + 1) == boundary .. "/" + 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 @@ -382,6 +425,11 @@ 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 diff --git a/docs/active-work.md b/docs/active-work.md index 3327aad..59783a1 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -333,11 +333,12 @@ authoritative tip** — the ref, not a SHA. Recover with - **`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 therefore reads slightly stale** — it frames marker 1 as - conditional on texlab honouring the file, when the operative fact - is that texlab honours the *client-supplied root*. Worth a revision - 3 by whoever next touches the document; not smuggled into this - lane's commit. + - **§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 @@ -360,6 +361,31 @@ authoritative tip** — the ref, not a SHA. Recover with (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 diff --git a/tests/lsp_latex_acceptance.rs b/tests/lsp_latex_acceptance.rs index e62a03e..4ccd138 100644 --- a/tests/lsp_latex_acceptance.rs +++ b/tests/lsp_latex_acceptance.rs @@ -488,6 +488,54 @@ fn two_chapters_of_one_thesis_share_a_single_server() { ); } +#[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 @@ -604,6 +652,47 @@ fn latex_root_walk_stops_at_the_search_boundary() { "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"]