use super::types::{
Block, CondBranch, Conditional, Content, ContentPart, HirFile, Sequence, 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(branch);
}
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(branch);
}
}
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.clone();
splice_around(&mut b, &prefix, &suffix, tags, ptr);
if trailing_eol {
b.stmts.push(Stmt::EndOfLine);
}
branches.push(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);
}
branches.push(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);
}
branches.push(CondBranch {
condition: branch.condition.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);
}
branches.push(CondBranch {
condition: 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 splice_around(
block: &mut Block,
prefix: &[ContentPart],
suffix: &[ContentPart],
tags: &[Tag],
ptr: Option<brink_syntax::ast::SyntaxNodePtr>,
) {
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();
parts.extend_from_slice(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();
new_parts.append(&mut c.parts);
new_parts.extend_from_slice(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();
new_parts.append(&mut c.parts);
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] {
c.parts.extend_from_slice(suffix);
}
} else {
let mut parts = prefix.to_vec();
parts.extend_from_slice(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, clippy::items_after_statements)]
mod tests {
use brink_syntax::ast::{AstNode, SyntaxNodePtr};
use super::super::types::*;
use super::normalize_file;
fn dummy_ptr() -> SyntaxNodePtr {
let parsed = brink_syntax::parse("hello\n");
let root = parsed.tree().syntax().clone();
SyntaxNodePtr::from_node(&root)
}
fn dummy_tag_ptr() -> brink_syntax::ast::AstPtr<brink_syntax::ast::Tag> {
let parsed = brink_syntax::parse("hello #tag\n");
let root = parsed.tree().syntax().clone();
fn find_tag(
node: &brink_syntax::SyntaxNode,
) -> Option<brink_syntax::ast::AstPtr<brink_syntax::ast::Tag>> {
use brink_syntax::ast::AstNode;
if let Some(tag) = brink_syntax::ast::Tag::cast(node.clone()) {
return Some(brink_syntax::ast::AstPtr::new(&tag));
}
for child in node.children() {
if let Some(ptr) = find_tag(&child) {
return Some(ptr);
}
}
None
}
find_tag(&root).expect("should find Tag node in 'hello #tag'")
}
fn dummy_choice_ptr() -> brink_syntax::ast::AstPtr<brink_syntax::ast::Choice> {
let parsed = brink_syntax::parse("* choice\n");
let root = parsed.tree().syntax().clone();
fn find_choice(
node: &brink_syntax::SyntaxNode,
) -> Option<brink_syntax::ast::AstPtr<brink_syntax::ast::Choice>> {
use brink_syntax::ast::AstNode;
if let Some(choice) = brink_syntax::ast::Choice::cast(node.clone()) {
return Some(brink_syntax::ast::AstPtr::new(&choice));
}
for child in node.children() {
if let Some(ptr) = find_choice(&child) {
return Some(ptr);
}
}
None
}
find_choice(&root).expect("should find Choice node in '* choice'")
}
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| Block {
label: None,
stmts: if parts.is_empty() {
Vec::new()
} else {
vec![Stmt::Content(Content {
ptr: Some(ptr),
parts,
tags: Vec::new(),
})]
},
container_id: None,
})
.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)| CondBranch {
condition,
body: Block {
label: None,
stmts: if parts.is_empty() {
Vec::new()
} else {
vec![Stmt::Content(Content {
ptr: Some(ptr),
parts,
tags: Vec::new(),
})]
},
container_id: None,
},
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 {
Block {
label: None,
stmts,
container_id: None,
}
}
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(),
externals: Vec::new(),
includes: 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 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].stmts.len(), 2);
let Stmt::Content(c0) = &seq.branches[0].stmts[0] else {
panic!("expected Content");
};
assert_eq!(content_text(c0), "It's a fine day.");
assert!(matches!(seq.branches[0].stmts[1], Stmt::EndOfLine));
let Stmt::Content(c1) = &seq.branches[1].stmts[0] else {
panic!("expected Content");
};
assert_eq!(content_text(c1), "It's a good day.");
assert!(matches!(seq.branches[1].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].stmts[0] else {
panic!("expected Content");
};
assert_eq!(c0.tags.len(), 1);
let Stmt::Content(c1) = &seq.branches[1].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].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].stmts[0] else {
panic!("expected Content in empty branch");
};
assert_eq!(content_text(c1), "It's fine");
}
#[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,
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 {
condition: Some(Expr::Bool(true)),
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(_)));
}
}