Skip to main content

jsslint_core/tex/
parser.rs

1//! Tolerant LaTeX tokenizer — a from-scratch parser producing the same
2//! six node kinds as `pylatexenc.latexwalker`, tuned to match its
3//! observed behavior rather than general TeX semantics. Ground truth
4//! was established empirically against the installed `pylatexenc`
5//! (see git history / plan notes for the probe sessions), not by
6//! reading its source line-by-line — where the two might diverge, the
7//! `tests/parser_parity.rs` fixture-diff harness is the actual
8//! arbiter, per the plan's Phase 1 scope ("no rules yet — just prove
9//! the substrate matches").
10//!
11//! Key empirically-verified rules this parser encodes:
12//!
13//! 1. **Control-word whitespace eating.** A control WORD (`\` + one or
14//!    more `[A-Za-z@]`) swallows trailing spaces/tabs and *at most one*
15//!    newline (plus further spaces/tabs after it) into its own span —
16//!    UNLESS eating that newline would immediately cross a blank line
17//!    (two adjacent `\n`s), which is left untouched as a paragraph
18//!    boundary. A control SYMBOL (`\` + exactly one other character)
19//!    does not eat anything.
20//! 2. **Unknown macros take zero arguments.** A macro/environment name
21//!    absent from `specs::macro_argspec` (compiled from pylatexenc's
22//!    own default database — see `tex/specs.rs`) ends immediately after
23//!    its (whitespace-eaten) name. Whatever follows — commonly a
24//!    `{...}` group — is simply the next sibling node in the parent's
25//!    list, not an argument. This is *why* JSS's own macros (`\pkg`,
26//!    `\proglang`, `\code`, ...) need the sibling-group fallback that
27//!    `tex::prose` ports from `_helpers.py::_macro_args_text`.
28//! 3. **Known macros/environments** parse arguments per an argspec
29//!    string (`{` mandatory group, `[` optional bracket group, `*`
30//!    optional literal star), each preceded by a TeX-whitespace skip
31//!    (rule 1's skipping logic, reused) — except a `[` slot that finds
32//!    no `[` does NOT consume the whitespace it peeked past.
33//! 4. **`\[`/`\]` and `\(`/`\)`** are recognized as literal display/
34//!    inline math delimiters at the tokenizer level, not as generic
35//!    control symbols.
36//!
37//! Known, deliberate simplifications:
38//!
39//! - An *unbraced* mandatory argument (`\section\relax text`,
40//!   vanishingly rare in real manuscripts) reads only a single bare
41//!   token (one char, or one control-sequence *name* with no further
42//!   recursive argument parsing), rather than pylatexenc's fully
43//!   general recursive expression read.
44//! - Inside a *deliberately unclosed* `{` group (malformed input),
45//!   this tokenizer keeps tokenizing normally, while real pylatexenc's
46//!   tolerant-mode recovery silently drops a stray `\end{...}` token
47//!   encountered there — see `tests/parser_parity.rs`'s
48//!   `KNOWN_DIVERGENCES` for the one fixture this affects.
49//!
50//! Revisit either only if the fixture-diff harness surfaces a real
51//! corpus file that needs it.
52
53use super::node::*;
54use super::specs;
55
56/// What ends the current `parse_nodelist` call. The matched stop text
57/// itself is consumed (its end position is returned) but never appears
58/// in the returned node list.
59#[derive(Debug, Clone)]
60enum Stop {
61    /// Top-level document parse — nothing but end-of-input stops it.
62    EndOfInput,
63    CloseBrace,
64    CloseBracket,
65    /// A single, undoubled `$`.
66    DollarMath,
67    /// `$$`.
68    DisplayDollarMath,
69    /// Literal `\]`.
70    MathBracket,
71    /// Literal `\)`.
72    MathParen,
73    /// Literal `\end{<name>}`, exact name match.
74    EndEnvironment(String),
75}
76
77pub struct Parser<'a> {
78    chars: &'a [char],
79}
80
81/// Parse `source` into a top-level node list. Never fails — unclosed
82/// groups/environments/math simply extend to end-of-input, matching
83/// this crate's tolerant-only philosophy (the Python side has a
84/// separate strict-then-tolerant retry; this port only implements the
85/// tolerant behavior, which is what most real documents already
86/// resolve to after `core/parser.py`'s pre-neutralization).
87pub fn parse(source: &str) -> (Vec<Node>, Vec<char>) {
88    let chars: Vec<char> = source.chars().collect();
89    let (nodes, _end) = {
90        let parser = Parser { chars: &chars };
91        parser.parse_nodelist(0, &Stop::EndOfInput)
92    };
93    (nodes, chars)
94}
95
96impl<'a> Parser<'a> {
97    fn len(&self) -> usize {
98        self.chars.len()
99    }
100
101    fn parse_nodelist(&self, mut pos: usize, stop: &Stop) -> (Vec<Node>, usize) {
102        let mut nodes: Vec<Node> = Vec::new();
103        let mut chars_start: Option<usize> = None;
104        let n = self.len();
105
106        while pos < n {
107            if let Some(stop_end) = self.matches_stop(pos, stop) {
108                self.flush_chars(&mut nodes, &mut chars_start, pos);
109                return (nodes, stop_end);
110            }
111            if let Some(matched) = self.match_specials(pos) {
112                self.flush_chars(&mut nodes, &mut chars_start, pos);
113                let len = matched.chars().count();
114                nodes.push(Node::Specials(SpecialsNode {
115                    span: Span { pos, len },
116                    chars: matched.to_string(),
117                }));
118                pos += len;
119                continue;
120            }
121            match self.chars[pos] {
122                '%' => {
123                    self.flush_chars(&mut nodes, &mut chars_start, pos);
124                    let (node, next) = self.parse_comment(pos);
125                    nodes.push(Node::Comment(node));
126                    pos = next;
127                }
128                '\\' => {
129                    self.flush_chars(&mut nodes, &mut chars_start, pos);
130                    let (node, next) = self.parse_backslash(pos);
131                    nodes.push(node);
132                    pos = next;
133                }
134                '{' => {
135                    self.flush_chars(&mut nodes, &mut chars_start, pos);
136                    let (node, next) = self.parse_group(pos, GroupDelims::Brace);
137                    nodes.push(Node::Group(node));
138                    pos = next;
139                }
140                '$' => {
141                    self.flush_chars(&mut nodes, &mut chars_start, pos);
142                    let (node, next) = self.parse_dollar_math(pos);
143                    nodes.push(Node::Math(node));
144                    pos = next;
145                }
146                _ => {
147                    if chars_start.is_none() {
148                        chars_start = Some(pos);
149                    }
150                    pos += 1;
151                }
152            }
153        }
154        self.flush_chars(&mut nodes, &mut chars_start, pos);
155        (nodes, pos)
156    }
157
158    fn flush_chars(&self, nodes: &mut Vec<Node>, chars_start: &mut Option<usize>, end: usize) {
159        if let Some(start) = chars_start.take() {
160            if end > start {
161                nodes.push(Node::Chars(CharsNode {
162                    span: Span {
163                        pos: start,
164                        len: end - start,
165                    },
166                    chars: self.chars[start..end].iter().collect(),
167                }));
168            }
169        }
170    }
171
172    /// `None` if `stop` doesn't match at `pos`; otherwise the position
173    /// right after the consumed stop text.
174    fn matches_stop(&self, pos: usize, stop: &Stop) -> Option<usize> {
175        match stop {
176            Stop::EndOfInput => None,
177            Stop::CloseBrace => (self.chars[pos] == '}').then_some(pos + 1),
178            Stop::CloseBracket => (self.chars[pos] == ']').then_some(pos + 1),
179            Stop::DollarMath => {
180                (self.chars[pos] == '$' && !self.starts_with(pos, "$$")).then_some(pos + 1)
181            }
182            Stop::DisplayDollarMath => self.starts_with(pos, "$$").then_some(pos + 2),
183            Stop::MathBracket => self.starts_with(pos, r"\]").then_some(pos + 2),
184            Stop::MathParen => self.starts_with(pos, r"\)").then_some(pos + 2),
185            Stop::EndEnvironment(name) => {
186                let marker = format!(r"\end{{{name}}}");
187                self.starts_with(pos, &marker)
188                    .then_some(pos + marker.chars().count())
189            }
190        }
191    }
192
193    fn starts_with(&self, pos: usize, needle: &str) -> bool {
194        let needle_chars: Vec<char> = needle.chars().collect();
195        if pos + needle_chars.len() > self.len() {
196            return false;
197        }
198        self.chars[pos..pos + needle_chars.len()] == needle_chars[..]
199    }
200
201    /// The longest `specs::specials()` entry matching literally at
202    /// `pos`, if any (the table is already longest-first).
203    fn match_specials(&self, pos: usize) -> Option<&'static str> {
204        specs::specials()
205            .iter()
206            .find(|&&s| self.starts_with(pos, s))
207            .copied()
208    }
209
210    /// TeX whitespace skip (control-word / macro post-space eating).
211    /// Mirrors pylatexenc's tokenizer loop exactly: consume ANY
212    /// Unicode-whitespace char (not just space/tab/`\n` — `\r`, form
213    /// feed, etc. all count, like Python's `str.isspace()`), UNLESS
214    /// doing so would make the just-consumed run end with a LITERAL
215    /// `"\n\n"` (two LF in a row — a paragraph boundary), in which case
216    /// back off those 2 chars and stop. This is a literal `"\n\n"`
217    /// check, not "any two newline-class chars in a row": a `\r\n\r\n`
218    /// blank line (CRLF) does NOT trigger it and is consumed in full —
219    /// see module docs rule 1.
220    fn skip_tex_whitespace(&self, pos: usize) -> usize {
221        let n = self.len();
222        let mut i = pos;
223        while i < n && self.chars[i].is_whitespace() {
224            i += 1;
225            if i - pos >= 2 && self.chars[i - 2] == '\n' && self.chars[i - 1] == '\n' {
226                i -= 2;
227                break;
228            }
229        }
230        i
231    }
232
233    fn parse_comment(&self, pos: usize) -> (CommentNode, usize) {
234        debug_assert_eq!(self.chars[pos], '%');
235        let n = self.len();
236        let mut i = pos + 1;
237        // `\r` terminates a comment exactly like `\n` — mirrors
238        // pylatexenc's tokenizer regex `(\n|\r|\n\r)(?P<extraspace>\s*)`,
239        // which matches whichever of `\n`/`\r` occurs first (a bare `\r`
240        // stops the comment scan on its own, `\r\n` is NOT treated as
241        // one atomic terminator).
242        while i < n && self.chars[i] != '\n' && self.chars[i] != '\r' {
243            i += 1;
244        }
245        let text_end = i;
246        // Include the terminating newline in the span (not the text)
247        // UNLESS doing so would cross into a blank line — same rule as
248        // control-word whitespace-eating (`skip_tex_whitespace`); empirically
249        // verified against pylatexenc (module docs rule 1 applies here too).
250        // `\r` counts as a newline for THIS check too: since a bare `\r`
251        // is itself "the terminator" the regex matches, a `\r\n` pair
252        // means the very next char after that terminator is ALSO
253        // newline-class, so pylatexenc's paragraph-break branch fires —
254        // net effect: a `\r\n` line ending is never consumed into a
255        // comment's span at all (differs from a plain `\n`, which IS
256        // consumed when not followed by a second newline).
257        let is_newline_class = |c: char| c == '\n' || c == '\r';
258        let span_end = if i < n && !(i + 1 < n && is_newline_class(self.chars[i + 1])) {
259            i + 1
260        } else {
261            i
262        };
263        (
264            CommentNode {
265                span: Span {
266                    pos,
267                    len: span_end - pos,
268                },
269                comment: self.chars[pos + 1..text_end].iter().collect(),
270            },
271            span_end,
272        )
273    }
274
275    fn parse_group(&self, pos: usize, delims: GroupDelims) -> (GroupNode, usize) {
276        debug_assert!(self.chars[pos] == '{' || self.chars[pos] == '[');
277        let stop = match delims {
278            GroupDelims::Brace => Stop::CloseBrace,
279            GroupDelims::Bracket => Stop::CloseBracket,
280        };
281        let (nodelist, end) = self.parse_nodelist(pos + 1, &stop);
282        (
283            GroupNode {
284                span: Span {
285                    pos,
286                    len: end - pos,
287                },
288                delims,
289                nodelist,
290            },
291            end,
292        )
293    }
294
295    fn parse_dollar_math(&self, pos: usize) -> (MathNode, usize) {
296        debug_assert_eq!(self.chars[pos], '$');
297        if self.starts_with(pos, "$$") {
298            let (nodelist, end) = self.parse_nodelist(pos + 2, &Stop::DisplayDollarMath);
299            (
300                MathNode {
301                    span: Span {
302                        pos,
303                        len: end - pos,
304                    },
305                    kind: MathKind::Display,
306                    nodelist,
307                },
308                end,
309            )
310        } else {
311            let (nodelist, end) = self.parse_nodelist(pos + 1, &Stop::DollarMath);
312            (
313                MathNode {
314                    span: Span {
315                        pos,
316                        len: end - pos,
317                    },
318                    kind: MathKind::Inline,
319                    nodelist,
320                },
321                end,
322            )
323        }
324    }
325
326    /// Dispatch on what follows a `\`: `\[`/`\(` math openers,
327    /// `\begin`/`\end`, a verbatim macro, a known macro (argspec
328    /// lookup), or an unknown zero-arg macro.
329    fn parse_backslash(&self, pos: usize) -> (Node, usize) {
330        debug_assert_eq!(self.chars[pos], '\\');
331        let n = self.len();
332        if pos + 1 >= n {
333            // Trailing lone backslash at EOF — degrade to an empty-name macro.
334            return (
335                Node::Macro(MacroNode {
336                    span: Span { pos, len: 1 },
337                    macroname: String::new(),
338                    args: Vec::new(),
339                }),
340                pos + 1,
341            );
342        }
343
344        if self.starts_with(pos, r"\[") {
345            let (nodelist, end) = self.parse_nodelist(pos + 2, &Stop::MathBracket);
346            return (
347                Node::Math(MathNode {
348                    span: Span {
349                        pos,
350                        len: end - pos,
351                    },
352                    kind: MathKind::Display,
353                    nodelist,
354                }),
355                end,
356            );
357        }
358        if self.starts_with(pos, r"\(") {
359            let (nodelist, end) = self.parse_nodelist(pos + 2, &Stop::MathParen);
360            return (
361                Node::Math(MathNode {
362                    span: Span {
363                        pos,
364                        len: end - pos,
365                    },
366                    kind: MathKind::Inline,
367                    nodelist,
368                }),
369                end,
370            );
371        }
372
373        let next = self.chars[pos + 1];
374        let (name, name_end, is_word) = if next.is_ascii_alphabetic() || next == '@' {
375            let mut i = pos + 1;
376            while i < n && (self.chars[i].is_ascii_alphabetic() || self.chars[i] == '@') {
377                i += 1;
378            }
379            (self.chars[pos + 1..i].iter().collect::<String>(), i, true)
380        } else {
381            (next.to_string(), pos + 2, false)
382        };
383
384        let after_ws = if is_word {
385            self.skip_tex_whitespace(name_end)
386        } else {
387            name_end
388        };
389
390        if is_word && name == "begin" {
391            return self.parse_environment(pos, after_ws);
392        }
393        // A stray `\end` not consumed as a body's Stop::EndEnvironment
394        // (e.g. mismatched/unbalanced input) — tolerant fallback: treat
395        // as an ordinary (unknown) zero-arg macro.
396        if is_word && specs::is_verbatim_macro(&name) {
397            return self.parse_verbatim_macro(pos, name, after_ws);
398        }
399        if let Some(argspec) = specs::macro_argspec(&name) {
400            let (args, end) = self.parse_args(argspec, after_ws);
401            return (
402                Node::Macro(MacroNode {
403                    span: Span {
404                        pos,
405                        len: end - pos,
406                    },
407                    macroname: name,
408                    args,
409                }),
410                end,
411            );
412        }
413        // Unknown macro: zero args. Trailing whitespace already eaten
414        // into `after_ws` for a control word; a control symbol eats
415        // nothing (rule 1).
416        (
417            Node::Macro(MacroNode {
418                span: Span {
419                    pos,
420                    len: after_ws - pos,
421                },
422                macroname: name,
423                args: Vec::new(),
424            }),
425            after_ws,
426        )
427    }
428
429    /// `\verb<delim>...<delim>` (and similarly-shaped verbatim macros):
430    /// the character immediately after (whitespace-eaten) name-end is
431    /// the delimiter; content runs verbatim until it repeats.
432    fn parse_verbatim_macro(&self, pos: usize, name: String, after_ws: usize) -> (Node, usize) {
433        let n = self.len();
434        if after_ws >= n {
435            return (
436                Node::Macro(MacroNode {
437                    span: Span {
438                        pos,
439                        len: after_ws - pos,
440                    },
441                    macroname: name,
442                    args: Vec::new(),
443                }),
444                after_ws,
445            );
446        }
447        let delim = self.chars[after_ws];
448        let content_start = after_ws + 1;
449        let mut i = content_start;
450        while i < n && self.chars[i] != delim {
451            i += 1;
452        }
453        let content_end = i;
454        let span_end = if i < n { i + 1 } else { i }; // include closing delimiter if found
455        let content: String = self.chars[content_start..content_end].iter().collect();
456        let arg = Node::Chars(CharsNode {
457            span: Span {
458                pos: content_start,
459                len: content_end - content_start,
460            },
461            chars: content,
462        });
463        (
464            Node::Macro(MacroNode {
465                span: Span {
466                    pos,
467                    len: span_end - pos,
468                },
469                macroname: name,
470                args: vec![Some(arg)],
471            }),
472            span_end,
473        )
474    }
475
476    fn parse_environment(&self, macro_start: usize, mut pos: usize) -> (Node, usize) {
477        let n = self.len();
478        // Expect `{name}` immediately (after whitespace, already skipped
479        // by the caller via `after_ws`).
480        if pos >= n || self.chars[pos] != '{' {
481            // Malformed `\begin` with no name — tolerant fallback: an
482            // unknown zero-arg macro named "begin".
483            return (
484                Node::Macro(MacroNode {
485                    span: Span {
486                        pos: macro_start,
487                        len: pos - macro_start,
488                    },
489                    macroname: "begin".to_string(),
490                    args: Vec::new(),
491                }),
492                pos,
493            );
494        }
495        let (name, name_group_end) = self.read_brace_delimited_name(pos);
496        pos = name_group_end;
497
498        let args = if let Some(argspec) = specs::environment_argspec(&name) {
499            let (a, next) = self.parse_args(argspec, pos);
500            pos = next;
501            a
502        } else {
503            Vec::new()
504        };
505
506        if specs::is_verbatim_environment(&name) {
507            return self.parse_verbatim_environment(macro_start, name, pos);
508        }
509
510        let (nodelist, end) = self.parse_nodelist(pos, &Stop::EndEnvironment(name.clone()));
511        (
512            Node::Environment(EnvironmentNode {
513                span: Span {
514                    pos: macro_start,
515                    len: end - macro_start,
516                },
517                environmentname: name,
518                args,
519                nodelist,
520            }),
521            end,
522        )
523    }
524
525    /// The `verbatim`/`verbatim*` environment: body is read as raw
526    /// text up to (not including further parsing of) the literal
527    /// `\end{name}` marker.
528    fn parse_verbatim_environment(
529        &self,
530        macro_start: usize,
531        name: String,
532        pos: usize,
533    ) -> (Node, usize) {
534        let marker: Vec<char> = format!(r"\end{{{name}}}").chars().collect();
535        let n = self.len();
536        let mut i = pos;
537        while i < n {
538            if i + marker.len() <= n && self.chars[i..i + marker.len()] == marker[..] {
539                break;
540            }
541            i += 1;
542        }
543        let body_end = i;
544        let span_end = if i < n { i + marker.len() } else { i };
545        let body_text: String = self.chars[pos..body_end].iter().collect();
546        // Mirrors pylatexenc's `VerbatimArgsParser`: the raw body lives
547        // in the environment's ARGUMENT list (`nodeargd.argnlist`), not
548        // its `nodelist` — `nodelist` is empty. This matters beyond
549        // cosmetics: `prose::walk`/`_walk_with_context` only recurse
550        // into `nodelist` for an `Environment` node, so a body stored
551        // there would be visible to prose-context walking, while one
552        // stored in `args` (matching pylatexenc) is invisible to it —
553        // and WIDTH-001/CODE-* read `env.nodelist` too, so this also
554        // reproduces Python's (accidental, but real) gap where content
555        // inside a literal `verbatim` env is never width/style-checked.
556        let verbatim_arg = vec![Some(Node::Chars(CharsNode {
557            span: Span {
558                pos,
559                len: body_end - pos,
560            },
561            chars: body_text,
562        }))];
563        (
564            Node::Environment(EnvironmentNode {
565                span: Span {
566                    pos: macro_start,
567                    len: span_end - macro_start,
568                },
569                environmentname: name,
570                args: verbatim_arg,
571                nodelist: Vec::new(),
572            }),
573            span_end,
574        )
575    }
576
577    /// Reads `{name}` starting at `pos` (which must be `{`), where
578    /// `name` is a raw identifier (no nested macro parsing — matches
579    /// environment names never legitimately containing them).
580    fn read_brace_delimited_name(&self, pos: usize) -> (String, usize) {
581        debug_assert_eq!(self.chars[pos], '{');
582        let n = self.len();
583        let mut i = pos + 1;
584        while i < n && self.chars[i] != '}' {
585            i += 1;
586        }
587        let name: String = self.chars[pos + 1..i].iter().collect();
588        let end = if i < n { i + 1 } else { i };
589        (name, end)
590    }
591
592    /// Parses `argspec` (chars `{`/`[`/`*`) starting at `pos`. See
593    /// module docs rule 3 for the whitespace-consumption asymmetry
594    /// between mandatory and optional slots.
595    fn parse_args(&self, argspec: &str, mut pos: usize) -> (Args, usize) {
596        let mut args: Args = Vec::with_capacity(argspec.chars().count());
597        for slot in argspec.chars() {
598            match slot {
599                '{' => {
600                    pos = self.skip_tex_whitespace(pos);
601                    if pos < self.len() && self.chars[pos] == '{' {
602                        let (g, next) = self.parse_group(pos, GroupDelims::Brace);
603                        pos = next;
604                        args.push(Some(Node::Group(g)));
605                    } else if pos < self.len() {
606                        let (node, next) = self.parse_single_token(pos);
607                        pos = next;
608                        args.push(Some(node));
609                    } else {
610                        args.push(None);
611                    }
612                }
613                '[' => {
614                    let peek = self.skip_tex_whitespace(pos);
615                    if peek < self.len() && self.chars[peek] == '[' {
616                        let (g, next) = self.parse_group(peek, GroupDelims::Bracket);
617                        pos = next;
618                        args.push(Some(Node::Group(g)));
619                    } else {
620                        args.push(None); // pos NOT advanced — matches pylatexenc
621                    }
622                }
623                '*' => {
624                    if pos < self.len() && self.chars[pos] == '*' {
625                        args.push(Some(Node::Chars(CharsNode {
626                            span: Span { pos, len: 1 },
627                            chars: "*".to_string(),
628                        })));
629                        pos += 1;
630                    } else {
631                        args.push(None);
632                    }
633                }
634                _ => {}
635            }
636        }
637        (args, pos)
638    }
639
640    /// Simplified single-token read for an unbraced mandatory argument
641    /// — see module docs' "known simplification" note.
642    fn parse_single_token(&self, pos: usize) -> (Node, usize) {
643        if self.chars[pos] == '}' {
644            // A lone closing brace can never BE an argument — it's
645            // whatever group we're already inside trying to close.
646            // Mirrors pylatexenc's `get_latex_expression`'s
647            // `tok.tok == 'brace_close'` branch: return an empty node
648            // at this position WITHOUT consuming the brace, so the
649            // enclosing group's own close-brace scan sees it next.
650            // Without this, a macro used bare (no explicit `{...}`) as
651            // the last thing before a group closes — e.g.
652            // `\newcommand{\mbf}{ \mathbf }` — swallows the `}` as
653            // `\mathbf`'s "argument", and the enclosing group never
654            // finds its own terminator, running away to end of input.
655            return (
656                Node::Chars(CharsNode {
657                    span: Span { pos, len: 0 },
658                    chars: String::new(),
659                }),
660                pos,
661            );
662        }
663        if self.chars[pos] == '\\' {
664            let n = self.len();
665            if pos + 1 >= n {
666                return (
667                    Node::Macro(MacroNode {
668                        span: Span { pos, len: 1 },
669                        macroname: String::new(),
670                        args: Vec::new(),
671                    }),
672                    pos + 1,
673                );
674            }
675            let next = self.chars[pos + 1];
676            if next.is_ascii_alphabetic() || next == '@' {
677                let mut i = pos + 1;
678                while i < n && (self.chars[i].is_ascii_alphabetic() || self.chars[i] == '@') {
679                    i += 1;
680                }
681                let end = self.skip_tex_whitespace(i);
682                return (
683                    Node::Macro(MacroNode {
684                        span: Span {
685                            pos,
686                            len: end - pos,
687                        },
688                        macroname: self.chars[pos + 1..i].iter().collect(),
689                        args: Vec::new(),
690                    }),
691                    end,
692                );
693            }
694            return (
695                Node::Macro(MacroNode {
696                    span: Span { pos, len: 2 },
697                    macroname: next.to_string(),
698                    args: Vec::new(),
699                }),
700                pos + 2,
701            );
702        }
703        (
704            Node::Chars(CharsNode {
705                span: Span { pos, len: 1 },
706                chars: self.chars[pos].to_string(),
707            }),
708            pos + 1,
709        )
710    }
711}