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}