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 rowan::GreenNode;
8use smol_str::SmolStr;
9
10use crate::parser::grammar;
11use crate::parser::lexer::{LatexFlavor, LexConfig, VerbCtx, lex_with};
12use crate::parser::tree_builder::build_tree;
13use crate::semantic::define::scan_definitions;
14use crate::semantic::signature::builtin;
15use crate::syntax::SyntaxNode;
16
17/// A parsed document: the green tree plus any syntax errors gathered alongside
18/// it. Errors never abort the parse (see `AGENTS.md`, Core decision #5).
19#[derive(Debug, Clone)]
20pub struct Parse {
21    pub green: GreenNode,
22    pub errors: Vec<SyntaxError>,
23}
24
25impl Parse {
26    /// Materialize a fresh red-tree cursor over the parsed document. Cheap (an
27    /// atomic clone of the green node).
28    pub fn syntax(&self) -> SyntaxNode {
29        SyntaxNode::new_root(self.green.clone())
30    }
31}
32
33/// A syntax error, carried on a side channel keyed by byte range.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct SyntaxError {
36    pub message: String,
37    pub start: usize,
38    pub end: usize,
39}
40
41/// Parse LaTeX source into a lossless CST.
42///
43/// A bounded **two-pass** parse handles user-defined verbatim-argument commands
44/// (`\newcommand`/xparse definitions that other a special char's catcode — see
45/// [`crate::semantic::define`]): the lexer needs to know such commands *before* it
46/// tokenizes their call sites, but they are only discoverable from the parsed tree.
47/// So pass 1 parses with built-in verbatim knowledge only, scans the result for
48/// catcode-verbatim definitions, and — *only* when it finds any — pass 2 re-parses
49/// with those commands fed into the lexer so their arguments become opaque `VERB`
50/// tokens. Two passes is the deliberate, conservative bound: a definition visible
51/// only after the second pass's re-tokenization is a tolerated false negative. The
52/// common case (no such definition) is a single parse (AGENTS.md decisions #1, #6).
53pub fn parse(input: &str) -> Parse {
54    parse_with_flavor(input, LatexFlavor::Document)
55}
56
57/// Parse LaTeX source into a lossless CST under an explicit [`LexConfig`].
58///
59/// Identical to [`parse`] but fixes the lexer's initial catcode regime (a
60/// [`Package`](LatexFlavor::Package) flavor — `.sty`/`.cls` — starts with `@` as a
61/// letter) and whether to run the `.dtx` docstrip mode. A bare [`LatexFlavor`]
62/// coerces in, so most callers pass one directly; [`parse`] is the
63/// [`Document`](LatexFlavor::Document) wrapper.
64pub fn parse_with_flavor(input: &str, config: impl Into<LexConfig>) -> Parse {
65    let config = config.into();
66    let pass1 = parse_with(input, &VerbCtx::default(), config);
67    let ctx = verbatim_ctx(&pass1.syntax());
68    if ctx.is_empty() {
69        return pass1;
70    }
71    parse_with(input, &ctx, config)
72}
73
74/// Run the lex → grammar → tree-build pipeline once with a fixed verbatim context.
75fn parse_with(input: &str, ctx: &VerbCtx, config: LexConfig) -> Parse {
76    let tokens = lex_with(input, ctx, config);
77    let (events, errors) = grammar::parse(&tokens, ctx);
78    let green = build_tree(&tokens, &events);
79    Parse { green, errors }
80}
81
82/// Scan `root` for user definitions and collect the catcode-verbatim commands and
83/// environments into a lexer [`VerbCtx`]. Each scanned signature's verbatim flag is
84/// already resolved (`scan_definitions`); a command's `args` hold its leading,
85/// non-verbatim arguments and an environment's `args` its (all leading) arguments —
86/// the exact shapes the lexer needs.
87///
88/// The inverse case also feeds the context: a command the file redefines *non-verbatim*
89/// whose name collides with a built-in braced-verbatim command (`\code`, `\url`, …) is
90/// recorded as *suppressed*, so the local definition shadows the built-in and pass 2
91/// lexes `\code{…}` as an ordinary group (follow-up to issue #53).
92fn verbatim_ctx(root: &SyntaxNode) -> VerbCtx {
93    let db = scan_definitions(root);
94    let mut ctx = VerbCtx::default();
95    for name in db.command_names() {
96        match db.command(name) {
97            Some(sig) if sig.verbatim => ctx.insert(SmolStr::new(name), sig.args.to_vec()),
98            // Redefined non-verbatim but shadowing a built-in verbatim command: suppress
99            // the built-in capture.
100            Some(_) if builtin().command(name).is_some_and(|sig| sig.verbatim) => {
101                ctx.suppress(SmolStr::new(name));
102            }
103            _ => {}
104        }
105    }
106    for name in db.environment_names() {
107        if let Some(sig) = db.environment(name).filter(|sig| sig.verbatim_body) {
108            ctx.insert_environment(SmolStr::new(name), sig.args.to_vec());
109        }
110    }
111    ctx
112}
113
114/// Parse `input` and render the CST back to source. By the losslessness
115/// invariant this always equals `input`.
116pub fn reconstruct(input: &str) -> String {
117    parse(input).syntax().to_string()
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn reconstruct_is_identity() {
126        let input = "\\section{Hi}\n\nbody $x^2$ % c\n";
127        assert_eq!(reconstruct(input), input);
128    }
129
130    #[test]
131    fn command_wraps_its_argument_group() {
132        use crate::syntax::SyntaxKind;
133        let parse = parse(r"\a{b}");
134        let command = parse
135            .syntax()
136            .descendants()
137            .find(|n| n.kind() == SyntaxKind::COMMAND)
138            .expect("a COMMAND node");
139        assert!(
140            command.children().any(|n| n.kind() == SyntaxKind::GROUP),
141            "the argument should be a nested GROUP node"
142        );
143        assert!(parse.errors.is_empty());
144    }
145
146    /// A named math environment (`equation`, flagged `math` in the built-in DB)
147    /// parses its body in math mode: a `MATH` node whose scripts become `SCRIPTED`,
148    /// exactly as `\[…\]`. Previously the body was a prose `PARAGRAPH` of loose
149    /// tokens.
150    #[test]
151    fn math_environment_body_is_a_math_node() {
152        use crate::syntax::SyntaxKind;
153        let input = "\\begin{equation}\n  x_i^2 = y\n\\end{equation}\n";
154        let root = parse(input).syntax();
155        let math = root
156            .descendants()
157            .find(|n| n.kind() == SyntaxKind::MATH)
158            .expect("the equation body is wrapped in a MATH node");
159        assert!(
160            math.descendants().any(|n| n.kind() == SyntaxKind::SCRIPTED),
161            "scripts inside the math environment build SCRIPTED nodes"
162        );
163        assert!(
164            !root
165                .descendants()
166                .any(|n| n.kind() == SyntaxKind::PARAGRAPH),
167            "the body is math, not a prose PARAGRAPH"
168        );
169        assert_eq!(reconstruct(input), input);
170    }
171
172    /// An alignment math environment keeps its `&` columns and `\\` rows as
173    /// `AMPERSAND` / `LINE_BREAK` inside the `MATH` node, so the formatter's grid
174    /// builder still sees them.
175    #[test]
176    fn align_environment_keeps_grid_tokens_inside_math() {
177        use crate::syntax::SyntaxKind;
178        let input = "\\begin{align}\n  a &= b \\\\\n  c &= d\n\\end{align}\n";
179        let root = parse(input).syntax();
180        let math = root
181            .descendants()
182            .find(|n| n.kind() == SyntaxKind::MATH)
183            .expect("the align body is wrapped in a MATH node");
184        assert!(
185            math.children_with_tokens()
186                .any(|e| e.kind() == SyntaxKind::AMPERSAND),
187            "top-level `&` stays a direct MATH child"
188        );
189        assert!(
190            math.children().any(|n| n.kind() == SyntaxKind::LINE_BREAK),
191            "top-level `\\\\` stays a LINE_BREAK child of MATH"
192        );
193        assert_eq!(reconstruct(input), input);
194    }
195
196    /// A non-math environment (`itemize`, not flagged `math`) is unchanged: its body
197    /// stays a prose block with no `MATH` node.
198    #[test]
199    fn non_math_environment_body_is_unchanged() {
200        use crate::syntax::SyntaxKind;
201        let input = "\\begin{itemize}\n  \\item a\n\\end{itemize}\n";
202        let root = parse(input).syntax();
203        assert!(
204            !root.descendants().any(|n| n.kind() == SyntaxKind::MATH),
205            "a text environment never enters math mode"
206        );
207        assert_eq!(reconstruct(input), input);
208    }
209
210    /// An unclosed math environment recovers at EOF (the `MATH` body ends, the
211    /// `ENVIRONMENT` closes) rather than looping or corrupting; losslessness holds.
212    #[test]
213    fn unclosed_math_environment_recovers() {
214        use crate::syntax::SyntaxKind;
215        let input = "\\begin{equation}\n  a = b\n";
216        let parse = parse(input);
217        assert!(
218            parse
219                .syntax()
220                .descendants()
221                .any(|n| n.kind() == SyntaxKind::MATH),
222            "the body still parses as math"
223        );
224        assert!(!parse.errors.is_empty(), "an unclosed environment reports");
225        assert_eq!(reconstruct(input), input);
226    }
227}