1use std::collections::BTreeMap;
25
26use brink_format::DefinitionId;
27use brink_ir::hir::{Expr, HirFile, HirVisitor, Path, StringPart};
28use brink_ir::{
29 BaseType, Constraint, Diagnostic, DiagnosticCode, DocBlock, ExternalKind, FileId,
30 SemanticTypeDef, SymbolIndex, SymbolInfo, SymbolKind, TypeRef,
31};
32
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
36pub enum ExternalCheckSeverity {
37 #[default]
39 Error,
40 Off,
42}
43
44#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
53pub enum SemanticTypeDiagnosticSeverity {
54 #[default]
57 Tolerant,
58 Error,
61}
62
63#[derive(Debug, Clone, Default, PartialEq, Eq)]
69pub struct SymbolMeta {
70 pub doc: Option<String>,
72 pub kind: ExternalKind,
75 pub returns: Option<ResolvedType>,
77 pub params: Vec<ResolvedParam>,
79 pub value: Option<ValueMeta>,
81 pub group_widgets: Vec<brink_ir::ArgGroupWidget>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct ValueMeta {
91 pub ty: Option<InferredType>,
93 pub value_text: Option<String>,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum InferredType {
108 Int,
109 Float,
110 Bool,
111 String,
112 Divert,
113 List(String),
114}
115
116impl InferredType {
117 #[must_use]
120 pub fn name(&self) -> String {
121 match self {
122 Self::Int => "int".to_string(),
123 Self::Float => "float".to_string(),
124 Self::Bool => "bool".to_string(),
125 Self::String => "string".to_string(),
126 Self::Divert => "divert".to_string(),
127 Self::List(list) => format!("List<{list}>"),
128 }
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct ResolvedParam {
135 pub name: String,
136 pub ty: Option<ResolvedType>,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct ResolvedType {
143 pub name: String,
145 pub base: Option<BaseType>,
147 pub constraint: Option<Constraint>,
149 pub values: Option<brink_ir::ValueSource>,
152 pub widget: Option<brink_ir::WidgetDecl>,
155}
156
157impl ResolvedType {
158 #[must_use]
167 pub fn is_registered(&self) -> bool {
168 self.base.is_some()
169 }
170}
171
172pub fn analyze_externals(
182 index: &SymbolIndex,
183 inline_docs: &BTreeMap<(SymbolKind, String), DocBlock>,
184 types: &BTreeMap<String, SemanticTypeDef>,
185 registered: &BTreeMap<String, &brink_ir::ManifestExternal>,
186 severity: ExternalCheckSeverity,
187 check_unknown_types: bool,
188) -> (BTreeMap<DefinitionId, SymbolMeta>, Vec<Diagnostic>) {
189 let mut metas: BTreeMap<DefinitionId, SymbolMeta> = BTreeMap::new();
190 let mut diags: Vec<Diagnostic> = Vec::new();
191
192 let mut externals: Vec<&SymbolInfo> = index
194 .symbols
195 .values()
196 .filter(|info| info.kind == SymbolKind::External)
197 .collect();
198 externals.sort_by_key(|info| (info.file.0, info.range.start()));
199
200 for info in externals {
201 let inline = inline_docs.get(&(SymbolKind::External, info.name.clone()));
202 let reg = registered.get(&info.name).copied();
203 if inline.is_none() && reg.is_none() {
204 continue; }
206
207 if let Some(reg) = reg
209 && reg.params.len() != info.params.len()
210 {
211 diags.push(Diagnostic {
212 file: info.file,
213 range: info.range,
214 message: format!(
215 "{}: `{}` is declared with {} parameter(s) but the manifest lists {}",
216 DiagnosticCode::E039.title(),
217 info.name,
218 info.params.len(),
219 reg.params.len(),
220 ),
221 code: DiagnosticCode::E039,
222 });
223 }
224
225 let mut params = Vec::with_capacity(info.params.len());
227 for (i, p) in info.params.iter().enumerate() {
228 let tref: Option<&TypeRef> = inline
229 .and_then(|d| d.params.iter().find(|(n, _)| n == &p.name).map(|(_, t)| t))
230 .or_else(|| reg.and_then(|r| r.params.get(i).map(|mp| &mp.ty)));
231 let ty =
232 tref.and_then(|t| resolve_type(t, types, info, check_unknown_types, &mut diags));
233 params.push(ResolvedParam {
234 name: p.name.clone(),
235 ty,
236 });
237 }
238
239 let returns = inline
241 .and_then(|d| d.returns.as_ref())
242 .or_else(|| reg.map(|r| &r.returns))
243 .and_then(|t| resolve_type(t, types, info, check_unknown_types, &mut diags));
244 let kind = inline
245 .and_then(|d| d.kind)
246 .or_else(|| reg.map(|r| r.kind))
247 .unwrap_or_default();
248 let doc = inline
249 .and_then(|d| d.doc.clone())
250 .or_else(|| reg.and_then(|r| r.doc.clone()));
251
252 metas.insert(
253 info.id,
254 SymbolMeta {
255 doc,
256 kind,
257 returns,
258 params,
259 value: None,
260 group_widgets: reg.map(|r| r.widgets.clone()).unwrap_or_default(),
261 },
262 );
263 }
264
265 if severity == ExternalCheckSeverity::Off {
266 diags.clear();
267 }
268 (metas, diags)
269}
270
271pub fn enrich_callables(
278 index: &SymbolIndex,
279 inline_docs: &BTreeMap<(SymbolKind, String), DocBlock>,
280 types: &BTreeMap<String, SemanticTypeDef>,
281 severity: ExternalCheckSeverity,
282 check_unknown_types: bool,
283) -> (BTreeMap<DefinitionId, SymbolMeta>, Vec<Diagnostic>) {
284 let mut metas: BTreeMap<DefinitionId, SymbolMeta> = BTreeMap::new();
285 let mut diags: Vec<Diagnostic> = Vec::new();
286
287 let mut callables: Vec<&SymbolInfo> = index
289 .symbols
290 .values()
291 .filter(|info| matches!(info.kind, SymbolKind::Knot | SymbolKind::Stitch))
292 .collect();
293 callables.sort_by_key(|info| (info.file.0, info.range.start()));
294
295 for info in callables {
296 let Some(inline) = inline_docs.get(&(info.kind, info.name.clone())) else {
297 continue;
298 };
299
300 let params = info
303 .params
304 .iter()
305 .map(|p| {
306 let tref = inline
307 .params
308 .iter()
309 .find(|(n, _)| n == &p.name)
310 .map(|(_, t)| t);
311 ResolvedParam {
312 name: p.name.clone(),
313 ty: tref.and_then(|t| {
314 resolve_type(t, types, info, check_unknown_types, &mut diags)
315 }),
316 }
317 })
318 .collect();
319 let returns = inline
320 .returns
321 .as_ref()
322 .and_then(|t| resolve_type(t, types, info, check_unknown_types, &mut diags));
323
324 metas.insert(
325 info.id,
326 SymbolMeta {
327 doc: inline.doc.clone(),
328 kind: ExternalKind::Plain,
329 returns,
330 params,
331 value: None,
332 group_widgets: Vec::new(),
333 },
334 );
335 }
336
337 if severity == ExternalCheckSeverity::Off {
338 diags.clear();
339 }
340 (metas, diags)
341}
342
343pub fn infer_value_meta(
347 files: &[(FileId, &HirFile)],
348 index: &SymbolIndex,
349 inline_docs: &BTreeMap<(SymbolKind, String), DocBlock>,
350) -> BTreeMap<DefinitionId, SymbolMeta> {
351 let mut metas: BTreeMap<DefinitionId, SymbolMeta> = BTreeMap::new();
352
353 for &(_file_id, hir) in files {
354 for v in &hir.variables {
355 add_value_meta(
356 &mut metas,
357 index,
358 inline_docs,
359 SymbolKind::Variable,
360 &v.name.text,
361 Some(&v.value),
362 false,
363 );
364 }
365 for c in &hir.constants {
366 add_value_meta(
367 &mut metas,
368 index,
369 inline_docs,
370 SymbolKind::Constant,
371 &c.name.text,
372 Some(&c.value),
373 true,
374 );
375 }
376 for l in &hir.lists {
378 add_value_meta(
379 &mut metas,
380 index,
381 inline_docs,
382 SymbolKind::List,
383 &l.name.text,
384 None,
385 false,
386 );
387 }
388 }
389 metas
390}
391
392fn add_value_meta(
395 metas: &mut BTreeMap<DefinitionId, SymbolMeta>,
396 index: &SymbolIndex,
397 inline_docs: &BTreeMap<(SymbolKind, String), DocBlock>,
398 kind: SymbolKind,
399 name: &str,
400 init: Option<&Expr>,
401 show_value: bool,
402) {
403 let doc = inline_docs
404 .get(&(kind, name.to_string()))
405 .and_then(|d| d.doc.clone());
406 let ty = init.and_then(|e| infer_literal_type(e, index));
407 let value_text = if show_value {
408 init.and_then(literal_display)
409 } else {
410 None
411 };
412 if doc.is_none() && ty.is_none() && value_text.is_none() {
413 return;
414 }
415 let Some(id) = index.by_name.get(name).and_then(|ids| {
416 ids.iter()
417 .copied()
418 .find(|id| index.symbols.get(id).is_some_and(|s| s.kind == kind))
419 }) else {
420 return;
421 };
422 let value = (ty.is_some() || value_text.is_some()).then_some(ValueMeta { ty, value_text });
423 metas.insert(
424 id,
425 SymbolMeta {
426 doc,
427 kind: ExternalKind::Plain,
428 returns: None,
429 params: Vec::new(),
430 value,
431 group_widgets: Vec::new(),
432 },
433 );
434}
435
436pub(crate) fn infer_literal_type(expr: &Expr, index: &SymbolIndex) -> Option<InferredType> {
442 match expr {
443 Expr::Int(_) => Some(InferredType::Int),
444 Expr::Float(_) => Some(InferredType::Float),
445 Expr::Bool(_) => Some(InferredType::Bool),
446 Expr::String(_) => Some(InferredType::String),
447 Expr::DivertTarget(_) => Some(InferredType::Divert),
448 Expr::ListLiteral(items) => list_literal_name(items, index).map(InferredType::List),
449 Expr::Prefix(brink_ir::hir::PrefixOp::Negate, inner) => match inner.as_ref() {
450 Expr::Int(_) | Expr::Float(_) => infer_literal_type(inner, index),
451 _ => None,
452 },
453 _ => None,
454 }
455}
456
457fn list_literal_name(items: &[brink_ir::hir::Path], index: &SymbolIndex) -> Option<String> {
469 items
470 .iter()
471 .find_map(|item| resolve_list_item_name(item, index))
472}
473
474fn resolve_list_item_name(item: &brink_ir::hir::Path, index: &SymbolIndex) -> Option<String> {
480 let segments: Vec<&str> = item.segments.iter().map(|s| s.text.as_str()).collect();
481 let is_list_item = |id: &DefinitionId| {
482 index
483 .symbols
484 .get(id)
485 .is_some_and(|info| info.kind == SymbolKind::ListItem)
486 };
487
488 if segments.len() > 1 {
489 let qualified = segments.join(".");
492 if index
493 .by_name
494 .get(qualified.as_str())
495 .is_some_and(|ids| ids.iter().any(is_list_item))
496 {
497 return qualified.split_once('.').map(|(list, _)| list.to_string());
498 }
499 }
500
501 let bare = segments.last()?;
504 match crate::resolve::lookup_list_item_bare(index, bare) {
505 crate::resolve::BareItemResult::Unique(id) => index
506 .symbols
507 .get(&id)
508 .and_then(|info| info.name.split_once('.').map(|(list, _)| list.to_string())),
509 crate::resolve::BareItemResult::Ambiguous | crate::resolve::BareItemResult::NotFound => {
510 None
511 }
512 }
513}
514
515fn literal_display(expr: &Expr) -> Option<String> {
518 match expr {
519 Expr::Int(n) => Some(n.to_string()),
520 Expr::Float(f) => Some(float_display(f.to_f64())),
521 Expr::Bool(b) => Some(b.to_string()),
522 Expr::String(_) => plain_string_value(expr).map(|s| format!("\"{s}\"")),
523 Expr::DivertTarget(p) => Some(format!("-> {}", path_display(p))),
524 Expr::Prefix(brink_ir::hir::PrefixOp::Negate, inner) => match inner.as_ref() {
525 Expr::Int(_) | Expr::Float(_) => literal_display(inner).map(|s| format!("-{s}")),
526 _ => None,
527 },
528 _ => None,
529 }
530}
531
532fn float_display(v: f64) -> String {
535 let s = v.to_string();
536 if s.contains('.') || s.contains('e') || s.contains("inf") || s.contains("NaN") {
537 s
538 } else {
539 format!("{s}.0")
540 }
541}
542
543fn path_display(path: &Path) -> String {
544 path.segments
545 .iter()
546 .map(|n| n.text.as_str())
547 .collect::<Vec<_>>()
548 .join(".")
549}
550
551fn resolve_type(
567 t: &TypeRef,
568 types: &BTreeMap<String, SemanticTypeDef>,
569 info: &SymbolInfo,
570 check_unknown_types: bool,
571 diags: &mut Vec<Diagnostic>,
572) -> Option<ResolvedType> {
573 use crate::type_resolution::{TypeShape, classify};
574
575 match classify(t, types) {
576 TypeShape::Unspecified => None,
577 TypeShape::Base(base) => Some(ResolvedType {
578 name: t.0.clone(),
579 base: Some(base),
580 constraint: None,
581 values: None,
582 widget: None,
583 }),
584 TypeShape::Registered(def) => Some(ResolvedType {
585 name: t.0.clone(),
586 base: Some(def.base),
587 constraint: def.constraint.clone(),
588 values: def.values.clone(),
589 widget: def.widget.clone(),
590 }),
591 TypeShape::Unregistered => {
592 if check_unknown_types {
593 diags.push(Diagnostic {
594 file: info.file,
595 range: info.range,
596 message: format!(
597 "{}: `{}` (on `{}`)",
598 DiagnosticCode::E040.title(),
599 t.0.trim(),
600 info.name,
601 ),
602 code: DiagnosticCode::E040,
603 });
604 }
605 Some(ResolvedType {
606 name: t.0.clone(),
607 base: None,
608 constraint: None,
609 values: None,
610 widget: None,
611 })
612 }
613 }
614}
615
616pub fn check_call_sites(
623 files: &[(FileId, &HirFile)],
624 name_to_meta: &BTreeMap<&str, &SymbolMeta>,
625) -> Vec<Diagnostic> {
626 let mut diags = Vec::new();
627 if name_to_meta.is_empty() {
628 return diags;
629 }
630 for &(file_id, hir) in files {
631 let mut checker = CallSiteChecker {
632 file_id,
633 name_to_meta,
634 diags: &mut diags,
635 };
636 brink_ir::hir::visit::visit(hir, &mut checker);
637 }
638 diags
639}
640
641struct CallSiteChecker<'a, 'm> {
644 file_id: FileId,
645 name_to_meta: &'m BTreeMap<&'m str, &'m SymbolMeta>,
646 diags: &'a mut Vec<Diagnostic>,
647}
648
649impl HirVisitor for CallSiteChecker<'_, '_> {
650 fn visit_exprs(&self) -> bool {
651 true
652 }
653
654 fn enter_expr(&mut self, expr: &Expr) {
655 if let Expr::Call(path, args) = expr {
656 check_call(self.file_id, path, args, self.name_to_meta, self.diags);
657 }
658 }
659}
660
661fn check_call(
662 file: FileId,
663 path: &Path,
664 args: &[Expr],
665 name_to_meta: &BTreeMap<&str, &SymbolMeta>,
666 diags: &mut Vec<Diagnostic>,
667) {
668 let name = path
669 .segments
670 .iter()
671 .map(|n| n.text.as_str())
672 .collect::<Vec<_>>()
673 .join(".");
674 let Some(meta) = name_to_meta.get(name.as_str()) else {
675 return; };
677 for (i, arg) in args.iter().enumerate() {
678 let Some(param) = meta.params.get(i) else {
679 continue; };
681 let Some(ty) = ¶m.ty else {
682 continue; };
684 check_literal_arg(file, path, &name, arg, ty, diags);
685 }
686}
687
688fn check_literal_arg(
691 file: FileId,
692 path: &Path,
693 call: &str,
694 arg: &Expr,
695 ty: &ResolvedType,
696 diags: &mut Vec<Diagnostic>,
697) {
698 if let (Some(lit), Some(expected)) = (literal_base(arg), ty.base)
699 && !compatible(lit, expected)
700 {
701 diags.push(Diagnostic {
702 file,
703 range: path.range,
704 message: format!(
705 "{}: `{call}` expects {} but a {} literal was passed",
706 DiagnosticCode::E041.title(),
707 base_name(expected),
708 base_name(lit),
709 ),
710 code: DiagnosticCode::E041,
711 });
712 return;
713 }
714 if let Some(constraint) = &ty.constraint {
715 check_constraint(file, path, call, &ty.name, arg, constraint, diags);
716 }
717}
718
719fn literal_base(expr: &Expr) -> Option<BaseType> {
721 match expr {
722 Expr::Int(_) => Some(BaseType::Int),
723 Expr::Float(_) => Some(BaseType::Float),
724 Expr::Bool(_) => Some(BaseType::Bool),
725 Expr::String(_) => Some(BaseType::String),
726 _ => None,
727 }
728}
729
730fn compatible(lit: BaseType, expected: BaseType) -> bool {
733 lit == expected || (lit == BaseType::Int && expected == BaseType::Float)
734}
735
736fn base_name(base: BaseType) -> &'static str {
737 match base {
738 BaseType::String => "string",
739 BaseType::Int => "int",
740 BaseType::Float => "float",
741 BaseType::Bool => "bool",
742 BaseType::Void => "void",
743 BaseType::Handle => "handle",
747 }
748}
749
750#[expect(
751 clippy::cast_precision_loss,
752 reason = "range bounds are small integers; f64 comparison is exact in practice"
753)]
754fn check_constraint(
755 file: FileId,
756 path: &Path,
757 call: &str,
758 type_name: &str,
759 arg: &Expr,
760 constraint: &Constraint,
761 diags: &mut Vec<Diagnostic>,
762) {
763 match constraint {
764 Constraint::Enum { values } => {
765 if let Some(s) = plain_string_value(arg)
766 && !values.iter().any(|v| v == s)
767 {
768 diags.push(Diagnostic {
769 file,
770 range: path.range,
771 message: format!(
772 "{}: `{s}` is not a valid `{type_name}` value for `{call}`",
773 DiagnosticCode::E042.title(),
774 ),
775 code: DiagnosticCode::E042,
776 });
777 }
778 }
779 Constraint::Range { min, max } => {
780 if let Some(v) = numeric_value(arg)
781 && (min.is_some_and(|m| v < m as f64) || max.is_some_and(|m| v > m as f64))
782 {
783 diags.push(Diagnostic {
784 file,
785 range: path.range,
786 message: format!(
787 "{}: value out of range for `{type_name}` on `{call}`",
788 DiagnosticCode::E042.title(),
789 ),
790 code: DiagnosticCode::E042,
791 });
792 }
793 }
794 Constraint::Regex { .. } => {}
797 }
798}
799
800fn plain_string_value(expr: &Expr) -> Option<&str> {
802 let Expr::String(s) = expr else { return None };
803 match s.parts.as_slice() {
804 [] => Some(""),
805 [StringPart::Literal(text)] => Some(text),
806 _ => None, }
808}
809
810fn numeric_value(expr: &Expr) -> Option<f64> {
812 match expr {
813 Expr::Int(n) => Some(f64::from(*n)),
814 Expr::Float(f) => Some(f.to_f64()),
815 _ => None,
816 }
817}
818
819#[cfg(test)]
820#[expect(clippy::cast_possible_truncation, reason = "test helper ranges")]
821mod tests {
822 use brink_ir::{
823 DeclaredSymbol, DocBlock, ManifestExternal, ManifestParam, ParamInfo, SemanticTypeDef,
824 SymbolManifest, TypeRef,
825 };
826 use brink_ir::{DiagnosticCode, FileId};
827 use rowan::{TextRange, TextSize};
828
829 use super::*;
830 use crate::manifest::merge_manifests;
831
832 fn index_with_external(name: &str, params: &[&str]) -> SymbolIndex {
833 let mut m = SymbolManifest::default();
834 m.externals.push(DeclaredSymbol {
835 name: name.to_string(),
836 range: TextRange::new(TextSize::new(0), TextSize::new(name.len() as u32)),
837 params: params
838 .iter()
839 .map(|n| ParamInfo {
840 name: (*n).to_string(),
841 is_ref: false,
842 is_divert: false,
843 })
844 .collect(),
845 detail: None,
846 visibility: None,
847 was: None,
848 });
849 merge_manifests(&[(FileId(0), &m)]).0
850 }
851
852 fn meta_for<'a>(
853 metas: &'a BTreeMap<DefinitionId, SymbolMeta>,
854 index: &SymbolIndex,
855 name: &str,
856 ) -> &'a SymbolMeta {
857 let id = index
858 .symbols
859 .values()
860 .find(|s| s.kind == SymbolKind::External && s.name == name)
861 .expect("external in index")
862 .id;
863 metas.get(&id).expect("meta for external")
864 }
865
866 fn inline(
867 params: &[(&str, &str)],
868 returns: Option<&str>,
869 kind: Option<ExternalKind>,
870 ) -> DocBlock {
871 DocBlock {
872 doc: None,
873 params: params
874 .iter()
875 .map(|(n, t)| ((*n).to_string(), TypeRef((*t).to_string())))
876 .collect(),
877 returns: returns.map(|t| TypeRef(t.to_string())),
878 kind,
879 }
880 }
881
882 #[test]
883 fn inline_doc_enriches_meta() {
884 let index = index_with_external("has", &["item"]);
885 let mut docs = BTreeMap::new();
886 docs.insert(
887 (SymbolKind::External, "has".to_string()),
888 inline(&[("item", "bool")], Some("bool"), Some(ExternalKind::Query)),
889 );
890 let (metas, diags) = analyze_externals(
891 &index,
892 &docs,
893 &BTreeMap::new(),
894 &BTreeMap::new(),
895 ExternalCheckSeverity::Error,
896 true,
897 );
898 assert!(diags.is_empty(), "no diags: {diags:?}");
899 let meta = meta_for(&metas, &index, "has");
900 assert_eq!(meta.kind, ExternalKind::Query);
901 assert_eq!(
902 meta.returns.as_ref().and_then(|t| t.base),
903 Some(BaseType::Bool)
904 );
905 assert_eq!(
906 meta.params[0].ty.as_ref().and_then(|t| t.base),
907 Some(BaseType::Bool)
908 );
909 }
910
911 #[test]
912 fn registered_enriches_when_no_inline() {
913 let index = index_with_external("grant", &["item"]);
914 let reg_ext = ManifestExternal {
915 name: "grant".to_string(),
916 params: vec![ManifestParam {
917 name: "item".to_string(),
918 ty: TypeRef("string".to_string()),
919 }],
920 returns: TypeRef("void".to_string()),
921 kind: ExternalKind::Effect,
922 doc: Some("Grant an item.".to_string()),
923
924 widgets: vec![],
925 path: Vec::new(),
926 };
927 let mut registered = BTreeMap::new();
928 registered.insert("grant".to_string(), ®_ext);
929 let (metas, _) = analyze_externals(
930 &index,
931 &BTreeMap::new(),
932 &BTreeMap::new(),
933 ®istered,
934 ExternalCheckSeverity::Error,
935 true,
936 );
937 let meta = meta_for(&metas, &index, "grant");
938 assert_eq!(meta.kind, ExternalKind::Effect);
939 assert_eq!(meta.doc.as_deref(), Some("Grant an item."));
940 assert_eq!(
941 meta.params[0].ty.as_ref().and_then(|t| t.base),
942 Some(BaseType::String)
943 );
944 }
945
946 #[test]
947 fn inline_wins_over_registered() {
948 let index = index_with_external("has", &["item"]);
949 let reg_ext = ManifestExternal {
950 name: "has".to_string(),
951 params: vec![ManifestParam {
952 name: "item".to_string(),
953 ty: TypeRef("int".to_string()),
954 }],
955 returns: TypeRef("int".to_string()),
956 kind: ExternalKind::Effect,
957 doc: None,
958
959 widgets: vec![],
960 path: Vec::new(),
961 };
962 let mut registered = BTreeMap::new();
963 registered.insert("has".to_string(), ®_ext);
964 let mut docs = BTreeMap::new();
965 docs.insert(
966 (SymbolKind::External, "has".to_string()),
967 inline(&[("item", "bool")], Some("bool"), Some(ExternalKind::Query)),
968 );
969
970 let (metas, _) = analyze_externals(
971 &index,
972 &docs,
973 &BTreeMap::new(),
974 ®istered,
975 ExternalCheckSeverity::Error,
976 true,
977 );
978 let meta = meta_for(&metas, &index, "has");
979 assert_eq!(meta.kind, ExternalKind::Query, "inline @kind wins");
980 assert_eq!(
981 meta.params[0].ty.as_ref().and_then(|t| t.base),
982 Some(BaseType::Bool)
983 );
984 }
985
986 #[test]
987 fn semantic_type_resolves_constraint() {
988 let index = index_with_external("give", &["item"]);
989 let mut types = BTreeMap::new();
990 types.insert(
991 "item_id".to_string(),
992 SemanticTypeDef {
993 name: "item_id".to_string(),
994 base: BaseType::String,
995 constraint: Some(Constraint::Enum {
996 values: vec!["sword".into(), "shield".into()],
997 }),
998 values: None,
999 widget: None,
1000 },
1001 );
1002 let mut docs = BTreeMap::new();
1003 docs.insert(
1004 (SymbolKind::External, "give".to_string()),
1005 inline(&[("item", "item_id")], None, None),
1006 );
1007
1008 let (metas, diags) = analyze_externals(
1009 &index,
1010 &docs,
1011 &types,
1012 &BTreeMap::new(),
1013 ExternalCheckSeverity::Error,
1014 true,
1015 );
1016 assert!(diags.is_empty(), "known semantic type: {diags:?}");
1017 let ty = meta_for(&metas, &index, "give").params[0]
1018 .ty
1019 .clone()
1020 .unwrap();
1021 assert_eq!(ty.base, Some(BaseType::String));
1022 assert!(matches!(ty.constraint, Some(Constraint::Enum { .. })));
1023 }
1024
1025 #[test]
1026 fn unknown_semantic_type_emits_e040() {
1027 let index = index_with_external("foo", &["x"]);
1028 let mut docs = BTreeMap::new();
1029 docs.insert(
1030 (SymbolKind::External, "foo".to_string()),
1031 inline(&[("x", "bogus")], None, None),
1032 );
1033
1034 let (metas, diags) = analyze_externals(
1035 &index,
1036 &docs,
1037 &BTreeMap::new(),
1038 &BTreeMap::new(),
1039 ExternalCheckSeverity::Error,
1040 true,
1041 );
1042 assert_eq!(diags.len(), 1);
1043 assert_eq!(diags[0].code, DiagnosticCode::E040);
1044 assert!(
1046 meta_for(&metas, &index, "foo").params[0]
1047 .ty
1048 .as_ref()
1049 .unwrap()
1050 .base
1051 .is_none()
1052 );
1053 }
1054
1055 #[test]
1059 fn unknown_semantic_type_tolerated_without_manifest() {
1060 let index = index_with_external("foo", &["x"]);
1061 let mut docs = BTreeMap::new();
1062 docs.insert(
1063 (SymbolKind::External, "foo".to_string()),
1064 inline(&[("x", "actor_id")], None, None),
1065 );
1066
1067 let (metas, diags) = analyze_externals(
1068 &index,
1069 &docs,
1070 &BTreeMap::new(),
1071 &BTreeMap::new(),
1072 ExternalCheckSeverity::Error,
1073 false, );
1075 assert!(
1076 diags.is_empty(),
1077 "no manifest: tolerated, no E040: {diags:?}"
1078 );
1079 assert!(
1080 meta_for(&metas, &index, "foo").params[0]
1081 .ty
1082 .as_ref()
1083 .unwrap()
1084 .base
1085 .is_none(),
1086 "type is still opaque (base: None) — just not diagnosed"
1087 );
1088 }
1089
1090 #[test]
1094 fn unknown_semantic_type_still_errors_with_manifest_registered() {
1095 let index = index_with_external("foo", &["x"]);
1096 let mut types = BTreeMap::new();
1097 types.insert(
1098 "actor_id".to_string(),
1099 SemanticTypeDef {
1100 name: "actor_id".to_string(),
1101 base: BaseType::String,
1102 constraint: None,
1103 values: None,
1104 widget: None,
1105 },
1106 );
1107 let mut docs = BTreeMap::new();
1108 docs.insert(
1109 (SymbolKind::External, "foo".to_string()),
1110 inline(&[("x", "totally_bogus")], None, None),
1111 );
1112
1113 let (_metas, diags) = analyze_externals(
1114 &index,
1115 &docs,
1116 &types,
1117 &BTreeMap::new(),
1118 ExternalCheckSeverity::Error,
1119 true, );
1121 assert_eq!(
1122 diags.len(),
1123 1,
1124 "manifest present: unknown type still errors"
1125 );
1126 assert_eq!(diags[0].code, DiagnosticCode::E040);
1127 }
1128
1129 #[test]
1130 fn arity_disagreement_emits_e039() {
1131 let index = index_with_external("has", &["item"]); let reg_ext = ManifestExternal {
1133 name: "has".to_string(),
1134 params: vec![
1135 ManifestParam {
1136 name: "item".to_string(),
1137 ty: TypeRef("string".to_string()),
1138 },
1139 ManifestParam {
1140 name: "qty".to_string(),
1141 ty: TypeRef("int".to_string()),
1142 },
1143 ],
1144 returns: TypeRef::default(),
1145 kind: ExternalKind::default(),
1146 doc: None,
1147
1148 widgets: vec![],
1149 path: Vec::new(),
1150 };
1151 let mut registered = BTreeMap::new();
1152 registered.insert("has".to_string(), ®_ext);
1153
1154 let (_metas, diags) = analyze_externals(
1155 &index,
1156 &BTreeMap::new(),
1157 &BTreeMap::new(),
1158 ®istered,
1159 ExternalCheckSeverity::Error,
1160 true,
1161 );
1162 assert_eq!(diags.len(), 1);
1163 assert_eq!(diags[0].code, DiagnosticCode::E039);
1164 }
1165
1166 #[test]
1167 fn severity_off_suppresses_diagnostics_but_keeps_meta() {
1168 let index = index_with_external("foo", &["x"]);
1169 let mut docs = BTreeMap::new();
1170 docs.insert(
1171 (SymbolKind::External, "foo".to_string()),
1172 inline(&[("x", "bogus")], None, None),
1173 );
1174
1175 let (metas, diags) = analyze_externals(
1176 &index,
1177 &docs,
1178 &BTreeMap::new(),
1179 &BTreeMap::new(),
1180 ExternalCheckSeverity::Off,
1181 true,
1182 );
1183 assert!(diags.is_empty(), "Off suppresses diagnostics");
1184 assert!(!metas.is_empty(), "enrichment still built when Off");
1185 }
1186
1187 fn index_with_callables() -> SymbolIndex {
1191 let mut m = SymbolManifest::default();
1192 m.knots.push(DeclaredSymbol {
1193 name: "damage".to_string(),
1194 range: TextRange::new(TextSize::new(0), TextSize::new(6)),
1195 params: vec![ParamInfo {
1196 name: "weapon".to_string(),
1197 is_ref: false,
1198 is_divert: false,
1199 }],
1200 detail: Some("function".to_string()),
1201 visibility: None,
1202 was: None,
1203 });
1204 m.stitches.push(DeclaredSymbol {
1205 name: "hub.market".to_string(),
1206 range: TextRange::new(TextSize::new(10), TextSize::new(16)),
1207 params: Vec::new(),
1208 detail: None,
1209 visibility: None,
1210 was: None,
1211 });
1212 merge_manifests(&[(FileId(0), &m)]).0
1213 }
1214
1215 fn meta_for_kind<'a>(
1216 metas: &'a BTreeMap<DefinitionId, SymbolMeta>,
1217 index: &SymbolIndex,
1218 kind: SymbolKind,
1219 name: &str,
1220 ) -> &'a SymbolMeta {
1221 let id = index
1222 .symbols
1223 .values()
1224 .find(|s| s.kind == kind && s.name == name)
1225 .expect("symbol in index")
1226 .id;
1227 metas.get(&id).expect("meta for symbol")
1228 }
1229
1230 #[test]
1231 fn knot_doc_enriches_meta_with_resolved_types() {
1232 let index = index_with_callables();
1233 let mut docs = BTreeMap::new();
1234 docs.insert(
1235 (SymbolKind::Knot, "damage".to_string()),
1236 DocBlock {
1237 doc: Some("Damage roll.".to_string()),
1238 params: vec![("weapon".to_string(), TypeRef("item_id".to_string()))],
1239 returns: Some(TypeRef("int".to_string())),
1240 kind: None,
1241 },
1242 );
1243 let mut types = BTreeMap::new();
1244 types.insert(
1245 "item_id".to_string(),
1246 SemanticTypeDef {
1247 name: "item_id".to_string(),
1248 base: BaseType::String,
1249 constraint: None,
1250 values: None,
1251 widget: None,
1252 },
1253 );
1254
1255 let (metas, diags) =
1256 enrich_callables(&index, &docs, &types, ExternalCheckSeverity::Error, true);
1257 assert!(diags.is_empty(), "known types: {diags:?}");
1258 let meta = meta_for_kind(&metas, &index, SymbolKind::Knot, "damage");
1259 assert_eq!(meta.doc.as_deref(), Some("Damage roll."));
1260 assert_eq!(meta.kind, ExternalKind::Plain);
1261 assert_eq!(
1262 meta.params[0].ty.as_ref().and_then(|t| t.base),
1263 Some(BaseType::String)
1264 );
1265 assert_eq!(
1266 meta.returns.as_ref().and_then(|t| t.base),
1267 Some(BaseType::Int)
1268 );
1269 }
1270
1271 #[test]
1272 fn stitch_doc_keyed_by_qualified_name() {
1273 let index = index_with_callables();
1274 let mut docs = BTreeMap::new();
1275 docs.insert(
1276 (SymbolKind::Stitch, "hub.market".to_string()),
1277 DocBlock {
1278 doc: Some("The market square.".to_string()),
1279 params: Vec::new(),
1280 returns: None,
1281 kind: None,
1282 },
1283 );
1284 let (metas, diags) = enrich_callables(
1285 &index,
1286 &docs,
1287 &BTreeMap::new(),
1288 ExternalCheckSeverity::Error,
1289 true,
1290 );
1291 assert!(diags.is_empty());
1292 let meta = meta_for_kind(&metas, &index, SymbolKind::Stitch, "hub.market");
1293 assert_eq!(meta.doc.as_deref(), Some("The market square."));
1294 }
1295
1296 #[test]
1297 fn unknown_semantic_type_on_knot_emits_e040() {
1298 let index = index_with_callables();
1299 let mut docs = BTreeMap::new();
1300 docs.insert(
1301 (SymbolKind::Knot, "damage".to_string()),
1302 DocBlock {
1303 doc: None,
1304 params: vec![("weapon".to_string(), TypeRef("bogus".to_string()))],
1305 returns: None,
1306 kind: None,
1307 },
1308 );
1309 let (metas, diags) = enrich_callables(
1310 &index,
1311 &docs,
1312 &BTreeMap::new(),
1313 ExternalCheckSeverity::Error,
1314 true,
1315 );
1316 assert_eq!(diags.len(), 1);
1317 assert_eq!(diags[0].code, DiagnosticCode::E040);
1318 let meta = meta_for_kind(&metas, &index, SymbolKind::Knot, "damage");
1320 assert!(meta.params[0].ty.as_ref().is_some_and(|t| t.base.is_none()));
1321
1322 let (metas, diags) = enrich_callables(
1324 &index,
1325 &docs,
1326 &BTreeMap::new(),
1327 ExternalCheckSeverity::Off,
1328 true,
1329 );
1330 assert!(diags.is_empty());
1331 assert!(!metas.is_empty());
1332 }
1333
1334 #[test]
1338 fn unknown_semantic_type_on_knot_tolerated_without_manifest() {
1339 let index = index_with_callables();
1340 let mut docs = BTreeMap::new();
1341 docs.insert(
1342 (SymbolKind::Knot, "damage".to_string()),
1343 DocBlock {
1344 doc: None,
1345 params: vec![("weapon".to_string(), TypeRef("item_id".to_string()))],
1346 returns: None,
1347 kind: None,
1348 },
1349 );
1350 let (metas, diags) = enrich_callables(
1351 &index,
1352 &docs,
1353 &BTreeMap::new(),
1354 ExternalCheckSeverity::Error,
1355 false, );
1357 assert!(
1358 diags.is_empty(),
1359 "no manifest: tolerated, no E040: {diags:?}"
1360 );
1361 let meta = meta_for_kind(&metas, &index, SymbolKind::Knot, "damage");
1362 assert!(meta.params[0].ty.as_ref().is_some_and(|t| t.base.is_none()));
1363 }
1364
1365 #[test]
1366 fn undocumented_callables_get_no_meta() {
1367 let index = index_with_callables();
1368 let (metas, diags) = enrich_callables(
1369 &index,
1370 &BTreeMap::new(),
1371 &BTreeMap::new(),
1372 ExternalCheckSeverity::Error,
1373 true,
1374 );
1375 assert!(metas.is_empty());
1376 assert!(diags.is_empty());
1377 }
1378
1379 fn analyze_source(src: &str) -> crate::AnalysisResult {
1383 let parsed = brink_syntax::parse(src);
1384 let tree = parsed.tree();
1385 let (hir, manifest, diags) = brink_ir::hir::lower(FileId(0), &tree);
1386 assert!(diags.is_empty(), "lowering diagnostics: {diags:?}");
1387 crate::analyze(&[(FileId(0), &hir, &manifest)])
1388 }
1389
1390 fn meta_by_name<'a>(
1391 result: &'a crate::AnalysisResult,
1392 kind: SymbolKind,
1393 name: &str,
1394 ) -> &'a SymbolMeta {
1395 let id = result
1396 .index
1397 .symbols
1398 .values()
1399 .find(|s| s.kind == kind && s.name == name)
1400 .expect("symbol in index")
1401 .id;
1402 result.symbol_meta.get(&id).expect("meta for symbol")
1403 }
1404
1405 #[test]
1406 fn var_initializer_types_are_inferred() {
1407 let result = analyze_source(
1408 "VAR health = 100\nVAR speed = 0.5\nVAR alive = true\nVAR name = \"Ada\"\n",
1409 );
1410 let ty = |name: &str| {
1411 meta_by_name(&result, SymbolKind::Variable, name)
1412 .value
1413 .as_ref()
1414 .expect("value meta")
1415 .ty
1416 .clone()
1417 };
1418 assert_eq!(ty("health"), Some(InferredType::Int));
1419 assert_eq!(ty("speed"), Some(InferredType::Float));
1420 assert_eq!(ty("alive"), Some(InferredType::Bool));
1421 assert_eq!(ty("name"), Some(InferredType::String));
1422 assert!(
1424 meta_by_name(&result, SymbolKind::Variable, "health")
1425 .value
1426 .as_ref()
1427 .is_some_and(|v| v.value_text.is_none())
1428 );
1429 }
1430
1431 #[test]
1432 fn const_gets_type_and_display_value() {
1433 let result = analyze_source(
1434 "CONST SPEED = 0.5\nCONST LIVES = -3\nCONST NAME = \"Ada\"\nCONST WHOLE = 1.0\n",
1435 );
1436 let value = |name: &str| {
1437 meta_by_name(&result, SymbolKind::Constant, name)
1438 .value
1439 .clone()
1440 .expect("value meta")
1441 };
1442 assert_eq!(value("SPEED").ty, Some(InferredType::Float));
1443 assert_eq!(value("SPEED").value_text.as_deref(), Some("0.5"));
1444 assert_eq!(value("LIVES").ty, Some(InferredType::Int));
1445 assert_eq!(value("LIVES").value_text.as_deref(), Some("-3"));
1446 assert_eq!(value("NAME").value_text.as_deref(), Some("\"Ada\""));
1447 assert_eq!(
1448 value("WHOLE").value_text.as_deref(),
1449 Some("1.0"),
1450 "whole floats keep a trailing .0"
1451 );
1452 }
1453
1454 #[test]
1455 fn docs_attach_to_values_and_lists() {
1456 let result = analyze_source(
1457 "/// Player health.\nVAR health = 100\n/// Mood states.\nLIST mood = happy, sad\n",
1458 );
1459 assert_eq!(
1460 meta_by_name(&result, SymbolKind::Variable, "health")
1461 .doc
1462 .as_deref(),
1463 Some("Player health.")
1464 );
1465 let list_meta = meta_by_name(&result, SymbolKind::List, "mood");
1466 assert_eq!(list_meta.doc.as_deref(), Some("Mood states."));
1467 assert!(list_meta.value.is_none(), "lists carry docs only");
1468 }
1469
1470 #[test]
1471 fn divert_target_initializer_infers_divert() {
1472 let result = analyze_source("VAR exit = -> hub\n== hub ==\ntext\n-> DONE\n");
1473 assert_eq!(
1474 meta_by_name(&result, SymbolKind::Variable, "exit")
1475 .value
1476 .as_ref()
1477 .and_then(|v| v.ty.clone()),
1478 Some(InferredType::Divert)
1479 );
1480 }
1481
1482 #[test]
1483 fn list_literal_var_infers_the_declaring_list_name() {
1484 let result = analyze_source("LIST Weathers = sunny, rainy, snowy\nVAR w = (sunny)\n");
1488 assert_eq!(
1489 meta_by_name(&result, SymbolKind::Variable, "w")
1490 .value
1491 .as_ref()
1492 .and_then(|v| v.ty.clone()),
1493 Some(InferredType::List("Weathers".to_string()))
1494 );
1495 }
1496
1497 #[test]
1498 fn list_literal_var_infers_the_list_name_when_qualified() {
1499 let result = analyze_source(
1500 "LIST Weathers = sunny, rainy\nVAR w = (Weathers.sunny, Weathers.rainy)\n",
1501 );
1502 assert_eq!(
1503 meta_by_name(&result, SymbolKind::Variable, "w")
1504 .value
1505 .as_ref()
1506 .and_then(|v| v.ty.clone()),
1507 Some(InferredType::List("Weathers".to_string()))
1508 );
1509 }
1510
1511 use brink_ir::hir::{
1514 Block, Expr, HirFile, Name, Path as HirPath, Stmt, StringExpr, StringPart,
1515 };
1516
1517 fn rng() -> TextRange {
1518 TextRange::new(TextSize::new(0), TextSize::new(1))
1519 }
1520
1521 fn hir_calling(name: &str, args: Vec<Expr>) -> HirFile {
1523 let path = HirPath {
1524 segments: vec![Name {
1525 text: name.to_string(),
1526 range: rng(),
1527 }],
1528 range: rng(),
1529 crosses_module_wall: false,
1530 };
1531 HirFile {
1532 root_content: Block::from_stmts(vec![Stmt::ExprStmt(Expr::Call(path, args))]),
1533 knots: Vec::new(),
1534 variables: Vec::new(),
1535 constants: Vec::new(),
1536 lists: Vec::new(),
1537 structs: Vec::new(),
1538 externals: Vec::new(),
1539 includes: Vec::new(),
1540 module: None,
1541 imports: Vec::new(),
1542 visibility: Vec::new(),
1543 was_directives: Vec::new(),
1544 allow_scopes: Vec::new(),
1545 element_matches: Vec::new(),
1546 cue_names: Vec::new(),
1547 native: false,
1548 claim_handlers: Vec::new(),
1549 dispatch_handlers: Vec::new(),
1550 }
1551 }
1552
1553 fn typed_meta(ty: ResolvedType) -> SymbolMeta {
1554 SymbolMeta {
1555 doc: None,
1556 kind: ExternalKind::default(),
1557 returns: None,
1558 params: vec![ResolvedParam {
1559 name: "x".to_string(),
1560 ty: Some(ty),
1561 }],
1562 value: None,
1563 group_widgets: Vec::new(),
1564 }
1565 }
1566
1567 fn run_call_check(call: &str, args: Vec<Expr>, meta: &SymbolMeta) -> Vec<Diagnostic> {
1568 let hir = hir_calling(call, args);
1569 let mut n2m: BTreeMap<&str, &SymbolMeta> = BTreeMap::new();
1570 n2m.insert(call, meta);
1571 check_call_sites(&[(FileId(0), &hir)], &n2m)
1572 }
1573
1574 fn string_lit(s: &str) -> Expr {
1575 Expr::String(StringExpr {
1576 parts: vec![StringPart::Literal(s.to_string())],
1577 })
1578 }
1579
1580 #[test]
1581 fn type_mismatch_emits_e041() {
1582 let meta = typed_meta(ResolvedType {
1583 name: "string".to_string(),
1584 base: Some(BaseType::String),
1585 constraint: None,
1586 values: None,
1587 widget: None,
1588 });
1589 let diags = run_call_check("tint", vec![Expr::Int(5)], &meta);
1590 assert_eq!(diags.len(), 1);
1591 assert_eq!(diags[0].code, DiagnosticCode::E041);
1592 }
1593
1594 #[test]
1595 fn matching_literal_no_diagnostic() {
1596 let meta = typed_meta(ResolvedType {
1597 name: "string".to_string(),
1598 base: Some(BaseType::String),
1599 constraint: None,
1600 values: None,
1601 widget: None,
1602 });
1603 let diags = run_call_check("tint", vec![string_lit("ok")], &meta);
1604 assert!(diags.is_empty(), "matching string literal: {diags:?}");
1605 }
1606
1607 #[test]
1608 fn int_widens_to_float_param() {
1609 let meta = typed_meta(ResolvedType {
1610 name: "float".to_string(),
1611 base: Some(BaseType::Float),
1612 constraint: None,
1613 values: None,
1614 widget: None,
1615 });
1616 let diags = run_call_check("scale", vec![Expr::Int(3)], &meta);
1617 assert!(
1618 diags.is_empty(),
1619 "int literal accepted for float param: {diags:?}"
1620 );
1621 }
1622
1623 #[test]
1624 fn non_literal_arg_skipped() {
1625 let meta = typed_meta(ResolvedType {
1627 name: "string".to_string(),
1628 base: Some(BaseType::String),
1629 constraint: None,
1630 values: None,
1631 widget: None,
1632 });
1633 let var = Expr::Path(HirPath {
1634 segments: vec![Name {
1635 text: "v".to_string(),
1636 range: rng(),
1637 }],
1638 range: rng(),
1639 crosses_module_wall: false,
1640 });
1641 let diags = run_call_check("tint", vec![var], &meta);
1642 assert!(
1643 diags.is_empty(),
1644 "non-literal arg is not checked: {diags:?}"
1645 );
1646 }
1647
1648 #[test]
1649 fn enum_violation_emits_e042() {
1650 let meta = typed_meta(ResolvedType {
1651 name: "item_id".to_string(),
1652 base: Some(BaseType::String),
1653 constraint: Some(Constraint::Enum {
1654 values: vec!["sword".into(), "shield".into()],
1655 }),
1656 values: None,
1657 widget: None,
1658 });
1659 let bad = run_call_check("give", vec![string_lit("banana")], &meta);
1660 assert_eq!(bad.len(), 1);
1661 assert_eq!(bad[0].code, DiagnosticCode::E042);
1662 let ok = run_call_check("give", vec![string_lit("sword")], &meta);
1663 assert!(ok.is_empty(), "valid enum value: {ok:?}");
1664 }
1665
1666 #[test]
1667 fn range_violation_emits_e042() {
1668 let meta = typed_meta(ResolvedType {
1669 name: "percent".to_string(),
1670 base: Some(BaseType::Int),
1671 constraint: Some(Constraint::Range {
1672 min: Some(0),
1673 max: Some(100),
1674 }),
1675 values: None,
1676 widget: None,
1677 });
1678 let bad = run_call_check("set", vec![Expr::Int(150)], &meta);
1679 assert_eq!(bad.len(), 1);
1680 assert_eq!(bad[0].code, DiagnosticCode::E042);
1681 let ok = run_call_check("set", vec![Expr::Int(50)], &meta);
1682 assert!(ok.is_empty(), "in-range value: {ok:?}");
1683 }
1684}