use super::types::{
Block, CondBranch, Conditional, Content, ContentPart, HirFile, Sequence, SequenceBranch,
SequenceType, Stmt, Tag,
};
pub fn normalize_file(hir: &mut HirFile) {
normalize_block(&mut hir.root_content);
for knot in &mut hir.knots {
normalize_block(&mut knot.body);
for stitch in &mut knot.stitches {
normalize_block(&mut stitch.body);
}
}
}
fn normalize_block(block: &mut Block) {
let old_stmts = std::mem::take(&mut block.stmts);
let mut new_stmts = Vec::with_capacity(old_stmts.len());
let mut iter = old_stmts.into_iter().peekable();
while let Some(stmt) = iter.next() {
match stmt {
Stmt::Content(content) => {
let trailing_eol = matches!(iter.peek(), Some(Stmt::EndOfLine));
match try_lift_inline(content, trailing_eol) {
Ok(lifted_stmts) => {
if trailing_eol {
let _ = iter.next();
}
new_stmts.extend(lifted_stmts);
}
Err(content) => {
new_stmts.push(Stmt::Content(content));
}
}
}
Stmt::ChoiceSet(mut cs) => {
for choice in &mut cs.choices {
normalize_block(&mut choice.body);
}
normalize_block(&mut cs.continuation);
new_stmts.push(Stmt::ChoiceSet(cs));
}
Stmt::LabeledBlock(mut lb) => {
normalize_block(&mut lb);
new_stmts.push(Stmt::LabeledBlock(lb));
}
Stmt::Conditional(mut cond) => {
for branch in &mut cond.branches {
normalize_block(&mut branch.body);
}
new_stmts.push(Stmt::Conditional(cond));
}
Stmt::Sequence(mut seq) => {
for branch in &mut seq.branches {
normalize_block(&mut branch.body);
}
new_stmts.push(Stmt::Sequence(seq));
}
other => new_stmts.push(other),
}
}
block.stmts = new_stmts;
for stmt in &mut block.stmts {
match stmt {
Stmt::Sequence(seq) => {
for branch in &mut seq.branches {
normalize_block(&mut branch.body);
}
}
Stmt::Conditional(cond) => {
for branch in &mut cond.branches {
normalize_block(&mut branch.body);
}
}
_ => {}
}
}
}
fn try_lift_inline(content: Content, trailing_eol: bool) -> Result<Vec<Stmt>, Content> {
let inline_idx = content.parts.iter().position(|p| {
matches!(
p,
ContentPart::InlineSequence(_) | ContentPart::InlineConditional(_)
)
});
let Some(idx) = inline_idx else {
return Err(content);
};
let prefix: Vec<ContentPart> = content.parts[..idx].to_vec();
let suffix: Vec<ContentPart> = content.parts[idx + 1..].to_vec();
let tags = &content.tags;
let ptr = content.ptr;
match &content.parts[idx] {
ContentPart::InlineSequence(seq) => {
let mut branches = Vec::with_capacity(seq.branches.len() + 1);
for branch in &seq.branches {
let mut b = branch.body.clone();
splice_around(&mut b, &prefix, &suffix, tags, ptr);
if trailing_eol {
b.stmts.push(Stmt::EndOfLine);
}
b.recompute_tail();
branches.push(SequenceBranch {
ptr: branch.ptr,
body: b,
});
}
let is_plain_once =
seq.kind.contains(SequenceType::ONCE) && !seq.kind.contains(SequenceType::SHUFFLE);
let kind = if is_plain_once && (!prefix.is_empty() || !suffix.is_empty()) {
let mut exhausted = Block::default();
splice_around(&mut exhausted, &prefix, &suffix, tags, ptr);
if trailing_eol {
exhausted.stmts.push(Stmt::EndOfLine);
}
exhausted.recompute_tail();
branches.push(SequenceBranch {
ptr: seq.ptr,
body: exhausted,
});
(seq.kind & !SequenceType::ONCE) | SequenceType::STOPPING
} else {
seq.kind
};
Ok(vec![Stmt::Sequence(Sequence {
ptr: seq.ptr,
kind,
branches,
container_id: None,
})])
}
ContentPart::InlineConditional(cond) => {
let mut branches = Vec::with_capacity(cond.branches.len() + 1);
for branch in &cond.branches {
let mut body = branch.body.clone();
splice_around(&mut body, &prefix, &suffix, tags, ptr);
if trailing_eol {
body.stmts.push(Stmt::EndOfLine);
}
body.recompute_tail();
branches.push(CondBranch {
ptr: branch.ptr,
condition: branch.condition.clone(),
binding: branch.binding.clone(),
body,
container_id: None,
});
}
let has_else = branches.iter().any(|b| b.condition.is_none());
if !has_else && (!prefix.is_empty() || !suffix.is_empty()) {
let mut else_body = Block::default();
splice_around(&mut else_body, &prefix, &suffix, tags, ptr);
if trailing_eol {
else_body.stmts.push(Stmt::EndOfLine);
}
else_body.recompute_tail();
branches.push(CondBranch {
ptr: cond.ptr,
condition: None,
binding: None,
body: else_body,
container_id: None,
});
}
Ok(vec![Stmt::Conditional(Conditional {
ptr: cond.ptr,
kind: cond.kind.clone(),
branches,
})])
}
_ => unreachable!("position() matched only InlineSequence/InlineConditional"),
}
}
fn extend_merging_text(parts: &mut Vec<ContentPart>, extra: &[ContentPart]) {
for part in extra {
if let (Some(ContentPart::Text(last)), ContentPart::Text(next)) = (parts.last_mut(), part) {
if last.ends_with(char::is_whitespace) && next.starts_with(char::is_whitespace) {
last.push_str(next.trim_start());
} else {
last.push_str(next);
}
} else {
parts.push(part.clone());
}
}
}
fn splice_around(
block: &mut Block,
prefix: &[ContentPart],
suffix: &[ContentPart],
tags: &[Tag],
ptr: Option<crate::Provenance>,
) {
let has_prefix = !prefix.is_empty();
let has_suffix = !suffix.is_empty();
if !has_prefix && !has_suffix && tags.is_empty() {
return;
}
if block.stmts.is_empty() {
let mut parts = prefix.to_vec();
extend_merging_text(&mut parts, suffix);
if !parts.is_empty() || !tags.is_empty() {
block.stmts.push(Stmt::Content(Content {
ptr,
parts,
tags: tags.to_vec(),
}));
}
return;
}
if block.stmts.len() == 1
&& let Stmt::Content(ref mut c) = block.stmts[0]
{
let mut new_parts = prefix.to_vec();
let original = std::mem::take(&mut c.parts);
extend_merging_text(&mut new_parts, &original);
extend_merging_text(&mut new_parts, suffix);
c.parts = new_parts;
c.tags.extend_from_slice(tags);
if c.ptr.is_none() {
c.ptr = ptr;
}
return;
}
let first_content_idx = block
.stmts
.iter()
.position(|s| matches!(s, Stmt::Content(_)));
let last_content_idx = block
.stmts
.iter()
.rposition(|s| matches!(s, Stmt::Content(_)));
if let (Some(first), Some(last)) = (first_content_idx, last_content_idx) {
if has_prefix && let Stmt::Content(ref mut c) = block.stmts[first] {
let mut new_parts = prefix.to_vec();
let original = std::mem::take(&mut c.parts);
extend_merging_text(&mut new_parts, &original);
c.parts = new_parts;
c.tags.extend_from_slice(tags);
if c.ptr.is_none() {
c.ptr = ptr;
}
} else if !tags.is_empty()
&& let Stmt::Content(ref mut c) = block.stmts[first]
{
c.tags.extend_from_slice(tags);
}
if has_suffix && let Stmt::Content(ref mut c) = block.stmts[last] {
extend_merging_text(&mut c.parts, suffix);
}
} else {
let mut parts = prefix.to_vec();
extend_merging_text(&mut parts, suffix);
if !parts.is_empty() || !tags.is_empty() {
block.stmts.insert(
0,
Stmt::Content(Content {
ptr,
parts,
tags: tags.to_vec(),
}),
);
}
}
}
#[cfg(test)]
#[expect(clippy::panic)]
mod tests {
use super::super::types::*;
use super::normalize_file;
fn dummy_ptr() -> crate::Provenance {
crate::Provenance::synthetic(
crate::provenance::NodeClass::Content,
rowan::TextRange::new(rowan::TextSize::new(0), rowan::TextSize::new(6)),
)
}
fn dummy_tag_ptr() -> crate::Provenance {
crate::Provenance::synthetic(
crate::provenance::NodeClass::Tag,
rowan::TextRange::new(rowan::TextSize::new(6), rowan::TextSize::new(10)),
)
}
fn dummy_choice_ptr() -> crate::Provenance {
crate::Provenance::synthetic(
crate::provenance::NodeClass::Choice,
rowan::TextRange::new(rowan::TextSize::new(0), rowan::TextSize::new(8)),
)
}
fn text(s: &str) -> ContentPart {
ContentPart::Text(s.to_string())
}
fn mk_content(parts: Vec<ContentPart>) -> Content {
Content {
ptr: Some(dummy_ptr()),
parts,
tags: Vec::new(),
}
}
fn mk_content_with_tags(parts: Vec<ContentPart>, tags: Vec<Tag>) -> Content {
Content {
ptr: Some(dummy_ptr()),
parts,
tags,
}
}
fn mk_inline_seq(kind: SequenceType, branches: Vec<Vec<ContentPart>>) -> ContentPart {
let ptr = dummy_ptr();
ContentPart::InlineSequence(Sequence {
ptr,
kind,
branches: branches
.into_iter()
.map(|parts| {
let stmts = if parts.is_empty() {
Vec::new()
} else {
vec![Stmt::Content(Content {
ptr: Some(ptr),
parts,
tags: Vec::new(),
})]
};
let tail = crate::tail_from_stmts(&stmts);
SequenceBranch {
ptr,
body: Block {
label: None,
stmts,
container_id: None,
tail,
},
}
})
.collect(),
container_id: None,
})
}
fn mk_inline_cond(branches: Vec<(Option<Expr>, Vec<ContentPart>)>) -> ContentPart {
let ptr = dummy_ptr();
ContentPart::InlineConditional(Conditional {
ptr,
kind: CondKind::InitialCondition,
branches: branches
.into_iter()
.map(|(condition, parts)| {
let stmts = if parts.is_empty() {
Vec::new()
} else {
vec![Stmt::Content(Content {
ptr: Some(ptr),
parts,
tags: Vec::new(),
})]
};
let tail = crate::tail_from_stmts(&stmts);
CondBranch {
ptr,
condition,
binding: None,
body: Block {
label: None,
stmts,
container_id: None,
tail,
},
container_id: None,
}
})
.collect(),
})
}
fn mk_tag(s: &str) -> Tag {
Tag {
parts: vec![ContentPart::Text(s.to_string())],
ptr: dummy_tag_ptr(),
}
}
fn mk_block(stmts: Vec<Stmt>) -> Block {
let tail = crate::tail_from_stmts(&stmts);
Block {
label: None,
stmts,
container_id: None,
tail,
}
}
fn mk_hir(stmts: Vec<Stmt>) -> HirFile {
HirFile {
root_content: mk_block(stmts),
knots: Vec::new(),
variables: Vec::new(),
constants: Vec::new(),
lists: Vec::new(),
structs: Vec::new(),
externals: Vec::new(),
includes: Vec::new(),
module: None,
imports: Vec::new(),
visibility: Vec::new(),
was_directives: Vec::new(),
allow_scopes: Vec::new(),
element_matches: Vec::new(),
cue_names: Vec::new(),
native: false,
claim_handlers: Vec::new(),
dispatch_handlers: Vec::new(),
}
}
fn content_text(content: &Content) -> String {
content
.parts
.iter()
.filter_map(|p| {
if let ContentPart::Text(s) = p {
Some(s.as_str())
} else {
None
}
})
.collect()
}
#[test]
fn lifted_conditional_branch_with_divert_recomputes_tail() {
let divert_body = mk_block(vec![Stmt::Divert(Divert {
ptr: None,
target: DivertTarget {
path: DivertPath::End,
args: Vec::new(),
},
})]);
assert!(
matches!(divert_body.tail, Tail::Diverge(_)),
"precondition: a bare-divert body has a Diverge tail"
);
let inline_cond = ContentPart::InlineConditional(Conditional {
ptr: dummy_ptr(),
kind: CondKind::InitialCondition,
branches: vec![CondBranch {
ptr: dummy_ptr(),
condition: Some(Expr::Bool(true)),
binding: None,
body: divert_body,
container_id: None,
}],
});
let content = mk_content(vec![text("A "), inline_cond]);
let mut hir = mk_hir(vec![Stmt::Content(content), Stmt::EndOfLine]);
normalize_file(&mut hir);
let cond = hir
.root_content
.stmts
.iter()
.find_map(|s| match s {
Stmt::Conditional(c) => Some(c),
_ => None,
})
.expect("inline conditional lifted to a Conditional stmt");
for branch in &cond.branches {
assert_eq!(
branch.body.tail,
crate::tail_from_stmts(&branch.body.stmts),
"lifted branch tail must match its stmts (not stale): {:?}",
branch.body
);
}
}
#[test]
fn simple_sequence_lift() {
let content = mk_content(vec![
text("It's "),
mk_inline_seq(
SequenceType::STOPPING,
vec![vec![text("a fine")], vec![text("a good")]],
),
text(" day."),
]);
let mut hir = mk_hir(vec![Stmt::Content(content), Stmt::EndOfLine]);
normalize_file(&mut hir);
assert_eq!(hir.root_content.stmts.len(), 1);
let Stmt::Sequence(seq) = &hir.root_content.stmts[0] else {
panic!("expected Sequence, got {:?}", hir.root_content.stmts[0]);
};
assert_eq!(seq.kind, SequenceType::STOPPING);
assert_eq!(seq.branches.len(), 2);
assert_eq!(seq.branches[0].body.stmts.len(), 2);
let Stmt::Content(c0) = &seq.branches[0].body.stmts[0] else {
panic!("expected Content");
};
assert_eq!(content_text(c0), "It's a fine day.");
assert!(matches!(seq.branches[0].body.stmts[1], Stmt::EndOfLine));
let Stmt::Content(c1) = &seq.branches[1].body.stmts[0] else {
panic!("expected Content");
};
assert_eq!(content_text(c1), "It's a good day.");
assert!(matches!(seq.branches[1].body.stmts[1], Stmt::EndOfLine));
}
#[test]
fn simple_conditional_lift() {
let cond_expr = Expr::Bool(true);
let content = mk_content(vec![
text("I'm "),
mk_inline_cond(vec![
(Some(cond_expr), vec![text("very")]),
(None, vec![text("not")]),
]),
text(" pleased."),
]);
let mut hir = mk_hir(vec![Stmt::Content(content), Stmt::EndOfLine]);
normalize_file(&mut hir);
assert_eq!(hir.root_content.stmts.len(), 1);
let Stmt::Conditional(cond) = &hir.root_content.stmts[0] else {
panic!("expected Conditional");
};
assert_eq!(cond.branches.len(), 2);
let Stmt::Content(c0) = &cond.branches[0].body.stmts[0] else {
panic!("expected Content");
};
assert_eq!(content_text(c0), "I'm very pleased.");
let Stmt::Content(c1) = &cond.branches[1].body.stmts[0] else {
panic!("expected Content");
};
assert_eq!(content_text(c1), "I'm not pleased.");
}
#[test]
fn tag_propagation() {
let content = mk_content_with_tags(
vec![
text("Hello "),
mk_inline_seq(
SequenceType::CYCLE,
vec![vec![text("world")], vec![text("there")]],
),
],
vec![mk_tag("greeting")],
);
let mut hir = mk_hir(vec![Stmt::Content(content), Stmt::EndOfLine]);
normalize_file(&mut hir);
let Stmt::Sequence(seq) = &hir.root_content.stmts[0] else {
panic!("expected Sequence");
};
let Stmt::Content(c0) = &seq.branches[0].body.stmts[0] else {
panic!("expected Content");
};
assert_eq!(c0.tags.len(), 1);
let Stmt::Content(c1) = &seq.branches[1].body.stmts[0] else {
panic!("expected Content");
};
assert_eq!(c1.tags.len(), 1);
}
#[test]
fn eol_absorption() {
let content = mk_content(vec![
text("a "),
mk_inline_seq(
SequenceType::STOPPING,
vec![vec![text("x")], vec![text("y")]],
),
text(" b"),
]);
let mut hir = mk_hir(vec![Stmt::Content(content)]);
normalize_file(&mut hir);
let Stmt::Sequence(seq) = &hir.root_content.stmts[0] else {
panic!("expected Sequence");
};
assert_eq!(seq.branches[0].body.stmts.len(), 1);
}
#[test]
fn empty_branch_gets_prefix_suffix() {
let content = mk_content(vec![
text("It's "),
mk_inline_seq(
SequenceType::STOPPING,
vec![vec![text("a")], vec![], vec![text("c")]],
),
text(" fine"),
]);
let mut hir = mk_hir(vec![Stmt::Content(content), Stmt::EndOfLine]);
normalize_file(&mut hir);
let Stmt::Sequence(seq) = &hir.root_content.stmts[0] else {
panic!("expected Sequence");
};
assert_eq!(seq.branches.len(), 3);
let Stmt::Content(c1) = &seq.branches[1].body.stmts[0] else {
panic!("expected Content in empty branch");
};
assert_eq!(content_text(c1), "It's fine");
assert_eq!(
c1.parts.len(),
1,
"prefix+suffix should merge into a single Text part so the \
recognizer can match Plain"
);
}
#[test]
fn no_inline_passes_through() {
let content = mk_content(vec![text("Just plain text.")]);
let mut hir = mk_hir(vec![Stmt::Content(content), Stmt::EndOfLine]);
normalize_file(&mut hir);
assert_eq!(hir.root_content.stmts.len(), 2);
assert!(matches!(hir.root_content.stmts[0], Stmt::Content(_)));
assert!(matches!(hir.root_content.stmts[1], Stmt::EndOfLine));
}
#[test]
fn recursion_into_choice_body() {
let body_content = mk_content(vec![
text("It's "),
mk_inline_seq(
SequenceType::STOPPING,
vec![vec![text("a")], vec![text("b")]],
),
]);
let choice = Choice {
ptr: dummy_choice_ptr(),
is_sticky: false,
is_fallback: false,
label: None,
condition: None,
binding: None,
start_content: Some(mk_content(vec![text("Pick")])),
bracket_content: None,
inner_content: None,
tags: Vec::new(),
body: mk_block(vec![Stmt::Content(body_content), Stmt::EndOfLine]),
container_id: None,
};
let cs = ChoiceSet {
choices: vec![choice],
continuation: mk_block(vec![]),
context: ChoiceSetContext::Weave,
depth: 1,
gather_id: None,
};
let mut hir = mk_hir(vec![Stmt::ChoiceSet(Box::new(cs))]);
normalize_file(&mut hir);
let Stmt::ChoiceSet(ref cs) = hir.root_content.stmts[0] else {
panic!("expected ChoiceSet");
};
assert_eq!(cs.choices[0].body.stmts.len(), 1);
assert!(matches!(cs.choices[0].body.stmts[0], Stmt::Sequence(_)));
}
#[test]
fn recursion_into_conditional_branches() {
let body_content = mk_content(vec![
text("Hello "),
mk_inline_seq(SequenceType::CYCLE, vec![vec![text("x")], vec![text("y")]]),
]);
let cond = Conditional {
ptr: dummy_ptr(),
kind: CondKind::IfElse,
branches: vec![CondBranch {
ptr: dummy_ptr(),
condition: Some(Expr::Bool(true)),
binding: None,
body: mk_block(vec![Stmt::Content(body_content), Stmt::EndOfLine]),
container_id: None,
}],
};
let mut hir = mk_hir(vec![Stmt::Conditional(cond)]);
normalize_file(&mut hir);
let Stmt::Conditional(ref c) = hir.root_content.stmts[0] else {
panic!("expected Conditional");
};
assert_eq!(c.branches[0].body.stmts.len(), 1);
assert!(matches!(c.branches[0].body.stmts[0], Stmt::Sequence(_)));
}
}