#![allow(clippy::panic)]
use brink_syntax::ast::AstNode;
use brink_syntax::parse;
use rowan::TextRange;
use crate::hir::lower::{
BodyChild, DeclareSymbols, 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>, SymbolManifest) {
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 (manifest, diagnostics) = sink.finish();
(block, diagnostics, manifest)
}
struct TestSink {
diagnostics: Vec<(TextRange, DiagnosticCode)>,
symbols: Vec<(SymbolKind, String)>,
}
impl TestSink {
fn new() -> Self {
Self {
diagnostics: Vec::new(),
symbols: 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()
}
fn declare_full(
&mut self,
kind: SymbolKind,
name: &str,
_range: TextRange,
_params: Vec<ParamInfo>,
_detail: Option<String>,
_doc: Option<DocBlock>,
) {
self.symbols.push((kind, name.to_string()));
}
fn add_local(&mut self, _local: crate::symbols::LocalSymbol) {}
fn add_unresolved(
&mut self,
_path: &str,
_range: TextRange,
_kind: crate::symbols::RefKind,
_scope: &Scope,
_arg_count: Option<usize>,
) {
}
}
#[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(lhs, InfixOp::Add, rhs))
if matches!(lhs.as_ref(), Expr::Int(3))
&& matches!(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 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 mock_sink_records_symbol_declarations() {
let parsed = parse("VAR x = 5\n");
let tree = parsed.tree();
let scope = make_scope();
let mut sink = TestSink::new();
for node in tree.syntax().descendants() {
if let Some(var) = brink_syntax::ast::VarDecl::cast(node) {
let _ = var.declare_and_lower(&scope, &mut sink);
}
}
assert!(
sink.symbols
.iter()
.any(|(kind, name)| *kind == SymbolKind::Variable && name == "x"),
"expected variable 'x' in mock sink, got: {:?}",
sink.symbols
);
}
#[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 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());
}