#![allow(clippy::panic)]
use super::*;
use crate::{BlockStmt, DiagnosticCode, ElseBranch, Tail, Terminator};
fn lower_src(src: &str) -> (HirFile, SymbolManifest, Vec<Diagnostic>) {
let parse = brink_syntax_native::parse(src);
let tree = parse.tree();
lower(FileId(0), &tree)
}
#[test]
fn top_level_flow_lowers_to_knot() {
let (hir, manifest, diags) = lower_src("flow greet(name) {\n Hi, {name}!\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.knots.len(), 1);
let knot = &hir.knots[0];
assert_eq!(knot.name.text, "greet");
assert!(!knot.is_function);
assert_eq!(knot.params.len(), 1);
assert_eq!(knot.params[0].name.text, "name");
assert!(!knot.body.stmts.is_empty(), "body must no longer be a stub");
assert_eq!(manifest.knots.len(), 1);
assert_eq!(manifest.knots[0].name, "greet");
}
#[test]
fn fn_decl_sets_is_function() {
let (hir, manifest, diags) = lower_src("fn heal(hp) {\n return hp;\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.knots.len(), 1);
assert!(hir.knots[0].is_function);
assert_eq!(
manifest.knots[0].detail.as_deref(),
Some("function"),
"is_function must agree with the manifest's function sentinel (E123)"
);
}
#[test]
fn nested_flow_becomes_a_stitch() {
let (hir, manifest, diags) =
lower_src("flow garden() {\n flow gate(ref hp) {\n Creak.\n }\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.knots.len(), 1);
assert_eq!(hir.knots[0].stitches.len(), 1);
let stitch = &hir.knots[0].stitches[0];
assert_eq!(stitch.name.text, "gate");
assert_eq!(stitch.params.len(), 1);
assert!(stitch.params[0].is_ref);
assert_eq!(manifest.stitches.len(), 1);
assert_eq!(
manifest.stitches[0].name, "garden.gate",
"knot.stitch qualification"
);
}
#[test]
fn leading_doc_comment_populates_knot_doc() {
let (hir, _manifest, diags) = lower_src(
"/// Greets the player.\n/// @param name {string}\nflow greet(name) {\n Hi!\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let knot = &hir.knots[0];
let doc = knot.doc.as_ref().expect("doc attached");
assert_eq!(doc.doc.as_deref(), Some("Greets the player."));
assert_eq!(
doc.params,
vec![("name".to_string(), crate::TypeRef("string".to_string()))]
);
}
#[test]
fn malformed_param_in_leading_doc_reports_e038() {
let (hir, _manifest, diags) = lower_src("/// @param name\nflow greet(name) {\n}\n");
assert!(hir.knots[0].doc.is_none(), "no valid tags -> no DocBlock");
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E038),
"expected E038 for the malformed @param: {diags:?}"
);
}
#[test]
fn inner_doc_populates_knot_doc_when_no_leading_doc() {
let (hir, _manifest, diags) =
lower_src("flow greet() {\n//! Describes this flow from within.\nHi!\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let doc = hir.knots[0].doc.as_ref().expect("inner doc attached");
assert_eq!(doc.doc.as_deref(), Some("Describes this flow from within."));
}
#[test]
fn leading_doc_wins_over_inner_doc_when_both_present() {
let src = "/// Outer doc.\nflow greet() {\n//! Inner doc.\nHi!\n}\n";
let (hir, _manifest, diags) = lower_src(src);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let doc = hir.knots[0].doc.as_ref().expect("doc attached");
assert_eq!(doc.doc.as_deref(), Some("Outer doc."));
}
#[test]
fn leading_doc_comment_populates_stitch_doc() {
let src = "flow garden() {\n /// The gate stitch.\n flow gate() {\n Creak.\n }\n}\n";
let (hir, _manifest, diags) = lower_src(src);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let stitch = &hir.knots[0].stitches[0];
let doc = stitch.doc.as_ref().expect("doc attached");
assert_eq!(doc.doc.as_deref(), Some("The gate stitch."));
}
#[test]
fn leading_doc_comment_populates_var_const_flags_struct_extern_doc() {
let src = "\
/// Player health.
var hp = 10
/// Max health.
const max_hp = 100
/// Mood states.
flags Mood = calm, wary
/// An NPC.
struct Npc {\n hp: int\n}
/// Logs a message.
extern log_msg(msg)
";
let (hir, _manifest, diags) = lower_src(src);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(
hir.variables[0].doc.as_ref().and_then(|d| d.doc.clone()),
Some("Player health.".to_string())
);
assert_eq!(
hir.constants[0].doc.as_ref().and_then(|d| d.doc.clone()),
Some("Max health.".to_string())
);
assert_eq!(
hir.lists[0].doc.as_ref().and_then(|d| d.doc.clone()),
Some("Mood states.".to_string())
);
assert_eq!(
hir.structs[0].doc.as_ref().and_then(|d| d.doc.clone()),
Some("An NPC.".to_string())
);
assert_eq!(
hir.externals[0].doc.as_ref().and_then(|d| d.doc.clone()),
Some("Logs a message.".to_string())
);
}
#[test]
fn undocumented_declarations_have_no_doc_native() {
let (hir, _manifest, diags) = lower_src("var hp = 10\nflow greet() {\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(hir.variables[0].doc.is_none());
assert!(hir.knots[0].doc.is_none());
}
#[test]
fn pub_flow_and_fn_lower_with_public_visibility() {
let (hir, _manifest, diags) =
lower_src("pub flow greet() {\n}\npub fn heal() {\n return 1;\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.knots.len(), 2);
assert_eq!(hir.knots[0].visibility, Some(crate::VisibilityMark::Public));
assert_eq!(hir.knots[1].visibility, Some(crate::VisibilityMark::Public));
}
#[test]
fn pub_nested_flow_stitch_lowers_with_public_visibility() {
let (hir, _manifest, diags) =
lower_src("flow garden() {\n pub flow gate() {\n Creak.\n }\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(
hir.knots[0].visibility, None,
"outer flow was not marked pub"
);
assert_eq!(
hir.knots[0].stitches[0].visibility,
Some(crate::VisibilityMark::Public)
);
}
#[test]
fn pub_var_const_flags_struct_extern_lower_with_public_visibility() {
let src = "\
pub var hp = 10
pub const MAX = 100
pub flags Mood = calm, wary
pub struct Npc {\n hp: int\n}
pub extern log_msg(msg)
";
let (hir, _manifest, diags) = lower_src(src);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
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 absent_pub_leaves_visibility_none_native() {
let src = "\
flow greet() {\n}
fn heal() {\n return 1;\n}
var hp = 10
const MAX = 100
flags Mood = calm, wary
struct Npc {\n hp: int\n}
extern log_msg(msg)
";
let (hir, _manifest, diags) = lower_src(src);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.knots[0].visibility, None);
assert_eq!(hir.knots[1].visibility, None);
assert_eq!(hir.variables[0].visibility, None);
assert_eq!(hir.constants[0].visibility, None);
assert_eq!(hir.lists[0].visibility, None);
assert_eq!(hir.structs[0].visibility, None);
assert_eq!(hir.externals[0].visibility, None);
}
#[test]
fn depth_three_nesting_is_rejected_loudly() {
let (hir, _manifest, diags) =
lower_src("flow a() {\n flow b() {\n flow c() {\n Too deep.\n }\n }\n}\n");
assert_eq!(hir.knots.len(), 1);
assert_eq!(hir.knots[0].stitches.len(), 1, "b still lowers as a stitch");
assert!(hir.knots[0].stitches[0].name.text.eq("b"));
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E130),
"expected E130 for the depth-3 flow c(): {diags:?}"
);
}
#[test]
fn nested_fn_is_not_yet_lowered() {
let (hir, _manifest, diags) = lower_src("flow a() {\n fn b() {\n x\n }\n}\n");
assert_eq!(hir.knots.len(), 1);
assert!(hir.knots[0].stitches.is_empty());
assert!(diags.iter().any(|d| d.code == DiagnosticCode::E129));
}
#[test]
fn var_const_flags_lower_and_hoist_globally() {
let (hir, manifest, diags) = lower_src(
"var hp = 10\nconst max_hp = 100\nflags Mood = (calm), wary, hostile\nflow a() {\n var nested = 1\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(
hir.variables.len(),
2,
"top-level hp + nested-in-flow-body var"
);
assert_eq!(hir.constants.len(), 1);
assert_eq!(hir.lists.len(), 1);
assert_eq!(hir.lists[0].members.len(), 3);
assert!(hir.lists[0].members[0].is_active);
assert!(!hir.lists[0].members[1].is_active);
assert_eq!(manifest.variables.len(), 2);
assert_eq!(manifest.constants.len(), 1);
assert_eq!(manifest.lists.len(), 1);
assert_eq!(manifest.list_items.len(), 3);
}
#[test]
fn struct_and_extern_lower_at_top_level() {
let (hir, manifest, diags) =
lower_src("struct Npc {\n name: string,\n hp: int\n}\nextern do_thing(a, ref b)\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.structs.len(), 1);
assert_eq!(hir.structs[0].fields.len(), 2);
assert_eq!(hir.externals.len(), 1);
assert_eq!(hir.externals[0].param_count, 2);
assert_eq!(hir.externals[0].params.len(), 2);
assert!(
!hir.externals[0].params[1].is_ref,
"EXTERNAL params always report is_ref=false, matching ink's convention"
);
assert_eq!(manifest.structs.len(), 1);
assert_eq!(manifest.externals.len(), 1);
}
#[test]
fn struct_nested_in_a_flow_body_is_not_silently_dropped() {
let (hir, _manifest, diags) = lower_src("flow a() {\n struct Npc {\n hp: int\n }\n}\n");
assert!(hir.structs.is_empty(), "not lowered — out of position");
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E129),
"must diagnose, not silently drop: {diags:?}"
);
}
#[test]
fn use_decl_lowers_to_import() {
let (hir, _manifest, diags) = lower_src("use story::market::{barter, haggle as h};\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.imports.len(), 1);
let imp = &hir.imports[0];
assert_eq!(imp.module, "story::market");
assert!(imp.bare);
assert_eq!(imp.items.len(), 2);
assert_eq!(imp.items[0].name, "barter");
assert_eq!(imp.items[0].alias, None);
assert_eq!(imp.items[1].name, "haggle");
assert_eq!(imp.items[1].alias.as_deref(), Some("h"));
}
#[test]
fn use_decl_leaf_is_the_item_not_part_of_the_module() {
let src = "use story::market::barter::haggle;\n";
let (hir, _manifest, diags) = lower_src(src);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.imports.len(), 1);
let imp = &hir.imports[0];
assert_eq!(imp.module, "story::market::barter");
assert!(imp.bare, "a named item is a name-precise (bare) import");
assert_eq!(imp.items.len(), 1);
assert_eq!(imp.items[0].name, "haggle");
assert_eq!(imp.items[0].alias, None);
assert_eq!(&src[imp.items[0].range], "haggle");
assert_eq!(&src[imp.module_range], "story::market::barter");
}
#[test]
fn aliased_use_decl_lowers_to_an_aliased_item() {
let src = "use story::market::barter as b;\n";
let (hir, _manifest, diags) = lower_src(src);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.imports.len(), 1);
let imp = &hir.imports[0];
assert_eq!(imp.module, "story::market");
assert!(imp.bare);
assert_eq!(imp.items.len(), 1);
assert_eq!(imp.items[0].name, "barter");
assert_eq!(imp.items[0].alias.as_deref(), Some("b"));
assert_eq!(&src[imp.items[0].range], "barter as b");
}
#[test]
fn single_segment_use_decl_lowers_to_bare_false_import() {
let (hir, _manifest, diags) = lower_src("use story;\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.imports.len(), 1);
assert_eq!(hir.imports[0].module, "story");
assert!(!hir.imports[0].bare);
assert!(hir.imports[0].items.is_empty());
}
#[test]
fn single_segment_aliased_use_decl_is_flagged() {
let (hir, _manifest, diags) = lower_src("use story as s;\n");
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E129),
"a module-level alias must be flagged: {diags:?}"
);
assert!(hir.imports.is_empty());
}
#[test]
fn import_decl_lowers_to_qualified_import() {
let (hir, _manifest, diags) = lower_src("import story::market\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.imports.len(), 1);
assert_eq!(hir.imports[0].module, "story::market");
assert!(!hir.imports[0].bare);
}
#[test]
fn module_block_is_flagged_and_flattened() {
let (hir, manifest, diags) = lower_src("module npcs {\n flow greet() {\n Hi!\n }\n}\n");
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E129),
"module nesting must be flagged: {diags:?}"
);
assert_eq!(
hir.knots.len(),
1,
"contents still flattened into the file scope"
);
assert_eq!(hir.knots[0].name.text, "greet");
assert_eq!(manifest.knots.len(), 1);
}
#[test]
fn root_content_is_empty_without_a_main_flow() {
let (hir, _manifest, _diags) = lower_src("flow a() {}\n");
assert!(hir.root_content.stmts.is_empty());
assert!(hir.includes.is_empty());
assert!(hir.module.is_none());
}
#[test]
fn top_level_main_flow_synthesizes_a_root_divert() {
let (hir, _manifest, diags) = lower_src("flow main() {\n Hi.\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.root_content.stmts.len(), 1);
let Stmt::Divert(d) = &hir.root_content.stmts[0] else {
panic!(
"expected root_content to be a single Divert, got {:?}",
hir.root_content.stmts[0]
);
};
assert!(
d.ptr.is_none(),
"synthesized entry divert has no source ptr"
);
let DivertPath::Path(path) = &d.target.path else {
panic!("expected a named divert target, got {:?}", d.target.path);
};
assert_eq!(path.segments.len(), 1);
assert_eq!(path.segments[0].text, "main");
assert!(d.target.args.is_empty());
assert!(
matches!(
hir.root_content.tail(),
Tail::Diverge(Terminator::Divert(_))
),
"the synthesized divert must also drive Block::tail: {:?}",
hir.root_content.tail()
);
}
#[test]
fn nested_main_flow_does_not_synthesize_an_entry() {
let (hir, _manifest, diags) = lower_src("flow outer() {\n flow main() {\n Hi.\n }\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(hir.root_content.stmts.is_empty());
}
#[test]
fn function_named_main_does_not_synthesize_an_entry() {
let (hir, _manifest, diags) = lower_src("fn main() {\n return;\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(hir.root_content.stmts.is_empty());
}
#[test]
fn parameterized_main_flow_does_not_synthesize_an_entry() {
let (hir, _manifest, diags) = lower_src("flow main(who) {\n Hi, {who}.\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(hir.root_content.stmts.is_empty());
}
#[test]
fn stray_top_level_content_is_diagnosed_not_dropped() {
let (_hir, _manifest, diags) = lower_src("Just some loose prose.\n");
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E129),
"stray content must not vanish silently: {diags:?}"
);
}
const DECLARATION_FIXTURE: &str = "\
var hp = 10
const max_hp = 100
flags Mood = (calm), wary, hostile
struct Npc {
name: string,
hp: int
}
extern do_thing(a, ref b)
use story::market::{barter, haggle as h};
import story::npcs
flow garden(mood) {
flow gate(ref visits) {
Creak.
}
}
fn heal(target, amount) {
return;
}
";
#[test]
fn well_formed_declaration_fixture_lowers_with_no_diagnostics() {
let (_hir, _manifest, diags) = lower_src(DECLARATION_FIXTURE);
assert!(
diags.is_empty(),
"declaration-only fixture must be diagnostic-clean: {diags:?}"
);
}
use crate::{
ChoiceSetContext, CondKind, ContentPart, DivertPath, Expr, ReturnKind, SequenceType, Stmt,
};
fn only_knot_body(hir: &HirFile) -> &crate::Block {
&hir.knots[0].body
}
#[test]
fn content_glue_interpolation_and_tags_lower() {
let (hir, _m, diags) = lower_src("flow a() {\n Hi, {name}! <> #mood: happy\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
assert_eq!(
body.stmts.len(),
2,
"glue suppresses the EndOfLine: {body:?}"
);
assert!(
matches!(
&body.stmts[1],
Stmt::Divert(d) if d.target.path == DivertPath::Done
),
"expected trailing implicit `-> DONE`, got {:?}",
body.stmts[1]
);
let Stmt::Content(c) = &body.stmts[0] else {
panic!("expected Content, got {:?}", body.stmts[0]);
};
assert_eq!(c.tags.len(), 1);
assert!(matches!(&c.parts[0], ContentPart::Text(t) if t == "Hi, "));
assert!(matches!(
&c.parts[1],
ContentPart::Interpolation(Expr::Path(_))
));
assert!(matches!(&c.parts[2], ContentPart::Text(t) if t == "! "));
assert!(matches!(c.parts[3], ContentPart::Glue));
}
#[test]
fn a_tag_starting_with_at_on_a_trailing_tag_line_emits_e172() {
let (hir, _m, diags) = lower_src("flow a() {\n Hi. #@was(\"old_name\")\n}\n");
assert_eq!(
diags
.iter()
.filter(|d| d.code == DiagnosticCode::E172)
.count(),
1,
"expected exactly one E172, got: {diags:?}"
);
let body = only_knot_body(&hir);
let Stmt::Content(c) = &body.stmts[0] else {
panic!("expected Content, got {:?}", body.stmts[0]);
};
assert_eq!(c.tags.len(), 1, "the tag still lowers as ordinary content");
assert!(matches!(&c.tags[0].parts[0], ContentPart::Text(t) if t == "@was(\"old_name\")"));
}
#[test]
fn a_tag_with_an_escaped_hash_lowers_with_the_backslash_stripped() {
let (hir, _m, diags) = lower_src("flow a() {\n Hi. #tag \\#more\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let Stmt::Content(c) = &body.stmts[0] else {
panic!("expected Content, got {:?}", body.stmts[0]);
};
assert_eq!(
c.tags.len(),
1,
"an escaped `#` must not split this into two tags: {:?}",
c.tags
);
assert!(
matches!(&c.tags[0].parts[0], ContentPart::Text(t) if t == "tag #more"),
"expected the literal `#` preserved with its escaping backslash \
stripped (issue #2045), got {:?}",
c.tags[0].parts
);
}
#[test]
fn a_tag_with_an_escaped_open_brace_lowers_with_the_backslash_stripped() {
let (hir, _m, diags) = lower_src("flow a() {\n Hi. #tag \\{gold\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let Stmt::Content(c) = &body.stmts[0] else {
panic!("expected Content, got {:?}", body.stmts[0]);
};
assert_eq!(c.tags.len(), 1);
assert!(
matches!(&c.tags[0].parts[0], ContentPart::Text(t) if t == "tag {gold"),
"expected the literal `{{` preserved with its escaping backslash \
stripped (issue #2045), got {:?}",
c.tags[0].parts
);
}
#[test]
fn an_escaped_closing_brace_lowers_to_a_literal_brace() {
let (hir, _m, diags) = lower_src("flow a() {\n A literal \\{brace\\} here.\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let Stmt::Content(c) = &body.stmts[0] else {
panic!("expected Content, got {:?}", body.stmts[0]);
};
assert!(
matches!(&c.parts[0], ContentPart::Text(t) if t == "A literal {brace} here."),
"both braces should survive as literals with their backslashes \
stripped, got {:?}",
c.parts
);
}
#[test]
fn a_standalone_at_prefixed_tag_line_emits_e172() {
let (hir, _m, diags) = lower_src("flow a() {\n #@private\n}\n");
assert_eq!(
diags
.iter()
.filter(|d| d.code == DiagnosticCode::E172)
.count(),
1,
"expected exactly one E172, got: {diags:?}"
);
assert_eq!(hir.knots.len(), 1);
}
#[test]
fn e172_names_the_native_annotation_equivalent_when_one_exists() {
let (_hir, _m, diags) = lower_src("flow a() {\n #@was(\"old\")\n}\n");
let msg = &diags
.iter()
.find(|d| d.code == DiagnosticCode::E172)
.expect("E172 expected")
.message;
assert!(
msg.contains("@[was(") && msg.contains("was"),
"expected the native `@[was(…)]` spelling to be named, got: {msg}"
);
}
#[test]
fn e172_says_no_native_meaning_when_no_annotation_equivalent_exists() {
let (_hir, _m, diags) = lower_src("flow a() {\n #@local\n}\n");
let msg = &diags
.iter()
.find(|d| d.code == DiagnosticCode::E172)
.expect("E172 expected")
.message;
assert!(
msg.contains("no directive channel") && msg.contains("no `local` equivalent"),
"expected the no-native-meaning wording, got: {msg}"
);
}
#[test]
fn e172_gives_allow_its_own_wording_rather_than_calling_it_an_ink_directive() {
let (_hir, _m, diags) = lower_src("flow a() {\n #@allow(E172)\n}\n");
let msg = &diags
.iter()
.find(|d| d.code == DiagnosticCode::E172)
.expect("E172 expected")
.message;
assert!(
msg.contains("no directive meaning in either dialect") && msg.contains("@[allow("),
"expected `#@allow`'s own wording (no ink meaning, names the unrelated native \
`@[allow(…)]` suppression channel), got: {msg}"
);
assert!(
!msg.contains("is the ink-dialect directive-tag spelling"),
"must not claim ink recognizes `allow` as a directive name: {msg}"
);
}
#[test]
fn e172_does_not_assert_ink_membership_for_an_unrecognized_name() {
let (_hir, _m, diags) = lower_src("flow a() {\n #@narrator\n}\n");
let msg = &diags
.iter()
.find(|d| d.code == DiagnosticCode::E172)
.expect("E172 expected")
.message;
assert!(
msg.contains("has the shape of an ink-dialect compiler-directive tag"),
"expected the shape-only wording for an unrecognized name, got: {msg}"
);
assert!(
!msg.contains("is an ink-dialect compiler-directive spelling"),
"must not assert ink recognizes `narrator` as a directive name: {msg}"
);
}
#[test]
fn an_ordinary_tag_without_a_leading_at_does_not_emit_e172() {
let (_hir, _m, diags) = lower_src("flow a() {\n Hi. #mood: happy\n}\n");
assert!(
diags.iter().all(|d| d.code != DiagnosticCode::E172),
"unexpected E172 for a plain tag: {diags:?}"
);
}
#[test]
fn content_without_glue_gets_end_of_line() {
let (hir, _m, diags) = lower_src("flow a() {\n Plain line.\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
assert_eq!(body.stmts.len(), 3);
assert!(matches!(body.stmts[0], Stmt::Content(_)));
assert!(matches!(body.stmts[1], Stmt::EndOfLine));
assert!(matches!(
&body.stmts[2],
Stmt::Divert(d) if d.target.path == DivertPath::Done
));
}
#[test]
fn a_span_lowers_to_content_part_span_with_name_attrs_and_children() {
let (hir, _m, diags) =
lower_src("flow a() {\n He hands you <item id=\"lantern\">the lantern</item>.\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let Stmt::Content(c) = &body.stmts[0] else {
panic!("expected Content, got {:?}", body.stmts[0]);
};
assert!(matches!(&c.parts[0], ContentPart::Text(t) if t == "He hands you "));
let ContentPart::Span(span) = &c.parts[1] else {
panic!("expected Span, got {:?}", c.parts[1]);
};
assert_eq!(span.name, "item");
assert_eq!(span.attrs.len(), 1);
assert_eq!(span.attrs[0].name, "id");
assert_eq!(span.attrs[0].value, "lantern");
assert_eq!(span.children.len(), 1);
assert!(matches!(&span.children[0], ContentPart::Text(t) if t == "the lantern"));
assert!(matches!(&c.parts[2], ContentPart::Text(t) if t == "."));
}
#[test]
fn a_self_closing_span_lowers_with_no_children_no_attrs() {
let (hir, _m, diags) = lower_src("flow a() {\n Bell tolls. <pause/> Door slams.\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let Stmt::Content(c) = &body.stmts[0] else {
panic!("expected Content, got {:?}", body.stmts[0]);
};
let span = c
.parts
.iter()
.find_map(|p| match p {
ContentPart::Span(s) => Some(s),
_ => None,
})
.expect("expected a Span part");
assert_eq!(span.name, "pause");
assert!(span.attrs.is_empty());
assert!(span.children.is_empty());
}
#[test]
fn nested_spans_lower_to_nested_span_parts() {
let (hir, _m, diags) = lower_src("flow a() {\n <b><i>hi</i></b>\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let Stmt::Content(c) = &body.stmts[0] else {
panic!("expected Content, got {:?}", body.stmts[0]);
};
let ContentPart::Span(outer) = &c.parts[0] else {
panic!("expected Span, got {:?}", c.parts[0]);
};
assert_eq!(outer.name, "b");
let ContentPart::Span(inner) = &outer.children[0] else {
panic!("expected nested Span, got {:?}", outer.children[0]);
};
assert_eq!(inner.name, "i");
assert!(matches!(&inner.children[0], ContentPart::Text(t) if t == "hi"));
}
#[test]
fn a_span_may_contain_interpolation() {
let (hir, _m, diags) = lower_src("flow a(name) {\n <b>hello {name}</b>\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let Stmt::Content(c) = &body.stmts[0] else {
panic!("expected Content, got {:?}", body.stmts[0]);
};
let ContentPart::Span(span) = &c.parts[0] else {
panic!("expected Span, got {:?}", c.parts[0]);
};
assert!(matches!(&span.children[0], ContentPart::Text(t) if t == "hello "));
assert!(matches!(
&span.children[1],
ContentPart::Interpolation(Expr::Path(_))
));
}
#[test]
fn all_four_escapes_lower_to_literal_text_merged_into_one_part() {
let (hir, _m, diags) = lower_src("flow a() {\n \\< \\{ \\# \\\\\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let Stmt::Content(c) = &body.stmts[0] else {
panic!("expected Content, got {:?}", body.stmts[0]);
};
assert_eq!(c.parts.len(), 1, "expected one merged Text part: {c:?}");
let ContentPart::Text(t) = &c.parts[0] else {
panic!("expected Text, got {:?}", c.parts[0]);
};
assert_eq!(t.as_str(), "< { # \\");
}
#[test]
fn a_leading_backslash_at_lowers_to_literal_text_not_a_cue() {
let (hir, _m, diags) = lower_src("flow a() {\n \\@VENDOR waves.\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let Stmt::Content(c) = &body.stmts[0] else {
panic!("expected Content (not a Cue), got {:?}", body.stmts[0]);
};
assert_eq!(c.parts.len(), 1, "expected one merged Text part: {c:?}");
let ContentPart::Text(t) = &c.parts[0] else {
panic!("expected Text, got {:?}", c.parts[0]);
};
assert_eq!(t.as_str(), "@VENDOR waves.");
}
#[test]
fn a_backslash_before_anything_else_is_a_parse_error_not_a_hir_diagnostic() {
let parse = brink_syntax_native::parse("flow a() {\n \\n not an escape\n}\n");
assert!(!parse.errors().is_empty());
}
#[test]
fn a_conditional_branch_may_contain_a_fully_closed_span() {
let (hir, _m, diags) = lower_src("flow a() {\n {if hp > 0: <i>yawn</i> else: Ready.}\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let Stmt::Conditional(cond) = &body.stmts[0] else {
panic!("expected Conditional, got {:?}", body.stmts[0]);
};
let Stmt::Content(c) = &cond.branches[0].body.stmts[0] else {
panic!("expected Content, got {:?}", cond.branches[0].body.stmts[0]);
};
assert!(matches!(&c.parts[0], ContentPart::Span(s) if s.name == "i"));
}
#[test]
fn if_else_conditional_lowers_to_if_else_branches() {
let (hir, _m, diags) =
lower_src("var mood = 1\nflow a() {\n {if mood > 0 { Happy. } else { Sad. }}\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let Stmt::Conditional(cond) = &body.stmts[0] else {
panic!("expected Conditional, got {body:?}");
};
assert_eq!(cond.kind, CondKind::InitialCondition);
assert_eq!(cond.branches.len(), 2);
assert!(cond.branches[0].condition.is_some());
assert!(cond.branches[1].condition.is_none());
}
#[test]
fn match_conditional_lowers_to_switch_with_subject() {
let (hir, _m, diags) =
lower_src("var mood = 1\nflow a() {\n {match mood { 1 => Happy. 2 => Sad. }}\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let Stmt::Conditional(cond) = &body.stmts[0] else {
panic!("expected Conditional, got {body:?}");
};
assert!(matches!(cond.kind, CondKind::Switch(_)));
assert_eq!(cond.branches.len(), 2);
}
#[test]
fn block_level_alternation_gets_leading_end_of_line_per_branch() {
let (hir, _m, diags) = lower_src("flow a() {\n {~ One. | Two. | Three.}\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let Stmt::Sequence(seq) = &body.stmts[0] else {
panic!("expected Sequence, got {body:?}");
};
assert_eq!(seq.kind, SequenceType::SHUFFLE);
assert_eq!(seq.branches.len(), 3);
for branch in &seq.branches {
assert!(
matches!(branch.body.stmts[0], Stmt::EndOfLine),
"block-level sequence branch must lead with EndOfLine: {branch:?}"
);
}
}
#[test]
fn inline_alternation_inside_content_does_not_get_leading_eol() {
let (hir, _m, diags) = lower_src("flow a() {\n You see {& a cat|a dog}.\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let Stmt::Content(c) = &body.stmts[0] else {
panic!("expected Content, got {:?}", body.stmts[0]);
};
let inline = c
.parts
.iter()
.find_map(|p| match p {
ContentPart::InlineSequence(s) => Some(s),
_ => None,
})
.expect("expected an InlineSequence part");
assert_eq!(inline.branches.len(), 2);
assert!(
inline.branches[0]
.body
.stmts
.iter()
.all(|s| !matches!(s, Stmt::EndOfLine))
);
}
#[test]
fn choice_point_lowers_to_choice_set_with_sticky_and_once() {
let (hir, _m, diags) = lower_src("flow a() {\n {?\n * Once.\n + Sticky.\n }\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let Stmt::ChoiceSet(cs) = &body.stmts[0] else {
panic!("expected ChoiceSet, got {body:?}");
};
assert_eq!(
cs.context,
ChoiceSetContext::Inline,
"D4 posture: native-normal neutral"
);
assert_eq!(cs.depth, 0, "D4 posture: native-normal neutral");
assert_eq!(cs.choices.len(), 2);
assert!(!cs.choices[0].is_sticky);
assert!(cs.choices[1].is_sticky);
}
#[test]
fn choice_guard_and_label_lower() {
let (hir, _m, diags) =
lower_src("var gold = 1\nflow a() {\n {?\n * {if gold > 0} (rich) Buy it.\n }\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let Stmt::ChoiceSet(cs) = &body.stmts[0] else {
panic!("expected ChoiceSet, got {body:?}");
};
let choice = &cs.choices[0];
assert!(choice.condition.is_some());
assert_eq!(choice.label.as_ref().unwrap().text, "rich");
}
#[test]
fn else_branch_lowers_to_fallback_choice() {
let (hir, _m, diags) = lower_src("flow a() {\n {?\n * A.\n else { B. }\n }\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let Stmt::ChoiceSet(cs) = &body.stmts[0] else {
panic!("expected ChoiceSet, got {body:?}");
};
assert_eq!(cs.choices.len(), 2);
assert!(!cs.choices[0].is_fallback);
assert!(cs.choices[1].is_fallback);
}
#[test]
fn dissolved_gather_becomes_choice_set_continuation() {
let (hir, _m, diags) =
lower_src("flow a() {\n Intro.\n {?\n * A.\n * B.\n }\n Reconverged.\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let cs = body
.stmts
.iter()
.find_map(|s| match s {
Stmt::ChoiceSet(cs) => Some(cs),
_ => None,
})
.expect("expected a ChoiceSet among the body's statements");
assert!(
!cs.continuation.stmts.is_empty(),
"reconverged content must be absorbed into the continuation"
);
let Stmt::Content(c) = &cs.continuation.stmts[0] else {
panic!(
"expected Content in continuation, got {:?}",
cs.continuation.stmts[0]
);
};
assert!(matches!(&c.parts[0], ContentPart::Text(t) if t == "Reconverged."));
}
#[test]
fn labeled_gather_after_choices_attaches_label_to_continuation() {
let (hir, _m, diags) =
lower_src("flow a() {\n {?\n * A.\n }\n (again)\n Loop point.\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let Stmt::ChoiceSet(cs) = &body.stmts[0] else {
panic!("expected ChoiceSet, got {body:?}");
};
assert_eq!(
cs.continuation.label.as_ref().map(|n| n.text.as_str()),
Some("again"),
"gather label must attach directly to continuation.label, not a nested LabeledBlock"
);
}
#[test]
fn standalone_labeled_content_line_becomes_labeled_block() {
let (hir, _m, diags) = lower_src("flow a() {\n Intro.\n (mid) Middle.\n End.\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let labeled = body
.stmts
.iter()
.find_map(|s| match s {
Stmt::LabeledBlock(b) => Some(b),
_ => None,
})
.expect("expected a LabeledBlock absorbing the labeled line and everything after it");
assert_eq!(labeled.label.as_ref().unwrap().text, "mid");
assert!(labeled.stmts.len() >= 2, "labeled block: {labeled:?}");
}
#[test]
fn divert_and_tunnel_lower() {
let (hir, _m, diags) =
lower_src("flow b() {\n Bye.\n}\nflow a() {\n -> b\n}\nflow c() {\n -> b ->\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let a_body = &hir.knots[1].body;
assert!(matches!(a_body.stmts[0], Stmt::Divert(_)));
let c_body = &hir.knots[2].body;
assert!(matches!(c_body.stmts[0], Stmt::TunnelCall(_)));
}
#[test]
fn divert_target_call_args_are_wired_through_not_dropped() {
let (hir, _m, diags) = lower_src("flow b(x) {\n Bye.\n}\nflow a() {\n -> b(1)\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let a_body = &hir.knots[1].body;
let Stmt::Divert(d) = &a_body.stmts[0] else {
panic!("expected Divert, got {:?}", a_body.stmts[0]);
};
assert_eq!(
d.target.args.len(),
1,
"the call arg must survive lowering into DivertTarget::args: {:?}",
d.target.args
);
assert!(
matches!(&d.target.args[0], Expr::Int(1)),
"expected the literal `1` argument to lower to Expr::Int(1), got: {:?}",
d.target.args[0]
);
}
#[test]
fn tunnel_call_target_args_are_wired_through_not_dropped() {
let (hir, _m, diags) = lower_src("flow b(x) {\n Bye.\n}\nflow a() {\n -> b(1) ->\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let a_body = &hir.knots[1].body;
let Stmt::TunnelCall(t) = &a_body.stmts[0] else {
panic!("expected TunnelCall, got {:?}", a_body.stmts[0]);
};
assert_eq!(t.targets.len(), 1);
assert_eq!(
t.targets[0].args.len(),
1,
"the tunnel-call target's arg must survive lowering: {:?}",
t.targets[0].args
);
}
#[test]
fn return_redirect_target_call_args_are_wired_through_not_dropped() {
let (hir, _m, diags) = lower_src("flow b(x) {\n Bye.\n}\nflow a() {\n return -> b(1)\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let Stmt::Return(r) = &hir.knots[1].body.stmts[0] else {
panic!("expected Return, got {:?}", hir.knots[1].body.stmts[0]);
};
assert_eq!(r.kind, ReturnKind::TunnelRedirect);
assert!(matches!(r.value, Some(Expr::DivertTarget(_))));
assert_eq!(
r.onwards_args.len(),
1,
"the call arg must survive lowering into Return::onwards_args: {:?}",
r.onwards_args
);
assert!(
matches!(r.onwards_args[0], Expr::Int(1)),
"expected the literal `1` argument to lower to Expr::Int(1), got: {:?}",
r.onwards_args[0]
);
}
#[test]
fn inline_divert_mid_content_line_splits_into_two_statements() {
let (hir, _m, diags) = lower_src("flow b() {\n Bye.\n}\nflow a() {\n The wager. -> b\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = &hir.knots[1].body;
assert!(matches!(body.stmts[0], Stmt::Content(_)));
assert!(matches!(body.stmts[1], Stmt::Divert(_)));
}
#[test]
fn logic_line_temp_decl_lowers_to_stmt_temp_decl() {
let (hir, _m, diags) = lower_src("flow a() {\n ~ let n = 5\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let Stmt::TempDecl(t) = &body.stmts[0] else {
panic!("expected Stmt::TempDecl, got {:?}", body.stmts[0]);
};
assert_eq!(t.name.text, "n");
assert!(matches!(t.value, Some(Expr::Int(5))));
}
#[test]
fn logic_line_temp_decl_without_initializer_lowers_with_no_value() {
let (hir, _m, diags) = lower_src("flow a() {\n ~ let n: int\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let Stmt::TempDecl(t) = &body.stmts[0] else {
panic!("expected Stmt::TempDecl, got {:?}", body.stmts[0]);
};
assert!(t.value.is_none());
assert!(
t.annotation.is_some(),
"expected the `: int` annotation to lower"
);
}
#[test]
fn logic_line_temp_decl_from_an_emitting_call_lowers_to_end_of_line() {
let (hir, _m, diags) =
lower_src("fn shout() >{\n Hi\n return 7\n}\nflow a() {\n ~ let n = shout()\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = &hir.knots[1].body;
let Stmt::TempDecl(t) = &body.stmts[0] else {
panic!("expected Stmt::TempDecl, got {:?}", body.stmts[0]);
};
assert!(matches!(t.value, Some(Expr::Call(..))));
assert!(
matches!(body.stmts[1], Stmt::EndOfLine),
"a temp decl whose value contains a call must still get the trailing \
EndOfLine the ink-dialect frontend applies to the same construct: {:?}",
body.stmts
);
}
#[test]
fn logic_line_assignment_lowers_to_stmt_assignment() {
let (hir, _m, diags) = lower_src("flow a() {\n ~ n = 5\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let Stmt::Assignment(a) = &body.stmts[0] else {
panic!("expected Stmt::Assignment, got {:?}", body.stmts[0]);
};
assert_eq!(a.op, crate::AssignOp::Set);
assert!(matches!(&a.target, Expr::Path(p) if p.segments.last().unwrap().text == "n"));
assert!(matches!(a.value, Expr::Int(5)));
}
#[test]
fn logic_line_compound_assignment_lowers_op() {
let (hir, _m, diags) = lower_src("flow a() {\n ~ n += 3\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let Stmt::Assignment(a) = &body.stmts[0] else {
panic!("expected Stmt::Assignment, got {:?}", body.stmts[0]);
};
assert_eq!(a.op, crate::AssignOp::Add);
}
#[test]
fn logic_line_bare_call_lowers_to_expr_stmt_with_end_of_line() {
let (hir, _m, diags) = lower_src("fn bump() {\n return 1;\n}\nflow a() {\n ~ bump()\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = &hir.knots[1].body;
assert!(matches!(body.stmts[0], Stmt::ExprStmt(Expr::Call(..))));
assert!(matches!(body.stmts[1], Stmt::EndOfLine));
}
#[test]
fn logic_line_assignment_from_an_emitting_call_lowers_to_end_of_line() {
let (hir, _m, diags) =
lower_src("fn shout() >{\n Hi\n return 7\n}\nflow a() {\n ~ n = shout()\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = &hir.knots[1].body;
let Stmt::Assignment(a) = &body.stmts[0] else {
panic!("expected Stmt::Assignment, got {:?}", body.stmts[0]);
};
assert!(matches!(a.value, Expr::Call(..)));
assert!(
matches!(body.stmts[1], Stmt::EndOfLine),
"an assignment whose value contains a call must still get the trailing \
EndOfLine the ink-dialect frontend applies to the same construct: {:?}",
body.stmts
);
}
#[test]
fn logic_line_precedes_ordinary_content_unaffected() {
let (hir, _m, diags) = lower_src("flow a() {\n ~ n = 5\n Value is {n}.\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
assert!(matches!(body.stmts[0], Stmt::Assignment(_)));
assert!(
body.stmts[1..]
.iter()
.any(|s| matches!(s, Stmt::Content(_))),
"the content line after the logic line must still lower normally: {:?}",
body.stmts
);
}
#[test]
fn logic_line_until_lowers_to_stmt_await() {
let (hir, _m, diags) = lower_src("flow a() {\n ~ until n > 0\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let Stmt::Await(a) = &body.stmts[0] else {
panic!("expected Stmt::Await, got {:?}", body.stmts[0]);
};
assert!(matches!(a.condition, Some(Expr::Infix(_))));
}
#[test]
fn logic_line_until_precedes_ordinary_content_unaffected() {
let (hir, _m, diags) = lower_src("flow a() {\n ~ until n > 0\n Value is {n}.\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
assert!(matches!(body.stmts[0], Stmt::Await(_)));
assert!(
body.stmts[1..]
.iter()
.any(|s| matches!(s, Stmt::Content(_))),
"the content line after the logic line must still lower normally: {:?}",
body.stmts
);
}
#[test]
fn logic_line_block_lowers_to_stmt_logic_block_with_standalone_scope() {
let (hir, _m, diags) = lower_src("flow a() {\n ~{\n let m = 1;\n n = m;\n }\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let Stmt::LogicBlock(lb) = &body.stmts[0] else {
panic!("expected Stmt::LogicBlock, got {:?}", body.stmts[0]);
};
assert_eq!(lb.scope, crate::LogicBlockScope::Standalone);
assert_eq!(lb.stmts.len(), 2);
assert!(matches!(lb.stmts[0], crate::BlockStmt::TempDecl(_)));
assert!(matches!(lb.stmts[1], crate::BlockStmt::Assignment(_)));
}
#[test]
fn logic_line_block_precedes_ordinary_content_unaffected() {
let (hir, _m, diags) = lower_src("flow a() {\n ~{\n n = 1;\n }\n Value is {n}.\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
assert!(matches!(body.stmts[0], Stmt::LogicBlock(_)));
assert!(
body.stmts[1..]
.iter()
.any(|s| matches!(s, Stmt::Content(_))),
"the content line after the logic block must still lower normally: {:?}",
body.stmts
);
}
#[test]
fn logic_line_block_containing_a_call_lowers_to_end_of_line() {
let (hir, _m, diags) = lower_src(
"fn shout() >{\n Hi\n return\n}\nflow a() {\n ~{\n let m = 1;\n shout();\n }\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = &hir.knots[1].body;
assert!(matches!(body.stmts[0], Stmt::LogicBlock(_)));
assert!(
matches!(body.stmts[1], Stmt::EndOfLine),
"a `~{{ }}` block containing a call must still get the trailing \
EndOfLine its single-statement siblings apply to the same \
construct: {:?}",
body.stmts
);
}
#[test]
fn logic_line_block_with_no_call_gets_no_end_of_line() {
let (hir, _m, diags) = lower_src("flow a() {\n ~{\n let m = 1;\n n = m;\n }\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
assert!(matches!(body.stmts[0], Stmt::LogicBlock(_)));
assert!(
!matches!(body.stmts.get(1), Some(Stmt::EndOfLine)),
"a call-free block must not gain a trailing EndOfLine: {:?}",
body.stmts
);
}
#[test]
fn logic_line_with_no_recognized_child_is_a_loud_e129_not_a_silent_drop() {
let (hir, _m, diags) = lower_src("flow a() {\n ~ if\n}\n");
assert!(
!diags.is_empty(),
"an unrecognized/malformed logic line must raise a diagnostic, never silently drop"
);
let body = only_knot_body(&hir);
assert!(
!body.stmts.iter().any(|s| matches!(s, Stmt::Content(_))),
"a malformed logic line must never lower to visible story content: {:?}",
body.stmts
);
}
#[test]
fn prose_line_only_body_lowers_to_content_and_end_of_line_with_no_logic_block() {
let (hir, _m, diags) = lower_src("fn radio() {\n > hi\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
assert!(
matches!(body.stmts[0], Stmt::Content(_)),
"a code-ground body with only a prose line must never wrap it in a \
LogicBlock (content is out of that closed set by design): {:?}",
body.stmts
);
assert!(matches!(body.stmts[1], Stmt::EndOfLine));
assert_eq!(body.stmts.len(), 2);
}
#[test]
fn prose_line_carries_interpolation_like_any_content_line() {
let (hir, _m, diags) = lower_src("fn radio(chan, text) {\n > [{chan}] {text}\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let Stmt::Content(c) = &body.stmts[0] else {
panic!("expected Stmt::Content, got {:?}", body.stmts[0]);
};
let interpolations = c
.parts
.iter()
.filter(|p| matches!(p, crate::ContentPart::Interpolation(_)))
.count();
assert_eq!(interpolations, 2, "one per `{{…}}` interpolation: {c:?}");
}
#[test]
fn prose_line_with_no_prose_still_lowers_to_a_single_logic_block_unchanged() {
let (hir, _m, diags) = lower_src("fn bump() {\n n += 1;\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
assert_eq!(body.stmts.len(), 1);
let Stmt::LogicBlock(lb) = &body.stmts[0] else {
panic!("expected Stmt::LogicBlock, got {:?}", body.stmts[0]);
};
assert_eq!(lb.stmts.len(), 1);
assert!(matches!(lb.stmts[0], BlockStmt::Assignment(_)));
}
#[test]
fn prose_line_with_no_prose_anchors_provenance_on_the_whole_stmt_block() {
let src = "fn bump() {\n n += 1;\n}\n";
let (hir, _m, diags) = lower_src(src);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
let Stmt::LogicBlock(lb) = &body.stmts[0] else {
panic!("expected Stmt::LogicBlock, got {:?}", body.stmts[0]);
};
let parse = brink_syntax_native::parse(src);
let expected = parse
.tree()
.syntax()
.descendants()
.find(|n| n.kind() == N::STMT_BLOCK)
.expect("STMT_BLOCK in tree")
.text_range();
assert_eq!(
lb.ptr.range, expected,
"the single-run LogicBlock's ptr must span the whole `STMT_BLOCK`, \
not just its first statement"
);
}
#[test]
fn prose_line_interleaves_with_logic_block_runs() {
let (hir, _m, diags) = lower_src("fn radio() {\n n = 1;\n > hi\n n = 2;\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
assert_eq!(
body.stmts.len(),
4,
"expected [LogicBlock, Content, EndOfLine, LogicBlock]: {:?}",
body.stmts
);
assert!(matches!(body.stmts[0], Stmt::LogicBlock(_)));
assert!(matches!(body.stmts[1], Stmt::Content(_)));
assert!(matches!(body.stmts[2], Stmt::EndOfLine));
assert!(matches!(body.stmts[3], Stmt::LogicBlock(_)));
let Stmt::LogicBlock(first) = &body.stmts[0] else {
unreachable!()
};
assert!(matches!(first.stmts[0], BlockStmt::Assignment(_)));
let Stmt::LogicBlock(second) = &body.stmts[3] else {
unreachable!()
};
assert!(matches!(second.stmts[0], BlockStmt::Assignment(_)));
}
#[test]
fn call_containing_run_with_no_split_gets_a_trailing_end_of_line() {
let src = "fn shout() >{\n Hi\n return\n}\nfn wrapper() {\n shout();\n}\n";
let (hir, _m, diags) = lower_src(src);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let wrapper = hir
.knots
.iter()
.find(|k| k.name.text == "wrapper")
.expect("wrapper knot");
assert_eq!(
wrapper.body.stmts.len(),
2,
"expected [LogicBlock, EndOfLine]: {:?}",
wrapper.body.stmts
);
assert!(matches!(wrapper.body.stmts[0], Stmt::LogicBlock(_)));
assert!(matches!(wrapper.body.stmts[1], Stmt::EndOfLine));
}
#[test]
fn call_containing_run_with_no_split_still_anchors_provenance_on_the_whole_stmt_block() {
let src = "fn shout() >{\n Hi\n return\n}\nfn wrapper() {\n shout();\n}\n";
let (hir, _m, diags) = lower_src(src);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let wrapper = hir
.knots
.iter()
.find(|k| k.name.text == "wrapper")
.expect("wrapper knot");
let Stmt::LogicBlock(lb) = &wrapper.body.stmts[0] else {
panic!("expected Stmt::LogicBlock, got {:?}", wrapper.body.stmts[0]);
};
let parse = brink_syntax_native::parse(src);
let expected = parse
.tree()
.syntax()
.descendants()
.filter(|n| n.kind() == N::STMT_BLOCK)
.last()
.expect("wrapper's STMT_BLOCK in tree")
.text_range();
assert_eq!(
lb.ptr.range, expected,
"the single-run LogicBlock's ptr must still span the whole `STMT_BLOCK`, \
even though it now has a trailing Stmt::EndOfLine sibling"
);
}
#[test]
fn one_run_split_body_still_anchors_provenance_on_run_start_not_the_whole_stmt_block() {
let src = "fn radio() {\n > hi\n n = 1;\n}\n";
let (hir, _m, diags) = lower_src(src);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = only_knot_body(&hir);
assert_eq!(
body.stmts.len(),
3,
"expected [Content, EndOfLine, LogicBlock]: {:?}",
body.stmts
);
assert!(matches!(body.stmts[0], Stmt::Content(_)));
assert!(matches!(body.stmts[1], Stmt::EndOfLine));
let Stmt::LogicBlock(lb) = &body.stmts[2] else {
panic!("expected Stmt::LogicBlock, got {:?}", body.stmts[2]);
};
let parse = brink_syntax_native::parse(src);
let expected = parse
.tree()
.syntax()
.descendants()
.find(|n| n.kind() == N::ASSIGN_STMT)
.expect("ASSIGN_STMT in tree")
.text_range();
assert_eq!(
lb.ptr.range, expected,
"a split body's trailing single-statement LogicBlock must keep \
run_start anchoring (the ASSIGN_STMT alone), not widen to the \
whole STMT_BLOCK"
);
}
#[test]
fn prose_line_interleaves_with_a_call_containing_logic_block_run() {
let src =
"fn shout() >{\n Hi\n return\n}\nfn wrapper() {\n shout();\n > mid\n n = 2;\n}\n";
let (hir, _m, diags) = lower_src(src);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let wrapper = hir
.knots
.iter()
.find(|k| k.name.text == "wrapper")
.expect("wrapper knot");
assert_eq!(
wrapper.body.stmts.len(),
5,
"expected [LogicBlock(call), EndOfLine, Content, EndOfLine, LogicBlock(no call)]: {:?}",
wrapper.body.stmts
);
assert!(matches!(wrapper.body.stmts[0], Stmt::LogicBlock(_)));
assert!(matches!(wrapper.body.stmts[1], Stmt::EndOfLine));
assert!(matches!(wrapper.body.stmts[2], Stmt::Content(_)));
assert!(matches!(wrapper.body.stmts[3], Stmt::EndOfLine));
assert!(matches!(wrapper.body.stmts[4], Stmt::LogicBlock(_)));
}
#[test]
fn prose_line_nested_in_an_if_body_is_a_loud_e129_not_silent() {
let (hir, _m, diags) = lower_src("fn radio() {\n if true {\n > hi\n }\n}\n");
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E129),
"expected E129 for a prose line nested inside an if body, got: {diags:?}"
);
let body = only_knot_body(&hir);
assert!(
!body_contains_content(body),
"a prose line with no content-emission home in this context must never \
still surface as content: {body:?}"
);
}
#[test]
fn a_g1_label_on_a_code_ground_prose_line_is_a_loud_e129_not_silently_dropped() {
let (hir, _m, diags) = lower_src("fn radio() {\n n = 1;\n > (again) hi\n}\n");
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E129),
"expected E129 for a G-1 label on a code-ground prose line, got: {diags:?}"
);
let body = only_knot_body(&hir);
assert!(
body_contains_content(body),
"the prose line's own content must still lower even though its label \
is rejected: {body:?}"
);
}
fn body_contains_content(block: &crate::Block) -> bool {
block.stmts.iter().any(stmt_contains_content)
}
fn stmt_contains_content(stmt: &Stmt) -> bool {
match stmt {
Stmt::Content(_) => true,
Stmt::LogicBlock(lb) => lb.stmts.iter().any(block_stmt_contains_content),
_ => false,
}
}
fn block_stmt_contains_content(stmt: &BlockStmt) -> bool {
match stmt {
BlockStmt::If(i) => {
i.body.iter().any(block_stmt_contains_content)
|| i.else_branch.as_ref().is_some_and(|e| match e {
ElseBranch::ElseIf(inner) => {
block_stmt_contains_content(&BlockStmt::If((**inner).clone()))
}
ElseBranch::Else(stmts) => stmts.iter().any(block_stmt_contains_content),
})
}
BlockStmt::While(w) => w.body.iter().any(block_stmt_contains_content),
BlockStmt::For(f) => f.body.iter().any(block_stmt_contains_content),
_ => false,
}
}
#[test]
fn end_and_done_targets_lower_to_sentinel_paths() {
let (hir, _m, diags) = lower_src("flow a() {\n -> END\n}\nflow b() {\n -> DONE\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let Stmt::Divert(d) = &hir.knots[0].body.stmts[0] else {
panic!("expected Divert");
};
assert_eq!(d.target.path, DivertPath::End);
let Stmt::Divert(d) = &hir.knots[1].body.stmts[0] else {
panic!("expected Divert");
};
assert_eq!(d.target.path, DivertPath::Done);
}
#[test]
fn splice_before_any_choice_becomes_preamble_thread_start() {
let (hir, _m, diags) = lower_src(
"flow opts() {\n {?\n * X.\n }\n}\nflow a() {\n {?\n <- opts()\n * Y.\n }\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = &hir.knots[1].body;
assert!(
matches!(body.stmts[0], Stmt::ThreadStart(_)),
"splice before any choice line must be a sibling preceding the ChoiceSet: {body:?}"
);
assert!(matches!(body.stmts[1], Stmt::ChoiceSet(_)));
}
#[test]
fn splice_after_a_choice_attaches_to_that_choices_body() {
let (hir, _m, diags) = lower_src(
"flow opts() {\n {?\n * X.\n }\n}\nflow a() {\n {?\n * Y.\n <- opts()\n }\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let Stmt::ChoiceSet(cs) = &hir.knots[1].body.stmts[0] else {
panic!("expected ChoiceSet");
};
assert!(
cs.choices[0]
.body
.stmts
.iter()
.any(|s| matches!(s, Stmt::ThreadStart(_))),
"splice after a choice line must land in that choice's body: {:?}",
cs.choices[0].body
);
}
#[test]
fn explicit_return_stamps_explicit_kind() {
let (hir, _m, diags) = lower_src("fn f() >{\n return\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let Stmt::Return(r) = &hir.knots[0].body.stmts[0] else {
panic!("expected Return");
};
assert_eq!(r.kind, ReturnKind::Explicit);
assert!(r.value.is_none());
}
#[test]
fn content_ground_return_with_value_lowers_the_expression() {
let (hir, _m, diags) = lower_src("fn f() >{\n return hp > 0\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let Stmt::Return(r) = &hir.knots[0].body.stmts[0] else {
panic!("expected Return, got {:?}", hir.knots[0].body.stmts[0]);
};
assert_eq!(r.kind, ReturnKind::Explicit);
assert!(
matches!(r.value, Some(Expr::Infix(_))),
"expected an infix comparison value, got {:?}",
r.value
);
}
#[test]
fn content_ground_return_with_value_stays_explicit_in_a_non_function() {
let (hir, _m, diags) = lower_src("flow f() {\n Hello.\n return 5\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let Stmt::Return(r) = hir.knots[0].body.stmts.last().expect("a Return statement") else {
panic!("expected Return, got {:?}", hir.knots[0].body.stmts);
};
assert_eq!(r.kind, ReturnKind::Explicit);
assert!(matches!(r.value, Some(Expr::Int(5))));
}
#[test]
fn return_redirect_to_named_path_stamps_tunnel_redirect() {
let (hir, _m, diags) = lower_src("flow b() {\n Bye.\n}\nflow a() {\n return -> b\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let Stmt::Return(r) = &hir.knots[1].body.stmts[0] else {
panic!("expected Return, got {:?}", hir.knots[1].body.stmts[0]);
};
assert_eq!(r.kind, ReturnKind::TunnelRedirect);
assert!(matches!(r.value, Some(Expr::DivertTarget(_))));
}
#[test]
fn bare_return_inside_a_non_function_flow_is_a_tunnel_redirect() {
let (hir, _m, diags) = lower_src("flow f() {\n Hello\n return\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let Stmt::Return(r) = hir.knots[0].body.stmts.last().expect("a Return statement") else {
panic!("expected Return, got {:?}", hir.knots[0].body.stmts);
};
assert_eq!(r.kind, ReturnKind::TunnelRedirect);
assert!(
matches!(
hir.knots[0].body.tail(),
Tail::Diverge(Terminator::Return(_))
),
"tail must be recomputed after the fixup: {:?}",
hir.knots[0].body.tail()
);
}
#[test]
fn bare_return_inside_a_function_stays_explicit() {
let (hir, _m, diags) = lower_src("fn f() >{\n return\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let Stmt::Return(r) = &hir.knots[0].body.stmts[0] else {
panic!("expected Return");
};
assert_eq!(r.kind, ReturnKind::Explicit);
}
#[test]
fn bare_return_inside_a_choice_body_of_a_non_function_flow_is_a_tunnel_redirect() {
let (hir, _m, diags) =
lower_src("flow f() {\n {?\n * A. {\n return\n }\n }\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let Stmt::ChoiceSet(cs) = &hir.knots[0].body.stmts[0] else {
panic!("expected ChoiceSet, got {:?}", hir.knots[0].body.stmts[0]);
};
let Stmt::Return(r) = cs.choices[0].body.stmts.last().expect("a Return statement") else {
panic!(
"expected Return in choice body, got {:?}",
cs.choices[0].body.stmts
);
};
assert_eq!(r.kind, ReturnKind::TunnelRedirect);
}
#[test]
fn logic_block_bare_return_in_non_function_flow_is_a_tunnel_redirect() {
let (hir, _m, diags) = lower_src("flow f() ~{\n return;\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let Stmt::LogicBlock(lb) = &hir.knots[0].body.stmts[0] else {
panic!("expected LogicBlock, got {:?}", hir.knots[0].body.stmts[0]);
};
let BlockStmt::Return(r) = &lb.stmts[0] else {
panic!("expected Return, got {:?}", lb.stmts[0]);
};
assert_eq!(
r.kind,
ReturnKind::TunnelRedirect,
"must agree with the content-ground equivalent \
(bare_return_inside_a_non_function_flow_is_a_tunnel_redirect)"
);
}
#[test]
fn logic_block_bare_return_inside_if_body_is_a_tunnel_redirect() {
let (hir, _m, diags) = lower_src("flow f() ~{\n if true {\n return;\n }\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let Stmt::LogicBlock(lb) = &hir.knots[0].body.stmts[0] else {
panic!("expected LogicBlock, got {:?}", hir.knots[0].body.stmts[0]);
};
let BlockStmt::If(if_stmt) = &lb.stmts[0] else {
panic!("expected If, got {:?}", lb.stmts[0]);
};
let BlockStmt::Return(r) = &if_stmt.body[0] else {
panic!("expected Return in if body, got {:?}", if_stmt.body[0]);
};
assert_eq!(r.kind, ReturnKind::TunnelRedirect);
}
#[test]
fn logic_block_bare_return_inside_else_body_is_a_tunnel_redirect() {
let (hir, _m, diags) =
lower_src("flow f() ~{\n if false {\n return;\n } else {\n return;\n }\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let Stmt::LogicBlock(lb) = &hir.knots[0].body.stmts[0] else {
panic!("expected LogicBlock, got {:?}", hir.knots[0].body.stmts[0]);
};
let BlockStmt::If(if_stmt) = &lb.stmts[0] else {
panic!("expected If, got {:?}", lb.stmts[0]);
};
let Some(ElseBranch::Else(else_stmts)) = &if_stmt.else_branch else {
panic!("expected an else branch, got {:?}", if_stmt.else_branch);
};
let BlockStmt::Return(r) = &else_stmts[0] else {
panic!("expected Return in else body, got {:?}", else_stmts[0]);
};
assert_eq!(r.kind, ReturnKind::TunnelRedirect);
}
#[test]
fn logic_block_bare_return_inside_while_body_is_a_tunnel_redirect() {
let (hir, _m, diags) = lower_src("flow f() ~{\n while true {\n return;\n }\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let Stmt::LogicBlock(lb) = &hir.knots[0].body.stmts[0] else {
panic!("expected LogicBlock, got {:?}", hir.knots[0].body.stmts[0]);
};
let BlockStmt::While(while_stmt) = &lb.stmts[0] else {
panic!("expected While, got {:?}", lb.stmts[0]);
};
let BlockStmt::Return(r) = &while_stmt.body[0] else {
panic!(
"expected Return in while body, got {:?}",
while_stmt.body[0]
);
};
assert_eq!(r.kind, ReturnKind::TunnelRedirect);
}
#[test]
fn logic_block_bare_return_nested_in_if_stays_explicit_inside_a_function() {
let (hir, _m, diags) = lower_src("fn f() {\n if true {\n return;\n }\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let Stmt::LogicBlock(lb) = &hir.knots[0].body.stmts[0] else {
panic!("expected LogicBlock, got {:?}", hir.knots[0].body.stmts[0]);
};
let BlockStmt::If(if_stmt) = &lb.stmts[0] else {
panic!("expected If, got {:?}", lb.stmts[0]);
};
let BlockStmt::Return(r) = &if_stmt.body[0] else {
panic!("expected Return in if body, got {:?}", if_stmt.body[0]);
};
assert_eq!(r.kind, ReturnKind::Explicit);
}
#[test]
fn return_redirect_to_done_lowers_as_plain_divert() {
let (hir, _m, diags) = lower_src("flow a() {\n return -> DONE\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let Stmt::Divert(d) = &hir.knots[0].body.stmts[0] else {
panic!(
"expected a plain Divert (Expr::DivertTarget cannot represent DONE), got {:?}",
hir.knots[0].body.stmts[0]
);
};
assert_eq!(d.target.path, DivertPath::Done);
}
#[test]
fn misplaced_body_annotation_is_diagnosed_not_dropped() {
let (hir, _m, diags) = lower_src("flow a() {\n @[effects(pure)]\n}\n");
let body = &hir.knots[0].body;
assert_eq!(body.stmts.len(), 1, "only the implicit `-> DONE`: {body:?}");
assert!(matches!(
&body.stmts[0],
Stmt::Divert(d) if d.target.path == DivertPath::Done
));
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E112),
"a misplaced body-position annotation must be diagnosed, not silently dropped: {diags:?}"
);
}
#[test]
fn effects_annotation_on_a_nested_fn_is_diagnosed_not_silently_dropped() {
let (_hir, _m, diags) =
lower_src("flow a() {\n @[effects(pure)]\n fn b() {\n x\n }\n}\n");
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E129),
"the nested fn itself is still the E129 fence: {diags:?}"
);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E112),
"the annotation attached to it must be separately diagnosed, not silently dropped: {diags:?}"
);
}
#[test]
fn effects_annotation_on_a_depth_three_flow_is_diagnosed_not_silently_dropped() {
let (_hir, _m, diags) = lower_src(
"flow a() {\n flow b() {\n @[effects(pure)]\n flow c() {\n Too deep.\n }\n }\n}\n",
);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E130),
"the depth-3 flow itself is still the E130 fence: {diags:?}"
);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E112),
"the annotation attached to it must be separately diagnosed, not silently dropped: {diags:?}"
);
}
#[test]
fn element_annotation_lowers_pattern_and_captures() {
let (hir, _m, diags) = lower_src(
"@[element(args = \"^(?<chan>\\\\w+): (?<text>.+)$\")]\nflow radio(chan, text) {\n Hi, {chan} and {text}!\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let knot = &hir.knots[0];
let element = knot
.element_annotation
.as_ref()
.expect("@[element] must lower to an ElementAnnotation");
assert!(element.pattern.contains("(?<chan>"));
assert_eq!(
element.captures,
vec!["chan".to_string(), "text".to_string()]
);
assert!(element.alias.is_none());
}
#[test]
fn element_annotation_alias_lowers() {
let (hir, _m, diags) = lower_src(
"@[element(args = \"^(?<chan>\\\\w+)$\", name = \"walkie\")]\nflow radio(chan) {\n Hi, {chan}!\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let element = hir.knots[0].element_annotation.as_ref().expect("present");
assert_eq!(element.alias.as_deref(), Some("walkie"));
}
#[test]
fn element_annotation_missing_args_clause_diagnoses_e159() {
let (hir, _m, diags) = lower_src("@[element()]\nflow radio(chan) {\n Hi, {chan}!\n}\n");
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E159),
"an @[element] with no args= clause must raise E159: {diags:?}"
);
assert!(hir.knots[0].element_annotation.is_none());
}
#[test]
fn element_annotation_bad_regex_diagnoses_e159() {
let (hir, _m, diags) =
lower_src("@[element(args = \"(unclosed\")]\nflow radio(chan) {\n Hi, {chan}!\n}\n");
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E159),
"a pattern that doesn't compile as regex must raise E159: {diags:?}"
);
assert!(hir.knots[0].element_annotation.is_none());
}
#[test]
fn element_annotation_capture_without_matching_param_diagnoses_e160() {
let (hir, _m, diags) = lower_src(
"@[element(args = \"^(?<chan>\\\\w+)$\")]\nflow radio(other) {\n Hi, {other}!\n}\n",
);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E160),
"a capture with no matching param must raise E160: {diags:?}"
);
assert!(hir.knots[0].element_annotation.is_none());
}
#[test]
fn element_annotation_block_flag_lowers_with_trailing_content_param() {
let (hir, _m, diags) = lower_src(
"@[element(args = \"^@(?<name>[A-Z]+)$\", block)]\nflow cue(name, body: content) {\n Hi, {name}!\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let element = hir.knots[0]
.element_annotation
.as_ref()
.expect("@[element(…, block)] must still lower to an ElementAnnotation");
assert!(element.block, "the `block` flag must be recorded");
assert_eq!(element.captures, vec!["name".to_string()]);
}
#[test]
fn element_annotation_block_without_content_param_diagnoses_e166() {
let (hir, _m, diags) = lower_src(
"@[element(args = \"^@(?<name>[A-Z]+)$\", block)]\nflow cue(name) {\n Hi, {name}!\n}\n",
);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E166),
"a `block` element with no content-typed param must raise E166: {diags:?}"
);
assert!(hir.knots[0].element_annotation.is_none());
}
#[test]
fn element_annotation_block_content_param_not_last_diagnoses_e166() {
let (hir, _m, diags) = lower_src(
"@[element(args = \"^@(?<name>[A-Z]+)$\", block)]\nflow cue(body: content, name) {\n Hi, {name}!\n}\n",
);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E166),
"the content-typed param must be trailing — E166 when it isn't last: {diags:?}"
);
assert!(hir.knots[0].element_annotation.is_none());
}
#[test]
fn element_annotation_block_content_param_matching_a_capture_diagnoses_e166() {
let (hir, _m, diags) = lower_src(
"@[element(args = \"^@(?<body>[A-Z]+)$\", block)]\nflow cue(body: content) {\n Hi, {body}!\n}\n",
);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E166),
"a content param that is also a named capture must raise E166: {diags:?}"
);
assert!(hir.knots[0].element_annotation.is_none());
}
#[test]
fn element_annotation_duplicate_block_flag_diagnoses_e159() {
let (single_hir, _m, single_diags) = lower_src(
"@[element(args = \"^@(?<name>[A-Z]+)$\", block)]\nflow cue(name, body: content) {\n Hi, {name}!\n}\n",
);
assert!(
single_diags.is_empty(),
"a lone `block` in this position must not raise a diagnostic: {single_diags:?}"
);
let single_element = single_hir.knots[0]
.element_annotation
.as_ref()
.expect("a lone `block` must still lower to an ElementAnnotation");
assert!(
single_element.block,
"the lone `block` flag must be recorded"
);
let (hir, _m, diags) = lower_src(
"@[element(args = \"^@(?<name>[A-Z]+)$\", block, block)]\nflow cue(name, body: content) {\n Hi, {name}!\n}\n",
);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E159),
"a repeated bare `block` clause must raise E159: {diags:?}"
);
assert!(hir.knots[0].element_annotation.is_none());
}
#[test]
fn element_annotation_block_with_assigned_value_diagnoses_e159() {
let (hir, _m, diags) = lower_src(
"@[element(args = \"^@(?<name>[A-Z]+)$\", block = \"true\")]\nflow cue(name, body: content) {\n Hi, {name}!\n}\n",
);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E159),
"`block` is a bare flag, not a `key = \"value\"` clause: {diags:?}"
);
assert!(hir.knots[0].element_annotation.is_none());
}
#[test]
fn element_annotation_without_block_flag_defaults_false() {
let (hir, _m, diags) = lower_src(
"@[element(args = \"^(?<chan>\\\\w+)$\")]\nflow radio(chan) {\n Hi, {chan}!\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let element = hir.knots[0].element_annotation.as_ref().expect("present");
assert!(!element.block, "no `block` clause must leave block false");
}
#[test]
fn style_annotation_lowers_with_paired_element() {
let (hir, _m, diags) = lower_src(
"@[element(args = \"^(?<chan>\\\\w+): (?<text>.+)$\")]\n@[style(chan = \"channel\", line = \"dim\")]\nflow radio(chan, text) {\n Hi, {chan} and {text}!\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let style = hir.knots[0]
.style_annotation
.as_ref()
.expect("@[style] must lower to a StyleAnnotation");
assert_eq!(style.entries.len(), 2);
assert_eq!(style.entries[0].key, "chan");
assert_eq!(
style.entries[0].value,
crate::StyleToken::Custom("channel".to_string())
);
assert_eq!(style.entries[1].key, "line");
assert_eq!(style.entries[1].value, crate::StyleToken::Dim);
}
#[test]
fn style_annotation_recognizes_built_in_vocabulary() {
let (hir, _m, diags) = lower_src(
"@[element(args = \"^(?<chan>\\\\w+)$\")]\n@[style(chan = \"uppercase\", line = \"conceal\", dispatch = \"#a1b2c3\")]\nflow radio(chan) {\n Hi, {chan}!\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let style = hir.knots[0].style_annotation.as_ref().expect("present");
assert_eq!(style.entries[0].value, crate::StyleToken::Uppercase);
assert_eq!(style.entries[1].value, crate::StyleToken::Conceal);
assert_eq!(
style.entries[2].value,
crate::StyleToken::Color("#a1b2c3".to_string())
);
}
#[test]
fn style_annotation_without_paired_element_diagnoses_e163() {
let (hir, _m, diags) =
lower_src("@[style(line = \"dim\")]\nflow radio(chan) {\n Hi, {chan}!\n}\n");
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E163),
"@[style] with no paired @[element] must raise E163: {diags:?}"
);
assert!(hir.knots[0].style_annotation.is_none());
}
#[test]
fn style_annotation_unknown_key_diagnoses_e162() {
let (hir, _m, diags) = lower_src(
"@[element(args = \"^(?<chan>\\\\w+)$\")]\n@[style(nope = \"dim\")]\nflow radio(chan) {\n Hi, {chan}!\n}\n",
);
assert_eq!(
diags.iter().map(|d| d.code).collect::<Vec<_>>(),
vec![DiagnosticCode::E162],
"a style key that is neither line/dispatch nor a capture must raise exactly E162: {diags:?}"
);
assert!(hir.knots[0].style_annotation.is_none());
}
#[test]
fn style_annotation_empty_args_diagnoses_e161() {
let (hir, _m, diags) = lower_src(
"@[element(args = \"^(?<chan>\\\\w+)$\")]\n@[style()]\nflow radio(chan) {\n Hi, {chan}!\n}\n",
);
assert_eq!(
diags.iter().map(|d| d.code).collect::<Vec<_>>(),
vec![DiagnosticCode::E161],
"an empty @[style()] argument list must raise exactly E161: {diags:?}"
);
assert!(hir.knots[0].style_annotation.is_none());
}
#[test]
fn style_annotation_all_clauses_rejected_is_not_also_e161() {
let (hir, _m, diags) = lower_src(
"@[element(args = \"^(?<chan>\\\\w+)$\")]\n@[style(chan = bare)]\nflow radio(chan) {\n Hi, {chan}!\n}\n",
);
assert_eq!(
diags.iter().map(|d| d.code).collect::<Vec<_>>(),
vec![DiagnosticCode::E161],
"a malformed clause must raise exactly one E161, not a spurious second one: {diags:?}"
);
assert!(hir.knots[0].style_annotation.is_none());
}
#[test]
fn element_and_style_annotation_on_a_nested_fn_is_diagnosed_not_silently_dropped() {
let (_hir, _m, diags) = lower_src(
"flow a() {\n @[element(args = \"^(?<chan>\\\\w+)$\")]\n fn b(chan) {\n x\n }\n}\n",
);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E129),
"the nested fn itself is still the E129 fence: {diags:?}"
);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E112),
"the annotation attached to it must be separately diagnosed, not silently dropped: {diags:?}"
);
}
fn only_claimed_call(block: &crate::Block) -> Option<(&str, Vec<String>)> {
block.stmts.iter().find_map(|s| match s {
Stmt::Content(c) => match c.parts.as_slice() {
[ContentPart::Interpolation(Expr::Call(path, args))] => Some((
path.segments[0].text.as_str(),
args.iter()
.map(|a| match a {
Expr::String(se) => match se.parts.as_slice() {
[crate::StringPart::Literal(t)] => t.clone(),
other => panic!("expected a literal argument, got {other:?}"),
},
other => panic!("expected a string argument, got {other:?}"),
})
.collect(),
)),
_ => None,
},
_ => None,
})
}
#[test]
fn a_claimed_content_line_lowers_to_exactly_one_call() {
let (hir, _m, diags) = lower_src(
"@[convention(claims = \"^(?<who>[A-Z]+) enters$\", order = 10)]\nfn arrival(who) {\n return who;\n}\n\nflow main() {\n VENDOR enters\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let main = hir
.knots
.iter()
.find(|k| k.name.text == "main")
.expect("main");
let (callee, args) =
only_claimed_call(&main.body).expect("the claimed line must lower to one call");
assert_eq!(callee, "arrival");
assert_eq!(args, vec!["VENDOR".to_string()]);
}
#[test]
fn a_claimed_scene_heading_lowers_to_a_call_and_keeps_its_body() {
let (hir, _m, diags) = lower_src(
"@[convention(claims = \"^INT\\\\. (?<place>.+)$\", order = 20)]\nfn interior(place) {\n return place;\n}\n\nflow main() {\n INT. MARKET SQUARE\n The stalls are shuttered.\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let main = hir
.knots
.iter()
.find(|k| k.name.text == "main")
.expect("main");
let (callee, args) = only_claimed_call(&main.body).expect("the heading must lower to one call");
assert_eq!(callee, "interior");
assert_eq!(args, vec!["MARKET SQUARE".to_string()]);
let rendered = format!("{:?}", main.body.stmts);
assert!(
rendered.contains("The stalls are shuttered."),
"the scene body's own lines must survive: {rendered}"
);
}
#[test]
fn an_unclaimed_scene_heading_is_still_loudly_unlowered() {
let (_hir, _m, diags) = lower_src("flow main() {\n INT. MARKET SQUARE\n}\n");
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E129),
"an unclaimed heading must stay loud: {diags:?}"
);
}
#[test]
fn a_slug_bearing_heading_is_now_claimable() {
let (hir, _m, diags) = lower_src(
"@[convention(claims = \"^INT\\\\. (?<place>.+)$\", order = 20)]\nfn interior(place) {\n return place;\n}\n\nflow main() {\n INT. MARKET SQUARE [market]\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let main = hir
.knots
.iter()
.find(|k| k.name.text == "main")
.expect("main");
let (callee, args) =
only_claimed_call(&main.body).expect("the slugged heading must lower to one call");
assert_eq!(callee, "interior");
assert_eq!(args, vec!["MARKET SQUARE".to_string()]);
}
#[test]
fn a_tag_bearing_heading_is_now_claimable() {
let (hir, _m, diags) = lower_src(
"@[convention(claims = \"^INT\\\\. (?<place>.+)$\", order = 20)]\nfn interior(place) {\n return place;\n}\n\nflow main() {\n INT. MARKET SQUARE #act1\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let main = hir
.knots
.iter()
.find(|k| k.name.text == "main")
.expect("main");
let (callee, args) =
only_claimed_call(&main.body).expect("the tag-carrying heading must lower to one call");
assert_eq!(callee, "interior");
assert_eq!(args, vec!["MARKET SQUARE".to_string()]);
let Stmt::Content(c) = main
.body
.stmts
.iter()
.find(|s| matches!(s, Stmt::Content(_)))
.expect("the claimed heading must lower to a Content statement")
else {
unreachable!("just matched Stmt::Content above");
};
assert_eq!(c.tags.len(), 1, "expected exactly one tag: {:?}", c.tags);
assert!(
matches!(&c.tags[0].parts[0], ContentPart::Text(t) if t == "act1"),
"the heading's own trailing tag must reach `Content.tags`: {:?}",
c.tags[0].parts
);
}
#[test]
fn a_slug_and_tag_bearing_heading_claims_and_delivers_both() {
let src = "@[convention(claims = \"^INT\\\\. (?<place>.+)$\", order = 20)]\nfn interior(place) {\n return place;\n}\n\nflow main() {\n INT. MARKET SQUARE [market] #act1\n}\n";
let (hir, _m, diags) = lower_src(src);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.element_matches.len(), 1);
let m = &hir.element_matches[0];
assert_eq!(m.kind, crate::ElementKind::SceneHeading);
assert_eq!(m.captures.len(), 1);
assert_eq!(m.captures[0].name, "place");
let slug = m
.slug
.as_ref()
.expect("a spelled `[slug]` must be delivered");
assert_eq!(slug.name, "slug");
assert_eq!(slug.text, "market");
assert_eq!(&src[slug.range], "market");
}
#[test]
fn an_unslugged_heading_delivers_no_slug_capture() {
let (hir, _m, diags) = lower_src(
"@[convention(claims = \"^INT\\\\. (?<place>.+)$\", order = 20)]\nfn interior(place) {\n return place;\n}\n\nflow main() {\n INT. MARKET SQUARE\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.element_matches.len(), 1);
assert!(
hir.element_matches[0].slug.is_none(),
"no explicit `[slug]` means no slug capture — an inferred address \
is not represented here: {:?}",
hir.element_matches[0].slug
);
}
#[test]
fn a_claimed_cue_lowers_to_a_call() {
let (hir, _m, diags) = lower_src(
"@[convention(claims = \"^(?<name>[A-Z][A-Z ]*)$\", order = 30)]\nfn cue(name) {\n return name;\n}\n\nflow main() {\n @VENDOR\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let main = hir
.knots
.iter()
.find(|k| k.name.text == "main")
.expect("main");
let (callee, args) =
only_claimed_call(&main.body).expect("the cue line must lower to one call");
assert_eq!(callee, "cue");
assert_eq!(args, vec!["VENDOR".to_string()]);
assert_eq!(hir.element_matches.len(), 1);
assert_eq!(hir.element_matches[0].kind, crate::ElementKind::Cue);
}
#[test]
fn an_unclaimed_cue_is_still_loudly_unlowered() {
let (_hir, _m, diags) = lower_src("flow main() {\n @VENDOR\n}\n");
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E129),
"an unclaimed cue must stay loud: {diags:?}"
);
}
#[test]
fn a_cue_with_a_tag_extension_is_now_claimable() {
let (hir, _m, diags) = lower_src(
"@[convention(claims = \"^(?<name>[A-Z][A-Z ]*)$\", order = 40)]\nfn cue(name) {\n return name;\n}\n\nflow main() {\n @VENDOR #(v.o.)\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let main = hir
.knots
.iter()
.find(|k| k.name.text == "main")
.expect("main");
let (callee, args) =
only_claimed_call(&main.body).expect("the tag-carrying cue must lower to one call");
assert_eq!(callee, "cue");
assert_eq!(args, vec!["VENDOR".to_string()]);
let Stmt::Content(c) = main
.body
.stmts
.iter()
.find(|s| matches!(s, Stmt::Content(_)))
.expect("the claimed cue must lower to a Content statement")
else {
unreachable!("just matched Stmt::Content above");
};
assert_eq!(c.tags.len(), 1, "expected exactly one tag: {:?}", c.tags);
assert!(
matches!(&c.tags[0].parts[0], ContentPart::Text(t) if t == "(v.o.)"),
"the cue's own trailing tag must reach `Content.tags`: {:?}",
c.tags[0].parts
);
}
#[test]
fn a_parenthetical_with_a_tag_extension_is_now_claimable() {
let (hir, _m, diags) = lower_src(
"@[convention(claims = \"^(?<name>[A-Z][A-Z ]*)$\", order = 50)]\nfn cue(name) {\n return name;\n}\n@[convention(claims = \"^(?<delivery>[a-z][a-z' -]*)$\", order = 60)]\nfn parenthetical(delivery) {\n return delivery;\n}\n\nflow main() {\n @VENDOR\n (hushed) #whisper\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(
hir.element_matches.len(),
2,
"both the cue and the tag-carrying parenthetical must be claimed: {:?}",
hir.element_matches
);
assert_eq!(
hir.element_matches[1].kind,
crate::ElementKind::Parenthetical
);
let main = hir
.knots
.iter()
.find(|k| k.name.text == "main")
.expect("main");
let contents: Vec<_> = main
.body
.stmts
.iter()
.filter_map(|s| match s {
Stmt::Content(c) => Some(c),
_ => None,
})
.collect();
assert_eq!(
contents.len(),
2,
"the cue and the parenthetical must each lower to their own Content \
statement: {:?}",
main.body.stmts
);
assert!(
matches!(&contents[1].parts[0], ContentPart::Interpolation(_)),
"the parenthetical's claimed call must be the second Content: {:?}",
contents[1].parts
);
assert_eq!(
contents[1].tags.len(),
1,
"expected exactly one tag on the parenthetical's own Content: {:?}",
contents[1].tags
);
assert!(
matches!(&contents[1].tags[0].parts[0], ContentPart::Text(t) if t == "whisper"),
"the parenthetical's own trailing tag must reach `Content.tags`: {:?}",
contents[1].tags[0].parts
);
}
#[test]
fn an_attach_mode_cue_with_a_tag_extension_still_declines() {
let (_hir, _m, diags) = lower_src(
"struct Cue {\n speaker: string\n}\n\n@[convention(claims = \"^(?<name>[A-Z][A-Z ]*)$\", attach = Cue, order = 10)]\nfn cue(name): Cue {\n return Cue { speaker: name };\n}\n\nflow main() {\n @VENDOR #(v.o.)\n}\n",
);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E129),
"an attach-mode cue with a tag extension must decline, not silently \
drop the tag: {diags:?}"
);
}
#[test]
fn an_attach_mode_parenthetical_with_a_tag_extension_still_declines() {
let (_hir, _m, diags) = lower_src(
"@[convention(claims = \"^(?<name>[A-Z][A-Z ]*)$\", order = 10)]\nfn cue(name) {\n return name;\n}\nstruct Parenthetical {\n delivery: string\n}\n\n@[convention(claims = \"^(?<delivery>[a-z][a-z' -]*)$\", attach = Parenthetical, order = 20)]\nfn parenthetical(delivery): Parenthetical {\n return Parenthetical { delivery: delivery };\n}\n\nflow main() {\n @VENDOR\n (hushed) #whisper\n}\n",
);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E129),
"an attach-mode parenthetical with a tag extension must decline, \
not silently drop the tag: {diags:?}"
);
}
#[test]
fn a_claimed_parenthetical_lowers_to_a_call() {
let (hir, _m, diags) = lower_src(
"@[convention(claims = \"^(?<name>[A-Z][A-Z ]*)$\", order = 50)]\nfn cue(name) {\n return name;\n}\n@[convention(claims = \"^(?<delivery>.+)$\", order = 60)]\nfn parenthetical(delivery) {\n return delivery;\n}\n\nflow main() {\n @VENDOR\n (hushed)\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(
hir.element_matches.len(),
2,
"both the cue and the parenthetical must be claimed: {:?}",
hir.element_matches
);
assert_eq!(
hir.element_matches[1].kind,
crate::ElementKind::Parenthetical
);
let main = hir
.knots
.iter()
.find(|k| k.name.text == "main")
.expect("main");
let rendered = format!("{:?}", main.body.stmts);
assert!(
rendered.contains("hushed"),
"the parenthetical's captured delivery text must reach the call: {rendered}"
);
}
#[test]
fn a_cue_block_capture_stops_at_a_following_parenthetical_and_the_parenthetical_claims_separately()
{
let src = "@[convention(claims = \"^(?<name>[A-Z][A-Z ]*)$\", order = 70, block)]\nfn cue(name: string, body: content) {\n return name;\n}\n@[convention(claims = \"^(?<delivery>[a-z][a-z' -]*)$\", order = 80, block)]\nfn parenthetical(delivery: string, body: content) {\n return delivery;\n}\n\nflow main() {\n @VENDOR\n (hushed)\n You shouldn't be here.\n}\n";
let (hir, _m, diags) = lower_src(src);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.element_matches.len(), 2);
let cue_match = &hir.element_matches[0];
assert_eq!(cue_match.handler.text, "cue");
assert_eq!(
cue_match.content, None,
"the cue's own block capture must see zero lines: the very next item is the \
parenthetical, not a plain CONTENT_LINE"
);
let paren_match = &hir.element_matches[1];
assert_eq!(paren_match.handler.text, "parenthetical");
let content_range = paren_match
.content
.expect("the parenthetical's own block capture must see the dialogue line");
assert_eq!(
&src[usize::from(content_range.start())..usize::from(content_range.end())],
"You shouldn't be here."
);
}
#[test]
fn a_claim_records_handler_and_capture_spans() {
let src = "@[convention(claims = \"^(?<who>[A-Z]+) enters$\", order = 90)]\nfn arrival(who) {\n return who;\n}\n\nflow main() {\n VENDOR enters\n}\n";
let (hir, _m, diags) = lower_src(src);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.element_matches.len(), 1);
let m = &hir.element_matches[0];
assert_eq!(m.kind, crate::ElementKind::ContentLine);
assert_eq!(m.disposition, crate::ElementDisposition::Call);
assert_eq!(m.handler.text, "arrival");
assert_eq!(
&src[usize::from(m.handler.range.start())..usize::from(m.handler.range.end())],
"arrival"
);
assert_eq!(m.captures.len(), 1);
let c = &m.captures[0];
assert_eq!(c.name, "who");
assert_eq!(c.text, "VENDOR");
assert_eq!(
&src[usize::from(c.range.start())..usize::from(c.range.end())],
"VENDOR"
);
assert!(
src[usize::from(m.annotation.start())..usize::from(m.annotation.end())]
.starts_with("@[convention(claims"),
"the annotation range must land on the claiming declaration"
);
}
fn claimed_fragment_stmts(block: &crate::Block) -> &[Stmt] {
let Some(Stmt::Content(c)) = block.stmts.first() else {
panic!("expected the claimed line's Content statement first: {block:?}");
};
let [ContentPart::Interpolation(Expr::Call(_, args))] = c.parts.as_slice() else {
panic!("expected a single-call interpolation: {:?}", c.parts);
};
let Some(Expr::Fragment(stmts)) = args.last() else {
panic!("expected the last call argument to be a Fragment: {args:?}");
};
stmts
}
#[test]
fn a_block_handler_captures_the_following_run_terminated_by_a_blank_line() {
let src = "@[convention(claims = \"^(?<name>[A-Z]+)$\", order = 100, block)]\nfn cue(name: string, body: content) {\n return name;\n}\n\nflow main() {\n VENDOR\n Line one.\n Line two.\n\n After the blank line.\n}\n";
let (hir, _m, diags) = lower_src(src);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.element_matches.len(), 1);
let m = &hir.element_matches[0];
assert_eq!(m.handler.text, "cue");
let content_range = m
.content
.expect("a block match must record the captured block's own range");
assert_eq!(
&src[usize::from(content_range.start())..usize::from(content_range.end())],
"Line one.\n Line two.",
"the recorded content range must cover exactly the two captured lines, no more"
);
let main = hir
.knots
.iter()
.find(|k| k.name.text == "main")
.expect("main");
let stmts = claimed_fragment_stmts(&main.body);
assert_eq!(
stmts.len(),
4,
"expected two captured lines' worth of statements: {stmts:?}"
);
let rendered = format!("{stmts:?}");
assert!(
rendered.contains("Line one.") && rendered.contains("Line two."),
"both captured lines must be present in the fragment: {rendered}"
);
assert_eq!(
main.body.stmts.len(),
5,
"main's own body must contain only the claimed call, the \
post-blank-line line, and the flow's own implicit end-of-body \
divert — not the captured lines a second time: {:?}",
main.body.stmts
);
let main_rendered = format!("{:?}", main.body.stmts);
assert!(
main_rendered.contains("After the blank line."),
"the line after the blank line must survive as ordinary content: {main_rendered}"
);
}
#[test]
fn a_block_handler_captures_the_following_run_terminated_by_an_element_level_line() {
let src = "@[convention(claims = \"^(?<name>[A-Z]+)$\", order = 110, block)]\nfn cue(name: string, body: content) {\n return name;\n}\n\nflow main() {\n VENDOR\n Line one.\n Line two.\n -> END\n}\n";
let (hir, _m, diags) = lower_src(src);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.element_matches.len(), 1);
let m = &hir.element_matches[0];
let content_range = m
.content
.expect("a block match must record the captured block's own range");
assert_eq!(
&src[usize::from(content_range.start())..usize::from(content_range.end())],
"Line one.\n Line two.",
"the divert must not be absorbed into the captured range"
);
let main = hir
.knots
.iter()
.find(|k| k.name.text == "main")
.expect("main");
let stmts = claimed_fragment_stmts(&main.body);
assert_eq!(
stmts.len(),
4,
"expected exactly the two captured lines: {stmts:?}"
);
assert!(
main.body.stmts.iter().any(|s| matches!(s, Stmt::Divert(_))),
"the terminating divert must still lower normally: {:?}",
main.body.stmts
);
}
#[test]
fn a_captured_line_ending_in_a_divert_does_not_join_the_block() {
let src = "@[convention(claims = \"^(?<name>[A-Z]+)$\", order = 120, block)]\nfn cue(name: string, body: content) {\n return name;\n}\n\nflow main() {\n VENDOR\n Line one.\n Get out. -> END\n}\n";
let (hir, _m, diags) = lower_src(src);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let m = &hir.element_matches[0];
let content_range = m
.content
.expect("a block match must record the captured block's own range");
assert_eq!(
&src[usize::from(content_range.start())..usize::from(content_range.end())],
"Line one.",
"the divert-carrying line must not be folded into the captured range"
);
let main = hir
.knots
.iter()
.find(|k| k.name.text == "main")
.expect("main");
let stmts = claimed_fragment_stmts(&main.body);
assert_eq!(
stmts.len(),
2,
"expected exactly the one captured line's worth of statements, not the \
divert-carrying one too: {stmts:?}"
);
assert!(
!format!("{stmts:?}").contains("Divert"),
"the divert must never appear inside the fragment: {stmts:?}"
);
assert!(
main.body.stmts.iter().any(|s| matches!(s, Stmt::Divert(_))),
"the divert-carrying line must still lower as a normal top-level \
statement, not be swallowed by the capture: {:?}",
main.body.stmts
);
let main_rendered = format!("{:?}", main.body.stmts);
assert!(
main_rendered.contains("Get out."),
"the divert line's own prose must survive: {main_rendered}"
);
}
#[test]
fn a_captured_line_carrying_a_label_does_not_join_the_block() {
let src = "@[convention(claims = \"^(?<name>[A-Z]+)$\", order = 130, block)]\nfn cue(name: string, body: content) {\n return name;\n}\n\nflow main() {\n VENDOR\n Line one.\n (later) You wait.\n -> END\n}\n";
let (hir, _m, diags) = lower_src(src);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let m = &hir.element_matches[0];
let content_range = m
.content
.expect("a block match must record the captured block's own range");
assert_eq!(
&src[usize::from(content_range.start())..usize::from(content_range.end())],
"Line one.",
"the labeled line must not be folded into the captured range"
);
let main = hir
.knots
.iter()
.find(|k| k.name.text == "main")
.expect("main");
let stmts = claimed_fragment_stmts(&main.body);
assert_eq!(
stmts.len(),
2,
"expected exactly the one captured line's worth of statements, not the \
labeled line too: {stmts:?}"
);
assert!(
main.body
.stmts
.iter()
.any(|s| matches!(s, Stmt::LabeledBlock(_))),
"the labeled line must still lower as a normal top-level \
LabeledBlock, not be swallowed by the capture: {:?}",
main.body.stmts
);
}
#[test]
fn a_claiming_handler_does_not_claim_lines_in_its_own_body() {
let (hir, _m, diags) = lower_src(
"@[convention(claims = \"^(?<who>[A-Z]+) enters$\", order = 140)]\nfn arrival(who) >{\n VENDOR enters\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(
hir.element_matches.is_empty(),
"a handler must not claim its own body: {:?}",
hir.element_matches
);
}
#[test]
fn a_claiming_pattern_declaring_both_args_and_claims_diagnoses_e159() {
let (hir, _m, diags) =
lower_src("@[element(args = \"^a$\", claims = \"^b$\")]\nfn one() {\n return 1;\n}\n");
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E159),
"two spellings of the same slot must raise E159: {diags:?}"
);
assert!(hir.knots[0].element_annotation.is_none());
}
#[test]
fn a_claiming_handler_param_with_no_capture_diagnoses_e167() {
let (hir, _m, diags) = lower_src(
"@[convention(claims = \"^(?<who>[A-Z]+) enters$\", order = 150)]\nfn arrival(who, mood) {\n return who;\n}\n",
);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E167),
"a param no capture binds must raise E167: {diags:?}"
);
assert!(hir.knots[0].element_annotation.is_none());
}
#[test]
fn a_claiming_handler_numeric_typed_param_diagnoses_e171() {
let src = "@[convention(claims = \"^Take (?<n>\\\\d+)$\", order = 160)]\nfn take(n: int) {\n return n;\n}\n";
let (hir, _m, diags) = lower_src(src);
let e171 = diags
.iter()
.find(|d| d.code == DiagnosticCode::E171)
.unwrap_or_else(|| panic!("a numeric captured param must raise E171: {diags:?}"));
assert_eq!(
&src[usize::from(e171.range.start())..usize::from(e171.range.end())],
"int",
"E171 must point at the mismatched param's own type annotation"
);
assert!(hir.knots[0].convention_annotation.is_none());
}
#[test]
fn a_claiming_handler_string_typed_param_does_not_diagnose_e171() {
let (hir, _m, diags) = lower_src(
"@[convention(claims = \"^Take (?<n>\\\\d+)$\", order = 170)]\nfn take(n: string) {\n return n;\n}\n",
);
assert!(
!diags.iter().any(|d| d.code == DiagnosticCode::E171),
"a string-typed captured param must not raise E171: {diags:?}"
);
assert!(hir.knots[0].convention_annotation.is_some());
}
#[test]
fn a_claiming_handler_untyped_param_does_not_diagnose_e171() {
let (hir, _m, diags) = lower_src(
"@[convention(claims = \"^Take (?<n>\\\\d+)$\", order = 180)]\nfn take(n) {\n return n;\n}\n",
);
assert!(
!diags.iter().any(|d| d.code == DiagnosticCode::E171),
"an untyped captured param must not raise E171: {diags:?}"
);
assert!(hir.knots[0].convention_annotation.is_some());
}
#[test]
fn a_claiming_handler_content_typed_param_does_not_diagnose_e171() {
let (hir, _m, diags) = lower_src(
"@[convention(claims = \"^INT\\\\. (?<place>.+)$\", order = 190)]\nfn interior(place: content) {\n return \"-- inside {place} --\";\n}\n",
);
assert!(
!diags.iter().any(|d| d.code == DiagnosticCode::E171),
"a content-typed captured param must not raise E171: {diags:?}"
);
assert!(hir.knots[0].convention_annotation.is_some());
}
#[test]
fn a_non_claiming_handler_may_have_params_beyond_its_captures() {
let (hir, _m, diags) = lower_src(
"@[element(args = \"^(?<who>[A-Z]+) enters$\")]\nfn arrival(who, mood) {\n return who;\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(hir.knots[0].element_annotation.is_some());
assert!(hir.knots[0].convention_annotation.is_none());
}
#[test]
fn a_bang_dispatch_line_lowers_to_exactly_one_call() {
let (hir, _m, diags) = lower_src(
"@[element(args = \"^(?<chan>[A-Z0-9-]+): (?<text>.+)$\")]\nfn radio(chan, text) {\n return text;\n}\n\nflow main() {\n !radio TAC-2: All units report in.\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let main = hir
.knots
.iter()
.find(|k| k.name.text == "main")
.expect("main");
let (callee, args) =
only_claimed_call(&main.body).expect("the dispatched line must lower to one call");
assert_eq!(callee, "radio");
assert_eq!(
args,
vec!["TAC-2".to_string(), "All units report in.".to_string()]
);
}
#[test]
fn a_bang_dispatch_records_handler_and_capture_spans() {
let src = "@[element(args = \"^(?<chan>[A-Z0-9-]+): (?<text>.+)$\")]\nfn radio(chan, text) {\n return text;\n}\n\nflow main() {\n !radio TAC-2: All units report in.\n}\n";
let (hir, _m, diags) = lower_src(src);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.element_matches.len(), 1);
let m = &hir.element_matches[0];
assert_eq!(m.kind, crate::ElementKind::BangDispatch);
assert_eq!(m.disposition, crate::ElementDisposition::Call);
assert_eq!(m.handler.text, "radio");
assert_eq!(m.captures.len(), 2);
assert_eq!(m.captures[0].name, "chan");
assert_eq!(m.captures[0].text, "TAC-2");
assert_eq!(
&src[usize::from(m.captures[0].range.start())..usize::from(m.captures[0].range.end())],
"TAC-2"
);
assert_eq!(m.captures[1].name, "text");
assert_eq!(m.captures[1].text, "All units report in.");
}
#[test]
fn a_bang_dispatch_honors_a_name_alias() {
let (hir, _m, diags) = lower_src(
"@[element(args = \"^ready$\", name = \"walkie\")]\nfn tally() {\n return \"ready\";\n}\n\nflow main() {\n !walkie ready\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let main = hir
.knots
.iter()
.find(|k| k.name.text == "main")
.expect("main");
let (callee, _args) =
only_claimed_call(&main.body).expect("the aliased dispatch must lower to one call");
assert_eq!(callee, "tally");
}
#[test]
fn a_bang_dispatch_naming_an_undeclared_handler_is_loudly_unlowered() {
let (_hir, _m, diags) = lower_src("flow main() {\n !radio TAC-2: hello.\n}\n");
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E129),
"an undeclared dispatch name must stay loud: {diags:?}"
);
}
#[test]
fn a_bang_dispatch_whose_remainder_does_not_match_is_loudly_unlowered() {
let (_hir, _m, diags) = lower_src(
"@[element(args = \"^(?<chan>[A-Z0-9-]+): (?<text>.+)$\")]\nfn radio(chan, text) {\n return text;\n}\n\nflow main() {\n !radio this does not match the pattern\n}\n",
);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E129),
"an unmatched remainder must stay loud: {diags:?}"
);
}
#[test]
fn two_bang_dispatch_handlers_with_the_same_name_first_declared_wins() {
let (hir, _m, diags) = lower_src(
"@[element(args = \"^ready$\")]\nfn tally_first() {\n return \"first\";\n}\n\n@[element(args = \"^ready$\", name = \"tally_first\")]\nfn tally_second() {\n return \"second\";\n}\n\nflow main() {\n !tally_first ready\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let main = hir
.knots
.iter()
.find(|k| k.name.text == "main")
.expect("main");
let (callee, _args) = only_claimed_call(&main.body).expect("must dispatch to the first");
assert_eq!(callee, "tally_first");
}
#[test]
fn a_bang_dispatch_handler_with_an_uncaptured_param_does_not_dispatch() {
let (_hir, _m, diags) = lower_src(
"@[element(args = \"^(?<who>[A-Z]+) enters$\")]\nfn arrival(who, mood) {\n return who;\n}\n\nflow main() {\n !arrival VENDOR enters\n}\n",
);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E129),
"a param with no capture must decline the dispatch: {diags:?}"
);
}
#[test]
fn an_escaped_bang_at_line_start_stays_plain_text_and_never_dispatches() {
let (hir, _m, diags) = lower_src(
"@[element(args = \"^radio.*$\")]\nfn radio() {\n return \"ping\";\n}\n\nflow main() {\n \\!radio still just prose.\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(
hir.element_matches.is_empty(),
"an escaped `\\!` must never dispatch: {:?}",
hir.element_matches
);
}
#[test]
fn a_convention_with_no_order_diagnoses_e178() {
let (hir, _m, diags) = lower_src(
"@[convention(claims = \"^(?<who>[A-Z]+) enters$\")]\nfn arrival(who) {\n return who;\n}\n",
);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E178),
"a @[convention] with no order clause must raise E178: {diags:?}"
);
assert!(hir.knots[0].convention_annotation.is_none());
}
#[test]
fn a_convention_with_an_order_does_not_diagnose_e178() {
let (hir, _m, diags) = lower_src(
"@[convention(claims = \"^(?<who>[A-Z]+) enters$\", order = 10)]\nfn arrival(who) {\n return who;\n}\n",
);
assert!(
!diags.iter().any(|d| d.code == DiagnosticCode::E178),
"a @[convention] with an order clause must not raise E178: {diags:?}"
);
let convention = hir.knots[0]
.convention_annotation
.as_ref()
.expect("present");
assert_eq!(convention.order, 10);
}
#[test]
fn two_conventions_sharing_an_order_diagnose_e179_on_both() {
let (_hir, _m, diags) = lower_src(
"@[convention(claims = \"^A$\", order = 10)]\nfn a() {\n return \"a\";\n}\n\n@[convention(claims = \"^B$\", order = 10)]\nfn b() {\n return \"b\";\n}\n",
);
let e179s: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagnosticCode::E179)
.collect();
assert_eq!(
e179s.len(),
2,
"a duplicate order must be reported against BOTH declarations, not just one: {diags:?}"
);
let src = "@[convention(claims = \"^A$\", order = 10)]\nfn a() {\n return \"a\";\n}\n\n@[convention(claims = \"^B$\", order = 10)]\nfn b() {\n return \"b\";\n}\n";
let ranges: Vec<&str> = e179s
.iter()
.map(|d| &src[usize::from(d.range.start())..usize::from(d.range.end())])
.collect();
assert!(
ranges.iter().any(|r| r.contains("claims = \"^A$\"")),
"{ranges:?}"
);
assert!(
ranges.iter().any(|r| r.contains("claims = \"^B$\"")),
"{ranges:?}"
);
for d in &e179s {
assert!(
d.message.contains("`a`") && d.message.contains("`b`"),
"each E179 message must name BOTH conflicting handlers: {d:?}"
);
assert!(
d.message.contains("order = 10"),
"each E179 message must name the shared `order` value: {d:?}"
);
}
}
#[test]
fn three_conventions_sharing_an_order_diagnose_e179_on_all_three() {
let (_hir, _m, diags) = lower_src(
"@[convention(claims = \"^A$\", order = 10)]\nfn a() {\n return \"a\";\n}\n\n@[convention(claims = \"^B$\", order = 10)]\nfn b() {\n return \"b\";\n}\n\n@[convention(claims = \"^C$\", order = 10)]\nfn c() {\n return \"c\";\n}\n",
);
let e179s: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagnosticCode::E179)
.collect();
assert_eq!(
e179s.len(),
3,
"three handlers sharing one order must produce exactly one diagnostic \
PER declaration (three), not one per pair (six): {diags:?}"
);
for d in &e179s {
assert!(
["a", "b", "c"]
.iter()
.filter(|name| d.message.contains(&format!("`{name}`")))
.count()
>= 2,
"each E179 message must name at least the two OTHER conflicting \
handlers in a three-way group: {d:?}"
);
}
}
#[test]
fn distinct_orders_never_diagnose_e179() {
let (_hir, _m, diags) = lower_src(
"@[convention(claims = \"^A$\", order = 10)]\nfn a() {\n return \"a\";\n}\n\n@[convention(claims = \"^B$\", order = 20)]\nfn b() {\n return \"b\";\n}\n",
);
assert!(
!diags.iter().any(|d| d.code == DiagnosticCode::E179),
"distinct orders must never raise E179: {diags:?}"
);
}
#[test]
fn order_determines_precedence_not_declaration_position() {
let (hir, _m, diags) = lower_src(
"@[convention(claims = \"^(?<who>[A-Z]+)$\", order = 20)]\nfn late_high_order(who) {\n return \"late\";\n}\n\n@[convention(claims = \"^(?<who>VENDOR)$\", order = 10)]\nfn early_low_order(who) {\n return \"early\";\n}\n\nflow main() {\n VENDOR\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let main = hir
.knots
.iter()
.find(|k| k.name.text == "main")
.expect("main");
let (callee, _args) =
only_claimed_call(&main.body).expect("the claimed line must lower to one call");
assert_eq!(
callee, "early_low_order",
"the lower-`order` handler must win the claim regardless of its later declaration position"
);
}
#[test]
fn attach_matching_the_declared_return_type_does_not_diagnose_e180() {
let (hir, _m, diags) = lower_src(
"struct Cue {\n speaker: string\n}\n\n@[convention(claims = \"^(?<who>[A-Z]+)$\", attach = Cue, order = 10)]\nfn cue(who): Cue {\n return Cue { speaker: who };\n}\n",
);
assert!(
!diags.iter().any(|d| d.code == DiagnosticCode::E180),
"attach matching the declared return type must not raise E180: {diags:?}"
);
let convention = hir.knots[0]
.convention_annotation
.as_ref()
.expect("present");
assert_eq!(
convention.attach.as_ref().map(|a| a.text.as_str()),
Some("Cue")
);
}
#[test]
fn attach_with_no_return_type_at_all_diagnoses_e180() {
let (hir, _m, diags) = lower_src(
"struct Cue {\n speaker: string\n}\n\n@[convention(claims = \"^(?<who>[A-Z]+)$\", attach = Cue, order = 10)]\nfn cue(who) {\n return who;\n}\n",
);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E180),
"attach with no declared return type at all must raise E180: {diags:?}"
);
assert!(hir.knots[0].convention_annotation.is_none());
}
#[test]
fn attach_naming_a_different_type_than_the_return_type_diagnoses_e180() {
let (_hir, _m, diags) = lower_src(
"struct Cue {\n speaker: string\n}\n\n@[convention(claims = \"^(?<who>[A-Z]+)$\", attach = Cue, order = 10)]\nfn cue(who): string {\n return who;\n}\n",
);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E180),
"attach naming a struct the return type disagrees with must raise E180: {diags:?}"
);
}
#[test]
fn no_attach_clause_at_all_never_diagnoses_e180() {
let (hir, _m, diags) = lower_src(
"@[convention(claims = \"^(?<who>[A-Z]+)$\", order = 10)]\nfn cue(who) {\n return who;\n}\n",
);
assert!(
!diags.iter().any(|d| d.code == DiagnosticCode::E180),
"no attach clause must never raise E180: {diags:?}"
);
assert!(
hir.knots[0]
.convention_annotation
.as_ref()
.expect("present")
.attach
.is_none()
);
}
#[test]
fn block_and_attach_together_diagnoses_e186_and_registers_no_handler() {
let src = "struct Cue {\n speaker: string,\n}\n\n@[convention(claims = \"^(?<name>[A-Z]+)$\", order = 10, block, attach = Cue)]\nfn cue(name: string, body: content): Cue {\n return Cue { speaker: name };\n}\n\nflow main() {\n VENDOR\n Line one.\n}\n";
let (hir, _m, diags) = lower_src(src);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E186),
"block + attach together on one handler must raise E186: {diags:?}"
);
assert!(
hir.knots[0].convention_annotation.is_none(),
"a block+attach declaration must never register as a claiming handler"
);
let main = hir.knots.iter().find(|k| k.name.text == "main").unwrap();
assert!(
only_claimed_call(&main.body).is_none(),
"VENDOR must not be rewritten to a call — no handler is registered: {:?}",
main.body
);
}
#[test]
fn block_and_attach_together_diagnoses_e186_even_when_attach_precedes_block() {
let src = "struct Cue {\n speaker: string,\n}\n\n@[convention(claims = \"^(?<name>[A-Z]+)$\", order = 10, attach = Cue, block)]\nfn cue(name: string, body: content): Cue {\n return Cue { speaker: name };\n}\n";
let (_hir, _m, diags) = lower_src(src);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E186),
"clause order must not matter for E186: {diags:?}"
);
}
#[test]
fn block_alone_never_diagnoses_e186() {
let src = "@[convention(claims = \"^(?<name>[A-Z]+)$\", order = 10, block)]\nfn cue(name: string, body: content) {\n return name;\n}\n";
let (_hir, _m, diags) = lower_src(src);
assert!(
!diags.iter().any(|d| d.code == DiagnosticCode::E186),
"block with no attach clause must never raise E186: {diags:?}"
);
}
#[test]
fn attach_alone_never_diagnoses_e186() {
let src = "struct Cue {\n speaker: string,\n}\n\n@[convention(claims = \"^(?<name>[A-Z]+)$\", order = 10, attach = Cue)]\nfn cue(name: string): Cue {\n return Cue { speaker: name };\n}\n";
let (_hir, _m, diags) = lower_src(src);
assert!(
!diags.iter().any(|d| d.code == DiagnosticCode::E186),
"attach with no block clause must never raise E186: {diags:?}"
);
}
#[test]
fn two_byte_identical_claim_patterns_diagnose_e168_on_the_later_one() {
let (_hir, _m, diags) = lower_src(
"@[convention(claims = \"^(?<who>[A-Z]+) enters$\", order = 200)]\nfn arrival(who) {\n return who;\n}\n\n@[convention(claims = \"^(?<who>[A-Z]+) enters$\", order = 210)]\nfn arrival_again(who) {\n return who;\n}\n",
);
let e168s: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagnosticCode::E168)
.collect();
assert_eq!(
e168s.len(),
1,
"exactly one duplicate diagnostic, on the later declaration: {diags:?}"
);
let second_annotation_start = u32::try_from(
"@[convention(claims = \"^(?<who>[A-Z]+) enters$\", order = 220)]\nfn arrival(who) {\n return who;\n}\n\n"
.len(),
)
.expect("fixture length fits in u32");
let e168_start: u32 = e168s[0].range.start().into();
assert!(
e168_start >= second_annotation_start,
"E168 must point at the later (shadowed) declaration's annotation, not the earlier one: {:?}",
e168s[0].range
);
}
#[test]
fn a_byte_identical_twin_that_claims_the_earlier_handlers_own_body_is_not_e168() {
let (hir, _m, diags) = lower_src(
"@[convention(claims = \"^SIGNAL$\", order = 230)]\nfn a() >{\n SIGNAL\n}\n\n@[convention(claims = \"^SIGNAL$\", order = 240)]\nfn b() >{\n ok\n}\n",
);
assert!(
!diags.iter().any(|d| d.code == DiagnosticCode::E168),
"b actually claimed a line (inside a's own body) — E168 is a false positive here: {diags:?}"
);
assert_eq!(
hir.element_matches.len(),
1,
"exactly one line (SIGNAL, inside a's body) is claimable, and only b can claim it: {:?}",
hir.element_matches
);
assert_eq!(
hir.element_matches[0].handler.text, "b",
"b must be the handler that actually claimed the line: {:?}",
hir.element_matches
);
}
#[test]
fn three_byte_identical_claim_patterns_each_report_one_e168() {
let (_hir, _m, diags) = lower_src(
"@[convention(claims = \"^(?<who>[A-Z]+) enters$\", order = 250)]\nfn a(who) {\n return who;\n}\n\n@[convention(claims = \"^(?<who>[A-Z]+) enters$\", order = 260)]\nfn b(who) {\n return who;\n}\n\n@[convention(claims = \"^(?<who>[A-Z]+) enters$\", order = 270)]\nfn c(who) {\n return who;\n}\n",
);
let e168_ranges: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagnosticCode::E168)
.map(|d| d.range)
.collect();
assert_eq!(
e168_ranges.len(),
2,
"exactly one E168 per dead later handler (b, c) — not one per earlier twin: {diags:?}"
);
assert_ne!(
e168_ranges[0], e168_ranges[1],
"b and c are two distinct dead declarations and must not be reported at the same range twice: {e168_ranges:?}"
);
}
#[test]
fn allow_e168_above_the_later_declaration_suppresses_it() {
let src = "@[convention(claims = \"^(?<who>[A-Z]+) enters$\", order = 280)]\nfn arrival(who) {\n return who;\n}\n\n@[allow(E168)]\n@[convention(claims = \"^(?<who>[A-Z]+) enters$\", order = 290)]\nfn arrival_again(who) {\n return who;\n}\n";
let (hir, _m, diags) = lower_src(src);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E168),
"the fixture must still produce E168 before suppression: {diags:?}"
);
let suppressions = crate::suppressions::Suppressions {
allow_scopes: hir.allow_scopes.clone(),
..Default::default()
};
let remaining = crate::suppressions::apply_suppressions(FileId(0), src, diags, &suppressions);
assert!(
!remaining.iter().any(|d| d.code == DiagnosticCode::E168),
"@[allow(E168)] above the later declaration must suppress its E168: {remaining:?}"
);
}
#[test]
fn non_identical_overlapping_claim_patterns_that_never_win_are_e170() {
let (_hir, _m, diags) = lower_src(
"@[convention(claims = \"^(?<who>[A-Z]+) enters$\", order = 300)]\nfn arrival_general(who) {\n return who;\n}\n\n@[convention(claims = \"^(?<who>VENDOR) enters$\", order = 310)]\nfn arrival_vendor(who) {\n return who;\n}\n",
);
let e170s: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagnosticCode::E170)
.collect();
assert_eq!(
e170s.len(),
1,
"exactly one E170 for the later, unreachable handler: {diags:?}"
);
}
#[test]
fn distinct_overlapping_claim_patterns_pin_first_match_wins() {
let (hir, _m, diags) = lower_src(
"@[convention(claims = \"^(?<who>[A-Z]+) enters$\", order = 320)]\nfn arrival_general(who) {\n return who;\n}\n\n@[convention(claims = \"^(?<who>VENDOR) enters$\", order = 330)]\nfn arrival_vendor(who) {\n return who;\n}\n\nflow main() {\n VENDOR enters\n}\n",
);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E170),
"arrival_vendor never wins a claim in this file, so E170 must still fire: {diags:?}"
);
let main = hir
.knots
.iter()
.find(|k| k.name.text == "main")
.expect("main");
let (callee, _args) =
only_claimed_call(&main.body).expect("the claimed line must lower to one call");
assert_eq!(
callee, "arrival_general",
"first-declared handler wins under the interim dispatch order"
);
}
#[test]
fn allow_e170_above_the_later_declaration_suppresses_it() {
let src = "@[convention(claims = \"^(?<who>[A-Z]+) enters$\", order = 340)]\nfn arrival_general(who) {\n return who;\n}\n\n@[allow(E170)]\n@[convention(claims = \"^(?<who>VENDOR) enters$\", order = 350)]\nfn arrival_vendor(who) {\n return who;\n}\n";
let (hir, _m, diags) = lower_src(src);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E170),
"the fixture must still produce E170 before suppression: {diags:?}"
);
let suppressions = crate::suppressions::Suppressions {
allow_scopes: hir.allow_scopes.clone(),
..Default::default()
};
let remaining = crate::suppressions::apply_suppressions(FileId(0), src, diags, &suppressions);
assert!(
!remaining.iter().any(|d| d.code == DiagnosticCode::E170),
"@[allow(E170)] above the later declaration must suppress its E170: {remaining:?}"
);
}
#[test]
fn overlapping_patterns_where_later_handler_actually_wins_are_not_e170() {
let (hir, _m, diags) = lower_src(
"@[convention(claims = \"^INT\\\\. (?<p>.+)$\", order = 360)]\nfn interior_full(p) >{\n INT. KITCHEN - DAY\n}\n\n@[convention(claims = \"^INT\\\\. (?<p>.+) - DAY$\", order = 370)]\nfn interior_daytime(p) >{\n ok\n}\n",
);
assert!(
!diags.iter().any(|d| d.code == DiagnosticCode::E170),
"interior_daytime actually claimed a line (inside interior_full's own body) — E170 is a false positive here: {diags:?}"
);
assert_eq!(
hir.element_matches.len(),
1,
"exactly one line (inside interior_full's body) is claimable, and only interior_daytime can claim it: {:?}",
hir.element_matches
);
assert_eq!(
hir.element_matches[0].handler.text, "interior_daytime",
"interior_daytime must be the handler that actually claimed the line: {:?}",
hir.element_matches
);
}
#[test]
fn non_overlapping_patterns_are_not_e170() {
let (_hir, _m, diags) = lower_src(
"@[convention(claims = \"^INT\\\\. (?<p>.+)$\", order = 380)]\nfn interior(p) {\n return p;\n}\n\n@[convention(claims = \"^EXT\\\\. (?<p>.+)$\", order = 390)]\nfn exterior(p) {\n return p;\n}\n",
);
assert!(
!diags.iter().any(|d| d.code == DiagnosticCode::E170),
"no overlap between INT.* and EXT.*: {diags:?}"
);
}
#[test]
fn a_claim_on_a_flow_is_misplaced_e112() {
let (_hir, _m, diags) = lower_src(
"@[convention(claims = \"^(?<who>[A-Z]+) enters$\", order = 400)]\nflow arrival(who) {\n Hi, {who}!\n}\n",
);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E112),
"a claim on a flow must be diagnosed misplaced: {diags:?}"
);
}
#[test]
fn a_claim_on_a_nested_fn_is_misplaced_e112() {
let (_hir, _m, diags) = lower_src(
"flow outer() {\n @[convention(claims = \"^(?<who>[A-Z]+) enters$\", order = 410)]\n fn arrival(who) {\n return who;\n }\n}\n",
);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E112),
"a claim on a nested fn must be diagnosed misplaced: {diags:?}"
);
}
#[test]
fn a_claim_on_a_fn_inside_a_module_is_misplaced_e112() {
let (hir, _m, diags) = lower_src(
"module npcs {\n @[convention(claims = \"^(?<who>[A-Z]+) enters$\", order = 420)]\n fn arrival(who) {\n return who;\n }\n}\n",
);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E112),
"a claim on a fn nested in a module must be diagnosed misplaced: {diags:?}"
);
assert!(
hir.element_matches.is_empty(),
"an unregistered claim must not claim: {:?}",
hir.element_matches
);
}
#[test]
fn a_line_carrying_interpolation_is_never_claimed() {
let (hir, _m, diags) = lower_src(
"var who = \"VENDOR\"\n\n@[convention(claims = \"^(?<who>[A-Z]+) enters$\", order = 430)]\nfn arrival(who) {\n return who;\n}\n\nflow main() {\n {who} enters\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(
hir.element_matches.is_empty(),
"an interpolated line must not be claimed: {:?}",
hir.element_matches
);
}
#[test]
fn element_matches_are_recorded_in_source_order_across_a_choice_point() {
let src = "@[convention(claims = \"^SIGNAL (?<sound>.+)$\", order = 440)]\nfn effect(sound) {\n return sound;\n}\n\nflow main() {\n {?\n * Option. {\n SIGNAL EARLY\n }\n }\n SIGNAL LATE\n}\n";
let (hir, _m, diags) = lower_src(src);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.element_matches.len(), 2, "{:?}", hir.element_matches);
let early_pos = src.find("SIGNAL EARLY").expect("EARLY in fixture");
let late_pos = src.find("SIGNAL LATE").expect("LATE in fixture");
assert!(
early_pos < late_pos,
"fixture sanity: EARLY must precede LATE in source"
);
assert_eq!(
usize::from(hir.element_matches[0].line.start()),
early_pos,
"the choice-body claim (source-earlier) must sort first: {:?}",
hir.element_matches
);
assert_eq!(
usize::from(hir.element_matches[1].line.start()),
late_pos,
"the continuation claim (source-later) must sort second: {:?}",
hir.element_matches
);
}
#[test]
fn block_ending_in_divert_has_diverge_tail() {
let (hir, _m, diags) = lower_src("flow b() {\n Bye.\n}\nflow a() {\n -> b\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let a_body = &hir.knots[1].body;
assert!(
matches!(a_body.tail(), Tail::Diverge(Terminator::Divert(_))),
"expected Diverge(Divert) tail, got {:?}",
a_body.tail()
);
}
#[test]
fn block_ending_in_explicit_return_has_diverge_tail() {
let (hir, _m, diags) = lower_src("fn f() >{\n return\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = &hir.knots[0].body;
assert!(
matches!(body.tail(), Tail::Diverge(Terminator::Return(_))),
"expected Diverge(Return) tail, got {:?}",
body.tail()
);
}
#[test]
fn plain_content_flow_body_gets_implicit_done_tail() {
let (hir, _m, diags) = lower_src("flow greet(name) {\n Hi, {name}!\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body = &hir.knots[0].body;
assert!(
matches!(body.tail(), Tail::Diverge(Terminator::Divert(d)) if d.target.path == DivertPath::Done),
"expected implicit `-> DONE` Diverge tail, got {:?}",
body.tail()
);
}
#[test]
fn splice_appended_after_a_choice_body_recomputes_tail() {
let (hir, _m, diags) = lower_src(
"flow opts() {\n X.\n}\nflow a() {\n {?\n * Y. -> a\n <- opts()\n }\n}\n",
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let Stmt::ChoiceSet(cs) = &hir.knots[1].body.stmts[0] else {
panic!("expected ChoiceSet, got {:?}", hir.knots[1].body.stmts[0]);
};
let choice = &cs.choices[0];
assert!(
matches!(choice.body.stmts.last(), Some(Stmt::ThreadStart(_))),
"expected the splice to be spliced onto the choice body, got {:?}",
choice.body.stmts
);
assert_eq!(
*choice.body.tail(),
Tail::Unit,
"a trailing splice (non-terminator) must flip tail back to Unit, got {:?}",
choice.body.tail()
);
}
#[test]
fn file_level_was_lowers_to_module_rename_record() {
let (hir, _m, diags) = lower_src("@[was(\"story::old::barter\")]\nflow hero() {\n hi\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let module = hir
.module
.as_ref()
.expect("a `@[was]` file must carry a ModuleDecl");
assert_eq!(
module.name, "",
"native module name is not authored in-file"
);
assert_eq!(
module.was.as_ref().map(|(old, _)| old.as_str()),
Some("story::old::barter"),
"the quoted old module path must reach `module.was`"
);
assert!(
diags.iter().all(|d| d.code != DiagnosticCode::E129),
"a recognized `@[was]` must not raise E129: {diags:?}"
);
}
#[test]
fn file_level_was_unquoted_path_lowers_to_module_rename_record() {
let (hir, _m, diags) = lower_src("@[was(story::old::barter)]\nflow hero() {\n hi\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let module = hir
.module
.as_ref()
.expect("an unquoted `@[was]` file must carry a ModuleDecl");
assert_eq!(
module.was.as_ref().map(|(old, _)| old.as_str()),
Some("story::old::barter"),
"the unquoted old module path must reach `module.was`"
);
assert!(
diags.iter().all(|d| d.code != DiagnosticCode::E129),
"a recognized unquoted `@[was]` must not raise E129: {diags:?}"
);
assert!(
diags.iter().all(|d| d.code != DiagnosticCode::E132),
"the unquoted path form must not diagnose E132: {diags:?}"
);
}
#[test]
fn no_was_annotation_leaves_module_none() {
let (hir, _m, diags) = lower_src("flow hero() {\n hi\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(
hir.module.is_none(),
"a file with no `@[was]` carries no ModuleDecl"
);
}
#[test]
fn malformed_was_without_string_arg_diagnoses_e132() {
let (hir, _m, diags) = lower_src("@[was]\nflow hero() {\n hi\n}\n");
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E132),
"a `@[was]` with no string argument must raise E132: {diags:?}"
);
assert!(
hir.module.is_none(),
"a malformed `@[was]` produces no rename record"
);
assert!(
diags.iter().all(|d| d.code != DiagnosticCode::E129),
"a malformed `@[was]` must not also raise E129: {diags:?}"
);
}
#[test]
fn first_was_wins_when_several_are_present() {
let (hir, _m, _diags) =
lower_src("@[was(\"story::first\")]\n@[was(\"story::second\")]\nflow hero() {\n hi\n}\n");
assert_eq!(
hir.module
.as_ref()
.and_then(|m| m.was.as_ref())
.map(|(old, _)| old.as_str()),
Some("story::first"),
"first `@[was]` wins"
);
}
fn named(ty: Option<&crate::TypeExpr>) -> Option<&str> {
match ty? {
crate::TypeExpr::Named { name, .. } => Some(name.as_str()),
_ => None,
}
}
#[test]
fn annotated_params_lower_to_type_exprs() {
let (hir, _m, diags) = lower_src("fn probability(g: Guest, ref n: int) {\n return 1;\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let params = &hir.knots[0].params;
assert_eq!(params.len(), 2);
assert_eq!(named(params[0].annotation.as_ref()), Some("Guest"));
assert!(!params[0].is_ref);
assert_eq!(named(params[1].annotation.as_ref()), Some("int"));
assert!(params[1].is_ref, "`ref` survives alongside the annotation");
}
#[test]
fn unannotated_param_still_lowers_with_none() {
let (hir, _m, diags) = lower_src("fn heal(hp) {\n return hp;\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(hir.knots[0].params[0].annotation.is_none());
}
#[test]
fn generic_param_annotation_lowers_with_its_arguments() {
let (hir, _m, diags) = lower_src("fn tally(m: Map<string, int>) {\n return 1;\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let Some(crate::TypeExpr::Generic { name, args, .. }) =
hir.knots[0].params[0].annotation.as_ref()
else {
unreachable!("expected a generic annotation: {:?}", hir.knots[0].params);
};
assert_eq!(name, "Map");
let arg_names: Vec<Option<&str>> = args.iter().map(|a| named(Some(a))).collect();
assert_eq!(arg_names, vec![Some("string"), Some("int")]);
}
#[test]
fn stitch_params_take_annotations_too() {
let (hir, _m, diags) =
lower_src("flow garden() {\n flow gate(hp: int) {\n Creak.\n }\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let stitch = &hir.knots[0].stitches[0];
assert_eq!(named(stitch.params[0].annotation.as_ref()), Some("int"));
}
#[test]
fn fn_return_type_lowers_to_knot_return_type() {
let (hir, _m, diags) = lower_src("fn probability(g: Guest): float {\n return 1;\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(named(hir.knots[0].return_type.as_ref()), Some("float"));
}
#[test]
fn plain_flow_has_no_return_type() {
let (hir, _m, diags) = lower_src("flow greet() {\n Hi.\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(hir.knots[0].return_type.is_none());
}
#[test]
fn a_return_typed_flow_does_not_get_the_implicit_done() {
let (plain, _m, diags) = lower_src("flow quest() {\n Onward.\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(
matches!(plain.knots[0].body.stmts.last(), Some(Stmt::Divert(d))
if d.target.path == crate::DivertPath::Done),
"a plain flow still ends implicitly: {:?}",
plain.knots[0].body.stmts
);
let (typed, _m, diags) = lower_src("flow quest(): int {\n Onward.\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(
!matches!(typed.knots[0].body.stmts.last(), Some(Stmt::Divert(d))
if d.target.path == crate::DivertPath::Done),
"a value-returning flow must not be given an implicit DONE: {:?}",
typed.knots[0].body.stmts
);
}
#[test]
fn a_return_typed_stitch_lowers_to_stitch_return_type() {
let (hir, _m, diags) = lower_src("flow garden() {\n flow gate(): int {\n Creak.\n }\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let stitch = &hir.knots[0].stitches[0];
assert_eq!(named(stitch.return_type.as_ref()), Some("int"));
}
#[test]
fn plain_stitch_has_no_return_type() {
let (hir, _m, diags) = lower_src("flow garden() {\n flow gate() {\n Creak.\n }\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(hir.knots[0].stitches[0].return_type.is_none());
}
#[test]
fn a_return_typed_stitch_does_not_get_the_implicit_done() {
let (plain, _m, diags) = lower_src("flow garden() {\n flow gate() {\n Creak.\n }\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(
matches!(plain.knots[0].stitches[0].body.stmts.last(), Some(Stmt::Divert(d))
if d.target.path == crate::DivertPath::Done),
"a plain stitch still ends implicitly: {:?}",
plain.knots[0].stitches[0].body.stmts
);
let (typed, _m, diags) =
lower_src("flow garden() {\n flow gate(): int {\n Creak.\n }\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(
!matches!(typed.knots[0].stitches[0].body.stmts.last(), Some(Stmt::Divert(d))
if d.target.path == crate::DivertPath::Done),
"a value-returning stitch must not be given an implicit DONE: {:?}",
typed.knots[0].stitches[0].body.stmts
);
}
#[test]
fn annotated_var_and_const_lower_their_annotations() {
let (hir, _m, diags) = lower_src("var hp: int = 10\nconst MAX: int = 100\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(named(hir.variables[0].annotation.as_ref()), Some("int"));
assert_eq!(named(hir.constants[0].annotation.as_ref()), Some("int"));
assert!(matches!(hir.variables[0].value, Expr::Int(_)));
assert!(matches!(hir.constants[0].value, Expr::Int(_)));
}
#[test]
fn unannotated_var_lowers_with_none() {
let (hir, _m, diags) = lower_src("var hp = 10\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(hir.variables[0].annotation.is_none());
}
#[test]
fn annotated_let_lowers_to_temp_decl_annotation() {
let (hir, _m, diags) =
lower_src("fn heal(hp: int): int {\n let boost: int = 2;\n return hp + boost;\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let temp = first_temp_decl(&hir.knots[0].body);
assert_eq!(temp.name.text, "boost");
assert_eq!(named(temp.annotation.as_ref()), Some("int"));
}
#[test]
fn unannotated_let_lowers_with_none() {
let (hir, _m, diags) = lower_src("fn heal(hp) {\n let boost = 2;\n return hp + boost;\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(first_temp_decl(&hir.knots[0].body).annotation.is_none());
}
fn first_temp_decl(body: &crate::Block) -> &crate::TempDecl {
let Some(Stmt::LogicBlock(lb)) = body.stmts.first() else {
unreachable!("expected a code-ground body: {:?}", body.stmts);
};
let Some(BlockStmt::TempDecl(temp)) = lb.stmts.first() else {
unreachable!("expected a temp decl: {:?}", lb.stmts);
};
temp
}