pmacs/tests/lsp_latex_acceptance.rs

700 lines
25 KiB
Rust

// 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;