Merge pull request #230 from levineuwirth/lsp-latex-coverage
feat(lsp): LaTeX via texlab, with a document-root resolver
This commit is contained in:
commit
01901029d6
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.<lang>.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).
|
||||
|
|
@ -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<T: mlua::FromLuaMulti>(state: &EditorState, source: &str) -> T {
|
||||
state.lua_host.lua().load(source.to_owned()).eval().unwrap()
|
||||
}
|
||||
|
||||
fn fake_lsp_path() -> String {
|
||||
env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned()
|
||||
}
|
||||
|
||||
/// A fresh editor with the SHIPPED 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<String> {
|
||||
let got: Option<String> = 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<String> {
|
||||
let joined: String = eval(
|
||||
state,
|
||||
r#"
|
||||
local out = {}
|
||||
for _, s in ipairs(pmacs.lsp.list()) do
|
||||
out[#out + 1] = table.concat({
|
||||
s.language_id or "",
|
||||
s.root_uri or "",
|
||||
s.cwd or "",
|
||||
(s.state and s.state.kind) or "",
|
||||
}, "|")
|
||||
end
|
||||
table.sort(out)
|
||||
return table.concat(out, "\n")
|
||||
"#,
|
||||
);
|
||||
if joined.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
joined.lines().map(str::to_owned).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn status(state: &EditorState) -> String {
|
||||
state.core.borrow().status.clone()
|
||||
}
|
||||
|
||||
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<String> = 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<String> =
|
||||
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<String> = 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<String> = eval(&state, "return pmacs.lsp.config.latex.root(nil)");
|
||||
assert_eq!(nil_arg, None, "a pathless buffer declines");
|
||||
let bare: Option<String> = 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;
|
||||
Loading…
Reference in New Issue