Skip to main content

badness_parser/semantic/
expl3.rs

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