Skip to main content

badness_parser/semantic/
expl3.rs

1//! The expl3 call-site model: **argspec arity** for expl3 function names, and
2//! the **statement segmentation** built on it.
3//!
4//! Two halves, both semantics layered on the syntax tree (like
5//! [`define`](super::define)'s definition scan): [`expl3_slots`] derives
6//! per-slot arity from the letters after the final `:` in `\cs_new:Npn`,
7//! `\tl_if_empty:nTF`, …, and [`segment_expl_statements`] applies it to an
8//! in-region element stream to produce the statement model the formatter's
9//! expl3 layout consumes. Neither builds `Ir` or touches layout policy — a
10//! wrong answer here can only produce ugly formatting downstream, never a
11//! wrong tree or a lost byte.
12//!
13//! Like [`xparse`](super::xparse), the argspec is parsed rather than executed:
14//! each letter describes an argument's call-site shape. No
15//! signature database is involved: the name string alone carries the spec, so
16//! there is nothing to curate and nothing to drift. Only meaningful inside an
17//! expl3 region, where `:`/`_` are catcode-11 and the whole name lexes as one
18//! `CONTROL_WORD` — callers of the segmentation guarantee the stream is
19//! in-region (out-of-region, colon names lex split and everything degrades to
20//! the fallback).
21//!
22//! The letter-by-letter model (interface3's argument specifiers):
23//!
24//! - `N`, `V` → [`Expl3Slot::SingleToken`]: one token, typically a control
25//!   sequence (`V` differs from `N` only in *expansion*, not call-site shape).
26//! - `n`, `c`, `v`, `o`, `x`, `e`, `f` → [`Expl3Slot::Group`]: one braced
27//!   `{…}` group (again, the letters differ only in how the material is
28//!   processed, which we never model).
29//! - `T`, `F` → [`Expl3Slot::Branch`]: a braced conditional branch. Sanctioned
30//!   only as a *trailing* run — in a standard argspec `T`/`F` are always last,
31//!   so a mid-spec `T`/`F` is treated as unknown.
32//! - `p` → [`Expl3Slot::ParameterText`]: TeX parameter text (`#1#2…`), which
33//!   has no fixed token count but a static *end*: TeX's own rule that the
34//!   parameter text runs to the first explicit `{`. The consumer scans by that
35//!   shape.
36//! - `w` (arbitrary delimiters) and `D` (kernel primitive) have no lexically
37//!   derivable call-site shape → the whole name is unrecognized (`None`), as is
38//!   any unknown letter (including one added to expl3 after this list was
39//!   written — new letters degrade to unrecognized, never to a wrong arity).
40
41use std::collections::VecDeque;
42
43use rowan::TextRange;
44
45use crate::ast::command_name;
46use crate::parser::lexer::expl_toggle;
47use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode, is_collapsible_trivia, is_param_digit};
48
49/// The call-site shape of one expl3 argument slot, derived from an argspec letter.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Expl3Slot {
52    /// `N`, `V`: exactly one token, typically a control sequence.
53    SingleToken,
54    /// `n`, `c`, `v`, `o`, `x`, `e`, `f`: one braced `{…}` group.
55    Group,
56    /// `T`, `F`: a braced conditional branch (a [`Group`](Expl3Slot::Group) a
57    /// consumer may lay out specially).
58    Branch,
59    /// `p`: TeX parameter text — the tokens up to (not including) the next
60    /// explicit `{`.
61    ParameterText,
62}
63
64/// The argument slots of an expl3 function name, read from its argspec suffix
65/// (the substring after the *final* `:`), or `None` when the name has no
66/// derivable call-site arity.
67///
68/// `Some` iff the name contains a `:` and every suffix letter is a fixed-shape
69/// letter per the module docs; an empty suffix (`\scan_stop:`, `\group_end:`)
70/// is `Some(vec![])` — a recognized zero-argument call. `None` for a colonless
71/// name (`\def`, `\@ifpackageloaded`), or a spec containing `w`, `D`, a
72/// mid-spec `T`/`F`, or any unknown letter.
73pub fn expl3_slots(name: &str) -> Option<Vec<Expl3Slot>> {
74    let argspec = name.rsplit_once(':')?.1;
75    let chars: Vec<char> = argspec.chars().collect();
76    let branches = chars
77        .iter()
78        .rev()
79        .take_while(|c| matches!(c, 'T' | 'F'))
80        .count();
81    let mut slots = Vec::with_capacity(chars.len());
82    for c in &chars[..chars.len() - branches] {
83        // `T`/`F` never match here, so a *mid*-spec `T`/`F` (nonstandard) falls
84        // through to unknown.
85        slots.push(match c {
86            'N' | 'V' => Expl3Slot::SingleToken,
87            'n' | 'c' | 'v' | 'o' | 'x' | 'e' | 'f' => Expl3Slot::Group,
88            'p' => Expl3Slot::ParameterText,
89            _ => return None,
90        });
91    }
92    slots.extend(std::iter::repeat_n(Expl3Slot::Branch, branches));
93    Some(slots)
94}
95
96/// The number of trailing `T`/`F` branch arguments of an expl3 conditional, read
97/// from the command *name*'s argspec (the substring after the final `:`).
98/// `\tl_if_empty:nTF` → `Some(2)`, `\bool_if:nT`/`:nF` → `Some(1)`; `None` for any
99/// name without a `:`-argspec ending in `T`/`F` — a non-conditional expl3 function
100/// (`\seq_new:N`), or a LaTeX2e command with no colon (`\@ifpackageloaded`). In an
101/// expl3 argspec `T`/`F` denote *only* the true/false branch slots, so a trailing
102/// `T`/`F` run is exactly the branch count.
103///
104/// Deliberately **not** derived from [`expl3_slots`]: this counts the raw
105/// trailing run, so a name whose *earlier* letters make the arity unrecognized
106/// (a hypothetical `:wTF` shape) still reports its branches — the conditional
107/// layout keys on the branches alone and must not regress when the full arity
108/// model bows out.
109pub fn conditional_branches(name: &str) -> Option<usize> {
110    let argspec = name.rsplit_once(':')?.1;
111    let n = argspec
112        .chars()
113        .rev()
114        .take_while(|c| *c == 'T' || *c == 'F')
115        .count();
116    (n > 0).then_some(n)
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use Expl3Slot::*;
123
124    #[test]
125    fn slots_read_from_name_suffix() {
126        assert_eq!(
127            expl3_slots("cs_new:Npn"),
128            Some(vec![SingleToken, ParameterText, Group])
129        );
130        assert_eq!(
131            expl3_slots("str_if_eq:nnTF"),
132            Some(vec![Group, Group, Branch, Branch])
133        );
134        assert_eq!(
135            expl3_slots("prop_get:NnNTF"),
136            Some(vec![SingleToken, Group, SingleToken, Branch, Branch])
137        );
138        assert_eq!(expl3_slots("tl_set:Nn"), Some(vec![SingleToken, Group]));
139        assert_eq!(
140            expl3_slots("exp_args:NNo"),
141            Some(vec![SingleToken, SingleToken, Group])
142        );
143        assert_eq!(expl3_slots("tl_set:Nv"), Some(vec![SingleToken, Group]));
144        assert_eq!(expl3_slots("use:c"), Some(vec![Group]));
145        assert_eq!(expl3_slots("tl_set:Nx"), Some(vec![SingleToken, Group]));
146    }
147
148    #[test]
149    fn zero_argument_names_are_recognized() {
150        assert_eq!(expl3_slots("scan_stop:"), Some(vec![]));
151        assert_eq!(expl3_slots("group_begin:"), Some(vec![]));
152        assert_eq!(expl3_slots("prg_return_true:"), Some(vec![]));
153    }
154
155    #[test]
156    fn underivable_specs_are_unrecognized() {
157        assert_eq!(expl3_slots("use_none_delimit_by_q_stop:w"), None);
158        assert_eq!(expl3_slots("exp_after:wN"), None);
159        assert_eq!(expl3_slots("tex_relax:D"), None);
160        assert_eq!(expl3_slots("odd:TnF"), None);
161        assert_eq!(expl3_slots("odd:nZn"), None);
162    }
163
164    #[test]
165    fn colonless_names_are_unrecognized() {
166        assert_eq!(expl3_slots("def"), None);
167        assert_eq!(expl3_slots("@ifpackageloaded"), None);
168        assert_eq!(expl3_slots("IfBooleanTF"), None);
169        assert_eq!(expl3_slots("l_tmpa_tl"), None);
170    }
171
172    #[test]
173    fn exp_internal_drivers() {
174        assert_eq!(expl3_slots("::n"), Some(vec![Group]));
175        assert_eq!(expl3_slots(":::"), Some(vec![]));
176    }
177
178    #[test]
179    fn conditional_branches_read_from_name_suffix() {
180        assert_eq!(conditional_branches("tl_if_empty:nTF"), Some(2));
181        assert_eq!(conditional_branches("bool_if:nT"), Some(1));
182        assert_eq!(conditional_branches("bool_if:nF"), Some(1));
183        assert_eq!(conditional_branches("str_if_eq:nnTF"), Some(2));
184        assert_eq!(conditional_branches("int_compare:nNnTF"), Some(2));
185        assert_eq!(conditional_branches("seq_map_inline:Nn"), None);
186        assert_eq!(conditional_branches("prg_return_true:"), None);
187        assert_eq!(conditional_branches("tl_new:N"), None);
188        assert_eq!(conditional_branches("@ifpackageloaded"), None);
189        assert_eq!(conditional_branches("IfBooleanTF"), None);
190    }
191
192    #[test]
193    fn branches_survive_underivable_arity() {
194        assert_eq!(expl3_slots("odd_if:wTF"), None);
195        assert_eq!(conditional_branches("odd_if:wTF"), Some(2));
196    }
197}
198
199/// Statement boundaries and attachment flags for an expl3 element stream.
200pub struct StatementMap {
201    flags: Vec<ElementFlags>,
202}
203
204#[derive(Clone, Copy, Default)]
205struct ElementFlags(u8);
206
207impl ElementFlags {
208    const BOUNDARY_AFTER: u8 = 1 << 0;
209    const GLUE_BEFORE: u8 = 1 << 1;
210    const GLUED: u8 = 1 << 2;
211    const FALLBACK: u8 = 1 << 3;
212
213    fn contains(self, flag: u8) -> bool {
214        self.0 & flag != 0
215    }
216
217    fn insert(&mut self, flag: u8) {
218        self.0 |= flag;
219    }
220}
221
222impl StatementMap {
223    pub fn boundary_after(&self, idx: usize) -> bool {
224        self.flags
225            .get(idx)
226            .is_some_and(|flags| flags.contains(ElementFlags::BOUNDARY_AFTER))
227    }
228
229    pub fn glue_before(&self, idx: usize) -> bool {
230        self.flags
231            .get(idx)
232            .is_some_and(|flags| flags.contains(ElementFlags::GLUE_BEFORE))
233    }
234
235    pub fn is_glued(&self, idx: usize) -> bool {
236        self.flags
237            .get(idx)
238            .is_some_and(|flags| flags.contains(ElementFlags::GLUED))
239    }
240
241    pub fn is_fallback(&self, idx: usize) -> bool {
242        self.flags
243            .get(idx)
244            .is_some_and(|flags| flags.contains(ElementFlags::FALLBACK))
245    }
246}
247
248/// Segments an expl3 element stream into statements.
249pub fn segment_expl_statements(elements: &[SyntaxElement]) -> StatementMap {
250    let mut flags = vec![ElementFlags::default(); elements.len()];
251    let mut i = 0;
252    while i < elements.len() {
253        match &elements[i] {
254            SyntaxElement::Token(t) if is_collapsible_trivia(t.kind()) => i += 1,
255            SyntaxElement::Token(t)
256                if matches!(
257                    t.kind(),
258                    SyntaxKind::COMMENT | SyntaxKind::GUARD | SyntaxKind::DOC_MARGIN
259                ) =>
260            {
261                if followed_by_newline(elements, i) {
262                    flags[i].insert(ElementFlags::BOUNDARY_AFTER);
263                }
264                i += 1;
265            }
266            SyntaxElement::Node(n) if n.kind() == SyntaxKind::COMMAND => {
267                match expl3_unit(elements, i) {
268                    Some(unit) => {
269                        let end = unit.last;
270                        let full = absorb_trailing_junk(elements, end);
271                        if full > end {
272                            for flags in &mut flags[i..=full] {
273                                flags.insert(ElementFlags::GLUED);
274                            }
275                        }
276                        flags[full].insert(ElementFlags::BOUNDARY_AFTER);
277                        i = full + 1;
278                    }
279                    None => i = fallback_line(elements, i, &mut flags),
280                }
281            }
282            _ => i = fallback_line(elements, i, &mut flags),
283        }
284    }
285    StatementMap { flags }
286}
287
288fn node_is_expl_toggle(node: &SyntaxNode) -> bool {
289    node.children_with_tokens()
290        .filter_map(|el| el.into_token())
291        .find(|t| t.kind() == SyntaxKind::CONTROL_WORD)
292        .is_some_and(|t| expl_toggle(t.text()).is_some())
293}
294
295fn is_recognized_head(node: &SyntaxNode) -> bool {
296    node.kind() == SyntaxKind::COMMAND
297        && (node_is_expl_toggle(node)
298            || command_name(node).is_some_and(|name| expl3_slots(&name).is_some()))
299}
300
301fn followed_by_newline(elements: &[SyntaxElement], idx: usize) -> bool {
302    for element in &elements[idx + 1..] {
303        match element {
304            SyntaxElement::Token(t) if t.kind() == SyntaxKind::WHITESPACE => {}
305            SyntaxElement::Token(t) if t.kind() == SyntaxKind::NEWLINE => return true,
306            _ => return false,
307        }
308    }
309    true
310}
311
312fn fallback_line(elements: &[SyntaxElement], start: usize, flags: &mut [ElementFlags]) -> usize {
313    let mut last = start;
314    let mut j = start;
315    while j < elements.len() {
316        match &elements[j] {
317            SyntaxElement::Token(t) if is_collapsible_trivia(t.kind()) => {
318                if t.kind() == SyntaxKind::NEWLINE {
319                    flags[last].insert(ElementFlags::BOUNDARY_AFTER);
320                    for flags in &mut flags[start..=last] {
321                        flags.insert(ElementFlags::FALLBACK);
322                    }
323                    return j;
324                }
325                j += 1;
326            }
327            element => {
328                if j > start
329                    && let SyntaxElement::Node(n) = element
330                    && is_recognized_head(n)
331                {
332                    flags[j].insert(ElementFlags::GLUE_BEFORE);
333                }
334                last = j;
335                j += 1;
336                if let SyntaxElement::Node(n) = element
337                    && n.kind() == SyntaxKind::COMMAND
338                    && node_carries_bare_line_break(n)
339                {
340                    flags[last].insert(ElementFlags::BOUNDARY_AFTER);
341                    for flags in &mut flags[start..=last] {
342                        flags.insert(ElementFlags::FALLBACK);
343                    }
344                    return j;
345                }
346            }
347        }
348    }
349    flags[last].insert(ElementFlags::BOUNDARY_AFTER);
350    for flags in &mut flags[start..=last] {
351        flags.insert(ElementFlags::FALLBACK);
352    }
353    elements.len()
354}
355
356fn node_carries_bare_line_break(node: &SyntaxNode) -> bool {
357    let mut after_newline = false;
358    for child in node.children_with_tokens() {
359        match &child {
360            SyntaxElement::Token(t) if t.kind() == SyntaxKind::NEWLINE => after_newline = true,
361            SyntaxElement::Token(t) if is_collapsible_trivia(t.kind()) => {}
362            SyntaxElement::Node(n)
363                if matches!(n.kind(), SyntaxKind::GROUP | SyntaxKind::OPTIONAL) =>
364            {
365                after_newline = false;
366            }
367            _ => {
368                if after_newline {
369                    return true;
370                }
371            }
372        }
373    }
374    false
375}
376
377fn absorb_trailing_junk(elements: &[SyntaxElement], end: usize) -> usize {
378    let mut end = end;
379    let mut j = end + 1;
380    while j < elements.len() {
381        match &elements[j] {
382            SyntaxElement::Token(t) if is_collapsible_trivia(t.kind()) => {
383                if t.kind() == SyntaxKind::NEWLINE {
384                    break;
385                }
386                j += 1;
387            }
388            SyntaxElement::Token(t) if t.kind() == SyntaxKind::COMMENT => {
389                end = j;
390                break;
391            }
392            SyntaxElement::Token(t)
393                if matches!(t.kind(), SyntaxKind::GUARD | SyntaxKind::DOC_MARGIN) =>
394            {
395                break;
396            }
397            SyntaxElement::Node(n) if n.kind() == SyntaxKind::GROUP => break,
398            SyntaxElement::Node(n) if is_recognized_head(n) => {
399                break;
400            }
401            _ => {
402                end = j;
403                j += 1;
404            }
405        }
406    }
407    end
408}
409
410enum Stop {
411    End,
412    Abort,
413}
414
415fn consume_unit(
416    elements: &[SyntaxElement],
417    head_idx: usize,
418    slots: &[Expl3Slot],
419) -> Option<Expl3Unit> {
420    let head = elements[head_idx].as_node()?;
421    let mut cur = UnitCursor::new(elements, head_idx, head);
422    let mut branches = Vec::new();
423    let mut complete = true;
424    for slot in slots {
425        let took = match slot {
426            Expl3Slot::SingleToken => cur.take_single_token(),
427            Expl3Slot::Group => cur.take_group().map(|_| ()),
428            Expl3Slot::Branch => cur.take_group().map(|el| branches.push(el.text_range())),
429            Expl3Slot::ParameterText => cur.take_parameter_text(),
430        };
431        match took {
432            Ok(()) => {}
433            Err(Stop::End) => {
434                complete = false;
435                break;
436            }
437            Err(Stop::Abort) => return None,
438        }
439    }
440    cur.extend_over_attachable_tail();
441    Some(Expl3Unit {
442        last: cur.last_sib,
443        branches: if complete { branches } else { Vec::new() },
444    })
445}
446
447#[derive(Debug, Clone, PartialEq, Eq)]
448/// The span and conditional branches of one parsed expl3 unit.
449pub struct Expl3Unit {
450    pub last: usize,
451    pub branches: Vec<TextRange>,
452}
453
454/// Resolves the expl3 unit beginning at `head_idx`.
455pub fn expl3_unit(elements: &[SyntaxElement], head_idx: usize) -> Option<Expl3Unit> {
456    let node = elements.get(head_idx)?.as_node()?;
457    if !is_recognized_head(node) {
458        return None;
459    }
460    let slots = if node_is_expl_toggle(node) {
461        Vec::new()
462    } else {
463        expl3_slots(&command_name(node)?)?
464    };
465    consume_unit(elements, head_idx, &slots)
466}
467
468struct UnitCursor<'a> {
469    elements: &'a [SyntaxElement],
470    queue: VecDeque<SyntaxElement>,
471    sib: usize,
472    last_sib: usize,
473    peeked: Option<(SyntaxElement, Option<usize>)>,
474    chain: bool,
475}
476
477impl<'a> UnitCursor<'a> {
478    fn new(elements: &'a [SyntaxElement], head_idx: usize, head: &SyntaxNode) -> Self {
479        let mut cur = UnitCursor {
480            elements,
481            queue: VecDeque::new(),
482            sib: head_idx + 1,
483            last_sib: head_idx,
484            peeked: None,
485            chain: true,
486        };
487        cur.queue_children_after_name(head, false);
488        cur
489    }
490
491    fn queue_children_after_name(&mut self, node: &SyntaxNode, front: bool) {
492        let mut seen_name = false;
493        let mut after: Vec<SyntaxElement> = Vec::new();
494        for child in node.children_with_tokens() {
495            if seen_name {
496                after.push(child);
497            } else if matches!(
498                child.kind(),
499                SyntaxKind::CONTROL_WORD | SyntaxKind::CONTROL_SYMBOL
500            ) {
501                seen_name = true;
502            }
503        }
504        if front {
505            for el in after.into_iter().rev() {
506                self.queue.push_front(el);
507            }
508        } else {
509            self.queue.extend(after);
510        }
511    }
512
513    fn peek(&mut self) -> Result<&SyntaxElement, Stop> {
514        if self.peeked.is_none() {
515            self.peeked = Some(self.advance()?);
516        }
517        Ok(&self.peeked.as_ref().expect("just filled").0)
518    }
519
520    fn bump(&mut self) -> Result<SyntaxElement, Stop> {
521        let (el, sib_idx) = match self.peeked.take() {
522            Some(peeked) => peeked,
523            None => self.advance()?,
524        };
525        if let Some(idx) = sib_idx {
526            self.last_sib = idx;
527        }
528        Ok(el)
529    }
530
531    fn advance(&mut self) -> Result<(SyntaxElement, Option<usize>), Stop> {
532        let mut gap_newlines = 0usize;
533        loop {
534            let (el, sib_idx) = if let Some(el) = self.queue.pop_front() {
535                (el, None)
536            } else {
537                let Some(el) = self.elements.get(self.sib) else {
538                    return Err(Stop::Abort);
539                };
540                if let SyntaxElement::Token(t) = el
541                    && t.kind() == SyntaxKind::NEWLINE
542                    && gap_newlines >= 1
543                {
544                    return Err(Stop::End);
545                }
546                let idx = self.sib;
547                self.sib += 1;
548                (el.clone(), Some(idx))
549            };
550            match &el {
551                SyntaxElement::Token(t) if is_collapsible_trivia(t.kind()) => {
552                    if t.kind() == SyntaxKind::NEWLINE {
553                        gap_newlines += 1;
554                        if gap_newlines >= 2 {
555                            return Err(Stop::End);
556                        }
557                    }
558                }
559                SyntaxElement::Token(t) if t.kind() == SyntaxKind::COMMENT => {}
560                SyntaxElement::Token(t) if t.kind() == SyntaxKind::TILDE => {}
561                SyntaxElement::Token(t)
562                    if matches!(t.kind(), SyntaxKind::GUARD | SyntaxKind::DOC_MARGIN) =>
563                {
564                    return Err(Stop::Abort);
565                }
566                _ => return Ok((el, sib_idx)),
567            }
568        }
569    }
570
571    fn take_single_token(&mut self) -> Result<(), Stop> {
572        let el = self.bump()?;
573        match &el {
574            SyntaxElement::Token(t)
575                if matches!(
576                    t.kind(),
577                    SyntaxKind::CONTROL_WORD | SyntaxKind::CONTROL_SYMBOL
578                ) =>
579            {
580                self.chain = false;
581                Ok(())
582            }
583            SyntaxElement::Token(t)
584                if t.kind() == SyntaxKind::WORD && t.text().chars().count() == 1 =>
585            {
586                self.chain = false;
587                Ok(())
588            }
589            SyntaxElement::Token(t) if t.kind() == SyntaxKind::HASH => {
590                self.chain = false;
591                loop {
592                    let next = self.bump()?;
593                    match &next {
594                        SyntaxElement::Token(t) if t.kind() == SyntaxKind::HASH => {}
595                        SyntaxElement::Token(t)
596                            if t.kind() == SyntaxKind::WORD && is_param_digit(t) =>
597                        {
598                            return Ok(());
599                        }
600                        _ => return Err(Stop::Abort),
601                    }
602                }
603            }
604            SyntaxElement::Node(n) if n.kind() == SyntaxKind::COMMAND => {
605                self.queue_children_after_name(n, true);
606                self.chain = true;
607                Ok(())
608            }
609            SyntaxElement::Node(n) if n.kind() == SyntaxKind::GROUP => Ok(()),
610            _ => Err(Stop::Abort),
611        }
612    }
613
614    fn take_group(&mut self) -> Result<SyntaxElement, Stop> {
615        let el = self.bump()?;
616        match &el {
617            SyntaxElement::Node(n) if n.kind() == SyntaxKind::GROUP => Ok(el),
618            _ => Err(Stop::Abort),
619        }
620    }
621
622    fn take_parameter_text(&mut self) -> Result<(), Stop> {
623        loop {
624            if let SyntaxElement::Node(n) = self.peek()?
625                && n.kind() == SyntaxKind::GROUP
626            {
627                return Ok(());
628            }
629            let el = self.bump()?;
630            match &el {
631                SyntaxElement::Token(_) => self.chain = false,
632                SyntaxElement::Node(n) if n.kind() == SyntaxKind::COMMAND => {
633                    self.queue_children_after_name(n, true);
634                    self.chain = true;
635                }
636                SyntaxElement::Node(n) if n.kind() == SyntaxKind::OPTIONAL => {}
637                _ => return Err(Stop::Abort),
638            }
639        }
640    }
641
642    fn extend_over_attachable_tail(&mut self) {
643        if let Some((el, sib_idx)) = self.peeked.take()
644            && sib_idx.is_none()
645        {
646            self.update_chain(&el);
647        }
648        while let Some(el) = self.queue.pop_front() {
649            self.update_chain(&el);
650        }
651        if !self.chain {
652            return;
653        }
654        let mut newlines = 0usize;
655        let mut i = self.last_sib + 1;
656        while let Some(el) = self.elements.get(i) {
657            match el {
658                SyntaxElement::Token(t) => match t.kind() {
659                    SyntaxKind::NEWLINE => {
660                        newlines += 1;
661                        if newlines >= 2 {
662                            return;
663                        }
664                    }
665                    SyntaxKind::COMMENT => newlines = 0,
666                    SyntaxKind::WHITESPACE | SyntaxKind::GUARD | SyntaxKind::DOC_MARGIN => {}
667                    _ => return,
668                },
669                SyntaxElement::Node(n)
670                    if matches!(n.kind(), SyntaxKind::GROUP | SyntaxKind::OPTIONAL) =>
671                {
672                    self.last_sib = i;
673                    newlines = 0;
674                }
675                SyntaxElement::Node(_) => return,
676            }
677            i += 1;
678        }
679    }
680
681    fn update_chain(&mut self, el: &SyntaxElement) {
682        match el {
683            SyntaxElement::Node(n) if n.kind() == SyntaxKind::COMMAND => self.chain = true,
684            SyntaxElement::Node(n)
685                if matches!(n.kind(), SyntaxKind::GROUP | SyntaxKind::OPTIONAL) => {}
686            SyntaxElement::Token(t)
687                if is_collapsible_trivia(t.kind())
688                    || matches!(
689                        t.kind(),
690                        SyntaxKind::COMMENT
691                            | SyntaxKind::TILDE
692                            | SyntaxKind::GUARD
693                            | SyntaxKind::DOC_MARGIN
694                    ) => {}
695            _ => self.chain = false,
696        }
697    }
698}
699
700#[cfg(test)]
701mod segmentation_tests {
702    use super::*;
703    use crate::parser::parse;
704    use crate::syntax::SyntaxNode;
705
706    fn statements(src: &str) -> Vec<String> {
707        let parsed = parse(src);
708        assert!(parsed.errors.is_empty(), "test source should parse cleanly");
709        let root = SyntaxNode::new_root(parsed.green);
710        let para = root
711            .children()
712            .find(|n| n.kind() == SyntaxKind::PARAGRAPH)
713            .expect("a paragraph");
714        let elements: Vec<SyntaxElement> = para.children_with_tokens().collect();
715        statement_texts(&elements)
716    }
717
718    fn statement_texts(elements: &[SyntaxElement]) -> Vec<String> {
719        let map = segment_expl_statements(elements);
720        let mut out = Vec::new();
721        let mut cur = String::new();
722        for (i, el) in elements.iter().enumerate() {
723            cur.push_str(&el.to_string());
724            if map.boundary_after(i) {
725                let text = normalize(&cur);
726                if !text.is_empty() {
727                    out.push(text);
728                }
729                cur.clear();
730            }
731        }
732        let tail = normalize(&cur);
733        if !tail.is_empty() {
734            out.push(tail);
735        }
736        out
737    }
738
739    fn normalize(s: &str) -> String {
740        s.split_whitespace().collect::<Vec<_>>().join(" ")
741    }
742
743    #[test]
744    fn statements_are_structural_units() {
745        let got = statements(
746            "\\ExplSyntaxOn\n\\tl_set:Nn \\l_a\n  { x }\n\\group_begin:\n\\ExplSyntaxOff\n",
747        );
748        assert_eq!(
749            got,
750            vec![
751                "\\ExplSyntaxOn",
752                "\\tl_set:Nn \\l_a { x }",
753                "\\group_begin:",
754                "\\ExplSyntaxOff",
755            ]
756        );
757    }
758
759    #[test]
760    fn same_line_calls_split() {
761        let got =
762            statements("\\ExplSyntaxOn\n\\group_begin: \\int_zero:N \\l_a\n\\ExplSyntaxOff\n");
763        assert_eq!(
764            got,
765            vec![
766                "\\ExplSyntaxOn",
767                "\\group_begin:",
768                "\\int_zero:N \\l_a",
769                "\\ExplSyntaxOff",
770            ]
771        );
772    }
773
774    #[test]
775    fn npn_definition_is_one_unit() {
776        let got =
777            statements("\\ExplSyntaxOn\n\\cs_new:Npn \\foo:n #1\n  { body #1 }\n\\ExplSyntaxOff\n");
778        assert_eq!(
779            got,
780            vec![
781                "\\ExplSyntaxOn",
782                "\\cs_new:Npn \\foo:n #1 { body #1 }",
783                "\\ExplSyntaxOff",
784            ]
785        );
786    }
787
788    #[test]
789    fn peel_back_reclaims_over_attached_group() {
790        let got = statements("\\ExplSyntaxOn\n\\cs_new:Nn \\foo:n\n  { body }\n\\ExplSyntaxOff\n");
791        assert_eq!(
792            got,
793            vec![
794                "\\ExplSyntaxOn",
795                "\\cs_new:Nn \\foo:n { body }",
796                "\\ExplSyntaxOff",
797            ]
798        );
799    }
800
801    #[test]
802    fn exp_args_chain_is_one_unit() {
803        let got = statements(
804            "\\ExplSyntaxOn\n\\exp_args:NNo \\tl_set:Nn \\l_a { \\l_b }\n\\ExplSyntaxOff\n",
805        );
806        assert_eq!(
807            got,
808            vec![
809                "\\ExplSyntaxOn",
810                "\\exp_args:NNo \\tl_set:Nn \\l_a { \\l_b }",
811                "\\ExplSyntaxOff",
812            ]
813        );
814    }
815
816    #[test]
817    fn hash_parameter_satisfies_single_token_slot() {
818        let got = statements("\\ExplSyntaxOn\n\\tl_set:Nn #1 { x }\n\\ExplSyntaxOff\n");
819        assert_eq!(
820            got,
821            vec!["\\ExplSyntaxOn", "\\tl_set:Nn #1 { x }", "\\ExplSyntaxOff"]
822        );
823    }
824
825    #[test]
826    fn relation_character_satisfies_single_token_slot() {
827        let got = statements(
828            "\\ExplSyntaxOn\n\\int_compare:nNnTF { \\l_a } = { 1 } { yes } { no } \\foo:\n\\ExplSyntaxOff\n",
829        );
830        assert_eq!(
831            got,
832            vec![
833                "\\ExplSyntaxOn",
834                "\\int_compare:nNnTF { \\l_a } = { 1 } { yes } { no }",
835                "\\foo:",
836                "\\ExplSyntaxOff",
837            ]
838        );
839    }
840
841    #[test]
842    fn relation_character_unit_is_newline_invariant() {
843        let inline = statements(
844            "\\ExplSyntaxOn\n\\int_compare:nNnTF { \\l_a } = { 1 } { yes } { no } \\foo:\n\\ExplSyntaxOff\n",
845        );
846        let broken = statements(
847            "\\ExplSyntaxOn\n\\int_compare:nNnTF { \\l_a } = { 1 }\n  { yes } { no }\n\\foo:\n\\ExplSyntaxOff\n",
848        );
849        assert_eq!(inline, broken);
850    }
851
852    #[test]
853    fn multi_character_word_does_not_satisfy_single_token_slot() {
854        let got = statements(
855            "\\ExplSyntaxOn\n\\int_compare:nNnT { \\l_a } <= { 1 } { yes }\n\\foo:\n\\ExplSyntaxOff\n",
856        );
857        assert_eq!(
858            got,
859            vec![
860                "\\ExplSyntaxOn",
861                "\\int_compare:nNnT { \\l_a } <= { 1 } { yes }",
862                "\\foo:",
863                "\\ExplSyntaxOff",
864            ]
865        );
866    }
867
868    #[test]
869    fn delimited_parameter_text_peels_the_body() {
870        let got = statements(
871            "\\ExplSyntaxOn\n\\cs_new:Npn \\foo:w #1 \\q_stop { body }\n\\ExplSyntaxOff\n",
872        );
873        assert_eq!(
874            got,
875            vec![
876                "\\ExplSyntaxOn",
877                "\\cs_new:Npn \\foo:w #1 \\q_stop { body }",
878                "\\ExplSyntaxOff",
879            ]
880        );
881    }
882
883    #[test]
884    fn comment_in_a_consumed_slot_ends_the_fallback_line() {
885        let got = statements(
886            "\\ExplSyntaxOn\n\\exp_after:wN \\foo \\tl_set:Nn \\l_a\n% doc\n{ x } \\group_begin:\n\\ExplSyntaxOff\n",
887        );
888        assert_eq!(
889            got,
890            vec![
891                "\\ExplSyntaxOn",
892                "\\exp_after:wN \\foo \\tl_set:Nn \\l_a % doc { x }",
893                "\\group_begin:",
894                "\\ExplSyntaxOff",
895            ]
896        );
897    }
898
899    #[test]
900    fn unknown_head_falls_back_to_its_line() {
901        let got = statements(
902            "\\ExplSyntaxOn\n\\exp_after:wN \\foo \\tl_set:Nn \\l_a { x }\n\\group_begin:\n\\ExplSyntaxOff\n",
903        );
904        assert_eq!(
905            got,
906            vec![
907                "\\ExplSyntaxOn",
908                "\\exp_after:wN \\foo \\tl_set:Nn \\l_a { x }",
909                "\\group_begin:",
910                "\\ExplSyntaxOff",
911            ]
912        );
913    }
914
915    #[test]
916    fn shape_mismatch_falls_back() {
917        let got = statements("\\ExplSyntaxOn\n\\tl_set:Nn\n\\l_a\n\\ExplSyntaxOff\n");
918        assert_eq!(
919            got,
920            vec!["\\ExplSyntaxOn", "\\tl_set:Nn", "\\l_a", "\\ExplSyntaxOff"]
921        );
922    }
923
924    #[test]
925    fn trailing_comment_rides_the_statement() {
926        let got = statements("\\ExplSyntaxOn\n\\tl_set:Nn \\l_a { x } % note\n\\ExplSyntaxOff\n");
927        assert_eq!(
928            got,
929            vec![
930                "\\ExplSyntaxOn",
931                "\\tl_set:Nn \\l_a { x } % note",
932                "\\ExplSyntaxOff",
933            ]
934        );
935    }
936
937    #[test]
938    fn leftover_attached_group_rides_the_statement() {
939        let got = statements("\\ExplSyntaxOn\n\\use:n { a } { b }\n\\ExplSyntaxOff\n");
940        assert_eq!(
941            got,
942            vec!["\\ExplSyntaxOn", "\\use:n { a } { b }", "\\ExplSyntaxOff"]
943        );
944    }
945
946    #[test]
947    fn conditional_call_is_one_unit() {
948        let got = statements(
949            "\\ExplSyntaxOn\n\\str_if_eq:nnTF { a } { b }\n  { yes }\n  { no }\n\\ExplSyntaxOff\n",
950        );
951        assert_eq!(
952            got,
953            vec![
954                "\\ExplSyntaxOn",
955                "\\str_if_eq:nnTF { a } { b } { yes } { no }",
956                "\\ExplSyntaxOff",
957            ]
958        );
959    }
960
961    fn branch_texts(src: &str, head: usize) -> Option<Vec<String>> {
962        let parsed = parse(src);
963        assert!(parsed.errors.is_empty(), "test source should parse cleanly");
964        let root = SyntaxNode::new_root(parsed.green);
965        let para = root
966            .children()
967            .find(|n| n.kind() == SyntaxKind::PARAGRAPH)
968            .expect("a paragraph");
969        let elements: Vec<SyntaxElement> = para.children_with_tokens().collect();
970        let unit = expl3_unit(&elements, head)?;
971        Some(
972            unit.branches
973                .iter()
974                .map(|range| normalize(&root.text().slice(*range).to_string()))
975                .collect(),
976        )
977    }
978
979    fn head_of(src: &str, name: &str) -> usize {
980        let parsed = parse(src);
981        let root = SyntaxNode::new_root(parsed.green);
982        let para = root
983            .children()
984            .find(|n| n.kind() == SyntaxKind::PARAGRAPH)
985            .expect("a paragraph");
986        para.children_with_tokens()
987            .position(|el| {
988                el.as_node().is_some_and(|n| {
989                    n.kind() == SyntaxKind::COMMAND
990                        && command_name(n).is_some_and(|got| got == name)
991                })
992            })
993            .unwrap_or_else(|| panic!("no command named {name}"))
994    }
995
996    #[test]
997    fn branches_are_resolved_wherever_attachment_put_them() {
998        let head_attached = "\\ExplSyntaxOn\n\\tl_if_empty:nTF {#1} { T } { F }\n";
999        assert_eq!(
1000            branch_texts(head_attached, head_of(head_attached, "tl_if_empty:nTF")),
1001            Some(vec!["{ T }".to_string(), "{ F }".to_string()])
1002        );
1003
1004        let one_sibling = "\\ExplSyntaxOn\n\\seq_if_in:NnTF \\l_seq {item} { T } { F }\n";
1005        assert_eq!(
1006            branch_texts(one_sibling, head_of(one_sibling, "seq_if_in:NnTF")),
1007            Some(vec!["{ T }".to_string(), "{ F }".to_string()])
1008        );
1009
1010        let two_siblings = "\\ExplSyntaxOn\n\\prop_get:NnNTF \\p {k} \\l { T } { F }\n";
1011        assert_eq!(
1012            branch_texts(two_siblings, head_of(two_siblings, "prop_get:NnNTF")),
1013            Some(vec!["{ T }".to_string(), "{ F }".to_string()])
1014        );
1015
1016        let stream_level = "\\ExplSyntaxOn\n\\int_compare:nNnTF {a} = { 1 } { T } { F }\n";
1017        assert_eq!(
1018            branch_texts(stream_level, head_of(stream_level, "int_compare:nNnTF")),
1019            Some(vec!["{ T }".to_string(), "{ F }".to_string()])
1020        );
1021    }
1022
1023    #[test]
1024    fn a_non_conditional_unit_has_no_branches() {
1025        let src = "\\ExplSyntaxOn\n\\tl_set:Nn \\l_a { x }\n";
1026        assert_eq!(branch_texts(src, head_of(src, "tl_set:Nn")), Some(vec![]));
1027    }
1028
1029    #[test]
1030    fn an_underivable_head_resolves_no_unit() {
1031        let src = "\\ExplSyntaxOn\n\\odd_if:wTF \\a \\b { T } { F }\n";
1032        assert_eq!(branch_texts(src, head_of(src, "odd_if:wTF")), None);
1033    }
1034
1035    #[test]
1036    fn a_blank_line_cut_unit_reports_no_branches() {
1037        let src = "\\ExplSyntaxOn\n\\use:n { \\prop_get:NnNTF \\p {k} \\l { T }\n\n{ F } }\n";
1038        let parsed = parse(src);
1039        assert!(parsed.errors.is_empty());
1040        let root = SyntaxNode::new_root(parsed.green);
1041        let group = root
1042            .descendants()
1043            .find(|n| n.kind() == SyntaxKind::GROUP)
1044            .expect("a group");
1045        let body: Vec<SyntaxElement> = group
1046            .children_with_tokens()
1047            .filter(|el| !matches!(el.kind(), SyntaxKind::L_BRACE | SyntaxKind::R_BRACE))
1048            .collect();
1049        let head = body
1050            .iter()
1051            .position(|el| el.as_node().is_some())
1052            .expect("the head command");
1053        let unit = expl3_unit(&body, head).expect("the partial unit still resolves");
1054        assert_eq!(unit.branches, vec![]);
1055    }
1056
1057    #[test]
1058    fn blank_line_ends_the_unit() {
1059        let src = "\\ExplSyntaxOn\n\\use:n { \\tl_set:Nn \\l_a\n\n  { x } }\n\\ExplSyntaxOff\n";
1060        let parsed = parse(src);
1061        assert!(parsed.errors.is_empty());
1062        let root = SyntaxNode::new_root(parsed.green);
1063        let group = root
1064            .descendants()
1065            .find(|n| n.kind() == SyntaxKind::GROUP)
1066            .expect("a group");
1067        let body: Vec<SyntaxElement> = group
1068            .children_with_tokens()
1069            .filter(|el| !matches!(el.kind(), SyntaxKind::L_BRACE | SyntaxKind::R_BRACE))
1070            .collect();
1071        assert_eq!(statement_texts(&body), vec!["\\tl_set:Nn \\l_a", "{ x }"]);
1072    }
1073
1074    #[test]
1075    fn guard_mid_unit_aborts_to_fallback() {
1076        use crate::parser::lexer::LexConfig;
1077        use crate::parser::{LatexFlavor, parse_with_flavor};
1078        let src = "% \\begin{macrocode}\n\\ExplSyntaxOn\n\\tl_set:Nn \\l_a\n%<latexrelease>  { x }\n\\ExplSyntaxOff\n% \\end{macrocode}\n";
1079        let config = LexConfig {
1080            flavor: LatexFlavor::Package,
1081            dtx: true,
1082        };
1083        let parsed = parse_with_flavor(src, config);
1084        assert!(parsed.errors.is_empty(), "test source should parse cleanly");
1085        let root = SyntaxNode::new_root(parsed.green);
1086        let para = root
1087            .descendants()
1088            .find(|n| n.kind() == SyntaxKind::PARAGRAPH)
1089            .expect("a paragraph");
1090        let elements: Vec<SyntaxElement> = para.children_with_tokens().collect();
1091        let map = segment_expl_statements(&elements);
1092        assert_eq!(
1093            statement_texts(&elements),
1094            vec![
1095                "\\ExplSyntaxOn",
1096                "\\tl_set:Nn \\l_a %<latexrelease> { x }",
1097                "\\ExplSyntaxOff",
1098            ]
1099        );
1100        let guarded_end = elements
1101            .iter()
1102            .position(|el| el.to_string().contains("latexrelease"))
1103            .expect("the guarded sibling");
1104        assert!(
1105            map.is_fallback(guarded_end),
1106            "the aborted unit must be a fallback statement"
1107        );
1108    }
1109
1110    #[test]
1111    fn e_and_f_letters_consume_braced_groups() {
1112        let got = statements(
1113            "\\ExplSyntaxOn\n\\tl_set:Ne \\l_a\n  { x }\n\\tl_set:Nf \\l_b\n  { y }\n\\ExplSyntaxOff\n",
1114        );
1115        assert_eq!(
1116            got,
1117            vec![
1118                "\\ExplSyntaxOn",
1119                "\\tl_set:Ne \\l_a { x }",
1120                "\\tl_set:Nf \\l_b { y }",
1121                "\\ExplSyntaxOff",
1122            ]
1123        );
1124    }
1125
1126    #[test]
1127    fn stream_ending_mid_unit_falls_back() {
1128        let src = "\\ExplSyntaxOn\n\\use:n { \\tl_set:Nn \\l_a }\n\\ExplSyntaxOff\n";
1129        let parsed = parse(src);
1130        assert!(parsed.errors.is_empty());
1131        let root = SyntaxNode::new_root(parsed.green);
1132        let group = root
1133            .descendants()
1134            .find(|n| n.kind() == SyntaxKind::GROUP)
1135            .expect("a group");
1136        let body: Vec<SyntaxElement> = group
1137            .children_with_tokens()
1138            .filter(|el| !matches!(el.kind(), SyntaxKind::L_BRACE | SyntaxKind::R_BRACE))
1139            .collect();
1140        let map = segment_expl_statements(&body);
1141        assert_eq!(statement_texts(&body), vec!["\\tl_set:Nn \\l_a"]);
1142        let head = body
1143            .iter()
1144            .position(|el| el.as_node().is_some())
1145            .expect("the head command");
1146        assert!(
1147            map.is_fallback(head),
1148            "a unit cut off by the stream end must be a fallback statement"
1149        );
1150    }
1151
1152    #[test]
1153    fn a_multi_line_group_node_does_not_end_a_fallback_line() {
1154        let src = "\\ExplSyntaxOn\n\
1155                   \\int_do_until:w { \\l_tmpa_int } > {#2}\n\
1156                   { \\lipsum_add:V { \\l_tmpa_int }\n\
1157                   \\int_incr:N \\l_tmpa_int } \\tl_put_right:NV \\l_a \\l_b\n\
1158                   \\ExplSyntaxOff\n";
1159        let parsed = parse(src);
1160        assert!(parsed.errors.is_empty());
1161        let root = SyntaxNode::new_root(parsed.green);
1162        let elements: Vec<SyntaxElement> = root
1163            .first_child()
1164            .expect("the paragraph")
1165            .children_with_tokens()
1166            .collect();
1167        let map = segment_expl_statements(&elements);
1168
1169        assert_eq!(
1170            statement_texts(&elements),
1171            vec![
1172                "\\ExplSyntaxOn",
1173                "\\int_do_until:w { \\l_tmpa_int } > {#2}",
1174                "{ \\lipsum_add:V { \\l_tmpa_int } \\int_incr:N \\l_tmpa_int } \
1175                 \\tl_put_right:NV \\l_a \\l_b",
1176                "\\ExplSyntaxOff",
1177            ]
1178        );
1179
1180        let group = elements
1181            .iter()
1182            .position(|el| el.kind() == SyntaxKind::GROUP && el.to_string().contains('\n'))
1183            .expect("the multi-line group");
1184        assert!(
1185            map.is_fallback(group),
1186            "the group belongs to a fallback statement"
1187        );
1188        assert!(
1189            !map.boundary_after(group),
1190            "a multi-line group's own newlines must not end the fallback line"
1191        );
1192
1193        let head = elements
1194            .iter()
1195            .skip(group)
1196            .position(|el| {
1197                el.as_node()
1198                    .is_some_and(|n| n.kind() == SyntaxKind::COMMAND)
1199            })
1200            .map(|off| group + off)
1201            .expect("the trailing recognized head");
1202        assert!(
1203            map.glue_before(head),
1204            "a recognized head mid-fallback-line owes an unbreakable gap"
1205        );
1206    }
1207
1208    #[test]
1209    fn own_line_comment_in_attached_span_rides_the_sibling() {
1210        let got =
1211            statements("\\ExplSyntaxOn\n\\tl_set:Nn \\l_a\n% note\n  { x }\n\\ExplSyntaxOff\n");
1212        assert_eq!(
1213            got,
1214            vec![
1215                "\\ExplSyntaxOn",
1216                "\\tl_set:Nn \\l_a % note { x }",
1217                "\\ExplSyntaxOff",
1218            ]
1219        );
1220    }
1221
1222    #[test]
1223    fn own_line_comment_at_sibling_level_ends_the_unit() {
1224        let got = statements(
1225            "\\ExplSyntaxOn\n\\cs_new:Npn \\foo:n\n% note\n#1 { body }\n\\ExplSyntaxOff\n",
1226        );
1227        assert_eq!(
1228            got,
1229            vec![
1230                "\\ExplSyntaxOn",
1231                "\\cs_new:Npn \\foo:n",
1232                "% note",
1233                "#1 { body }",
1234                "\\ExplSyntaxOff",
1235            ]
1236        );
1237    }
1238}