Skip to main content

brink_analyzer/
external_check.rs

1//! Symbol metadata enrichment: host-manifest checks for externals, doc/type
2//! enrichment for knots and stitches, and initializer info for VAR/CONST.
3//!
4//! For externals, merges inline `///` doc-comments (parsed during HIR
5//! lowering) with the registered [`HostManifest`] into [`SymbolMeta`] (keyed
6//! by `DefinitionId`), and emits manifest-driven diagnostics. Knots/stitches
7//! get doc-only enrichment ([`enrich_callables`]); VAR/CONST/LIST get
8//! initializer-derived value info ([`infer_value_meta`]). Tooling /
9//! author-time only — the runtime and codegen never see any of this.
10//!
11//! The enrichment map is always built (the IDE consumes it for hover /
12//! signature even with checks disabled); only the *diagnostics* are gated by
13//! the [`ExternalCheckSeverity`] policy.
14//!
15//! Semantic-type resolution additionally degrades gracefully when **no**
16//! [`HostManifest`] is registered at all: an unknown type name is treated as
17//! opaque (no `E040`) rather than hard-erroring, so ink that references host
18//! vocabulary (e.g. `actor_id`) still compiles host-free. Once a manifest is
19//! registered, checking is fully binding again — an unresolved name is a real
20//! `E040`. See issue #339. A host that wants strict checking even without a
21//! manifest (e.g. to catch typo'd semantic-type tags) can raise the
22//! [`SemanticTypeDiagnosticSeverity`] lever to `Error`. See issue #532.
23
24use 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/// Severity policy for manifest-driven external checks. Configurable as a
34/// compiler/IDE flag; defaults to `Error` (a registered manifest is binding).
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
36pub enum ExternalCheckSeverity {
37    /// Emit manifest-driven diagnostics (default).
38    #[default]
39    Error,
40    /// Suppress manifest-driven diagnostics (enrichment is still built).
41    Off,
42}
43
44/// Severity policy for unknown-semantic-type diagnostics (`E040`), parallel to
45/// [`ExternalCheckSeverity`]. Configurable as a compiler/IDE flag; defaults to
46/// `Tolerant` — the #339/#527 default-tolerant path, where an unresolved
47/// semantic type is only diagnosed once a [`HostManifest`](brink_ir::HostManifest)
48/// is registered. Raising it to `Error` opts back into strict checking (`E040`
49/// fires for any unresolved type even with no manifest registered) — e.g. so a
50/// host can catch typo'd semantic-type tags before wiring up a full manifest
51/// (#532).
52#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
53pub enum SemanticTypeDiagnosticSeverity {
54    /// Tolerate unknown semantic types when no manifest is registered —
55    /// opaque, no `E040` (default).
56    #[default]
57    Tolerant,
58    /// Always emit `E040` for an unresolved semantic type, even with no
59    /// manifest registered.
60    Error,
61}
62
63/// Per-symbol merged metadata (docs, types, values), surfaced to the IDE and
64/// used by the call-site checks. Keyed by the symbol's `DefinitionId` on the
65/// `AnalysisResult`. For externals this merges inline docs with the registered
66/// host manifest; knots/stitches carry inline docs only; VAR/CONST add an
67/// inferred initializer value.
68#[derive(Debug, Clone, Default, PartialEq, Eq)]
69pub struct SymbolMeta {
70    /// Free-text documentation.
71    pub doc: Option<String>,
72    /// Presentation/effect category (informational; externals only —
73    /// `Plain` everywhere else).
74    pub kind: ExternalKind,
75    /// Resolved return type, if specified.
76    pub returns: Option<ResolvedType>,
77    /// Resolved parameter types, by ink declaration order.
78    pub params: Vec<ResolvedParam>,
79    /// Initializer-derived value info (VAR/CONST only).
80    pub value: Option<ValueMeta>,
81    /// Arg-group widgets declared on the external (argument-widget spec §2);
82    /// empty for non-externals.
83    pub group_widgets: Vec<brink_ir::ArgGroupWidget>,
84}
85
86/// Initializer-derived metadata for a VAR or CONST declaration. Purely
87/// presentational — ink variables are dynamically retyped at runtime, so this
88/// never drives diagnostics.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct ValueMeta {
91    /// Type inferred from the initializer literal, if it is one.
92    pub ty: Option<InferredType>,
93    /// Display text of the initializer value (CONST only), e.g. `"0.5"`.
94    pub value_text: Option<String>,
95}
96
97/// The type of a VAR/CONST initializer literal. Deliberately separate from
98/// the host-manifest `BaseType` vocabulary — `Divert`/`List` are ink runtime
99/// concepts that must not leak into the manifest serialization schema.
100///
101/// `List` carries the declaring LIST's name (issue #628): a list-literal
102/// initializer's type is nominal (`List<L>`), same as every other list type
103/// in the `Ty` universe (`Ty::List`, TM-2's `List<L>` annotation) — the
104/// scalar/divert variants have no such nominal identity, so they stay bare.
105/// Not `Copy` any more (the `String` payload), unlike before.
106#[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    /// Display name, as shown in hover (e.g. `health: int`,
118    /// `weather: List<Weathers>`).
119    #[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/// A merged parameter: name (from the ink declaration) and resolved type.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct ResolvedParam {
135    pub name: String,
136    pub ty: Option<ResolvedType>,
137}
138
139/// A resolved type reference: the written name, its base type (if resolvable),
140/// and any closed-domain constraint (from a semantic type definition).
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct ResolvedType {
143    /// The type name as written (a base keyword or a semantic-type name).
144    pub name: String,
145    /// The underlying base type, if resolvable.
146    pub base: Option<BaseType>,
147    /// A closed-domain constraint, for literal-argument validation.
148    pub constraint: Option<Constraint>,
149    /// The picker's value source (Tier 3, #174) — advisory; drives the
150    /// argument picker + value-label inlay hints, never checked against.
151    pub values: Option<brink_ir::ValueSource>,
152    /// The studio-builtin argument widget for this type (argument-widget spec)
153    /// — advisory; drives the inline affordance + editor, never checked.
154    pub widget: Option<brink_ir::WidgetDecl>,
155}
156
157impl ResolvedType {
158    /// Whether this type actually resolved against a base keyword or a
159    /// registered semantic type — `false` means `name` is neither (#1027):
160    /// [`resolve_type`] still builds a `ResolvedType` for an unregistered
161    /// name (so callers keep the written name for display), but `base` is
162    /// `None` in that case. Consumers that render a type with unconditional
163    /// confidence (hover, signature help) must check this first — showing
164    /// `id: var_id` for an unregistered `var_id` is exactly the #1004
165    /// divergence this issue closes.
166    #[must_use]
167    pub fn is_registered(&self) -> bool {
168        self.base.is_some()
169    }
170}
171
172/// Build the per-external enrichment map and collect manifest-driven
173/// diagnostics. The map is always returned; diagnostics are empty when the
174/// severity policy is `Off`.
175///
176/// `check_unknown_types` gates unknown-semantic-type checking (`E040`):
177/// callers pass `true` when either a `HostManifest` is registered or the
178/// [`SemanticTypeDiagnosticSeverity`] lever is raised to `Error`; otherwise an
179/// unresolved type name is tolerated as opaque rather than diagnosed
180/// (#339/#532).
181pub 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    // Deterministic order for diagnostics: sort externals by (file, offset).
193    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; // no enrichment for this external
205        }
206
207        // Arity disagreement: registered manifest vs the (authoritative) ink decl.
208        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        // Param types: inline `@param` (by name) wins, else registered (by position).
226        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        // Return type, kind, doc: inline wins, else registered.
240        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
271/// Build doc/type enrichment for knots and stitches from their inline `///`
272/// docs. Signature tags (`@param` / `@returns`) resolve against the same
273/// semantic-type vocabulary as externals (E040 on unknown types, tolerated as
274/// opaque when `check_unknown_types` is `false` — #339/#532), but unlike
275/// externals there are no call-site checks — callable metadata is
276/// presentational only.
277pub 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    // Deterministic order for diagnostics: sort callables by (file, offset).
288    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        // `@param` tags match declared params by name; unmatched tags are
301        // ignored (same leniency as externals).
302        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
343/// Build VAR/CONST/LIST metadata: initializer-inferred types, CONST display
344/// values, and attached `///` docs. Purely presentational — ink variables are
345/// dynamically retyped at runtime, so this never produces diagnostics.
346pub 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        // Lists carry docs only — there is nothing to infer.
377        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
392/// Insert a [`SymbolMeta`] for one VAR/CONST/LIST declaration, if it has a
393/// doc or an inferable initializer.
394fn 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
436/// The [`InferredType`] of an initializer literal, or `None` for anything
437/// whose type isn't statically obvious (calls, references, arithmetic).
438///
439/// `index` resolves a `ListLiteral`'s items to their declaring LIST (issue
440/// #628) — every other arm is purely syntactic and ignores it.
441pub(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
457/// The declaring LIST name for a list literal's items (issue #628), if any
458/// item resolves unambiguously. Mirrors `infer::body::infer_list_literal`'s
459/// policy exactly — first-resolved-item wins, so this phase-0 stub and the
460/// real per-body HM inference never disagree: a "mixed" literal whose items
461/// span more than one LIST (legal ink; the spec has no ruling narrowing it
462/// further) reports whichever list its first resolvable item belongs to, not
463/// a synthesized union. An item that doesn't resolve to a known list item
464/// (typo, or a genuinely ambiguous bare name with no qualifying prefix) is
465/// skipped, same "Unknown escape" fallback every other unrepresentable
466/// initializer already gets — not a silent drop, since `None` still surfaces
467/// as "no inferred type" rather than a wrong guess.
468fn 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
474/// One list-literal item's declaring LIST name, resolved the same way
475/// `resolve::lookup_list_item_bare` (bare form) and the qualified-list-item
476/// branch of `resolve::lookup_by_name` (qualified form) do. List items are
477/// always project-global — never locally scoped — so no `ImportScope`/
478/// per-file resolution map is needed here, unlike general path resolution.
479fn 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        // Qualified `ListName.ItemName` — an exact index hit is authoritative,
490        // same as `resolve::lookup_by_name`'s qualified-list-item branch.
491        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    // Bare — suffix-match over every declared list item; ambiguous (two-plus
502    // lists share this item name) or unresolved both fall through to `None`.
503    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
515/// Display text for a literal initializer (CONST hover), e.g. `0.5`,
516/// `"sword"`, `-> hub`. `None` for non-literals and interpolated strings.
517fn 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
532/// Format a float for display, keeping a trailing `.0` so it still reads as
533/// a float (`1.0`, not `1`).
534fn 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
551/// Resolve a [`TypeRef`] to a [`ResolvedType`], emitting E040 for an unknown
552/// semantic type — but only when `check_unknown_types` is `true`. With no manifest
553/// registered, semantic types are opaque by default (host vocabulary can't be
554/// validated against nothing) so an unresolved name is silently accepted as
555/// `base: None` rather than diagnosed (#339). Returns `None` for an
556/// unspecified (empty) ref.
557///
558/// Classification (base keyword / registered / unregistered) goes through
559/// [`crate::type_resolution::classify`] — the same function
560/// `infer::type_ref_to_ty` (strict inference) uses — so an unregistered name
561/// is classified identically on both paths (#1027). A `base: None`
562/// [`ResolvedType`] coming out of the `Unregistered` arm below is this
563/// function's honest way of saying "unresolved"; see
564/// [`ResolvedType::is_registered`] for the flag consumers must check before
565/// rendering the name with any confidence.
566fn 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
616// ─── Call-site literal checks (E041 type, E042 closed-domain) ───────────
617
618/// Walk the HIR and check literal arguments at external call sites against the
619/// merged [`SymbolMeta`]. Only literal arguments are checked (ink is
620/// dynamically typed); non-literals and untyped params are skipped, so there
621/// are no false positives. Returns the diagnostics (caller gates on severity).
622pub 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
641/// Visits every call site in a file via the shared HIR visitor, checking each
642/// literal argument against the merged [`SymbolMeta`].
643struct 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; // not an external we have metadata for
676    };
677    for (i, arg) in args.iter().enumerate() {
678        let Some(param) = meta.params.get(i) else {
679            continue; // surplus args — arity is checked elsewhere
680        };
681        let Some(ty) = &param.ty else {
682            continue; // untyped param — nothing to check
683        };
684        check_literal_arg(file, path, &name, arg, ty, diags);
685    }
686}
687
688/// Check one literal argument against a resolved param type. Non-literals are
689/// ignored (literals-only). A type mismatch suppresses the domain check.
690fn 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
719/// The base type of a literal expression, or `None` if not a literal.
720fn 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
730/// Whether a literal of base `lit` is acceptable for a param of base
731/// `expected`. Int widens to Float; otherwise an exact match is required.
732fn 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        // No handle literal syntax exists (T1d-1), so `literal_base` never
744        // produces this arm in practice — kept exhaustive so a future
745        // literal form can't silently skip this display path.
746        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        // Regex enforcement is deferred (no regex dependency at the MVP); the
795        // pattern is still surfaced to the IDE via SymbolMeta.
796        Constraint::Regex { .. } => {}
797    }
798}
799
800/// The string value of a plain (non-interpolated) string literal.
801fn 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, // interpolated — value not statically known
807    }
808}
809
810/// The numeric value of an int/float literal as `f64`.
811fn 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(), &reg_ext);
929        let (metas, _) = analyze_externals(
930            &index,
931            &BTreeMap::new(),
932            &BTreeMap::new(),
933            &registered,
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(), &reg_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            &registered,
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        // Meta is still built, with an unresolved (base: None) param type.
1045        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    /// #339: with no `HostManifest` registered at all, an unresolved
1056    /// semantic type is tolerated as opaque — no E040 — rather than
1057    /// hard-erroring. Enrichment is still built (base: None).
1058    #[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, // no manifest registered
1074        );
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    /// #339, other half: once a manifest *is* registered, checking is fully
1091    /// binding again — a genuinely unknown type still emits E040 even though
1092    /// other types resolve fine.
1093    #[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, // manifest registered (has `actor_id`, but not `totally_bogus`)
1120        );
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"]); // ink: 1 param
1132        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(), &reg_ext);
1153
1154        let (_metas, diags) = analyze_externals(
1155            &index,
1156            &BTreeMap::new(),
1157            &BTreeMap::new(),
1158            &registered,
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    // ── Callable (knot/stitch) doc enrichment ────────────────────
1188
1189    /// An index with one function knot and one (qualified) stitch.
1190    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        // Meta still built with an unresolved param type.
1319        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        // Severity Off keeps the meta but suppresses the diagnostic.
1323        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    /// #339: with no manifest registered, an unresolved semantic type on a
1335    /// knot/stitch doc tag is tolerated as opaque too (callables share the
1336    /// same vocabulary as externals).
1337    #[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, // no manifest registered
1356        );
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    // ── VAR/CONST/LIST value metadata ────────────────────────────
1380
1381    /// Parse, lower, and fully analyze a single source file.
1382    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        // VARs never get display values — only CONSTs do.
1423        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        // Issue #628: a VAR initialized directly to a list literal must keep
1485        // the nominal LIST identity through the phase-0 `Sig`
1486        // stub/`SymbolMeta` enrichment, not collapse to a bare "list".
1487        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    // ── Call-site literal checks (E041, E042) ────────────────────
1512
1513    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    /// A HIR file whose root content is a single `~ name(args)` expression statement.
1522    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        // A variable reference is not a literal — never flagged (literals-only).
1626        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}