feat(lsp): LaTeX via texlab, with a document-root resolver
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
parent
75f5f97582
commit
e4c6421612
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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_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"
|
||||
);
|
||||
}
|
||||
|
||||
#[path = "common/iso.rs"]
|
||||
mod iso;
|
||||
Loading…
Reference in New Issue