1use std::collections::BTreeMap;
16
17use brink_format::DefinitionId;
18use brink_ir::hir::{
19 Block, ChoiceSet, CondKind, Conditional, Content, ContentPart, DivertTarget, Expr, HirFile,
20 Path, Sequence, Stmt, StringPart,
21};
22use brink_ir::{
23 BaseType, Constraint, Diagnostic, DiagnosticCode, DocBlock, ExternalKind, FileId,
24 SemanticTypeDef, SymbolIndex, SymbolInfo, SymbolKind, TypeRef,
25};
26
27#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
30pub enum ExternalCheckSeverity {
31 #[default]
33 Error,
34 Off,
36}
37
38#[derive(Debug, Clone, Default, PartialEq, Eq)]
44pub struct SymbolMeta {
45 pub doc: Option<String>,
47 pub kind: ExternalKind,
50 pub returns: Option<ResolvedType>,
52 pub params: Vec<ResolvedParam>,
54 pub value: Option<ValueMeta>,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct ValueMeta {
63 pub ty: Option<InferredType>,
65 pub value_text: Option<String>,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum InferredType {
74 Int,
75 Float,
76 Bool,
77 String,
78 Divert,
79 List,
80}
81
82impl InferredType {
83 #[must_use]
85 pub fn name(self) -> &'static str {
86 match self {
87 Self::Int => "int",
88 Self::Float => "float",
89 Self::Bool => "bool",
90 Self::String => "string",
91 Self::Divert => "divert",
92 Self::List => "list",
93 }
94 }
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct ResolvedParam {
100 pub name: String,
101 pub ty: Option<ResolvedType>,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct ResolvedType {
108 pub name: String,
110 pub base: Option<BaseType>,
112 pub constraint: Option<Constraint>,
114 pub values: Option<brink_ir::ValueSource>,
117}
118
119pub fn analyze_externals(
123 index: &SymbolIndex,
124 inline_docs: &BTreeMap<(SymbolKind, String), DocBlock>,
125 types: &BTreeMap<String, SemanticTypeDef>,
126 registered: &BTreeMap<String, &brink_ir::ManifestExternal>,
127 severity: ExternalCheckSeverity,
128) -> (BTreeMap<DefinitionId, SymbolMeta>, Vec<Diagnostic>) {
129 let mut metas: BTreeMap<DefinitionId, SymbolMeta> = BTreeMap::new();
130 let mut diags: Vec<Diagnostic> = Vec::new();
131
132 let mut externals: Vec<&SymbolInfo> = index
134 .symbols
135 .values()
136 .filter(|info| info.kind == SymbolKind::External)
137 .collect();
138 externals.sort_by_key(|info| (info.file.0, info.range.start()));
139
140 for info in externals {
141 let inline = inline_docs.get(&(SymbolKind::External, info.name.clone()));
142 let reg = registered.get(&info.name).copied();
143 if inline.is_none() && reg.is_none() {
144 continue; }
146
147 if let Some(reg) = reg
149 && reg.params.len() != info.params.len()
150 {
151 diags.push(Diagnostic {
152 file: info.file,
153 range: info.range,
154 message: format!(
155 "{}: `{}` is declared with {} parameter(s) but the manifest lists {}",
156 DiagnosticCode::E039.title(),
157 info.name,
158 info.params.len(),
159 reg.params.len(),
160 ),
161 code: DiagnosticCode::E039,
162 });
163 }
164
165 let mut params = Vec::with_capacity(info.params.len());
167 for (i, p) in info.params.iter().enumerate() {
168 let tref: Option<&TypeRef> = inline
169 .and_then(|d| d.params.iter().find(|(n, _)| n == &p.name).map(|(_, t)| t))
170 .or_else(|| reg.and_then(|r| r.params.get(i).map(|mp| &mp.ty)));
171 let ty = tref.and_then(|t| resolve_type(t, types, info, &mut diags));
172 params.push(ResolvedParam {
173 name: p.name.clone(),
174 ty,
175 });
176 }
177
178 let returns = inline
180 .and_then(|d| d.returns.as_ref())
181 .or_else(|| reg.map(|r| &r.returns))
182 .and_then(|t| resolve_type(t, types, info, &mut diags));
183 let kind = inline
184 .and_then(|d| d.kind)
185 .or_else(|| reg.map(|r| r.kind))
186 .unwrap_or_default();
187 let doc = inline
188 .and_then(|d| d.doc.clone())
189 .or_else(|| reg.and_then(|r| r.doc.clone()));
190
191 metas.insert(
192 info.id,
193 SymbolMeta {
194 doc,
195 kind,
196 returns,
197 params,
198 value: None,
199 },
200 );
201 }
202
203 if severity == ExternalCheckSeverity::Off {
204 diags.clear();
205 }
206 (metas, diags)
207}
208
209pub fn enrich_callables(
215 index: &SymbolIndex,
216 inline_docs: &BTreeMap<(SymbolKind, String), DocBlock>,
217 types: &BTreeMap<String, SemanticTypeDef>,
218 severity: ExternalCheckSeverity,
219) -> (BTreeMap<DefinitionId, SymbolMeta>, Vec<Diagnostic>) {
220 let mut metas: BTreeMap<DefinitionId, SymbolMeta> = BTreeMap::new();
221 let mut diags: Vec<Diagnostic> = Vec::new();
222
223 let mut callables: Vec<&SymbolInfo> = index
225 .symbols
226 .values()
227 .filter(|info| matches!(info.kind, SymbolKind::Knot | SymbolKind::Stitch))
228 .collect();
229 callables.sort_by_key(|info| (info.file.0, info.range.start()));
230
231 for info in callables {
232 let Some(inline) = inline_docs.get(&(info.kind, info.name.clone())) else {
233 continue;
234 };
235
236 let params = info
239 .params
240 .iter()
241 .map(|p| {
242 let tref = inline
243 .params
244 .iter()
245 .find(|(n, _)| n == &p.name)
246 .map(|(_, t)| t);
247 ResolvedParam {
248 name: p.name.clone(),
249 ty: tref.and_then(|t| resolve_type(t, types, info, &mut diags)),
250 }
251 })
252 .collect();
253 let returns = inline
254 .returns
255 .as_ref()
256 .and_then(|t| resolve_type(t, types, info, &mut diags));
257
258 metas.insert(
259 info.id,
260 SymbolMeta {
261 doc: inline.doc.clone(),
262 kind: ExternalKind::Plain,
263 returns,
264 params,
265 value: None,
266 },
267 );
268 }
269
270 if severity == ExternalCheckSeverity::Off {
271 diags.clear();
272 }
273 (metas, diags)
274}
275
276pub fn infer_value_meta(
280 files: &[(FileId, &HirFile)],
281 index: &SymbolIndex,
282 inline_docs: &BTreeMap<(SymbolKind, String), DocBlock>,
283) -> BTreeMap<DefinitionId, SymbolMeta> {
284 let mut metas: BTreeMap<DefinitionId, SymbolMeta> = BTreeMap::new();
285
286 for &(_file_id, hir) in files {
287 for v in &hir.variables {
288 add_value_meta(
289 &mut metas,
290 index,
291 inline_docs,
292 SymbolKind::Variable,
293 &v.name.text,
294 Some(&v.value),
295 false,
296 );
297 }
298 for c in &hir.constants {
299 add_value_meta(
300 &mut metas,
301 index,
302 inline_docs,
303 SymbolKind::Constant,
304 &c.name.text,
305 Some(&c.value),
306 true,
307 );
308 }
309 for l in &hir.lists {
311 add_value_meta(
312 &mut metas,
313 index,
314 inline_docs,
315 SymbolKind::List,
316 &l.name.text,
317 None,
318 false,
319 );
320 }
321 }
322 metas
323}
324
325fn add_value_meta(
328 metas: &mut BTreeMap<DefinitionId, SymbolMeta>,
329 index: &SymbolIndex,
330 inline_docs: &BTreeMap<(SymbolKind, String), DocBlock>,
331 kind: SymbolKind,
332 name: &str,
333 init: Option<&Expr>,
334 show_value: bool,
335) {
336 let doc = inline_docs
337 .get(&(kind, name.to_string()))
338 .and_then(|d| d.doc.clone());
339 let ty = init.and_then(infer_literal_type);
340 let value_text = if show_value {
341 init.and_then(literal_display)
342 } else {
343 None
344 };
345 if doc.is_none() && ty.is_none() && value_text.is_none() {
346 return;
347 }
348 let Some(id) = index.by_name.get(name).and_then(|ids| {
349 ids.iter()
350 .copied()
351 .find(|id| index.symbols.get(id).is_some_and(|s| s.kind == kind))
352 }) else {
353 return;
354 };
355 let value = (ty.is_some() || value_text.is_some()).then_some(ValueMeta { ty, value_text });
356 metas.insert(
357 id,
358 SymbolMeta {
359 doc,
360 kind: ExternalKind::Plain,
361 returns: None,
362 params: Vec::new(),
363 value,
364 },
365 );
366}
367
368fn infer_literal_type(expr: &Expr) -> Option<InferredType> {
371 match expr {
372 Expr::Int(_) => Some(InferredType::Int),
373 Expr::Float(_) => Some(InferredType::Float),
374 Expr::Bool(_) => Some(InferredType::Bool),
375 Expr::String(_) => Some(InferredType::String),
376 Expr::DivertTarget(_) => Some(InferredType::Divert),
377 Expr::ListLiteral(_) => Some(InferredType::List),
378 Expr::Prefix(brink_ir::hir::PrefixOp::Negate, inner) => match inner.as_ref() {
379 Expr::Int(_) | Expr::Float(_) => infer_literal_type(inner),
380 _ => None,
381 },
382 _ => None,
383 }
384}
385
386fn literal_display(expr: &Expr) -> Option<String> {
389 match expr {
390 Expr::Int(n) => Some(n.to_string()),
391 Expr::Float(f) => Some(float_display(f.to_f64())),
392 Expr::Bool(b) => Some(b.to_string()),
393 Expr::String(_) => plain_string_value(expr).map(|s| format!("\"{s}\"")),
394 Expr::DivertTarget(p) => Some(format!("-> {}", path_display(p))),
395 Expr::Prefix(brink_ir::hir::PrefixOp::Negate, inner) => match inner.as_ref() {
396 Expr::Int(_) | Expr::Float(_) => literal_display(inner).map(|s| format!("-{s}")),
397 _ => None,
398 },
399 _ => None,
400 }
401}
402
403fn float_display(v: f64) -> String {
406 let s = v.to_string();
407 if s.contains('.') || s.contains('e') || s.contains("inf") || s.contains("NaN") {
408 s
409 } else {
410 format!("{s}.0")
411 }
412}
413
414fn path_display(path: &Path) -> String {
415 path.segments
416 .iter()
417 .map(|n| n.text.as_str())
418 .collect::<Vec<_>>()
419 .join(".")
420}
421
422fn resolve_type(
425 t: &TypeRef,
426 types: &BTreeMap<String, SemanticTypeDef>,
427 info: &SymbolInfo,
428 diags: &mut Vec<Diagnostic>,
429) -> Option<ResolvedType> {
430 if t.is_unspecified() {
431 return None;
432 }
433 if let Some(base) = t.as_base() {
434 return Some(ResolvedType {
435 name: t.0.clone(),
436 base: Some(base),
437 constraint: None,
438 values: None,
439 });
440 }
441 if let Some(def) = types.get(t.0.trim()) {
442 return Some(ResolvedType {
443 name: t.0.clone(),
444 base: Some(def.base),
445 constraint: def.constraint.clone(),
446 values: def.values.clone(),
447 });
448 }
449 diags.push(Diagnostic {
450 file: info.file,
451 range: info.range,
452 message: format!(
453 "{}: `{}` (on `{}`)",
454 DiagnosticCode::E040.title(),
455 t.0.trim(),
456 info.name,
457 ),
458 code: DiagnosticCode::E040,
459 });
460 Some(ResolvedType {
461 name: t.0.clone(),
462 base: None,
463 constraint: None,
464 values: None,
465 })
466}
467
468pub fn check_call_sites(
475 files: &[(FileId, &HirFile)],
476 name_to_meta: &BTreeMap<&str, &SymbolMeta>,
477) -> Vec<Diagnostic> {
478 let mut diags = Vec::new();
479 if name_to_meta.is_empty() {
480 return diags;
481 }
482 for &(file_id, hir) in files {
483 let mut visit = |path: &Path, args: &[Expr]| {
484 check_call(file_id, path, args, name_to_meta, &mut diags);
485 };
486 walk_block(&hir.root_content, &mut visit);
487 for knot in &hir.knots {
488 walk_block(&knot.body, &mut visit);
489 for stitch in &knot.stitches {
490 walk_block(&stitch.body, &mut visit);
491 }
492 }
493 }
494 diags
495}
496
497fn walk_block(block: &Block, visit: &mut dyn FnMut(&Path, &[Expr])) {
498 for stmt in &block.stmts {
499 walk_stmt(stmt, visit);
500 }
501}
502
503fn walk_stmt(stmt: &Stmt, visit: &mut dyn FnMut(&Path, &[Expr])) {
504 match stmt {
505 Stmt::Content(c) => walk_content(c, visit),
506 Stmt::Divert(d) => walk_target(&d.target, visit),
507 Stmt::TunnelCall(t) => {
508 for target in &t.targets {
509 walk_target(target, visit);
510 }
511 }
512 Stmt::ThreadStart(t) => walk_target(&t.target, visit),
513 Stmt::TempDecl(t) => {
514 if let Some(e) = &t.value {
515 walk_expr(e, visit);
516 }
517 }
518 Stmt::Assignment(a) => walk_expr(&a.value, visit),
519 Stmt::Return(r) => {
520 if let Some(e) = &r.value {
521 walk_expr(e, visit);
522 }
523 for e in &r.onwards_args {
524 walk_expr(e, visit);
525 }
526 }
527 Stmt::ChoiceSet(cs) => walk_choice_set(cs, visit),
528 Stmt::LabeledBlock(b) => walk_block(b, visit),
529 Stmt::Conditional(c) => walk_conditional(c, visit),
530 Stmt::Sequence(s) => walk_sequence(s, visit),
531 Stmt::ExprStmt(e) => walk_expr(e, visit),
532 Stmt::EndOfLine => {}
533 }
534}
535
536fn walk_target(target: &DivertTarget, visit: &mut dyn FnMut(&Path, &[Expr])) {
537 for e in &target.args {
538 walk_expr(e, visit);
539 }
540}
541
542fn walk_content(content: &Content, visit: &mut dyn FnMut(&Path, &[Expr])) {
543 for part in &content.parts {
544 match part {
545 ContentPart::Interpolation(e) => walk_expr(e, visit),
546 ContentPart::InlineConditional(c) => walk_conditional(c, visit),
547 ContentPart::InlineSequence(s) => walk_sequence(s, visit),
548 ContentPart::Text(_) | ContentPart::Glue | ContentPart::Spring => {}
549 }
550 }
551}
552
553fn walk_conditional(cond: &Conditional, visit: &mut dyn FnMut(&Path, &[Expr])) {
554 if let CondKind::Switch(e) = &cond.kind {
555 walk_expr(e, visit);
556 }
557 for branch in &cond.branches {
558 if let Some(e) = &branch.condition {
559 walk_expr(e, visit);
560 }
561 walk_block(&branch.body, visit);
562 }
563}
564
565fn walk_sequence(seq: &Sequence, visit: &mut dyn FnMut(&Path, &[Expr])) {
566 for branch in &seq.branches {
567 walk_block(branch, visit);
568 }
569}
570
571fn walk_choice_set(cs: &ChoiceSet, visit: &mut dyn FnMut(&Path, &[Expr])) {
572 for choice in &cs.choices {
573 if let Some(e) = &choice.condition {
574 walk_expr(e, visit);
575 }
576 for content in [
577 &choice.start_content,
578 &choice.bracket_content,
579 &choice.inner_content,
580 ]
581 .into_iter()
582 .flatten()
583 {
584 walk_content(content, visit);
585 }
586 walk_block(&choice.body, visit);
587 }
588 walk_block(&cs.continuation, visit);
589}
590
591fn walk_expr(expr: &Expr, visit: &mut dyn FnMut(&Path, &[Expr])) {
592 match expr {
593 Expr::Call(path, args) => {
594 visit(path, args);
595 for arg in args {
596 walk_expr(arg, visit);
597 }
598 }
599 Expr::Prefix(_, inner) | Expr::Postfix(inner, _) => walk_expr(inner, visit),
600 Expr::Infix(lhs, _, rhs) => {
601 walk_expr(lhs, visit);
602 walk_expr(rhs, visit);
603 }
604 Expr::String(s) => {
605 for part in &s.parts {
606 if let StringPart::Interpolation(e) = part {
607 walk_expr(e, visit);
608 }
609 }
610 }
611 Expr::Int(_)
612 | Expr::Float(_)
613 | Expr::Bool(_)
614 | Expr::Null
615 | Expr::Path(_)
616 | Expr::DivertTarget(_)
617 | Expr::ListLiteral(_) => {}
618 }
619}
620
621fn check_call(
622 file: FileId,
623 path: &Path,
624 args: &[Expr],
625 name_to_meta: &BTreeMap<&str, &SymbolMeta>,
626 diags: &mut Vec<Diagnostic>,
627) {
628 let name = path
629 .segments
630 .iter()
631 .map(|n| n.text.as_str())
632 .collect::<Vec<_>>()
633 .join(".");
634 let Some(meta) = name_to_meta.get(name.as_str()) else {
635 return; };
637 for (i, arg) in args.iter().enumerate() {
638 let Some(param) = meta.params.get(i) else {
639 continue; };
641 let Some(ty) = ¶m.ty else {
642 continue; };
644 check_literal_arg(file, path, &name, arg, ty, diags);
645 }
646}
647
648fn check_literal_arg(
651 file: FileId,
652 path: &Path,
653 call: &str,
654 arg: &Expr,
655 ty: &ResolvedType,
656 diags: &mut Vec<Diagnostic>,
657) {
658 if let (Some(lit), Some(expected)) = (literal_base(arg), ty.base)
659 && !compatible(lit, expected)
660 {
661 diags.push(Diagnostic {
662 file,
663 range: path.range,
664 message: format!(
665 "{}: `{call}` expects {} but a {} literal was passed",
666 DiagnosticCode::E041.title(),
667 base_name(expected),
668 base_name(lit),
669 ),
670 code: DiagnosticCode::E041,
671 });
672 return;
673 }
674 if let Some(constraint) = &ty.constraint {
675 check_constraint(file, path, call, &ty.name, arg, constraint, diags);
676 }
677}
678
679fn literal_base(expr: &Expr) -> Option<BaseType> {
681 match expr {
682 Expr::Int(_) => Some(BaseType::Int),
683 Expr::Float(_) => Some(BaseType::Float),
684 Expr::Bool(_) => Some(BaseType::Bool),
685 Expr::String(_) => Some(BaseType::String),
686 _ => None,
687 }
688}
689
690fn compatible(lit: BaseType, expected: BaseType) -> bool {
693 lit == expected || (lit == BaseType::Int && expected == BaseType::Float)
694}
695
696fn base_name(base: BaseType) -> &'static str {
697 match base {
698 BaseType::String => "string",
699 BaseType::Int => "int",
700 BaseType::Float => "float",
701 BaseType::Bool => "bool",
702 BaseType::Void => "void",
703 }
704}
705
706#[expect(
707 clippy::cast_precision_loss,
708 reason = "range bounds are small integers; f64 comparison is exact in practice"
709)]
710fn check_constraint(
711 file: FileId,
712 path: &Path,
713 call: &str,
714 type_name: &str,
715 arg: &Expr,
716 constraint: &Constraint,
717 diags: &mut Vec<Diagnostic>,
718) {
719 match constraint {
720 Constraint::Enum { values } => {
721 if let Some(s) = plain_string_value(arg)
722 && !values.iter().any(|v| v == s)
723 {
724 diags.push(Diagnostic {
725 file,
726 range: path.range,
727 message: format!(
728 "{}: `{s}` is not a valid `{type_name}` value for `{call}`",
729 DiagnosticCode::E042.title(),
730 ),
731 code: DiagnosticCode::E042,
732 });
733 }
734 }
735 Constraint::Range { min, max } => {
736 if let Some(v) = numeric_value(arg)
737 && (min.is_some_and(|m| v < m as f64) || max.is_some_and(|m| v > m as f64))
738 {
739 diags.push(Diagnostic {
740 file,
741 range: path.range,
742 message: format!(
743 "{}: value out of range for `{type_name}` on `{call}`",
744 DiagnosticCode::E042.title(),
745 ),
746 code: DiagnosticCode::E042,
747 });
748 }
749 }
750 Constraint::Regex { .. } => {}
753 }
754}
755
756fn plain_string_value(expr: &Expr) -> Option<&str> {
758 let Expr::String(s) = expr else { return None };
759 match s.parts.as_slice() {
760 [] => Some(""),
761 [StringPart::Literal(text)] => Some(text),
762 _ => None, }
764}
765
766fn numeric_value(expr: &Expr) -> Option<f64> {
768 match expr {
769 Expr::Int(n) => Some(f64::from(*n)),
770 Expr::Float(f) => Some(f.to_f64()),
771 _ => None,
772 }
773}
774
775#[cfg(test)]
776#[expect(clippy::cast_possible_truncation, reason = "test helper ranges")]
777mod tests {
778 use brink_ir::{
779 DeclaredSymbol, DocBlock, ManifestExternal, ManifestParam, ParamInfo, SemanticTypeDef,
780 SymbolManifest, TypeRef,
781 };
782 use brink_ir::{DiagnosticCode, FileId};
783 use rowan::{TextRange, TextSize};
784
785 use super::*;
786 use crate::manifest::merge_manifests;
787
788 fn index_with_external(name: &str, params: &[&str]) -> SymbolIndex {
789 let mut m = SymbolManifest::default();
790 m.externals.push(DeclaredSymbol {
791 name: name.to_string(),
792 range: TextRange::new(TextSize::new(0), TextSize::new(name.len() as u32)),
793 params: params
794 .iter()
795 .map(|n| ParamInfo {
796 name: (*n).to_string(),
797 is_ref: false,
798 is_divert: false,
799 })
800 .collect(),
801 detail: None,
802 });
803 merge_manifests(&[(FileId(0), &m)]).0
804 }
805
806 fn meta_for<'a>(
807 metas: &'a BTreeMap<DefinitionId, SymbolMeta>,
808 index: &SymbolIndex,
809 name: &str,
810 ) -> &'a SymbolMeta {
811 let id = index
812 .symbols
813 .values()
814 .find(|s| s.kind == SymbolKind::External && s.name == name)
815 .expect("external in index")
816 .id;
817 metas.get(&id).expect("meta for external")
818 }
819
820 fn inline(
821 params: &[(&str, &str)],
822 returns: Option<&str>,
823 kind: Option<ExternalKind>,
824 ) -> DocBlock {
825 DocBlock {
826 doc: None,
827 params: params
828 .iter()
829 .map(|(n, t)| ((*n).to_string(), TypeRef((*t).to_string())))
830 .collect(),
831 returns: returns.map(|t| TypeRef(t.to_string())),
832 kind,
833 }
834 }
835
836 #[test]
837 fn inline_doc_enriches_meta() {
838 let index = index_with_external("has", &["item"]);
839 let mut docs = BTreeMap::new();
840 docs.insert(
841 (SymbolKind::External, "has".to_string()),
842 inline(&[("item", "bool")], Some("bool"), Some(ExternalKind::Query)),
843 );
844 let (metas, diags) = analyze_externals(
845 &index,
846 &docs,
847 &BTreeMap::new(),
848 &BTreeMap::new(),
849 ExternalCheckSeverity::Error,
850 );
851 assert!(diags.is_empty(), "no diags: {diags:?}");
852 let meta = meta_for(&metas, &index, "has");
853 assert_eq!(meta.kind, ExternalKind::Query);
854 assert_eq!(
855 meta.returns.as_ref().and_then(|t| t.base),
856 Some(BaseType::Bool)
857 );
858 assert_eq!(
859 meta.params[0].ty.as_ref().and_then(|t| t.base),
860 Some(BaseType::Bool)
861 );
862 }
863
864 #[test]
865 fn registered_enriches_when_no_inline() {
866 let index = index_with_external("grant", &["item"]);
867 let reg_ext = ManifestExternal {
868 name: "grant".to_string(),
869 params: vec![ManifestParam {
870 name: "item".to_string(),
871 ty: TypeRef("string".to_string()),
872 }],
873 returns: TypeRef("void".to_string()),
874 kind: ExternalKind::Effect,
875 doc: Some("Grant an item.".to_string()),
876 };
877 let mut registered = BTreeMap::new();
878 registered.insert("grant".to_string(), ®_ext);
879 let (metas, _) = analyze_externals(
880 &index,
881 &BTreeMap::new(),
882 &BTreeMap::new(),
883 ®istered,
884 ExternalCheckSeverity::Error,
885 );
886 let meta = meta_for(&metas, &index, "grant");
887 assert_eq!(meta.kind, ExternalKind::Effect);
888 assert_eq!(meta.doc.as_deref(), Some("Grant an item."));
889 assert_eq!(
890 meta.params[0].ty.as_ref().and_then(|t| t.base),
891 Some(BaseType::String)
892 );
893 }
894
895 #[test]
896 fn inline_wins_over_registered() {
897 let index = index_with_external("has", &["item"]);
898 let reg_ext = ManifestExternal {
899 name: "has".to_string(),
900 params: vec![ManifestParam {
901 name: "item".to_string(),
902 ty: TypeRef("int".to_string()),
903 }],
904 returns: TypeRef("int".to_string()),
905 kind: ExternalKind::Effect,
906 doc: None,
907 };
908 let mut registered = BTreeMap::new();
909 registered.insert("has".to_string(), ®_ext);
910 let mut docs = BTreeMap::new();
911 docs.insert(
912 (SymbolKind::External, "has".to_string()),
913 inline(&[("item", "bool")], Some("bool"), Some(ExternalKind::Query)),
914 );
915
916 let (metas, _) = analyze_externals(
917 &index,
918 &docs,
919 &BTreeMap::new(),
920 ®istered,
921 ExternalCheckSeverity::Error,
922 );
923 let meta = meta_for(&metas, &index, "has");
924 assert_eq!(meta.kind, ExternalKind::Query, "inline @kind wins");
925 assert_eq!(
926 meta.params[0].ty.as_ref().and_then(|t| t.base),
927 Some(BaseType::Bool)
928 );
929 }
930
931 #[test]
932 fn semantic_type_resolves_constraint() {
933 let index = index_with_external("give", &["item"]);
934 let mut types = BTreeMap::new();
935 types.insert(
936 "item_id".to_string(),
937 SemanticTypeDef {
938 name: "item_id".to_string(),
939 base: BaseType::String,
940 constraint: Some(Constraint::Enum {
941 values: vec!["sword".into(), "shield".into()],
942 }),
943 values: None,
944 },
945 );
946 let mut docs = BTreeMap::new();
947 docs.insert(
948 (SymbolKind::External, "give".to_string()),
949 inline(&[("item", "item_id")], None, None),
950 );
951
952 let (metas, diags) = analyze_externals(
953 &index,
954 &docs,
955 &types,
956 &BTreeMap::new(),
957 ExternalCheckSeverity::Error,
958 );
959 assert!(diags.is_empty(), "known semantic type: {diags:?}");
960 let ty = meta_for(&metas, &index, "give").params[0]
961 .ty
962 .clone()
963 .unwrap();
964 assert_eq!(ty.base, Some(BaseType::String));
965 assert!(matches!(ty.constraint, Some(Constraint::Enum { .. })));
966 }
967
968 #[test]
969 fn unknown_semantic_type_emits_e040() {
970 let index = index_with_external("foo", &["x"]);
971 let mut docs = BTreeMap::new();
972 docs.insert(
973 (SymbolKind::External, "foo".to_string()),
974 inline(&[("x", "bogus")], None, None),
975 );
976
977 let (metas, diags) = analyze_externals(
978 &index,
979 &docs,
980 &BTreeMap::new(),
981 &BTreeMap::new(),
982 ExternalCheckSeverity::Error,
983 );
984 assert_eq!(diags.len(), 1);
985 assert_eq!(diags[0].code, DiagnosticCode::E040);
986 assert!(
988 meta_for(&metas, &index, "foo").params[0]
989 .ty
990 .as_ref()
991 .unwrap()
992 .base
993 .is_none()
994 );
995 }
996
997 #[test]
998 fn arity_disagreement_emits_e039() {
999 let index = index_with_external("has", &["item"]); let reg_ext = ManifestExternal {
1001 name: "has".to_string(),
1002 params: vec![
1003 ManifestParam {
1004 name: "item".to_string(),
1005 ty: TypeRef("string".to_string()),
1006 },
1007 ManifestParam {
1008 name: "qty".to_string(),
1009 ty: TypeRef("int".to_string()),
1010 },
1011 ],
1012 returns: TypeRef::default(),
1013 kind: ExternalKind::default(),
1014 doc: None,
1015 };
1016 let mut registered = BTreeMap::new();
1017 registered.insert("has".to_string(), ®_ext);
1018
1019 let (_metas, diags) = analyze_externals(
1020 &index,
1021 &BTreeMap::new(),
1022 &BTreeMap::new(),
1023 ®istered,
1024 ExternalCheckSeverity::Error,
1025 );
1026 assert_eq!(diags.len(), 1);
1027 assert_eq!(diags[0].code, DiagnosticCode::E039);
1028 }
1029
1030 #[test]
1031 fn severity_off_suppresses_diagnostics_but_keeps_meta() {
1032 let index = index_with_external("foo", &["x"]);
1033 let mut docs = BTreeMap::new();
1034 docs.insert(
1035 (SymbolKind::External, "foo".to_string()),
1036 inline(&[("x", "bogus")], None, None),
1037 );
1038
1039 let (metas, diags) = analyze_externals(
1040 &index,
1041 &docs,
1042 &BTreeMap::new(),
1043 &BTreeMap::new(),
1044 ExternalCheckSeverity::Off,
1045 );
1046 assert!(diags.is_empty(), "Off suppresses diagnostics");
1047 assert!(!metas.is_empty(), "enrichment still built when Off");
1048 }
1049
1050 fn index_with_callables() -> SymbolIndex {
1054 let mut m = SymbolManifest::default();
1055 m.knots.push(DeclaredSymbol {
1056 name: "damage".to_string(),
1057 range: TextRange::new(TextSize::new(0), TextSize::new(6)),
1058 params: vec![ParamInfo {
1059 name: "weapon".to_string(),
1060 is_ref: false,
1061 is_divert: false,
1062 }],
1063 detail: Some("function".to_string()),
1064 });
1065 m.stitches.push(DeclaredSymbol {
1066 name: "hub.market".to_string(),
1067 range: TextRange::new(TextSize::new(10), TextSize::new(16)),
1068 params: Vec::new(),
1069 detail: None,
1070 });
1071 merge_manifests(&[(FileId(0), &m)]).0
1072 }
1073
1074 fn meta_for_kind<'a>(
1075 metas: &'a BTreeMap<DefinitionId, SymbolMeta>,
1076 index: &SymbolIndex,
1077 kind: SymbolKind,
1078 name: &str,
1079 ) -> &'a SymbolMeta {
1080 let id = index
1081 .symbols
1082 .values()
1083 .find(|s| s.kind == kind && s.name == name)
1084 .expect("symbol in index")
1085 .id;
1086 metas.get(&id).expect("meta for symbol")
1087 }
1088
1089 #[test]
1090 fn knot_doc_enriches_meta_with_resolved_types() {
1091 let index = index_with_callables();
1092 let mut docs = BTreeMap::new();
1093 docs.insert(
1094 (SymbolKind::Knot, "damage".to_string()),
1095 DocBlock {
1096 doc: Some("Damage roll.".to_string()),
1097 params: vec![("weapon".to_string(), TypeRef("item_id".to_string()))],
1098 returns: Some(TypeRef("int".to_string())),
1099 kind: None,
1100 },
1101 );
1102 let mut types = BTreeMap::new();
1103 types.insert(
1104 "item_id".to_string(),
1105 SemanticTypeDef {
1106 name: "item_id".to_string(),
1107 base: BaseType::String,
1108 constraint: None,
1109 values: None,
1110 },
1111 );
1112
1113 let (metas, diags) = enrich_callables(&index, &docs, &types, ExternalCheckSeverity::Error);
1114 assert!(diags.is_empty(), "known types: {diags:?}");
1115 let meta = meta_for_kind(&metas, &index, SymbolKind::Knot, "damage");
1116 assert_eq!(meta.doc.as_deref(), Some("Damage roll."));
1117 assert_eq!(meta.kind, ExternalKind::Plain);
1118 assert_eq!(
1119 meta.params[0].ty.as_ref().and_then(|t| t.base),
1120 Some(BaseType::String)
1121 );
1122 assert_eq!(
1123 meta.returns.as_ref().and_then(|t| t.base),
1124 Some(BaseType::Int)
1125 );
1126 }
1127
1128 #[test]
1129 fn stitch_doc_keyed_by_qualified_name() {
1130 let index = index_with_callables();
1131 let mut docs = BTreeMap::new();
1132 docs.insert(
1133 (SymbolKind::Stitch, "hub.market".to_string()),
1134 DocBlock {
1135 doc: Some("The market square.".to_string()),
1136 params: Vec::new(),
1137 returns: None,
1138 kind: None,
1139 },
1140 );
1141 let (metas, diags) = enrich_callables(
1142 &index,
1143 &docs,
1144 &BTreeMap::new(),
1145 ExternalCheckSeverity::Error,
1146 );
1147 assert!(diags.is_empty());
1148 let meta = meta_for_kind(&metas, &index, SymbolKind::Stitch, "hub.market");
1149 assert_eq!(meta.doc.as_deref(), Some("The market square."));
1150 }
1151
1152 #[test]
1153 fn unknown_semantic_type_on_knot_emits_e040() {
1154 let index = index_with_callables();
1155 let mut docs = BTreeMap::new();
1156 docs.insert(
1157 (SymbolKind::Knot, "damage".to_string()),
1158 DocBlock {
1159 doc: None,
1160 params: vec![("weapon".to_string(), TypeRef("bogus".to_string()))],
1161 returns: None,
1162 kind: None,
1163 },
1164 );
1165 let (metas, diags) = enrich_callables(
1166 &index,
1167 &docs,
1168 &BTreeMap::new(),
1169 ExternalCheckSeverity::Error,
1170 );
1171 assert_eq!(diags.len(), 1);
1172 assert_eq!(diags[0].code, DiagnosticCode::E040);
1173 let meta = meta_for_kind(&metas, &index, SymbolKind::Knot, "damage");
1175 assert!(meta.params[0].ty.as_ref().is_some_and(|t| t.base.is_none()));
1176
1177 let (metas, diags) =
1179 enrich_callables(&index, &docs, &BTreeMap::new(), ExternalCheckSeverity::Off);
1180 assert!(diags.is_empty());
1181 assert!(!metas.is_empty());
1182 }
1183
1184 #[test]
1185 fn undocumented_callables_get_no_meta() {
1186 let index = index_with_callables();
1187 let (metas, diags) = enrich_callables(
1188 &index,
1189 &BTreeMap::new(),
1190 &BTreeMap::new(),
1191 ExternalCheckSeverity::Error,
1192 );
1193 assert!(metas.is_empty());
1194 assert!(diags.is_empty());
1195 }
1196
1197 fn analyze_source(src: &str) -> crate::AnalysisResult {
1201 let parsed = brink_syntax::parse(src);
1202 let tree = parsed.tree();
1203 let (hir, manifest, diags) = brink_ir::hir::lower(FileId(0), &tree);
1204 assert!(diags.is_empty(), "lowering diagnostics: {diags:?}");
1205 crate::analyze(&[(FileId(0), &hir, &manifest)])
1206 }
1207
1208 fn meta_by_name<'a>(
1209 result: &'a crate::AnalysisResult,
1210 kind: SymbolKind,
1211 name: &str,
1212 ) -> &'a SymbolMeta {
1213 let id = result
1214 .index
1215 .symbols
1216 .values()
1217 .find(|s| s.kind == kind && s.name == name)
1218 .expect("symbol in index")
1219 .id;
1220 result.symbol_meta.get(&id).expect("meta for symbol")
1221 }
1222
1223 #[test]
1224 fn var_initializer_types_are_inferred() {
1225 let result = analyze_source(
1226 "VAR health = 100\nVAR speed = 0.5\nVAR alive = true\nVAR name = \"Ada\"\n",
1227 );
1228 let ty = |name: &str| {
1229 meta_by_name(&result, SymbolKind::Variable, name)
1230 .value
1231 .as_ref()
1232 .expect("value meta")
1233 .ty
1234 };
1235 assert_eq!(ty("health"), Some(InferredType::Int));
1236 assert_eq!(ty("speed"), Some(InferredType::Float));
1237 assert_eq!(ty("alive"), Some(InferredType::Bool));
1238 assert_eq!(ty("name"), Some(InferredType::String));
1239 assert!(
1241 meta_by_name(&result, SymbolKind::Variable, "health")
1242 .value
1243 .as_ref()
1244 .is_some_and(|v| v.value_text.is_none())
1245 );
1246 }
1247
1248 #[test]
1249 fn const_gets_type_and_display_value() {
1250 let result = analyze_source(
1251 "CONST SPEED = 0.5\nCONST LIVES = -3\nCONST NAME = \"Ada\"\nCONST WHOLE = 1.0\n",
1252 );
1253 let value = |name: &str| {
1254 meta_by_name(&result, SymbolKind::Constant, name)
1255 .value
1256 .clone()
1257 .expect("value meta")
1258 };
1259 assert_eq!(value("SPEED").ty, Some(InferredType::Float));
1260 assert_eq!(value("SPEED").value_text.as_deref(), Some("0.5"));
1261 assert_eq!(value("LIVES").ty, Some(InferredType::Int));
1262 assert_eq!(value("LIVES").value_text.as_deref(), Some("-3"));
1263 assert_eq!(value("NAME").value_text.as_deref(), Some("\"Ada\""));
1264 assert_eq!(
1265 value("WHOLE").value_text.as_deref(),
1266 Some("1.0"),
1267 "whole floats keep a trailing .0"
1268 );
1269 }
1270
1271 #[test]
1272 fn docs_attach_to_values_and_lists() {
1273 let result = analyze_source(
1274 "/// Player health.\nVAR health = 100\n/// Mood states.\nLIST mood = happy, sad\n",
1275 );
1276 assert_eq!(
1277 meta_by_name(&result, SymbolKind::Variable, "health")
1278 .doc
1279 .as_deref(),
1280 Some("Player health.")
1281 );
1282 let list_meta = meta_by_name(&result, SymbolKind::List, "mood");
1283 assert_eq!(list_meta.doc.as_deref(), Some("Mood states."));
1284 assert!(list_meta.value.is_none(), "lists carry docs only");
1285 }
1286
1287 #[test]
1288 fn divert_target_initializer_infers_divert() {
1289 let result = analyze_source("VAR exit = -> hub\n== hub ==\ntext\n-> DONE\n");
1290 assert_eq!(
1291 meta_by_name(&result, SymbolKind::Variable, "exit")
1292 .value
1293 .as_ref()
1294 .and_then(|v| v.ty),
1295 Some(InferredType::Divert)
1296 );
1297 }
1298
1299 use brink_ir::hir::{
1302 Block, Expr, HirFile, Name, Path as HirPath, Stmt, StringExpr, StringPart,
1303 };
1304
1305 fn rng() -> TextRange {
1306 TextRange::new(TextSize::new(0), TextSize::new(1))
1307 }
1308
1309 fn hir_calling(name: &str, args: Vec<Expr>) -> HirFile {
1311 let path = HirPath {
1312 segments: vec![Name {
1313 text: name.to_string(),
1314 range: rng(),
1315 }],
1316 range: rng(),
1317 };
1318 HirFile {
1319 root_content: Block {
1320 label: None,
1321 stmts: vec![Stmt::ExprStmt(Expr::Call(path, args))],
1322 container_id: None,
1323 },
1324 knots: Vec::new(),
1325 variables: Vec::new(),
1326 constants: Vec::new(),
1327 lists: Vec::new(),
1328 externals: Vec::new(),
1329 includes: Vec::new(),
1330 }
1331 }
1332
1333 fn typed_meta(ty: ResolvedType) -> SymbolMeta {
1334 SymbolMeta {
1335 doc: None,
1336 kind: ExternalKind::default(),
1337 returns: None,
1338 params: vec![ResolvedParam {
1339 name: "x".to_string(),
1340 ty: Some(ty),
1341 }],
1342 value: None,
1343 }
1344 }
1345
1346 fn run_call_check(call: &str, args: Vec<Expr>, meta: &SymbolMeta) -> Vec<Diagnostic> {
1347 let hir = hir_calling(call, args);
1348 let mut n2m: BTreeMap<&str, &SymbolMeta> = BTreeMap::new();
1349 n2m.insert(call, meta);
1350 check_call_sites(&[(FileId(0), &hir)], &n2m)
1351 }
1352
1353 fn string_lit(s: &str) -> Expr {
1354 Expr::String(StringExpr {
1355 parts: vec![StringPart::Literal(s.to_string())],
1356 })
1357 }
1358
1359 #[test]
1360 fn type_mismatch_emits_e041() {
1361 let meta = typed_meta(ResolvedType {
1362 name: "string".to_string(),
1363 base: Some(BaseType::String),
1364 constraint: None,
1365 values: None,
1366 });
1367 let diags = run_call_check("tint", vec![Expr::Int(5)], &meta);
1368 assert_eq!(diags.len(), 1);
1369 assert_eq!(diags[0].code, DiagnosticCode::E041);
1370 }
1371
1372 #[test]
1373 fn matching_literal_no_diagnostic() {
1374 let meta = typed_meta(ResolvedType {
1375 name: "string".to_string(),
1376 base: Some(BaseType::String),
1377 constraint: None,
1378 values: None,
1379 });
1380 let diags = run_call_check("tint", vec![string_lit("ok")], &meta);
1381 assert!(diags.is_empty(), "matching string literal: {diags:?}");
1382 }
1383
1384 #[test]
1385 fn int_widens_to_float_param() {
1386 let meta = typed_meta(ResolvedType {
1387 name: "float".to_string(),
1388 base: Some(BaseType::Float),
1389 constraint: None,
1390 values: None,
1391 });
1392 let diags = run_call_check("scale", vec![Expr::Int(3)], &meta);
1393 assert!(
1394 diags.is_empty(),
1395 "int literal accepted for float param: {diags:?}"
1396 );
1397 }
1398
1399 #[test]
1400 fn non_literal_arg_skipped() {
1401 let meta = typed_meta(ResolvedType {
1403 name: "string".to_string(),
1404 base: Some(BaseType::String),
1405 constraint: None,
1406 values: None,
1407 });
1408 let var = Expr::Path(HirPath {
1409 segments: vec![Name {
1410 text: "v".to_string(),
1411 range: rng(),
1412 }],
1413 range: rng(),
1414 });
1415 let diags = run_call_check("tint", vec![var], &meta);
1416 assert!(
1417 diags.is_empty(),
1418 "non-literal arg is not checked: {diags:?}"
1419 );
1420 }
1421
1422 #[test]
1423 fn enum_violation_emits_e042() {
1424 let meta = typed_meta(ResolvedType {
1425 name: "item_id".to_string(),
1426 base: Some(BaseType::String),
1427 constraint: Some(Constraint::Enum {
1428 values: vec!["sword".into(), "shield".into()],
1429 }),
1430 values: None,
1431 });
1432 let bad = run_call_check("give", vec![string_lit("banana")], &meta);
1433 assert_eq!(bad.len(), 1);
1434 assert_eq!(bad[0].code, DiagnosticCode::E042);
1435 let ok = run_call_check("give", vec![string_lit("sword")], &meta);
1436 assert!(ok.is_empty(), "valid enum value: {ok:?}");
1437 }
1438
1439 #[test]
1440 fn range_violation_emits_e042() {
1441 let meta = typed_meta(ResolvedType {
1442 name: "percent".to_string(),
1443 base: Some(BaseType::Int),
1444 constraint: Some(Constraint::Range {
1445 min: Some(0),
1446 max: Some(100),
1447 }),
1448 values: None,
1449 });
1450 let bad = run_call_check("set", vec![Expr::Int(150)], &meta);
1451 assert_eq!(bad.len(), 1);
1452 assert_eq!(bad[0].code, DiagnosticCode::E042);
1453 let ok = run_call_check("set", vec![Expr::Int(50)], &meta);
1454 assert!(ok.is_empty(), "in-range value: {ok:?}");
1455 }
1456}