use std::collections::VecDeque;
use crate::ast::command_name;
use crate::parser::lexer::expl_toggle;
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode, is_collapsible_trivia, is_param_digit};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Expl3Slot {
SingleToken,
Group,
Branch,
ParameterText,
}
pub fn expl3_slots(name: &str) -> Option<Vec<Expl3Slot>> {
let argspec = name.rsplit_once(':')?.1;
let chars: Vec<char> = argspec.chars().collect();
let branches = chars
.iter()
.rev()
.take_while(|c| matches!(c, 'T' | 'F'))
.count();
let mut slots = Vec::with_capacity(chars.len());
for c in &chars[..chars.len() - branches] {
slots.push(match c {
'N' | 'V' => Expl3Slot::SingleToken,
'n' | 'c' | 'v' | 'o' | 'x' | 'e' | 'f' => Expl3Slot::Group,
'p' => Expl3Slot::ParameterText,
_ => return None,
});
}
slots.extend(std::iter::repeat_n(Expl3Slot::Branch, branches));
Some(slots)
}
pub fn conditional_branches(name: &str) -> Option<usize> {
let argspec = name.rsplit_once(':')?.1;
let n = argspec
.chars()
.rev()
.take_while(|c| *c == 'T' || *c == 'F')
.count();
(n > 0).then_some(n)
}
#[cfg(test)]
mod tests {
use super::*;
use Expl3Slot::*;
#[test]
fn slots_read_from_name_suffix() {
assert_eq!(
expl3_slots("cs_new:Npn"),
Some(vec![SingleToken, ParameterText, Group])
);
assert_eq!(
expl3_slots("str_if_eq:nnTF"),
Some(vec![Group, Group, Branch, Branch])
);
assert_eq!(
expl3_slots("prop_get:NnNTF"),
Some(vec![SingleToken, Group, SingleToken, Branch, Branch])
);
assert_eq!(expl3_slots("tl_set:Nn"), Some(vec![SingleToken, Group]));
assert_eq!(
expl3_slots("exp_args:NNo"),
Some(vec![SingleToken, SingleToken, Group])
);
assert_eq!(expl3_slots("tl_set:Nv"), Some(vec![SingleToken, Group]));
assert_eq!(expl3_slots("use:c"), Some(vec![Group]));
assert_eq!(expl3_slots("tl_set:Nx"), Some(vec![SingleToken, Group]));
}
#[test]
fn zero_argument_names_are_recognized() {
assert_eq!(expl3_slots("scan_stop:"), Some(vec![]));
assert_eq!(expl3_slots("group_begin:"), Some(vec![]));
assert_eq!(expl3_slots("prg_return_true:"), Some(vec![]));
}
#[test]
fn underivable_specs_are_unrecognized() {
assert_eq!(expl3_slots("use_none_delimit_by_q_stop:w"), None);
assert_eq!(expl3_slots("exp_after:wN"), None);
assert_eq!(expl3_slots("tex_relax:D"), None);
assert_eq!(expl3_slots("odd:TnF"), None);
assert_eq!(expl3_slots("odd:nZn"), None);
}
#[test]
fn colonless_names_are_unrecognized() {
assert_eq!(expl3_slots("def"), None);
assert_eq!(expl3_slots("@ifpackageloaded"), None);
assert_eq!(expl3_slots("IfBooleanTF"), None);
assert_eq!(expl3_slots("l_tmpa_tl"), None);
}
#[test]
fn exp_internal_drivers() {
assert_eq!(expl3_slots("::n"), Some(vec![Group]));
assert_eq!(expl3_slots(":::"), Some(vec![]));
}
#[test]
fn conditional_branches_read_from_name_suffix() {
assert_eq!(conditional_branches("tl_if_empty:nTF"), Some(2));
assert_eq!(conditional_branches("bool_if:nT"), Some(1));
assert_eq!(conditional_branches("bool_if:nF"), Some(1));
assert_eq!(conditional_branches("str_if_eq:nnTF"), Some(2));
assert_eq!(conditional_branches("int_compare:nNnTF"), Some(2));
assert_eq!(conditional_branches("seq_map_inline:Nn"), None);
assert_eq!(conditional_branches("prg_return_true:"), None);
assert_eq!(conditional_branches("tl_new:N"), None);
assert_eq!(conditional_branches("@ifpackageloaded"), None);
assert_eq!(conditional_branches("IfBooleanTF"), None);
}
#[test]
fn branches_survive_underivable_arity() {
assert_eq!(expl3_slots("odd_if:wTF"), None);
assert_eq!(conditional_branches("odd_if:wTF"), Some(2));
}
}
pub struct StatementMap {
boundary_after: Vec<bool>,
glue_before: Vec<bool>,
glued: Vec<bool>,
fallback: Vec<bool>,
}
impl StatementMap {
pub fn boundary_after(&self, idx: usize) -> bool {
self.boundary_after.get(idx).copied().unwrap_or(false)
}
pub fn glue_before(&self, idx: usize) -> bool {
self.glue_before.get(idx).copied().unwrap_or(false)
}
pub fn is_glued(&self, idx: usize) -> bool {
self.glued.get(idx).copied().unwrap_or(false)
}
pub fn is_fallback(&self, idx: usize) -> bool {
self.fallback.get(idx).copied().unwrap_or(false)
}
}
pub fn segment_expl_statements(elements: &[SyntaxElement]) -> StatementMap {
let mut boundary_after = vec![false; elements.len()];
let mut glue_before = vec![false; elements.len()];
let mut glued = vec![false; elements.len()];
let mut fallback = vec![false; elements.len()];
let mut i = 0;
while i < elements.len() {
match &elements[i] {
SyntaxElement::Token(t) if is_collapsible_trivia(t.kind()) => i += 1,
SyntaxElement::Token(t)
if matches!(
t.kind(),
SyntaxKind::COMMENT | SyntaxKind::GUARD | SyntaxKind::DOC_MARGIN
) =>
{
if followed_by_newline(elements, i) {
boundary_after[i] = true;
}
i += 1;
}
SyntaxElement::Node(n) if n.kind() == SyntaxKind::COMMAND => {
let slots = if node_is_expl_toggle(n) {
Some(Vec::new())
} else {
command_name(n).and_then(|name| expl3_slots(&name))
};
match slots.and_then(|slots| consume_unit(elements, i, &slots)) {
Some(end) => {
let full = absorb_trailing_junk(elements, end);
if full > end {
glued[i..=full].fill(true);
}
boundary_after[full] = true;
i = full + 1;
}
None => {
i = fallback_line(
elements,
i,
&mut boundary_after,
&mut glue_before,
&mut fallback,
)
}
}
}
_ => {
i = fallback_line(
elements,
i,
&mut boundary_after,
&mut glue_before,
&mut fallback,
)
}
}
}
StatementMap {
boundary_after,
glue_before,
glued,
fallback,
}
}
fn node_is_expl_toggle(node: &SyntaxNode) -> bool {
node.children_with_tokens()
.filter_map(|el| el.into_token())
.find(|t| t.kind() == SyntaxKind::CONTROL_WORD)
.is_some_and(|t| expl_toggle(t.text()).is_some())
}
fn followed_by_newline(elements: &[SyntaxElement], idx: usize) -> bool {
for element in &elements[idx + 1..] {
match element {
SyntaxElement::Token(t) if t.kind() == SyntaxKind::WHITESPACE => {}
SyntaxElement::Token(t) if t.kind() == SyntaxKind::NEWLINE => return true,
_ => return false,
}
}
true
}
fn fallback_line(
elements: &[SyntaxElement],
start: usize,
boundary_after: &mut [bool],
glue_before: &mut [bool],
fallback: &mut [bool],
) -> usize {
let mut last = start;
let mut j = start;
while j < elements.len() {
match &elements[j] {
SyntaxElement::Token(t) if is_collapsible_trivia(t.kind()) => {
if t.kind() == SyntaxKind::NEWLINE {
boundary_after[last] = true;
fallback[start..=last].fill(true);
return j;
}
j += 1;
}
element => {
if j > start
&& let SyntaxElement::Node(n) = element
&& n.kind() == SyntaxKind::COMMAND
&& (node_is_expl_toggle(n)
|| command_name(n).is_some_and(|name| expl3_slots(&name).is_some()))
{
glue_before[j] = true;
}
last = j;
j += 1;
}
}
}
boundary_after[last] = true;
fallback[start..=last].fill(true);
elements.len()
}
fn absorb_trailing_junk(elements: &[SyntaxElement], end: usize) -> usize {
let mut end = end;
let mut j = end + 1;
while j < elements.len() {
match &elements[j] {
SyntaxElement::Token(t) if is_collapsible_trivia(t.kind()) => {
if t.kind() == SyntaxKind::NEWLINE {
break;
}
j += 1;
}
SyntaxElement::Token(t) if t.kind() == SyntaxKind::COMMENT => {
end = j;
break;
}
SyntaxElement::Token(t)
if matches!(t.kind(), SyntaxKind::GUARD | SyntaxKind::DOC_MARGIN) =>
{
break;
}
SyntaxElement::Node(n) if n.kind() == SyntaxKind::GROUP => break,
SyntaxElement::Node(n)
if n.kind() == SyntaxKind::COMMAND
&& (node_is_expl_toggle(n)
|| command_name(n).is_some_and(|name| expl3_slots(&name).is_some())) =>
{
break;
}
_ => {
end = j;
j += 1;
}
}
}
end
}
enum Stop {
End,
Abort,
}
fn consume_unit(elements: &[SyntaxElement], head_idx: usize, slots: &[Expl3Slot]) -> Option<usize> {
let head = elements[head_idx].as_node()?;
let mut cur = UnitCursor::new(elements, head_idx, head);
for slot in slots {
let took = match slot {
Expl3Slot::SingleToken => cur.take_single_token(),
Expl3Slot::Group | Expl3Slot::Branch => cur.take_group(),
Expl3Slot::ParameterText => cur.take_parameter_text(),
};
match took {
Ok(()) => {}
Err(Stop::End) => break,
Err(Stop::Abort) => return None,
}
}
Some(cur.last_sib)
}
struct UnitCursor<'a> {
elements: &'a [SyntaxElement],
queue: VecDeque<SyntaxElement>,
sib: usize,
last_sib: usize,
peeked: Option<(SyntaxElement, Option<usize>)>,
}
impl<'a> UnitCursor<'a> {
fn new(elements: &'a [SyntaxElement], head_idx: usize, head: &SyntaxNode) -> Self {
let mut cur = UnitCursor {
elements,
queue: VecDeque::new(),
sib: head_idx + 1,
last_sib: head_idx,
peeked: None,
};
cur.queue_children_after_name(head, false);
cur
}
fn queue_children_after_name(&mut self, node: &SyntaxNode, front: bool) {
let mut seen_name = false;
let mut after: Vec<SyntaxElement> = Vec::new();
for child in node.children_with_tokens() {
if seen_name {
after.push(child);
} else if matches!(
child.kind(),
SyntaxKind::CONTROL_WORD | SyntaxKind::CONTROL_SYMBOL
) {
seen_name = true;
}
}
if front {
for el in after.into_iter().rev() {
self.queue.push_front(el);
}
} else {
self.queue.extend(after);
}
}
fn peek(&mut self) -> Result<&SyntaxElement, Stop> {
if self.peeked.is_none() {
self.peeked = Some(self.advance()?);
}
Ok(&self.peeked.as_ref().expect("just filled").0)
}
fn bump(&mut self) -> Result<SyntaxElement, Stop> {
let (el, sib_idx) = match self.peeked.take() {
Some(peeked) => peeked,
None => self.advance()?,
};
if let Some(idx) = sib_idx {
self.last_sib = idx;
}
Ok(el)
}
fn advance(&mut self) -> Result<(SyntaxElement, Option<usize>), Stop> {
let mut gap_newlines = 0usize;
loop {
let (el, sib_idx) = if let Some(el) = self.queue.pop_front() {
(el, None)
} else {
let Some(el) = self.elements.get(self.sib) else {
return Err(Stop::Abort);
};
if let SyntaxElement::Token(t) = el
&& t.kind() == SyntaxKind::NEWLINE
&& gap_newlines >= 1
{
return Err(Stop::End);
}
let idx = self.sib;
self.sib += 1;
(el.clone(), Some(idx))
};
match &el {
SyntaxElement::Token(t) if is_collapsible_trivia(t.kind()) => {
if t.kind() == SyntaxKind::NEWLINE {
gap_newlines += 1;
if gap_newlines >= 2 {
return Err(Stop::End);
}
}
}
SyntaxElement::Token(t) if t.kind() == SyntaxKind::COMMENT => {}
SyntaxElement::Token(t) if t.kind() == SyntaxKind::TILDE => {}
SyntaxElement::Token(t)
if matches!(t.kind(), SyntaxKind::GUARD | SyntaxKind::DOC_MARGIN) =>
{
return Err(Stop::Abort);
}
_ => return Ok((el, sib_idx)),
}
}
}
fn take_single_token(&mut self) -> Result<(), Stop> {
let el = self.bump()?;
match &el {
SyntaxElement::Token(t)
if matches!(
t.kind(),
SyntaxKind::CONTROL_WORD | SyntaxKind::CONTROL_SYMBOL
) =>
{
Ok(())
}
SyntaxElement::Token(t) if t.kind() == SyntaxKind::HASH => {
loop {
let next = self.bump()?;
match &next {
SyntaxElement::Token(t) if t.kind() == SyntaxKind::HASH => {}
SyntaxElement::Token(t)
if t.kind() == SyntaxKind::WORD && is_param_digit(t) =>
{
return Ok(());
}
_ => return Err(Stop::Abort),
}
}
}
SyntaxElement::Node(n) if n.kind() == SyntaxKind::COMMAND => {
self.queue_children_after_name(n, true);
Ok(())
}
SyntaxElement::Node(n) if n.kind() == SyntaxKind::GROUP => Ok(()),
_ => Err(Stop::Abort),
}
}
fn take_group(&mut self) -> Result<(), Stop> {
let el = self.bump()?;
match &el {
SyntaxElement::Node(n) if n.kind() == SyntaxKind::GROUP => Ok(()),
_ => Err(Stop::Abort),
}
}
fn take_parameter_text(&mut self) -> Result<(), Stop> {
loop {
if let SyntaxElement::Node(n) = self.peek()?
&& n.kind() == SyntaxKind::GROUP
{
return Ok(());
}
let el = self.bump()?;
match &el {
SyntaxElement::Token(_) => {}
SyntaxElement::Node(n) if n.kind() == SyntaxKind::COMMAND => {
self.queue_children_after_name(n, true);
}
SyntaxElement::Node(n) if n.kind() == SyntaxKind::OPTIONAL => {}
_ => return Err(Stop::Abort),
}
}
}
}
#[cfg(test)]
mod segmentation_tests {
use super::*;
use crate::parser::parse;
use crate::syntax::SyntaxNode;
fn statements(src: &str) -> Vec<String> {
let parsed = parse(src);
assert!(parsed.errors.is_empty(), "test source should parse cleanly");
let root = SyntaxNode::new_root(parsed.green);
let para = root
.children()
.find(|n| n.kind() == SyntaxKind::PARAGRAPH)
.expect("a paragraph");
let elements: Vec<SyntaxElement> = para.children_with_tokens().collect();
statement_texts(&elements)
}
fn statement_texts(elements: &[SyntaxElement]) -> Vec<String> {
let map = segment_expl_statements(elements);
let mut out = Vec::new();
let mut cur = String::new();
for (i, el) in elements.iter().enumerate() {
cur.push_str(&el.to_string());
if map.boundary_after(i) {
let text = normalize(&cur);
if !text.is_empty() {
out.push(text);
}
cur.clear();
}
}
let tail = normalize(&cur);
if !tail.is_empty() {
out.push(tail);
}
out
}
fn normalize(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[test]
fn statements_are_structural_units() {
let got = statements(
"\\ExplSyntaxOn\n\\tl_set:Nn \\l_a\n { x }\n\\group_begin:\n\\ExplSyntaxOff\n",
);
assert_eq!(
got,
vec![
"\\ExplSyntaxOn",
"\\tl_set:Nn \\l_a { x }",
"\\group_begin:",
"\\ExplSyntaxOff",
]
);
}
#[test]
fn same_line_calls_split() {
let got =
statements("\\ExplSyntaxOn\n\\group_begin: \\int_zero:N \\l_a\n\\ExplSyntaxOff\n");
assert_eq!(
got,
vec![
"\\ExplSyntaxOn",
"\\group_begin:",
"\\int_zero:N \\l_a",
"\\ExplSyntaxOff",
]
);
}
#[test]
fn npn_definition_is_one_unit() {
let got =
statements("\\ExplSyntaxOn\n\\cs_new:Npn \\foo:n #1\n { body #1 }\n\\ExplSyntaxOff\n");
assert_eq!(
got,
vec![
"\\ExplSyntaxOn",
"\\cs_new:Npn \\foo:n #1 { body #1 }",
"\\ExplSyntaxOff",
]
);
}
#[test]
fn peel_back_reclaims_over_attached_group() {
let got = statements("\\ExplSyntaxOn\n\\cs_new:Nn \\foo:n\n { body }\n\\ExplSyntaxOff\n");
assert_eq!(
got,
vec![
"\\ExplSyntaxOn",
"\\cs_new:Nn \\foo:n { body }",
"\\ExplSyntaxOff",
]
);
}
#[test]
fn exp_args_chain_is_one_unit() {
let got = statements(
"\\ExplSyntaxOn\n\\exp_args:NNo \\tl_set:Nn \\l_a { \\l_b }\n\\ExplSyntaxOff\n",
);
assert_eq!(
got,
vec![
"\\ExplSyntaxOn",
"\\exp_args:NNo \\tl_set:Nn \\l_a { \\l_b }",
"\\ExplSyntaxOff",
]
);
}
#[test]
fn hash_parameter_satisfies_single_token_slot() {
let got = statements("\\ExplSyntaxOn\n\\tl_set:Nn #1 { x }\n\\ExplSyntaxOff\n");
assert_eq!(
got,
vec!["\\ExplSyntaxOn", "\\tl_set:Nn #1 { x }", "\\ExplSyntaxOff"]
);
}
#[test]
fn delimited_parameter_text_peels_the_body() {
let got = statements(
"\\ExplSyntaxOn\n\\cs_new:Npn \\foo:w #1 \\q_stop { body }\n\\ExplSyntaxOff\n",
);
assert_eq!(
got,
vec![
"\\ExplSyntaxOn",
"\\cs_new:Npn \\foo:w #1 \\q_stop { body }",
"\\ExplSyntaxOff",
]
);
}
#[test]
fn unknown_head_falls_back_to_its_line() {
let got = statements(
"\\ExplSyntaxOn\n\\exp_after:wN \\foo \\tl_set:Nn \\l_a { x }\n\\group_begin:\n\\ExplSyntaxOff\n",
);
assert_eq!(
got,
vec![
"\\ExplSyntaxOn",
"\\exp_after:wN \\foo \\tl_set:Nn \\l_a { x }",
"\\group_begin:",
"\\ExplSyntaxOff",
]
);
}
#[test]
fn shape_mismatch_falls_back() {
let got = statements("\\ExplSyntaxOn\n\\tl_set:Nn\n\\l_a\n\\ExplSyntaxOff\n");
assert_eq!(
got,
vec!["\\ExplSyntaxOn", "\\tl_set:Nn", "\\l_a", "\\ExplSyntaxOff"]
);
}
#[test]
fn trailing_comment_rides_the_statement() {
let got = statements("\\ExplSyntaxOn\n\\tl_set:Nn \\l_a { x } % note\n\\ExplSyntaxOff\n");
assert_eq!(
got,
vec![
"\\ExplSyntaxOn",
"\\tl_set:Nn \\l_a { x } % note",
"\\ExplSyntaxOff",
]
);
}
#[test]
fn leftover_attached_group_rides_the_statement() {
let got = statements("\\ExplSyntaxOn\n\\use:n { a } { b }\n\\ExplSyntaxOff\n");
assert_eq!(
got,
vec!["\\ExplSyntaxOn", "\\use:n { a } { b }", "\\ExplSyntaxOff"]
);
}
#[test]
fn conditional_call_is_one_unit() {
let got = statements(
"\\ExplSyntaxOn\n\\str_if_eq:nnTF { a } { b }\n { yes }\n { no }\n\\ExplSyntaxOff\n",
);
assert_eq!(
got,
vec![
"\\ExplSyntaxOn",
"\\str_if_eq:nnTF { a } { b } { yes } { no }",
"\\ExplSyntaxOff",
]
);
}
#[test]
fn blank_line_ends_the_unit() {
let src = "\\ExplSyntaxOn\n\\use:n { \\tl_set:Nn \\l_a\n\n { x } }\n\\ExplSyntaxOff\n";
let parsed = parse(src);
assert!(parsed.errors.is_empty());
let root = SyntaxNode::new_root(parsed.green);
let group = root
.descendants()
.find(|n| n.kind() == SyntaxKind::GROUP)
.expect("a group");
let body: Vec<SyntaxElement> = group
.children_with_tokens()
.filter(|el| !matches!(el.kind(), SyntaxKind::L_BRACE | SyntaxKind::R_BRACE))
.collect();
assert_eq!(statement_texts(&body), vec!["\\tl_set:Nn \\l_a", "{ x }"]);
}
#[test]
fn guard_mid_unit_aborts_to_fallback() {
use crate::parser::lexer::LexConfig;
use crate::parser::{LatexFlavor, parse_with_flavor};
let src = "% \\begin{macrocode}\n\\ExplSyntaxOn\n\\tl_set:Nn \\l_a\n%<latexrelease> { x }\n\\ExplSyntaxOff\n% \\end{macrocode}\n";
let config = LexConfig {
flavor: LatexFlavor::Package,
dtx: true,
};
let parsed = parse_with_flavor(src, config);
assert!(parsed.errors.is_empty(), "test source should parse cleanly");
let root = SyntaxNode::new_root(parsed.green);
let para = root
.descendants()
.find(|n| n.kind() == SyntaxKind::PARAGRAPH)
.expect("a paragraph");
let elements: Vec<SyntaxElement> = para.children_with_tokens().collect();
let map = segment_expl_statements(&elements);
assert_eq!(
statement_texts(&elements),
vec![
"\\ExplSyntaxOn",
"\\tl_set:Nn \\l_a %<latexrelease> { x }",
"\\ExplSyntaxOff",
]
);
let guarded_end = elements
.iter()
.position(|el| el.to_string().contains("latexrelease"))
.expect("the guarded sibling");
assert!(
map.is_fallback(guarded_end),
"the aborted unit must be a fallback statement"
);
}
#[test]
fn e_and_f_letters_consume_braced_groups() {
let got = statements(
"\\ExplSyntaxOn\n\\tl_set:Ne \\l_a\n { x }\n\\tl_set:Nf \\l_b\n { y }\n\\ExplSyntaxOff\n",
);
assert_eq!(
got,
vec![
"\\ExplSyntaxOn",
"\\tl_set:Ne \\l_a { x }",
"\\tl_set:Nf \\l_b { y }",
"\\ExplSyntaxOff",
]
);
}
#[test]
fn stream_ending_mid_unit_falls_back() {
let src = "\\ExplSyntaxOn\n\\use:n { \\tl_set:Nn \\l_a }\n\\ExplSyntaxOff\n";
let parsed = parse(src);
assert!(parsed.errors.is_empty());
let root = SyntaxNode::new_root(parsed.green);
let group = root
.descendants()
.find(|n| n.kind() == SyntaxKind::GROUP)
.expect("a group");
let body: Vec<SyntaxElement> = group
.children_with_tokens()
.filter(|el| !matches!(el.kind(), SyntaxKind::L_BRACE | SyntaxKind::R_BRACE))
.collect();
let map = segment_expl_statements(&body);
assert_eq!(statement_texts(&body), vec!["\\tl_set:Nn \\l_a"]);
let head = body
.iter()
.position(|el| el.as_node().is_some())
.expect("the head command");
assert!(
map.is_fallback(head),
"a unit cut off by the stream end must be a fallback statement"
);
}
#[test]
fn own_line_comment_in_attached_span_rides_the_sibling() {
let got =
statements("\\ExplSyntaxOn\n\\tl_set:Nn \\l_a\n% note\n { x }\n\\ExplSyntaxOff\n");
assert_eq!(
got,
vec![
"\\ExplSyntaxOn",
"\\tl_set:Nn \\l_a % note { x }",
"\\ExplSyntaxOff",
]
);
}
#[test]
fn own_line_comment_at_sibling_level_ends_the_unit() {
let got = statements(
"\\ExplSyntaxOn\n\\cs_new:Npn \\foo:n\n% note\n#1 { body }\n\\ExplSyntaxOff\n",
);
assert_eq!(
got,
vec![
"\\ExplSyntaxOn",
"\\cs_new:Npn \\foo:n",
"% note",
"#1 { body }",
"\\ExplSyntaxOff",
]
);
}
}