Skip to main content

badness_parser/parser/
core.rs

1//! The parser entry point and its output type.
2//!
3//! `parse` runs the pipeline: [`lex`](crate::parser::lex) → [`grammar::parse`] (the recursive
4//! descent, which emits events + errors) → [`build_tree`] (the green tree).
5//! Syntax errors ride a side channel and never abort the parse.
6
7use std::collections::HashMap;
8
9use rowan::GreenNode;
10use smol_str::SmolStr;
11
12use crate::declarations::ResolvedDeclarations;
13use crate::parser::grammar;
14use crate::parser::lexer::{LatexFlavor, LexConfig, ParseCtx, lex_with};
15use crate::parser::tree_builder::build_tree;
16use crate::semantic::define::scan_definitions;
17use crate::semantic::signature::builtin;
18use crate::syntax::SyntaxNode;
19
20/// A parsed document: the green tree plus any syntax errors gathered alongside
21/// it. Errors never abort the parse (see `AGENTS.md`, Core decision #5).
22#[derive(Debug, Clone)]
23pub struct Parse {
24    pub green: GreenNode,
25    pub errors: Vec<SyntaxError>,
26}
27
28impl Parse {
29    /// Materialize a fresh red-tree cursor over the parsed document. Cheap (an
30    /// atomic clone of the green node).
31    pub fn syntax(&self) -> SyntaxNode {
32        SyntaxNode::new_root(self.green.clone())
33    }
34}
35
36/// A syntax error, carried on a side channel keyed by byte range.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct SyntaxError {
39    pub message: String,
40    pub start: usize,
41    pub end: usize,
42}
43
44/// Parse LaTeX source into a lossless CST.
45///
46/// A bounded **two-pass** parse handles user-defined verbatim-argument commands
47/// (`\newcommand`/xparse definitions that other a special char's catcode — see
48/// [`crate::semantic::define`]): the lexer needs to know such commands *before* it
49/// tokenizes their call sites, but they are only discoverable from the parsed tree.
50/// So pass 1 parses with built-in verbatim knowledge only, scans the result for
51/// catcode-verbatim definitions, and — *only* when it finds any — pass 2 re-parses
52/// with those commands fed into the lexer so their arguments become opaque `VERB`
53/// tokens. Two passes is the deliberate, conservative bound: a definition visible
54/// only after the second pass's re-tokenization is a tolerated false negative. The
55/// common case (no such definition) is a single parse (AGENTS.md decisions #1, #6).
56pub fn parse(input: &str) -> Parse {
57    parse_with_flavor(input, LatexFlavor::Document)
58}
59
60/// Parse LaTeX source into a lossless CST under an explicit [`LexConfig`].
61///
62/// Identical to [`parse`] but fixes the lexer's initial catcode regime (a
63/// [`Package`](LatexFlavor::Package) flavor — `.sty`/`.cls` — starts with `@` as a
64/// letter) and whether to run the `.dtx` docstrip mode. A bare [`LatexFlavor`]
65/// coerces in, so most callers pass one directly; [`parse`] is the
66/// [`Document`](LatexFlavor::Document) wrapper.
67pub fn parse_with_flavor(input: &str, config: impl Into<LexConfig>) -> Parse {
68    parse_with_declarations(input, config, &ResolvedDeclarations::default())
69}
70
71/// Parse LaTeX source under an explicit [`LexConfig`] *and* a project's
72/// [declarations](crate::declarations) — the one input to the tree that is not
73/// the text (`AGENTS.md` decision #12).
74///
75/// The declarations are applied to **pass 1**, so a declaring project pays no
76/// extra parse: they are known before a byte is lexed, unlike the file's own
77/// definitions, which are what the two-pass scan exists to discover. The second
78/// pass is then decided by asking whether the *scan* contributed anything the
79/// declarations did not already say — not by asking whether the context is
80/// empty, which a seeded context never is.
81///
82/// [`ResolvedDeclarations`] rather than a bare `SignatureDb`: this is the only
83/// signature data the parser accepts, and a type that can only come from a
84/// declaration block is what keeps a document's merged scope (package scans,
85/// scanned definitions, the CWL tier) from reaching the tree.
86pub fn parse_with_declarations(
87    input: &str,
88    config: impl Into<LexConfig>,
89    declared: &ResolvedDeclarations,
90) -> Parse {
91    let config = config.into();
92    let mut seed = ParseCtx::default();
93    seed.overlay_declarations(declared);
94
95    let pass1 = parse_with(input, &seed, config);
96    let mut ctx = parse_ctx(&pass1.syntax());
97    // Declared wins over scanned, so the overlay is applied *after* the scan.
98    ctx.overlay_declarations(declared);
99    if ctx == seed {
100        return pass1;
101    }
102    parse_with(input, &ctx, config)
103}
104
105/// Run the lex → grammar → tree-build pipeline once with a fixed scan context.
106fn parse_with(input: &str, ctx: &ParseCtx, config: LexConfig) -> Parse {
107    let tokens = lex_with(input, ctx, config);
108    let (events, errors) = grammar::parse(&tokens, ctx);
109    let green = build_tree(&tokens, &events);
110    Parse { green, errors }
111}
112
113/// Scan `root` for user definitions and collect the facts pass 2 needs into a
114/// [`ParseCtx`]. Each scanned signature's verbatim flag is already resolved
115/// (`scan_definitions`); a command's `args` hold its leading, non-verbatim
116/// arguments and an environment's `args` its (all leading) arguments — the exact
117/// shapes the lexer needs.
118///
119/// The inverse case also feeds the context: a command the file redefines *non-verbatim*
120/// whose name collides with a built-in braced-verbatim command (`\code`, `\url`, …) is
121/// recorded as *suppressed*, so the local definition shadows the built-in and pass 2
122/// lexes `\code{…}` as an ordinary group (follow-up to issue #53).
123///
124/// Environment aliases (issue #109) are projected the same way, but only when the
125/// alias is *called* somewhere: a `.sty` that defines `\bea`/`\eea` for its users
126/// and never uses them must not pay a second parse for nothing. The occurrence
127/// count is a sound one-sided filter — a definition always contributes at least one
128/// `COMMAND` node, so a called alias always has two or more, and the worst a
129/// `\renewcommand` can do is admit an unused alias (one wasted pass, never a missed
130/// pairing).
131fn parse_ctx(root: &SyntaxNode) -> ParseCtx {
132    let db = scan_definitions(root);
133    let mut ctx = ParseCtx::default();
134    for name in db.command_names() {
135        match db.command(name) {
136            Some(sig) if sig.verbatim => ctx.insert(SmolStr::new(name), sig.args.to_vec()),
137            // Redefined non-verbatim but shadowing a built-in verbatim command: suppress
138            // the built-in capture.
139            Some(_) if builtin().command(name).is_some_and(|sig| sig.verbatim) => {
140                ctx.suppress(SmolStr::new(name));
141            }
142            _ => {}
143        }
144    }
145    for name in db.environment_names() {
146        if let Some(sig) = db.environment(name).filter(|sig| sig.verbatim_body) {
147            ctx.insert_environment(SmolStr::new(name), sig.args.to_vec());
148        }
149    }
150    // Gated on there being any alias at all: this is an extra tree walk, and the
151    // overwhelmingly common case is a file with no aliases, which must not pay for
152    // the feature.
153    if db.env_begin_aliases().next().is_some() {
154        let called = command_call_counts(root);
155        let is_called = |name: &str| called.get(name).is_some_and(|n| *n >= 2);
156        let mut live_targets: std::collections::HashSet<&str> = std::collections::HashSet::new();
157        for (name, target) in db.env_begin_aliases() {
158            if is_called(name) {
159                ctx.insert_begin_alias(SmolStr::new(name), SmolStr::new(target));
160                live_targets.insert(target);
161            }
162        }
163        for (name, target) in db.env_end_aliases() {
164            // A closer whose target has no live opener can never pair, and left in
165            // it would keep `ParseCtx::is_empty` false all on its own — buying the
166            // file a second parse that has no alias work to do.
167            if is_called(name) && live_targets.contains(target) {
168                ctx.insert_end_alias(SmolStr::new(name), SmolStr::new(target));
169            }
170        }
171    }
172    ctx
173}
174
175/// How many `COMMAND` nodes name each control word in `root`. Used only for the
176/// alias "is it called anywhere" filter above, so it counts occurrences rather
177/// than distinguishing definitions from calls — see [`parse_ctx`] for why that
178/// one-sided approximation is sound.
179fn command_call_counts(root: &SyntaxNode) -> HashMap<SmolStr, usize> {
180    let mut counts: HashMap<SmolStr, usize> = HashMap::new();
181    for node in root
182        .descendants()
183        .filter(|n| n.kind() == crate::syntax::SyntaxKind::COMMAND)
184    {
185        if let Some(name) = crate::ast::command_name(&node) {
186            *counts.entry(SmolStr::new(name)).or_default() += 1;
187        }
188    }
189    counts
190}
191
192/// Parse `input` and render the CST back to source. By the losslessness
193/// invariant this always equals `input`.
194pub fn reconstruct(input: &str) -> String {
195    parse(input).syntax().to_string()
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn reconstruct_is_identity() {
204        let input = "\\section{Hi}\n\nbody $x^2$ % c\n";
205        assert_eq!(reconstruct(input), input);
206    }
207
208    #[test]
209    fn command_wraps_its_argument_group() {
210        use crate::syntax::SyntaxKind;
211        let parse = parse(r"\a{b}");
212        let command = parse
213            .syntax()
214            .descendants()
215            .find(|n| n.kind() == SyntaxKind::COMMAND)
216            .expect("a COMMAND node");
217        assert!(
218            command.children().any(|n| n.kind() == SyntaxKind::GROUP),
219            "the argument should be a nested GROUP node"
220        );
221        assert!(parse.errors.is_empty());
222    }
223
224    /// A named math environment (`equation`, flagged `math` in the built-in DB)
225    /// parses its body in math mode: a `MATH` node whose scripts become `SCRIPTED`,
226    /// exactly as `\[…\]`. Previously the body was a prose `PARAGRAPH` of loose
227    /// tokens.
228    #[test]
229    fn math_environment_body_is_a_math_node() {
230        use crate::syntax::SyntaxKind;
231        let input = "\\begin{equation}\n  x_i^2 = y\n\\end{equation}\n";
232        let root = parse(input).syntax();
233        let math = root
234            .descendants()
235            .find(|n| n.kind() == SyntaxKind::MATH)
236            .expect("the equation body is wrapped in a MATH node");
237        assert!(
238            math.descendants().any(|n| n.kind() == SyntaxKind::SCRIPTED),
239            "scripts inside the math environment build SCRIPTED nodes"
240        );
241        assert!(
242            !root
243                .descendants()
244                .any(|n| n.kind() == SyntaxKind::PARAGRAPH),
245            "the body is math, not a prose PARAGRAPH"
246        );
247        assert_eq!(reconstruct(input), input);
248    }
249
250    /// An alignment math environment keeps its `&` columns and `\\` rows as
251    /// `AMPERSAND` / `LINE_BREAK` inside the `MATH` node, so the formatter's grid
252    /// builder still sees them.
253    #[test]
254    fn align_environment_keeps_grid_tokens_inside_math() {
255        use crate::syntax::SyntaxKind;
256        let input = "\\begin{align}\n  a &= b \\\\\n  c &= d\n\\end{align}\n";
257        let root = parse(input).syntax();
258        let math = root
259            .descendants()
260            .find(|n| n.kind() == SyntaxKind::MATH)
261            .expect("the align body is wrapped in a MATH node");
262        assert!(
263            math.children_with_tokens()
264                .any(|e| e.kind() == SyntaxKind::AMPERSAND),
265            "top-level `&` stays a direct MATH child"
266        );
267        assert!(
268            math.children().any(|n| n.kind() == SyntaxKind::LINE_BREAK),
269            "top-level `\\\\` stays a LINE_BREAK child of MATH"
270        );
271        assert_eq!(reconstruct(input), input);
272    }
273
274    /// A non-math environment (`itemize`, not flagged `math`) is unchanged: its body
275    /// stays a prose block with no `MATH` node.
276    #[test]
277    fn non_math_environment_body_is_unchanged() {
278        use crate::syntax::SyntaxKind;
279        let input = "\\begin{itemize}\n  \\item a\n\\end{itemize}\n";
280        let root = parse(input).syntax();
281        assert!(
282            !root.descendants().any(|n| n.kind() == SyntaxKind::MATH),
283            "a text environment never enters math mode"
284        );
285        assert_eq!(reconstruct(input), input);
286    }
287
288    /// An unclosed math environment recovers at EOF (the `MATH` body ends, the
289    /// `ENVIRONMENT` closes) rather than looping or corrupting; losslessness holds.
290    #[test]
291    fn unclosed_math_environment_recovers() {
292        use crate::syntax::SyntaxKind;
293        let input = "\\begin{equation}\n  a = b\n";
294        let parse = parse(input);
295        assert!(
296            parse
297                .syntax()
298                .descendants()
299                .any(|n| n.kind() == SyntaxKind::MATH),
300            "the body still parses as math"
301        );
302        assert!(!parse.errors.is_empty(), "an unclosed environment reports");
303        assert_eq!(reconstruct(input), input);
304    }
305}