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