// tests/lsp_latex_acceptance.rs --- LSP language coverage: LaTeX. //! `docs/lsp-language-coverage-framing.md` §6, one test per bullet. //! //! The lane ships exactly one thing: `pmacs.lsp.config.latex`, command //! `texlab`, with a function-valued `root` that walks up for texlab's //! own project markers and stops at the document directory. Two pins //! are load-bearing and the rest guard the boundary around them: //! //! * the resolver returns the MARKER directory for a thesis whose //! chapters live in a subdirectory — the case a file-directory root //! gets wrong; and //! * `.git` NEVER becomes the root. This is the one entry where //! copying the other fourteen's instinct is actively wrong, and the //! failure mode is subtle: the resolver does not exclude `.git` by //! omitting it from its marker list, it excludes it by never //! declining, because `project_root_for` falls through to //! `pmacs.project.detect` on a nil and *that* walk includes `.git`. //! So the pin is end to end through attach, not just on the //! resolver's return. //! //! **Every fixture calls `pmacs.project.set_search_boundary` at its own //! tempdir root.** R8 was a fixture letting detection escape into the //! developer's environment, and a LaTeX root fixture is precisely that //! hazard's shape: a stray `latexmkrc` or `.git` anywhere above the //! temp directory would otherwise turn the markerless cases into marked //! ones, and the assertions would still pass while testing nothing. //! //! **Attach fixtures point the command at `pmacs_fake_lsp`, and the //! missing-server fixture at a path asserted not to exist.** The shipped //! default is `texlab`, which is genuinely installed on the development //! machine — a suite that relied on either its presence or its absence //! would behave differently here and in CI. use std::path::{Path, PathBuf}; use std::time::Duration; use pmacs::editor::EditorState; fn exec(state: &EditorState, source: &str) { state.lua_host.lua().load(source.to_owned()).exec().unwrap(); } fn eval(state: &EditorState, source: &str) -> T { state.lua_host.lua().load(source.to_owned()).eval().unwrap() } fn fake_lsp_path() -> String { env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned() } /// A fresh editor with the SHIPPED configs intact — this suite is about /// the shipped `latex` entry, so it cannot clear the table the way the /// multi-root suite does. fn editor() -> EditorState { EditorState::new_with_roots(&crate::iso::roots()) } fn lua_str(path: &Path) -> String { path.display() .to_string() .replace('\\', "\\\\") .replace('"', "\\\"") } /// Mirror of `file_uri_for` in `builtin/runtime/lsp.lua`. Reimplemented /// rather than imported so the test states the expected encoding /// independently of the code under test. fn file_uri(path: &Path) -> String { let mut out = String::from("file://"); for ch in path.display().to_string().chars() { match ch { 'a'..='z' | 'A'..='Z' | '0'..='9' | '/' | '-' | '_' | '.' | '~' | ':' => out.push(ch), _ => { use std::fmt::Write as _; let mut buf = [0u8; 4]; for byte in ch.encode_utf8(&mut buf).as_bytes() { let _ = write!(out, "%{byte:02X}"); } } } } out } struct Fixture { _dir: tempfile::TempDir, root: PathBuf, } impl Fixture { /// Canonicalized, because the resolver canonicalizes before walking /// (`/var` is a symlink to `/private/var` on macOS) and the expected /// roots below have to compare equal to what it returns. fn new() -> Self { let dir = tempfile::tempdir().unwrap(); let root = std::fs::canonicalize(dir.path()).unwrap(); Self { _dir: dir, root } } fn write(&self, rel: &str, contents: &str) -> PathBuf { let path = self.root.join(rel); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, contents).unwrap(); path } fn mkdir(&self, rel: &str) -> PathBuf { let path = self.root.join(rel); std::fs::create_dir_all(&path).unwrap(); path } fn dir(&self, rel: &str) -> PathBuf { self.root.join(rel) } fn bind(&self, state: &EditorState) { exec( state, &format!( "pmacs.project.set_search_boundary(\"{}\")", lua_str(&self.root) ), ); // The boundary is the whole hermeticity story for this suite, so // assert it took rather than trusting the call. let seen: String = eval(state, "return pmacs.project.search_boundary() or \"\""); assert_eq!( seen, self.root.display().to_string(), "fixture precondition: the search boundary must be this tempdir" ); } } /// Call the SHIPPED resolver directly. fn resolve_root(state: &EditorState, file: &Path) -> Option { let got: Option = eval( state, &format!("return pmacs.lsp.config.latex.root(\"{}\")", lua_str(file)), ); got } /// Repoint only the command, preserving the shipped `root` resolver — /// which is the thing under test. fn point_command_at(state: &EditorState, command: &str) { exec( state, &format!("pmacs.lsp.config.latex.command = {command:?}"), ); } fn open(state: &EditorState, path: &Path) { exec( state, &format!("pmacs.buffer.find_or_open(\"{}\")", lua_str(path)), ); } fn settle(state: &mut EditorState) { for _ in 0..8 { state.tick_processes(); state.tick_lsp(); std::thread::sleep(Duration::from_millis(2)); } } /// One `language_id|root_uri|cwd|state` row per live server. fn rows(state: &EditorState) -> Vec { let joined: String = eval( state, r#" local out = {} for _, s in ipairs(pmacs.lsp.list()) do out[#out + 1] = table.concat({ s.language_id or "", s.root_uri or "", s.cwd or "", (s.state and s.state.kind) or "", }, "|") end table.sort(out) return table.concat(out, "\n") "#, ); if joined.is_empty() { Vec::new() } else { joined.lines().map(str::to_owned).collect() } } fn status(state: &EditorState) -> String { state.core.borrow().status.clone() } const DOC: &str = "\\documentclass{article}\n\\begin{document}\nhi\n\\end{document}\n"; // --------------------------------------------------------------------------- // §6 — the shipped entry. Command `texlab`, and NOTHING opinionated // (Q#LX1: no `settings`, no `init_options`). // --------------------------------------------------------------------------- #[test] fn latex_entry_ships_texlab_with_a_resolver_and_no_opinionated_config() { let state = editor(); let command: String = eval(&state, "return pmacs.lsp.config.latex.command"); assert_eq!( command, "texlab", "the shipped LaTeX server is texlab, invoked bare — the binary \ serves LSP over stdio with no subcommand" ); // Q#LX1. Build-on-save and forward-search are both opinionated and // forward-search needs a configured viewer, so an empty section // takes texlab's defaults through the `workspace/configuration` // answer pmacs already gives. let has_settings: bool = eval(&state, "return pmacs.lsp.config.latex.settings ~= nil"); assert!(!has_settings, "Q#LX1: no `settings` may ship"); let has_init: bool = eval(&state, "return pmacs.lsp.config.latex.init_options ~= nil"); assert!(!has_init, "Q#LX1: no `init_options` may ship"); let root_kind: String = eval(&state, "return type(pmacs.lsp.config.latex.root)"); assert_eq!( root_kind, "function", "the root must be a resolver — the shared marker walk cannot \ express a LaTeX root, because it would include .git" ); } // --------------------------------------------------------------------------- // §6 — detection is unchanged: `.tex`/`.latex`/`.sty`/`.cls` resolve to // `latex` through the GRAMMAR path, ahead of the LSP filetype map. // // Pinned so that a later "helpful" filetype-map addition cannot be // mistaken for the thing that made attach work. Revision 2 of the // framing exists because revision 1 proposed exactly that addition. // --------------------------------------------------------------------------- #[test] fn latex_extensions_resolve_through_the_grammar_not_the_lsp_filetype_map() { let state = editor(); for ext in ["tex", "latex", "sty", "cls"] { let language: Option = eval( &state, &format!("return pmacs.parse.language_for_path(\"/tmp/doc.{ext}\")"), ); assert_eq!( language.as_deref(), Some("latex"), ".{ext} must resolve to `latex` via the bundled grammar" ); // And the map is empty for it, so the assertion above cannot be // being satisfied by a filetype entry. let mapped: Option = eval(&state, &format!("return pmacs.lsp.filetypes[\"{ext}\"]")); assert_eq!( mapped, None, "no `pmacs.lsp.filetypes.{ext}` ships: the grammar already \ carries the extension and sits ahead of this map in \ detect_buffer_language" ); } } // --------------------------------------------------------------------------- // §6 — LOAD-BEARING: the resolver returns the MARKER directory, on the // thesis shape (marker above a `chapters/` subdirectory). This is // exactly the case a file-directory root gets wrong. // --------------------------------------------------------------------------- #[test] fn latex_root_is_the_marker_directory_for_a_thesis_with_chapters() { // texlab's own marker set, from `crates/distro/src/language.rs` at // v5.25.1: `.texlabroot`/`texlabroot` -> Root, `Tectonic.toml` -> // Tectonic, `.latexmkrc`/`latexmkrc` -> Latexmkrc. for marker in [ ".texlabroot", "texlabroot", "Tectonic.toml", ".latexmkrc", "latexmkrc", ] { let fx = Fixture::new(); let state = editor(); fx.bind(&state); // Empty, because `.texlabroot` is normally written empty and // existence — not content — is the marker semantics. fx.write(&format!("thesis/{marker}"), ""); fx.write("thesis/thesis.tex", DOC); let chapter = fx.write("thesis/chapters/one.tex", "\\section{One}\n"); assert_eq!( resolve_root(&state, &chapter).as_deref(), Some(fx.dir("thesis").display().to_string().as_str()), "{marker}: the root must be the marker directory, not the \ chapter's own directory" ); } } #[test] fn latex_root_takes_the_innermost_marker_when_markers_nest() { let fx = Fixture::new(); let state = editor(); fx.bind(&state); fx.write("outer/latexmkrc", ""); fx.write("outer/inner/Tectonic.toml", ""); let doc = fx.write("outer/inner/chapters/one.tex", "\\section{One}\n"); assert_eq!( resolve_root(&state, &doc).as_deref(), Some(fx.dir("outer/inner").display().to_string().as_str()), "innermost ancestor wins, matching texlab's own \ ProjectRoot::walk_and_find" ); } #[test] fn latex_root_ignores_a_marker_that_is_a_directory() { // `io.open` succeeds on a directory, so a bare truthiness test would // accept `latexmkrc/` as a marker. The read-error discriminator is // what rejects it; without this pin that subtlety is unguarded. let fx = Fixture::new(); let state = editor(); fx.bind(&state); fx.mkdir("proj/latexmkrc"); let doc = fx.write("proj/chapters/one.tex", "\\section{One}\n"); assert_eq!( resolve_root(&state, &doc).as_deref(), Some(fx.dir("proj/chapters").display().to_string().as_str()), "a DIRECTORY named latexmkrc is not a marker" ); } // --------------------------------------------------------------------------- // §6 — it falls back to the file's own directory with no marker present. // --------------------------------------------------------------------------- #[test] fn latex_root_falls_back_to_the_files_own_directory() { let fx = Fixture::new(); let state = editor(); fx.bind(&state); let doc = fx.write("loose/note.tex", DOC); assert_eq!( resolve_root(&state, &doc).as_deref(), Some(fx.dir("loose").display().to_string().as_str()), "a markerless document roots at its own directory" ); } // --------------------------------------------------------------------------- // §6 — LOAD-BEARING: `.git` does NOT become the root. // // Both halves matter. The resolver must not return the repository root, // AND it must not DECLINE — a nil falls through to // `pmacs.project.detect`, whose marker walk does include `.git`, so a // declining resolver would hand texlab the monorepo by the back door. // The second assertion is therefore end to end through attach. // --------------------------------------------------------------------------- #[test] fn latex_root_is_never_a_git_repository_root() { let fx = Fixture::new(); let state = editor(); fx.bind(&state); // A repository ABOVE a document directory — the thesis-inside-a- // monorepo shape. fx.mkdir("repo/.git"); fx.write("repo/README.md", "monorepo\n"); let doc = fx.write("repo/paper/paper.tex", DOC); assert_eq!( resolve_root(&state, &doc).as_deref(), Some(fx.dir("repo/paper").display().to_string().as_str()), "the document directory wins: texlab wants the DOCUMENT root, \ and a thesis in a monorepo must not get the monorepo" ); // The same fixture proves `pmacs.project.detect` really would have // answered the repository root, so the assertion above is not // vacuous. let detected: Option = eval( &state, &format!( "local ok, d = pcall(pmacs.project.detect, \"{}\")\n\ if ok and d then return d.root end\n\ return nil", lua_str(&doc) ), ); assert_eq!( detected.as_deref(), Some(fx.dir("repo").display().to_string().as_str()), "fixture precondition: the shared detector DOES answer the \ repository root here — that is what the resolver must avoid" ); } #[test] fn a_tex_buffer_in_a_git_repo_attaches_at_the_document_directory() { let fx = Fixture::new(); let mut state = editor(); fx.bind(&state); point_command_at(&state, &fake_lsp_path()); fx.mkdir("repo/.git"); let doc = fx.write("repo/paper/paper.tex", DOC); open(&state, &doc); settle(&mut state); let rows = rows(&state); assert_eq!(rows.len(), 1, "one latex server: {rows:?}"); let fields: Vec<&str> = rows[0].split('|').collect(); assert_eq!(fields[0], "latex"); assert_eq!( fields[1], file_uri(&fx.dir("repo/paper")), "root_uri must be the document directory, NOT the repository root" ); assert_eq!( fields[2], fx.dir("repo/paper").display().to_string(), "cwd must be the document directory" ); } // --------------------------------------------------------------------------- // §6 — a `.tex` buffer attaches the LaTeX server, witnessed end to end // rather than by asserting the config table's contents. // --------------------------------------------------------------------------- #[test] fn a_tex_buffer_attaches_the_latex_server_at_the_marker_root() { let fx = Fixture::new(); let mut state = editor(); fx.bind(&state); point_command_at(&state, &fake_lsp_path()); fx.write("thesis/latexmkrc", ""); let chapter = fx.write("thesis/chapters/one.tex", "\\section{One}\n"); open(&state, &chapter); settle(&mut state); let rows = rows(&state); assert_eq!(rows.len(), 1, "expected one latex server: {rows:?}"); let fields: Vec<&str> = rows[0].split('|').collect(); assert_eq!( fields[0], "latex", "the buffer must resolve to language `latex` and attach" ); assert_eq!( fields[1], file_uri(&fx.dir("thesis")), "the attached server's root is the marker directory" ); } #[test] fn two_chapters_of_one_thesis_share_a_single_server() { // The marker walk's whole point: without it each chapter directory // would be its own root and texlab would serve isolated files. let fx = Fixture::new(); let mut state = editor(); fx.bind(&state); point_command_at(&state, &fake_lsp_path()); fx.write("thesis/latexmkrc", ""); let one = fx.write("thesis/chapters/one.tex", "\\section{One}\n"); let two = fx.write("thesis/appendix/two.tex", "\\section{Two}\n"); open(&state, &one); settle(&mut state); open(&state, &two); settle(&mut state); let rows = rows(&state); assert_eq!( rows.len(), 1, "both chapters share the thesis root, so one server: {rows:?}" ); assert_eq!( rows[0].split('|').nth(1).unwrap(), file_uri(&fx.dir("thesis")) ); } #[test] fn two_chapters_share_one_server_under_a_root_search_boundary() { // A `/` boundary is "clamp nothing", spelled as a path — and it used // to disable the marker walk OUTRIGHT. The containment test was // string arithmetic (`dir:sub(1, #boundary + 1) == boundary .. "/"`), // so a `/` boundary asked whether each ancestor began with `"//"`, // which no canonical path does. Every ancestor was judged out of // bounds, no marker was ever examined, and each chapter got its own // root — the lane's headline behaviour, silently off, with the // predicate's unit-level answers all still looking plausible. // // Pinned through ATTACH because that is where the symptom lives: two // texlab processes for one thesis, not a wrong string. // // Still hermetic despite the unclamped boundary: innermost marker // wins, and `thesis/` has one, so no `latexmkrc` above the tempdir // can change the answer. let fx = Fixture::new(); let mut state = editor(); exec(&state, "pmacs.project.set_search_boundary(\"/\")"); let seen: String = eval(&state, "return pmacs.project.search_boundary() or \"\""); assert_eq!( seen, "/", "fixture precondition: the boundary must be the filesystem root" ); point_command_at(&state, &fake_lsp_path()); fx.write("thesis/latexmkrc", ""); let one = fx.write("thesis/chapters/one.tex", "\\section{One}\n"); let two = fx.write("thesis/appendix/two.tex", "\\section{Two}\n"); open(&state, &one); settle(&mut state); open(&state, &two); settle(&mut state); let rows = rows(&state); assert_eq!( rows.len(), 1, "a root boundary must behave like any other boundary: both \ chapters resolve to the thesis root, so ONE server: {rows:?}" ); assert_eq!( rows[0].split('|').nth(1).unwrap(), file_uri(&fx.dir("thesis")), "and that one server is rooted at the marker directory" ); } #[test] fn two_markerless_documents_in_different_directories_do_not_share_a_server() { // The complement of the pin above: the fallback is the file's own // directory, so unrelated loose documents keep separate scopes // rather than collapsing into one rootless server. let fx = Fixture::new(); let mut state = editor(); fx.bind(&state); point_command_at(&state, &fake_lsp_path()); let one = fx.write("a/one.tex", DOC); let two = fx.write("b/two.tex", DOC); open(&state, &one); settle(&mut state); open(&state, &two); settle(&mut state); let rows = rows(&state); assert_eq!(rows.len(), 2, "one server per document directory: {rows:?}"); let roots: Vec<&str> = rows.iter().map(|r| r.split('|').nth(1).unwrap()).collect(); assert!( roots.contains(&file_uri(&fx.dir("a")).as_str()), "{roots:?}" ); assert!( roots.contains(&file_uri(&fx.dir("b")).as_str()), "{roots:?}" ); } // --------------------------------------------------------------------------- // §6 — a missing `texlab` surfaces guidance through the existing // spawn-failure path (#204). Asserted, not assumed. // --------------------------------------------------------------------------- #[test] fn a_missing_texlab_surfaces_installation_guidance() { let fx = Fixture::new(); let mut state = editor(); fx.bind(&state); // A path that cannot exist, asserted — texlab IS installed on the // development machine, so relying on its absence would make this // vacuous here and meaningful only in CI. let absent = fx.dir("no-such-bin/texlab"); assert!( !absent.exists(), "fixture precondition: {} must not exist", absent.display() ); point_command_at(&state, &absent.display().to_string()); let doc = fx.write("paper/paper.tex", DOC); open(&state, &doc); settle(&mut state); assert!(rows(&state).is_empty(), "nothing may have started"); let status = status(&state); assert!( status.contains("did not start") && status.contains("latex"), "the spawn-failure path must name the language: {status:?}" ); assert!( status.contains("pmacs.lsp.config.latex.command"), "the guidance must name the override seam: {status:?}" ); // And the failure is recorded, not just flashed. let recorded: bool = eval( &state, "for _, f in ipairs(pmacs.lsp.spawn_failures()) do\n\ if f.language == \"latex\" then return true end\n\ end\n\ return false", ); assert!(recorded, "M-x lsp.status must carry the latex failure"); } // --------------------------------------------------------------------------- // The resolver declines only when there is no directory to vouch for. // A decline is the one path that reaches `pmacs.project.detect`, so its // preconditions are worth pinning. // --------------------------------------------------------------------------- #[test] fn latex_root_declines_for_a_non_string_or_pathless_argument() { let state = editor(); let nil_arg: Option = eval(&state, "return pmacs.lsp.config.latex.root(nil)"); assert_eq!(nil_arg, None, "a pathless buffer declines"); let bare: Option = eval( &state, "return pmacs.lsp.config.latex.root(\"noslash.tex\")", ); assert_eq!(bare, None, "a name with no directory component declines"); } #[test] fn latex_root_walk_stops_at_the_search_boundary() { // R8's shape, pinned directly: a marker ABOVE the boundary must be // invisible, or every markerless assertion in this file is hostage // to the developer's filesystem. let fx = Fixture::new(); let state = editor(); // Marker at the tempdir root, boundary set BELOW it. fx.write("latexmkrc", ""); let inner = fx.mkdir("inner"); fx.write("inner/chapters/one.tex", "\\section{One}\n"); exec( &state, &format!("pmacs.project.set_search_boundary(\"{}\")", lua_str(&inner)), ); let doc = fx.dir("inner/chapters/one.tex"); assert_eq!( resolve_root(&state, &doc).as_deref(), Some(fx.dir("inner/chapters").display().to_string().as_str()), "the walk must not climb past the search boundary to reach the \ marker above it" ); // The other direction, and it is not decoration: "stops at the // boundary" is also satisfied by a walk that never runs at all — // which is precisely what a `/` boundary used to produce. So assert // that within the boundary the walk still CLIMBS, and that the // boundary directory itself is a candidate (inclusive, matching // `set_search_boundary`'s documented contract). fx.write("inner/.texlabroot", ""); assert_eq!( resolve_root(&state, &doc).as_deref(), Some(inner.display().to_string().as_str()), "a marker AT the boundary directory is found, and the walk \ climbs out of `chapters/` to reach it" ); } #[test] fn latex_root_for_a_document_at_the_filesystem_root_is_the_root() { // The same root-is-special trap one level up: `/paper.tex` slices to // an EMPTY directory string, which canonicalizes to nothing, so the // resolver DECLINED — and a decline is the one path that falls // through to `pmacs.project.detect`, whose walk includes `.git`. // Hermetic: the boundary is this fixture's tempdir, so `/` is out of // bounds, no marker is examined, and the answer is the directory // itself regardless of what sits at the filesystem root. let fx = Fixture::new(); let state = editor(); fx.bind(&state); let doc = Path::new("/pmacs-lsp-latex-no-such-document.tex"); assert!( !doc.exists(), "fixture precondition: {} must not exist", doc.display() ); assert_eq!( resolve_root(&state, doc).as_deref(), Some("/"), "a document at the filesystem root roots at `/`; it must not \ decline into the shared `.git`-aware detector" ); } #[path = "common/iso.rs"] mod iso;