#![allow(clippy::panic)]
use brink_syntax::ast::AstNode;
use brink_syntax::parse;
use rowan::TextRange;
use crate::hir::lower::{
BodyChild, EffectSink, LowerScope, LowerSink, classify_body_child, lower_simple_body,
};
use crate::*;
fn make_scope() -> LowerScope {
LowerScope::new(FileId(0))
}
fn make_sink() -> EffectSink {
EffectSink::new(FileId(0))
}
fn lower_body(source: &str) -> (Block, Vec<Diagnostic>) {
let parsed = parse(source);
let tree = parsed.tree();
let scope = make_scope();
let mut sink = make_sink();
let block = lower_simple_body(tree.syntax(), &scope, &mut sink);
let diagnostics = sink.finish();
(block, diagnostics)
}
struct TestSink {
diagnostics: Vec<(TextRange, DiagnosticCode)>,
}
impl TestSink {
fn new() -> Self {
Self {
diagnostics: Vec::new(),
}
}
}
impl LowerSink for TestSink {
fn diagnose(&mut self, range: TextRange, code: DiagnosticCode) -> crate::hir::lower::Diagnosed {
self.diagnostics.push((range, code));
crate::hir::lower::Diagnosed::test_token()
}
}
#[test]
fn lower_integer_literal() {
let source = "~ temp x = 42\n";
let (block, diags) = lower_body(source);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(block.stmts.len(), 1);
match &block.stmts[0] {
Stmt::TempDecl(td) => {
assert_eq!(td.name.text, "x");
assert!(
matches!(td.value, Some(Expr::Int(42))),
"expected Int(42), got {:?}",
td.value
);
}
other => panic!("expected TempDecl, got {other:?}"),
}
}
#[test]
fn lower_infix_expression() {
let source = "~ temp y = 3 + 4\n";
let (block, diags) = lower_body(source);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(block.stmts.len(), 1);
match &block.stmts[0] {
Stmt::TempDecl(td) => {
assert_eq!(td.name.text, "y");
assert!(
matches!(
&td.value,
Some(Expr::Infix(ie))
if ie.op == InfixOp::Add
&& matches!(ie.lhs.as_ref(), Expr::Int(3))
&& matches!(ie.rhs.as_ref(), Expr::Int(4))
),
"expected 3 + 4, got {:?}",
td.value
);
}
other => panic!("expected TempDecl, got {other:?}"),
}
}
#[test]
fn simple_text_line() {
let (block, diags) = lower_body("Hello, world!\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(block.stmts.len(), 2, "expected Content + EndOfLine");
assert!(matches!(&block.stmts[0], Stmt::Content(c) if !c.parts.is_empty()));
assert!(matches!(&block.stmts[1], Stmt::EndOfLine));
}
#[test]
fn expression_interpolation() {
let (block, diags) = lower_body("Value is {x}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(block.stmts.len(), 2);
match &block.stmts[0] {
Stmt::Content(c) => {
assert!(c.parts.len() >= 2, "expected text + interpolation");
assert!(matches!(&c.parts[0], ContentPart::Text(t) if t.contains("Value")));
assert!(
matches!(&c.parts[1], ContentPart::Interpolation(Expr::Path(_))),
"expected path interpolation, got {:?}",
c.parts[1]
);
}
other => panic!("expected Content, got {other:?}"),
}
}
#[test]
fn tag_on_content_line() {
let (block, diags) = lower_body("Hello #greeting\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(block.stmts.len(), 2);
match &block.stmts[0] {
Stmt::Content(c) => {
assert!(!c.tags.is_empty(), "expected at least one tag");
assert!(
matches!(&c.tags[0].parts[0], ContentPart::Text(t) if t == "greeting"),
"expected 'greeting' tag, got {:?}",
c.tags[0].parts
);
}
other => panic!("expected Content, got {other:?}"),
}
}
#[test]
fn logic_line_assignment() {
let source = "~ temp x = 0\n~ x = 5\n";
let (block, diags) = lower_body(source);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(block.stmts.len(), 2, "expected TempDecl + Assignment");
assert!(matches!(&block.stmts[0], Stmt::TempDecl(_)));
assert!(matches!(&block.stmts[1], Stmt::Assignment(_)));
}
#[test]
fn logic_line_emits_diagnostic_on_malformed() {
let source = "~\n";
let (_, diags) = lower_body(source);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E014),
"expected E014 diagnostic, got: {:?}",
diags.iter().map(|d| d.code.as_str()).collect::<Vec<_>>()
);
}
#[test]
fn computed_callee_indexed_emits_e104() {
let source = "~ temp x = handlers[state](event)\n";
let (_, diags) = lower_body(source);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E104),
"expected E104 diagnostic, got: {:?}",
diags.iter().map(|d| d.code.as_str()).collect::<Vec<_>>()
);
}
#[test]
fn computed_callee_field_access_emits_e104() {
let source = "~ temp x = obj.field()\n";
let (_, diags) = lower_body(source);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E104),
"expected E104 diagnostic, got: {:?}",
diags.iter().map(|d| d.code.as_str()).collect::<Vec<_>>()
);
}
#[test]
fn computed_callee_call_result_emits_e104() {
let source = "~ temp x = get_handler()()\n";
let (_, diags) = lower_body(source);
assert!(
diags.iter().any(|d| d.code == DiagnosticCode::E104),
"expected E104 diagnostic, got: {:?}",
diags.iter().map(|d| d.code.as_str()).collect::<Vec<_>>()
);
}
#[test]
fn bare_name_direct_call_never_emits_e104() {
let source = "~ temp x = bare(1, 2)\n";
let (_, diags) = lower_body(source);
assert!(
!diags.iter().any(|d| d.code == DiagnosticCode::E104),
"bare-name call incorrectly rejected: {:?}",
diags.iter().map(|d| d.code.as_str()).collect::<Vec<_>>()
);
}
#[test]
fn explicit_call_form_never_emits_e104() {
let source = "~ temp x = call(handlers[state], event)\n";
let (_, diags) = lower_body(source);
assert!(
!diags.iter().any(|d| d.code == DiagnosticCode::E104),
"call(f, args…) incorrectly rejected: {:?}",
diags.iter().map(|d| d.code.as_str()).collect::<Vec<_>>()
);
}
#[test]
fn mock_sink_records_diagnostics() {
let parsed = parse("~\n");
let tree = parsed.tree();
let scope = make_scope();
let mut sink = TestSink::new();
let _ = lower_simple_body(tree.syntax(), &scope, &mut sink);
assert!(
sink.diagnostics
.iter()
.any(|(_, code)| *code == DiagnosticCode::E014),
"expected E014 in mock sink"
);
}
#[test]
fn classify_recognizes_content_line() {
let parsed = parse("Hello\n");
let tree = parsed.tree();
let mut found = false;
for child in tree.syntax().children() {
if matches!(classify_body_child(&child), BodyChild::ContentLine(_)) {
found = true;
}
}
assert!(found, "expected to find a ContentLine child");
}
#[test]
fn classify_recognizes_logic_line() {
let parsed = parse("~ temp x = 1\n");
let tree = parsed.tree();
let mut found = false;
for child in tree.syntax().children() {
if matches!(classify_body_child(&child), BodyChild::LogicLine(_)) {
found = true;
}
}
assert!(found, "expected to find a LogicLine child");
}
#[test]
fn accumulator_content_with_glue_suppresses_eol() {
let source = "Hello<>\n";
let (block, diags) = lower_body(source);
assert!(diags.is_empty());
assert!(
matches!(&block.stmts[0], Stmt::Content(c) if !c.parts.is_empty()),
"expected Content stmt"
);
assert!(
!block.stmts.iter().any(|s| matches!(s, Stmt::EndOfLine)),
"EndOfLine should be suppressed by glue"
);
}
#[test]
fn whitespace_between_inline_construct_and_glue_lowers_to_a_spring() {
use crate::ContentPart;
let parts_of = |source: &str| -> Vec<&'static str> {
let (block, diags) = lower_body(source);
assert!(diags.is_empty(), "{source:?}: {diags:?}");
let Some(Stmt::Content(c)) = block.stmts.first() else {
panic!(
"{source:?}: expected a leading Content stmt, got {:?}",
block.stmts
);
};
c.parts
.iter()
.map(|p| match p {
ContentPart::Text(_) => "text",
ContentPart::Glue => "glue",
ContentPart::Spring => "spring",
ContentPart::Interpolation(_) => "interp",
ContentPart::InlineConditional(_) => "cond",
ContentPart::InlineSequence(_) => "seq",
ContentPart::Span(_) => "span",
})
.collect()
};
assert_eq!(parts_of("{0} <>\nworld\n"), ["interp", "spring", "glue"]);
assert_eq!(parts_of("{0} <>\nworld\n"), ["interp", "spring", "glue"]);
assert_eq!(parts_of("{0}<>\nworld\n"), ["interp", "glue"]);
assert_eq!(parts_of("{true:x} <>\nworld\n"), ["cond", "spring", "glue"]);
assert_eq!(parts_of("{a|b} <>\nworld\n"), ["seq", "spring", "glue"]);
assert_eq!(
parts_of("{0} <> <>\nworld\n"),
["interp", "spring", "glue", "glue"]
);
assert_eq!(parts_of("hello <>\nworld\n"), ["text", "glue"]);
assert_eq!(parts_of("{0} x <>\nworld\n"), ["interp", "text", "glue"]);
}
#[test]
fn accumulator_logic_line_with_call_emits_eol() {
let source = "=== function f() ===\n~ return 1\n=== main ===\n~ f()\n";
let (block, _) = lower_body(source);
let _ = block;
}
fn lower_full(source: &str) -> (SymbolManifest, Vec<Diagnostic>) {
let parsed = parse(source);
let tree = parsed.tree();
let (_hir, manifest, diags) = crate::hir::lower(FileId(0), &tree);
(manifest, diags)
}
#[test]
fn docs_attach_to_all_declaration_kinds() {
let source = "\
/// An external.
EXTERNAL ping(x)
/// A variable.
VAR health = 100
/// A constant.
CONST SPEED = 0.5
/// A list.
LIST mood = happy, sad
/// A knot.
== hub ==
intro
/// A nested stitch.
= market
stalls
/// A function knot.
== function damage(weapon) ==
~ return 1
";
let (manifest, diags) = lower_full(source);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let doc_text = |kind: SymbolKind, name: &str| {
manifest
.docs
.get(&(kind, name.to_string()))
.unwrap_or_else(|| panic!("doc for {kind:?} {name}"))
.doc
.clone()
};
assert_eq!(
doc_text(SymbolKind::External, "ping").as_deref(),
Some("An external.")
);
assert_eq!(
doc_text(SymbolKind::Variable, "health").as_deref(),
Some("A variable.")
);
assert_eq!(
doc_text(SymbolKind::Constant, "SPEED").as_deref(),
Some("A constant.")
);
assert_eq!(
doc_text(SymbolKind::List, "mood").as_deref(),
Some("A list.")
);
assert_eq!(
doc_text(SymbolKind::Knot, "hub").as_deref(),
Some("A knot.")
);
assert_eq!(
doc_text(SymbolKind::Stitch, "hub.market").as_deref(),
Some("A nested stitch."),
"nested stitch docs are keyed by qualified name"
);
assert_eq!(
doc_text(SymbolKind::Knot, "damage").as_deref(),
Some("A function knot.")
);
}
#[test]
fn inapplicable_tags_emit_e043() {
let source = "\
/// @kind query
== hub ==
intro
/// @param x {int}
VAR health = 100
";
let (manifest, diags) = lower_full(source);
let e043: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagnosticCode::E043)
.collect();
assert_eq!(e043.len(), 2, "one E043 per inapplicable tag: {diags:?}");
assert!(
!manifest
.docs
.contains_key(&(SymbolKind::Knot, "hub".to_string())),
"tag-only block with all tags dropped attaches nothing"
);
}
#[test]
fn undocumented_declarations_have_no_doc_entries() {
let source = "\
EXTERNAL ping(x)
VAR health = 100
== hub ==
intro
";
let (manifest, diags) = lower_full(source);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(manifest.docs.is_empty());
}
fn lower_hir(source: &str) -> (HirFile, Vec<Diagnostic>) {
let parsed = parse(source);
let tree = parsed.tree();
let (hir, _manifest, diags) = crate::hir::lower(FileId(0), &tree);
(hir, diags)
}
fn all_content_tags(hir: &HirFile) -> Vec<String> {
fn tags_in_block(block: &Block, out: &mut Vec<String>) {
for stmt in &block.stmts {
if let Stmt::Content(c) = stmt {
for tag in &c.tags {
let mut text = String::new();
for part in &tag.parts {
if let ContentPart::Text(t) = part {
text.push_str(t);
}
}
out.push(text);
}
}
}
}
let mut out = Vec::new();
tags_in_block(&hir.root_content, &mut out);
for knot in &hir.knots {
tags_in_block(&knot.body, &mut out);
for stitch in &knot.stitches {
tags_in_block(&stitch.body, &mut out);
}
}
out
}
fn codes(diags: &[Diagnostic]) -> Vec<DiagnosticCode> {
diags.iter().map(|d| d.code).collect()
}
#[test]
fn local_directive_marks_var() {
let (hir, diags) = lower_hir("#@local\nVAR mood = 0\nhello\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.variables.len(), 1);
assert!(hir.variables[0].is_local);
assert!(all_content_tags(&hir).is_empty());
}
#[test]
fn plain_var_is_not_local() {
let (hir, diags) = lower_hir("VAR mood = 0\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(!hir.variables[0].is_local);
}
#[test]
fn local_directive_marks_knot_from_top_of_body() {
let (hir, diags) = lower_hir("== guard ==\n#@local\nHalt!\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.knots.len(), 1);
assert!(hir.knots[0].is_local);
assert!(all_content_tags(&hir).is_empty());
}
#[test]
fn local_directive_marks_stitch() {
let (hir, diags) = lower_hir("== guard ==\nHalt!\n= mood\n#@local\ngrumpy\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(!hir.knots[0].is_local);
assert!(hir.knots[0].stitches[0].is_local);
}
#[test]
fn knot_directive_coexists_with_plain_knot_tags() {
let (hir, diags) = lower_hir("== guard ==\n# author: bob\n#@local\nHalt!\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(hir.knots[0].is_local);
assert_eq!(all_content_tags(&hir), vec!["author: bob".to_string()]);
}
#[test]
fn unmarked_knot_is_not_local() {
let (hir, diags) = lower_hir("== guard ==\nHalt!\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(!hir.knots[0].is_local);
}
#[test]
fn unknown_directive_is_e044() {
let (_hir, diags) = lower_hir("#@locale\nVAR mood = 0\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E044]);
}
#[test]
fn directive_above_content_line_is_e045() {
let (hir, diags) = lower_hir("#@local\njust text\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E045]);
assert!(all_content_tags(&hir).is_empty());
}
#[test]
fn inline_directive_tag_is_e045() {
let (hir, diags) = lower_hir("some text #@local\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E045]);
assert!(all_content_tags(&hir).is_empty());
}
#[test]
fn directive_mid_knot_body_is_e045() {
let (_hir, diags) = lower_hir("== guard ==\nHalt!\n#@local\nmore\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E045]);
}
#[test]
fn dynamic_directive_is_e046() {
let (_hir, diags) = lower_hir("#@{x}\nVAR mood = 0\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E046]);
}
#[test]
fn mixed_directive_and_plain_tags_is_e047() {
let (hir, diags) = lower_hir("#@local # art.png\nsome text\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E047]);
assert_eq!(all_content_tags(&hir), vec!["art.png".to_string()]);
}
#[test]
fn duplicate_local_directive_is_e048() {
let (hir, diags) = lower_hir("#@local\n#@local\nVAR mood = 0\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E048]);
assert!(hir.variables[0].is_local);
}
#[test]
fn local_on_const_is_e049() {
let (_hir, diags) = lower_hir("#@local\nCONST max = 3\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E049]);
}
#[test]
fn local_on_list_is_e049() {
let (_hir, diags) = lower_hir("#@local\nLIST moods = happy, sad\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E049]);
}
#[test]
fn local_on_external_is_e049() {
let (_hir, diags) = lower_hir("#@local\nEXTERNAL ping(x)\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E049]);
}
#[test]
fn local_with_args_is_e050() {
let (_hir, diags) = lower_hir("#@local(now)\nVAR mood = 0\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E050]);
}
#[test]
fn directive_with_blank_line_still_attaches() {
let (hir, diags) = lower_hir("#@local\n\nVAR mood = 0\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(hir.variables[0].is_local);
}
#[test]
fn plain_tag_lines_are_unaffected() {
let (hir, diags) = lower_hir("# above\nsome text # inline\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let tags = all_content_tags(&hir);
assert_eq!(tags.len(), 2, "both plain tags survive: {tags:?}");
}
#[test]
fn effects_directive_parses_reads_writes_calls_on_a_knot() {
let (hir, diags) =
lower_hir("== guard ==\n@[effects(reads(gold), writes(alarm), calls(audio))]\nHalt!\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let assertion = hir.knots[0]
.effects_assertion
.as_ref()
.expect("assertion present");
assert!(!assertion.pure);
assert_eq!(assertion.reads, vec!["gold".to_string()]);
assert_eq!(assertion.writes, vec!["alarm".to_string()]);
assert_eq!(assertion.calls, vec!["audio".to_string()]);
assert!(all_content_tags(&hir).is_empty());
}
#[test]
fn effects_paren_clause_names_multiple_cells() {
let (hir, diags) =
lower_hir("== guard ==\n@[effects(reads(a, b), writes(c), calls(d))]\nHalt!\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let assertion = hir.knots[0].effects_assertion.as_ref().expect("present");
assert_eq!(assertion.reads, vec!["a".to_string(), "b".to_string()]);
assert_eq!(assertion.writes, vec!["c".to_string()]);
assert_eq!(assertion.calls, vec!["d".to_string()]);
}
#[test]
fn hash_effects_colon_clause_continuation_stays_frozen() {
let (hir, diags) =
lower_hir("== guard ==\n#@effects(reads: a, b, writes: c, calls: d)\nHalt!\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E110]);
let assertion = hir.knots[0].effects_assertion.as_ref().expect("present");
assert_eq!(assertion.reads, vec!["a".to_string(), "b".to_string()]);
assert_eq!(assertion.writes, vec!["c".to_string()]);
assert_eq!(assertion.calls, vec!["d".to_string()]);
}
#[test]
fn effects_pure_sugar_sets_pure_with_empty_lists() {
let (hir, diags) = lower_hir("== guard ==\n@[effects(pure)]\nHalt!\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let assertion = hir.knots[0].effects_assertion.as_ref().expect("present");
assert!(assertion.pure);
assert!(assertion.reads.is_empty());
assert!(assertion.writes.is_empty());
assert!(assertion.calls.is_empty());
}
#[test]
fn effects_directive_marks_stitch() {
let (hir, diags) = lower_hir("== guard ==\nHalt!\n= mood\n@[effects(reads(gold))]\ngrumpy\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(hir.knots[0].effects_assertion.is_none());
assert!(hir.knots[0].stitches[0].effects_assertion.is_some());
}
#[test]
fn unmarked_knot_has_no_effects_assertion() {
let (hir, diags) = lower_hir("== guard ==\nHalt!\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(hir.knots[0].effects_assertion.is_none());
}
#[test]
fn bare_effects_directive_is_e100() {
let (_hir, diags) = lower_hir("== guard ==\n#@effects\nHalt!\n");
assert_eq!(
codes(&diags),
vec![DiagnosticCode::E110, DiagnosticCode::E100]
);
}
#[test]
fn bare_effects_annotation_is_e100() {
let (_hir, diags) = lower_hir("== guard ==\n@[effects]\nHalt!\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E100]);
}
#[test]
fn empty_effects_args_is_e100() {
let (_hir, diags) = lower_hir("== guard ==\n@[effects()]\nHalt!\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E100]);
}
#[test]
fn effects_unknown_clause_keyword_is_e101() {
let (_hir, diags) = lower_hir("== guard ==\n@[effects(frobs(gold))]\nHalt!\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E101]);
}
#[test]
fn effects_bare_non_flag_ident_is_e101() {
let (_hir, diags) = lower_hir("== guard ==\n@[effects(gold)]\nHalt!\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E101]);
}
#[test]
fn effects_non_identifier_value_is_e101() {
let (_hir, diags) = lower_hir("== guard ==\n@[effects(reads(1gold))]\nHalt!\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E101]);
}
#[test]
fn effects_colon_clause_in_annotation_spelling_is_e101() {
let (_hir, diags) = lower_hir("== guard ==\n@[effects(reads: gold)]\nHalt!\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E101]);
}
#[test]
fn effects_dynamic_content_is_e046() {
let (_hir, diags) = lower_hir("== guard ==\n#@effects({x})\nHalt!\n");
assert_eq!(
codes(&diags),
vec![DiagnosticCode::E110, DiagnosticCode::E046]
);
}
#[test]
fn was_dynamic_content_is_e046() {
let (_hir, diags) = lower_hir("== guard ==\n#@was({x})\nHalt!\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E046]);
}
#[test]
fn duplicate_effects_directive_is_e048_first_wins() {
let (hir, diags) =
lower_hir("== guard ==\n@[effects(reads(a))]\n@[effects(reads(b))]\nHalt!\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E048]);
let assertion = hir.knots[0].effects_assertion.as_ref().expect("present");
assert_eq!(assertion.reads, vec!["a".to_string()]);
}
#[test]
fn effects_on_var_is_e049() {
let (_hir, diags) = lower_hir("#@effects(reads: gold)\nVAR gold = 0\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E049]);
}
#[test]
fn effects_on_const_is_e049() {
let (_hir, diags) = lower_hir("#@effects(pure)\nCONST max = 3\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E049]);
}
#[test]
fn effects_annotation_flags_parse_any_subset() {
let (hir, diags) = lower_hir("== guard ==\n@[effects(pure, silent, total)]\nHalt!\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let a = hir.knots[0].effects_assertion.as_ref().expect("present");
assert!(a.pure && a.silent && a.total);
assert!(a.reads.is_empty() && a.writes.is_empty() && a.calls.is_empty());
}
#[test]
fn effects_annotation_silent_alone_parses() {
let (hir, diags) = lower_hir("== guard ==\n@[effects(silent)]\nHalt!\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let a = hir.knots[0].effects_assertion.as_ref().expect("present");
assert!(!a.pure && a.silent && !a.total);
}
#[test]
fn effects_annotation_flags_combine_with_clauses() {
let (hir, diags) =
lower_hir("VAR gold = 0\n== guard ==\n@[effects(silent, reads(gold))]\nHalt!\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let a = hir.knots[0].effects_assertion.as_ref().expect("present");
assert!(a.silent && !a.pure && !a.total);
assert_eq!(a.reads, vec!["gold".to_string()]);
}
#[test]
fn effects_flag_after_a_clause_is_a_flag_not_a_clause_value() {
let (hir, diags) =
lower_hir("VAR gold = 0\n== guard ==\n@[effects(reads(gold), silent)]\nHalt!\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let a = hir.knots[0].effects_assertion.as_ref().expect("present");
assert!(a.silent, "`silent` after `reads(gold)` is the flag");
assert_eq!(
a.reads,
vec!["gold".to_string()],
"`silent` must not be swallowed into the reads clause"
);
}
#[test]
fn effects_flag_name_inside_a_clause_is_a_clause_value() {
let (hir, diags) = lower_hir("VAR silent = 0\n== guard ==\n@[effects(reads(silent))]\nHalt!\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let a = hir.knots[0].effects_assertion.as_ref().expect("present");
assert!(
!a.silent,
"`silent` inside `reads(…)` is a cell name, not a flag"
);
assert_eq!(a.reads, vec!["silent".to_string()]);
}
#[test]
fn effects_pure_with_a_state_clause_is_contradictory_e101() {
let (_hir, diags) = lower_hir("== guard ==\n@[effects(pure, reads(gold))]\nHalt!\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E101]);
}
#[test]
fn deprecated_hash_effects_spelling_warns_e110_and_still_parses() {
let (hir, diags) = lower_hir("== guard ==\n#@effects(pure)\nHalt!\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E110]);
let a = hir.knots[0]
.effects_assertion
.as_ref()
.expect("alias still parses");
assert!(a.pure);
}
#[test]
fn hash_effects_spelling_accepts_the_new_flags_too() {
let (hir, diags) = lower_hir("== guard ==\n#@effects(silent, total)\nHalt!\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E110]);
let a = hir.knots[0].effects_assertion.as_ref().expect("present");
assert!(a.silent && a.total);
}
#[test]
fn unknown_annotation_name_is_e111() {
let (_hir, diags) = lower_hir("== guard ==\n@[frobnicate(now)]\nHalt!\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E111]);
}
#[test]
fn tag_directive_names_do_not_alias_into_the_annotation_channel() {
let (hir, diags) = lower_hir("== guard ==\n@[local]\nHalt!\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E111]);
assert!(
!hir.knots[0].is_local,
"`@[local]` must not act as `#@local`"
);
}
#[test]
fn misplaced_annotation_line_is_e112_not_content() {
let (hir, diags) = lower_hir("== guard ==\nHalt!\n@[effects(pure)]\nMore.\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E112]);
assert!(
hir.knots[0].effects_assertion.is_none(),
"a misplaced annotation must not attach"
);
}
#[test]
fn file_level_annotation_line_is_e112() {
let (_hir, diags) = lower_hir("@[effects(pure)]\nHello.\n");
assert_eq!(codes(&diags), vec![DiagnosticCode::E112]);
}
#[test]
fn annotation_line_never_lowers_to_content() {
let (hir, diags) = lower_hir("== guard ==\n@[effects(pure)]\nHalt!\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let body_text = format!("{:?}", hir.knots[0].body);
assert!(
!body_text.contains("effects"),
"annotation text leaked into the lowered body: {body_text}"
);
}
#[test]
fn annotation_below_plain_tag_line_still_attaches() {
let (hir, diags) = lower_hir("== guard ==\n# mood: grim\n@[effects(pure)]\nHalt!\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert!(hir.knots[0].effects_assertion.is_some());
}
#[test]
fn param_annotation_lowers_to_named_type_expr() {
let (hir, diags) = lower_hir("=== heal(hp: int) ===\n~ return\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let knot = &hir.knots[0];
assert_eq!(knot.params.len(), 1);
match &knot.params[0].annotation {
Some(TypeExpr::Named { name, .. }) => assert_eq!(name, "int"),
other => panic!("expected Named(\"int\"), got {other:?}"),
}
}
#[test]
fn unannotated_param_lowers_to_none() {
let (hir, diags) = lower_hir("=== heal(hp) ===\n~ return\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.knots[0].params[0].annotation, None);
}
#[test]
fn return_type_annotation_lowers_onto_knot() {
let (hir, diags) = lower_hir("=== function heal(hp) ===\n~ return hp\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.knots[0].return_type, None);
let (hir2, diags2) = lower_hir("=== function heal(hp): int ===\n~ return hp\n");
assert!(diags2.is_empty(), "unexpected diagnostics: {diags2:?}");
match &hir2.knots[0].return_type {
Some(TypeExpr::Named { name, .. }) => assert_eq!(name, "int"),
other => panic!("expected Named(\"int\"), got {other:?}"),
}
}
#[test]
fn void_return_type_lowers_to_named_void_not_none() {
let (hir, diags) = lower_hir("=== function noop(): void ===\n~ return\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
match &hir.knots[0].return_type {
Some(TypeExpr::Named { name, .. }) => assert_eq!(name, "void"),
other => panic!("expected Named(\"void\"), got {other:?}"),
}
}
#[test]
fn unannotated_stitch_header_has_no_return_type() {
let (hir, diags) = lower_hir("=== camp ===\nText.\n= fire\nMore.\n-> DONE\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let camp = hir.knots.iter().find(|k| k.name.text == "camp").unwrap();
assert_eq!(camp.return_type, None);
assert_eq!(camp.stitches[0].name.text, "fire");
assert_eq!(camp.stitches[0].return_type, None);
}
#[test]
fn return_type_annotation_lowers_onto_nested_stitch() {
let (hir, diags) = lower_hir("=== camp ===\nText.\n= fire(logs): int\n~ return logs\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let camp = hir.knots.iter().find(|k| k.name.text == "camp").unwrap();
match &camp.stitches[0].return_type {
Some(TypeExpr::Named { name, .. }) => assert_eq!(name, "int"),
other => panic!("expected Named(\"int\"), got {other:?}"),
}
}
#[test]
fn return_type_annotation_lowers_onto_promoted_top_level_stitch() {
let (hir, diags) = lower_hir("= fire(logs): int\n~ return logs\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let fire = hir.knots.iter().find(|k| k.name.text == "fire").unwrap();
match &fire.return_type {
Some(TypeExpr::Named { name, .. }) => assert_eq!(name, "int"),
other => panic!("expected Named(\"int\"), got {other:?}"),
}
}
#[test]
fn var_annotation_lowers_onto_var_decl() {
let (hir, diags) = lower_hir("VAR gold: int = 100\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
match &hir.variables[0].annotation {
Some(TypeExpr::Named { name, .. }) => assert_eq!(name, "int"),
other => panic!("expected Named(\"int\"), got {other:?}"),
}
}
#[test]
fn unannotated_var_lowers_to_none() {
let (hir, diags) = lower_hir("VAR gold = 100\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.variables[0].annotation, None);
}
#[test]
fn temp_ascription_lowers_onto_temp_decl() {
let (hir, diags) = lower_hir("~ temp name: string = \"who\"\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
match &hir.root_content.stmts[0] {
Stmt::TempDecl(td) => match &td.annotation {
Some(TypeExpr::Named { name, .. }) => assert_eq!(name, "string"),
other => panic!("expected Named(\"string\"), got {other:?}"),
},
other => panic!("expected TempDecl, got {other:?}"),
}
}
#[test]
fn block_scoped_temp_ascription_lowers_onto_block_temp_decl() {
let (hir, diags) = lower_hir("~ {\ntemp x: int = 1\n}\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
match &hir.root_content.stmts[0] {
Stmt::LogicBlock(lb) => match &lb.stmts[0] {
BlockStmt::TempDecl(td) => match &td.annotation {
Some(TypeExpr::Named { name, .. }) => assert_eq!(name, "int"),
other => panic!("expected Named(\"int\"), got {other:?}"),
},
other => panic!("expected TempDecl, got {other:?}"),
},
other => panic!("expected LogicBlock, got {other:?}"),
}
}
#[test]
fn generic_list_and_map_annotations_lower_with_args() {
let (hir, diags) = lower_hir("VAR w: List<Weathers> = 0\nVAR m: Map<string, int> = 0\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
match &hir.variables[0].annotation {
Some(TypeExpr::Generic { name, args, .. }) => {
assert_eq!(name, "List");
assert_eq!(args.len(), 1);
assert!(matches!(&args[0], TypeExpr::Named { name, .. } if name == "Weathers"));
}
other => panic!("expected Generic(\"List\", ...), got {other:?}"),
}
match &hir.variables[1].annotation {
Some(TypeExpr::Generic { name, args, .. }) => {
assert_eq!(name, "Map");
assert_eq!(args.len(), 2);
}
other => panic!("expected Generic(\"Map\", ...), got {other:?}"),
}
}
#[test]
fn fn_type_annotation_lowers_with_params_and_return() {
let (hir, diags) = lower_hir("VAR cb: fn(int, int): bool = 0\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
match &hir.variables[0].annotation {
Some(TypeExpr::Fn { params, ret, .. }) => {
assert_eq!(params.len(), 2);
assert!(matches!(**ret, TypeExpr::Named { ref name, .. } if name == "bool"));
}
other => panic!("expected Fn, got {other:?}"),
}
}
#[test]
fn unknown_type_name_still_lowers_without_diagnostics() {
let (hir, diags) = lower_hir("VAR p: Frobnicator = 0\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
match &hir.variables[0].annotation {
Some(TypeExpr::Named { name, .. }) => assert_eq!(name, "Frobnicator"),
other => panic!("expected Named(\"Frobnicator\"), got {other:?}"),
}
}
#[test]
fn const_annotation_lowers_onto_const_decl() {
let (hir, diags) = lower_hir("CONST speed: float = 0.5\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
match &hir.constants[0].annotation {
Some(TypeExpr::Named { name, .. }) => assert_eq!(name, "float"),
other => panic!("expected Named(\"float\"), got {other:?}"),
}
}
#[test]
fn unannotated_const_lowers_to_none() {
let (hir, diags) = lower_hir("CONST speed = 0.5\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.constants[0].annotation, None);
}
#[test]
fn file_module_directive_recognized_and_erased() {
let (hir, diags) = lower_hir("#@module(quest)\n== start ==\nHi\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let module = hir.module.as_ref().expect("module declared");
assert_eq!(module.name, "quest");
assert!(
all_content_tags(&hir).is_empty(),
"the #@module directive must not leak into content tags"
);
}
#[test]
fn plain_file_has_no_module_declaration() {
let (hir, diags) = lower_hir("== start ==\nHi\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.module, None);
}
#[test]
fn module_directive_after_leading_comment_still_recognized() {
let (hir, diags) = lower_hir("// header\n#@module(quest)\nHi\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.module.map(|m| m.name), Some("quest".to_string()));
}
#[test]
fn module_directive_without_name_is_e086() {
let (hir, diags) = lower_hir("#@module\nHi\n");
assert_eq!(hir.module, None);
assert!(
codes(&diags).contains(&DiagnosticCode::E086),
"expected E086, got {diags:?}"
);
}
#[test]
fn module_directive_empty_name_is_e086() {
let (hir, diags) = lower_hir("#@module()\nHi\n");
assert_eq!(hir.module, None);
assert!(codes(&diags).contains(&DiagnosticCode::E086));
}
#[test]
fn duplicate_module_directive_is_e086() {
let (hir, diags) = lower_hir("#@module(quest)\n#@module(other)\nHi\n");
assert_eq!(hir.module.map(|m| m.name), Some("quest".to_string()));
assert!(codes(&diags).contains(&DiagnosticCode::E086));
}
#[test]
fn unknown_file_level_directive_still_errors_e045() {
let (hir, diags) = lower_hir("#@bogus\nHi\n");
assert_eq!(hir.module, None);
assert!(
codes(&diags).contains(&DiagnosticCode::E045),
"expected E045, got {diags:?}"
);
}
#[test]
fn import_qualified_form_extracted() {
let (hir, diags) = lower_hir("IMPORT quest_3\nHi\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.imports.len(), 1);
assert_eq!(hir.imports[0].module, "quest_3");
assert!(!hir.imports[0].bare);
assert!(hir.imports[0].items.is_empty());
}
#[test]
fn import_bare_list_with_alias_extracted() {
let (hir, diags) = lower_hir("IMPORT { ambush, guard_talk AS gt } FROM quest_3\nHi\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(hir.imports.len(), 1);
let imp = &hir.imports[0];
assert_eq!(imp.module, "quest_3");
assert!(imp.bare);
assert_eq!(imp.items.len(), 2);
assert_eq!(imp.items[0].name, "ambush");
assert_eq!(imp.items[0].alias, None);
assert_eq!(imp.items[0].local_name(), "ambush");
assert_eq!(imp.items[1].name, "guard_talk");
assert_eq!(imp.items[1].alias.as_deref(), Some("gt"));
assert_eq!(imp.items[1].local_name(), "gt");
}
#[test]
fn private_directive_marks_var_visibility() {
let (manifest, diags) = lower_full("#@private\nVAR secret = 0\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(
manifest.variables[0].visibility,
Some(crate::VisibilityMark::Private)
);
}
#[test]
fn public_directive_marks_knot_visibility() {
let (manifest, diags) = lower_full("== guard ==\n#@public\nHalt!\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(
manifest.knots[0].visibility,
Some(crate::VisibilityMark::Public)
);
}
#[test]
fn visibility_directives_collected_for_gate() {
let (hir, _diags) = lower_hir("#@private\nVAR secret = 0\n");
assert_eq!(hir.visibility.len(), 1);
assert_eq!(hir.visibility[0].mark, crate::VisibilityMark::Private);
assert!(all_content_tags(&hir).is_empty());
}
#[test]
fn conflicting_visibility_directives_is_e093() {
let (_manifest, diags) = lower_full("#@private\n#@public\nVAR x = 0\n");
assert!(
codes(&diags).contains(&DiagnosticCode::E093),
"expected E093, got {diags:?}"
);
}
#[test]
fn block_ending_in_divert_has_diverge_tail() {
let (hir, diags) = lower_hir("== a ==\nHello\n-> b\n== b ==\nDone.\n-> END\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let a_body = &hir.knots[0].body;
assert!(
matches!(a_body.tail(), Tail::Diverge(Terminator::Divert(_))),
"expected Diverge(Divert) tail, got {:?}",
a_body.tail()
);
let b_body = &hir.knots[1].body;
assert!(
matches!(b_body.tail(), Tail::Diverge(Terminator::Divert(_))),
"-> END is still a Divert (DivertPath::End), got {:?}",
b_body.tail()
);
}
#[test]
fn block_ending_in_explicit_return_has_diverge_tail() {
let (hir, diags) = lower_hir("=== function f() ===\n~ return 1\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_block_has_unit_tail() {
let (block, diags) = lower_body("Hello, world!\n");
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
assert_eq!(*block.tail(), Tail::Unit);
}
#[test]
fn weave_choice_body_ending_in_divert_has_diverge_tail() {
let (hir, diags) =
lower_hir("== a ==\n* Choice.\n more text\n -> b\n== b ==\nDone.\n-> END\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 choice_body = &cs.choices[0].body;
assert!(
matches!(choice_body.stmts.last(), Some(Stmt::Divert(_))),
"expected the weave-folded content to end in the divert, got {:?}",
choice_body.stmts
);
assert!(
matches!(choice_body.tail(), Tail::Diverge(Terminator::Divert(_))),
"expected Diverge(Divert) tail, got {:?}",
choice_body.tail()
);
}
fn find_content_text<'a>(block: &'a Block, needle: &str) -> Option<&'a Content> {
for stmt in &block.stmts {
if let Stmt::Content(c) = stmt {
let text: String = c
.parts
.iter()
.filter_map(|p| match p {
ContentPart::Text(t) => Some(t.as_str()),
_ => None,
})
.collect();
if text.contains(needle) {
return Some(c);
}
}
}
None
}
#[test]
#[expect(
clippy::cast_possible_truncation,
reason = "test fixture byte offsets are a few dozen bytes, far below u32::MAX"
)]
fn branchless_first_arm_content_carries_a_real_content_span() {
let src = "\
=== start ===
{ Flag:
Some dialogue prose inside the arm.
- else:
Wait here.
}
-> DONE
";
let (hir, diags) = lower_hir(src);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let Stmt::Conditional(cond) = &hir.knots[0].body.stmts[0] else {
panic!("expected Conditional, got {:?}", hir.knots[0].body.stmts[0]);
};
assert_eq!(cond.branches.len(), 2, "implicit first arm + else arm");
let first_arm_content = find_content_text(&cond.branches[0].body, "Some dialogue")
.expect("the first arm's prose is a Content stmt");
let ptr = first_arm_content
.ptr
.expect("the first (branchless) arm's Content must carry a ptr — issue #981");
let expected_start = src
.find("Some dialogue")
.expect("fixture contains the text");
let expected = "Some dialogue prose inside the arm.";
assert_eq!(
ptr.text_range(),
TextRange::new(
(expected_start as u32).into(),
((expected_start + expected.len()) as u32).into()
),
"the arm's Content span must be byte-exact over its own prose, not the whole construct"
);
}
#[test]
#[expect(
clippy::cast_possible_truncation,
reason = "test fixture byte offsets are a few dozen bytes, far below u32::MAX"
)]
fn else_arm_content_carries_a_real_content_span() {
let src = "\
=== start ===
{ Flag:
Some dialogue prose inside the arm.
- else:
Wait here.
}
-> DONE
";
let (hir, diags) = lower_hir(src);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let Stmt::Conditional(cond) = &hir.knots[0].body.stmts[0] else {
panic!("expected Conditional, got {:?}", hir.knots[0].body.stmts[0]);
};
let else_arm_content = find_content_text(&cond.branches[1].body, "Wait here")
.expect("the else arm's prose is a Content stmt");
let ptr = else_arm_content
.ptr
.expect("the else arm's Content must carry a ptr — issue #981");
let expected_start = src.find("Wait here").expect("fixture contains the text");
let expected = "Wait here.";
assert_eq!(
ptr.text_range(),
TextRange::new(
(expected_start as u32).into(),
((expected_start + expected.len()) as u32).into()
),
"the else arm's Content span must be byte-exact over its own prose"
);
}
#[test]
#[expect(
clippy::cast_possible_truncation,
reason = "test fixture byte offsets are a few dozen bytes, far below u32::MAX"
)]
fn multiway_switch_branch_content_carries_a_real_content_span() {
let src = "\
=== start ===
VAR mood = 1
{ mood:
- 1:
Cheerful greeting here.
- 2:
Grumpy greeting here.
- else:
Neutral greeting here.
}
-> DONE
";
let (hir, diags) = lower_hir(src);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let Stmt::Conditional(cond) = &hir.knots[0].body.stmts[0] else {
panic!("expected Conditional, got {:?}", hir.knots[0].body.stmts[0]);
};
assert_eq!(cond.branches.len(), 3, "two valued branches + else");
for (needle, text) in [
("Cheerful greeting", "Cheerful greeting here."),
("Grumpy greeting", "Grumpy greeting here."),
("Neutral greeting", "Neutral greeting here."),
] {
let branch = cond
.branches
.iter()
.find(|b| find_content_text(&b.body, needle).is_some())
.unwrap_or_else(|| panic!("no branch contains {needle:?}"));
let content = find_content_text(&branch.body, needle).expect("just found above");
let ptr = content
.ptr
.unwrap_or_else(|| panic!("switch branch Content for {needle:?} must carry a ptr"));
let expected_start = src
.find(text)
.unwrap_or_else(|| panic!("fixture contains {text:?}"));
assert_eq!(
ptr.text_range(),
TextRange::new(
(expected_start as u32).into(),
((expected_start + text.len()) as u32).into()
),
"branch {needle:?}'s Content span must be byte-exact"
);
}
}
#[test]
#[expect(
clippy::cast_possible_truncation,
reason = "test fixture byte offsets are a few dozen bytes, far below u32::MAX"
)]
fn top_level_content_span_is_unchanged_by_the_arm_content_fix() {
let src = "=== start ===\nA greeting line.\n-> DONE\n";
let (hir, diags) = lower_hir(src);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let content = find_content_text(&hir.knots[0].body, "A greeting")
.expect("top-level prose is a Content stmt");
let ptr = content
.ptr
.expect("top-level Content already carried a ptr");
let expected_start = src
.find("A greeting line.")
.expect("fixture contains the line") as u32;
assert!(
ptr.text_range().contains_range(TextRange::new(
expected_start.into(),
(expected_start + "A greeting line.".len() as u32).into()
)),
"top-level Content span must still cover its own prose: {:?}",
ptr.text_range()
);
assert_ne!(
ptr.kind,
crate::KindToken::synthetic(crate::NodeClass::Content),
"top-level Content still resolves to a real CONTENT_LINE node, not a synthetic union"
);
}
#[test]
fn todo_lines_emit_e189_info_diagnostics() {
let source = "\
TODO: tighten the opening
Some prose.
=== start ===
TODO: minnie's letter needs a second pass
The rain had not stopped.
= letter
TODO: voice is too old
More prose.
";
let (_manifest, diags) = lower_full(source);
let todos: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagnosticCode::E189)
.collect();
assert_eq!(todos.len(), 3, "one E189 per TODO line: {todos:?}");
assert_eq!(todos[0].message, "TODO: tighten the opening");
assert_eq!(
todos[1].message,
"TODO: minnie's letter needs a second pass"
);
assert_eq!(todos[2].message, "TODO: voice is too old");
assert_eq!(DiagnosticCode::E189.severity(), Severity::Info);
}
#[test]
fn bare_todo_line_emits_e189_with_plain_message() {
let (_manifest, diags) = lower_full("TODO:\nSome prose.\n");
let todos: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagnosticCode::E189)
.collect();
assert_eq!(todos.len(), 1, "{todos:?}");
assert_eq!(todos[0].message, "TODO");
}
#[test]
fn db_road_split_lowering_emits_each_e189_exactly_once() {
let source = "\
TODO: top-level note
Some prose.
=== start ===
TODO: knot note
Content.
";
let parsed = parse(source);
let tree = parsed.tree();
let (_hir, _manifest, full_diags) = crate::hir::lower(FileId(0), &tree);
let full: Vec<_> = full_diags
.iter()
.filter(|d| d.code == DiagnosticCode::E189)
.map(|d| d.message.clone())
.collect();
let (_block, _knots, top_diags) = crate::hir::lower_top_level(FileId(0), &tree);
let mut split: Vec<_> = top_diags
.iter()
.filter(|d| d.code == DiagnosticCode::E189)
.map(|d| d.message.clone())
.collect();
for knot in tree.knots() {
let (_k, knot_diags) = crate::hir::lower_single_knot(FileId(0), &knot);
split.extend(
knot_diags
.iter()
.filter(|d| d.code == DiagnosticCode::E189)
.map(|d| d.message.clone()),
);
}
assert_eq!(full, vec!["TODO: top-level note", "TODO: knot note"]);
assert_eq!(split, full, "db-road split must match whole-file lowering");
}
#[test]
fn todo_inside_conditional_branch_emits_e189_and_no_prose_content() {
let source = "\
VAR x = true
{ x:
Then branch.
TODO: inside then branch
- else:
TODO: inside else branch
}
";
let parsed = parse(source);
let tree = parsed.tree();
let (hir, _manifest, diags) = crate::hir::lower(FileId(0), &tree);
let todos: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagnosticCode::E189)
.map(|d| d.message.clone())
.collect();
assert_eq!(
todos,
vec![
"TODO: inside then branch".to_owned(),
"TODO: inside else branch".to_owned(),
],
"one E189 per TODO line, both branches: {diags:?}"
);
let dump = format!("{:#?}", hir.root_content);
assert!(
!dump.contains("TODO"),
"TODO prose leaked into the lowered Block content:\n{dump}"
);
}
#[test]
fn todo_inside_nested_conditional_block_emits_e189_and_no_prose_content() {
let source = "\
VAR x = true
VAR y = false
{ x:
{ y:
Nested then.
TODO: inside nested then
- else:
TODO: inside nested else
}
- else:
Outer else.
}
";
let parsed = parse(source);
let tree = parsed.tree();
let (hir, _manifest, diags) = crate::hir::lower(FileId(0), &tree);
let todos: Vec<_> = diags
.iter()
.filter(|d| d.code == DiagnosticCode::E189)
.map(|d| d.message.clone())
.collect();
assert_eq!(
todos,
vec![
"TODO: inside nested then".to_owned(),
"TODO: inside nested else".to_owned(),
],
"one E189 per TODO line, nested block: {diags:?}"
);
let dump = format!("{:#?}", hir.root_content);
assert!(
!dump.contains("TODO"),
"TODO prose leaked into the lowered Block content:\n{dump}"
);
}