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 a spec mini-language that is
14//! *parsed*, never executed (AGENTS.md decision #1): each letter names the
15//! **shape** an argument takes at the call site, a bounded, purely lexical
16//! fact — squarely decision #2's "the semantic layer assigns arity". No
17//! signature database is involved: the name string alone carries the spec, so
18//! there is nothing to curate and nothing to drift. Only meaningful inside an
19//! expl3 region, where `:`/`_` are catcode-11 and the whole name lexes as one
20//! `CONTROL_WORD` — callers of the segmentation guarantee the stream is
21//! in-region (out-of-region, colon names lex split and everything degrades to
22//! the fallback).
23//!
24//! The letter-by-letter model (interface3's argument specifiers):
25//!
26//! - `N`, `V` → [`Expl3Slot::SingleToken`]: one token, typically a control
27//!   sequence (`V` differs from `N` only in *expansion*, not call-site shape).
28//! - `n`, `c`, `v`, `o`, `x`, `e`, `f` → [`Expl3Slot::Group`]: one braced
29//!   `{…}` group (again, the letters differ only in how the material is
30//!   processed, which we never model).
31//! - `T`, `F` → [`Expl3Slot::Branch`]: a braced conditional branch. Sanctioned
32//!   only as a *trailing* run — in a standard argspec `T`/`F` are always last,
33//!   so a mid-spec `T`/`F` is treated as unknown.
34//! - `p` → [`Expl3Slot::ParameterText`]: TeX parameter text (`#1#2…`), which
35//!   has no fixed token count but a static *end*: TeX's own rule that the
36//!   parameter text runs to the first explicit `{`. The consumer scans by that
37//!   shape.
38//! - `w` (arbitrary delimiters) and `D` (kernel primitive) have no lexically
39//!   derivable call-site shape → the whole name is unrecognized (`None`), as is
40//!   any unknown letter (including one added to expl3 after this list was
41//!   written — new letters degrade to unrecognized, never to a wrong arity).
42
43use std::collections::VecDeque;
44
45use rowan::TextRange;
46
47use crate::ast::command_name;
48use crate::parser::lexer::expl_toggle;
49use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode, is_collapsible_trivia, is_param_digit};
50
51/// The call-site shape of one expl3 argument slot, derived from an argspec letter.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Expl3Slot {
54    /// `N`, `V`: exactly one token, typically a control sequence.
55    SingleToken,
56    /// `n`, `c`, `v`, `o`, `x`, `e`, `f`: one braced `{…}` group.
57    Group,
58    /// `T`, `F`: a braced conditional branch (a [`Group`](Expl3Slot::Group) a
59    /// consumer may lay out specially).
60    Branch,
61    /// `p`: TeX parameter text — the tokens up to (not including) the next
62    /// explicit `{`.
63    ParameterText,
64}
65
66/// The argument slots of an expl3 function name, read from its argspec suffix
67/// (the substring after the *final* `:`), or `None` when the name has no
68/// derivable call-site arity.
69///
70/// `Some` iff the name contains a `:` and every suffix letter is a fixed-shape
71/// letter per the module docs; an empty suffix (`\scan_stop:`, `\group_end:`)
72/// is `Some(vec![])` — a recognized zero-argument call. `None` for a colonless
73/// name (`\def`, `\@ifpackageloaded`), or a spec containing `w`, `D`, a
74/// mid-spec `T`/`F`, or any unknown letter.
75pub fn expl3_slots(name: &str) -> Option<Vec<Expl3Slot>> {
76    let argspec = name.rsplit_once(':')?.1;
77    let chars: Vec<char> = argspec.chars().collect();
78    let branches = chars
79        .iter()
80        .rev()
81        .take_while(|c| matches!(c, 'T' | 'F'))
82        .count();
83    let mut slots = Vec::with_capacity(chars.len());
84    for c in &chars[..chars.len() - branches] {
85        // `T`/`F` never match here, so a *mid*-spec `T`/`F` (nonstandard) falls
86        // through to unknown.
87        slots.push(match c {
88            'N' | 'V' => Expl3Slot::SingleToken,
89            'n' | 'c' | 'v' | 'o' | 'x' | 'e' | 'f' => Expl3Slot::Group,
90            'p' => Expl3Slot::ParameterText,
91            _ => return None,
92        });
93    }
94    slots.extend(std::iter::repeat_n(Expl3Slot::Branch, branches));
95    Some(slots)
96}
97
98/// The number of trailing `T`/`F` branch arguments of an expl3 conditional, read
99/// from the command *name*'s argspec (the substring after the final `:`).
100/// `\tl_if_empty:nTF` → `Some(2)`, `\bool_if:nT`/`:nF` → `Some(1)`; `None` for any
101/// name without a `:`-argspec ending in `T`/`F` — a non-conditional expl3 function
102/// (`\seq_new:N`), or a LaTeX2e command with no colon (`\@ifpackageloaded`). In an
103/// expl3 argspec `T`/`F` denote *only* the true/false branch slots, so a trailing
104/// `T`/`F` run is exactly the branch count.
105///
106/// Deliberately **not** derived from [`expl3_slots`]: this counts the raw
107/// trailing run, so a name whose *earlier* letters make the arity unrecognized
108/// (a hypothetical `:wTF` shape) still reports its branches — the conditional
109/// layout keys on the branches alone and must not regress when the full arity
110/// model bows out.
111pub fn conditional_branches(name: &str) -> Option<usize> {
112    let argspec = name.rsplit_once(':')?.1;
113    let n = argspec
114        .chars()
115        .rev()
116        .take_while(|c| *c == 'T' || *c == 'F')
117        .count();
118    (n > 0).then_some(n)
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use Expl3Slot::*;
125
126    #[test]
127    fn slots_read_from_name_suffix() {
128        assert_eq!(
129            expl3_slots("cs_new:Npn"),
130            Some(vec![SingleToken, ParameterText, Group])
131        );
132        assert_eq!(
133            expl3_slots("str_if_eq:nnTF"),
134            Some(vec![Group, Group, Branch, Branch])
135        );
136        assert_eq!(
137            expl3_slots("prop_get:NnNTF"),
138            Some(vec![SingleToken, Group, SingleToken, Branch, Branch])
139        );
140        assert_eq!(expl3_slots("tl_set:Nn"), Some(vec![SingleToken, Group]));
141        assert_eq!(
142            expl3_slots("exp_args:NNo"),
143            Some(vec![SingleToken, SingleToken, Group])
144        );
145        assert_eq!(expl3_slots("tl_set:Nv"), Some(vec![SingleToken, Group]));
146        assert_eq!(expl3_slots("use:c"), Some(vec![Group]));
147        assert_eq!(expl3_slots("tl_set:Nx"), Some(vec![SingleToken, Group]));
148    }
149
150    #[test]
151    fn zero_argument_names_are_recognized() {
152        assert_eq!(expl3_slots("scan_stop:"), Some(vec![]));
153        assert_eq!(expl3_slots("group_begin:"), Some(vec![]));
154        assert_eq!(expl3_slots("prg_return_true:"), Some(vec![]));
155    }
156
157    #[test]
158    fn underivable_specs_are_unrecognized() {
159        // `w`: arbitrary delimiters; `D`: kernel primitive of arbitrary arity.
160        assert_eq!(expl3_slots("use_none_delimit_by_q_stop:w"), None);
161        assert_eq!(expl3_slots("exp_after:wN"), None);
162        assert_eq!(expl3_slots("tex_relax:D"), None);
163        // Mid-spec `T`/`F` is nonstandard, so unknown.
164        assert_eq!(expl3_slots("odd:TnF"), None);
165        // Unknown letter anywhere bows out entirely — never a partial arity.
166        assert_eq!(expl3_slots("odd:nZn"), None);
167    }
168
169    #[test]
170    fn colonless_names_are_unrecognized() {
171        assert_eq!(expl3_slots("def"), None);
172        assert_eq!(expl3_slots("@ifpackageloaded"), None);
173        assert_eq!(expl3_slots("IfBooleanTF"), None);
174        assert_eq!(expl3_slots("l_tmpa_tl"), None);
175    }
176
177    #[test]
178    fn exp_internal_drivers() {
179        // The `\::n` expansion drivers: name is empty, spec is real. Their
180        // runtime protocol is nothing like a call site, but the greedy shape
181        // rules in the consumer keep them on the fallback path anyway; the
182        // lexical read here is just the suffix.
183        assert_eq!(expl3_slots("::n"), Some(vec![Group]));
184        assert_eq!(expl3_slots(":::"), Some(vec![]));
185    }
186
187    #[test]
188    fn conditional_branches_read_from_name_suffix() {
189        // Trailing `T`/`F` run in the argspec (after the final `:`) is the branch
190        // count; non-conditionals and colonless 2e names are `None`.
191        assert_eq!(conditional_branches("tl_if_empty:nTF"), Some(2));
192        assert_eq!(conditional_branches("bool_if:nT"), Some(1));
193        assert_eq!(conditional_branches("bool_if:nF"), Some(1));
194        assert_eq!(conditional_branches("str_if_eq:nnTF"), Some(2));
195        assert_eq!(conditional_branches("int_compare:nNnTF"), Some(2));
196        assert_eq!(conditional_branches("seq_map_inline:Nn"), None);
197        assert_eq!(conditional_branches("prg_return_true:"), None);
198        assert_eq!(conditional_branches("tl_new:N"), None);
199        // A LaTeX2e conditional has no `:`-argspec, so it is never matched (issue
200        // #94's `\@ifpackageloaded` stays on the width path).
201        assert_eq!(conditional_branches("@ifpackageloaded"), None);
202        assert_eq!(conditional_branches("IfBooleanTF"), None);
203    }
204
205    #[test]
206    fn branches_survive_underivable_arity() {
207        // The documented asymmetry: arity bows out, branch count must not.
208        assert_eq!(expl3_slots("odd_if:wTF"), None);
209        assert_eq!(conditional_branches("odd_if:wTF"), Some(2));
210    }
211}
212
213// --- Statement segmentation -------------------------------------------------
214//
215// Structural statement segmentation for expl3 code — the mechanism that
216// retired the newline-keyed `Statements::SplitAtNewlines` boundary.
217//
218// [`segment_expl_statements`] walks a stream of in-region sibling elements (a
219// paragraph run or a brace-group body) and decides, for every gap between
220// elements, whether a statement boundary sits there. The layout loop
221// (`lower_expl_code`) then commits logical lines where the map says, instead
222// of where the *author's* newlines fell — retiring the unsafe
223// newline-vs-space trivia read (the root of the K&R↔Allman idempotency
224// family; see `formatter.md`, § Trivia-invariant layout).
225//
226// A statement is a **call unit**: a head `COMMAND` whose name has a derivable
227// argspec arity ([`expl3_slots`]) plus the elements its slots consume.
228// Consumption is a pure shape scan — no `Ir` is built here — over two sources
229// in order: the head's own greedily-attached children (the parser attaches
230// every trailing `{…}` regardless of arity, decision #8), then the following
231// siblings. Greedy attachment routinely gives an argument to the *wrong
232// owner* (`\cs_new:Nn \foo:n {body}` attaches `{body}` to `\foo:n`); when a
233// `COMMAND` node satisfies a single-token slot, its own attached children are
234// *peeled* back onto the front of the scan queue so they can satisfy the
235// outer head's remaining slots. Only the head's argspec ever drives
236// consumption — an argument's own argspec is inert data, exactly as TeX
237// grabs it.
238//
239// The trivia the scan may read is confined to *preserved* predicates:
240// - a **blank line** (a gap of two or more newlines) ends the unit where it
241//   stands — the partial unit commits as-is, pass-stably, because blank-line
242//   presence is preserved by the formatter;
243// - a **comment** sharing a line with consumed material is transparent to
244//   consumption (the layout loop makes it end its physical line; the unit
245//   continues), while an **own-line** comment mid-unit ends the unit where it
246//   stands exactly like a blank line — its flanking newlines bound the gap
247//   (`advance` counts them across the skipped comment), so the partial unit
248//   commits pass-stably. When the comment rides *inside* a greedily-attached
249//   sibling, the committed unit still carries that sibling whole (boundaries
250//   never split a node), so the call's text stays together anyway. A comment
251//   trailing a *complete* unit is pulled into the statement so it stays on
252//   the call's line. Comment presence and own-line-ness are preserved
253//   predicates;
254// - a lone-newline-vs-space gap is **never** read on the structural path.
255//
256// Anything the shape scan cannot resolve — an unrecognized head (no `:`
257// suffix, or a `w`/`D`/unknown letter), a slot facing the wrong shape, a
258// docstrip `GUARD` mid-unit (guarded alternative bodies make arity lie,
259// issue #78), or the stream ending mid-unit — degrades that statement to the
260// **fallback**: the authored physical line is the statement, exactly the old
261// `SplitAtNewlines` behavior demoted to a per-line escape hatch (Tier 2; see
262// `formatter.md`, § Trivia-invariant layout). Recognition is re-attempted at every
263// statement start, so recognized and fallback statements interleave
264// deterministically; a recognized head *mid*-fallback-line is never split
265// out.
266
267/// The statement-boundary map for one element stream: `boundary_after(i)` says
268/// a statement ends in the gap after element `i`. Boundaries sit on whole
269/// top-level siblings — a boundary never splits a CST node, so anything the
270/// greedy parser over-attached to a consumed sibling rides along in its
271/// statement.
272pub struct StatementMap {
273    boundary_after: Vec<bool>,
274    glue_before: Vec<bool>,
275    glued: Vec<bool>,
276    fallback: Vec<bool>,
277}
278
279impl StatementMap {
280    /// Whether a statement boundary sits in the gap after element `idx`.
281    pub fn boundary_after(&self, idx: usize) -> bool {
282        self.boundary_after.get(idx).copied().unwrap_or(false)
283    }
284
285    /// Whether the gap *before* element `idx` must render unbreakable. Set for
286    /// a recognized-head `COMMAND` sitting mid-way through a fallback
287    /// statement: a width wrap at that gap would start a printed line with the
288    /// recognized head, which the next pass segments as its own statement
289    /// mid-way through this one and the passes disagree (`l3fp-trig.dtx`'s
290    /// `\@@_sep:`-delimited protocols, `xo-or.dtx`'s `=~ \exp_not:c {…}\space`
291    /// trace lines). Every other fallback gap stays breakable: a printed
292    /// continuation line starting with anything unrecognized re-segments to
293    /// exactly that line and renders to itself, the fallback's fixed point.
294    pub fn glue_before(&self, idx: usize) -> bool {
295        self.glue_before.get(idx).copied().unwrap_or(false)
296    }
297
298    /// Whether element `idx` belongs to a recognized statement that absorbed
299    /// trailing same-line material ([`absorb_trailing_junk`]) — a call unit
300    /// followed by unrecognized tokens or a comment on its authored line
301    /// (xparse's `\bool_if:NTF … { \cs_set:cpn } … ##1 \q_@@ …` definition
302    /// trickery). Such a statement renders with every top-level gap
303    /// unbreakable: its junk extent is newline-keyed (the fallback's Tier-2
304    /// residue), so a width wrap moving material across a line boundary would
305    /// change the extent — and with it the trailing-command glue decision —
306    /// on the next pass. All-hard gaps preserve the authored line shape
307    /// (node-internal layout still breaks freely and re-reads node-internal),
308    /// which is a fixed point by construction.
309    pub fn is_glued(&self, idx: usize) -> bool {
310        self.glued.get(idx).copied().unwrap_or(false)
311    }
312
313    /// Whether element `idx` belongs to a fallback statement. A fallback line
314    /// commits as a plain *greedy* fill, never the sticky fill structural
315    /// statements use: greedy packing is self-fulfilling (each printed line
316    /// re-segments to a fallback statement that re-fills to exactly itself),
317    /// while a sticky cascade forces atoms that would fit onto their own
318    /// broken lines — a shape the next pass's shorter per-line statements
319    /// do not reproduce.
320    pub fn is_fallback(&self, idx: usize) -> bool {
321        self.fallback.get(idx).copied().unwrap_or(false)
322    }
323}
324
325/// Segment an in-region element stream into statements. See the module docs
326/// for the model; the caller guarantees the stream is inside an expl3 region
327/// (so `:`/`_` were letters and names carry their argspec suffix).
328pub fn segment_expl_statements(elements: &[SyntaxElement]) -> StatementMap {
329    let mut boundary_after = vec![false; elements.len()];
330    let mut glue_before = vec![false; elements.len()];
331    let mut glued = vec![false; elements.len()];
332    let mut fallback = vec![false; elements.len()];
333    let mut i = 0;
334    while i < elements.len() {
335        match &elements[i] {
336            SyntaxElement::Token(t) if is_collapsible_trivia(t.kind()) => i += 1,
337            // A comment, guard, or doc margin between statements ends at its
338            // newline exactly as today (each is line-structured in the source);
339            // the boundary keeps the next statement off its line. Comment
340            // presence/own-line-ness and guard/margin column-0 are preserved
341            // predicates, so the read is sanctioned.
342            SyntaxElement::Token(t)
343                if matches!(
344                    t.kind(),
345                    SyntaxKind::COMMENT | SyntaxKind::GUARD | SyntaxKind::DOC_MARGIN
346                ) =>
347            {
348                if followed_by_newline(elements, i) {
349                    boundary_after[i] = true;
350                }
351                i += 1;
352            }
353            SyntaxElement::Node(n) if n.kind() == SyntaxKind::COMMAND => {
354                // A region toggle (`\ExplSyntaxOn`, `\ProvidesExplPackage`, …)
355                // is colonless but in the shared toggle name set and takes no
356                // trailing call-site material beyond its greedily-attached
357                // groups: a recognized zero-arity unit — handled inside
358                // [`expl3_unit`], which resolves the whole shape. Without it,
359                // every region's opening line would stay a newline-keyed
360                // fallback statement and strict trivia-invariance could never
361                // hold for any expl3 stream.
362                match expl3_unit(elements, i) {
363                    Some(unit) => {
364                        let end = unit.last;
365                        let full = absorb_trailing_junk(elements, end);
366                        if full > end {
367                            glued[i..=full].fill(true);
368                        }
369                        boundary_after[full] = true;
370                        i = full + 1;
371                    }
372                    None => {
373                        i = fallback_line(
374                            elements,
375                            i,
376                            &mut boundary_after,
377                            &mut glue_before,
378                            &mut fallback,
379                        )
380                    }
381                }
382            }
383            _ => {
384                i = fallback_line(
385                    elements,
386                    i,
387                    &mut boundary_after,
388                    &mut glue_before,
389                    &mut fallback,
390                )
391            }
392        }
393    }
394    StatementMap {
395        boundary_after,
396        glue_before,
397        glued,
398        fallback,
399    }
400}
401
402/// Whether a `COMMAND`'s name token is one of the shared expl3 region-toggle
403/// spellings (`parser::lexer::expl_toggle`).
404fn node_is_expl_toggle(node: &SyntaxNode) -> bool {
405    node.children_with_tokens()
406        .filter_map(|el| el.into_token())
407        .find(|t| t.kind() == SyntaxKind::CONTROL_WORD)
408        .is_some_and(|t| expl_toggle(t.text()).is_some())
409}
410
411/// Whether only inline whitespace separates element `idx` from the next
412/// newline (or the stream end) — i.e. the element ends its physical line.
413fn followed_by_newline(elements: &[SyntaxElement], idx: usize) -> bool {
414    for element in &elements[idx + 1..] {
415        match element {
416            SyntaxElement::Token(t) if t.kind() == SyntaxKind::WHITESPACE => {}
417            SyntaxElement::Token(t) if t.kind() == SyntaxKind::NEWLINE => return true,
418            _ => return false,
419        }
420    }
421    true
422}
423
424/// The fallback: the statement is the authored physical line, verbatim the old
425/// `SplitAtNewlines` rule demoted to a per-statement escape hatch. Marks the
426/// boundary after the line's last non-trivia element and returns the index to
427/// resume the outer walk from.
428fn fallback_line(
429    elements: &[SyntaxElement],
430    start: usize,
431    boundary_after: &mut [bool],
432    glue_before: &mut [bool],
433    fallback: &mut [bool],
434) -> usize {
435    let mut last = start;
436    let mut j = start;
437    while j < elements.len() {
438        match &elements[j] {
439            SyntaxElement::Token(t) if is_collapsible_trivia(t.kind()) => {
440                if t.kind() == SyntaxKind::NEWLINE {
441                    boundary_after[last] = true;
442                    fallback[start..=last].fill(true);
443                    return j;
444                }
445                j += 1;
446            }
447            element => {
448                // A recognized head mid-line must never start a printed
449                // continuation line (see [`StatementMap::glue_before`]).
450                if j > start
451                    && let SyntaxElement::Node(n) = element
452                    && n.kind() == SyntaxKind::COMMAND
453                    && (node_is_expl_toggle(n)
454                        || command_name(n).is_some_and(|name| expl3_slots(&name).is_some()))
455                {
456                    glue_before[j] = true;
457                }
458                last = j;
459                j += 1;
460            }
461        }
462    }
463    boundary_after[last] = true;
464    fallback[start..=last].fill(true);
465    elements.len()
466}
467
468/// Extend a completed unit over trailing same-line *junk*: unrecognized
469/// material — punctuation and words (`\int_use:N \c@… , %mc-num`'s comma),
470/// unrecognized command tokens, a trailing comment — that the author wrote as
471/// part of the call's line. The scan never crosses a newline (junk on a later
472/// line stays its own fallback statement, and a recognized head is never
473/// pulled apart from fallback material it shares a line with) and stops at
474/// the next recognized head or toggle (the next call), a `{…}` group (a
475/// statement-leading block keeps its continuation-hang treatment), or a guard
476/// or doc margin (line-structured). This same-line read is part of the
477/// fallback's Tier-2 residue, not the structural model; a comment stays
478/// sanctioned either way (own-line-ness is a preserved predicate).
479fn absorb_trailing_junk(elements: &[SyntaxElement], end: usize) -> usize {
480    let mut end = end;
481    let mut j = end + 1;
482    while j < elements.len() {
483        match &elements[j] {
484            SyntaxElement::Token(t) if is_collapsible_trivia(t.kind()) => {
485                if t.kind() == SyntaxKind::NEWLINE {
486                    break;
487                }
488                j += 1;
489            }
490            SyntaxElement::Token(t) if t.kind() == SyntaxKind::COMMENT => {
491                end = j;
492                break;
493            }
494            SyntaxElement::Token(t)
495                if matches!(t.kind(), SyntaxKind::GUARD | SyntaxKind::DOC_MARGIN) =>
496            {
497                break;
498            }
499            SyntaxElement::Node(n) if n.kind() == SyntaxKind::GROUP => break,
500            SyntaxElement::Node(n)
501                if n.kind() == SyntaxKind::COMMAND
502                    && (node_is_expl_toggle(n)
503                        || command_name(n).is_some_and(|name| expl3_slots(&name).is_some())) =>
504            {
505                break;
506            }
507            _ => {
508                end = j;
509                j += 1;
510            }
511        }
512    }
513    end
514}
515
516/// Why slot consumption stopped early.
517enum Stop {
518    /// A blank line: the unit ends here and the partial statement commits
519    /// as-is (blank-line presence is a preserved predicate, so pass-stable).
520    End,
521    /// The shape scan cannot resolve the unit — degrade to [`fallback_line`].
522    Abort,
523}
524
525/// Consume `slots` for the head at `head_idx`, returning the resolved unit, or
526/// `None` to degrade to the fallback.
527fn consume_unit(
528    elements: &[SyntaxElement],
529    head_idx: usize,
530    slots: &[Expl3Slot],
531) -> Option<Expl3Unit> {
532    let head = elements[head_idx].as_node()?;
533    let mut cur = UnitCursor::new(elements, head_idx, head);
534    let mut branches = Vec::new();
535    let mut complete = true;
536    for slot in slots {
537        let took = match slot {
538            Expl3Slot::SingleToken => cur.take_single_token(),
539            Expl3Slot::Group => cur.take_group().map(|_| ()),
540            // The one slot whose *identity* escapes the scan: a branch may live
541            // inside a peeled sibling, so its range is the only handle a consumer
542            // can use to find it again (see [`Expl3Unit::branches`]).
543            Expl3Slot::Branch => cur.take_group().map(|el| branches.push(el.text_range())),
544            Expl3Slot::ParameterText => cur.take_parameter_text(),
545        };
546        match took {
547            Ok(()) => {}
548            Err(Stop::End) => {
549                complete = false;
550                break;
551            }
552            Err(Stop::Abort) => return None,
553        }
554    }
555    Some(Expl3Unit {
556        last: cur.last_sib,
557        // A blank line cut the unit short, so the branch list is partial. Report
558        // none rather than a prefix: a layout keyed on "the branches" must never
559        // see two of a `TF` call's three.
560        branches: if complete { branches } else { Vec::new() },
561    })
562}
563
564/// The resolved shape of one expl3 call unit — what [`consume_unit`]'s slot scan
565/// learns, kept rather than discarded.
566///
567/// [`segment_expl_statements`] needs only `last`; the formatter's conditional
568/// layout needs `branches`, because *where* greedy attachment put a branch group
569/// is an accident of the surrounding tokens and must not be a layout input. In
570/// `\tl_if_empty:nTF {#1} {T} {F}` the branches hang off the head command, but a
571/// single-token slot breaks attachment and hands them to a sibling
572/// (`\seq_if_in:NnTF \l_seq {item} {T} {F}` peels all three off `\l_seq`) or to
573/// the stream itself (`\int_compare:nNnTF {a} = {1} {T} {F}`, where the relation
574/// is a `WORD`). The scan resolves all three the same way, so the branch ranges
575/// are the one handle that works for every shape.
576#[derive(Debug, Clone, PartialEq, Eq)]
577pub struct Expl3Unit {
578    /// Last sibling index the unit spans (the head itself for a zero-arity or
579    /// entirely head-internal unit).
580    pub last: usize,
581    /// The `T`/`F` branch groups, in argspec order. Empty for a non-conditional
582    /// head, and also for a unit a blank line cut short before every branch slot
583    /// was filled.
584    pub branches: Vec<TextRange>,
585}
586
587/// Resolve the expl3 call unit headed by `elements[head_idx]`, or `None` when the
588/// shape scan cannot (an unrecognized head, a slot facing the wrong shape, a
589/// docstrip guard mid-unit, or the stream ending mid-unit) — exactly the
590/// conditions under which [`segment_expl_statements`] degrades that statement to
591/// the fallback.
592///
593/// Public so the formatter can ask about one head directly, without a
594/// [`StatementMap`]: the conditional layout runs inside a command's attached
595/// arguments too, where there are no statements to segment.
596pub fn expl3_unit(elements: &[SyntaxElement], head_idx: usize) -> Option<Expl3Unit> {
597    let node = elements.get(head_idx)?.as_node()?;
598    if node.kind() != SyntaxKind::COMMAND {
599        return None;
600    }
601    let slots = if node_is_expl_toggle(node) {
602        Vec::new()
603    } else {
604        expl3_slots(&command_name(node)?)?
605    };
606    consume_unit(elements, head_idx, &slots)
607}
608
609/// The consumption cursor: candidates come from the peel **queue** first (an
610/// already-consumed `COMMAND`'s attached children), then from the sibling
611/// stream. Trivia, comments, and `~` are skipped in place (a `~` is a space
612/// token TeX skips before an undelimited argument, so it can never satisfy a
613/// slot — it stays in the extent for the layout loop's tilde arm).
614struct UnitCursor<'a> {
615    elements: &'a [SyntaxElement],
616    queue: VecDeque<SyntaxElement>,
617    /// Next sibling index to pull from.
618    sib: usize,
619    /// Last sibling index consumed into the unit — the unit's extent.
620    last_sib: usize,
621    /// A peeked candidate not yet consumed; the index is its sibling position
622    /// when it came from the sibling stream (`None` for queue candidates).
623    peeked: Option<(SyntaxElement, Option<usize>)>,
624}
625
626impl<'a> UnitCursor<'a> {
627    fn new(elements: &'a [SyntaxElement], head_idx: usize, head: &SyntaxNode) -> Self {
628        let mut cur = UnitCursor {
629            elements,
630            queue: VecDeque::new(),
631            sib: head_idx + 1,
632            last_sib: head_idx,
633            peeked: None,
634        };
635        cur.queue_children_after_name(head, false);
636        cur
637    }
638
639    /// Push `node`'s children after its name token onto the queue — at the
640    /// back when seeding from the head, at the **front** when peeling an
641    /// argument (its children must be scanned before later siblings).
642    fn queue_children_after_name(&mut self, node: &SyntaxNode, front: bool) {
643        let mut seen_name = false;
644        let mut after: Vec<SyntaxElement> = Vec::new();
645        for child in node.children_with_tokens() {
646            if seen_name {
647                after.push(child);
648            } else if matches!(
649                child.kind(),
650                SyntaxKind::CONTROL_WORD | SyntaxKind::CONTROL_SYMBOL
651            ) {
652                seen_name = true;
653            }
654        }
655        if front {
656            for el in after.into_iter().rev() {
657                self.queue.push_front(el);
658            }
659        } else {
660            self.queue.extend(after);
661        }
662    }
663
664    /// The next slot candidate, without consuming it.
665    fn peek(&mut self) -> Result<&SyntaxElement, Stop> {
666        if self.peeked.is_none() {
667            self.peeked = Some(self.advance()?);
668        }
669        Ok(&self.peeked.as_ref().expect("just filled").0)
670    }
671
672    /// Consume the next slot candidate, extending the unit over it.
673    fn bump(&mut self) -> Result<SyntaxElement, Stop> {
674        let (el, sib_idx) = match self.peeked.take() {
675            Some(peeked) => peeked,
676            None => self.advance()?,
677        };
678        if let Some(idx) = sib_idx {
679            self.last_sib = idx;
680        }
681        Ok(el)
682    }
683
684    /// Scan forward to the next candidate, skipping inline trivia, comments,
685    /// and `~`. A blank-line gap is [`Stop::End`]; a guard or doc margin
686    /// mid-unit, or the stream running out, is [`Stop::Abort`].
687    fn advance(&mut self) -> Result<(SyntaxElement, Option<usize>), Stop> {
688        let mut gap_newlines = 0usize;
689        loop {
690            let (el, sib_idx) = if let Some(el) = self.queue.pop_front() {
691                (el, None)
692            } else {
693                let Some(el) = self.elements.get(self.sib) else {
694                    return Err(Stop::Abort);
695                };
696                // A blank line must end the unit *before* it is crossed, so
697                // peek the newline count without consuming past it.
698                if let SyntaxElement::Token(t) = el
699                    && t.kind() == SyntaxKind::NEWLINE
700                    && gap_newlines >= 1
701                {
702                    return Err(Stop::End);
703                }
704                let idx = self.sib;
705                self.sib += 1;
706                (el.clone(), Some(idx))
707            };
708            match &el {
709                SyntaxElement::Token(t) if is_collapsible_trivia(t.kind()) => {
710                    if t.kind() == SyntaxKind::NEWLINE {
711                        gap_newlines += 1;
712                        if gap_newlines >= 2 {
713                            return Err(Stop::End);
714                        }
715                    }
716                }
717                SyntaxElement::Token(t) if t.kind() == SyntaxKind::COMMENT => {}
718                SyntaxElement::Token(t) if t.kind() == SyntaxKind::TILDE => {}
719                SyntaxElement::Token(t)
720                    if matches!(t.kind(), SyntaxKind::GUARD | SyntaxKind::DOC_MARGIN) =>
721                {
722                    return Err(Stop::Abort);
723                }
724                _ => return Ok((el, sib_idx)),
725            }
726        }
727    }
728
729    /// An `N`/`V` slot: one token — a control sequence, a single character, a
730    /// `#`-parameter, a braced group (TeX-faithful: braces around an `N`
731    /// argument are grabbed whole; `N` vs `n` is convention, not matching
732    /// behavior), or a `COMMAND` node whose *name* satisfies the slot and whose
733    /// greedily-attached children are peeled back for the head's remaining
734    /// slots.
735    fn take_single_token(&mut self) -> Result<(), Stop> {
736        let el = self.bump()?;
737        match &el {
738            SyntaxElement::Token(t)
739                if matches!(
740                    t.kind(),
741                    SyntaxKind::CONTROL_WORD | SyntaxKind::CONTROL_SYMBOL
742                ) =>
743            {
744                Ok(())
745            }
746            // A relation character: `\int_compare:nNnTF { … } = { 1 } {T} {F}`
747            // (issue #106). TeX grabs one character for an undelimited
748            // argument, so only a *single-character* `WORD` satisfies the slot
749            // — the lexer packs a run of characters into one token, and
750            // consuming a multi-character run would take material TeX leaves
751            // for the next slot. That shape aborts to the fallback instead.
752            SyntaxElement::Token(t)
753                if t.kind() == SyntaxKind::WORD && t.text().chars().count() == 1 =>
754            {
755                Ok(())
756            }
757            SyntaxElement::Token(t) if t.kind() == SyntaxKind::HASH => {
758                // `#1` (or `##1` in a nested definition): hash(es) plus one
759                // parameter digit read as one parameter token.
760                loop {
761                    let next = self.bump()?;
762                    match &next {
763                        SyntaxElement::Token(t) if t.kind() == SyntaxKind::HASH => {}
764                        SyntaxElement::Token(t)
765                            if t.kind() == SyntaxKind::WORD && is_param_digit(t) =>
766                        {
767                            return Ok(());
768                        }
769                        _ => return Err(Stop::Abort),
770                    }
771                }
772            }
773            SyntaxElement::Node(n) if n.kind() == SyntaxKind::COMMAND => {
774                self.queue_children_after_name(n, true);
775                Ok(())
776            }
777            SyntaxElement::Node(n) if n.kind() == SyntaxKind::GROUP => Ok(()),
778            _ => Err(Stop::Abort),
779        }
780    }
781
782    /// An `n`-family or `T`/`F` slot: exactly a braced group, returned so a
783    /// `T`/`F` slot can record which one it took. A bare token is legal TeX for
784    /// an undelimited argument, but accepting it would let sloppy shapes (and
785    /// the `\::n` expansion-driver protocol) swallow the next statement's head —
786    /// those stay on the fallback path instead.
787    fn take_group(&mut self) -> Result<SyntaxElement, Stop> {
788        let el = self.bump()?;
789        match &el {
790            SyntaxElement::Node(n) if n.kind() == SyntaxKind::GROUP => Ok(el),
791            _ => Err(Stop::Abort),
792        }
793    }
794
795    /// A `p` slot: TeX parameter text — everything up to (not including) the
796    /// first explicit `{`, which is left for the following slot. Tokens and
797    /// `[…]` are parameter text; a control sequence delimiting the text
798    /// (`#1 \q_stop {body}`) has its own over-attached children peeled, so the
799    /// terminating group is found wherever greedy attachment put it. The
800    /// `#{`-terminated form works out to the same rule (the `{` opens the
801    /// replacement text).
802    fn take_parameter_text(&mut self) -> Result<(), Stop> {
803        loop {
804            if let SyntaxElement::Node(n) = self.peek()?
805                && n.kind() == SyntaxKind::GROUP
806            {
807                return Ok(());
808            }
809            let el = self.bump()?;
810            match &el {
811                SyntaxElement::Token(_) => {}
812                SyntaxElement::Node(n) if n.kind() == SyntaxKind::COMMAND => {
813                    self.queue_children_after_name(n, true);
814                }
815                SyntaxElement::Node(n) if n.kind() == SyntaxKind::OPTIONAL => {}
816                _ => return Err(Stop::Abort),
817            }
818        }
819    }
820}
821
822#[cfg(test)]
823mod segmentation_tests {
824    use super::*;
825    use crate::parser::parse;
826    use crate::syntax::SyntaxNode;
827
828    /// Segment the first paragraph of `src` (which must open with
829    /// `\ExplSyntaxOn` so the lexer treats `:`/`_` as letters) and render each
830    /// statement's source text with whitespace collapsed, for stable
831    /// assertions.
832    fn statements(src: &str) -> Vec<String> {
833        let parsed = parse(src);
834        assert!(parsed.errors.is_empty(), "test source should parse cleanly");
835        let root = SyntaxNode::new_root(parsed.green);
836        let para = root
837            .children()
838            .find(|n| n.kind() == SyntaxKind::PARAGRAPH)
839            .expect("a paragraph");
840        let elements: Vec<SyntaxElement> = para.children_with_tokens().collect();
841        statement_texts(&elements)
842    }
843
844    fn statement_texts(elements: &[SyntaxElement]) -> Vec<String> {
845        let map = segment_expl_statements(elements);
846        let mut out = Vec::new();
847        let mut cur = String::new();
848        for (i, el) in elements.iter().enumerate() {
849            cur.push_str(&el.to_string());
850            if map.boundary_after(i) {
851                let text = normalize(&cur);
852                if !text.is_empty() {
853                    out.push(text);
854                }
855                cur.clear();
856            }
857        }
858        let tail = normalize(&cur);
859        if !tail.is_empty() {
860            out.push(tail);
861        }
862        out
863    }
864
865    fn normalize(s: &str) -> String {
866        s.split_whitespace().collect::<Vec<_>>().join(" ")
867    }
868
869    #[test]
870    fn statements_are_structural_units() {
871        // Mid-call newlines join; the colonless toggles fall back per-line.
872        let got = statements(
873            "\\ExplSyntaxOn\n\\tl_set:Nn \\l_a\n  { x }\n\\group_begin:\n\\ExplSyntaxOff\n",
874        );
875        assert_eq!(
876            got,
877            vec![
878                "\\ExplSyntaxOn",
879                "\\tl_set:Nn \\l_a { x }",
880                "\\group_begin:",
881                "\\ExplSyntaxOff",
882            ]
883        );
884    }
885
886    #[test]
887    fn same_line_calls_split() {
888        let got =
889            statements("\\ExplSyntaxOn\n\\group_begin: \\int_zero:N \\l_a\n\\ExplSyntaxOff\n");
890        assert_eq!(
891            got,
892            vec![
893                "\\ExplSyntaxOn",
894                "\\group_begin:",
895                "\\int_zero:N \\l_a",
896                "\\ExplSyntaxOff",
897            ]
898        );
899    }
900
901    #[test]
902    fn npn_definition_is_one_unit() {
903        // `N` takes `\foo:n`, `p` scans `#1`, `n` takes the body — across the
904        // authored Allman break.
905        let got =
906            statements("\\ExplSyntaxOn\n\\cs_new:Npn \\foo:n #1\n  { body #1 }\n\\ExplSyntaxOff\n");
907        assert_eq!(
908            got,
909            vec![
910                "\\ExplSyntaxOn",
911                "\\cs_new:Npn \\foo:n #1 { body #1 }",
912                "\\ExplSyntaxOff",
913            ]
914        );
915    }
916
917    #[test]
918    fn peel_back_reclaims_over_attached_group() {
919        // Greedy attachment gives `{ body }` to `\foo:n`; the `N` slot takes
920        // the name and the peeled group satisfies the outer `n` slot.
921        let got = statements("\\ExplSyntaxOn\n\\cs_new:Nn \\foo:n\n  { body }\n\\ExplSyntaxOff\n");
922        assert_eq!(
923            got,
924            vec![
925                "\\ExplSyntaxOn",
926                "\\cs_new:Nn \\foo:n { body }",
927                "\\ExplSyntaxOff",
928            ]
929        );
930    }
931
932    #[test]
933    fn exp_args_chain_is_one_unit() {
934        let got = statements(
935            "\\ExplSyntaxOn\n\\exp_args:NNo \\tl_set:Nn \\l_a { \\l_b }\n\\ExplSyntaxOff\n",
936        );
937        assert_eq!(
938            got,
939            vec![
940                "\\ExplSyntaxOn",
941                "\\exp_args:NNo \\tl_set:Nn \\l_a { \\l_b }",
942                "\\ExplSyntaxOff",
943            ]
944        );
945    }
946
947    #[test]
948    fn hash_parameter_satisfies_single_token_slot() {
949        let got = statements("\\ExplSyntaxOn\n\\tl_set:Nn #1 { x }\n\\ExplSyntaxOff\n");
950        assert_eq!(
951            got,
952            vec!["\\ExplSyntaxOn", "\\tl_set:Nn #1 { x }", "\\ExplSyntaxOff"]
953        );
954    }
955
956    #[test]
957    fn relation_character_satisfies_single_token_slot() {
958        // `\int_compare:nNnTF`'s `N` slot is the relation `=` (issue #106).
959        // Without it the whole conditional degraded to the newline-keyed
960        // fallback, so the trailing call's line was authored, not derived.
961        let got = statements(
962            "\\ExplSyntaxOn\n\\int_compare:nNnTF { \\l_a } = { 1 } { yes } { no } \\foo:\n\\ExplSyntaxOff\n",
963        );
964        assert_eq!(
965            got,
966            vec![
967                "\\ExplSyntaxOn",
968                "\\int_compare:nNnTF { \\l_a } = { 1 } { yes } { no }",
969                "\\foo:",
970                "\\ExplSyntaxOff",
971            ]
972        );
973    }
974
975    #[test]
976    fn relation_character_unit_is_newline_invariant() {
977        // The same call broken across lines segments identically — the point
978        // of the structural model.
979        let inline = statements(
980            "\\ExplSyntaxOn\n\\int_compare:nNnTF { \\l_a } = { 1 } { yes } { no } \\foo:\n\\ExplSyntaxOff\n",
981        );
982        let broken = statements(
983            "\\ExplSyntaxOn\n\\int_compare:nNnTF { \\l_a } = { 1 }\n  { yes } { no }\n\\foo:\n\\ExplSyntaxOff\n",
984        );
985        assert_eq!(inline, broken);
986    }
987
988    #[test]
989    fn multi_character_word_does_not_satisfy_single_token_slot() {
990        // TeX grabs one character for an undelimited argument, so a lexed run
991        // of characters is the wrong shape and degrades to the fallback (here:
992        // the authored line).
993        let got = statements(
994            "\\ExplSyntaxOn\n\\int_compare:nNnT { \\l_a } <= { 1 } { yes }\n\\foo:\n\\ExplSyntaxOff\n",
995        );
996        assert_eq!(
997            got,
998            vec![
999                "\\ExplSyntaxOn",
1000                "\\int_compare:nNnT { \\l_a } <= { 1 } { yes }",
1001                "\\foo:",
1002                "\\ExplSyntaxOff",
1003            ]
1004        );
1005    }
1006
1007    #[test]
1008    fn delimited_parameter_text_peels_the_body() {
1009        // `{ body }` greedily attached to `\q_stop`; the p-scan peels it and
1010        // stops there, leaving it for the trailing `n` slot.
1011        let got = statements(
1012            "\\ExplSyntaxOn\n\\cs_new:Npn \\foo:w #1 \\q_stop { body }\n\\ExplSyntaxOff\n",
1013        );
1014        assert_eq!(
1015            got,
1016            vec![
1017                "\\ExplSyntaxOn",
1018                "\\cs_new:Npn \\foo:w #1 \\q_stop { body }",
1019                "\\ExplSyntaxOff",
1020            ]
1021        );
1022    }
1023
1024    #[test]
1025    fn unknown_head_falls_back_to_its_line() {
1026        // `\exp_after:wN` has no derivable arity: its authored line is the
1027        // statement, and the recognized call sharing that line is not split out.
1028        let got = statements(
1029            "\\ExplSyntaxOn\n\\exp_after:wN \\foo \\tl_set:Nn \\l_a { x }\n\\group_begin:\n\\ExplSyntaxOff\n",
1030        );
1031        assert_eq!(
1032            got,
1033            vec![
1034                "\\ExplSyntaxOn",
1035                "\\exp_after:wN \\foo \\tl_set:Nn \\l_a { x }",
1036                "\\group_begin:",
1037                "\\ExplSyntaxOff",
1038            ]
1039        );
1040    }
1041
1042    #[test]
1043    fn shape_mismatch_falls_back() {
1044        // The `n` slot faces a command, not a group: the whole statement
1045        // degrades to newline splitting rather than swallowing the next head.
1046        let got = statements("\\ExplSyntaxOn\n\\tl_set:Nn\n\\l_a\n\\ExplSyntaxOff\n");
1047        assert_eq!(
1048            got,
1049            vec!["\\ExplSyntaxOn", "\\tl_set:Nn", "\\l_a", "\\ExplSyntaxOff"]
1050        );
1051    }
1052
1053    #[test]
1054    fn trailing_comment_rides_the_statement() {
1055        let got = statements("\\ExplSyntaxOn\n\\tl_set:Nn \\l_a { x } % note\n\\ExplSyntaxOff\n");
1056        assert_eq!(
1057            got,
1058            vec![
1059                "\\ExplSyntaxOn",
1060                "\\tl_set:Nn \\l_a { x } % note",
1061                "\\ExplSyntaxOff",
1062            ]
1063        );
1064    }
1065
1066    #[test]
1067    fn leftover_attached_group_rides_the_statement() {
1068        // `\use:n` has arity 1; the second group is over-attached to the head
1069        // node, and boundaries never split a node, so it stays in the unit.
1070        let got = statements("\\ExplSyntaxOn\n\\use:n { a } { b }\n\\ExplSyntaxOff\n");
1071        assert_eq!(
1072            got,
1073            vec!["\\ExplSyntaxOn", "\\use:n { a } { b }", "\\ExplSyntaxOff"]
1074        );
1075    }
1076
1077    #[test]
1078    fn conditional_call_is_one_unit() {
1079        let got = statements(
1080            "\\ExplSyntaxOn\n\\str_if_eq:nnTF { a } { b }\n  { yes }\n  { no }\n\\ExplSyntaxOff\n",
1081        );
1082        assert_eq!(
1083            got,
1084            vec![
1085                "\\ExplSyntaxOn",
1086                "\\str_if_eq:nnTF { a } { b } { yes } { no }",
1087                "\\ExplSyntaxOff",
1088            ]
1089        );
1090    }
1091
1092    /// The source text of each `T`/`F` branch [`expl3_unit`] resolved for the
1093    /// head at index `head`, whitespace-collapsed for stable assertions.
1094    fn branch_texts(src: &str, head: usize) -> Option<Vec<String>> {
1095        let parsed = parse(src);
1096        assert!(parsed.errors.is_empty(), "test source should parse cleanly");
1097        let root = SyntaxNode::new_root(parsed.green);
1098        let para = root
1099            .children()
1100            .find(|n| n.kind() == SyntaxKind::PARAGRAPH)
1101            .expect("a paragraph");
1102        let elements: Vec<SyntaxElement> = para.children_with_tokens().collect();
1103        let unit = expl3_unit(&elements, head)?;
1104        Some(
1105            unit.branches
1106                .iter()
1107                .map(|range| normalize(&root.text().slice(*range).to_string()))
1108                .collect(),
1109        )
1110    }
1111
1112    /// The sibling index of the `COMMAND` named `name` in the first paragraph.
1113    /// Keyed on the name rather than on position because the leading
1114    /// `\ExplSyntaxOn` is itself a `COMMAND` — and a recognized zero-arity unit.
1115    fn head_of(src: &str, name: &str) -> usize {
1116        let parsed = parse(src);
1117        let root = SyntaxNode::new_root(parsed.green);
1118        let para = root
1119            .children()
1120            .find(|n| n.kind() == SyntaxKind::PARAGRAPH)
1121            .expect("a paragraph");
1122        para.children_with_tokens()
1123            .position(|el| {
1124                el.as_node().is_some_and(|n| {
1125                    n.kind() == SyntaxKind::COMMAND
1126                        && command_name(n).is_some_and(|got| got == name)
1127                })
1128            })
1129            .unwrap_or_else(|| panic!("no command named {name}"))
1130    }
1131
1132    #[test]
1133    fn branches_are_resolved_wherever_attachment_put_them() {
1134        // The point of [`Expl3Unit::branches`]: the same call shape, with the
1135        // branch groups on the head, peeled off one sibling, split across two,
1136        // and at the stream level — all four resolve to the same two branches.
1137        let head_attached = "\\ExplSyntaxOn\n\\tl_if_empty:nTF {#1} { T } { F }\n";
1138        assert_eq!(
1139            branch_texts(head_attached, head_of(head_attached, "tl_if_empty:nTF")),
1140            Some(vec!["{ T }".to_string(), "{ F }".to_string()])
1141        );
1142
1143        // `\l_seq` swallowed all three trailing groups; the `n` slot takes the
1144        // first back off the peel queue and the branches are the other two.
1145        let one_sibling = "\\ExplSyntaxOn\n\\seq_if_in:NnTF \\l_seq {item} { T } { F }\n";
1146        assert_eq!(
1147            branch_texts(one_sibling, head_of(one_sibling, "seq_if_in:NnTF")),
1148            Some(vec!["{ T }".to_string(), "{ F }".to_string()])
1149        );
1150
1151        // The TODO's own example: `{k}` on `\p`, both branches on `\l`.
1152        let two_siblings = "\\ExplSyntaxOn\n\\prop_get:NnNTF \\p {k} \\l { T } { F }\n";
1153        assert_eq!(
1154            branch_texts(two_siblings, head_of(two_siblings, "prop_get:NnNTF")),
1155            Some(vec!["{ T }".to_string(), "{ F }".to_string()])
1156        );
1157
1158        // A `WORD` relation breaks attachment outright, so every group after it
1159        // is a top-level sibling (issue #106).
1160        let stream_level = "\\ExplSyntaxOn\n\\int_compare:nNnTF {a} = { 1 } { T } { F }\n";
1161        assert_eq!(
1162            branch_texts(stream_level, head_of(stream_level, "int_compare:nNnTF")),
1163            Some(vec!["{ T }".to_string(), "{ F }".to_string()])
1164        );
1165    }
1166
1167    #[test]
1168    fn a_non_conditional_unit_has_no_branches() {
1169        let src = "\\ExplSyntaxOn\n\\tl_set:Nn \\l_a { x }\n";
1170        assert_eq!(branch_texts(src, head_of(src, "tl_set:Nn")), Some(vec![]));
1171    }
1172
1173    #[test]
1174    fn an_underivable_head_resolves_no_unit() {
1175        // `conditional_branches` still reports 2 for `:wTF`
1176        // ([`branches_survive_underivable_arity`]), but the arity model bows out,
1177        // so there is no unit and no branch list — the consumer must not be handed
1178        // a guess.
1179        let src = "\\ExplSyntaxOn\n\\odd_if:wTF \\a \\b { T } { F }\n";
1180        assert_eq!(branch_texts(src, head_of(src, "odd_if:wTF")), None);
1181    }
1182
1183    #[test]
1184    fn a_blank_line_cut_unit_reports_no_branches() {
1185        // The unit still commits as far as it got (`last` is real), but a partial
1186        // branch list must never drive a layout keyed on "the branches" — a `TF`
1187        // call would otherwise explode with one of its two. Inside a group body,
1188        // because at the *stream* level a blank line ends the paragraph and the
1189        // unit aborts on the stream end instead ([`Stop::Abort`], no unit at all).
1190        let src = "\\ExplSyntaxOn\n\\use:n { \\prop_get:NnNTF \\p {k} \\l { T }\n\n{ F } }\n";
1191        let parsed = parse(src);
1192        assert!(parsed.errors.is_empty());
1193        let root = SyntaxNode::new_root(parsed.green);
1194        let group = root
1195            .descendants()
1196            .find(|n| n.kind() == SyntaxKind::GROUP)
1197            .expect("a group");
1198        let body: Vec<SyntaxElement> = group
1199            .children_with_tokens()
1200            .filter(|el| !matches!(el.kind(), SyntaxKind::L_BRACE | SyntaxKind::R_BRACE))
1201            .collect();
1202        let head = body
1203            .iter()
1204            .position(|el| el.as_node().is_some())
1205            .expect("the head command");
1206        let unit = expl3_unit(&body, head).expect("the partial unit still resolves");
1207        assert_eq!(unit.branches, vec![]);
1208    }
1209
1210    #[test]
1211    fn blank_line_ends_the_unit() {
1212        // Inside a group body a blank line can sit mid-call: the unit commits
1213        // as-is before it, and the stranded group starts a fresh statement.
1214        let src = "\\ExplSyntaxOn\n\\use:n { \\tl_set:Nn \\l_a\n\n  { x } }\n\\ExplSyntaxOff\n";
1215        let parsed = parse(src);
1216        assert!(parsed.errors.is_empty());
1217        let root = SyntaxNode::new_root(parsed.green);
1218        let group = root
1219            .descendants()
1220            .find(|n| n.kind() == SyntaxKind::GROUP)
1221            .expect("a group");
1222        let body: Vec<SyntaxElement> = group
1223            .children_with_tokens()
1224            .filter(|el| !matches!(el.kind(), SyntaxKind::L_BRACE | SyntaxKind::R_BRACE))
1225            .collect();
1226        assert_eq!(statement_texts(&body), vec!["\\tl_set:Nn \\l_a", "{ x }"]);
1227    }
1228
1229    #[test]
1230    fn guard_mid_unit_aborts_to_fallback() {
1231        // A docstrip guard inside the unit (issue #78: guarded alternative
1232        // bodies make arity lie) aborts consumption; the statement degrades to
1233        // the fallback, and the guard-bearing sibling rides it whole because
1234        // boundaries never split a node.
1235        use crate::parser::lexer::LexConfig;
1236        use crate::parser::{LatexFlavor, parse_with_flavor};
1237        let src = "% \\begin{macrocode}\n\\ExplSyntaxOn\n\\tl_set:Nn \\l_a\n%<latexrelease>  { x }\n\\ExplSyntaxOff\n% \\end{macrocode}\n";
1238        let config = LexConfig {
1239            flavor: LatexFlavor::Package,
1240            dtx: true,
1241        };
1242        let parsed = parse_with_flavor(src, config);
1243        assert!(parsed.errors.is_empty(), "test source should parse cleanly");
1244        let root = SyntaxNode::new_root(parsed.green);
1245        let para = root
1246            .descendants()
1247            .find(|n| n.kind() == SyntaxKind::PARAGRAPH)
1248            .expect("a paragraph");
1249        let elements: Vec<SyntaxElement> = para.children_with_tokens().collect();
1250        let map = segment_expl_statements(&elements);
1251        assert_eq!(
1252            statement_texts(&elements),
1253            vec![
1254                "\\ExplSyntaxOn",
1255                "\\tl_set:Nn \\l_a %<latexrelease> { x }",
1256                "\\ExplSyntaxOff",
1257            ]
1258        );
1259        let guarded_end = elements
1260            .iter()
1261            .position(|el| el.to_string().contains("latexrelease"))
1262            .expect("the guarded sibling");
1263        assert!(
1264            map.is_fallback(guarded_end),
1265            "the aborted unit must be a fallback statement"
1266        );
1267    }
1268
1269    #[test]
1270    fn e_and_f_letters_consume_braced_groups() {
1271        let got = statements(
1272            "\\ExplSyntaxOn\n\\tl_set:Ne \\l_a\n  { x }\n\\tl_set:Nf \\l_b\n  { y }\n\\ExplSyntaxOff\n",
1273        );
1274        assert_eq!(
1275            got,
1276            vec![
1277                "\\ExplSyntaxOn",
1278                "\\tl_set:Ne \\l_a { x }",
1279                "\\tl_set:Nf \\l_b { y }",
1280                "\\ExplSyntaxOff",
1281            ]
1282        );
1283    }
1284
1285    #[test]
1286    fn stream_ending_mid_unit_falls_back() {
1287        // The `n` slot is still open when the group body runs out: the unit
1288        // aborts to the fallback rather than committing a partial unit.
1289        let src = "\\ExplSyntaxOn\n\\use:n { \\tl_set:Nn \\l_a }\n\\ExplSyntaxOff\n";
1290        let parsed = parse(src);
1291        assert!(parsed.errors.is_empty());
1292        let root = SyntaxNode::new_root(parsed.green);
1293        let group = root
1294            .descendants()
1295            .find(|n| n.kind() == SyntaxKind::GROUP)
1296            .expect("a group");
1297        let body: Vec<SyntaxElement> = group
1298            .children_with_tokens()
1299            .filter(|el| !matches!(el.kind(), SyntaxKind::L_BRACE | SyntaxKind::R_BRACE))
1300            .collect();
1301        let map = segment_expl_statements(&body);
1302        assert_eq!(statement_texts(&body), vec!["\\tl_set:Nn \\l_a"]);
1303        let head = body
1304            .iter()
1305            .position(|el| el.as_node().is_some())
1306            .expect("the head command");
1307        assert!(
1308            map.is_fallback(head),
1309            "a unit cut off by the stream end must be a fallback statement"
1310        );
1311    }
1312
1313    #[test]
1314    fn a_multi_line_group_node_does_not_end_a_fallback_line() {
1315        // [`fallback_line`] scans *sibling* `NEWLINE` tokens only, so a group
1316        // whose body spans several source lines carries those newlines inside
1317        // the node and the fallback statement runs straight past it: the group
1318        // and the following recognized head are one statement, and that head
1319        // still owes an unbreakable `glue_before` space. The formatter's
1320        // hanging-group dispatch relies on this — a forced-break commit there
1321        // would split a pair the segmentation kept together (latex2e's
1322        // `lipsum.sty`).
1323        // The `>` keeps the block a *sibling* of the head rather than a
1324        // greedily-attached argument, as in `\int_do_until:nNnn`'s real shape.
1325        let src = "\\ExplSyntaxOn\n\
1326                   \\int_do_until:w { \\l_tmpa_int } > {#2}\n\
1327                   { \\lipsum_add:V { \\l_tmpa_int }\n\
1328                   \\int_incr:N \\l_tmpa_int } \\tl_put_right:NV \\l_a \\l_b\n\
1329                   \\ExplSyntaxOff\n";
1330        let parsed = parse(src);
1331        assert!(parsed.errors.is_empty());
1332        let root = SyntaxNode::new_root(parsed.green);
1333        let elements: Vec<SyntaxElement> = root
1334            .first_child()
1335            .expect("the paragraph")
1336            .children_with_tokens()
1337            .collect();
1338        let map = segment_expl_statements(&elements);
1339
1340        // `\int_do_until:w` is underivable (`w`), so its line degrades to the
1341        // fallback. The block starts the next fallback line, which then runs
1342        // past the block's *internal* newlines and absorbs the
1343        // `\tl_put_right:NV` call sharing the block's closing line.
1344        assert_eq!(
1345            statement_texts(&elements),
1346            vec![
1347                "\\ExplSyntaxOn",
1348                "\\int_do_until:w { \\l_tmpa_int } > {#2}",
1349                "{ \\lipsum_add:V { \\l_tmpa_int } \\int_incr:N \\l_tmpa_int } \
1350                 \\tl_put_right:NV \\l_a \\l_b",
1351                "\\ExplSyntaxOff",
1352            ]
1353        );
1354
1355        let group = elements
1356            .iter()
1357            .position(|el| el.kind() == SyntaxKind::GROUP && el.to_string().contains('\n'))
1358            .expect("the multi-line group");
1359        assert!(
1360            map.is_fallback(group),
1361            "the group belongs to a fallback statement"
1362        );
1363        assert!(
1364            !map.boundary_after(group),
1365            "a multi-line group's own newlines must not end the fallback line"
1366        );
1367
1368        let head = elements
1369            .iter()
1370            .skip(group)
1371            .position(|el| {
1372                el.as_node()
1373                    .is_some_and(|n| n.kind() == SyntaxKind::COMMAND)
1374            })
1375            .map(|off| group + off)
1376            .expect("the trailing recognized head");
1377        assert!(
1378            map.glue_before(head),
1379            "a recognized head mid-fallback-line owes an unbreakable gap"
1380        );
1381    }
1382
1383    #[test]
1384    fn own_line_comment_in_attached_span_rides_the_sibling() {
1385        // The own-line comment's flanking newlines bound the gap like a blank
1386        // line, ending the unit at the `N` slot — but greedy attachment put
1387        // the comment *and* the group inside the `\l_a` sibling, and
1388        // boundaries never split a node, so the committed partial unit still
1389        // carries the whole sibling. Pass-stable either way (comment
1390        // own-line-ness is a preserved predicate).
1391        let got =
1392            statements("\\ExplSyntaxOn\n\\tl_set:Nn \\l_a\n% note\n  { x }\n\\ExplSyntaxOff\n");
1393        assert_eq!(
1394            got,
1395            vec![
1396                "\\ExplSyntaxOn",
1397                "\\tl_set:Nn \\l_a % note { x }",
1398                "\\ExplSyntaxOff",
1399            ]
1400        );
1401    }
1402
1403    #[test]
1404    fn own_line_comment_at_sibling_level_ends_the_unit() {
1405        // Before a candidate no comment can bind to (`#1` parameter text, not
1406        // a `COMMAND`), the own-line comment stays a sibling: the unit ends at
1407        // the gap, the comment keeps its own line, and the leftover material
1408        // falls back per-line.
1409        let got = statements(
1410            "\\ExplSyntaxOn\n\\cs_new:Npn \\foo:n\n% note\n#1 { body }\n\\ExplSyntaxOff\n",
1411        );
1412        assert_eq!(
1413            got,
1414            vec![
1415                "\\ExplSyntaxOn",
1416                "\\cs_new:Npn \\foo:n",
1417                "% note",
1418                "#1 { body }",
1419                "\\ExplSyntaxOff",
1420            ]
1421        );
1422    }
1423}