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, lex_with_implicit_expl};
15use crate::parser::tree_builder::build_tree;
16use crate::semantic::define::scan_definitions;
17use crate::semantic::signature::builtin;
18use crate::syntax::SyntaxNode;
19
20pub use crate::SyntaxError;
21
22/// A green tree and the syntax errors gathered while parsing it.
23#[derive(Debug, Clone)]
24pub struct Parse {
25    pub green: GreenNode,
26    pub errors: Vec<SyntaxError>,
27}
28
29impl Parse {
30    /// Returns a red-tree cursor over the parsed document.
31    pub fn syntax(&self) -> SyntaxNode {
32        SyntaxNode::new_root(self.green.clone())
33    }
34}
35
36/// Parse LaTeX source into a lossless CST.
37///
38/// A bounded two-pass parse handles user-defined verbatim-argument commands
39/// (`\newcommand`/xparse definitions that other a special char's catcode — see
40/// [`crate::semantic::define`]): the lexer needs to know such commands *before* it
41/// tokenizes their call sites, but they are only discoverable from the parsed tree.
42/// So pass 1 parses with built-in verbatim knowledge only, scans the result for
43/// catcode-verbatim definitions, and — *only* when it finds any — pass 2 re-parses
44/// with those commands fed into the lexer so their arguments become opaque `VERB`
45/// tokens. Two passes is a conservative bound: a definition visible
46/// only after the second pass's re-tokenization is a tolerated false negative. The
47/// common case is a single parse.
48pub fn parse(input: &str) -> Parse {
49    parse_with_flavor(input, LatexFlavor::Document)
50}
51
52/// Parse LaTeX source into a lossless CST under an explicit [`LexConfig`].
53///
54/// Identical to [`parse`] but fixes the lexer's initial catcode regime (a
55/// [`Package`](LatexFlavor::Package) flavor — `.sty`/`.cls` — starts with `@` as a
56/// letter) and whether to run the `.dtx` docstrip mode. A bare [`LatexFlavor`]
57/// coerces in, so most callers pass one directly; [`parse`] is the
58/// [`Document`](LatexFlavor::Document) wrapper.
59pub fn parse_with_flavor(input: &str, config: impl Into<LexConfig>) -> Parse {
60    parse_with_declarations(input, config, &ResolvedDeclarations::default())
61}
62
63/// Parse LaTeX source under an explicit [`LexConfig`] *and* a project's
64/// [declarations](crate::declarations) — the one input to the tree that is not
65/// the text (`AGENTS.md` decision #12).
66///
67/// The declarations are applied to **pass 1**, so a declaring project pays no
68/// extra parse: they are known before a byte is lexed, unlike the file's own
69/// definitions, which are what the two-pass scan exists to discover. The second
70/// pass is then decided by asking whether the *scan* contributed anything the
71/// declarations did not already say — not by asking whether the context is
72/// empty, which a seeded context never is.
73///
74/// [`ResolvedDeclarations`] rather than a bare `SignatureDb`: this is the only
75/// signature data the parser accepts, and a type that can only come from a
76/// declaration block is what keeps a document's merged scope (package scans,
77/// scanned definitions, the CWL tier) from reaching the tree.
78pub fn parse_with_declarations(
79    input: &str,
80    config: impl Into<LexConfig>,
81    declared: &ResolvedDeclarations,
82) -> Parse {
83    parse_with_declarations_resolved(input, config, declared).0
84}
85
86/// [`parse_with_declarations`], additionally handing back the [`ParseCtx`] the
87/// returned tree was parsed under.
88///
89/// The context is not a second output so much as a *witness*: an incremental
90/// reparse that relexes a fragment of this text must do so under the same context,
91/// or the fragment's tokens are not the ones the tree holds — a `\newcommand` the
92/// scan found makes its call sites lex differently, and a relex under a default
93/// context would silently disagree. It is free to hand back, since both passes
94/// compute it anyway (the one-pass case returns the seed it compared equal to).
95///
96/// The plain [`parse_with_declarations`] stays the entry point for everyone who
97/// only wants a tree.
98pub fn parse_with_declarations_resolved(
99    input: &str,
100    config: impl Into<LexConfig>,
101    declared: &ResolvedDeclarations,
102) -> (Parse, ParseCtx) {
103    let config = config.into();
104    let mut seed = ParseCtx::default();
105    seed.overlay_declarations(declared);
106
107    let pass1 = parse_with(input, &seed, config);
108    let mut ctx = parse_ctx(&pass1.syntax());
109    // Declared wins over scanned, so the overlay is applied *after* the scan.
110    ctx.overlay_declarations(declared);
111    if ctx == seed {
112        return (pass1, ctx);
113    }
114    let pass2 = parse_with(input, &ctx, config);
115    (pass2, ctx)
116}
117
118/// Run the lex → grammar → tree-build pipeline once with a fixed scan context.
119fn parse_with(input: &str, ctx: &ParseCtx, config: LexConfig) -> Parse {
120    let tokens = lex_with(input, ctx, config);
121    let (events, errors) = grammar::parse(&tokens, ctx);
122    let green = build_tree(&tokens, &events);
123    Parse { green, errors }
124}
125
126/// Parse a root fragment under the exact file-level context of an existing parse.
127///
128/// Unlike [`parse_with_declarations_resolved`], this does not rescan definitions
129/// from the fragment. Incremental tiers need the full file's context to remain the
130/// authority, including the full file's one-shot `.dtx` implicit-expl signal.
131pub(crate) fn parse_fragment_with_ctx(
132    input: &str,
133    ctx: &ParseCtx,
134    config: LexConfig,
135    implicit_expl: bool,
136) -> Parse {
137    let tokens = lex_with_implicit_expl(input, ctx, config, implicit_expl);
138    let (events, errors) = grammar::parse(&tokens, ctx);
139    let green = build_tree(&tokens, &events);
140    Parse { green, errors }
141}
142
143/// Scan `root` for user definitions and collect the facts pass 2 needs into a
144/// [`ParseCtx`]. Each scanned signature's verbatim flag is already resolved
145/// (`scan_definitions`); a command's `args` hold its leading, non-verbatim
146/// arguments and an environment's `args` its (all leading) arguments — the exact
147/// shapes the lexer needs.
148///
149/// The inverse case also feeds the context: a command the file redefines *non-verbatim*
150/// whose name collides with a built-in raw-argument command (`\code`, `\href`, …) is
151/// recorded as *suppressed*, so the local definition shadows the built-in and pass 2
152/// lexes `\code{…}` as an ordinary group (follow-up to issue #53).
153///
154/// Environment aliases (issue #109) are projected the same way, but only when the
155/// alias is *called* somewhere: a `.sty` that defines `\bea`/`\eea` for its users
156/// and never uses them must not pay a second parse for nothing. The occurrence
157/// count is a sound one-sided filter — a definition always contributes at least one
158/// `COMMAND` node, so a called alias always has two or more, and the worst a
159/// `\renewcommand` can do is admit an unused alias (one wasted pass, never a missed
160/// pairing).
161fn parse_ctx(root: &SyntaxNode) -> ParseCtx {
162    let db = scan_definitions(root);
163    let mut ctx = ParseCtx::default();
164    for name in db.command_names() {
165        match db.command(name) {
166            Some(sig) if sig.verbatim => ctx.insert(SmolStr::new(name), sig.args.to_vec()),
167            // Redefined non-verbatim but shadowing a built-in raw-argument command:
168            // suppress the built-in capture.
169            Some(_)
170                if builtin()
171                    .command(name)
172                    .is_some_and(|sig| sig.verbatim || sig.args.iter().any(|arg| arg.verbatim)) =>
173            {
174                ctx.suppress(SmolStr::new(name));
175            }
176            _ => {}
177        }
178    }
179    for name in db.environment_names() {
180        if let Some(sig) = db.environment(name).filter(|sig| sig.verbatim_body) {
181            ctx.insert_environment(SmolStr::new(name), sig.args.to_vec());
182        }
183    }
184    // Gated on there being any alias at all: this is an extra tree walk, and the
185    // overwhelmingly common case is a file with no aliases, which must not pay for
186    // the feature.
187    if db.env_begin_aliases().next().is_some() || db.env_end_aliases().next().is_some() {
188        let called = command_call_counts(root);
189        let is_called = |name: &str| called.get(name).is_some_and(|n| *n >= 2);
190        for (name, target) in db.env_begin_aliases() {
191            if is_called(name) {
192                ctx.insert_begin_alias(SmolStr::new(name), SmolStr::new(target));
193            }
194        }
195        // No "its target must have a live opener" filter: since issue #117 the
196        // literal `\begin{X}` is an opener spelling too, so a closer alias whose
197        // partner is never defined still pairs. The `is_called` filter alone
198        // carries what that one was for — keeping an alias no call site uses from
199        // buying the file a second parse.
200        for (name, target) in db.env_end_aliases() {
201            if is_called(name) {
202                ctx.insert_end_alias(SmolStr::new(name), SmolStr::new(target));
203            }
204        }
205    }
206    ctx
207}
208
209/// How many `COMMAND` nodes name each control word in `root`. Used only for the
210/// alias "is it called anywhere" filter above, so it counts occurrences rather
211/// than distinguishing definitions from calls — see [`parse_ctx`] for why that
212/// one-sided approximation is sound.
213fn command_call_counts(root: &SyntaxNode) -> HashMap<SmolStr, usize> {
214    let mut counts: HashMap<SmolStr, usize> = HashMap::new();
215    for node in root
216        .descendants()
217        .filter(|n| n.kind() == crate::syntax::SyntaxKind::COMMAND)
218    {
219        if let Some(name) = crate::ast::command_name(&node) {
220            *counts.entry(SmolStr::new(name)).or_default() += 1;
221        }
222    }
223    counts
224}
225
226/// Parse `input` and render the CST back to source. By the losslessness
227/// invariant this always equals `input`.
228pub fn reconstruct(input: &str) -> String {
229    parse(input).syntax().to_string()
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn reconstruct_is_identity() {
238        let input = "\\section{Hi}\n\nbody $x^2$ % c\n";
239        assert_eq!(reconstruct(input), input);
240    }
241
242    #[test]
243    fn the_resolved_context_carries_the_second_pass_scan() {
244        let input = "\\newcommand\\shellcmd[1]{\\@makeother\\$#1}\n\\shellcmd{a_$b$}\n";
245        let (parse, ctx) =
246            parse_with_declarations_resolved(input, LatexFlavor::Document, &Default::default());
247
248        assert_ne!(
249            ctx,
250            ParseCtx::default(),
251            "the scan found a verbatim definition, so the context must not be the seed"
252        );
253        let relexed: String = lex_with(input, &ctx, LatexFlavor::Document.into())
254            .iter()
255            .map(|t| t.text.as_str())
256            .collect();
257        assert_eq!(relexed, parse.syntax().to_string());
258        assert!(
259            parse.syntax().to_string().contains("a_$b$"),
260            "the call-site argument should be captured whole"
261        );
262    }
263
264    #[test]
265    fn the_resolved_context_is_the_seed_when_no_scan_contributes() {
266        let input = "\\section{Hi}\n\nplain prose\n";
267        let (_, ctx) =
268            parse_with_declarations_resolved(input, LatexFlavor::Document, &Default::default());
269        assert_eq!(ctx, ParseCtx::default());
270    }
271
272    #[test]
273    fn command_wraps_its_argument_group() {
274        use crate::syntax::SyntaxKind;
275        let parse = parse(r"\a{b}");
276        let command = parse
277            .syntax()
278            .descendants()
279            .find(|n| n.kind() == SyntaxKind::COMMAND)
280            .expect("a COMMAND node");
281        assert!(
282            command.children().any(|n| n.kind() == SyntaxKind::GROUP),
283            "the argument should be a nested GROUP node"
284        );
285        assert!(parse.errors.is_empty());
286    }
287
288    #[test]
289    fn math_environment_body_is_a_math_node() {
290        use crate::syntax::SyntaxKind;
291        let input = "\\begin{equation}\n  x_i^2 = y\n\\end{equation}\n";
292        let root = parse(input).syntax();
293        let math = root
294            .descendants()
295            .find(|n| n.kind() == SyntaxKind::MATH)
296            .expect("the equation body is wrapped in a MATH node");
297        assert!(
298            math.descendants().any(|n| n.kind() == SyntaxKind::SCRIPTED),
299            "scripts inside the math environment build SCRIPTED nodes"
300        );
301        assert!(
302            !root
303                .descendants()
304                .any(|n| n.kind() == SyntaxKind::PARAGRAPH),
305            "the body is math, not a prose PARAGRAPH"
306        );
307        assert_eq!(reconstruct(input), input);
308    }
309
310    #[test]
311    fn align_environment_keeps_grid_tokens_inside_math() {
312        use crate::syntax::SyntaxKind;
313        let input = "\\begin{align}\n  a &= b \\\\\n  c &= d\n\\end{align}\n";
314        let root = parse(input).syntax();
315        let math = root
316            .descendants()
317            .find(|n| n.kind() == SyntaxKind::MATH)
318            .expect("the align body is wrapped in a MATH node");
319        assert!(
320            math.children_with_tokens()
321                .any(|e| e.kind() == SyntaxKind::AMPERSAND),
322            "top-level `&` stays a direct MATH child"
323        );
324        assert!(
325            math.children().any(|n| n.kind() == SyntaxKind::LINE_BREAK),
326            "top-level `\\\\` stays a LINE_BREAK child of MATH"
327        );
328        assert_eq!(reconstruct(input), input);
329    }
330
331    #[test]
332    fn non_math_environment_body_is_unchanged() {
333        use crate::syntax::SyntaxKind;
334        let input = "\\begin{itemize}\n  \\item a\n\\end{itemize}\n";
335        let root = parse(input).syntax();
336        assert!(
337            !root.descendants().any(|n| n.kind() == SyntaxKind::MATH),
338            "a text environment never enters math mode"
339        );
340        assert_eq!(reconstruct(input), input);
341    }
342
343    #[test]
344    fn unclosed_math_environment_recovers() {
345        use crate::syntax::SyntaxKind;
346        let input = "\\begin{equation}\n  a = b\n";
347        let parse = parse(input);
348        assert!(
349            parse
350                .syntax()
351                .descendants()
352                .any(|n| n.kind() == SyntaxKind::MATH),
353            "the body still parses as math"
354        );
355        assert!(!parse.errors.is_empty(), "an unclosed environment reports");
356        assert_eq!(reconstruct(input), input);
357    }
358}