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 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        // `w`: arbitrary delimiters; `D`: kernel primitive of arbitrary arity.
158        assert_eq!(expl3_slots("use_none_delimit_by_q_stop:w"), None);
159        assert_eq!(expl3_slots("exp_after:wN"), None);
160        assert_eq!(expl3_slots("tex_relax:D"), None);
161        // Mid-spec `T`/`F` is nonstandard, so unknown.
162        assert_eq!(expl3_slots("odd:TnF"), None);
163        // Unknown letter anywhere bows out entirely — never a partial arity.
164        assert_eq!(expl3_slots("odd:nZn"), None);
165    }
166
167    #[test]
168    fn colonless_names_are_unrecognized() {
169        assert_eq!(expl3_slots("def"), None);
170        assert_eq!(expl3_slots("@ifpackageloaded"), None);
171        assert_eq!(expl3_slots("IfBooleanTF"), None);
172        assert_eq!(expl3_slots("l_tmpa_tl"), None);
173    }
174
175    #[test]
176    fn exp_internal_drivers() {
177        // The `\::n` expansion drivers: name is empty, spec is real. Their
178        // runtime protocol is nothing like a call site, but the greedy shape
179        // rules in the consumer keep them on the fallback path anyway; the
180        // lexical read here is just the suffix.
181        assert_eq!(expl3_slots("::n"), Some(vec![Group]));
182        assert_eq!(expl3_slots(":::"), Some(vec![]));
183    }
184
185    #[test]
186    fn conditional_branches_read_from_name_suffix() {
187        // Trailing `T`/`F` run in the argspec (after the final `:`) is the branch
188        // count; non-conditionals and colonless 2e names are `None`.
189        assert_eq!(conditional_branches("tl_if_empty:nTF"), Some(2));
190        assert_eq!(conditional_branches("bool_if:nT"), Some(1));
191        assert_eq!(conditional_branches("bool_if:nF"), Some(1));
192        assert_eq!(conditional_branches("str_if_eq:nnTF"), Some(2));
193        assert_eq!(conditional_branches("int_compare:nNnTF"), Some(2));
194        assert_eq!(conditional_branches("seq_map_inline:Nn"), None);
195        assert_eq!(conditional_branches("prg_return_true:"), None);
196        assert_eq!(conditional_branches("tl_new:N"), None);
197        // A LaTeX2e conditional has no `:`-argspec, so it is never matched (issue
198        // #94's `\@ifpackageloaded` stays on the width path).
199        assert_eq!(conditional_branches("@ifpackageloaded"), None);
200        assert_eq!(conditional_branches("IfBooleanTF"), None);
201    }
202
203    #[test]
204    fn branches_survive_underivable_arity() {
205        // The documented asymmetry: arity bows out, branch count must not.
206        assert_eq!(expl3_slots("odd_if:wTF"), None);
207        assert_eq!(conditional_branches("odd_if:wTF"), Some(2));
208    }
209}
210
211// --- Statement segmentation -------------------------------------------------
212//
213// Structural statement segmentation for expl3 code — the S4 mechanism.
214//
215// [`segment_expl_statements`] walks a stream of in-region sibling elements (a
216// paragraph run or a brace-group body) and decides, for every gap between
217// elements, whether a statement boundary sits there. The layout loop
218// (`lower_expl_code`) then commits logical lines where the map says, instead
219// of where the *author's* newlines fell — retiring the unsafe
220// newline-vs-space trivia read (the root of the K&R↔Allman idempotency
221// family; see `formatter.md`, § Trivia-invariant layout).
222//
223// A statement is a **call unit**: a head `COMMAND` whose name has a derivable
224// argspec arity ([`expl3_slots`]) plus the elements its slots consume.
225// Consumption is a pure shape scan — no `Ir` is built here — over two sources
226// in order: the head's own greedily-attached children (the parser attaches
227// every trailing `{…}` regardless of arity, decision #8), then the following
228// siblings. Greedy attachment routinely gives an argument to the *wrong
229// owner* (`\cs_new:Nn \foo:n {body}` attaches `{body}` to `\foo:n`); when a
230// `COMMAND` node satisfies a single-token slot, its own attached children are
231// *peeled* back onto the front of the scan queue so they can satisfy the
232// outer head's remaining slots. Only the head's argspec ever drives
233// consumption — an argument's own argspec is inert data, exactly as TeX
234// grabs it.
235//
236// The trivia the scan may read is confined to *preserved* predicates:
237// - a **blank line** (a gap of two or more newlines) ends the unit where it
238//   stands — the partial unit commits as-is, pass-stably, because blank-line
239//   presence is preserved by the formatter;
240// - a **comment** sharing a line with consumed material is transparent to
241//   consumption (the layout loop makes it end its physical line; the unit
242//   continues), while an **own-line** comment mid-unit ends the unit where it
243//   stands exactly like a blank line — its flanking newlines bound the gap
244//   (`advance` counts them across the skipped comment), so the partial unit
245//   commits pass-stably. When the comment rides *inside* a greedily-attached
246//   sibling, the committed unit still carries that sibling whole (boundaries
247//   never split a node), so the call's text stays together anyway. A comment
248//   trailing a *complete* unit is pulled into the statement so it stays on
249//   the call's line. Comment presence and own-line-ness are preserved
250//   predicates;
251// - a lone-newline-vs-space gap is **never** read on the structural path.
252//
253// Anything the shape scan cannot resolve — an unrecognized head (no `:`
254// suffix, or a `w`/`D`/unknown letter), a slot facing the wrong shape, a
255// docstrip `GUARD` mid-unit (guarded alternative bodies make arity lie,
256// issue #78), or the stream ending mid-unit — degrades that statement to the
257// **fallback**: the authored physical line is the statement, exactly the old
258// `SplitAtNewlines` behavior demoted to a per-line escape hatch (Tier 2; see
259// `formatter.md`, § Known violations). Recognition is re-attempted at every
260// statement start, so recognized and fallback statements interleave
261// deterministically; a recognized head *mid*-fallback-line is never split
262// out.
263
264/// The statement-boundary map for one element stream: `boundary_after(i)` says
265/// a statement ends in the gap after element `i`. Boundaries sit on whole
266/// top-level siblings — a boundary never splits a CST node, so anything the
267/// greedy parser over-attached to a consumed sibling rides along in its
268/// statement.
269pub struct StatementMap {
270    boundary_after: Vec<bool>,
271    glue_before: Vec<bool>,
272    glued: Vec<bool>,
273    fallback: Vec<bool>,
274}
275
276impl StatementMap {
277    /// Whether a statement boundary sits in the gap after element `idx`.
278    pub fn boundary_after(&self, idx: usize) -> bool {
279        self.boundary_after.get(idx).copied().unwrap_or(false)
280    }
281
282    /// Whether the gap *before* element `idx` must render unbreakable. Set for
283    /// a recognized-head `COMMAND` sitting mid-way through a fallback
284    /// statement: a width wrap at that gap would start a printed line with the
285    /// recognized head, which the next pass segments as its own statement
286    /// mid-way through this one and the passes disagree (`l3fp-trig.dtx`'s
287    /// `\@@_sep:`-delimited protocols, `xo-or.dtx`'s `=~ \exp_not:c {…}\space`
288    /// trace lines). Every other fallback gap stays breakable: a printed
289    /// continuation line starting with anything unrecognized re-segments to
290    /// exactly that line and renders to itself, the fallback's fixed point.
291    pub fn glue_before(&self, idx: usize) -> bool {
292        self.glue_before.get(idx).copied().unwrap_or(false)
293    }
294
295    /// Whether element `idx` belongs to a recognized statement that absorbed
296    /// trailing same-line material ([`absorb_trailing_junk`]) — a call unit
297    /// followed by unrecognized tokens or a comment on its authored line
298    /// (xparse's `\bool_if:NTF … { \cs_set:cpn } … ##1 \q_@@ …` definition
299    /// trickery). Such a statement renders with every top-level gap
300    /// unbreakable: its junk extent is newline-keyed (the fallback's Tier-2
301    /// residue), so a width wrap moving material across a line boundary would
302    /// change the extent — and with it the trailing-command glue decision —
303    /// on the next pass. All-hard gaps preserve the authored line shape
304    /// (node-internal layout still breaks freely and re-reads node-internal),
305    /// which is a fixed point by construction.
306    pub fn is_glued(&self, idx: usize) -> bool {
307        self.glued.get(idx).copied().unwrap_or(false)
308    }
309
310    /// Whether element `idx` belongs to a fallback statement. A fallback line
311    /// commits as a plain *greedy* fill, never the sticky fill structural
312    /// statements use: greedy packing is self-fulfilling (each printed line
313    /// re-segments to a fallback statement that re-fills to exactly itself),
314    /// while a sticky cascade forces atoms that would fit onto their own
315    /// broken lines — a shape the next pass's shorter per-line statements
316    /// do not reproduce.
317    pub fn is_fallback(&self, idx: usize) -> bool {
318        self.fallback.get(idx).copied().unwrap_or(false)
319    }
320}
321
322/// Segment an in-region element stream into statements. See the module docs
323/// for the model; the caller guarantees the stream is inside an expl3 region
324/// (so `:`/`_` were letters and names carry their argspec suffix).
325pub fn segment_expl_statements(elements: &[SyntaxElement]) -> StatementMap {
326    let mut boundary_after = vec![false; elements.len()];
327    let mut glue_before = vec![false; elements.len()];
328    let mut glued = vec![false; elements.len()];
329    let mut fallback = vec![false; elements.len()];
330    let mut i = 0;
331    while i < elements.len() {
332        match &elements[i] {
333            SyntaxElement::Token(t) if is_collapsible_trivia(t.kind()) => i += 1,
334            // A comment, guard, or doc margin between statements ends at its
335            // newline exactly as today (each is line-structured in the source);
336            // the boundary keeps the next statement off its line. Comment
337            // presence/own-line-ness and guard/margin column-0 are preserved
338            // predicates, so the read is sanctioned.
339            SyntaxElement::Token(t)
340                if matches!(
341                    t.kind(),
342                    SyntaxKind::COMMENT | SyntaxKind::GUARD | SyntaxKind::DOC_MARGIN
343                ) =>
344            {
345                if followed_by_newline(elements, i) {
346                    boundary_after[i] = true;
347                }
348                i += 1;
349            }
350            SyntaxElement::Node(n) if n.kind() == SyntaxKind::COMMAND => {
351                // A region toggle (`\ExplSyntaxOn`, `\ProvidesExplPackage`, …)
352                // is colonless but in the shared toggle name set and takes no
353                // trailing call-site material beyond its greedily-attached
354                // groups: a recognized zero-arity unit. Without this, every
355                // region's opening line would stay a newline-keyed fallback
356                // statement and strict trivia-invariance could never hold for
357                // any expl3 stream.
358                let slots = if node_is_expl_toggle(n) {
359                    Some(Vec::new())
360                } else {
361                    command_name(n).and_then(|name| expl3_slots(&name))
362                };
363                match slots.and_then(|slots| consume_unit(elements, i, &slots)) {
364                    Some(end) => {
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 index of the last
526/// sibling element the unit spans (the head itself for a zero-arity or
527/// entirely head-internal unit), or `None` to degrade to the fallback.
528fn consume_unit(elements: &[SyntaxElement], head_idx: usize, slots: &[Expl3Slot]) -> Option<usize> {
529    let head = elements[head_idx].as_node()?;
530    let mut cur = UnitCursor::new(elements, head_idx, head);
531    for slot in slots {
532        let took = match slot {
533            Expl3Slot::SingleToken => cur.take_single_token(),
534            Expl3Slot::Group | Expl3Slot::Branch => cur.take_group(),
535            Expl3Slot::ParameterText => cur.take_parameter_text(),
536        };
537        match took {
538            Ok(()) => {}
539            Err(Stop::End) => break,
540            Err(Stop::Abort) => return None,
541        }
542    }
543    Some(cur.last_sib)
544}
545
546/// The consumption cursor: candidates come from the peel **queue** first (an
547/// already-consumed `COMMAND`'s attached children), then from the sibling
548/// stream. Trivia, comments, and `~` are skipped in place (a `~` is a space
549/// token TeX skips before an undelimited argument, so it can never satisfy a
550/// slot — it stays in the extent for the layout loop's tilde arm).
551struct UnitCursor<'a> {
552    elements: &'a [SyntaxElement],
553    queue: VecDeque<SyntaxElement>,
554    /// Next sibling index to pull from.
555    sib: usize,
556    /// Last sibling index consumed into the unit — the unit's extent.
557    last_sib: usize,
558    /// A peeked candidate not yet consumed; the index is its sibling position
559    /// when it came from the sibling stream (`None` for queue candidates).
560    peeked: Option<(SyntaxElement, Option<usize>)>,
561}
562
563impl<'a> UnitCursor<'a> {
564    fn new(elements: &'a [SyntaxElement], head_idx: usize, head: &SyntaxNode) -> Self {
565        let mut cur = UnitCursor {
566            elements,
567            queue: VecDeque::new(),
568            sib: head_idx + 1,
569            last_sib: head_idx,
570            peeked: None,
571        };
572        cur.queue_children_after_name(head, false);
573        cur
574    }
575
576    /// Push `node`'s children after its name token onto the queue — at the
577    /// back when seeding from the head, at the **front** when peeling an
578    /// argument (its children must be scanned before later siblings).
579    fn queue_children_after_name(&mut self, node: &SyntaxNode, front: bool) {
580        let mut seen_name = false;
581        let mut after: Vec<SyntaxElement> = Vec::new();
582        for child in node.children_with_tokens() {
583            if seen_name {
584                after.push(child);
585            } else if matches!(
586                child.kind(),
587                SyntaxKind::CONTROL_WORD | SyntaxKind::CONTROL_SYMBOL
588            ) {
589                seen_name = true;
590            }
591        }
592        if front {
593            for el in after.into_iter().rev() {
594                self.queue.push_front(el);
595            }
596        } else {
597            self.queue.extend(after);
598        }
599    }
600
601    /// The next slot candidate, without consuming it.
602    fn peek(&mut self) -> Result<&SyntaxElement, Stop> {
603        if self.peeked.is_none() {
604            self.peeked = Some(self.advance()?);
605        }
606        Ok(&self.peeked.as_ref().expect("just filled").0)
607    }
608
609    /// Consume the next slot candidate, extending the unit over it.
610    fn bump(&mut self) -> Result<SyntaxElement, Stop> {
611        let (el, sib_idx) = match self.peeked.take() {
612            Some(peeked) => peeked,
613            None => self.advance()?,
614        };
615        if let Some(idx) = sib_idx {
616            self.last_sib = idx;
617        }
618        Ok(el)
619    }
620
621    /// Scan forward to the next candidate, skipping inline trivia, comments,
622    /// and `~`. A blank-line gap is [`Stop::End`]; a guard or doc margin
623    /// mid-unit, or the stream running out, is [`Stop::Abort`].
624    fn advance(&mut self) -> Result<(SyntaxElement, Option<usize>), Stop> {
625        let mut gap_newlines = 0usize;
626        loop {
627            let (el, sib_idx) = if let Some(el) = self.queue.pop_front() {
628                (el, None)
629            } else {
630                let Some(el) = self.elements.get(self.sib) else {
631                    return Err(Stop::Abort);
632                };
633                // A blank line must end the unit *before* it is crossed, so
634                // peek the newline count without consuming past it.
635                if let SyntaxElement::Token(t) = el
636                    && t.kind() == SyntaxKind::NEWLINE
637                    && gap_newlines >= 1
638                {
639                    return Err(Stop::End);
640                }
641                let idx = self.sib;
642                self.sib += 1;
643                (el.clone(), Some(idx))
644            };
645            match &el {
646                SyntaxElement::Token(t) if is_collapsible_trivia(t.kind()) => {
647                    if t.kind() == SyntaxKind::NEWLINE {
648                        gap_newlines += 1;
649                        if gap_newlines >= 2 {
650                            return Err(Stop::End);
651                        }
652                    }
653                }
654                SyntaxElement::Token(t) if t.kind() == SyntaxKind::COMMENT => {}
655                SyntaxElement::Token(t) if t.kind() == SyntaxKind::TILDE => {}
656                SyntaxElement::Token(t)
657                    if matches!(t.kind(), SyntaxKind::GUARD | SyntaxKind::DOC_MARGIN) =>
658                {
659                    return Err(Stop::Abort);
660                }
661                _ => return Ok((el, sib_idx)),
662            }
663        }
664    }
665
666    /// An `N`/`V` slot: one token — a control sequence, a single character, a
667    /// `#`-parameter, a braced group (TeX-faithful: braces around an `N`
668    /// argument are grabbed whole; `N` vs `n` is convention, not matching
669    /// behavior), or a `COMMAND` node whose *name* satisfies the slot and whose
670    /// greedily-attached children are peeled back for the head's remaining
671    /// slots.
672    fn take_single_token(&mut self) -> Result<(), Stop> {
673        let el = self.bump()?;
674        match &el {
675            SyntaxElement::Token(t)
676                if matches!(
677                    t.kind(),
678                    SyntaxKind::CONTROL_WORD | SyntaxKind::CONTROL_SYMBOL
679                ) =>
680            {
681                Ok(())
682            }
683            // A relation character: `\int_compare:nNnTF { … } = { 1 } {T} {F}`
684            // (issue #106). TeX grabs one character for an undelimited
685            // argument, so only a *single-character* `WORD` satisfies the slot
686            // — the lexer packs a run of characters into one token, and
687            // consuming a multi-character run would take material TeX leaves
688            // for the next slot. That shape aborts to the fallback instead.
689            SyntaxElement::Token(t)
690                if t.kind() == SyntaxKind::WORD && t.text().chars().count() == 1 =>
691            {
692                Ok(())
693            }
694            SyntaxElement::Token(t) if t.kind() == SyntaxKind::HASH => {
695                // `#1` (or `##1` in a nested definition): hash(es) plus one
696                // parameter digit read as one parameter token.
697                loop {
698                    let next = self.bump()?;
699                    match &next {
700                        SyntaxElement::Token(t) if t.kind() == SyntaxKind::HASH => {}
701                        SyntaxElement::Token(t)
702                            if t.kind() == SyntaxKind::WORD && is_param_digit(t) =>
703                        {
704                            return Ok(());
705                        }
706                        _ => return Err(Stop::Abort),
707                    }
708                }
709            }
710            SyntaxElement::Node(n) if n.kind() == SyntaxKind::COMMAND => {
711                self.queue_children_after_name(n, true);
712                Ok(())
713            }
714            SyntaxElement::Node(n) if n.kind() == SyntaxKind::GROUP => Ok(()),
715            _ => Err(Stop::Abort),
716        }
717    }
718
719    /// An `n`-family or `T`/`F` slot: exactly a braced group. A bare token is
720    /// legal TeX for an undelimited argument, but accepting it would let
721    /// sloppy shapes (and the `\::n` expansion-driver protocol) swallow the
722    /// next statement's head — those stay on the fallback path instead.
723    fn take_group(&mut self) -> Result<(), Stop> {
724        let el = self.bump()?;
725        match &el {
726            SyntaxElement::Node(n) if n.kind() == SyntaxKind::GROUP => Ok(()),
727            _ => Err(Stop::Abort),
728        }
729    }
730
731    /// A `p` slot: TeX parameter text — everything up to (not including) the
732    /// first explicit `{`, which is left for the following slot. Tokens and
733    /// `[…]` are parameter text; a control sequence delimiting the text
734    /// (`#1 \q_stop {body}`) has its own over-attached children peeled, so the
735    /// terminating group is found wherever greedy attachment put it. The
736    /// `#{`-terminated form works out to the same rule (the `{` opens the
737    /// replacement text).
738    fn take_parameter_text(&mut self) -> Result<(), Stop> {
739        loop {
740            if let SyntaxElement::Node(n) = self.peek()?
741                && n.kind() == SyntaxKind::GROUP
742            {
743                return Ok(());
744            }
745            let el = self.bump()?;
746            match &el {
747                SyntaxElement::Token(_) => {}
748                SyntaxElement::Node(n) if n.kind() == SyntaxKind::COMMAND => {
749                    self.queue_children_after_name(n, true);
750                }
751                SyntaxElement::Node(n) if n.kind() == SyntaxKind::OPTIONAL => {}
752                _ => return Err(Stop::Abort),
753            }
754        }
755    }
756}
757
758#[cfg(test)]
759mod segmentation_tests {
760    use super::*;
761    use crate::parser::parse;
762    use crate::syntax::SyntaxNode;
763
764    /// Segment the first paragraph of `src` (which must open with
765    /// `\ExplSyntaxOn` so the lexer treats `:`/`_` as letters) and render each
766    /// statement's source text with whitespace collapsed, for stable
767    /// assertions.
768    fn statements(src: &str) -> Vec<String> {
769        let parsed = parse(src);
770        assert!(parsed.errors.is_empty(), "test source should parse cleanly");
771        let root = SyntaxNode::new_root(parsed.green);
772        let para = root
773            .children()
774            .find(|n| n.kind() == SyntaxKind::PARAGRAPH)
775            .expect("a paragraph");
776        let elements: Vec<SyntaxElement> = para.children_with_tokens().collect();
777        statement_texts(&elements)
778    }
779
780    fn statement_texts(elements: &[SyntaxElement]) -> Vec<String> {
781        let map = segment_expl_statements(elements);
782        let mut out = Vec::new();
783        let mut cur = String::new();
784        for (i, el) in elements.iter().enumerate() {
785            cur.push_str(&el.to_string());
786            if map.boundary_after(i) {
787                let text = normalize(&cur);
788                if !text.is_empty() {
789                    out.push(text);
790                }
791                cur.clear();
792            }
793        }
794        let tail = normalize(&cur);
795        if !tail.is_empty() {
796            out.push(tail);
797        }
798        out
799    }
800
801    fn normalize(s: &str) -> String {
802        s.split_whitespace().collect::<Vec<_>>().join(" ")
803    }
804
805    #[test]
806    fn statements_are_structural_units() {
807        // Mid-call newlines join; the colonless toggles fall back per-line.
808        let got = statements(
809            "\\ExplSyntaxOn\n\\tl_set:Nn \\l_a\n  { x }\n\\group_begin:\n\\ExplSyntaxOff\n",
810        );
811        assert_eq!(
812            got,
813            vec![
814                "\\ExplSyntaxOn",
815                "\\tl_set:Nn \\l_a { x }",
816                "\\group_begin:",
817                "\\ExplSyntaxOff",
818            ]
819        );
820    }
821
822    #[test]
823    fn same_line_calls_split() {
824        let got =
825            statements("\\ExplSyntaxOn\n\\group_begin: \\int_zero:N \\l_a\n\\ExplSyntaxOff\n");
826        assert_eq!(
827            got,
828            vec![
829                "\\ExplSyntaxOn",
830                "\\group_begin:",
831                "\\int_zero:N \\l_a",
832                "\\ExplSyntaxOff",
833            ]
834        );
835    }
836
837    #[test]
838    fn npn_definition_is_one_unit() {
839        // `N` takes `\foo:n`, `p` scans `#1`, `n` takes the body — across the
840        // authored Allman break.
841        let got =
842            statements("\\ExplSyntaxOn\n\\cs_new:Npn \\foo:n #1\n  { body #1 }\n\\ExplSyntaxOff\n");
843        assert_eq!(
844            got,
845            vec![
846                "\\ExplSyntaxOn",
847                "\\cs_new:Npn \\foo:n #1 { body #1 }",
848                "\\ExplSyntaxOff",
849            ]
850        );
851    }
852
853    #[test]
854    fn peel_back_reclaims_over_attached_group() {
855        // Greedy attachment gives `{ body }` to `\foo:n`; the `N` slot takes
856        // the name and the peeled group satisfies the outer `n` slot.
857        let got = statements("\\ExplSyntaxOn\n\\cs_new:Nn \\foo:n\n  { body }\n\\ExplSyntaxOff\n");
858        assert_eq!(
859            got,
860            vec![
861                "\\ExplSyntaxOn",
862                "\\cs_new:Nn \\foo:n { body }",
863                "\\ExplSyntaxOff",
864            ]
865        );
866    }
867
868    #[test]
869    fn exp_args_chain_is_one_unit() {
870        let got = statements(
871            "\\ExplSyntaxOn\n\\exp_args:NNo \\tl_set:Nn \\l_a { \\l_b }\n\\ExplSyntaxOff\n",
872        );
873        assert_eq!(
874            got,
875            vec![
876                "\\ExplSyntaxOn",
877                "\\exp_args:NNo \\tl_set:Nn \\l_a { \\l_b }",
878                "\\ExplSyntaxOff",
879            ]
880        );
881    }
882
883    #[test]
884    fn hash_parameter_satisfies_single_token_slot() {
885        let got = statements("\\ExplSyntaxOn\n\\tl_set:Nn #1 { x }\n\\ExplSyntaxOff\n");
886        assert_eq!(
887            got,
888            vec!["\\ExplSyntaxOn", "\\tl_set:Nn #1 { x }", "\\ExplSyntaxOff"]
889        );
890    }
891
892    #[test]
893    fn relation_character_satisfies_single_token_slot() {
894        // `\int_compare:nNnTF`'s `N` slot is the relation `=` (issue #106).
895        // Without it the whole conditional degraded to the newline-keyed
896        // fallback, so the trailing call's line was authored, not derived.
897        let got = statements(
898            "\\ExplSyntaxOn\n\\int_compare:nNnTF { \\l_a } = { 1 } { yes } { no } \\foo:\n\\ExplSyntaxOff\n",
899        );
900        assert_eq!(
901            got,
902            vec![
903                "\\ExplSyntaxOn",
904                "\\int_compare:nNnTF { \\l_a } = { 1 } { yes } { no }",
905                "\\foo:",
906                "\\ExplSyntaxOff",
907            ]
908        );
909    }
910
911    #[test]
912    fn relation_character_unit_is_newline_invariant() {
913        // The same call broken across lines segments identically — the point
914        // of the structural model.
915        let inline = statements(
916            "\\ExplSyntaxOn\n\\int_compare:nNnTF { \\l_a } = { 1 } { yes } { no } \\foo:\n\\ExplSyntaxOff\n",
917        );
918        let broken = statements(
919            "\\ExplSyntaxOn\n\\int_compare:nNnTF { \\l_a } = { 1 }\n  { yes } { no }\n\\foo:\n\\ExplSyntaxOff\n",
920        );
921        assert_eq!(inline, broken);
922    }
923
924    #[test]
925    fn multi_character_word_does_not_satisfy_single_token_slot() {
926        // TeX grabs one character for an undelimited argument, so a lexed run
927        // of characters is the wrong shape and degrades to the fallback (here:
928        // the authored line).
929        let got = statements(
930            "\\ExplSyntaxOn\n\\int_compare:nNnT { \\l_a } <= { 1 } { yes }\n\\foo:\n\\ExplSyntaxOff\n",
931        );
932        assert_eq!(
933            got,
934            vec![
935                "\\ExplSyntaxOn",
936                "\\int_compare:nNnT { \\l_a } <= { 1 } { yes }",
937                "\\foo:",
938                "\\ExplSyntaxOff",
939            ]
940        );
941    }
942
943    #[test]
944    fn delimited_parameter_text_peels_the_body() {
945        // `{ body }` greedily attached to `\q_stop`; the p-scan peels it and
946        // stops there, leaving it for the trailing `n` slot.
947        let got = statements(
948            "\\ExplSyntaxOn\n\\cs_new:Npn \\foo:w #1 \\q_stop { body }\n\\ExplSyntaxOff\n",
949        );
950        assert_eq!(
951            got,
952            vec![
953                "\\ExplSyntaxOn",
954                "\\cs_new:Npn \\foo:w #1 \\q_stop { body }",
955                "\\ExplSyntaxOff",
956            ]
957        );
958    }
959
960    #[test]
961    fn unknown_head_falls_back_to_its_line() {
962        // `\exp_after:wN` has no derivable arity: its authored line is the
963        // statement, and the recognized call sharing that line is not split out.
964        let got = statements(
965            "\\ExplSyntaxOn\n\\exp_after:wN \\foo \\tl_set:Nn \\l_a { x }\n\\group_begin:\n\\ExplSyntaxOff\n",
966        );
967        assert_eq!(
968            got,
969            vec![
970                "\\ExplSyntaxOn",
971                "\\exp_after:wN \\foo \\tl_set:Nn \\l_a { x }",
972                "\\group_begin:",
973                "\\ExplSyntaxOff",
974            ]
975        );
976    }
977
978    #[test]
979    fn shape_mismatch_falls_back() {
980        // The `n` slot faces a command, not a group: the whole statement
981        // degrades to newline splitting rather than swallowing the next head.
982        let got = statements("\\ExplSyntaxOn\n\\tl_set:Nn\n\\l_a\n\\ExplSyntaxOff\n");
983        assert_eq!(
984            got,
985            vec!["\\ExplSyntaxOn", "\\tl_set:Nn", "\\l_a", "\\ExplSyntaxOff"]
986        );
987    }
988
989    #[test]
990    fn trailing_comment_rides_the_statement() {
991        let got = statements("\\ExplSyntaxOn\n\\tl_set:Nn \\l_a { x } % note\n\\ExplSyntaxOff\n");
992        assert_eq!(
993            got,
994            vec![
995                "\\ExplSyntaxOn",
996                "\\tl_set:Nn \\l_a { x } % note",
997                "\\ExplSyntaxOff",
998            ]
999        );
1000    }
1001
1002    #[test]
1003    fn leftover_attached_group_rides_the_statement() {
1004        // `\use:n` has arity 1; the second group is over-attached to the head
1005        // node, and boundaries never split a node, so it stays in the unit.
1006        let got = statements("\\ExplSyntaxOn\n\\use:n { a } { b }\n\\ExplSyntaxOff\n");
1007        assert_eq!(
1008            got,
1009            vec!["\\ExplSyntaxOn", "\\use:n { a } { b }", "\\ExplSyntaxOff"]
1010        );
1011    }
1012
1013    #[test]
1014    fn conditional_call_is_one_unit() {
1015        let got = statements(
1016            "\\ExplSyntaxOn\n\\str_if_eq:nnTF { a } { b }\n  { yes }\n  { no }\n\\ExplSyntaxOff\n",
1017        );
1018        assert_eq!(
1019            got,
1020            vec![
1021                "\\ExplSyntaxOn",
1022                "\\str_if_eq:nnTF { a } { b } { yes } { no }",
1023                "\\ExplSyntaxOff",
1024            ]
1025        );
1026    }
1027
1028    #[test]
1029    fn blank_line_ends_the_unit() {
1030        // Inside a group body a blank line can sit mid-call: the unit commits
1031        // as-is before it, and the stranded group starts a fresh statement.
1032        let src = "\\ExplSyntaxOn\n\\use:n { \\tl_set:Nn \\l_a\n\n  { x } }\n\\ExplSyntaxOff\n";
1033        let parsed = parse(src);
1034        assert!(parsed.errors.is_empty());
1035        let root = SyntaxNode::new_root(parsed.green);
1036        let group = root
1037            .descendants()
1038            .find(|n| n.kind() == SyntaxKind::GROUP)
1039            .expect("a group");
1040        let body: Vec<SyntaxElement> = group
1041            .children_with_tokens()
1042            .filter(|el| !matches!(el.kind(), SyntaxKind::L_BRACE | SyntaxKind::R_BRACE))
1043            .collect();
1044        assert_eq!(statement_texts(&body), vec!["\\tl_set:Nn \\l_a", "{ x }"]);
1045    }
1046
1047    #[test]
1048    fn guard_mid_unit_aborts_to_fallback() {
1049        // A docstrip guard inside the unit (issue #78: guarded alternative
1050        // bodies make arity lie) aborts consumption; the statement degrades to
1051        // the fallback, and the guard-bearing sibling rides it whole because
1052        // boundaries never split a node.
1053        use crate::parser::lexer::LexConfig;
1054        use crate::parser::{LatexFlavor, parse_with_flavor};
1055        let src = "% \\begin{macrocode}\n\\ExplSyntaxOn\n\\tl_set:Nn \\l_a\n%<latexrelease>  { x }\n\\ExplSyntaxOff\n% \\end{macrocode}\n";
1056        let config = LexConfig {
1057            flavor: LatexFlavor::Package,
1058            dtx: true,
1059        };
1060        let parsed = parse_with_flavor(src, config);
1061        assert!(parsed.errors.is_empty(), "test source should parse cleanly");
1062        let root = SyntaxNode::new_root(parsed.green);
1063        let para = root
1064            .descendants()
1065            .find(|n| n.kind() == SyntaxKind::PARAGRAPH)
1066            .expect("a paragraph");
1067        let elements: Vec<SyntaxElement> = para.children_with_tokens().collect();
1068        let map = segment_expl_statements(&elements);
1069        assert_eq!(
1070            statement_texts(&elements),
1071            vec![
1072                "\\ExplSyntaxOn",
1073                "\\tl_set:Nn \\l_a %<latexrelease> { x }",
1074                "\\ExplSyntaxOff",
1075            ]
1076        );
1077        let guarded_end = elements
1078            .iter()
1079            .position(|el| el.to_string().contains("latexrelease"))
1080            .expect("the guarded sibling");
1081        assert!(
1082            map.is_fallback(guarded_end),
1083            "the aborted unit must be a fallback statement"
1084        );
1085    }
1086
1087    #[test]
1088    fn e_and_f_letters_consume_braced_groups() {
1089        let got = statements(
1090            "\\ExplSyntaxOn\n\\tl_set:Ne \\l_a\n  { x }\n\\tl_set:Nf \\l_b\n  { y }\n\\ExplSyntaxOff\n",
1091        );
1092        assert_eq!(
1093            got,
1094            vec![
1095                "\\ExplSyntaxOn",
1096                "\\tl_set:Ne \\l_a { x }",
1097                "\\tl_set:Nf \\l_b { y }",
1098                "\\ExplSyntaxOff",
1099            ]
1100        );
1101    }
1102
1103    #[test]
1104    fn stream_ending_mid_unit_falls_back() {
1105        // The `n` slot is still open when the group body runs out: the unit
1106        // aborts to the fallback rather than committing a partial unit.
1107        let src = "\\ExplSyntaxOn\n\\use:n { \\tl_set:Nn \\l_a }\n\\ExplSyntaxOff\n";
1108        let parsed = parse(src);
1109        assert!(parsed.errors.is_empty());
1110        let root = SyntaxNode::new_root(parsed.green);
1111        let group = root
1112            .descendants()
1113            .find(|n| n.kind() == SyntaxKind::GROUP)
1114            .expect("a group");
1115        let body: Vec<SyntaxElement> = group
1116            .children_with_tokens()
1117            .filter(|el| !matches!(el.kind(), SyntaxKind::L_BRACE | SyntaxKind::R_BRACE))
1118            .collect();
1119        let map = segment_expl_statements(&body);
1120        assert_eq!(statement_texts(&body), vec!["\\tl_set:Nn \\l_a"]);
1121        let head = body
1122            .iter()
1123            .position(|el| el.as_node().is_some())
1124            .expect("the head command");
1125        assert!(
1126            map.is_fallback(head),
1127            "a unit cut off by the stream end must be a fallback statement"
1128        );
1129    }
1130
1131    #[test]
1132    fn a_multi_line_group_node_does_not_end_a_fallback_line() {
1133        // [`fallback_line`] scans *sibling* `NEWLINE` tokens only, so a group
1134        // whose body spans several source lines carries those newlines inside
1135        // the node and the fallback statement runs straight past it: the group
1136        // and the following recognized head are one statement, and that head
1137        // still owes an unbreakable `glue_before` space. The formatter's
1138        // hanging-group dispatch relies on this — a forced-break commit there
1139        // would split a pair the segmentation kept together (latex2e's
1140        // `lipsum.sty`).
1141        // The `>` keeps the block a *sibling* of the head rather than a
1142        // greedily-attached argument, as in `\int_do_until:nNnn`'s real shape.
1143        let src = "\\ExplSyntaxOn\n\
1144                   \\int_do_until:w { \\l_tmpa_int } > {#2}\n\
1145                   { \\lipsum_add:V { \\l_tmpa_int }\n\
1146                   \\int_incr:N \\l_tmpa_int } \\tl_put_right:NV \\l_a \\l_b\n\
1147                   \\ExplSyntaxOff\n";
1148        let parsed = parse(src);
1149        assert!(parsed.errors.is_empty());
1150        let root = SyntaxNode::new_root(parsed.green);
1151        let elements: Vec<SyntaxElement> = root
1152            .first_child()
1153            .expect("the paragraph")
1154            .children_with_tokens()
1155            .collect();
1156        let map = segment_expl_statements(&elements);
1157
1158        // `\int_do_until:w` is underivable (`w`), so its line degrades to the
1159        // fallback. The block starts the next fallback line, which then runs
1160        // past the block's *internal* newlines and absorbs the
1161        // `\tl_put_right:NV` call sharing the block's closing line.
1162        assert_eq!(
1163            statement_texts(&elements),
1164            vec![
1165                "\\ExplSyntaxOn",
1166                "\\int_do_until:w { \\l_tmpa_int } > {#2}",
1167                "{ \\lipsum_add:V { \\l_tmpa_int } \\int_incr:N \\l_tmpa_int } \
1168                 \\tl_put_right:NV \\l_a \\l_b",
1169                "\\ExplSyntaxOff",
1170            ]
1171        );
1172
1173        let group = elements
1174            .iter()
1175            .position(|el| el.kind() == SyntaxKind::GROUP && el.to_string().contains('\n'))
1176            .expect("the multi-line group");
1177        assert!(
1178            map.is_fallback(group),
1179            "the group belongs to a fallback statement"
1180        );
1181        assert!(
1182            !map.boundary_after(group),
1183            "a multi-line group's own newlines must not end the fallback line"
1184        );
1185
1186        let head = elements
1187            .iter()
1188            .skip(group)
1189            .position(|el| {
1190                el.as_node()
1191                    .is_some_and(|n| n.kind() == SyntaxKind::COMMAND)
1192            })
1193            .map(|off| group + off)
1194            .expect("the trailing recognized head");
1195        assert!(
1196            map.glue_before(head),
1197            "a recognized head mid-fallback-line owes an unbreakable gap"
1198        );
1199    }
1200
1201    #[test]
1202    fn own_line_comment_in_attached_span_rides_the_sibling() {
1203        // The own-line comment's flanking newlines bound the gap like a blank
1204        // line, ending the unit at the `N` slot — but greedy attachment put
1205        // the comment *and* the group inside the `\l_a` sibling, and
1206        // boundaries never split a node, so the committed partial unit still
1207        // carries the whole sibling. Pass-stable either way (comment
1208        // own-line-ness is a preserved predicate).
1209        let got =
1210            statements("\\ExplSyntaxOn\n\\tl_set:Nn \\l_a\n% note\n  { x }\n\\ExplSyntaxOff\n");
1211        assert_eq!(
1212            got,
1213            vec![
1214                "\\ExplSyntaxOn",
1215                "\\tl_set:Nn \\l_a % note { x }",
1216                "\\ExplSyntaxOff",
1217            ]
1218        );
1219    }
1220
1221    #[test]
1222    fn own_line_comment_at_sibling_level_ends_the_unit() {
1223        // Before a candidate no comment can bind to (`#1` parameter text, not
1224        // a `COMMAND`), the own-line comment stays a sibling: the unit ends at
1225        // the gap, the comment keeps its own line, and the leftover material
1226        // falls back per-line.
1227        let got = statements(
1228            "\\ExplSyntaxOn\n\\cs_new:Npn \\foo:n\n% note\n#1 { body }\n\\ExplSyntaxOff\n",
1229        );
1230        assert_eq!(
1231            got,
1232            vec![
1233                "\\ExplSyntaxOn",
1234                "\\cs_new:Npn \\foo:n",
1235                "% note",
1236                "#1 { body }",
1237                "\\ExplSyntaxOff",
1238            ]
1239        );
1240    }
1241}