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