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