diff --git a/Cargo.lock b/Cargo.lock index 489a87d..70d9531 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -423,6 +423,16 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "codebook-tree-sitter-latex" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3ab764ede0961a43ee3121cd8a71dd01d355f836181ebf2ede04f4c24636ca0" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -2528,6 +2538,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" name = "pmacs" version = "1.0.0" dependencies = [ + "codebook-tree-sitter-latex", "crossbeam", "crossterm", "loro", diff --git a/Cargo.toml b/Cargo.toml index 8bfb286..2e4dbfc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -217,6 +217,22 @@ tree-sitter-yaml = "0.7" # alternative names `tree-sitter-markdown*` are forks of varying # maintenance status — pick the upstream one. tree-sitter-md = "0.5" +# LaTeX / TeX (`.tex`/`.latex`/`.sty`/`.cls`) — the "basic LaTeX mode" +# foothold and the tier-1 detection substrate for inline math +# (`docs/inline-math-framing.md`; lane framing +# `docs/latex-grammar-math-substrate-framing.md`). The canonical crate name +# `tree-sitter-latex` is squatted by a broken 0.1.0 repackage that ships no +# `scanner.c` for its declared external tokens and therefore CANNOT LINK +# (undefined `tree_sitter_latex_external_scanner_*`). `codebook-tree-sitter-latex` +# is a linkable republish of the real, maintained `latex-lsp/tree-sitter-latex` +# (MIT, texlab's author) modernized to the `LanguageFn` shim: it ships +# `scanner.c`, exports `LANGUAGE` over `tree-sitter-language 0.1` (tree-sitter +# dev-only, shared ABI crate — no second core in the graph), and its +# `grammar.js`/`scanner.c` are byte-identical to upstream (provenance diff +# discharged in the framing). It exports NO query constants, so highlighting +# uses the in-repo overlay `builtin/queries/latex/highlights.scm` (the first +# such overlay; `include_str!`'d in `crate::syntax::BUILTIN_LANGUAGES`). +codebook-tree-sitter-latex = "0.6" # T M4.4 process supervisor: signal sending without `unsafe`. Keep # the feature surface tight to keep build time low. `poll` feeds the # compile-mode group readers (cancellable poll-based reads, Q#CM3). diff --git a/builtin/queries/latex/highlights.scm b/builtin/queries/latex/highlights.scm index c39cd1b..69f36f1 100644 --- a/builtin/queries/latex/highlights.scm +++ b/builtin/queries/latex/highlights.scm @@ -1,23 +1,41 @@ +; LaTeX highlights — vendored from nvim-treesitter (Apache-2.0) and reconciled +; onto pmacs' recognized capture set (src/highlight.rs; framing Q#LX2 / F4). +; +; Reconciliation vs the verbatim upstream (previous commit): +; * @spell / @nospell markers stripped — meaningless to pmacs, and a +; same-node @nospell risks clobbering the real capture. +; * The 8 predicate-gated patterns (#eq? / #any-of? / #lua-match?) removed: +; pmacs evaluates only `#is? local` predicates, so unevaluated they would +; over-match every generic command (as \if-conditional / emphasis) and +; every line_comment (as a magic directive). Re-instating them is deferred +; to predicate support. This also drops the only @markup.italic/@markup.strong +; uses, so those need no remap. +; * Fall-through captures remapped onto the recognized set: +; @module -> @keyword (\begin/\end/sectioning commands) +; @label -> @type (environment / theorem names) +; @markup.heading -> @keyword.control (section-title text) +; @markup.link* -> @constant (labels, refs, citations, urls) +; @markup.math -> @string (inline/displayed formulas) +; Grammar node names are UNCHANGED from upstream — Query::new compiling this +; against the bundled grammar is the node-name compatibility gate (acceptance 3). + ; General syntax -(command_name) @function @nospell +(command_name) @function (caption command: _ @function) -; Turn spelling on for text -(text) @spell - ; \text, \intertext, \shortintertext, ... (text_mode - command: _ @function @nospell + command: _ @function content: (curly_group - (_) @none @spell)) + (_) @none)) ; Variables, parameters (placeholder) @variable (key_value_pair - key: (_) @variable.parameter @nospell + key: (_) @variable.parameter value: (_)) (curly_group_spec @@ -51,200 +69,167 @@ ; General environments (begin - command: _ @module + command: _ @keyword name: (curly_group_text - (text) @label @nospell)) + (text) @type)) (end - command: _ @module + command: _ @keyword name: (curly_group_text - (text) @label @nospell)) + (text) @type)) ; Definitions and references (new_command_definition - command: _ @function.macro @nospell) + command: _ @function.macro) (old_command_definition - command: _ @function.macro @nospell) + command: _ @function.macro) (let_command_definition - command: _ @function.macro @nospell) + command: _ @function.macro) (environment_definition - command: _ @function.macro @nospell + command: _ @function.macro name: (curly_group_text - (_) @label @nospell)) + (_) @type)) (theorem_definition - command: _ @function.macro @nospell + command: _ @function.macro name: (curly_group_text_list - (_) @label @nospell)) + (_) @type)) (paired_delimiter_definition - command: _ @function.macro @nospell + command: _ @function.macro declaration: (curly_group_command_name (_) @function)) +; NOTE: this grammar cut (codebook 0.6.1, ~Dec 2025) uses distinct +; `curly_group_label`/`curly_group_label_list` nodes for label commands, where +; newer latex-lsp (which nvim's query targets) unified them onto +; `curly_group_text`. Kept grammar-accurate here (acceptance 3 is the gate). (label_definition command: _ @function.macro - name: (curly_group_text - (_) @markup.link @nospell)) + name: (curly_group_label + (_) @constant)) (label_reference_range command: _ @function.macro - from: (curly_group_text - (_) @markup.link) - to: (curly_group_text - (_) @markup.link)) + from: (curly_group_label + (_) @constant) + to: (curly_group_label + (_) @constant)) (label_reference command: _ @function.macro - names: (curly_group_text_list - (_) @markup.link)) + names: (curly_group_label_list + (_) @constant)) (label_number command: _ @function.macro - name: (curly_group_text - (_) @markup.link) - number: (_) @markup.link) + name: (curly_group_label + (_) @constant) + number: (_) @constant) (citation - command: _ @function.macro @nospell - keys: (curly_group_text_list) @markup.link @nospell) + command: _ @function.macro + keys: (curly_group_text_list) @constant) -((hyperlink - command: _ @function @nospell +(hyperlink + command: _ @function uri: (curly_group_uri - (_) @markup.link.url @nospell)) @_hyperlink - (#set! @_hyperlink url @markup.link.url)) + (_) @constant)) (glossary_entry_definition - command: _ @function.macro @nospell + command: _ @function.macro name: (curly_group_text - (_) @markup.link @nospell)) + (_) @constant)) (glossary_entry_reference command: _ @function.macro name: (curly_group_text - (_) @markup.link)) + (_) @constant)) (acronym_definition - command: _ @function.macro @nospell + command: _ @function.macro name: (curly_group_text - (_) @markup.link @nospell)) + (_) @constant)) (acronym_reference command: _ @function.macro name: (curly_group_text - (_) @markup.link)) + (_) @constant)) (color_definition command: _ @function.macro name: (curly_group_text - (_) @markup.link)) + (_) @constant)) (color_reference command: _ @function.macro name: (curly_group_text - (_) @markup.link)?) + (_) @constant)?) ; Sectioning (title_declaration - command: _ @module + command: _ @keyword options: (brack_group - (_) @markup.heading.1)? + (_) @keyword.control)? text: (curly_group - (_) @markup.heading.1)) + (_) @keyword.control)) (author_declaration - command: _ @module + command: _ @keyword authors: (curly_group_author_list - (author)+ @markup.heading.1)) + (author)+ @keyword.control)) (chapter - command: _ @module + command: _ @keyword toc: (brack_group - (_) @markup.heading.2)? + (_) @keyword.control)? text: (curly_group - (_) @markup.heading.2)) + (_) @keyword.control)) (part - command: _ @module + command: _ @keyword toc: (brack_group - (_) @markup.heading.2)? + (_) @keyword.control)? text: (curly_group - (_) @markup.heading.2)) + (_) @keyword.control)) (section - command: _ @module + command: _ @keyword toc: (brack_group - (_) @markup.heading.3)? + (_) @keyword.control)? text: (curly_group - (_) @markup.heading.3)) + (_) @keyword.control)) (subsection - command: _ @module + command: _ @keyword toc: (brack_group - (_) @markup.heading.4)? + (_) @keyword.control)? text: (curly_group - (_) @markup.heading.4)) + (_) @keyword.control)) (subsubsection - command: _ @module + command: _ @keyword toc: (brack_group - (_) @markup.heading.5)? + (_) @keyword.control)? text: (curly_group - (_) @markup.heading.5)) + (_) @keyword.control)) (paragraph - command: _ @module + command: _ @keyword toc: (brack_group - (_) @markup.heading.6)? + (_) @keyword.control)? text: (curly_group - (_) @markup.heading.6)) + (_) @keyword.control)) (subparagraph - command: _ @module + command: _ @keyword toc: (brack_group - (_) @markup.heading.6)? + (_) @keyword.control)? text: (curly_group - (_) @markup.heading.6)) - -; Beamer frames -(generic_environment - (begin - name: (curly_group_text - (text) @label) - (#any-of? @label "frame")) - . - (curly_group - (_) @markup.heading)) - -((generic_command - command: (command_name) @_name - arg: (curly_group - (_) @markup.heading)) - (#eq? @_name "\\frametitle")) - -((generic_command - command: (command_name) @_name - arg: (curly_group - (_) @markup.italic)) - (#any-of? @_name "\\emph" "\\textit" "\\mathit")) - -((generic_command - command: (command_name) @_name - arg: (curly_group - (_) @markup.strong)) - (#any-of? @_name "\\textbf" "\\mathbf")) - -(generic_command - (command_name) @keyword.conditional - (#lua-match? @keyword.conditional "^\\if[a-zA-Z@]+$")) - -(generic_command - (command_name) @keyword.conditional - (#any-of? @keyword.conditional "\\fi" "\\else")) + (_) @keyword.control)) ; File inclusion commands (class_include @@ -296,47 +281,18 @@ command: _ @keyword.import paths: (curly_group_path_list) @string) -; Turn spelling off for whole nodes -[ - (label_reference) - (label_reference_range) - (label_number) - (glossary_entry_reference) - (acronym_reference) - (color_definition) - (color_reference) - (class_include) - (package_include) - (latex_include) - (verbatim_include) - (import_include) - (bibstyle_include) - (bibtex_include) - (biblatex_include) - (graphics_include) - (svg_include) - (inkscape_include) - (tikz_library_import) -] @nospell - ; Math [ (displayed_equation) (inline_formula) -] @markup.math @nospell +] @string (math_environment - (_) @markup.math) + (_) @string) ; Comments [ (line_comment) (block_comment) (comment_environment) -] @comment @spell - -((line_comment) @keyword.directive @nospell - (#lua-match? @keyword.directive "^%% !TeX")) - -((line_comment) @keyword.directive @nospell - (#lua-match? @keyword.directive "^%%&")) +] @comment diff --git a/src/syntax.rs b/src/syntax.rs index 6c91115..2f6232e 100644 --- a/src/syntax.rs +++ b/src/syntax.rs @@ -1093,8 +1093,32 @@ pub const BUILTIN_LANGUAGES: &[LanguageEntry] = &[ locals_query: &[], injections_query: &[], }, + // LaTeX / TeX. The grammar crate exports no query constants (unlike every + // entry above), so the highlights query is the in-repo overlay + // `builtin/queries/latex/highlights.scm`, `include_str!`'d as + // `LATEX_HIGHLIGHTS` below — the first such overlay in the tree (framing + // Q#LX2; the `audit-rules.scm` include is the precedent). Locals and + // injections are empty for v0; `(math_environment) @math` injection + // detection is deferred to the inline-math arc. + LanguageEntry { + name: "latex", + extensions: &["tex", "latex", "sty", "cls"], + loader: || codebook_tree_sitter_latex::LANGUAGE.into(), + highlights_query: &[LATEX_HIGHLIGHTS], + locals_query: &[], + injections_query: &[], + }, ]; +/// LaTeX highlights overlay (framing Q#LX2). The chosen grammar crate +/// (`codebook-tree-sitter-latex`) ships no query constants, so — unlike every +/// other [`BUILTIN_LANGUAGES`] entry, which references a crate-exported +/// `HIGHLIGHTS_QUERY` — LaTeX highlighting is driven by this vendored query, +/// reconciled onto pmacs' recognized capture set (`crate::highlight`). The +/// `include_str!` path mirrors the sole prior `.scm` precedent, +/// `crate::audit`'s `audit-rules.scm`. +const LATEX_HIGHLIGHTS: &str = include_str!("../builtin/queries/latex/highlights.scm"); + /// Registry that the Lua surface ([`crate::lua_bindings::install_parse`]) /// reads to map language names to grammars and buffer ids to attached /// [`ParseView`] handles. Held by `Rc<...>` --- main-thread state, no @@ -2237,6 +2261,117 @@ mod tests { ); } + #[test] + fn builtin_languages_include_latex() { + // The LaTeX entry claims its four extensions and — uniquely among + // BUILTIN_LANGUAGES — drives highlighting from the in-repo overlay + // `builtin/queries/latex/highlights.scm` (`LATEX_HIGHLIGHTS`), because + // the grammar crate exports no query constant (framing Q#LX2). + let latex = BUILTIN_LANGUAGES + .iter() + .find(|l| l.name == "latex") + .expect("`latex` language entry must be present"); + for ext in ["tex", "latex", "sty", "cls"] { + assert!(latex.extensions.contains(&ext), "`latex` claims `.{ext}`"); + } + assert!( + latex.highlights_query.contains(&LATEX_HIGHLIGHTS), + "`latex` carries the in-repo highlights overlay" + ); + assert!( + !LATEX_HIGHLIGHTS.trim().is_empty(), + "the vendored LaTeX highlights overlay is non-empty" + ); + } + + #[test] + fn latex_grammar_loads_and_parses() { + // ABI acceptance: `codebook-tree-sitter-latex` (LanguageFn over + // `tree-sitter-language 0.1`) must be accepted by our `tree-sitter` + // 0.26 core. The `verbatim` environment exercises the grammar's + // external scanner (`scanner.c`) — the exact surface the squatted, + // scanner-less `tree-sitter-latex` 0.1.0 crate lacked — so an + // error-free parse proves the linkable republish is wired, not a + // partial grammar. + let reg = SyntaxRegistry::new(); + let language = reg + .language("latex") + .expect("`latex` language loads from BUILTIN_LANGUAGES"); + let mut buf = fresh_buffer("paper.tex"); + buf.apply_edit(EditOp::Insert { + pos: 0, + bytes: b"\\documentclass{article}\n\ + \\begin{document}\n\ + Hello $x^2$ and text.\n\ + \\begin{verbatim}\n\ + raw $ text\n\ + \\end{verbatim}\n\ + \\end{document}\n", + }) + .unwrap(); + let view = ParseView::new(&buf, language, "latex".to_owned()); + let handle = view.handle(); + let _vid = buf.attach_view(Box::new(view)); + let bundle = parse_synchronously(&handle); + assert_eq!( + bundle.root_tree().root_node().kind(), + "source_file", + "LaTeX grammar roots at source_file" + ); + assert!( + !bundle.root_tree().root_node().has_error(), + "LaTeX grammar parses a document with a verbatim environment \ + (external scanner) without error" + ); + } + + #[test] + fn latex_highlights_resolve() { + // The vendored overlay must COMPILE against the bundled grammar — + // simultaneously the grammar/query node-name compatibility gate + // (framing Q#LX2): a query referencing a node the grammar version + // lacks fails here. Assert the reconciled captures are the ones pmacs + // recognizes, and that no upstream fall-through capture survived. + let reg = SyntaxRegistry::new(); + let query = reg + .highlights_query("latex") + .expect("latex highlights compile against the grammar"); + let names = query.capture_names(); + for expected in ["function", "keyword", "comment"] { + assert!( + names.contains(&expected), + "reconciled query carries the recognized `@{expected}` capture; got {names:?}" + ); + } + for stray in [ + "module", + "label", + "markup.heading", + "markup.link", + "markup.math", + ] { + assert!( + !names.contains(&stray), + "reconciliation removed the fall-through `@{stray}` capture; got {names:?}" + ); + } + } + + #[test] + fn language_for_path_resolves_latex_extensions() { + // `.tex`/`.latex`/`.sty`/`.cls` all resolve to the LaTeX grammar via + // the same extension path as every bundled language — the single + // `extensions` field wires detection ahead of the LSP filetype map. + let reg = SyntaxRegistry::new(); + for path in ["paper.tex", "slides.latex", "mypkg.sty", "myclass.cls"] { + assert_eq!( + reg.language_name_for_path(path).as_deref(), + Some("latex"), + "{path} resolves to latex" + ); + } + } + #[test] fn builtin_languages_include_bash() { // Regression guard: the bash entry claims the wider shell family