use std::fmt::Write as _;
use crate::{
AssignOp, Assignment, AwaitStmt, Block, BlockStmt, Choice, ChoiceSet, CondBranch, CondKind,
Conditional, ConstDecl, Content, ContentPart, DivertPath, DivertTarget, Expr, ExternalDecl,
HirFile, Import, InfixOp, Knot, LambdaBody, ListDecl, LogicBlock, LogicBlockScope, Name, Param,
Path, PostfixOp, PrefixOp, Return, Stitch, Stmt, StringPart, StructDecl, Tag, TempDecl,
ThreadStart, TypeExpr, VarDecl,
};
#[derive(Debug, Clone, thiserror::Error)]
pub enum EmitError {
#[error("unsupported for native emission: {what} ({context})")]
Unsupported { what: &'static str, context: String },
#[error(
"root content needs a synthesized `flow main()`, but a top-level `main` already exists"
)]
RootMainCollision,
}
fn unsupported(what: &'static str, context: impl Into<String>) -> EmitError {
EmitError::Unsupported {
what,
context: context.into(),
}
}
fn is_synthetic_main_entry(hir: &HirFile) -> bool {
let [Stmt::Divert(d)] = hir.root_content.stmts.as_slice() else {
return false;
};
let DivertPath::Path(p) = &d.target.path else {
return false;
};
if !d.target.args.is_empty() {
return false;
}
let [seg] = p.segments.as_slice() else {
return false;
};
if seg.text != "main" {
return false;
}
hir.knots
.iter()
.any(|k| k.name.text == "main" && k.params.is_empty() && !k.is_function)
}
fn refuse_unsupported_file_channels(hir: &HirFile) -> Result<(), EmitError> {
if !hir.includes.is_empty() {
return Err(unsupported("INCLUDE sites", "file"));
}
if hir.module.is_some() {
return Err(unsupported("#@module directive", "file"));
}
if !hir.visibility.is_empty() || !hir.was_directives.is_empty() {
return Err(unsupported(
"file-level visibility/#@was directives",
"file",
));
}
if !hir.allow_scopes.is_empty() {
return Err(unsupported("@[allow(…)] suppression scopes", "file"));
}
Ok(())
}
pub fn emit_file(hir: &HirFile) -> Result<String, EmitError> {
refuse_unsupported_file_channels(hir)?;
let mut out = String::new();
let mut wrote_any = false;
let before = out.len();
for import in &hir.imports {
emit_import(&mut out, import);
}
if out.len() != before {
out.push('\n');
wrote_any = true;
}
for v in &hir.variables {
emit_var_decl(&mut out, v)?;
wrote_any = true;
}
if wrote_any {
out.push('\n');
}
let before = out.len();
for c in &hir.constants {
emit_const_decl(&mut out, c)?;
}
if out.len() != before {
out.push('\n');
wrote_any = true;
}
let before = out.len();
for l in &hir.lists {
emit_flags_decl(&mut out, l)?;
}
if out.len() != before {
out.push('\n');
wrote_any = true;
}
let before = out.len();
for s in &hir.structs {
emit_struct_decl(&mut out, s)?;
out.push('\n');
}
if out.len() != before {
wrote_any = true;
}
let before = out.len();
for e in &hir.externals {
emit_external_decl(&mut out, e)?;
}
if out.len() != before {
out.push('\n');
wrote_any = true;
}
let mut knots: Vec<&Knot> = hir.knots.iter().collect();
let synthetic_main;
if !hir.root_content.stmts.is_empty() && !is_synthetic_main_entry(hir) {
if knots
.iter()
.any(|k| k.name.text == "main" && k.params.is_empty())
{
return Err(EmitError::RootMainCollision);
}
let empty_range = rowan::TextRange::empty(rowan::TextSize::from(0));
synthetic_main = Knot {
ptr: crate::provenance::Provenance::synthetic(
crate::provenance::NodeClass::Knot,
empty_range,
),
name: Name {
text: "main".to_string(),
range: empty_range,
},
is_function: false,
params: Vec::new(),
body: hir.root_content.clone(),
stitches: Vec::new(),
is_local: false,
effects_assertion: None,
element_annotation: None,
convention_annotation: None,
style_annotation: None,
return_type: None,
doc: None,
visibility: None,
was: None,
};
knots.insert(0, &synthetic_main);
}
for (i, knot) in knots.iter().enumerate() {
if i > 0 {
out.push('\n');
}
emit_knot(&mut out, knot)?;
}
let _ = wrote_any;
Ok(out)
}
fn emit_var_decl(out: &mut String, v: &VarDecl) -> Result<(), EmitError> {
if v.is_local || v.doc.is_some() || v.was.is_some() {
return Err(unsupported("var directive channel", &v.name.text));
}
let pub_kw = pub_prefix(v.visibility, "var directive channel", &v.name.text)?;
let ty = emit_annotation_suffix(v.annotation.as_ref());
let value = emit_expr(&v.value, &v.name.text)?;
let _ = writeln!(out, "{pub_kw}var {}{ty} = {value}", v.name.text);
Ok(())
}
fn emit_const_decl(out: &mut String, c: &ConstDecl) -> Result<(), EmitError> {
if c.doc.is_some() || c.was.is_some() {
return Err(unsupported("const directive channel", &c.name.text));
}
let pub_kw = pub_prefix(c.visibility, "const directive channel", &c.name.text)?;
let ty = emit_annotation_suffix(c.annotation.as_ref());
let value = emit_expr(&c.value, &c.name.text)?;
let _ = writeln!(out, "{pub_kw}const {}{ty} = {value}", c.name.text);
Ok(())
}
fn pub_prefix(
visibility: Option<crate::VisibilityMark>,
channel: &'static str,
name: &str,
) -> Result<&'static str, EmitError> {
match visibility {
None => Ok(""),
Some(crate::VisibilityMark::Public) => Ok("pub "),
Some(crate::VisibilityMark::Private) => Err(unsupported(channel, name)),
}
}
fn emit_annotation_suffix(annotation: Option<&TypeExpr>) -> String {
annotation.map_or_else(String::new, |ty| format!(": {}", emit_type(ty)))
}
fn emit_flags_decl(out: &mut String, l: &ListDecl) -> Result<(), EmitError> {
if l.doc.is_some() || l.was.is_some() {
return Err(unsupported("flags directive channel", &l.name.text));
}
let pub_kw = pub_prefix(l.visibility, "flags directive channel", &l.name.text)?;
let members: Vec<String> = l
.members
.iter()
.map(|m| {
let mut s = m.name.text.clone();
if let Some(v) = m.value {
let _ = write!(s, " = {v}");
}
if m.is_active { format!("({s})") } else { s }
})
.collect();
let _ = writeln!(
out,
"{pub_kw}flags {} = {}",
l.name.text,
members.join(", ")
);
Ok(())
}
fn emit_struct_decl(out: &mut String, s: &StructDecl) -> Result<(), EmitError> {
if s.doc.is_some() {
return Err(unsupported("struct directive channel", &s.name.text));
}
let pub_kw = pub_prefix(s.visibility, "struct directive channel", &s.name.text)?;
let _ = writeln!(out, "{pub_kw}struct {} {{", s.name.text);
for f in &s.fields {
let ty = emit_type(&f.ty);
let _ = writeln!(out, " {}: {ty}", f.name.text);
}
let _ = writeln!(out, "}}");
Ok(())
}
fn emit_external_decl(out: &mut String, e: &ExternalDecl) -> Result<(), EmitError> {
if e.doc.is_some() || e.was.is_some() {
return Err(unsupported("extern directive channel", &e.name.text));
}
let pub_kw = pub_prefix(e.visibility, "extern directive channel", &e.name.text)?;
let params: Vec<&str> = e.params.iter().map(|p| p.name.as_str()).collect();
let _ = writeln!(out, "{pub_kw}extern {}({})", e.name.text, params.join(", "));
Ok(())
}
fn emit_import(out: &mut String, import: &Import) {
if import.bare {
let items: Vec<String> = import
.items
.iter()
.map(|item| match &item.alias {
Some(alias) => format!("{} as {alias}", item.name),
None => item.name.clone(),
})
.collect();
let _ = writeln!(out, "use {}::{{{}}};", import.module, items.join(", "));
} else {
let _ = writeln!(out, "import {}", import.module);
}
}
fn emit_type(ty: &TypeExpr) -> String {
match ty {
TypeExpr::Named { name, .. } => name.clone(),
TypeExpr::Generic { name, args, .. } => {
let rendered: Vec<String> = args.iter().map(emit_type).collect();
format!("{name}<{}>", rendered.join(", "))
}
TypeExpr::Fn { params, ret, .. } => {
let rendered: Vec<String> = params.iter().map(emit_type).collect();
format!("fn({}): {}", rendered.join(", "), emit_type(ret))
}
}
}
fn emit_param(p: &Param, context: &str) -> Result<String, EmitError> {
if p.is_divert {
return Err(unsupported("divert-typed parameter", context));
}
let mut s = String::new();
if p.is_ref {
s.push_str("ref ");
}
s.push_str(&p.name.text);
if let Some(ty) = &p.annotation {
let _ = write!(s, ": {}", emit_type(ty));
}
Ok(s)
}
fn emit_params(params: &[Param], context: &str) -> Result<String, EmitError> {
let rendered: Result<Vec<String>, EmitError> =
params.iter().map(|p| emit_param(p, context)).collect();
Ok(rendered?.join(", "))
}
fn emit_knot(out: &mut String, k: &Knot) -> Result<(), EmitError> {
if k.is_local
|| k.effects_assertion.is_some()
|| k.element_annotation.is_some()
|| k.convention_annotation.is_some()
|| k.style_annotation.is_some()
|| k.doc.is_some()
|| k.was.is_some()
{
return Err(unsupported("knot directive/doc channel", &k.name.text));
}
let pub_kw = pub_prefix(k.visibility, "knot directive/doc channel", &k.name.text)?;
let keyword = if k.is_function { "fn" } else { "flow" };
let params = emit_params(&k.params, &k.name.text)?;
let ret = emit_annotation_suffix(k.return_type.as_ref());
let selector = if k.is_function { ">" } else { "" };
let _ = writeln!(
out,
"{pub_kw}{keyword} {}({params}){ret} {selector}{{",
k.name.text
);
emit_block_stmts(out, &k.body, 1, &k.name.text)?;
for s in &k.stitches {
emit_stitch(out, s, 1)?;
}
let _ = writeln!(out, "}}");
Ok(())
}
fn emit_stitch(out: &mut String, s: &Stitch, depth: usize) -> Result<(), EmitError> {
if s.is_local
|| s.effects_assertion.is_some()
|| s.element_annotation.is_some()
|| s.convention_annotation.is_some()
|| s.style_annotation.is_some()
|| s.doc.is_some()
|| s.was.is_some()
{
return Err(unsupported("stitch directive/doc channel", &s.name.text));
}
let pub_kw = pub_prefix(s.visibility, "stitch directive/doc channel", &s.name.text)?;
let indent = " ".repeat(depth);
let params = emit_params(&s.params, &s.name.text)?;
let ret = emit_annotation_suffix(s.return_type.as_ref());
let _ = writeln!(
out,
"{indent}{pub_kw}flow {}({params}){ret} {{",
s.name.text
);
emit_block_stmts(out, &s.body, depth + 1, &s.name.text)?;
let _ = writeln!(out, "{indent}}}");
Ok(())
}
fn emit_block_stmts(
out: &mut String,
block: &Block,
depth: usize,
context: &str,
) -> Result<(), EmitError> {
if block.label.is_some() {
return Err(unsupported("labeled block", context));
}
emit_stmt_stream(out, &block.stmts, depth, context)
}
fn emit_stmt_stream(
out: &mut String,
stmts: &[Stmt],
depth: usize,
context: &str,
) -> Result<(), EmitError> {
let indent = " ".repeat(depth);
let mut i = 0;
while i < stmts.len() {
let stmt = &stmts[i];
match stmt {
Stmt::EndOfLine => {}
Stmt::Content(c) => {
let same_line_divert = match stmts.get(i + 1) {
Some(Stmt::Divert(d)) => Some(emit_divert_target(&d.target, context)?),
Some(Stmt::TunnelCall(t)) if t.targets.len() == 1 => Some(format!(
"{} ->",
emit_divert_target(&t.targets[0], context)?
)),
_ => None,
};
if let Some(divert_text) = same_line_divert {
let text =
escape_leading_line_start_sigil(&emit_content_parts(&c.parts, context)?);
if !c.tags.is_empty() {
return Err(unsupported(
"tags on a content line sharing its line with a divert",
context,
));
}
let _ = writeln!(out, "{indent}{text}-> {divert_text}");
i += 2;
continue;
}
emit_content_line(out, &indent, c, context)?;
}
Stmt::Divert(d) => {
let target = emit_divert_target(&d.target, context)?;
let _ = writeln!(out, "{indent}-> {target}");
}
Stmt::TunnelCall(t) => {
if t.targets.len() != 1 {
return Err(unsupported("multi-hop tunnel chain", context));
}
let target = emit_divert_target(&t.targets[0], context)?;
let _ = writeln!(out, "{indent}-> {target} ->");
}
Stmt::Return(r) => {
let line = emit_return(r, context)?;
let _ = writeln!(out, "{indent}{line}");
}
Stmt::ChoiceSet(cs) => {
emit_choice_set_and_continuation(out, &indent, depth, cs, &[], context)?;
return Ok(());
}
Stmt::Conditional(cond) => emit_conditional(out, &indent, depth, cond, context)?,
Stmt::LabeledBlock(b) => {
let Some(label) = &b.label else {
return Err(unsupported("labeled block without a label", context));
};
emit_labeled_stmt_stream(out, label, &b.stmts, depth, context)?;
return Ok(());
}
Stmt::Sequence(_) => return Err(unsupported("alternation sequence", context)),
Stmt::TempDecl(t) => {
let line = emit_temp_decl(t, context)?;
let _ = writeln!(out, "{indent}{line}");
}
Stmt::Assignment(a) => {
let line = emit_assignment(a, context)?;
let _ = writeln!(out, "{indent}{line}");
}
Stmt::ExprStmt(e) => {
let line = emit_expr_stmt(e, context)?;
let _ = writeln!(out, "{indent}{line}");
}
Stmt::ThreadStart(_) => {
let mut j = i;
while matches!(stmts.get(j), Some(Stmt::ThreadStart(_))) {
j += 1;
}
let Some(Stmt::ChoiceSet(cs)) = stmts.get(j) else {
return Err(unsupported("thread-start splice", context));
};
let leading: Vec<&ThreadStart> = stmts[i..j]
.iter()
.map(|s| match s {
Stmt::ThreadStart(t) => t,
_ => unreachable!("loop above only advances over ThreadStart"),
})
.collect();
emit_choice_set_and_continuation(out, &indent, depth, cs, &leading, context)?;
return Ok(());
}
Stmt::LogicBlock(lb) => emit_logic_block(out, &indent, depth, lb, context)?,
Stmt::Await(a) => {
let line = emit_await(a, context)?;
let _ = writeln!(out, "{indent}{line}");
}
Stmt::AttachElement(_) | Stmt::EndElementRun => {
return Err(unsupported("attach-mode element rewrite", context));
}
}
i += 1;
}
Ok(())
}
fn emit_labeled_stmt_stream(
out: &mut String,
label: &Name,
stmts: &[Stmt],
depth: usize,
context: &str,
) -> Result<(), EmitError> {
let indent = " ".repeat(depth);
let mut head = format!("{indent}({})", label.text);
match stmts {
[Stmt::Content(c), Stmt::Divert(d), rest @ ..] => {
if !c.tags.is_empty() {
return Err(unsupported(
"tags on a labeled content line sharing its line with a divert",
context,
));
}
let text = emit_content_parts(&c.parts, context)?;
if !text.is_empty() {
let _ = write!(head, " {text}");
}
let target = emit_divert_target(&d.target, context)?;
let _ = writeln!(out, "{head}-> {target}");
emit_stmt_stream(out, rest, depth, context)
}
[Stmt::Content(c), Stmt::TunnelCall(t), rest @ ..] if t.targets.len() == 1 => {
if !c.tags.is_empty() {
return Err(unsupported(
"tags on a labeled content line sharing its line with a tunnel call",
context,
));
}
let text = emit_content_parts(&c.parts, context)?;
if !text.is_empty() {
let _ = write!(head, " {text}");
}
let target = emit_divert_target(&t.targets[0], context)?;
let _ = writeln!(out, "{head}-> {target} ->");
emit_stmt_stream(out, rest, depth, context)
}
[Stmt::Content(c), Stmt::EndOfLine, rest @ ..] => {
let text = emit_content_parts(&c.parts, context)?;
if !text.is_empty() {
let _ = write!(head, " {text}");
}
for tag in &c.tags {
let _ = write!(head, " #{}", emit_tag(tag, context)?);
}
let _ = writeln!(out, "{head}");
emit_stmt_stream(out, rest, depth, context)
}
[Stmt::Content(_), ..] => Err(unsupported(
"labeled line with an unsupported leading shape",
context,
)),
rest => {
let _ = writeln!(out, "{head}");
emit_stmt_stream(out, rest, depth, context)
}
}
}
fn emit_temp_decl(t: &TempDecl, context: &str) -> Result<String, EmitError> {
let ty = emit_annotation_suffix(t.annotation.as_ref());
match &t.value {
Some(v) => {
let value = emit_expr(v, context)?;
Ok(format!("~ let {}{ty} = {value}", t.name.text))
}
None => Ok(format!("~ let {}{ty}", t.name.text)),
}
}
fn emit_assignment(a: &Assignment, context: &str) -> Result<String, EmitError> {
let target = emit_expr(&a.target, context)?;
let op = match a.op {
AssignOp::Set => "=",
AssignOp::Add => "+=",
AssignOp::Sub => "-=",
};
let value = emit_expr(&a.value, context)?;
Ok(format!("~ {target} {op} {value}"))
}
fn emit_expr_stmt(e: &Expr, context: &str) -> Result<String, EmitError> {
Ok(format!("~ {}", emit_expr(e, context)?))
}
fn emit_await(a: &AwaitStmt, context: &str) -> Result<String, EmitError> {
let Some(cond) = &a.condition else {
return Err(unsupported("`until`/`await` with no condition", context));
};
Ok(format!("~ until {}", emit_expr(cond, context)?))
}
fn emit_logic_block(
out: &mut String,
indent: &str,
depth: usize,
lb: &LogicBlock,
context: &str,
) -> Result<(), EmitError> {
if lb.scope != LogicBlockScope::Standalone {
return Err(unsupported(
"a code-ground body split by a nested `> text` line",
context,
));
}
let _ = writeln!(out, "{indent}~{{");
emit_block_stmt_stream(out, &lb.stmts, depth + 1, context)?;
let _ = writeln!(out, "{indent}}}");
Ok(())
}
fn emit_block_stmt_stream(
out: &mut String,
stmts: &[BlockStmt],
depth: usize,
context: &str,
) -> Result<(), EmitError> {
let indent = " ".repeat(depth);
for stmt in stmts {
let line = match stmt {
BlockStmt::TempDecl(t) => {
let ty = emit_annotation_suffix(t.annotation.as_ref());
match &t.value {
Some(v) => format!("let {}{ty} = {}", t.name.text, emit_expr(v, context)?),
None => format!("let {}{ty}", t.name.text),
}
}
BlockStmt::Assignment(a) => {
let target = emit_expr(&a.target, context)?;
let op = match a.op {
AssignOp::Set => "=",
AssignOp::Add => "+=",
AssignOp::Sub => "-=",
};
format!("{target} {op} {}", emit_expr(&a.value, context)?)
}
BlockStmt::ExprStmt(e) => emit_expr(e, context)?,
BlockStmt::Return(r) => {
if matches!(r.value, Some(Expr::DivertTarget(_))) {
return Err(unsupported(
"code-ground `return -> target` (no tunnel-redirect counterpart at code-ground position)",
context,
));
}
emit_return(r, context)?
}
BlockStmt::Break(_) => "break".to_string(),
BlockStmt::Continue(_) => "continue".to_string(),
BlockStmt::Await(a) => {
let Some(cond) = &a.condition else {
return Err(unsupported("`until` with no condition", context));
};
format!("until {}", emit_expr(cond, context)?)
}
BlockStmt::If(_) | BlockStmt::While(_) | BlockStmt::For(_) => {
return Err(unsupported(
"nested control flow inside a `~{ }` logic block",
context,
));
}
};
let _ = writeln!(out, "{indent}{line};");
}
Ok(())
}
fn emit_return(r: &Return, context: &str) -> Result<String, EmitError> {
if !r.onwards_args.is_empty() {
return Err(unsupported("tunnel-return onwards args", context));
}
match &r.value {
None => Ok("return".to_string()),
Some(Expr::DivertTarget(p)) => Ok(format!("return -> {}", emit_path(p))),
Some(v) => Ok(format!("return {}", emit_expr(v, context)?)),
}
}
fn emit_divert_target(t: &DivertTarget, context: &str) -> Result<String, EmitError> {
let head = match &t.path {
DivertPath::Path(p) => emit_path(p),
DivertPath::Done => "DONE".to_string(),
DivertPath::End => "END".to_string(),
};
if t.args.is_empty() {
Ok(head)
} else {
let rendered: Result<Vec<String>, EmitError> =
t.args.iter().map(|a| emit_expr(a, context)).collect();
Ok(format!("{head}({})", rendered?.join(", ")))
}
}
fn emit_path(p: &Path) -> String {
p.segments
.iter()
.map(|s| s.text.as_str())
.collect::<Vec<_>>()
.join(".")
}
fn emit_content_line(
out: &mut String,
indent: &str,
c: &Content,
context: &str,
) -> Result<(), EmitError> {
let text = escape_leading_line_start_sigil(&emit_content_parts(&c.parts, context)?);
let mut line = format!("{indent}{text}");
for tag in &c.tags {
let _ = write!(line, " #{}", emit_tag(tag, context)?);
}
let _ = writeln!(out, "{line}");
Ok(())
}
fn emit_tag(tag: &Tag, context: &str) -> Result<String, EmitError> {
emit_content_parts(&tag.parts, context)
}
fn emit_content_parts(parts: &[ContentPart], context: &str) -> Result<String, EmitError> {
let mut s = String::new();
for part in parts {
match part {
ContentPart::Text(t) => s.push_str(&escape_content_text(t)),
ContentPart::Glue => s.push_str("<>"),
ContentPart::Interpolation(e) => {
let _ = write!(s, "{{{}}}", emit_expr(e, context)?);
}
ContentPart::Spring => return Err(unsupported("word-break spring", context)),
ContentPart::InlineConditional(_) => {
return Err(unsupported("inline conditional in content", context));
}
ContentPart::InlineSequence(_) => {
return Err(unsupported("inline sequence in content", context));
}
ContentPart::Span(span) => s.push_str(&emit_span(span, context)?),
}
}
Ok(s)
}
fn emit_span(span: &crate::hir::SpanPart, context: &str) -> Result<String, EmitError> {
let mut s = format!("<{}", span.name);
for attr in &span.attrs {
let _ = write!(s, " {}=\"{}\"", attr.name, escape_attr_value(&attr.value));
}
if span.children.is_empty() {
s.push_str("/>");
return Ok(s);
}
s.push('>');
s.push_str(&emit_content_parts(&span.children, context)?);
let _ = write!(s, "</{}>", span.name);
Ok(s)
}
fn escape_content_text(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if matches!(c, '\\' | '<' | '{' | '#') {
out.push('\\');
}
out.push(c);
}
out
}
fn escape_leading_line_start_sigil(text: &str) -> String {
let mut chars = text.chars();
match chars.next() {
Some(sigil @ ('@' | '!')) => {
let rest = chars.as_str();
if rest.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') {
format!("\\{sigil}{rest}")
} else {
text.to_string()
}
}
_ => text.to_string(),
}
}
fn escape_attr_value(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
_ => out.push(c),
}
}
out
}
fn emit_choice_set_and_continuation(
out: &mut String,
indent: &str,
depth: usize,
cs: &ChoiceSet,
leading: &[&ThreadStart],
context: &str,
) -> Result<(), EmitError> {
emit_choice_set(out, indent, depth, cs, leading, context)?;
match &cs.continuation.label {
Some(label) => {
emit_labeled_stmt_stream(out, label, &cs.continuation.stmts, depth, context)?;
}
None => {
emit_stmt_stream(out, &cs.continuation.stmts, depth, context)?;
}
}
Ok(())
}
fn emit_choice_set(
out: &mut String,
indent: &str,
depth: usize,
cs: &ChoiceSet,
leading: &[&ThreadStart],
context: &str,
) -> Result<(), EmitError> {
let _ = writeln!(out, "{indent}{{?");
let child_indent = " ".repeat(depth + 1);
for t in leading {
let target = emit_divert_target(&t.target, context)?;
let _ = writeln!(out, "{child_indent}<- {target}");
}
for choice in &cs.choices {
emit_choice(out, depth + 1, choice, context)?;
}
let _ = writeln!(out, "{indent}}}");
Ok(())
}
fn split_trailing_thread_starts(stmts: &[Stmt]) -> (&[Stmt], &[Stmt]) {
let mut split = stmts.len();
while split > 0 && matches!(stmts[split - 1], Stmt::ThreadStart(_)) {
split -= 1;
}
stmts.split_at(split)
}
fn emit_trailing_thread_starts(
out: &mut String,
indent: &str,
trailing: &[Stmt],
context: &str,
) -> Result<(), EmitError> {
for stmt in trailing {
let Stmt::ThreadStart(t) = stmt else {
unreachable!("split_trailing_thread_starts guarantees only ThreadStart here")
};
let target = emit_divert_target(&t.target, context)?;
let _ = writeln!(out, "{indent}<- {target}");
}
Ok(())
}
fn emit_choice(out: &mut String, depth: usize, c: &Choice, context: &str) -> Result<(), EmitError> {
let indent = " ".repeat(depth);
if !c.tags.is_empty() {
return Err(unsupported("choice-line trailing tags", context));
}
let (own_stmts, trailing_threads) = split_trailing_thread_starts(c.body.stmts.as_slice());
if c.is_fallback {
let _ = write!(out, "{indent}else ");
emit_choice_body(out, depth, c.body.label.as_ref(), own_stmts, context)?;
emit_trailing_thread_starts(out, &indent, trailing_threads, context)?;
return Ok(());
}
let marker = if c.is_sticky { "+" } else { "*" };
let mut head = format!("{indent}{marker}");
if let Some(cond) = &c.condition {
let binding_suffix = match &c.binding {
Some(name) => format!(" as {}", name.text),
None => String::new(),
};
let _ = write!(
head,
" {{if {}{binding_suffix}}}",
emit_expr(cond, context)?
);
}
if let Some(label) = &c.label {
let _ = write!(head, " ({})", label.text);
}
if let Some(start) = &c.start_content {
let text = emit_content_parts(&start.parts, context)?;
if !text.is_empty() {
let _ = write!(head, " {text}");
}
}
let needs_brackets = c.bracket_content.is_some() || c.inner_content.is_some();
if needs_brackets {
let bracket = match &c.bracket_content {
Some(b) => emit_content_parts(&b.parts, context)?,
None => String::new(),
};
let _ = write!(head, "[{bracket}]");
if let Some(inner) = &c.inner_content {
let text = emit_content_parts(&inner.parts, context)?;
head.push_str(&text);
}
}
out.push_str(&head);
if c.body.label.is_some() {
return Err(unsupported("labeled choice body", context));
}
let stmts = own_stmts;
match stmts {
[] => {
return Err(unsupported(
"malformed choice body (no EndOfLine marker)",
context,
));
}
[Stmt::EndOfLine] => {
out.push('\n');
}
[Stmt::Divert(d), Stmt::EndOfLine] => {
let target = emit_divert_target(&d.target, context)?;
let _ = writeln!(out, " -> {target}");
}
[Stmt::TunnelCall(t), Stmt::EndOfLine] if t.targets.len() == 1 => {
let target = emit_divert_target(&t.targets[0], context)?;
let _ = writeln!(out, " -> {target} ->");
}
[Stmt::EndOfLine, rest @ ..] => {
out.push(' ');
emit_choice_body_stmts(out, depth, rest, context)?;
}
[Stmt::Divert(_) | Stmt::TunnelCall(_), Stmt::EndOfLine, ..] => {
out.push(' ');
emit_choice_body_stmts(out, depth, stmts, context)?;
}
_ => {
return Err(unsupported(
"malformed choice body (no leading EndOfLine)",
context,
));
}
}
emit_trailing_thread_starts(out, &indent, trailing_threads, context)
}
fn emit_choice_body(
out: &mut String,
depth: usize,
label: Option<&Name>,
stmts: &[Stmt],
context: &str,
) -> Result<(), EmitError> {
if label.is_some() {
return Err(unsupported("labeled choice/else body", context));
}
match stmts {
[] => Err(unsupported(
"malformed else body (no EndOfLine marker)",
context,
)),
[Stmt::EndOfLine] => {
out.push('\n');
Ok(())
}
[Stmt::EndOfLine, rest @ ..] => emit_choice_body_stmts(out, depth, rest, context),
[Stmt::Divert(_) | Stmt::TunnelCall(_), ..] => {
emit_choice_body_stmts(out, depth, stmts, context)
}
_ => Err(unsupported(
"malformed else body (no leading EndOfLine)",
context,
)),
}
}
fn emit_choice_body_stmts(
out: &mut String,
depth: usize,
rest: &[Stmt],
context: &str,
) -> Result<(), EmitError> {
let indent = " ".repeat(depth);
let _ = writeln!(out, "{{");
emit_stmt_stream(out, rest, depth + 1, context)?;
let _ = writeln!(out, "{indent}}}");
Ok(())
}
fn emit_conditional(
out: &mut String,
indent: &str,
depth: usize,
cond: &Conditional,
context: &str,
) -> Result<(), EmitError> {
match &cond.kind {
CondKind::InitialCondition => {
if cond.branches.is_empty() || cond.branches.len() > 2 {
return Err(unsupported("multi-branch conditional shape", context));
}
let first = &cond.branches[0];
let Some(if_cond) = &first.condition else {
return Err(unsupported(
"conditional with no leading condition",
context,
));
};
let binding_suffix = match &first.binding {
Some(name) => format!(" as {}", name.text),
None => String::new(),
};
let _ = writeln!(
out,
"{indent}{{if {}{binding_suffix} {{",
emit_expr(if_cond, context)?
);
emit_block_stmts(out, &first.body, depth + 1, context)?;
if let Some(second) = cond.branches.get(1) {
if second.condition.is_some() {
return Err(unsupported("`else if` chain", context));
}
let _ = writeln!(out, "{indent}}} else {{");
emit_block_stmts(out, &second.body, depth + 1, context)?;
}
let _ = writeln!(out, "{indent}}}}}");
Ok(())
}
CondKind::Switch(subject) => {
let _ = writeln!(out, "{indent}{{match {} {{", emit_expr(subject, context)?);
for branch in &cond.branches {
emit_match_arm(out, depth + 1, branch, context)?;
}
let _ = writeln!(out, "{indent}}}}}");
Ok(())
}
CondKind::IfElse => emit_if_else_chain(out, indent, depth, &cond.branches, context),
}
}
fn emit_if_else_chain(
out: &mut String,
indent: &str,
depth: usize,
branches: &[CondBranch],
context: &str,
) -> Result<(), EmitError> {
let Some((first, rest)) = branches.split_first() else {
return Err(unsupported("empty `else if` chain", context));
};
let Some(if_cond) = &first.condition else {
return Err(unsupported(
"`else if` chain branch with no leading condition",
context,
));
};
let binding_suffix = match &first.binding {
Some(name) => format!(" as {}", name.text),
None => String::new(),
};
let _ = writeln!(
out,
"{indent}{{if {}{binding_suffix} {{",
emit_expr(if_cond, context)?
);
emit_block_stmts(out, &first.body, depth + 1, context)?;
match rest {
[] => {}
[only] if only.condition.is_none() => {
let _ = writeln!(out, "{indent}}} else {{");
emit_block_stmts(out, &only.body, depth + 1, context)?;
}
_ => {
let inner_indent = " ".repeat(depth + 1);
let _ = writeln!(out, "{indent}}} else {{");
emit_if_else_chain(out, &inner_indent, depth + 1, rest, context)?;
}
}
let _ = writeln!(out, "{indent}}}}}");
Ok(())
}
fn emit_match_arm(
out: &mut String,
depth: usize,
branch: &CondBranch,
context: &str,
) -> Result<(), EmitError> {
let Some(pattern) = &branch.condition else {
return Err(unsupported(
"match arm with no pattern (default arm)",
context,
));
};
let indent = " ".repeat(depth);
let _ = writeln!(out, "{indent}{} => {{", emit_expr(pattern, context)?);
emit_block_stmts(out, &branch.body, depth + 1, context)?;
let _ = writeln!(out, "{indent}}}");
Ok(())
}
fn emit_expr(e: &Expr, context: &str) -> Result<String, EmitError> {
match e {
Expr::Int(n) => Ok(n.to_string()),
Expr::Float(f) => Ok(f.to_f64().to_string()),
Expr::Bool(b) => Ok(b.to_string()),
Expr::Path(p) => Ok(emit_path(p)),
Expr::String(s) => {
let mut out = String::from("\"");
for part in &s.parts {
match part {
StringPart::Literal(t) => out.push_str(t),
StringPart::Interpolation(inner) => {
let _ = write!(out, "{{{}}}", emit_expr(inner, context)?);
}
}
}
out.push('"');
Ok(out)
}
Expr::Prefix(op, inner) => {
let op_str = match op {
PrefixOp::Negate => "-",
PrefixOp::Not => "not ",
};
Ok(format!("{op_str}{}", emit_expr(inner, context)?))
}
Expr::Infix(ie) => {
let op_str = infix_op_str(ie.op);
Ok(format!(
"{} {op_str} {}",
emit_expr(&ie.lhs, context)?,
emit_expr(&ie.rhs, context)?
))
}
Expr::Postfix(inner, op) => {
let op_str = match op {
PostfixOp::Increment => "++",
PostfixOp::Decrement => "--",
};
Ok(format!("{}{op_str}", emit_expr(inner, context)?))
}
Expr::Call(path, args) => {
let rendered: Result<Vec<String>, EmitError> =
args.iter().map(|a| emit_expr(a, context)).collect();
Ok(format!("{}({})", emit_path(path), rendered?.join(", ")))
}
Expr::Null => Err(unsupported("`null` literal", context)),
Expr::DivertTarget(_) => Err(unsupported("divert-target-as-value expression", context)),
Expr::ListLiteral(_) => Err(unsupported("list literal expression", context)),
Expr::ArrayLiteral(_) => Err(unsupported("array sigil literal", context)),
Expr::MapLiteral(_) => Err(unsupported("map sigil literal", context)),
Expr::Index(_) => Err(unsupported("index expression", context)),
Expr::Range(_) => Err(unsupported("range literal", context)),
Expr::StructLiteral(_) => Err(unsupported("struct construction literal", context)),
Expr::FieldAccess(_) => Err(unsupported("field access expression", context)),
Expr::FnLiteral(fl) if fl.args.is_empty() => Ok(emit_path(&fl.target)),
Expr::FnLiteral(_) => Err(unsupported("`#fn` literal with bound arguments", context)),
Expr::RefArg(_) => Err(unsupported("`ref` argument expression", context)),
Expr::Lambda(l) => {
let params = emit_params(&l.params, context)?;
let ret = emit_annotation_suffix(l.return_type.as_ref());
match &l.body {
LambdaBody::Expr(body) => {
Ok(format!("|{params}|{ret} {}", emit_expr(body, context)?))
}
LambdaBody::Block { .. } => Err(unsupported("lambda with a braced body", context)),
}
}
Expr::Fragment(_) => Err(unsupported("internal fragment-capture expression", context)),
}
}
fn infix_op_str(op: InfixOp) -> &'static str {
match op {
InfixOp::Add => "+",
InfixOp::Sub => "-",
InfixOp::Mul => "*",
InfixOp::Div => "/",
InfixOp::Mod => "%",
InfixOp::Intersect => "^",
InfixOp::Eq => "==",
InfixOp::NotEq => "!=",
InfixOp::Lt => "<",
InfixOp::Gt => ">",
InfixOp::LtEq => "<=",
InfixOp::GtEq => ">=",
InfixOp::And => "&&",
InfixOp::Or => "||",
InfixOp::Has => "?",
InfixOp::HasNot => "!?",
InfixOp::Coalesce => "or",
}
}
#[cfg(test)]
#[expect(
clippy::panic,
reason = "test-only `let-else { panic!(...) }` assertions for concise failure messages"
)]
mod tests {
use super::*;
fn lower_and_emit(src: &str) -> Result<String, EmitError> {
let parse = brink_syntax_native::parse(src);
let tree = parse.tree();
let (hir, _manifest, diags) = crate::hir::lower_native::lower(crate::FileId(0), &tree);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
emit_file(&hir)
}
fn reparse_and_lower(src: &str) -> crate::HirFile {
let parse = brink_syntax_native::parse(src);
assert!(
parse.errors().is_empty(),
"emitted source has parse errors: {:?}\n--- source ---\n{src}",
parse.errors()
);
let tree = parse.tree();
let (hir, _manifest, diags) = crate::hir::lower_native::lower(crate::FileId(0), &tree);
assert!(
diags.is_empty(),
"emitted source has lowering diagnostics: {diags:?}\n--- source ---\n{src}"
);
hir
}
#[test]
fn labeled_gather_continuation_round_trips() {
let src = "flow a() {\n {?\n * A.\n }\n (again)\n Loop point.\n}\n";
let emitted = lower_and_emit(src).expect("labeled gather continuation must now emit");
assert!(
emitted.contains("(again)"),
"emitted source dropped the continuation label:\n{emitted}"
);
let hir = reparse_and_lower(&emitted);
let body = &hir.knots[0].body;
let Stmt::ChoiceSet(cs) = &body.stmts[0] else {
panic!("expected ChoiceSet as the re-lowered body's first statement: {body:?}");
};
assert_eq!(
cs.continuation.label.as_ref().map(|n| n.text.as_str()),
Some("again"),
"re-lowered continuation lost its label"
);
}
#[test]
fn labeled_mid_flow_block_round_trips() {
let src = "flow a() {\n Intro.\n (mid) Middle.\n End.\n}\n";
let emitted = lower_and_emit(src).expect("labeled mid-flow block must now emit");
assert!(
emitted.contains("(mid)"),
"emitted source dropped the mid-flow label:\n{emitted}"
);
let hir = reparse_and_lower(&emitted);
let body = &hir.knots[0].body;
let labeled = body
.stmts
.iter()
.find_map(|s| match s {
Stmt::LabeledBlock(b) => Some(b),
_ => None,
})
.expect("expected a LabeledBlock among the re-lowered body's statements");
assert_eq!(labeled.label.as_ref().map(|n| n.text.as_str()), Some("mid"));
}
#[test]
fn bare_label_line_at_end_of_flow_has_no_trailing_space() {
let src = "flow a() {\n {?\n * A.\n }\n (again)\n}\n";
let emitted = lower_and_emit(src).expect("bare trailing label must now emit");
assert!(
emitted.lines().any(|l| l.trim() == "(again)"),
"expected a bare `(again)` line with no trailing text:\n{emitted}"
);
reparse_and_lower(&emitted);
}
#[test]
fn label_with_empty_own_line_attaches_to_following_content() {
let src = "flow a() {\n {?\n * A.\n }\n (again)\n Loop point.\n}\n";
let emitted = lower_and_emit(src).expect("labeled gather continuation must now emit");
assert!(
emitted.lines().any(|l| l.trim() == "(again) Loop point."),
"expected the label to attach to the following content line:\n{emitted}"
);
}
#[test]
fn fn_type_annotation_round_trips() {
let src = "flow apply(f: fn(int): bool) {\n Hello.\n}\n";
let emitted = lower_and_emit(src).expect("fn(...) type annotation must now emit");
assert!(
emitted.contains("fn(int): bool"),
"expected the emitted source to spell the fn(...) type back out:\n{emitted}"
);
let hir = reparse_and_lower(&emitted);
let Some(annotation) = &hir.knots[0].params[0].annotation else {
panic!("re-lowered param lost its type annotation");
};
assert!(
matches!(annotation, TypeExpr::Fn { .. }),
"expected a re-lowered TypeExpr::Fn, got {annotation:?}"
);
}
#[test]
fn stitch_return_type_round_trips() {
let src = "flow garden() {\n flow gate(): int {\n Onward.\n }\n}\n";
let emitted = lower_and_emit(src).expect("stitch return type must now emit");
assert!(
emitted.contains("gate(): int"),
"expected the emitted source to spell the stitch's return type back out:\n{emitted}"
);
let hir = reparse_and_lower(&emitted);
match &hir.knots[0].stitches[0].return_type {
Some(TypeExpr::Named { name, .. }) => assert_eq!(name, "int"),
other => panic!("re-lowered stitch lost its return type: {other:?}"),
}
}
#[test]
fn conditional_as_binding_round_trips() {
let src = "flow a() {\n {if some(9) as l: number {l} else: nobody}\n}\n";
let emitted = lower_and_emit(src).expect("`as` binding conditional must now emit");
assert!(
emitted.contains("as l"),
"emitted source dropped the `as` binding:\n{emitted}"
);
let hir = reparse_and_lower(&emitted);
let body = &hir.knots[0].body;
let Stmt::Conditional(cond) = &body.stmts[0] else {
panic!("expected Conditional as the re-lowered body's first statement: {body:?}");
};
assert_eq!(
cond.branches[0].binding.as_ref().map(|n| n.text.as_str()),
Some("l"),
"re-lowered conditional lost its `as` binding"
);
}
#[test]
fn choice_guard_as_binding_round_trips() {
let src = "flow a() {\n {?\n * {if some(9) as n} [pick] {n}\n }\n}\n";
let emitted = lower_and_emit(src).expect("choice guard `as` binding must now emit");
assert!(
emitted.contains("as n"),
"emitted source dropped the guard's `as` binding:\n{emitted}"
);
let hir = reparse_and_lower(&emitted);
let body = &hir.knots[0].body;
let Stmt::ChoiceSet(cs) = &body.stmts[0] else {
panic!("expected ChoiceSet as the re-lowered body's first statement: {body:?}");
};
assert_eq!(
cs.choices[0].binding.as_ref().map(|n| n.text.as_str()),
Some("n"),
"re-lowered choice lost its guard `as` binding"
);
}
#[test]
fn use_and_import_round_trip() {
let src = "import story::market\n\
use story::market::barter::haggle;\n\
use story::shop::{gold as g, silver};\n\n\
flow a() {\n Hi.\n}\n";
let emitted = lower_and_emit(src).expect("use/import must now emit");
let hir = reparse_and_lower(&emitted);
assert_eq!(hir.imports.len(), 3, "emitted source:\n{emitted}");
assert!(!hir.imports[0].bare);
assert_eq!(hir.imports[0].module, "story::market");
assert!(hir.imports[0].items.is_empty());
assert!(hir.imports[1].bare);
assert_eq!(hir.imports[1].module, "story::market::barter");
assert_eq!(hir.imports[1].items.len(), 1);
assert_eq!(hir.imports[1].items[0].name, "haggle");
assert_eq!(hir.imports[1].items[0].alias, None);
assert!(hir.imports[2].bare);
assert_eq!(hir.imports[2].module, "story::shop");
assert_eq!(hir.imports[2].items.len(), 2);
assert_eq!(hir.imports[2].items[0].name, "gold");
assert_eq!(hir.imports[2].items[0].alias.as_deref(), Some("g"));
assert_eq!(hir.imports[2].items[1].name, "silver");
assert_eq!(hir.imports[2].items[1].alias, None);
}
#[test]
fn lambda_with_annotated_expr_body_round_trips() {
let src = "var add = |x: int|: int x + 1\n";
let emitted = lower_and_emit(src).expect("annotated lambda expr body must emit");
assert!(
emitted.contains("|x: int|: int"),
"expected the emitted source to spell the param + return annotations back out:\n{emitted}"
);
let hir = reparse_and_lower(&emitted);
let Expr::Lambda(lambda) = &hir.variables.first().expect("one var").value else {
panic!(
"re-lowered var initializer lost its lambda: {:?}",
hir.variables
);
};
assert_eq!(lambda.params.len(), 1);
assert!(
matches!(&lambda.params[0].annotation, Some(TypeExpr::Named { name, .. }) if name == "int"),
"re-lowered lambda lost its param annotation: {:?}",
lambda.params[0].annotation
);
assert!(
matches!(&lambda.return_type, Some(TypeExpr::Named { name, .. }) if name == "int"),
"re-lowered lambda lost its return annotation: {:?}",
lambda.return_type
);
assert!(
matches!(&lambda.body, LambdaBody::Expr(_)),
"re-lowered lambda lost its expression body: {:?}",
lambda.body
);
}
#[test]
fn value_carrying_return_round_trips() {
let src = "flow f() {\n Hello.\n return hp > 0\n}\n";
let emitted = lower_and_emit(src).expect("value-carrying return must now emit");
assert!(
emitted.contains("return hp > 0"),
"expected the emitted source to spell the return's value back out:\n{emitted}"
);
let hir = reparse_and_lower(&emitted);
let Stmt::Return(r) = hir.knots[0].body.stmts.last().expect("a Return statement") else {
panic!(
"expected Return as the re-lowered body's last statement: {:?}",
hir.knots[0].body.stmts
);
};
assert!(
matches!(&r.value, Some(Expr::Infix(_))),
"re-lowered return lost its value expression: {:?}",
r.value
);
}
#[test]
fn return_redirect_still_wins_over_general_value_emission() {
let src = "flow b() {\n Bye.\n}\nflow a() {\n return -> b\n}\n";
let emitted = lower_and_emit(src).expect("return redirect must emit");
assert!(
emitted.contains("return -> b"),
"expected the tunnel-return redirect spelling, got:\n{emitted}"
);
assert!(
!emitted.contains("return b"),
"must not spell the redirect as a bare value expression:\n{emitted}"
);
reparse_and_lower(&emitted);
}
#[test]
fn code_ground_return_with_divert_target_value_refuses_to_emit() {
let lb = crate::LogicBlock {
ptr: crate::Provenance::synthetic(
crate::provenance::NodeClass::LogicBlock,
rowan::TextRange::new(rowan::TextSize::new(0), rowan::TextSize::new(1)),
),
stmts: vec![BlockStmt::Return(Return {
ptr: None,
kind: crate::ReturnKind::TunnelRedirect,
value: Some(Expr::DivertTarget(crate::Path {
segments: vec![Name {
text: "b".to_string(),
range: rowan::TextRange::new(
rowan::TextSize::new(0),
rowan::TextSize::new(1),
),
}],
range: rowan::TextRange::new(rowan::TextSize::new(0), rowan::TextSize::new(1)),
crosses_module_wall: false,
})),
onwards_args: Vec::new(),
})],
scope: LogicBlockScope::Standalone,
};
let mut out = String::new();
let err = emit_logic_block(&mut out, "", 0, &lb, "test").expect_err(
"a code-ground return of a divert target must refuse, not guess a spelling",
);
assert!(matches!(err, EmitError::Unsupported { .. }), "{err:?}");
}
#[test]
fn allow_scope_is_refused_not_silently_dropped() {
let src = "@[allow(E014)]\nvar gold = 0\n";
let err = lower_and_emit(src).expect_err("an @[allow(…)] scope must not silently vanish");
assert!(
matches!(
err,
EmitError::Unsupported {
what: "@[allow(…)] suppression scopes",
..
}
),
"expected the allow-scopes refusal, got {err:?}"
);
}
#[test]
fn choice_body_with_trailing_statement_after_same_line_divert_round_trips() {
let src = "flow a() {\n {?\n * Hop. -> b {\n -> c\n }\n }\n}\n\
flow b() {\n Hi.\n}\n\
flow c() {\n Hey.\n}\n";
let emitted =
lower_and_emit(src).expect("a trailing statement after a same-line divert must emit");
assert!(emitted.contains("-> b"), "{emitted}");
assert!(emitted.contains("-> c"), "{emitted}");
let hir = reparse_and_lower(&emitted);
let Stmt::ChoiceSet(cs) = &hir.knots[0].body.stmts[0] else {
panic!(
"expected ChoiceSet as the re-lowered body's first statement: {:?}",
hir.knots[0].body
);
};
let target_names: Vec<String> = cs.choices[0]
.body
.stmts
.iter()
.filter_map(|s| match s {
Stmt::Divert(d) => match &d.target.path {
DivertPath::Path(p) => Some(emit_path(p)),
_ => None,
},
_ => None,
})
.collect();
assert_eq!(target_names, vec!["b".to_string(), "c".to_string()]);
}
#[test]
fn labeled_block_immediately_followed_by_choice_point_round_trips() {
let src = "flow a() {\n (start)\n {?\n *[Choice 1]\n *[Choice 2]\n }\n}\n";
let emitted =
lower_and_emit(src).expect("a label directly above a choice point must now emit");
assert!(emitted.contains("(start)"), "{emitted}");
let hir = reparse_and_lower(&emitted);
let Stmt::LabeledBlock(b) = &hir.knots[0].body.stmts[0] else {
panic!(
"expected LabeledBlock as the re-lowered body's first statement: {:?}",
hir.knots[0].body
);
};
assert_eq!(b.label.as_ref().map(|n| n.text.as_str()), Some("start"));
assert!(
matches!(b.stmts.as_slice(), [Stmt::ChoiceSet(_)]),
"expected the label's body to be exactly a ChoiceSet: {:?}",
b.stmts
);
}
#[test]
fn logic_line_assignment_round_trips() {
let src = "flow a() {\n ~ n = 5\n}\n";
let emitted = lower_and_emit(src).expect("a content-ground assignment must now emit");
assert!(emitted.contains("~ n = 5"), "{emitted}");
let hir = reparse_and_lower(&emitted);
let Stmt::Assignment(a) = &hir.knots[0].body.stmts[0] else {
panic!(
"expected Stmt::Assignment as the re-lowered body's first statement: {:?}",
hir.knots[0].body
);
};
assert_eq!(a.op, crate::AssignOp::Set);
assert!(matches!(a.value, Expr::Int(5)));
}
#[test]
fn logic_line_compound_assignment_round_trips() {
for (src_op, expected_op) in [("+=", crate::AssignOp::Add), ("-=", crate::AssignOp::Sub)] {
let src = format!("flow a() {{\n ~ n {src_op} 3\n}}\n");
let emitted =
lower_and_emit(&src).expect("a content-ground compound assignment must now emit");
assert!(emitted.contains(&format!("~ n {src_op} 3")), "{emitted}");
let hir = reparse_and_lower(&emitted);
let Stmt::Assignment(a) = &hir.knots[0].body.stmts[0] else {
panic!("expected Stmt::Assignment: {:?}", hir.knots[0].body);
};
assert_eq!(a.op, expected_op);
}
}
#[test]
fn logic_line_bare_call_round_trips() {
let src = "flow bump() {\n return\n}\nflow a() {\n ~ bump()\n}\n";
let emitted = lower_and_emit(src).expect("a content-ground bare call must now emit");
assert!(emitted.contains("~ bump()"), "{emitted}");
let hir = reparse_and_lower(&emitted);
let a_body = &hir.knots[1].body;
assert!(matches!(a_body.stmts[0], Stmt::ExprStmt(Expr::Call(..))));
}
#[test]
fn logic_line_temp_decl_round_trips() {
let src = "flow a() {\n ~ let n = 5\n}\n";
let emitted = lower_and_emit(src).expect("a content-ground temp decl must now emit");
assert!(emitted.contains("~ let n = 5"), "{emitted}");
let hir = reparse_and_lower(&emitted);
let Stmt::TempDecl(t) = &hir.knots[0].body.stmts[0] else {
panic!(
"expected Stmt::TempDecl as the re-lowered body's first statement: {:?}",
hir.knots[0].body
);
};
assert_eq!(t.name.text, "n");
assert!(matches!(t.value, Some(Expr::Int(5))));
}
#[test]
fn logic_line_temp_decl_with_annotation_and_no_initializer_round_trips() {
let src = "flow a() {\n ~ let n: int\n}\n";
let emitted =
lower_and_emit(src).expect("an annotated, uninitialized temp decl must now emit");
assert!(emitted.contains("~ let n: int"), "{emitted}");
let hir = reparse_and_lower(&emitted);
let Stmt::TempDecl(t) = &hir.knots[0].body.stmts[0] else {
panic!("expected Stmt::TempDecl: {:?}", hir.knots[0].body);
};
assert!(t.value.is_none());
assert!(t.annotation.is_some());
}
#[test]
fn leading_thread_start_splice_round_trips() {
let src = "flow hub() {\n {?\n <- options(2)\n }\n}\n\
flow options(count) {\n -> DONE\n}\n";
let emitted = lower_and_emit(src).expect("a leading thread-start splice must now emit");
assert!(
emitted.contains("<- options(2)"),
"expected the emitted source to spell the splice (with its args) back out:\n{emitted}"
);
let hir = reparse_and_lower(&emitted);
let Stmt::ThreadStart(ts) = &hir.knots[0].body.stmts[0] else {
panic!(
"expected a leading Stmt::ThreadStart in the re-lowered body: {:?}",
hir.knots[0].body
);
};
assert!(
matches!(&ts.target.path, DivertPath::Path(p) if p.segments.last().is_some_and(|s| s.text == "options"))
);
assert!(matches!(ts.target.args.as_slice(), [Expr::Int(2)]));
assert!(
matches!(hir.knots[0].body.stmts.get(1), Some(Stmt::ChoiceSet(_))),
"expected the ChoiceSet immediately after the re-lowered leading splice: {:?}",
hir.knots[0].body
);
}
#[test]
fn trailing_thread_start_splice_round_trips() {
let src = "flow main() {\n {?\n * Look. You look around.\n <- helper(3)\n * Other choice.\n }\n}\n\
flow helper(n) {\n -> DONE\n}\n";
let emitted = lower_and_emit(src).expect("a trailing thread-start splice must now emit");
assert!(
emitted.contains("<- helper(3)"),
"expected the emitted source to spell the splice (with its args) back out:\n{emitted}"
);
let hir = reparse_and_lower(&emitted);
let Stmt::ChoiceSet(cs) = &hir.knots[0].body.stmts[0] else {
panic!(
"expected ChoiceSet as the re-lowered body's first statement: {:?}",
hir.knots[0].body
);
};
assert_eq!(
cs.choices.len(),
2,
"expected both choices to survive: {cs:?}"
);
let Some(Stmt::ThreadStart(ts)) = cs.choices[0].body.stmts.last() else {
panic!(
"expected the first choice's body to end in a re-lowered Stmt::ThreadStart: {:?}",
cs.choices[0].body
);
};
assert!(
matches!(&ts.target.path, DivertPath::Path(p) if p.segments.last().is_some_and(|s| s.text == "helper"))
);
assert!(matches!(ts.target.args.as_slice(), [Expr::Int(3)]));
}
#[test]
fn logic_line_until_round_trips() {
let src = "flow a() {\n ~ until n > 0\n}\n";
let emitted = lower_and_emit(src).expect("a content-ground `until` must now emit");
assert!(emitted.contains("~ until n > 0"), "{emitted}");
let hir = reparse_and_lower(&emitted);
let Stmt::Await(a) = &hir.knots[0].body.stmts[0] else {
panic!(
"expected Stmt::Await as the re-lowered body's first statement: {:?}",
hir.knots[0].body
);
};
assert!(matches!(a.condition, Some(Expr::Infix(_))));
}
#[test]
fn logic_line_block_round_trips() {
let src = "flow a() {\n ~{\n let m = 1;\n n = m;\n bump();\n }\n}\n";
let emitted = lower_and_emit(src).expect("a content-ground logic block must now emit");
assert!(emitted.contains("~{"), "{emitted}");
assert!(emitted.contains("let m = 1;"), "{emitted}");
assert!(emitted.contains("n = m;"), "{emitted}");
assert!(emitted.contains("bump();"), "{emitted}");
let hir = reparse_and_lower(&emitted);
let Stmt::LogicBlock(lb) = &hir.knots[0].body.stmts[0] else {
panic!(
"expected Stmt::LogicBlock as the re-lowered body's first statement: {:?}",
hir.knots[0].body
);
};
assert_eq!(lb.scope, crate::LogicBlockScope::Standalone);
assert_eq!(lb.stmts.len(), 3);
assert!(matches!(lb.stmts[0], crate::BlockStmt::TempDecl(_)));
assert!(matches!(lb.stmts[1], crate::BlockStmt::Assignment(_)));
assert!(matches!(lb.stmts[2], crate::BlockStmt::ExprStmt(_)));
assert!(
matches!(hir.knots[0].body.stmts.get(1), Some(Stmt::EndOfLine)),
"expected a trailing Stmt::EndOfLine after the re-lowered LogicBlock: {:?}",
hir.knots[0].body
);
}
#[test]
fn logic_line_block_with_nested_control_flow_refuses_to_emit() {
let src = "flow a() {\n ~{\n if n > 0 {\n n = 1;\n }\n }\n}\n";
let err = lower_and_emit(src)
.expect_err("nested control flow inside a `~{ }` block must still refuse, not guess");
assert!(matches!(err, EmitError::Unsupported { .. }));
}
#[test]
fn fn_default_code_ground_body_round_trips_via_logic_block() {
let src = "fn shout() {\n n = n + 1;\n}\n";
let emitted = lower_and_emit(src).expect("a fn's default code-ground body must now emit");
assert!(emitted.contains(">{"), "{emitted}");
assert!(emitted.contains("~{"), "{emitted}");
let hir = reparse_and_lower(&emitted);
let Stmt::LogicBlock(lb) = &hir.knots[0].body.stmts[0] else {
panic!(
"expected the re-lowered fn body to still be one whole-body LogicBlock: {:?}",
hir.knots[0].body
);
};
assert_eq!(lb.scope, crate::LogicBlockScope::Standalone);
assert!(matches!(
lb.stmts.as_slice(),
[crate::BlockStmt::Assignment(_)]
));
}
fn synthetic_branch(condition: Option<Expr>, stmt: Stmt) -> CondBranch {
let empty_range = rowan::TextRange::empty(rowan::TextSize::from(0));
CondBranch {
ptr: crate::provenance::Provenance::synthetic(
crate::provenance::NodeClass::ConditionalBranch,
empty_range,
),
condition,
binding: None,
body: Block {
label: None,
stmts: vec![stmt.clone()],
container_id: None,
tail: crate::tail_from_stmts(&[stmt]),
},
container_id: None,
}
}
#[test]
fn if_else_two_way_chain_emits_nested_native_syntax() {
let empty_range = rowan::TextRange::empty(rowan::TextSize::from(0));
let cond = Conditional {
ptr: crate::provenance::Provenance::synthetic(
crate::provenance::NodeClass::Conditional,
empty_range,
),
kind: CondKind::IfElse,
branches: vec![
synthetic_branch(Some(Expr::Bool(true)), Stmt::ExprStmt(Expr::Int(1))),
synthetic_branch(None, Stmt::ExprStmt(Expr::Int(2))),
],
};
let mut out = String::new();
emit_conditional(&mut out, "", 0, &cond, "test")
.expect("a 2-way IfElse chain must now emit");
assert_eq!(out, "{if true {\n ~ 1\n} else {\n ~ 2\n}}\n");
}
#[test]
fn if_else_three_way_chain_emits_nested_native_syntax_and_reparses() {
let empty_range = rowan::TextRange::empty(rowan::TextSize::from(0));
let cond = Conditional {
ptr: crate::provenance::Provenance::synthetic(
crate::provenance::NodeClass::Conditional,
empty_range,
),
kind: CondKind::IfElse,
branches: vec![
synthetic_branch(Some(Expr::Bool(true)), Stmt::ExprStmt(Expr::Int(1))),
synthetic_branch(Some(Expr::Bool(false)), Stmt::ExprStmt(Expr::Int(2))),
synthetic_branch(None, Stmt::ExprStmt(Expr::Int(3))),
],
};
let mut out = String::new();
emit_conditional(&mut out, "", 0, &cond, "test")
.expect("a 3-way IfElse chain must now emit");
assert_eq!(
out,
"{if true {\n ~ 1\n} else {\n {if false {\n ~ 2\n } else {\n ~ 3\n }}\n}}\n"
);
let src = format!("flow a() {{\n{out}}}\n");
let hir = reparse_and_lower(&src);
let Some(Stmt::Conditional(outer)) = hir.knots[0].body.stmts.first() else {
panic!(
"expected the re-lowered body's first statement to be a Conditional: {:?}",
hir.knots[0].body
);
};
assert_eq!(outer.kind, CondKind::InitialCondition);
assert_eq!(outer.branches.len(), 2);
let else_arm = &outer.branches[1];
assert!(else_arm.condition.is_none());
let Some(Stmt::Conditional(inner)) = else_arm.body.stmts.first() else {
panic!(
"expected the outer else arm's body to open with a nested Conditional: {:?}",
else_arm.body
);
};
assert_eq!(inner.kind, CondKind::InitialCondition);
assert_eq!(inner.branches.len(), 2);
}
#[test]
fn if_else_chain_with_no_trailing_else_has_no_innermost_else_arm() {
let empty_range = rowan::TextRange::empty(rowan::TextSize::from(0));
let cond = Conditional {
ptr: crate::provenance::Provenance::synthetic(
crate::provenance::NodeClass::Conditional,
empty_range,
),
kind: CondKind::IfElse,
branches: vec![
synthetic_branch(Some(Expr::Bool(true)), Stmt::ExprStmt(Expr::Int(1))),
synthetic_branch(Some(Expr::Bool(false)), Stmt::ExprStmt(Expr::Int(2))),
],
};
let mut out = String::new();
emit_conditional(&mut out, "", 0, &cond, "test")
.expect("an IfElse chain with no trailing else must now emit");
assert_eq!(
out,
"{if true {\n ~ 1\n} else {\n {if false {\n ~ 2\n }}\n}}\n"
);
}
#[test]
fn fn_prose_body_value_return_round_trips() {
let src = "fn heal(hp) >{\n return hp\n}\n";
let emitted = lower_and_emit(src).expect("fn with a value return must emit");
assert!(
emitted.contains(">{"),
"emitted fn body must carry the `>{{` prose-ground override:\n{emitted}"
);
let hir = reparse_and_lower(&emitted);
assert!(hir.knots[0].is_function);
let Stmt::Return(r) = &hir.knots[0].body.stmts[0] else {
panic!(
"expected Stmt::Return as the re-lowered fn body's first statement: {:?}",
hir.knots[0].body
);
};
assert!(matches!(r.value, Some(Expr::Path(_))));
}
#[test]
fn fn_prose_body_bare_return_and_content_round_trip() {
let src = "fn greet() >{\n Hi.\n return\n}\n";
let emitted = lower_and_emit(src).expect("fn with prose content + bare return must emit");
assert!(
emitted.contains(">{"),
"emitted fn body must carry the `>{{` prose-ground override:\n{emitted}"
);
let hir = reparse_and_lower(&emitted);
assert!(hir.knots[0].is_function);
let stmts = &hir.knots[0].body.stmts;
assert!(
stmts.iter().any(|s| matches!(s, Stmt::Content(_))),
"expected a Content statement among the re-lowered fn body: {stmts:?}"
);
assert!(
stmts
.iter()
.any(|s| matches!(s, Stmt::Return(r) if r.value.is_none())),
"expected a bare Return statement among the re-lowered fn body: {stmts:?}"
);
}
#[test]
fn flow_body_has_no_selector_prefix() {
let src = "flow a() {\n Hi.\n}\n";
let emitted = lower_and_emit(src).expect("flow must emit");
assert!(
emitted.contains("flow a() {\n"),
"flow header must stay a bare `{{`, no selector:\n{emitted}"
);
}
#[test]
fn pub_var_const_flags_struct_extern_round_trip() {
let src = "\
pub var hp: int = 10
pub const cap = 100
pub flags mood = calm, wary
pub struct npc {
hp: int
}
pub extern log_msg(msg)
flow a() {
Hi.
}
";
let emitted = lower_and_emit(src).expect("pub var/const/flags/struct/extern must now emit");
assert!(
emitted.contains("pub var hp"),
"var must keep its pub prefix:\n{emitted}"
);
assert!(
emitted.contains("pub const cap"),
"const must keep its pub prefix:\n{emitted}"
);
assert!(
emitted.contains("pub flags mood"),
"flags must keep its pub prefix:\n{emitted}"
);
assert!(
emitted.contains("pub struct npc"),
"struct must keep its pub prefix:\n{emitted}"
);
assert!(
emitted.contains("pub extern log_msg"),
"extern must keep its pub prefix:\n{emitted}"
);
let hir = reparse_and_lower(&emitted);
assert_eq!(
hir.variables[0].visibility,
Some(crate::VisibilityMark::Public)
);
assert_eq!(
hir.constants[0].visibility,
Some(crate::VisibilityMark::Public)
);
assert_eq!(hir.lists[0].visibility, Some(crate::VisibilityMark::Public));
assert_eq!(
hir.structs[0].visibility,
Some(crate::VisibilityMark::Public)
);
assert_eq!(
hir.externals[0].visibility,
Some(crate::VisibilityMark::Public)
);
}
#[test]
fn pub_flow_knot_and_nested_stitch_round_trip() {
let src = "pub flow a() {\n Hi.\n\n pub flow b() {\n Inner.\n }\n}\n";
let emitted = lower_and_emit(src)
.expect("pub flow (knot) and nested pub flow (stitch) must now emit");
assert!(
emitted.starts_with("pub flow a("),
"top-level flow must keep its pub prefix:\n{emitted}"
);
assert!(
emitted.contains("pub flow b("),
"nested flow (stitch) must keep its pub prefix:\n{emitted}"
);
let hir = reparse_and_lower(&emitted);
assert_eq!(hir.knots[0].visibility, Some(crate::VisibilityMark::Public));
assert_eq!(
hir.knots[0].stitches[0].visibility,
Some(crate::VisibilityMark::Public)
);
}
#[test]
fn pub_fn_round_trips() {
let src = "pub fn heal() {\n return 1;\n}\n";
let emitted = lower_and_emit(src).expect("pub fn must now emit");
assert!(
emitted.starts_with("pub fn heal("),
"fn must keep its pub prefix:\n{emitted}"
);
let hir = reparse_and_lower(&emitted);
assert_eq!(hir.knots[0].visibility, Some(crate::VisibilityMark::Public));
}
#[test]
fn absent_visibility_still_emits_with_no_pub_prefix() {
let src = "var hp = 1\nflow a() {\n Hi.\n}\n";
let emitted = lower_and_emit(src).expect("undecorated declarations must still emit");
assert!(
emitted.starts_with("var hp"),
"var with no visibility mark must not gain a pub prefix:\n{emitted}"
);
assert!(
emitted.contains("\nflow a("),
"flow with no visibility mark must not gain a pub prefix:\n{emitted}"
);
}
#[test]
fn private_visibility_mark_is_still_refused_with_a_named_reason() {
let src = "var hp = 10\n";
let parse = brink_syntax_native::parse(src);
let tree = parse.tree();
let (mut hir, _manifest, diags) = crate::hir::lower_native::lower(crate::FileId(0), &tree);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
hir.variables[0].visibility = Some(crate::VisibilityMark::Private);
let err = emit_file(&hir)
.expect_err("Some(Private) has no native spelling and must stay refused");
let msg = err.to_string();
assert!(
msg.contains("var directive channel"),
"refusal message should name the var-directive channel, got: {msg}"
);
}
}