Skip to main content

badness_parser/ast/
nodes.rs

1//! Typed [`AstNode`] wrappers over the generic CST.
2//!
3//! Accessors are positional and tolerate greedily attached groups. They expose
4//! syntax without assigning command meaning or consulting the signature database.
5
6use rowan::{NodeOrToken, TextRange, TextSize};
7use smol_str::{SmolStr, SmolStrBuilder};
8
9use super::{AstNode, AstToken, child, child_token, children};
10use crate::ast::tokens::ControlWord;
11use crate::syntax::{SyntaxKind, SyntaxNode};
12
13/// Declares a newtype wrapper over a `SyntaxNode` of exactly one `SyntaxKind`,
14/// implementing [`AstNode`]. Only the *identity* (`can_cast`/`cast`/`syntax`) is
15/// generated; every accessor is hand-written in a separate `impl` block. This is
16/// ordinary in-tree Rust, not codegen — no build step, no generated artifacts.
17macro_rules! ast_node {
18    ($(#[$meta:meta])* $name:ident, $kind:ident) => {
19        $(#[$meta])*
20        #[derive(Debug, Clone, PartialEq, Eq, Hash)]
21        pub struct $name {
22            syntax: SyntaxNode,
23        }
24
25        impl AstNode for $name {
26            fn can_cast(kind: SyntaxKind) -> bool {
27                kind == SyntaxKind::$kind
28            }
29
30            fn cast(syntax: SyntaxNode) -> Option<Self> {
31                Self::can_cast(syntax.kind()).then_some(Self { syntax })
32            }
33
34            fn syntax(&self) -> &SyntaxNode {
35                &self.syntax
36            }
37        }
38    };
39}
40
41ast_node!(
42    /// A control sequence with its greedily-attached argument groups.
43    Command, COMMAND
44);
45ast_node!(
46    /// A `{ … }` group — an argument, or a nested brace group.
47    Group, GROUP
48);
49ast_node!(
50    /// A `[ … ]` optional argument.
51    Optional, OPTIONAL
52);
53ast_node!(
54    /// The `{name}` group following `\begin` / `\end`.
55    NameGroup, NAME_GROUP
56);
57ast_node!(
58    /// A `\begin{name}` node.
59    Begin, BEGIN
60);
61ast_node!(
62    /// An `\end{name}` node.
63    End, END
64);
65ast_node!(
66    /// A `\begin{…} … \end{…}` environment.
67    Environment, ENVIRONMENT
68);
69ast_node!(
70    /// An `\if… … \else … \or … \fi` conditional the shape gate paired.
71    Conditional, CONDITIONAL
72);
73ast_node!(
74    /// One branch of a [`Conditional`]. The first holds the opener, its test, and
75    /// the then-body; every later one opens with its `\else`/`\or` divider.
76    ConditionalBranch, CONDITIONAL_BRANCH
77);
78
79impl Command {
80    /// The leading `CONTROL_WORD` token, or `None` for a control symbol. The
81    /// grammar bumps the control word as the command's first token.
82    pub fn control_word(&self) -> Option<ControlWord> {
83        self.syntax
84            .children_with_tokens()
85            .filter_map(NodeOrToken::into_token)
86            .find_map(ControlWord::cast)
87    }
88
89    /// The control-word name (leading `\` stripped), or `None` for a control
90    /// symbol.
91    pub fn name(&self) -> Option<SmolStr> {
92        self.control_word().map(|cw| SmolStr::new(cw.name()))
93    }
94
95    /// The range of the leading `CONTROL_WORD` token (the `\foo` itself, backslash
96    /// included), or `None` for a control symbol. Callers use this to underline just
97    /// the control word rather than the whole node, which may carry greedily-attached
98    /// argument groups.
99    pub fn control_word_range(&self) -> Option<TextRange> {
100        self.control_word().map(|cw| cw.range())
101    }
102
103    /// The `n`-th `GROUP` argument, if present. Filters `GROUP` only, so `OPTIONAL`
104    /// arguments do *not* shift brace indexing (`\cmd[o]{a}` → `nth_group(0)` is
105    /// `{a}`).
106    pub fn nth_group(&self, n: usize) -> Option<Group> {
107        self.groups().nth(n)
108    }
109
110    /// The `GROUP` argument nodes, in source order.
111    pub fn groups(&self) -> impl Iterator<Item = Group> {
112        children::<Group>(&self.syntax)
113    }
114
115    /// The `OPTIONAL` argument nodes, in source order.
116    pub fn optionals(&self) -> impl Iterator<Item = Optional> {
117        children::<Optional>(&self.syntax)
118    }
119
120    /// The literal text inside the `n`-th `GROUP` argument, braces dropped. Returns
121    /// `None` when there is no `n`-th group or it holds non-token content (a nested
122    /// command — not a flat literal). See [`Group::inner_text`].
123    pub fn nth_group_text(&self, n: usize) -> Option<SmolStr> {
124        self.nth_group(n)?.inner_text()
125    }
126
127    /// The byte range of the content *inside* the `n`-th `GROUP` argument together
128    /// with that inner text — the location-aware counterpart to
129    /// [`Command::nth_group_text`]. See [`Group::inner`].
130    pub fn nth_group_inner(&self, n: usize) -> Option<(TextRange, SmolStr)> {
131        self.nth_group(n)?.inner()
132    }
133
134    /// The byte range of this command spanning its control word through the end of
135    /// its *first* `{…}` group — e.g. `\label{key}` up to the closing brace of
136    /// `{key}`. Deliberately not [`SyntaxNode::text_range`], which the greedy parser
137    /// may stretch over a *second* group it attached without knowing arity
138    /// (`\label{a}\n{…}`; decision #8). Falls back to the full command range when the
139    /// first group is absent.
140    pub fn first_group_range(&self) -> TextRange {
141        match self.nth_group(0) {
142            Some(group) => TextRange::new(
143                self.syntax.text_range().start(),
144                group.syntax.text_range().end(),
145            ),
146            None => self.syntax.text_range(),
147        }
148    }
149}
150
151impl Group {
152    /// The literal text inside this group, with the enclosing braces dropped.
153    /// Concatenates the inner token text so content split across `WORD`/`.`/`/`/…
154    /// tokens (e.g. `chapters/my_file`, `sec:intro`) reassembles. Returns `None` when
155    /// the group holds non-token content (a nested command — not a flat literal) or a
156    /// parameter token (`\ref{#1}`, `\eqref{##1}` — a macro-parameter template whose
157    /// literal value exists only at expansion time).
158    pub fn inner_text(&self) -> Option<SmolStr> {
159        Some(flat_inner(&self.syntax)?.text)
160    }
161
162    /// The byte range of the content *inside* this group (the span between the
163    /// braces) together with that inner text — the location-aware counterpart to
164    /// [`Group::inner_text`]. The inner range runs from the first inner token's start
165    /// to the last inner token's end; an empty group (`{}`) yields a zero-width range
166    /// just after the `{`. Returns `None` under the same conditions as
167    /// [`Group::inner_text`].
168    ///
169    /// The text/range correspondence is exact: in the success path the group holds
170    /// only flat tokens, so its inner bytes are contiguous and per-key sub-ranges can
171    /// be sliced off the range by byte offset (used by the semantic builder to give
172    /// each key in a `\cref{a,b}` its own precise span).
173    pub fn inner(&self) -> Option<(TextRange, SmolStr)> {
174        let inner = flat_inner(&self.syntax)?;
175        let range = inner
176            .range
177            .unwrap_or_else(|| TextRange::empty(inner.empty_anchor));
178        Some((range, inner.text))
179    }
180
181    /// The raw inner source of this group with its outer braces dropped, but *all*
182    /// interior text preserved — nested `{…}` braces included. Unlike
183    /// [`Group::inner_text`], which bails on nested nodes, this reconstructs the
184    /// verbatim content needed for an xparse argument spec like `{m O{0} m}` (whose
185    /// `{0}` default parses as a nested `GROUP`). Trivia is kept verbatim; the caller
186    /// tokenizes the result.
187    pub fn inner_source(&self) -> String {
188        inner_source_of(&self.syntax)
189    }
190
191    /// The single `COMMAND` child wrapped in this group, if any.
192    pub fn command(&self) -> Option<Command> {
193        child::<Command>(&self.syntax)
194    }
195
196    /// The control-word name (leading `\` stripped) of a single `COMMAND` wrapped in
197    /// this group, as in a `\newcommand{\foo}` name group. Returns `None` unless the
198    /// group's only relevant child is exactly one control word.
199    pub fn command_name(&self) -> Option<SmolStr> {
200        self.command()?.name()
201    }
202}
203
204/// The shared body of [`Group::inner_source`], kept kind-agnostic so the
205/// free-function shim can call it on any node — an xparse default like `O{0}` parses
206/// its `{0}` as a nested group but a top-level default body may be an `OPTIONAL`
207/// rather than a `GROUP`. Concatenates all descendant token text, then drops a single
208/// leading `{` and trailing `}` if present (a bracket-delimited `OPTIONAL` keeps its
209/// brackets, matching the pre-wrapper behavior).
210pub(crate) fn inner_source_of(node: &SyntaxNode) -> String {
211    let mut text = String::new();
212    for element in node.descendants_with_tokens() {
213        if let NodeOrToken::Token(token) = element {
214            text.push_str(token.text());
215        }
216    }
217    let inner = text.strip_prefix('{').unwrap_or(&text);
218    inner.strip_suffix('}').unwrap_or(inner).to_string()
219}
220
221impl NameGroup {
222    /// The environment name — the literal text of this `NAME_GROUP`, braces dropped.
223    /// Returns `None` when it holds non-token content or a parameter token.
224    pub fn text(&self) -> Option<String> {
225        Some(flat_inner(&self.syntax)?.text.to_string())
226    }
227
228    /// The byte range of the name *inside* this `NAME_GROUP` (the span between the
229    /// braces) — the location-aware counterpart to [`NameGroup::text`]. Returns
230    /// `None` when it holds a nested node or parameter token, or the name is empty
231    /// (`\begin{}`, nothing to highlight).
232    pub fn range(&self) -> Option<TextRange> {
233        flat_inner(&self.syntax)?.range
234    }
235}
236
237struct FlatInner {
238    text: SmolStr,
239    range: Option<TextRange>,
240    empty_anchor: TextSize,
241}
242
243/// Reads brace-delimited content only when it is a literal token sequence.
244/// Keeping rejection here ensures that text-only and range-aware accessors cannot
245/// disagree about which source shapes are resolvable.
246fn flat_inner(node: &SyntaxNode) -> Option<FlatInner> {
247    let mut text = SmolStrBuilder::new();
248    let mut start = None;
249    let mut end = None;
250    let mut empty_anchor = node.text_range().start();
251
252    for element in node.children_with_tokens() {
253        match element {
254            NodeOrToken::Token(token) => match token.kind() {
255                SyntaxKind::L_BRACE => empty_anchor = token.text_range().end(),
256                SyntaxKind::R_BRACE => {}
257                SyntaxKind::HASH => return None,
258                _ => {
259                    let token_range = token.text_range();
260                    start.get_or_insert(token_range.start());
261                    end = Some(token_range.end());
262                    text.push_str(token.text());
263                }
264            },
265            NodeOrToken::Node(_) => return None,
266        }
267    }
268
269    Some(FlatInner {
270        text: text.finish(),
271        range: start
272            .zip(end)
273            .map(|(start, end)| TextRange::new(start, end)),
274        empty_anchor,
275    })
276}
277
278/// The environment an alias-delimiter node names: its bare `CONTROL_WORD` with the
279/// leading `\` stripped, when that word is not `keyword` (the spelled-out
280/// `\begin`/`\end`, whose name lives in a `NAME_GROUP` instead).
281fn alias_delimiter_name(node: &SyntaxNode, keyword: &str) -> Option<String> {
282    let head = child_token::<ControlWord>(node)?;
283    let text = head.syntax().text();
284    (text != keyword)
285        .then(|| text.strip_prefix('\\'))
286        .flatten()
287        .filter(|name| !name.is_empty())
288        .map(str::to_owned)
289}
290
291impl Begin {
292    /// The `{name}` group following `\begin`.
293    pub fn name_group(&self) -> Option<NameGroup> {
294        child::<NameGroup>(&self.syntax)
295    }
296
297    /// The environment name (braces dropped), or `None` for a malformed `\begin`.
298    ///
299    /// A `BEGIN` opened by an *environment alias* (`\bea`, issue #109) carries no
300    /// `NAME_GROUP` at all — the whole node is the bare control word — so the name
301    /// falls back to that word with its `\` stripped. Positional and meaning-free,
302    /// per decision #10: it reads the name from wherever the tree puts it and looks
303    /// nothing up. `Signatures::environment` is what maps `bea` on to the target's
304    /// curated behavior.
305    ///
306    /// The fallback is guarded on the head *not* being `\begin`, so the malformed
307    /// `\begin`-without-a-name path (which builds a `BEGIN` with no `NAME_GROUP`)
308    /// keeps reporting `None` rather than suddenly claiming to be named `begin`.
309    pub fn name(&self) -> Option<String> {
310        match self.name_group() {
311            Some(group) => group.text(),
312            None => alias_delimiter_name(&self.syntax, "\\begin"),
313        }
314    }
315
316    /// Whether this `BEGIN` is an *environment-alias* delimiter — a bare control
317    /// word standing in for `\begin{X}` — rather than a spelled-out `\begin{X}`.
318    ///
319    /// Purely structural (no `NAME_GROUP`, head is not `\begin`), like every other
320    /// accessor here. It exists because [`name`](Self::name) makes the two shapes
321    /// indistinguishable by name, and the alias table describes the *command*, not
322    /// the name: a literal `\begin{bea}` written in a file that also defines `\bea`
323    /// as an alias is a different, unrelated environment and must not inherit the
324    /// target's behavior. `Signatures::environment_at` is the consumer.
325    pub fn is_alias(&self) -> bool {
326        self.name_group().is_none() && alias_delimiter_name(&self.syntax, "\\begin").is_some()
327    }
328
329    /// The byte range of the environment name inside the `NAME_GROUP`.
330    pub fn name_range(&self) -> Option<TextRange> {
331        self.name_group()?.range()
332    }
333}
334
335impl End {
336    /// The `{name}` group following `\end`.
337    pub fn name_group(&self) -> Option<NameGroup> {
338        child::<NameGroup>(&self.syntax)
339    }
340
341    /// The environment name (braces dropped), or `None` for a malformed `\end`.
342    pub fn name(&self) -> Option<String> {
343        self.name_group()?.text()
344    }
345
346    /// The byte range of the environment name inside the `NAME_GROUP`.
347    pub fn name_range(&self) -> Option<TextRange> {
348        self.name_group()?.range()
349    }
350}
351
352impl Environment {
353    /// The `\begin{…}` node, replacing the raw `children().find(==BEGIN)` idiom.
354    pub fn begin(&self) -> Option<Begin> {
355        child::<Begin>(&self.syntax)
356    }
357
358    /// The `\end{…}` node.
359    pub fn end(&self) -> Option<End> {
360        child::<End>(&self.syntax)
361    }
362
363    /// The environment name, read from the `\begin` node.
364    pub fn name(&self) -> Option<String> {
365        self.begin()?.name()
366    }
367}
368
369impl Conditional {
370    /// The branches, in source order — at least one, since the grammar opens a
371    /// branch before the opener.
372    pub fn branches(&self) -> impl Iterator<Item = ConditionalBranch> {
373        children::<ConditionalBranch>(&self.syntax)
374    }
375
376    /// The closing `\fi`, read *positionally* as the last child node rather than
377    /// by matching the name: which control word closes a conditional is the
378    /// grammar's call, and re-deciding it here would be the same meaning check
379    /// twice (decision #10). `None` only if the gate's guarantee is ever broken,
380    /// which callers must tolerate rather than assume away.
381    pub fn closer(&self) -> Option<Command> {
382        self.syntax.last_child().and_then(Command::cast)
383    }
384}
385
386impl ConditionalBranch {
387    /// The leading `\if…`/`\else`/`\or` control word of this branch, if it opens
388    /// with one. Positional: the first child node, cast to a `COMMAND`.
389    pub fn head(&self) -> Option<Command> {
390        self.syntax.first_child().and_then(Command::cast)
391    }
392}