use std::collections::BTreeMap;
use brink_format::DefinitionId;
use brink_ir::hir::{
Block, ChoiceSet, CondKind, Conditional, Content, ContentPart, DivertTarget, Expr, HirFile,
Path, Sequence, Stmt, StringPart,
};
use brink_ir::{
BaseType, Constraint, Diagnostic, DiagnosticCode, DocBlock, ExternalKind, FileId,
SemanticTypeDef, SymbolIndex, SymbolInfo, SymbolKind, TypeRef,
};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ExternalCheckSeverity {
#[default]
Error,
Off,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SymbolMeta {
pub doc: Option<String>,
pub kind: ExternalKind,
pub returns: Option<ResolvedType>,
pub params: Vec<ResolvedParam>,
pub value: Option<ValueMeta>,
pub group_widgets: Vec<brink_ir::ArgGroupWidget>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValueMeta {
pub ty: Option<InferredType>,
pub value_text: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InferredType {
Int,
Float,
Bool,
String,
Divert,
List,
}
impl InferredType {
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::Int => "int",
Self::Float => "float",
Self::Bool => "bool",
Self::String => "string",
Self::Divert => "divert",
Self::List => "list",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedParam {
pub name: String,
pub ty: Option<ResolvedType>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedType {
pub name: String,
pub base: Option<BaseType>,
pub constraint: Option<Constraint>,
pub values: Option<brink_ir::ValueSource>,
pub widget: Option<brink_ir::WidgetDecl>,
}
pub fn analyze_externals(
index: &SymbolIndex,
inline_docs: &BTreeMap<(SymbolKind, String), DocBlock>,
types: &BTreeMap<String, SemanticTypeDef>,
registered: &BTreeMap<String, &brink_ir::ManifestExternal>,
severity: ExternalCheckSeverity,
) -> (BTreeMap<DefinitionId, SymbolMeta>, Vec<Diagnostic>) {
let mut metas: BTreeMap<DefinitionId, SymbolMeta> = BTreeMap::new();
let mut diags: Vec<Diagnostic> = Vec::new();
let mut externals: Vec<&SymbolInfo> = index
.symbols
.values()
.filter(|info| info.kind == SymbolKind::External)
.collect();
externals.sort_by_key(|info| (info.file.0, info.range.start()));
for info in externals {
let inline = inline_docs.get(&(SymbolKind::External, info.name.clone()));
let reg = registered.get(&info.name).copied();
if inline.is_none() && reg.is_none() {
continue; }
if let Some(reg) = reg
&& reg.params.len() != info.params.len()
{
diags.push(Diagnostic {
file: info.file,
range: info.range,
message: format!(
"{}: `{}` is declared with {} parameter(s) but the manifest lists {}",
DiagnosticCode::E039.title(),
info.name,
info.params.len(),
reg.params.len(),
),
code: DiagnosticCode::E039,
});
}
let mut params = Vec::with_capacity(info.params.len());
for (i, p) in info.params.iter().enumerate() {
let tref: Option<&TypeRef> = inline
.and_then(|d| d.params.iter().find(|(n, _)| n == &p.name).map(|(_, t)| t))
.or_else(|| reg.and_then(|r| r.params.get(i).map(|mp| &mp.ty)));
let ty = tref.and_then(|t| resolve_type(t, types, info, &mut diags));
params.push(ResolvedParam {
name: p.name.clone(),
ty,
});
}
let returns = inline
.and_then(|d| d.returns.as_ref())
.or_else(|| reg.map(|r| &r.returns))
.and_then(|t| resolve_type(t, types, info, &mut diags));
let kind = inline
.and_then(|d| d.kind)
.or_else(|| reg.map(|r| r.kind))
.unwrap_or_default();
let doc = inline
.and_then(|d| d.doc.clone())
.or_else(|| reg.and_then(|r| r.doc.clone()));
metas.insert(
info.id,
SymbolMeta {
doc,
kind,
returns,
params,
value: None,
group_widgets: reg.map(|r| r.widgets.clone()).unwrap_or_default(),
},
);
}
if severity == ExternalCheckSeverity::Off {
diags.clear();
}
(metas, diags)
}
pub fn enrich_callables(
index: &SymbolIndex,
inline_docs: &BTreeMap<(SymbolKind, String), DocBlock>,
types: &BTreeMap<String, SemanticTypeDef>,
severity: ExternalCheckSeverity,
) -> (BTreeMap<DefinitionId, SymbolMeta>, Vec<Diagnostic>) {
let mut metas: BTreeMap<DefinitionId, SymbolMeta> = BTreeMap::new();
let mut diags: Vec<Diagnostic> = Vec::new();
let mut callables: Vec<&SymbolInfo> = index
.symbols
.values()
.filter(|info| matches!(info.kind, SymbolKind::Knot | SymbolKind::Stitch))
.collect();
callables.sort_by_key(|info| (info.file.0, info.range.start()));
for info in callables {
let Some(inline) = inline_docs.get(&(info.kind, info.name.clone())) else {
continue;
};
let params = info
.params
.iter()
.map(|p| {
let tref = inline
.params
.iter()
.find(|(n, _)| n == &p.name)
.map(|(_, t)| t);
ResolvedParam {
name: p.name.clone(),
ty: tref.and_then(|t| resolve_type(t, types, info, &mut diags)),
}
})
.collect();
let returns = inline
.returns
.as_ref()
.and_then(|t| resolve_type(t, types, info, &mut diags));
metas.insert(
info.id,
SymbolMeta {
doc: inline.doc.clone(),
kind: ExternalKind::Plain,
returns,
params,
value: None,
group_widgets: Vec::new(),
},
);
}
if severity == ExternalCheckSeverity::Off {
diags.clear();
}
(metas, diags)
}
pub fn infer_value_meta(
files: &[(FileId, &HirFile)],
index: &SymbolIndex,
inline_docs: &BTreeMap<(SymbolKind, String), DocBlock>,
) -> BTreeMap<DefinitionId, SymbolMeta> {
let mut metas: BTreeMap<DefinitionId, SymbolMeta> = BTreeMap::new();
for &(_file_id, hir) in files {
for v in &hir.variables {
add_value_meta(
&mut metas,
index,
inline_docs,
SymbolKind::Variable,
&v.name.text,
Some(&v.value),
false,
);
}
for c in &hir.constants {
add_value_meta(
&mut metas,
index,
inline_docs,
SymbolKind::Constant,
&c.name.text,
Some(&c.value),
true,
);
}
for l in &hir.lists {
add_value_meta(
&mut metas,
index,
inline_docs,
SymbolKind::List,
&l.name.text,
None,
false,
);
}
}
metas
}
fn add_value_meta(
metas: &mut BTreeMap<DefinitionId, SymbolMeta>,
index: &SymbolIndex,
inline_docs: &BTreeMap<(SymbolKind, String), DocBlock>,
kind: SymbolKind,
name: &str,
init: Option<&Expr>,
show_value: bool,
) {
let doc = inline_docs
.get(&(kind, name.to_string()))
.and_then(|d| d.doc.clone());
let ty = init.and_then(infer_literal_type);
let value_text = if show_value {
init.and_then(literal_display)
} else {
None
};
if doc.is_none() && ty.is_none() && value_text.is_none() {
return;
}
let Some(id) = index.by_name.get(name).and_then(|ids| {
ids.iter()
.copied()
.find(|id| index.symbols.get(id).is_some_and(|s| s.kind == kind))
}) else {
return;
};
let value = (ty.is_some() || value_text.is_some()).then_some(ValueMeta { ty, value_text });
metas.insert(
id,
SymbolMeta {
doc,
kind: ExternalKind::Plain,
returns: None,
params: Vec::new(),
value,
group_widgets: Vec::new(),
},
);
}
fn infer_literal_type(expr: &Expr) -> Option<InferredType> {
match expr {
Expr::Int(_) => Some(InferredType::Int),
Expr::Float(_) => Some(InferredType::Float),
Expr::Bool(_) => Some(InferredType::Bool),
Expr::String(_) => Some(InferredType::String),
Expr::DivertTarget(_) => Some(InferredType::Divert),
Expr::ListLiteral(_) => Some(InferredType::List),
Expr::Prefix(brink_ir::hir::PrefixOp::Negate, inner) => match inner.as_ref() {
Expr::Int(_) | Expr::Float(_) => infer_literal_type(inner),
_ => None,
},
_ => None,
}
}
fn literal_display(expr: &Expr) -> Option<String> {
match expr {
Expr::Int(n) => Some(n.to_string()),
Expr::Float(f) => Some(float_display(f.to_f64())),
Expr::Bool(b) => Some(b.to_string()),
Expr::String(_) => plain_string_value(expr).map(|s| format!("\"{s}\"")),
Expr::DivertTarget(p) => Some(format!("-> {}", path_display(p))),
Expr::Prefix(brink_ir::hir::PrefixOp::Negate, inner) => match inner.as_ref() {
Expr::Int(_) | Expr::Float(_) => literal_display(inner).map(|s| format!("-{s}")),
_ => None,
},
_ => None,
}
}
fn float_display(v: f64) -> String {
let s = v.to_string();
if s.contains('.') || s.contains('e') || s.contains("inf") || s.contains("NaN") {
s
} else {
format!("{s}.0")
}
}
fn path_display(path: &Path) -> String {
path.segments
.iter()
.map(|n| n.text.as_str())
.collect::<Vec<_>>()
.join(".")
}
fn resolve_type(
t: &TypeRef,
types: &BTreeMap<String, SemanticTypeDef>,
info: &SymbolInfo,
diags: &mut Vec<Diagnostic>,
) -> Option<ResolvedType> {
if t.is_unspecified() {
return None;
}
if let Some(base) = t.as_base() {
return Some(ResolvedType {
name: t.0.clone(),
base: Some(base),
constraint: None,
values: None,
widget: None,
});
}
if let Some(def) = types.get(t.0.trim()) {
return Some(ResolvedType {
name: t.0.clone(),
base: Some(def.base),
constraint: def.constraint.clone(),
values: def.values.clone(),
widget: def.widget.clone(),
});
}
diags.push(Diagnostic {
file: info.file,
range: info.range,
message: format!(
"{}: `{}` (on `{}`)",
DiagnosticCode::E040.title(),
t.0.trim(),
info.name,
),
code: DiagnosticCode::E040,
});
Some(ResolvedType {
name: t.0.clone(),
base: None,
constraint: None,
values: None,
widget: None,
})
}
pub fn check_call_sites(
files: &[(FileId, &HirFile)],
name_to_meta: &BTreeMap<&str, &SymbolMeta>,
) -> Vec<Diagnostic> {
let mut diags = Vec::new();
if name_to_meta.is_empty() {
return diags;
}
for &(file_id, hir) in files {
let mut visit = |path: &Path, args: &[Expr]| {
check_call(file_id, path, args, name_to_meta, &mut diags);
};
walk_block(&hir.root_content, &mut visit);
for knot in &hir.knots {
walk_block(&knot.body, &mut visit);
for stitch in &knot.stitches {
walk_block(&stitch.body, &mut visit);
}
}
}
diags
}
fn walk_block(block: &Block, visit: &mut dyn FnMut(&Path, &[Expr])) {
for stmt in &block.stmts {
walk_stmt(stmt, visit);
}
}
fn walk_stmt(stmt: &Stmt, visit: &mut dyn FnMut(&Path, &[Expr])) {
match stmt {
Stmt::Content(c) => walk_content(c, visit),
Stmt::Divert(d) => walk_target(&d.target, visit),
Stmt::TunnelCall(t) => {
for target in &t.targets {
walk_target(target, visit);
}
}
Stmt::ThreadStart(t) => walk_target(&t.target, visit),
Stmt::TempDecl(t) => {
if let Some(e) = &t.value {
walk_expr(e, visit);
}
}
Stmt::Assignment(a) => walk_expr(&a.value, visit),
Stmt::Return(r) => {
if let Some(e) = &r.value {
walk_expr(e, visit);
}
for e in &r.onwards_args {
walk_expr(e, visit);
}
}
Stmt::ChoiceSet(cs) => walk_choice_set(cs, visit),
Stmt::LabeledBlock(b) => walk_block(b, visit),
Stmt::Conditional(c) => walk_conditional(c, visit),
Stmt::Sequence(s) => walk_sequence(s, visit),
Stmt::ExprStmt(e) => walk_expr(e, visit),
Stmt::EndOfLine => {}
}
}
fn walk_target(target: &DivertTarget, visit: &mut dyn FnMut(&Path, &[Expr])) {
for e in &target.args {
walk_expr(e, visit);
}
}
fn walk_content(content: &Content, visit: &mut dyn FnMut(&Path, &[Expr])) {
for part in &content.parts {
match part {
ContentPart::Interpolation(e) => walk_expr(e, visit),
ContentPart::InlineConditional(c) => walk_conditional(c, visit),
ContentPart::InlineSequence(s) => walk_sequence(s, visit),
ContentPart::Text(_) | ContentPart::Glue | ContentPart::Spring => {}
}
}
}
fn walk_conditional(cond: &Conditional, visit: &mut dyn FnMut(&Path, &[Expr])) {
if let CondKind::Switch(e) = &cond.kind {
walk_expr(e, visit);
}
for branch in &cond.branches {
if let Some(e) = &branch.condition {
walk_expr(e, visit);
}
walk_block(&branch.body, visit);
}
}
fn walk_sequence(seq: &Sequence, visit: &mut dyn FnMut(&Path, &[Expr])) {
for branch in &seq.branches {
walk_block(branch, visit);
}
}
fn walk_choice_set(cs: &ChoiceSet, visit: &mut dyn FnMut(&Path, &[Expr])) {
for choice in &cs.choices {
if let Some(e) = &choice.condition {
walk_expr(e, visit);
}
for content in [
&choice.start_content,
&choice.bracket_content,
&choice.inner_content,
]
.into_iter()
.flatten()
{
walk_content(content, visit);
}
walk_block(&choice.body, visit);
}
walk_block(&cs.continuation, visit);
}
fn walk_expr(expr: &Expr, visit: &mut dyn FnMut(&Path, &[Expr])) {
match expr {
Expr::Call(path, args) => {
visit(path, args);
for arg in args {
walk_expr(arg, visit);
}
}
Expr::Prefix(_, inner) | Expr::Postfix(inner, _) => walk_expr(inner, visit),
Expr::Infix(lhs, _, rhs) => {
walk_expr(lhs, visit);
walk_expr(rhs, visit);
}
Expr::String(s) => {
for part in &s.parts {
if let StringPart::Interpolation(e) = part {
walk_expr(e, visit);
}
}
}
Expr::Int(_)
| Expr::Float(_)
| Expr::Bool(_)
| Expr::Null
| Expr::Path(_)
| Expr::DivertTarget(_)
| Expr::ListLiteral(_) => {}
}
}
fn check_call(
file: FileId,
path: &Path,
args: &[Expr],
name_to_meta: &BTreeMap<&str, &SymbolMeta>,
diags: &mut Vec<Diagnostic>,
) {
let name = path
.segments
.iter()
.map(|n| n.text.as_str())
.collect::<Vec<_>>()
.join(".");
let Some(meta) = name_to_meta.get(name.as_str()) else {
return; };
for (i, arg) in args.iter().enumerate() {
let Some(param) = meta.params.get(i) else {
continue; };
let Some(ty) = ¶m.ty else {
continue; };
check_literal_arg(file, path, &name, arg, ty, diags);
}
}
fn check_literal_arg(
file: FileId,
path: &Path,
call: &str,
arg: &Expr,
ty: &ResolvedType,
diags: &mut Vec<Diagnostic>,
) {
if let (Some(lit), Some(expected)) = (literal_base(arg), ty.base)
&& !compatible(lit, expected)
{
diags.push(Diagnostic {
file,
range: path.range,
message: format!(
"{}: `{call}` expects {} but a {} literal was passed",
DiagnosticCode::E041.title(),
base_name(expected),
base_name(lit),
),
code: DiagnosticCode::E041,
});
return;
}
if let Some(constraint) = &ty.constraint {
check_constraint(file, path, call, &ty.name, arg, constraint, diags);
}
}
fn literal_base(expr: &Expr) -> Option<BaseType> {
match expr {
Expr::Int(_) => Some(BaseType::Int),
Expr::Float(_) => Some(BaseType::Float),
Expr::Bool(_) => Some(BaseType::Bool),
Expr::String(_) => Some(BaseType::String),
_ => None,
}
}
fn compatible(lit: BaseType, expected: BaseType) -> bool {
lit == expected || (lit == BaseType::Int && expected == BaseType::Float)
}
fn base_name(base: BaseType) -> &'static str {
match base {
BaseType::String => "string",
BaseType::Int => "int",
BaseType::Float => "float",
BaseType::Bool => "bool",
BaseType::Void => "void",
}
}
#[expect(
clippy::cast_precision_loss,
reason = "range bounds are small integers; f64 comparison is exact in practice"
)]
fn check_constraint(
file: FileId,
path: &Path,
call: &str,
type_name: &str,
arg: &Expr,
constraint: &Constraint,
diags: &mut Vec<Diagnostic>,
) {
match constraint {
Constraint::Enum { values } => {
if let Some(s) = plain_string_value(arg)
&& !values.iter().any(|v| v == s)
{
diags.push(Diagnostic {
file,
range: path.range,
message: format!(
"{}: `{s}` is not a valid `{type_name}` value for `{call}`",
DiagnosticCode::E042.title(),
),
code: DiagnosticCode::E042,
});
}
}
Constraint::Range { min, max } => {
if let Some(v) = numeric_value(arg)
&& (min.is_some_and(|m| v < m as f64) || max.is_some_and(|m| v > m as f64))
{
diags.push(Diagnostic {
file,
range: path.range,
message: format!(
"{}: value out of range for `{type_name}` on `{call}`",
DiagnosticCode::E042.title(),
),
code: DiagnosticCode::E042,
});
}
}
Constraint::Regex { .. } => {}
}
}
fn plain_string_value(expr: &Expr) -> Option<&str> {
let Expr::String(s) = expr else { return None };
match s.parts.as_slice() {
[] => Some(""),
[StringPart::Literal(text)] => Some(text),
_ => None, }
}
fn numeric_value(expr: &Expr) -> Option<f64> {
match expr {
Expr::Int(n) => Some(f64::from(*n)),
Expr::Float(f) => Some(f.to_f64()),
_ => None,
}
}
#[cfg(test)]
#[expect(clippy::cast_possible_truncation, reason = "test helper ranges")]
mod tests {
use brink_ir::{
DeclaredSymbol, DocBlock, ManifestExternal, ManifestParam, ParamInfo, SemanticTypeDef,
SymbolManifest, TypeRef,
};
use brink_ir::{DiagnosticCode, FileId};
use rowan::{TextRange, TextSize};
use super::*;
use crate::manifest::merge_manifests;
fn index_with_external(name: &str, params: &[&str]) -> SymbolIndex {
let mut m = SymbolManifest::default();
m.externals.push(DeclaredSymbol {
name: name.to_string(),
range: TextRange::new(TextSize::new(0), TextSize::new(name.len() as u32)),
params: params
.iter()
.map(|n| ParamInfo {
name: (*n).to_string(),
is_ref: false,
is_divert: false,
})
.collect(),
detail: None,
});
merge_manifests(&[(FileId(0), &m)]).0
}
fn meta_for<'a>(
metas: &'a BTreeMap<DefinitionId, SymbolMeta>,
index: &SymbolIndex,
name: &str,
) -> &'a SymbolMeta {
let id = index
.symbols
.values()
.find(|s| s.kind == SymbolKind::External && s.name == name)
.expect("external in index")
.id;
metas.get(&id).expect("meta for external")
}
fn inline(
params: &[(&str, &str)],
returns: Option<&str>,
kind: Option<ExternalKind>,
) -> DocBlock {
DocBlock {
doc: None,
params: params
.iter()
.map(|(n, t)| ((*n).to_string(), TypeRef((*t).to_string())))
.collect(),
returns: returns.map(|t| TypeRef(t.to_string())),
kind,
}
}
#[test]
fn inline_doc_enriches_meta() {
let index = index_with_external("has", &["item"]);
let mut docs = BTreeMap::new();
docs.insert(
(SymbolKind::External, "has".to_string()),
inline(&[("item", "bool")], Some("bool"), Some(ExternalKind::Query)),
);
let (metas, diags) = analyze_externals(
&index,
&docs,
&BTreeMap::new(),
&BTreeMap::new(),
ExternalCheckSeverity::Error,
);
assert!(diags.is_empty(), "no diags: {diags:?}");
let meta = meta_for(&metas, &index, "has");
assert_eq!(meta.kind, ExternalKind::Query);
assert_eq!(
meta.returns.as_ref().and_then(|t| t.base),
Some(BaseType::Bool)
);
assert_eq!(
meta.params[0].ty.as_ref().and_then(|t| t.base),
Some(BaseType::Bool)
);
}
#[test]
fn registered_enriches_when_no_inline() {
let index = index_with_external("grant", &["item"]);
let reg_ext = ManifestExternal {
name: "grant".to_string(),
params: vec![ManifestParam {
name: "item".to_string(),
ty: TypeRef("string".to_string()),
}],
returns: TypeRef("void".to_string()),
kind: ExternalKind::Effect,
doc: Some("Grant an item.".to_string()),
widgets: vec![],
};
let mut registered = BTreeMap::new();
registered.insert("grant".to_string(), ®_ext);
let (metas, _) = analyze_externals(
&index,
&BTreeMap::new(),
&BTreeMap::new(),
®istered,
ExternalCheckSeverity::Error,
);
let meta = meta_for(&metas, &index, "grant");
assert_eq!(meta.kind, ExternalKind::Effect);
assert_eq!(meta.doc.as_deref(), Some("Grant an item."));
assert_eq!(
meta.params[0].ty.as_ref().and_then(|t| t.base),
Some(BaseType::String)
);
}
#[test]
fn inline_wins_over_registered() {
let index = index_with_external("has", &["item"]);
let reg_ext = ManifestExternal {
name: "has".to_string(),
params: vec![ManifestParam {
name: "item".to_string(),
ty: TypeRef("int".to_string()),
}],
returns: TypeRef("int".to_string()),
kind: ExternalKind::Effect,
doc: None,
widgets: vec![],
};
let mut registered = BTreeMap::new();
registered.insert("has".to_string(), ®_ext);
let mut docs = BTreeMap::new();
docs.insert(
(SymbolKind::External, "has".to_string()),
inline(&[("item", "bool")], Some("bool"), Some(ExternalKind::Query)),
);
let (metas, _) = analyze_externals(
&index,
&docs,
&BTreeMap::new(),
®istered,
ExternalCheckSeverity::Error,
);
let meta = meta_for(&metas, &index, "has");
assert_eq!(meta.kind, ExternalKind::Query, "inline @kind wins");
assert_eq!(
meta.params[0].ty.as_ref().and_then(|t| t.base),
Some(BaseType::Bool)
);
}
#[test]
fn semantic_type_resolves_constraint() {
let index = index_with_external("give", &["item"]);
let mut types = BTreeMap::new();
types.insert(
"item_id".to_string(),
SemanticTypeDef {
name: "item_id".to_string(),
base: BaseType::String,
constraint: Some(Constraint::Enum {
values: vec!["sword".into(), "shield".into()],
}),
values: None,
widget: None,
},
);
let mut docs = BTreeMap::new();
docs.insert(
(SymbolKind::External, "give".to_string()),
inline(&[("item", "item_id")], None, None),
);
let (metas, diags) = analyze_externals(
&index,
&docs,
&types,
&BTreeMap::new(),
ExternalCheckSeverity::Error,
);
assert!(diags.is_empty(), "known semantic type: {diags:?}");
let ty = meta_for(&metas, &index, "give").params[0]
.ty
.clone()
.unwrap();
assert_eq!(ty.base, Some(BaseType::String));
assert!(matches!(ty.constraint, Some(Constraint::Enum { .. })));
}
#[test]
fn unknown_semantic_type_emits_e040() {
let index = index_with_external("foo", &["x"]);
let mut docs = BTreeMap::new();
docs.insert(
(SymbolKind::External, "foo".to_string()),
inline(&[("x", "bogus")], None, None),
);
let (metas, diags) = analyze_externals(
&index,
&docs,
&BTreeMap::new(),
&BTreeMap::new(),
ExternalCheckSeverity::Error,
);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagnosticCode::E040);
assert!(
meta_for(&metas, &index, "foo").params[0]
.ty
.as_ref()
.unwrap()
.base
.is_none()
);
}
#[test]
fn arity_disagreement_emits_e039() {
let index = index_with_external("has", &["item"]); let reg_ext = ManifestExternal {
name: "has".to_string(),
params: vec![
ManifestParam {
name: "item".to_string(),
ty: TypeRef("string".to_string()),
},
ManifestParam {
name: "qty".to_string(),
ty: TypeRef("int".to_string()),
},
],
returns: TypeRef::default(),
kind: ExternalKind::default(),
doc: None,
widgets: vec![],
};
let mut registered = BTreeMap::new();
registered.insert("has".to_string(), ®_ext);
let (_metas, diags) = analyze_externals(
&index,
&BTreeMap::new(),
&BTreeMap::new(),
®istered,
ExternalCheckSeverity::Error,
);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagnosticCode::E039);
}
#[test]
fn severity_off_suppresses_diagnostics_but_keeps_meta() {
let index = index_with_external("foo", &["x"]);
let mut docs = BTreeMap::new();
docs.insert(
(SymbolKind::External, "foo".to_string()),
inline(&[("x", "bogus")], None, None),
);
let (metas, diags) = analyze_externals(
&index,
&docs,
&BTreeMap::new(),
&BTreeMap::new(),
ExternalCheckSeverity::Off,
);
assert!(diags.is_empty(), "Off suppresses diagnostics");
assert!(!metas.is_empty(), "enrichment still built when Off");
}
fn index_with_callables() -> SymbolIndex {
let mut m = SymbolManifest::default();
m.knots.push(DeclaredSymbol {
name: "damage".to_string(),
range: TextRange::new(TextSize::new(0), TextSize::new(6)),
params: vec![ParamInfo {
name: "weapon".to_string(),
is_ref: false,
is_divert: false,
}],
detail: Some("function".to_string()),
});
m.stitches.push(DeclaredSymbol {
name: "hub.market".to_string(),
range: TextRange::new(TextSize::new(10), TextSize::new(16)),
params: Vec::new(),
detail: None,
});
merge_manifests(&[(FileId(0), &m)]).0
}
fn meta_for_kind<'a>(
metas: &'a BTreeMap<DefinitionId, SymbolMeta>,
index: &SymbolIndex,
kind: SymbolKind,
name: &str,
) -> &'a SymbolMeta {
let id = index
.symbols
.values()
.find(|s| s.kind == kind && s.name == name)
.expect("symbol in index")
.id;
metas.get(&id).expect("meta for symbol")
}
#[test]
fn knot_doc_enriches_meta_with_resolved_types() {
let index = index_with_callables();
let mut docs = BTreeMap::new();
docs.insert(
(SymbolKind::Knot, "damage".to_string()),
DocBlock {
doc: Some("Damage roll.".to_string()),
params: vec![("weapon".to_string(), TypeRef("item_id".to_string()))],
returns: Some(TypeRef("int".to_string())),
kind: None,
},
);
let mut types = BTreeMap::new();
types.insert(
"item_id".to_string(),
SemanticTypeDef {
name: "item_id".to_string(),
base: BaseType::String,
constraint: None,
values: None,
widget: None,
},
);
let (metas, diags) = enrich_callables(&index, &docs, &types, ExternalCheckSeverity::Error);
assert!(diags.is_empty(), "known types: {diags:?}");
let meta = meta_for_kind(&metas, &index, SymbolKind::Knot, "damage");
assert_eq!(meta.doc.as_deref(), Some("Damage roll."));
assert_eq!(meta.kind, ExternalKind::Plain);
assert_eq!(
meta.params[0].ty.as_ref().and_then(|t| t.base),
Some(BaseType::String)
);
assert_eq!(
meta.returns.as_ref().and_then(|t| t.base),
Some(BaseType::Int)
);
}
#[test]
fn stitch_doc_keyed_by_qualified_name() {
let index = index_with_callables();
let mut docs = BTreeMap::new();
docs.insert(
(SymbolKind::Stitch, "hub.market".to_string()),
DocBlock {
doc: Some("The market square.".to_string()),
params: Vec::new(),
returns: None,
kind: None,
},
);
let (metas, diags) = enrich_callables(
&index,
&docs,
&BTreeMap::new(),
ExternalCheckSeverity::Error,
);
assert!(diags.is_empty());
let meta = meta_for_kind(&metas, &index, SymbolKind::Stitch, "hub.market");
assert_eq!(meta.doc.as_deref(), Some("The market square."));
}
#[test]
fn unknown_semantic_type_on_knot_emits_e040() {
let index = index_with_callables();
let mut docs = BTreeMap::new();
docs.insert(
(SymbolKind::Knot, "damage".to_string()),
DocBlock {
doc: None,
params: vec![("weapon".to_string(), TypeRef("bogus".to_string()))],
returns: None,
kind: None,
},
);
let (metas, diags) = enrich_callables(
&index,
&docs,
&BTreeMap::new(),
ExternalCheckSeverity::Error,
);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagnosticCode::E040);
let meta = meta_for_kind(&metas, &index, SymbolKind::Knot, "damage");
assert!(meta.params[0].ty.as_ref().is_some_and(|t| t.base.is_none()));
let (metas, diags) =
enrich_callables(&index, &docs, &BTreeMap::new(), ExternalCheckSeverity::Off);
assert!(diags.is_empty());
assert!(!metas.is_empty());
}
#[test]
fn undocumented_callables_get_no_meta() {
let index = index_with_callables();
let (metas, diags) = enrich_callables(
&index,
&BTreeMap::new(),
&BTreeMap::new(),
ExternalCheckSeverity::Error,
);
assert!(metas.is_empty());
assert!(diags.is_empty());
}
fn analyze_source(src: &str) -> crate::AnalysisResult {
let parsed = brink_syntax::parse(src);
let tree = parsed.tree();
let (hir, manifest, diags) = brink_ir::hir::lower(FileId(0), &tree);
assert!(diags.is_empty(), "lowering diagnostics: {diags:?}");
crate::analyze(&[(FileId(0), &hir, &manifest)])
}
fn meta_by_name<'a>(
result: &'a crate::AnalysisResult,
kind: SymbolKind,
name: &str,
) -> &'a SymbolMeta {
let id = result
.index
.symbols
.values()
.find(|s| s.kind == kind && s.name == name)
.expect("symbol in index")
.id;
result.symbol_meta.get(&id).expect("meta for symbol")
}
#[test]
fn var_initializer_types_are_inferred() {
let result = analyze_source(
"VAR health = 100\nVAR speed = 0.5\nVAR alive = true\nVAR name = \"Ada\"\n",
);
let ty = |name: &str| {
meta_by_name(&result, SymbolKind::Variable, name)
.value
.as_ref()
.expect("value meta")
.ty
};
assert_eq!(ty("health"), Some(InferredType::Int));
assert_eq!(ty("speed"), Some(InferredType::Float));
assert_eq!(ty("alive"), Some(InferredType::Bool));
assert_eq!(ty("name"), Some(InferredType::String));
assert!(
meta_by_name(&result, SymbolKind::Variable, "health")
.value
.as_ref()
.is_some_and(|v| v.value_text.is_none())
);
}
#[test]
fn const_gets_type_and_display_value() {
let result = analyze_source(
"CONST SPEED = 0.5\nCONST LIVES = -3\nCONST NAME = \"Ada\"\nCONST WHOLE = 1.0\n",
);
let value = |name: &str| {
meta_by_name(&result, SymbolKind::Constant, name)
.value
.clone()
.expect("value meta")
};
assert_eq!(value("SPEED").ty, Some(InferredType::Float));
assert_eq!(value("SPEED").value_text.as_deref(), Some("0.5"));
assert_eq!(value("LIVES").ty, Some(InferredType::Int));
assert_eq!(value("LIVES").value_text.as_deref(), Some("-3"));
assert_eq!(value("NAME").value_text.as_deref(), Some("\"Ada\""));
assert_eq!(
value("WHOLE").value_text.as_deref(),
Some("1.0"),
"whole floats keep a trailing .0"
);
}
#[test]
fn docs_attach_to_values_and_lists() {
let result = analyze_source(
"/// Player health.\nVAR health = 100\n/// Mood states.\nLIST mood = happy, sad\n",
);
assert_eq!(
meta_by_name(&result, SymbolKind::Variable, "health")
.doc
.as_deref(),
Some("Player health.")
);
let list_meta = meta_by_name(&result, SymbolKind::List, "mood");
assert_eq!(list_meta.doc.as_deref(), Some("Mood states."));
assert!(list_meta.value.is_none(), "lists carry docs only");
}
#[test]
fn divert_target_initializer_infers_divert() {
let result = analyze_source("VAR exit = -> hub\n== hub ==\ntext\n-> DONE\n");
assert_eq!(
meta_by_name(&result, SymbolKind::Variable, "exit")
.value
.as_ref()
.and_then(|v| v.ty),
Some(InferredType::Divert)
);
}
use brink_ir::hir::{
Block, Expr, HirFile, Name, Path as HirPath, Stmt, StringExpr, StringPart,
};
fn rng() -> TextRange {
TextRange::new(TextSize::new(0), TextSize::new(1))
}
fn hir_calling(name: &str, args: Vec<Expr>) -> HirFile {
let path = HirPath {
segments: vec![Name {
text: name.to_string(),
range: rng(),
}],
range: rng(),
};
HirFile {
root_content: Block {
label: None,
stmts: vec![Stmt::ExprStmt(Expr::Call(path, args))],
container_id: None,
},
knots: Vec::new(),
variables: Vec::new(),
constants: Vec::new(),
lists: Vec::new(),
externals: Vec::new(),
includes: Vec::new(),
}
}
fn typed_meta(ty: ResolvedType) -> SymbolMeta {
SymbolMeta {
doc: None,
kind: ExternalKind::default(),
returns: None,
params: vec![ResolvedParam {
name: "x".to_string(),
ty: Some(ty),
}],
value: None,
group_widgets: Vec::new(),
}
}
fn run_call_check(call: &str, args: Vec<Expr>, meta: &SymbolMeta) -> Vec<Diagnostic> {
let hir = hir_calling(call, args);
let mut n2m: BTreeMap<&str, &SymbolMeta> = BTreeMap::new();
n2m.insert(call, meta);
check_call_sites(&[(FileId(0), &hir)], &n2m)
}
fn string_lit(s: &str) -> Expr {
Expr::String(StringExpr {
parts: vec![StringPart::Literal(s.to_string())],
})
}
#[test]
fn type_mismatch_emits_e041() {
let meta = typed_meta(ResolvedType {
name: "string".to_string(),
base: Some(BaseType::String),
constraint: None,
values: None,
widget: None,
});
let diags = run_call_check("tint", vec![Expr::Int(5)], &meta);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, DiagnosticCode::E041);
}
#[test]
fn matching_literal_no_diagnostic() {
let meta = typed_meta(ResolvedType {
name: "string".to_string(),
base: Some(BaseType::String),
constraint: None,
values: None,
widget: None,
});
let diags = run_call_check("tint", vec![string_lit("ok")], &meta);
assert!(diags.is_empty(), "matching string literal: {diags:?}");
}
#[test]
fn int_widens_to_float_param() {
let meta = typed_meta(ResolvedType {
name: "float".to_string(),
base: Some(BaseType::Float),
constraint: None,
values: None,
widget: None,
});
let diags = run_call_check("scale", vec![Expr::Int(3)], &meta);
assert!(
diags.is_empty(),
"int literal accepted for float param: {diags:?}"
);
}
#[test]
fn non_literal_arg_skipped() {
let meta = typed_meta(ResolvedType {
name: "string".to_string(),
base: Some(BaseType::String),
constraint: None,
values: None,
widget: None,
});
let var = Expr::Path(HirPath {
segments: vec![Name {
text: "v".to_string(),
range: rng(),
}],
range: rng(),
});
let diags = run_call_check("tint", vec![var], &meta);
assert!(
diags.is_empty(),
"non-literal arg is not checked: {diags:?}"
);
}
#[test]
fn enum_violation_emits_e042() {
let meta = typed_meta(ResolvedType {
name: "item_id".to_string(),
base: Some(BaseType::String),
constraint: Some(Constraint::Enum {
values: vec!["sword".into(), "shield".into()],
}),
values: None,
widget: None,
});
let bad = run_call_check("give", vec![string_lit("banana")], &meta);
assert_eq!(bad.len(), 1);
assert_eq!(bad[0].code, DiagnosticCode::E042);
let ok = run_call_check("give", vec![string_lit("sword")], &meta);
assert!(ok.is_empty(), "valid enum value: {ok:?}");
}
#[test]
fn range_violation_emits_e042() {
let meta = typed_meta(ResolvedType {
name: "percent".to_string(),
base: Some(BaseType::Int),
constraint: Some(Constraint::Range {
min: Some(0),
max: Some(100),
}),
values: None,
widget: None,
});
let bad = run_call_check("set", vec![Expr::Int(150)], &meta);
assert_eq!(bad.len(), 1);
assert_eq!(bad[0].code, DiagnosticCode::E042);
let ok = run_call_check("set", vec![Expr::Int(50)], &meta);
assert!(ok.is_empty(), "in-range value: {ok:?}");
}
}