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