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