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