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