1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Expl3Slot {
52 SingleToken,
54 Group,
56 Branch,
59 ParameterText,
62}
63
64pub 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 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
96pub 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 assert_eq!(expl3_slots("use_none_delimit_by_q_stop:w"), None);
158 assert_eq!(expl3_slots("exp_after:wN"), None);
159 assert_eq!(expl3_slots("tex_relax:D"), None);
160 assert_eq!(expl3_slots("odd:TnF"), None);
161 assert_eq!(expl3_slots("odd:nZn"), None);
162 }
163
164 #[test]
165 fn colonless_names_are_unrecognized() {
166 assert_eq!(expl3_slots("def"), None);
167 assert_eq!(expl3_slots("@ifpackageloaded"), None);
168 assert_eq!(expl3_slots("IfBooleanTF"), None);
169 assert_eq!(expl3_slots("l_tmpa_tl"), None);
170 }
171
172 #[test]
173 fn exp_internal_drivers() {
174 assert_eq!(expl3_slots("::n"), Some(vec![Group]));
175 assert_eq!(expl3_slots(":::"), Some(vec![]));
176 }
177
178 #[test]
179 fn conditional_branches_read_from_name_suffix() {
180 assert_eq!(conditional_branches("tl_if_empty:nTF"), Some(2));
181 assert_eq!(conditional_branches("bool_if:nT"), Some(1));
182 assert_eq!(conditional_branches("bool_if:nF"), Some(1));
183 assert_eq!(conditional_branches("str_if_eq:nnTF"), Some(2));
184 assert_eq!(conditional_branches("int_compare:nNnTF"), Some(2));
185 assert_eq!(conditional_branches("seq_map_inline:Nn"), None);
186 assert_eq!(conditional_branches("prg_return_true:"), None);
187 assert_eq!(conditional_branches("tl_new:N"), None);
188 assert_eq!(conditional_branches("@ifpackageloaded"), None);
189 assert_eq!(conditional_branches("IfBooleanTF"), None);
190 }
191
192 #[test]
193 fn branches_survive_underivable_arity() {
194 assert_eq!(expl3_slots("odd_if:wTF"), None);
195 assert_eq!(conditional_branches("odd_if:wTF"), Some(2));
196 }
197}
198
199pub struct StatementMap {
201 flags: Vec<ElementFlags>,
202}
203
204#[derive(Clone, Copy, Default)]
205struct ElementFlags(u8);
206
207impl ElementFlags {
208 const BOUNDARY_AFTER: u8 = 1 << 0;
209 const GLUE_BEFORE: u8 = 1 << 1;
210 const GLUED: u8 = 1 << 2;
211 const FALLBACK: u8 = 1 << 3;
212
213 fn contains(self, flag: u8) -> bool {
214 self.0 & flag != 0
215 }
216
217 fn insert(&mut self, flag: u8) {
218 self.0 |= flag;
219 }
220}
221
222impl StatementMap {
223 pub fn boundary_after(&self, idx: usize) -> bool {
224 self.flags
225 .get(idx)
226 .is_some_and(|flags| flags.contains(ElementFlags::BOUNDARY_AFTER))
227 }
228
229 pub fn glue_before(&self, idx: usize) -> bool {
230 self.flags
231 .get(idx)
232 .is_some_and(|flags| flags.contains(ElementFlags::GLUE_BEFORE))
233 }
234
235 pub fn is_glued(&self, idx: usize) -> bool {
236 self.flags
237 .get(idx)
238 .is_some_and(|flags| flags.contains(ElementFlags::GLUED))
239 }
240
241 pub fn is_fallback(&self, idx: usize) -> bool {
242 self.flags
243 .get(idx)
244 .is_some_and(|flags| flags.contains(ElementFlags::FALLBACK))
245 }
246}
247
248pub fn segment_expl_statements(elements: &[SyntaxElement]) -> StatementMap {
250 let mut flags = vec![ElementFlags::default(); elements.len()];
251 let mut i = 0;
252 while i < elements.len() {
253 match &elements[i] {
254 SyntaxElement::Token(t) if is_collapsible_trivia(t.kind()) => i += 1,
255 SyntaxElement::Token(t)
256 if matches!(
257 t.kind(),
258 SyntaxKind::COMMENT | SyntaxKind::GUARD | SyntaxKind::DOC_MARGIN
259 ) =>
260 {
261 if followed_by_newline(elements, i) {
262 flags[i].insert(ElementFlags::BOUNDARY_AFTER);
263 }
264 i += 1;
265 }
266 SyntaxElement::Node(n) if n.kind() == SyntaxKind::COMMAND => {
267 match expl3_unit(elements, i) {
268 Some(unit) => {
269 let end = unit.last;
270 let full = absorb_trailing_junk(elements, end);
271 if full > end {
272 for flags in &mut flags[i..=full] {
273 flags.insert(ElementFlags::GLUED);
274 }
275 }
276 flags[full].insert(ElementFlags::BOUNDARY_AFTER);
277 i = full + 1;
278 }
279 None => i = fallback_line(elements, i, &mut flags),
280 }
281 }
282 _ => i = fallback_line(elements, i, &mut flags),
283 }
284 }
285 StatementMap { flags }
286}
287
288fn node_is_expl_toggle(node: &SyntaxNode) -> bool {
289 node.children_with_tokens()
290 .filter_map(|el| el.into_token())
291 .find(|t| t.kind() == SyntaxKind::CONTROL_WORD)
292 .is_some_and(|t| expl_toggle(t.text()).is_some())
293}
294
295fn is_recognized_head(node: &SyntaxNode) -> bool {
296 node.kind() == SyntaxKind::COMMAND
297 && (node_is_expl_toggle(node)
298 || command_name(node).is_some_and(|name| expl3_slots(&name).is_some()))
299}
300
301fn followed_by_newline(elements: &[SyntaxElement], idx: usize) -> bool {
302 for element in &elements[idx + 1..] {
303 match element {
304 SyntaxElement::Token(t) if t.kind() == SyntaxKind::WHITESPACE => {}
305 SyntaxElement::Token(t) if t.kind() == SyntaxKind::NEWLINE => return true,
306 _ => return false,
307 }
308 }
309 true
310}
311
312fn fallback_line(elements: &[SyntaxElement], start: usize, flags: &mut [ElementFlags]) -> usize {
313 let mut last = start;
314 let mut j = start;
315 while j < elements.len() {
316 match &elements[j] {
317 SyntaxElement::Token(t) if is_collapsible_trivia(t.kind()) => {
318 if t.kind() == SyntaxKind::NEWLINE {
319 flags[last].insert(ElementFlags::BOUNDARY_AFTER);
320 for flags in &mut flags[start..=last] {
321 flags.insert(ElementFlags::FALLBACK);
322 }
323 return j;
324 }
325 j += 1;
326 }
327 element => {
328 if j > start
329 && let SyntaxElement::Node(n) = element
330 && is_recognized_head(n)
331 {
332 flags[j].insert(ElementFlags::GLUE_BEFORE);
333 }
334 last = j;
335 j += 1;
336 if let SyntaxElement::Node(n) = element
337 && n.kind() == SyntaxKind::COMMAND
338 && node_carries_bare_line_break(n)
339 {
340 flags[last].insert(ElementFlags::BOUNDARY_AFTER);
341 for flags in &mut flags[start..=last] {
342 flags.insert(ElementFlags::FALLBACK);
343 }
344 return j;
345 }
346 }
347 }
348 }
349 flags[last].insert(ElementFlags::BOUNDARY_AFTER);
350 for flags in &mut flags[start..=last] {
351 flags.insert(ElementFlags::FALLBACK);
352 }
353 elements.len()
354}
355
356fn node_carries_bare_line_break(node: &SyntaxNode) -> bool {
357 let mut after_newline = false;
358 for child in node.children_with_tokens() {
359 match &child {
360 SyntaxElement::Token(t) if t.kind() == SyntaxKind::NEWLINE => after_newline = true,
361 SyntaxElement::Token(t) if is_collapsible_trivia(t.kind()) => {}
362 SyntaxElement::Node(n)
363 if matches!(n.kind(), SyntaxKind::GROUP | SyntaxKind::OPTIONAL) =>
364 {
365 after_newline = false;
366 }
367 _ => {
368 if after_newline {
369 return true;
370 }
371 }
372 }
373 }
374 false
375}
376
377fn absorb_trailing_junk(elements: &[SyntaxElement], end: usize) -> usize {
378 let mut end = end;
379 let mut j = end + 1;
380 while j < elements.len() {
381 match &elements[j] {
382 SyntaxElement::Token(t) if is_collapsible_trivia(t.kind()) => {
383 if t.kind() == SyntaxKind::NEWLINE {
384 break;
385 }
386 j += 1;
387 }
388 SyntaxElement::Token(t) if t.kind() == SyntaxKind::COMMENT => {
389 end = j;
390 break;
391 }
392 SyntaxElement::Token(t)
393 if matches!(t.kind(), SyntaxKind::GUARD | SyntaxKind::DOC_MARGIN) =>
394 {
395 break;
396 }
397 SyntaxElement::Node(n) if n.kind() == SyntaxKind::GROUP => break,
398 SyntaxElement::Node(n) if is_recognized_head(n) => {
399 break;
400 }
401 _ => {
402 end = j;
403 j += 1;
404 }
405 }
406 }
407 end
408}
409
410enum Stop {
411 End,
412 Abort,
413}
414
415fn consume_unit(
416 elements: &[SyntaxElement],
417 head_idx: usize,
418 slots: &[Expl3Slot],
419) -> Option<Expl3Unit> {
420 let head = elements[head_idx].as_node()?;
421 let mut cur = UnitCursor::new(elements, head_idx, head);
422 let mut branches = Vec::new();
423 let mut complete = true;
424 for slot in slots {
425 let took = match slot {
426 Expl3Slot::SingleToken => cur.take_single_token(),
427 Expl3Slot::Group => cur.take_group().map(|_| ()),
428 Expl3Slot::Branch => cur.take_group().map(|el| branches.push(el.text_range())),
429 Expl3Slot::ParameterText => cur.take_parameter_text(),
430 };
431 match took {
432 Ok(()) => {}
433 Err(Stop::End) => {
434 complete = false;
435 break;
436 }
437 Err(Stop::Abort) => return None,
438 }
439 }
440 cur.extend_over_attachable_tail();
441 Some(Expl3Unit {
442 last: cur.last_sib,
443 branches: if complete { branches } else { Vec::new() },
444 })
445}
446
447#[derive(Debug, Clone, PartialEq, Eq)]
448pub struct Expl3Unit {
450 pub last: usize,
451 pub branches: Vec<TextRange>,
452}
453
454pub fn expl3_unit(elements: &[SyntaxElement], head_idx: usize) -> Option<Expl3Unit> {
456 let node = elements.get(head_idx)?.as_node()?;
457 if !is_recognized_head(node) {
458 return None;
459 }
460 let slots = if node_is_expl_toggle(node) {
461 Vec::new()
462 } else {
463 expl3_slots(&command_name(node)?)?
464 };
465 consume_unit(elements, head_idx, &slots)
466}
467
468struct UnitCursor<'a> {
469 elements: &'a [SyntaxElement],
470 queue: VecDeque<SyntaxElement>,
471 sib: usize,
472 last_sib: usize,
473 peeked: Option<(SyntaxElement, Option<usize>)>,
474 chain: bool,
475}
476
477impl<'a> UnitCursor<'a> {
478 fn new(elements: &'a [SyntaxElement], head_idx: usize, head: &SyntaxNode) -> Self {
479 let mut cur = UnitCursor {
480 elements,
481 queue: VecDeque::new(),
482 sib: head_idx + 1,
483 last_sib: head_idx,
484 peeked: None,
485 chain: true,
486 };
487 cur.queue_children_after_name(head, false);
488 cur
489 }
490
491 fn queue_children_after_name(&mut self, node: &SyntaxNode, front: bool) {
492 let mut seen_name = false;
493 let mut after: Vec<SyntaxElement> = Vec::new();
494 for child in node.children_with_tokens() {
495 if seen_name {
496 after.push(child);
497 } else if matches!(
498 child.kind(),
499 SyntaxKind::CONTROL_WORD | SyntaxKind::CONTROL_SYMBOL
500 ) {
501 seen_name = true;
502 }
503 }
504 if front {
505 for el in after.into_iter().rev() {
506 self.queue.push_front(el);
507 }
508 } else {
509 self.queue.extend(after);
510 }
511 }
512
513 fn peek(&mut self) -> Result<&SyntaxElement, Stop> {
514 if self.peeked.is_none() {
515 self.peeked = Some(self.advance()?);
516 }
517 Ok(&self.peeked.as_ref().expect("just filled").0)
518 }
519
520 fn bump(&mut self) -> Result<SyntaxElement, Stop> {
521 let (el, sib_idx) = match self.peeked.take() {
522 Some(peeked) => peeked,
523 None => self.advance()?,
524 };
525 if let Some(idx) = sib_idx {
526 self.last_sib = idx;
527 }
528 Ok(el)
529 }
530
531 fn advance(&mut self) -> Result<(SyntaxElement, Option<usize>), Stop> {
532 let mut gap_newlines = 0usize;
533 loop {
534 let (el, sib_idx) = if let Some(el) = self.queue.pop_front() {
535 (el, None)
536 } else {
537 let Some(el) = self.elements.get(self.sib) else {
538 return Err(Stop::Abort);
539 };
540 if let SyntaxElement::Token(t) = el
541 && t.kind() == SyntaxKind::NEWLINE
542 && gap_newlines >= 1
543 {
544 return Err(Stop::End);
545 }
546 let idx = self.sib;
547 self.sib += 1;
548 (el.clone(), Some(idx))
549 };
550 match &el {
551 SyntaxElement::Token(t) if is_collapsible_trivia(t.kind()) => {
552 if t.kind() == SyntaxKind::NEWLINE {
553 gap_newlines += 1;
554 if gap_newlines >= 2 {
555 return Err(Stop::End);
556 }
557 }
558 }
559 SyntaxElement::Token(t) if t.kind() == SyntaxKind::COMMENT => {}
560 SyntaxElement::Token(t) if t.kind() == SyntaxKind::TILDE => {}
561 SyntaxElement::Token(t)
562 if matches!(t.kind(), SyntaxKind::GUARD | SyntaxKind::DOC_MARGIN) =>
563 {
564 return Err(Stop::Abort);
565 }
566 _ => return Ok((el, sib_idx)),
567 }
568 }
569 }
570
571 fn take_single_token(&mut self) -> Result<(), Stop> {
572 let el = self.bump()?;
573 match &el {
574 SyntaxElement::Token(t)
575 if matches!(
576 t.kind(),
577 SyntaxKind::CONTROL_WORD | SyntaxKind::CONTROL_SYMBOL
578 ) =>
579 {
580 self.chain = false;
581 Ok(())
582 }
583 SyntaxElement::Token(t)
584 if t.kind() == SyntaxKind::WORD && t.text().chars().count() == 1 =>
585 {
586 self.chain = false;
587 Ok(())
588 }
589 SyntaxElement::Token(t) if t.kind() == SyntaxKind::HASH => {
590 self.chain = false;
591 loop {
592 let next = self.bump()?;
593 match &next {
594 SyntaxElement::Token(t) if t.kind() == SyntaxKind::HASH => {}
595 SyntaxElement::Token(t)
596 if t.kind() == SyntaxKind::WORD && is_param_digit(t) =>
597 {
598 return Ok(());
599 }
600 _ => return Err(Stop::Abort),
601 }
602 }
603 }
604 SyntaxElement::Node(n) if n.kind() == SyntaxKind::COMMAND => {
605 self.queue_children_after_name(n, true);
606 self.chain = true;
607 Ok(())
608 }
609 SyntaxElement::Node(n) if n.kind() == SyntaxKind::GROUP => Ok(()),
610 _ => Err(Stop::Abort),
611 }
612 }
613
614 fn take_group(&mut self) -> Result<SyntaxElement, Stop> {
615 let el = self.bump()?;
616 match &el {
617 SyntaxElement::Node(n) if n.kind() == SyntaxKind::GROUP => Ok(el),
618 _ => Err(Stop::Abort),
619 }
620 }
621
622 fn take_parameter_text(&mut self) -> Result<(), Stop> {
623 loop {
624 if let SyntaxElement::Node(n) = self.peek()?
625 && n.kind() == SyntaxKind::GROUP
626 {
627 return Ok(());
628 }
629 let el = self.bump()?;
630 match &el {
631 SyntaxElement::Token(_) => self.chain = false,
632 SyntaxElement::Node(n) if n.kind() == SyntaxKind::COMMAND => {
633 self.queue_children_after_name(n, true);
634 self.chain = true;
635 }
636 SyntaxElement::Node(n) if n.kind() == SyntaxKind::OPTIONAL => {}
637 _ => return Err(Stop::Abort),
638 }
639 }
640 }
641
642 fn extend_over_attachable_tail(&mut self) {
643 if let Some((el, sib_idx)) = self.peeked.take()
644 && sib_idx.is_none()
645 {
646 self.update_chain(&el);
647 }
648 while let Some(el) = self.queue.pop_front() {
649 self.update_chain(&el);
650 }
651 if !self.chain {
652 return;
653 }
654 let mut newlines = 0usize;
655 let mut i = self.last_sib + 1;
656 while let Some(el) = self.elements.get(i) {
657 match el {
658 SyntaxElement::Token(t) => match t.kind() {
659 SyntaxKind::NEWLINE => {
660 newlines += 1;
661 if newlines >= 2 {
662 return;
663 }
664 }
665 SyntaxKind::COMMENT => newlines = 0,
666 SyntaxKind::WHITESPACE | SyntaxKind::GUARD | SyntaxKind::DOC_MARGIN => {}
667 _ => return,
668 },
669 SyntaxElement::Node(n)
670 if matches!(n.kind(), SyntaxKind::GROUP | SyntaxKind::OPTIONAL) =>
671 {
672 self.last_sib = i;
673 newlines = 0;
674 }
675 SyntaxElement::Node(_) => return,
676 }
677 i += 1;
678 }
679 }
680
681 fn update_chain(&mut self, el: &SyntaxElement) {
682 match el {
683 SyntaxElement::Node(n) if n.kind() == SyntaxKind::COMMAND => self.chain = true,
684 SyntaxElement::Node(n)
685 if matches!(n.kind(), SyntaxKind::GROUP | SyntaxKind::OPTIONAL) => {}
686 SyntaxElement::Token(t)
687 if is_collapsible_trivia(t.kind())
688 || matches!(
689 t.kind(),
690 SyntaxKind::COMMENT
691 | SyntaxKind::TILDE
692 | SyntaxKind::GUARD
693 | SyntaxKind::DOC_MARGIN
694 ) => {}
695 _ => self.chain = false,
696 }
697 }
698}
699
700#[cfg(test)]
701mod segmentation_tests {
702 use super::*;
703 use crate::parser::parse;
704 use crate::syntax::SyntaxNode;
705
706 fn statements(src: &str) -> Vec<String> {
707 let parsed = parse(src);
708 assert!(parsed.errors.is_empty(), "test source should parse cleanly");
709 let root = SyntaxNode::new_root(parsed.green);
710 let para = root
711 .children()
712 .find(|n| n.kind() == SyntaxKind::PARAGRAPH)
713 .expect("a paragraph");
714 let elements: Vec<SyntaxElement> = para.children_with_tokens().collect();
715 statement_texts(&elements)
716 }
717
718 fn statement_texts(elements: &[SyntaxElement]) -> Vec<String> {
719 let map = segment_expl_statements(elements);
720 let mut out = Vec::new();
721 let mut cur = String::new();
722 for (i, el) in elements.iter().enumerate() {
723 cur.push_str(&el.to_string());
724 if map.boundary_after(i) {
725 let text = normalize(&cur);
726 if !text.is_empty() {
727 out.push(text);
728 }
729 cur.clear();
730 }
731 }
732 let tail = normalize(&cur);
733 if !tail.is_empty() {
734 out.push(tail);
735 }
736 out
737 }
738
739 fn normalize(s: &str) -> String {
740 s.split_whitespace().collect::<Vec<_>>().join(" ")
741 }
742
743 #[test]
744 fn statements_are_structural_units() {
745 let got = statements(
746 "\\ExplSyntaxOn\n\\tl_set:Nn \\l_a\n { x }\n\\group_begin:\n\\ExplSyntaxOff\n",
747 );
748 assert_eq!(
749 got,
750 vec![
751 "\\ExplSyntaxOn",
752 "\\tl_set:Nn \\l_a { x }",
753 "\\group_begin:",
754 "\\ExplSyntaxOff",
755 ]
756 );
757 }
758
759 #[test]
760 fn same_line_calls_split() {
761 let got =
762 statements("\\ExplSyntaxOn\n\\group_begin: \\int_zero:N \\l_a\n\\ExplSyntaxOff\n");
763 assert_eq!(
764 got,
765 vec![
766 "\\ExplSyntaxOn",
767 "\\group_begin:",
768 "\\int_zero:N \\l_a",
769 "\\ExplSyntaxOff",
770 ]
771 );
772 }
773
774 #[test]
775 fn npn_definition_is_one_unit() {
776 let got =
777 statements("\\ExplSyntaxOn\n\\cs_new:Npn \\foo:n #1\n { body #1 }\n\\ExplSyntaxOff\n");
778 assert_eq!(
779 got,
780 vec![
781 "\\ExplSyntaxOn",
782 "\\cs_new:Npn \\foo:n #1 { body #1 }",
783 "\\ExplSyntaxOff",
784 ]
785 );
786 }
787
788 #[test]
789 fn peel_back_reclaims_over_attached_group() {
790 let got = statements("\\ExplSyntaxOn\n\\cs_new:Nn \\foo:n\n { body }\n\\ExplSyntaxOff\n");
791 assert_eq!(
792 got,
793 vec![
794 "\\ExplSyntaxOn",
795 "\\cs_new:Nn \\foo:n { body }",
796 "\\ExplSyntaxOff",
797 ]
798 );
799 }
800
801 #[test]
802 fn exp_args_chain_is_one_unit() {
803 let got = statements(
804 "\\ExplSyntaxOn\n\\exp_args:NNo \\tl_set:Nn \\l_a { \\l_b }\n\\ExplSyntaxOff\n",
805 );
806 assert_eq!(
807 got,
808 vec![
809 "\\ExplSyntaxOn",
810 "\\exp_args:NNo \\tl_set:Nn \\l_a { \\l_b }",
811 "\\ExplSyntaxOff",
812 ]
813 );
814 }
815
816 #[test]
817 fn hash_parameter_satisfies_single_token_slot() {
818 let got = statements("\\ExplSyntaxOn\n\\tl_set:Nn #1 { x }\n\\ExplSyntaxOff\n");
819 assert_eq!(
820 got,
821 vec!["\\ExplSyntaxOn", "\\tl_set:Nn #1 { x }", "\\ExplSyntaxOff"]
822 );
823 }
824
825 #[test]
826 fn relation_character_satisfies_single_token_slot() {
827 let got = statements(
828 "\\ExplSyntaxOn\n\\int_compare:nNnTF { \\l_a } = { 1 } { yes } { no } \\foo:\n\\ExplSyntaxOff\n",
829 );
830 assert_eq!(
831 got,
832 vec![
833 "\\ExplSyntaxOn",
834 "\\int_compare:nNnTF { \\l_a } = { 1 } { yes } { no }",
835 "\\foo:",
836 "\\ExplSyntaxOff",
837 ]
838 );
839 }
840
841 #[test]
842 fn relation_character_unit_is_newline_invariant() {
843 let inline = statements(
844 "\\ExplSyntaxOn\n\\int_compare:nNnTF { \\l_a } = { 1 } { yes } { no } \\foo:\n\\ExplSyntaxOff\n",
845 );
846 let broken = statements(
847 "\\ExplSyntaxOn\n\\int_compare:nNnTF { \\l_a } = { 1 }\n { yes } { no }\n\\foo:\n\\ExplSyntaxOff\n",
848 );
849 assert_eq!(inline, broken);
850 }
851
852 #[test]
853 fn multi_character_word_does_not_satisfy_single_token_slot() {
854 let got = statements(
855 "\\ExplSyntaxOn\n\\int_compare:nNnT { \\l_a } <= { 1 } { yes }\n\\foo:\n\\ExplSyntaxOff\n",
856 );
857 assert_eq!(
858 got,
859 vec![
860 "\\ExplSyntaxOn",
861 "\\int_compare:nNnT { \\l_a } <= { 1 } { yes }",
862 "\\foo:",
863 "\\ExplSyntaxOff",
864 ]
865 );
866 }
867
868 #[test]
869 fn delimited_parameter_text_peels_the_body() {
870 let got = statements(
871 "\\ExplSyntaxOn\n\\cs_new:Npn \\foo:w #1 \\q_stop { body }\n\\ExplSyntaxOff\n",
872 );
873 assert_eq!(
874 got,
875 vec![
876 "\\ExplSyntaxOn",
877 "\\cs_new:Npn \\foo:w #1 \\q_stop { body }",
878 "\\ExplSyntaxOff",
879 ]
880 );
881 }
882
883 #[test]
884 fn comment_in_a_consumed_slot_ends_the_fallback_line() {
885 let got = statements(
886 "\\ExplSyntaxOn\n\\exp_after:wN \\foo \\tl_set:Nn \\l_a\n% doc\n{ x } \\group_begin:\n\\ExplSyntaxOff\n",
887 );
888 assert_eq!(
889 got,
890 vec![
891 "\\ExplSyntaxOn",
892 "\\exp_after:wN \\foo \\tl_set:Nn \\l_a % doc { x }",
893 "\\group_begin:",
894 "\\ExplSyntaxOff",
895 ]
896 );
897 }
898
899 #[test]
900 fn unknown_head_falls_back_to_its_line() {
901 let got = statements(
902 "\\ExplSyntaxOn\n\\exp_after:wN \\foo \\tl_set:Nn \\l_a { x }\n\\group_begin:\n\\ExplSyntaxOff\n",
903 );
904 assert_eq!(
905 got,
906 vec![
907 "\\ExplSyntaxOn",
908 "\\exp_after:wN \\foo \\tl_set:Nn \\l_a { x }",
909 "\\group_begin:",
910 "\\ExplSyntaxOff",
911 ]
912 );
913 }
914
915 #[test]
916 fn shape_mismatch_falls_back() {
917 let got = statements("\\ExplSyntaxOn\n\\tl_set:Nn\n\\l_a\n\\ExplSyntaxOff\n");
918 assert_eq!(
919 got,
920 vec!["\\ExplSyntaxOn", "\\tl_set:Nn", "\\l_a", "\\ExplSyntaxOff"]
921 );
922 }
923
924 #[test]
925 fn trailing_comment_rides_the_statement() {
926 let got = statements("\\ExplSyntaxOn\n\\tl_set:Nn \\l_a { x } % note\n\\ExplSyntaxOff\n");
927 assert_eq!(
928 got,
929 vec![
930 "\\ExplSyntaxOn",
931 "\\tl_set:Nn \\l_a { x } % note",
932 "\\ExplSyntaxOff",
933 ]
934 );
935 }
936
937 #[test]
938 fn leftover_attached_group_rides_the_statement() {
939 let got = statements("\\ExplSyntaxOn\n\\use:n { a } { b }\n\\ExplSyntaxOff\n");
940 assert_eq!(
941 got,
942 vec!["\\ExplSyntaxOn", "\\use:n { a } { b }", "\\ExplSyntaxOff"]
943 );
944 }
945
946 #[test]
947 fn conditional_call_is_one_unit() {
948 let got = statements(
949 "\\ExplSyntaxOn\n\\str_if_eq:nnTF { a } { b }\n { yes }\n { no }\n\\ExplSyntaxOff\n",
950 );
951 assert_eq!(
952 got,
953 vec![
954 "\\ExplSyntaxOn",
955 "\\str_if_eq:nnTF { a } { b } { yes } { no }",
956 "\\ExplSyntaxOff",
957 ]
958 );
959 }
960
961 fn branch_texts(src: &str, head: usize) -> Option<Vec<String>> {
962 let parsed = parse(src);
963 assert!(parsed.errors.is_empty(), "test source should parse cleanly");
964 let root = SyntaxNode::new_root(parsed.green);
965 let para = root
966 .children()
967 .find(|n| n.kind() == SyntaxKind::PARAGRAPH)
968 .expect("a paragraph");
969 let elements: Vec<SyntaxElement> = para.children_with_tokens().collect();
970 let unit = expl3_unit(&elements, head)?;
971 Some(
972 unit.branches
973 .iter()
974 .map(|range| normalize(&root.text().slice(*range).to_string()))
975 .collect(),
976 )
977 }
978
979 fn head_of(src: &str, name: &str) -> usize {
980 let parsed = parse(src);
981 let root = SyntaxNode::new_root(parsed.green);
982 let para = root
983 .children()
984 .find(|n| n.kind() == SyntaxKind::PARAGRAPH)
985 .expect("a paragraph");
986 para.children_with_tokens()
987 .position(|el| {
988 el.as_node().is_some_and(|n| {
989 n.kind() == SyntaxKind::COMMAND
990 && command_name(n).is_some_and(|got| got == name)
991 })
992 })
993 .unwrap_or_else(|| panic!("no command named {name}"))
994 }
995
996 #[test]
997 fn branches_are_resolved_wherever_attachment_put_them() {
998 let head_attached = "\\ExplSyntaxOn\n\\tl_if_empty:nTF {#1} { T } { F }\n";
999 assert_eq!(
1000 branch_texts(head_attached, head_of(head_attached, "tl_if_empty:nTF")),
1001 Some(vec!["{ T }".to_string(), "{ F }".to_string()])
1002 );
1003
1004 let one_sibling = "\\ExplSyntaxOn\n\\seq_if_in:NnTF \\l_seq {item} { T } { F }\n";
1005 assert_eq!(
1006 branch_texts(one_sibling, head_of(one_sibling, "seq_if_in:NnTF")),
1007 Some(vec!["{ T }".to_string(), "{ F }".to_string()])
1008 );
1009
1010 let two_siblings = "\\ExplSyntaxOn\n\\prop_get:NnNTF \\p {k} \\l { T } { F }\n";
1011 assert_eq!(
1012 branch_texts(two_siblings, head_of(two_siblings, "prop_get:NnNTF")),
1013 Some(vec!["{ T }".to_string(), "{ F }".to_string()])
1014 );
1015
1016 let stream_level = "\\ExplSyntaxOn\n\\int_compare:nNnTF {a} = { 1 } { T } { F }\n";
1017 assert_eq!(
1018 branch_texts(stream_level, head_of(stream_level, "int_compare:nNnTF")),
1019 Some(vec!["{ T }".to_string(), "{ F }".to_string()])
1020 );
1021 }
1022
1023 #[test]
1024 fn a_non_conditional_unit_has_no_branches() {
1025 let src = "\\ExplSyntaxOn\n\\tl_set:Nn \\l_a { x }\n";
1026 assert_eq!(branch_texts(src, head_of(src, "tl_set:Nn")), Some(vec![]));
1027 }
1028
1029 #[test]
1030 fn an_underivable_head_resolves_no_unit() {
1031 let src = "\\ExplSyntaxOn\n\\odd_if:wTF \\a \\b { T } { F }\n";
1032 assert_eq!(branch_texts(src, head_of(src, "odd_if:wTF")), None);
1033 }
1034
1035 #[test]
1036 fn a_blank_line_cut_unit_reports_no_branches() {
1037 let src = "\\ExplSyntaxOn\n\\use:n { \\prop_get:NnNTF \\p {k} \\l { T }\n\n{ F } }\n";
1038 let parsed = parse(src);
1039 assert!(parsed.errors.is_empty());
1040 let root = SyntaxNode::new_root(parsed.green);
1041 let group = root
1042 .descendants()
1043 .find(|n| n.kind() == SyntaxKind::GROUP)
1044 .expect("a group");
1045 let body: Vec<SyntaxElement> = group
1046 .children_with_tokens()
1047 .filter(|el| !matches!(el.kind(), SyntaxKind::L_BRACE | SyntaxKind::R_BRACE))
1048 .collect();
1049 let head = body
1050 .iter()
1051 .position(|el| el.as_node().is_some())
1052 .expect("the head command");
1053 let unit = expl3_unit(&body, head).expect("the partial unit still resolves");
1054 assert_eq!(unit.branches, vec![]);
1055 }
1056
1057 #[test]
1058 fn blank_line_ends_the_unit() {
1059 let src = "\\ExplSyntaxOn\n\\use:n { \\tl_set:Nn \\l_a\n\n { x } }\n\\ExplSyntaxOff\n";
1060 let parsed = parse(src);
1061 assert!(parsed.errors.is_empty());
1062 let root = SyntaxNode::new_root(parsed.green);
1063 let group = root
1064 .descendants()
1065 .find(|n| n.kind() == SyntaxKind::GROUP)
1066 .expect("a group");
1067 let body: Vec<SyntaxElement> = group
1068 .children_with_tokens()
1069 .filter(|el| !matches!(el.kind(), SyntaxKind::L_BRACE | SyntaxKind::R_BRACE))
1070 .collect();
1071 assert_eq!(statement_texts(&body), vec!["\\tl_set:Nn \\l_a", "{ x }"]);
1072 }
1073
1074 #[test]
1075 fn guard_mid_unit_aborts_to_fallback() {
1076 use crate::parser::lexer::LexConfig;
1077 use crate::parser::{LatexFlavor, parse_with_flavor};
1078 let src = "% \\begin{macrocode}\n\\ExplSyntaxOn\n\\tl_set:Nn \\l_a\n%<latexrelease> { x }\n\\ExplSyntaxOff\n% \\end{macrocode}\n";
1079 let config = LexConfig {
1080 flavor: LatexFlavor::Package,
1081 dtx: true,
1082 };
1083 let parsed = parse_with_flavor(src, config);
1084 assert!(parsed.errors.is_empty(), "test source should parse cleanly");
1085 let root = SyntaxNode::new_root(parsed.green);
1086 let para = root
1087 .descendants()
1088 .find(|n| n.kind() == SyntaxKind::PARAGRAPH)
1089 .expect("a paragraph");
1090 let elements: Vec<SyntaxElement> = para.children_with_tokens().collect();
1091 let map = segment_expl_statements(&elements);
1092 assert_eq!(
1093 statement_texts(&elements),
1094 vec![
1095 "\\ExplSyntaxOn",
1096 "\\tl_set:Nn \\l_a %<latexrelease> { x }",
1097 "\\ExplSyntaxOff",
1098 ]
1099 );
1100 let guarded_end = elements
1101 .iter()
1102 .position(|el| el.to_string().contains("latexrelease"))
1103 .expect("the guarded sibling");
1104 assert!(
1105 map.is_fallback(guarded_end),
1106 "the aborted unit must be a fallback statement"
1107 );
1108 }
1109
1110 #[test]
1111 fn e_and_f_letters_consume_braced_groups() {
1112 let got = statements(
1113 "\\ExplSyntaxOn\n\\tl_set:Ne \\l_a\n { x }\n\\tl_set:Nf \\l_b\n { y }\n\\ExplSyntaxOff\n",
1114 );
1115 assert_eq!(
1116 got,
1117 vec![
1118 "\\ExplSyntaxOn",
1119 "\\tl_set:Ne \\l_a { x }",
1120 "\\tl_set:Nf \\l_b { y }",
1121 "\\ExplSyntaxOff",
1122 ]
1123 );
1124 }
1125
1126 #[test]
1127 fn stream_ending_mid_unit_falls_back() {
1128 let src = "\\ExplSyntaxOn\n\\use:n { \\tl_set:Nn \\l_a }\n\\ExplSyntaxOff\n";
1129 let parsed = parse(src);
1130 assert!(parsed.errors.is_empty());
1131 let root = SyntaxNode::new_root(parsed.green);
1132 let group = root
1133 .descendants()
1134 .find(|n| n.kind() == SyntaxKind::GROUP)
1135 .expect("a group");
1136 let body: Vec<SyntaxElement> = group
1137 .children_with_tokens()
1138 .filter(|el| !matches!(el.kind(), SyntaxKind::L_BRACE | SyntaxKind::R_BRACE))
1139 .collect();
1140 let map = segment_expl_statements(&body);
1141 assert_eq!(statement_texts(&body), vec!["\\tl_set:Nn \\l_a"]);
1142 let head = body
1143 .iter()
1144 .position(|el| el.as_node().is_some())
1145 .expect("the head command");
1146 assert!(
1147 map.is_fallback(head),
1148 "a unit cut off by the stream end must be a fallback statement"
1149 );
1150 }
1151
1152 #[test]
1153 fn a_multi_line_group_node_does_not_end_a_fallback_line() {
1154 let src = "\\ExplSyntaxOn\n\
1155 \\int_do_until:w { \\l_tmpa_int } > {#2}\n\
1156 { \\lipsum_add:V { \\l_tmpa_int }\n\
1157 \\int_incr:N \\l_tmpa_int } \\tl_put_right:NV \\l_a \\l_b\n\
1158 \\ExplSyntaxOff\n";
1159 let parsed = parse(src);
1160 assert!(parsed.errors.is_empty());
1161 let root = SyntaxNode::new_root(parsed.green);
1162 let elements: Vec<SyntaxElement> = root
1163 .first_child()
1164 .expect("the paragraph")
1165 .children_with_tokens()
1166 .collect();
1167 let map = segment_expl_statements(&elements);
1168
1169 assert_eq!(
1170 statement_texts(&elements),
1171 vec![
1172 "\\ExplSyntaxOn",
1173 "\\int_do_until:w { \\l_tmpa_int } > {#2}",
1174 "{ \\lipsum_add:V { \\l_tmpa_int } \\int_incr:N \\l_tmpa_int } \
1175 \\tl_put_right:NV \\l_a \\l_b",
1176 "\\ExplSyntaxOff",
1177 ]
1178 );
1179
1180 let group = elements
1181 .iter()
1182 .position(|el| el.kind() == SyntaxKind::GROUP && el.to_string().contains('\n'))
1183 .expect("the multi-line group");
1184 assert!(
1185 map.is_fallback(group),
1186 "the group belongs to a fallback statement"
1187 );
1188 assert!(
1189 !map.boundary_after(group),
1190 "a multi-line group's own newlines must not end the fallback line"
1191 );
1192
1193 let head = elements
1194 .iter()
1195 .skip(group)
1196 .position(|el| {
1197 el.as_node()
1198 .is_some_and(|n| n.kind() == SyntaxKind::COMMAND)
1199 })
1200 .map(|off| group + off)
1201 .expect("the trailing recognized head");
1202 assert!(
1203 map.glue_before(head),
1204 "a recognized head mid-fallback-line owes an unbreakable gap"
1205 );
1206 }
1207
1208 #[test]
1209 fn own_line_comment_in_attached_span_rides_the_sibling() {
1210 let got =
1211 statements("\\ExplSyntaxOn\n\\tl_set:Nn \\l_a\n% note\n { x }\n\\ExplSyntaxOff\n");
1212 assert_eq!(
1213 got,
1214 vec![
1215 "\\ExplSyntaxOn",
1216 "\\tl_set:Nn \\l_a % note { x }",
1217 "\\ExplSyntaxOff",
1218 ]
1219 );
1220 }
1221
1222 #[test]
1223 fn own_line_comment_at_sibling_level_ends_the_unit() {
1224 let got = statements(
1225 "\\ExplSyntaxOn\n\\cs_new:Npn \\foo:n\n% note\n#1 { body }\n\\ExplSyntaxOff\n",
1226 );
1227 assert_eq!(
1228 got,
1229 vec![
1230 "\\ExplSyntaxOn",
1231 "\\cs_new:Npn \\foo:n",
1232 "% note",
1233 "#1 { body }",
1234 "\\ExplSyntaxOff",
1235 ]
1236 );
1237 }
1238}