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