use super::types::{
Block, CondBranch, Conditional, Content, ContentPart, Expr, HirFile, Name, Path, Sequence,
SequenceBranch, SequenceType, Stmt, Tag, TempDecl,
};
pub const SYNTHETIC_TEMP_PREFIX: &str = "$lift";
#[must_use]
pub fn is_synthetic_temp_name(name: &str) -> bool {
name.starts_with(SYNTHETIC_TEMP_PREFIX)
}
#[derive(Default)]
struct Hoister {
next: u32,
}
impl Hoister {
fn fresh(&mut self) -> String {
let n = self.next;
self.next += 1;
format!("{SYNTHETIC_TEMP_PREFIX}{n}")
}
}
pub fn normalize_file(hir: &mut HirFile) {
let mut hoister = Hoister::default();
normalize_block(&mut hir.root_content, &mut hoister);
for knot in &mut hir.knots {
normalize_block(&mut knot.body, &mut hoister);
for stitch in &mut knot.stitches {
normalize_block(&mut stitch.body, &mut hoister);
}
}
}
fn normalize_block(block: &mut Block, hoister: &mut Hoister) {
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) => {
if crate::lir::lower::recognize::claims_variant_line(&content) {
new_stmts.push(Stmt::Content(content));
continue;
}
let trailing_eol = matches!(iter.peek(), Some(Stmt::EndOfLine));
match try_lift_inline(content, trailing_eol, hoister) {
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, hoister);
}
normalize_block(&mut cs.continuation, hoister);
new_stmts.push(Stmt::ChoiceSet(cs));
}
Stmt::LabeledBlock(mut lb) => {
normalize_block(&mut lb, hoister);
new_stmts.push(Stmt::LabeledBlock(lb));
}
Stmt::Conditional(mut cond) => {
for branch in &mut cond.branches {
normalize_block(&mut branch.body, hoister);
}
new_stmts.push(Stmt::Conditional(cond));
}
Stmt::Sequence(mut seq) => {
for branch in &mut seq.branches {
normalize_block(&mut branch.body, hoister);
}
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, hoister);
}
}
Stmt::Conditional(cond) => {
for branch in &mut cond.branches {
normalize_block(&mut branch.body, hoister);
}
}
_ => {}
}
}
}
fn try_lift_inline(
content: Content,
trailing_eol: bool,
hoister: &mut Hoister,
) -> Result<Vec<Stmt>, Content> {
let Some(idx) = lift_index(&content.parts) else {
return Err(content);
};
let mut 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;
let mut stmts = hoist_prefix(&mut prefix, hoister, ptr);
let lifted = match &content.parts[idx] {
ContentPart::InlineSequence(seq) => {
let mut branches = Vec::with_capacity(seq.branches.len() + 1);
for (branch_idx, branch) in seq.branches.iter().enumerate() {
let mut b = branch.body.clone();
let salt = lift_salt(nonce_of(seq.container_id), branch_idx);
let (p, s, t) = salted_splice_sources(&prefix, &suffix, tags, salt);
splice_around(&mut b, &p, &s, &t, 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();
let salt = lift_salt(nonce_of(seq.container_id), seq.branches.len());
let (p, s, t) = salted_splice_sources(&prefix, &suffix, tags, salt);
splice_around(&mut exhausted, &p, &s, &t, ptr);
if trailing_eol {
exhausted.stmts.push(Stmt::EndOfLine);
}
exhausted.recompute_tail();
exhausted.container_id = seq
.container_id
.map(|id| super::stamp::derive_id(id, "exhausted", 0));
branches.push(SequenceBranch {
ptr: seq.ptr,
body: exhausted,
});
(seq.kind & !SequenceType::ONCE) | SequenceType::STOPPING
} else {
seq.kind
};
Stmt::Sequence(Sequence {
ptr: seq.ptr,
kind,
branches,
container_id: seq.container_id,
counter_id: seq.counter_id,
})
}
ContentPart::InlineConditional(cond) => {
let mut branches = Vec::with_capacity(cond.branches.len() + 1);
let nonce = conditional_nonce(cond);
for (branch_idx, branch) in cond.branches.iter().enumerate() {
let mut body = branch.body.clone();
let salt = lift_salt(nonce, branch_idx);
let (p, s, t) = salted_splice_sources(&prefix, &suffix, tags, salt);
splice_around(&mut body, &p, &s, &t, 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: branch.container_id,
});
}
let has_else = branches.iter().any(|b| b.condition.is_none());
if !has_else && (!prefix.is_empty() || !suffix.is_empty() || trailing_eol) {
branches.push(synthesized_else_branch(
cond,
&prefix,
&suffix,
tags,
ptr,
trailing_eol,
));
}
Stmt::Conditional(Conditional {
ptr: cond.ptr,
kind: cond.kind.clone(),
branches,
})
}
_ => unreachable!("position() matched only InlineSequence/InlineConditional"),
};
stmts.push(lifted);
Ok(stmts)
}
fn hoist_prefix(
prefix: &mut [ContentPart],
hoister: &mut Hoister,
line_ptr: Option<crate::Provenance>,
) -> Vec<Stmt> {
let mut out = Vec::new();
hoist_parts(prefix, hoister, line_ptr, &mut out);
out
}
fn hoist_parts(
parts: &mut [ContentPart],
hoister: &mut Hoister,
line_ptr: Option<crate::Provenance>,
out: &mut Vec<Stmt>,
) {
for part in parts {
match part {
ContentPart::Interpolation(expr) => {
if is_synthetic_read(expr) {
continue;
}
let text = hoister.fresh();
let name = Name {
text: text.clone(),
range: rowan::TextRange::default(),
};
let value = std::mem::replace(expr, synthetic_read(text));
out.push(Stmt::TempDecl(TempDecl {
ptr: line_ptr.unwrap_or_else(|| {
crate::Provenance::synthetic(
crate::provenance::NodeClass::TempDecl,
rowan::TextRange::default(),
)
}),
name,
value: Some(value),
annotation: None,
synthetic: true,
}));
}
ContentPart::Span(span) => hoist_parts(&mut span.children, hoister, line_ptr, out),
ContentPart::Text(_)
| ContentPart::Glue
| ContentPart::Spring
| ContentPart::InlineConditional(_)
| ContentPart::InlineSequence(_) => {}
}
}
}
fn synthetic_read(text: String) -> Expr {
Expr::Path(Path {
segments: vec![Name {
text,
range: rowan::TextRange::default(),
}],
range: rowan::TextRange::default(),
crosses_module_wall: false,
})
}
fn is_synthetic_read(expr: &Expr) -> bool {
matches!(expr, Expr::Path(p) if p.segments.len() == 1 && is_synthetic_temp_name(&p.segments[0].text))
}
fn lift_index(parts: &[ContentPart]) -> Option<usize> {
parts
.iter()
.position(|p| match p {
ContentPart::InlineConditional(cond) => {
cond.branches.iter().any(|b| block_contains_label(&b.body))
}
_ => false,
})
.or_else(|| {
parts.iter().position(|p| {
matches!(
p,
ContentPart::InlineSequence(_) | ContentPart::InlineConditional(_)
)
})
})
}
fn block_contains_label(block: &Block) -> bool {
block.label.is_some() || block.stmts.iter().any(stmt_contains_label)
}
fn stmt_contains_label(stmt: &Stmt) -> bool {
match stmt {
Stmt::ChoiceSet(cs) => {
cs.continuation.label.is_some()
|| cs
.choices
.iter()
.any(|c| c.label.is_some() || block_contains_label(&c.body))
|| block_contains_label(&cs.continuation)
}
Stmt::LabeledBlock(b) => block_contains_label(b),
Stmt::Conditional(cond) => cond.branches.iter().any(|b| block_contains_label(&b.body)),
Stmt::Sequence(seq) => seq.branches.iter().any(|b| block_contains_label(&b.body)),
Stmt::Content(c) => c.parts.iter().any(content_part_contains_label),
_ => false,
}
}
fn content_part_contains_label(part: &ContentPart) -> bool {
match part {
ContentPart::InlineConditional(cond) => {
cond.branches.iter().any(|b| block_contains_label(&b.body))
}
ContentPart::InlineSequence(seq) => {
seq.branches.iter().any(|b| block_contains_label(&b.body))
}
ContentPart::Span(span) => span.children.iter().any(content_part_contains_label),
_ => false,
}
}
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 synthesized_else_branch(
cond: &Conditional,
prefix: &[ContentPart],
suffix: &[ContentPart],
tags: &[Tag],
ptr: Option<crate::Provenance>,
trailing_eol: bool,
) -> CondBranch {
let mut else_body = Block::default();
let salt = lift_salt(conditional_nonce(cond), cond.branches.len());
let (p, s, t) = salted_splice_sources(prefix, suffix, tags, salt);
splice_around(&mut else_body, &p, &s, &t, ptr);
if trailing_eol {
else_body.stmts.push(Stmt::EndOfLine);
}
else_body.recompute_tail();
CondBranch {
ptr: cond.ptr,
condition: None,
binding: None,
body: else_body,
container_id: cond
.branches
.last()
.and_then(|b| b.container_id)
.map(|id| super::stamp::derive_id(id, "synth-else", 0)),
}
}
fn nonce_of(id: Option<brink_format::DefinitionId>) -> u64 {
id.map_or(0, brink_format::DefinitionId::to_raw)
}
fn conditional_nonce(cond: &Conditional) -> u64 {
nonce_of(cond.branches.first().and_then(|b| b.container_id))
}
fn lift_salt(nonce: u64, branch_idx: usize) -> u64 {
if branch_idx == 0 {
return 0;
}
let mut hasher = std::collections::hash_map::DefaultHasher::new();
std::hash::Hash::hash(&nonce, &mut hasher);
std::hash::Hash::hash(&(branch_idx as u64), &mut hasher);
std::hash::Hasher::finish(&hasher).max(1)
}
fn salted_splice_sources(
prefix: &[ContentPart],
suffix: &[ContentPart],
tags: &[Tag],
salt: u64,
) -> (Vec<ContentPart>, Vec<ContentPart>, Vec<Tag>) {
let mut p = prefix.to_vec();
let mut s = suffix.to_vec();
let mut t = tags.to_vec();
super::stamp::rederive_cloned_parts(&mut p, salt);
super::stamp::rederive_cloned_parts(&mut s, salt);
for tag in &mut t {
super::stamp::rederive_cloned_parts(&mut tag.parts, salt);
}
(p, s, t)
}
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 ((has_prefix || has_suffix) && ptr.is_some()) || 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 ptr.is_some() || 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,
counter_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."),
ContentPart::Glue,
]);
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")]],
),
ContentPart::Glue,
],
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"),
ContentPart::Glue,
]);
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::SHUFFLE | SequenceType::ONCE,
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_cond(vec![
(Some(Expr::Bool(true)), vec![text("a")]),
(None, 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::Conditional(_)));
}
#[test]
fn recursion_into_conditional_branches() {
let body_content = mk_content(vec![
text("Hello "),
mk_inline_cond(vec![
(Some(Expr::Bool(true)), vec![text("x")]),
(None, 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::Conditional(_)));
}
#[test]
fn variant_claimed_line_is_not_lifted() {
let content = mk_content(vec![
text("Line: "),
mk_inline_seq(
SequenceType::STOPPING,
vec![vec![text("a")], vec![text("b")]],
),
text(" "),
mk_inline_seq(
SequenceType::STOPPING,
vec![vec![text("x")], vec![text("y")]],
),
]);
let mut hir = mk_hir(vec![Stmt::Content(content), Stmt::EndOfLine]);
normalize_file(&mut hir);
assert_eq!(
hir.root_content.stmts.len(),
2,
"claimed line passes through whole: {:?}",
hir.root_content.stmts
);
assert!(matches!(hir.root_content.stmts[0], Stmt::Content(_)));
assert!(matches!(hir.root_content.stmts[1], Stmt::EndOfLine));
}
#[test]
fn combo_kind_line_still_lifts() {
let content = mk_content(vec![
text("Line: "),
mk_inline_seq(
SequenceType::SHUFFLE | SequenceType::ONCE,
vec![vec![text("a")], vec![text("b")]],
),
]);
let mut hir = mk_hir(vec![Stmt::Content(content), Stmt::EndOfLine]);
normalize_file(&mut hir);
assert!(
matches!(hir.root_content.stmts[0], Stmt::Sequence(_)),
"combo kinds keep the lift: {:?}",
hir.root_content.stmts[0]
);
}
#[test]
fn label_bearing_conditional_lifts_first() {
fn count_labeled(block: &Block) -> usize {
block
.stmts
.iter()
.map(|s| match s {
Stmt::ChoiceSet(cs) => {
cs.choices
.iter()
.map(|c| usize::from(c.label.is_some()) + count_labeled(&c.body))
.sum::<usize>()
+ count_labeled(&cs.continuation)
}
Stmt::LabeledBlock(b) => count_labeled(b),
Stmt::Conditional(c) => c.branches.iter().map(|b| count_labeled(&b.body)).sum(),
Stmt::Sequence(sq) => sq.branches.iter().map(|b| count_labeled(&b.body)).sum(),
_ => 0,
})
.sum()
}
let labeled_choice = Choice {
ptr: dummy_choice_ptr(),
is_sticky: false,
is_fallback: false,
label: Some(Name {
text: "dup".to_string(),
range: rowan::TextRange::new(rowan::TextSize::new(0), rowan::TextSize::new(3)),
}),
condition: None,
binding: None,
start_content: Some(mk_content(vec![text("Pick me")])),
bracket_content: None,
inner_content: None,
tags: Vec::new(),
body: mk_block(vec![]),
container_id: None,
};
let cs = ChoiceSet {
choices: vec![labeled_choice],
continuation: mk_block(vec![]),
context: ChoiceSetContext::Weave,
depth: 1,
gather_id: None,
};
let cond_body = mk_block(vec![Stmt::ChoiceSet(Box::new(cs))]);
let tail = crate::tail_from_stmts(&cond_body.stmts);
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: Block {
label: None,
stmts: cond_body.stmts,
container_id: None,
tail,
},
container_id: None,
}],
});
let content = mk_content(vec![
text("Pre "),
mk_inline_seq(
SequenceType::SHUFFLE,
vec![vec![text("one")], vec![text("two")]],
),
text(" mid "),
inline_cond,
text(" post."),
]);
let mut hir = mk_hir(vec![Stmt::Content(content), Stmt::EndOfLine]);
normalize_file(&mut hir);
let Stmt::Conditional(cond) = &hir.root_content.stmts[0] else {
panic!(
"label-bearing conditional must lift first, got {:?}",
hir.root_content.stmts[0]
);
};
let total: usize = cond.branches.iter().map(|b| count_labeled(&b.body)).sum();
assert_eq!(total, 1, "the labeled choice must not be cloned");
}
fn range(lo: u32, hi: u32) -> rowan::TextRange {
rowan::TextRange::new(rowan::TextSize::new(lo), rowan::TextSize::new(hi))
}
#[test]
fn spliced_branch_takes_enclosing_line_location_over_its_own_narrower_one() {
let enclosing_ptr =
crate::Provenance::synthetic(crate::provenance::NodeClass::Content, range(0, 25));
let branch_ptr =
crate::Provenance::synthetic(crate::provenance::NodeClass::Content, range(8, 12));
let branch_body_stmts = vec![Stmt::Content(Content {
ptr: Some(branch_ptr),
parts: vec![text("high")],
tags: Vec::new(),
})];
let tail = crate::tail_from_stmts(&branch_body_stmts);
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: Block {
label: None,
stmts: branch_body_stmts,
container_id: None,
tail,
},
container_id: None,
}],
});
let content = Content {
ptr: Some(enclosing_ptr),
parts: vec![text("Ready "), inline_cond, text(" now.")],
tags: Vec::new(),
};
let mut hir = mk_hir(vec![Stmt::Content(content), Stmt::EndOfLine]);
normalize_file(&mut hir);
let Stmt::Conditional(cond) = &hir.root_content.stmts[0] else {
panic!("expected Conditional, got {:?}", hir.root_content.stmts[0]);
};
let Stmt::Content(spliced) = &cond.branches[0].body.stmts[0] else {
panic!(
"expected spliced Content, got {:?}",
cond.branches[0].body.stmts[0]
);
};
assert_eq!(content_text(spliced), "Ready high now.");
assert_eq!(
spliced.ptr,
Some(enclosing_ptr),
"spliced branch must carry the whole line's location, not its own narrower one: {:?}",
spliced.ptr
);
}
#[test]
fn cloned_sequence_keeps_its_id_on_clone_zero_and_counts_on_it_elsewhere() {
use brink_format::{DefinitionId, DefinitionTag};
let id = |raw: u64| DefinitionId::new(DefinitionTag::Address, raw);
let ContentPart::InlineSequence(mut outer) = mk_inline_seq(
SequenceType::STOPPING,
vec![vec![text("a")], vec![text("b")]],
) else {
panic!("mk_inline_seq builds an InlineSequence");
};
outer.container_id = Some(id(1));
let ContentPart::InlineSequence(mut inner) = mk_inline_seq(
SequenceType::STOPPING,
vec![vec![text("c")], vec![text("d")], vec![text("e")]],
) else {
panic!("mk_inline_seq builds an InlineSequence");
};
inner.container_id = Some(id(2));
let content = mk_content(vec![
ContentPart::InlineSequence(outer),
ContentPart::InlineSequence(inner),
ContentPart::Glue,
]);
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, got {:?}", hir.root_content.stmts[0]);
};
assert_eq!(seq.container_id, Some(id(1)));
assert_eq!(seq.counter_id, None);
assert_eq!(seq.branches.len(), 2);
let clones: Vec<&Sequence> = seq
.branches
.iter()
.map(|b| {
let Stmt::Sequence(inner) = &b.body.stmts[0] else {
panic!("expected the clone to lift, got {:?}", b.body.stmts);
};
inner
})
.collect();
assert_eq!(clones[0].container_id, Some(id(2)));
assert_eq!(clones[0].counter_id, None);
assert_ne!(clones[1].container_id, Some(id(2)));
assert!(clones[1].container_id.is_some());
assert_eq!(clones[1].counter_id, Some(id(2)));
for (clone, prefix) in clones.iter().zip(["a", "b"]) {
let Stmt::Content(c) = &clone.branches[0].body.stmts[0] else {
panic!("expected a whole-line rendering");
};
assert_eq!(content_text(c), format!("{prefix}c"));
}
}
fn call(name: &str) -> Expr {
Expr::Call(
Path {
segments: vec![Name {
text: name.to_string(),
range: rowan::TextRange::default(),
}],
range: rowan::TextRange::default(),
crosses_module_wall: false,
},
Vec::new(),
)
}
fn read(name: &str) -> Expr {
Expr::Path(Path {
segments: vec![Name {
text: name.to_string(),
range: rowan::TextRange::default(),
}],
range: rowan::TextRange::default(),
crosses_module_wall: false,
})
}
fn synthetic_decl(stmt: &Stmt) -> &TempDecl {
let Stmt::TempDecl(decl) = stmt else {
panic!("expected a hoisted TempDecl, got {stmt:?}");
};
assert!(decl.synthetic, "hoisted temp must be flagged synthetic");
assert!(
super::is_synthetic_temp_name(&decl.name.text),
"hoisted temp name {:?} must carry the synthetic prefix",
decl.name.text
);
decl
}
#[test]
fn prefix_interpolation_is_hoisted_before_the_lifted_conditional() {
let content = mk_content(vec![
ContentPart::Interpolation(call("bump")),
mk_inline_cond(vec![
(Some(read("cond")), vec![text("yes")]),
(None, vec![text("no")]),
]),
]);
let mut hir = mk_hir(vec![Stmt::Content(content), Stmt::EndOfLine]);
normalize_file(&mut hir);
let stmts = &hir.root_content.stmts;
assert_eq!(
stmts.len(),
2,
"hoisted temp + lifted conditional: {stmts:?}"
);
let decl = synthetic_decl(&stmts[0]);
assert_eq!(decl.value, Some(call("bump")));
assert!(decl.annotation.is_none());
let Stmt::Conditional(cond) = &stmts[1] else {
panic!("expected the lifted Conditional, got {:?}", stmts[1]);
};
for branch in &cond.branches {
let Stmt::Content(c) = &branch.body.stmts[0] else {
panic!("expected a spliced line, got {:?}", branch.body.stmts);
};
assert_eq!(
c.parts[0],
ContentPart::Interpolation(read(&decl.name.text)),
"every clone must read the hoisted temp, not re-evaluate the call"
);
assert!(
!c.parts
.iter()
.any(|p| *p == ContentPart::Interpolation(call("bump"))),
"the call must evaluate exactly once"
);
}
}
#[test]
fn prefix_read_is_hoisted_ahead_of_an_effectful_condition() {
let content = mk_content(vec![
ContentPart::Interpolation(read("n")),
mk_inline_cond(vec![(Some(call("f")), vec![text("yes")])]),
]);
let mut hir = mk_hir(vec![Stmt::Content(content), Stmt::EndOfLine]);
normalize_file(&mut hir);
let stmts = &hir.root_content.stmts;
let decl = synthetic_decl(&stmts[0]);
assert_eq!(decl.value, Some(read("n")));
assert!(matches!(&stmts[1], Stmt::Conditional(_)));
}
#[test]
fn prefix_interpolations_hoist_in_source_order_with_distinct_temps() {
let content = mk_content(vec![
text("a "),
ContentPart::Interpolation(call("first")),
ContentPart::Glue,
ContentPart::Interpolation(call("second")),
mk_inline_seq(
SequenceType::STOPPING,
vec![vec![text("x")], vec![text("y")]],
),
]);
let mut hir = mk_hir(vec![Stmt::Content(content), Stmt::EndOfLine]);
normalize_file(&mut hir);
let stmts = &hir.root_content.stmts;
assert_eq!(stmts.len(), 3, "{stmts:?}");
let first = synthetic_decl(&stmts[0]);
let second = synthetic_decl(&stmts[1]);
assert_eq!(first.value, Some(call("first")));
assert_eq!(second.value, Some(call("second")));
assert_ne!(first.name.text, second.name.text);
let Stmt::Sequence(seq) = &stmts[2] else {
panic!("expected the lifted Sequence, got {:?}", stmts[2]);
};
let Stmt::Content(c) = &seq.branches[0].body.stmts[0] else {
panic!("expected a spliced line");
};
assert_eq!(
&c.parts[..4],
&[
text("a "),
ContentPart::Interpolation(read(&first.name.text)),
ContentPart::Glue,
ContentPart::Interpolation(read(&second.name.text)),
]
);
}
#[test]
fn a_synthetic_read_is_not_rehoisted_at_the_next_lift_level() {
let content = mk_content(vec![
ContentPart::Interpolation(call("a")),
mk_inline_cond(vec![
(Some(read("p")), vec![text("P")]),
(None, vec![text("Q")]),
]),
ContentPart::Interpolation(call("b")),
mk_inline_cond(vec![
(Some(read("q")), vec![text("R")]),
(None, vec![text("S")]),
]),
]);
let mut hir = mk_hir(vec![Stmt::Content(content), Stmt::EndOfLine]);
normalize_file(&mut hir);
let stmts = &hir.root_content.stmts;
assert_eq!(stmts.len(), 2, "{stmts:?}");
let outer = synthetic_decl(&stmts[0]);
let Stmt::Conditional(cond) = &stmts[1] else {
panic!("expected the outer Conditional");
};
for branch in &cond.branches {
let inner_stmts = &branch.body.stmts;
assert_eq!(inner_stmts.len(), 2, "{inner_stmts:?}");
let inner = synthetic_decl(&inner_stmts[0]);
assert_eq!(inner.value, Some(call("b")));
assert_ne!(inner.name.text, outer.name.text);
let Stmt::Conditional(inner_cond) = &inner_stmts[1] else {
panic!("expected the inner Conditional");
};
for ib in &inner_cond.branches {
let Stmt::Content(c) = &ib.body.stmts[0] else {
panic!("expected a spliced line");
};
let reads: Vec<&ContentPart> = c
.parts
.iter()
.filter(|p| matches!(p, ContentPart::Interpolation(_)))
.collect();
assert_eq!(
reads,
vec![
&ContentPart::Interpolation(read(&outer.name.text)),
&ContentPart::Interpolation(read(&inner.name.text)),
],
"reads must be the two temps, in order, with no re-hoist copy"
);
}
}
}
#[test]
fn span_nested_prefix_interpolation_is_hoisted() {
let span = ContentPart::Span(SpanPart {
ptr: dummy_ptr(),
name: "b".to_string(),
attrs: Vec::new(),
children: vec![ContentPart::Interpolation(call("f"))],
});
let content = mk_content(vec![
span,
mk_inline_cond(vec![
(Some(read("c")), vec![text("x")]),
(None, vec![text("y")]),
]),
]);
let mut hir = mk_hir(vec![Stmt::Content(content), Stmt::EndOfLine]);
normalize_file(&mut hir);
let stmts = &hir.root_content.stmts;
let decl = synthetic_decl(&stmts[0]);
let Stmt::Conditional(cond) = &stmts[1] else {
panic!("expected the lifted Conditional");
};
let Stmt::Content(c) = &cond.branches[0].body.stmts[0] else {
panic!("expected a spliced line");
};
let ContentPart::Span(s) = &c.parts[0] else {
panic!("the span must survive the splice, got {:?}", c.parts[0]);
};
assert_eq!(
s.children,
vec![ContentPart::Interpolation(read(&decl.name.text))]
);
}
#[test]
fn suffix_only_interpolations_are_not_hoisted() {
let content = mk_content(vec![
text("a "),
mk_inline_cond(vec![
(Some(read("c")), vec![text("x")]),
(None, vec![text("y")]),
]),
ContentPart::Interpolation(call("after")),
]);
let mut hir = mk_hir(vec![Stmt::Content(content), Stmt::EndOfLine]);
normalize_file(&mut hir);
let stmts = &hir.root_content.stmts;
assert_eq!(stmts.len(), 1, "no temp expected: {stmts:?}");
let Stmt::Conditional(cond) = &stmts[0] else {
panic!("expected the lifted Conditional");
};
let Stmt::Content(c) = &cond.branches[0].body.stmts[0] else {
panic!("expected a spliced line");
};
assert_eq!(
c.parts.last(),
Some(&ContentPart::Interpolation(call("after")))
);
}
}