Skip to main content

pine_sema/
analyzer.rs

1//! The semantic analyzer: a scope-aware walk that emits Tier 1 (name
2//! resolution) and Tier 4 (structural) errors.
3//!
4//! This intentionally does **not** use the shared [`pine_ast::Visitor`]. That
5//! traversal is for observational passes; sema needs to push/pop a scope at
6//! every block boundary, hoist declarations, and track context (loop depth,
7//! global-vs-local), which the default recurse-everything walk doesn't express.
8//! So we hand-write the recursion and interleave the scope bookkeeping.
9
10use std::collections::{HashMap, HashSet};
11
12use pine_ast::{Argument, ExportItem, Expr, FunctionParam, Literal, Loc, Program, Stmt};
13use pine_core::{LibraryLoader, PineOutput};
14use pine_interpreter::{BuiltinSignature, Value};
15use pine_parser::Parser;
16
17use crate::scope::{is_global_only, Namespace, SymbolKind};
18use crate::symbols::{FileId, ScopeId, ScopeKind, Symbol, SymbolId, SymbolTable};
19use pine_diagnostics::Diagnostic;
20
21pub struct Analyzer<'a, O: PineOutput> {
22    diagnostics: Vec<Diagnostic>,
23    /// Enclosing loops in the current function (reset at function boundaries).
24    loop_depth: u32,
25    /// The runtime's registered built-ins (namespaces, globals, per-bar variables).
26    builtins: &'a HashMap<String, Value<O>>,
27    /// Script declarations seen (indicator/strategy/library); at most one allowed.
28    declarations: u32,
29    /// Whether the current file declared `library(...)` — required of an import.
30    library_declared: bool,
31    /// Free functions, `name -> (required, total)` param counts, for arity checks.
32    functions: HashMap<String, (usize, usize)>,
33    /// User type/enum names, collected up front for forward-referencing annotations.
34    user_types: HashSet<String>,
35    /// Functions enclosing the current point; the last is the caller of any call.
36    fn_stack: Vec<String>,
37    /// The call graph `(caller, callee, call-site)`, scanned afterwards for cycles.
38    call_edges: Vec<CallEdge>,
39    /// The durable symbol table; `scope_ids` tracks the current (innermost-last) scope.
40    symbols: SymbolTable,
41    scope_ids: Vec<ScopeId>,
42    /// Resolves `import` paths to source; absent means no cross-file resolution.
43    loader: Option<&'a dyn LibraryLoader>,
44}
45
46/// Per-file state saved and restored around analyzing a library.
47struct FileState {
48    scope_ids: Vec<ScopeId>,
49    loop_depth: u32,
50    functions: HashMap<String, (usize, usize)>,
51    user_types: HashSet<String>,
52    declarations: u32,
53    library_declared: bool,
54    fn_stack: Vec<String>,
55    call_edges: Vec<CallEdge>,
56}
57
58/// One call-graph edge: `(caller, callee, call-site location)`.
59type CallEdge = (String, String, Loc);
60
61/// The script-declaration functions — a script must have exactly one.
62const SCRIPT_DECLARATIONS: &[&str] = &["study", "indicator", "strategy", "library"];
63
64/// Built-in type names an annotation may use without a user declaration.
65const BUILTIN_TYPES: &[&str] = &[
66    "int",
67    "float",
68    "bool",
69    "string",
70    "color",
71    "line",
72    "linefill",
73    "label",
74    "box",
75    "table",
76    "polyline",
77    "array",
78    "matrix",
79    "map",
80    "footprint",
81    "volume_row",
82];
83
84/// The called name as written, for diagnostics: `plot` or `ta.sma`.
85fn callee_name(callee: &Expr) -> String {
86    match callee {
87        Expr::Variable { name, .. } => name.clone(),
88        Expr::MemberAccess { object, member, .. } => match object.as_ref() {
89            Expr::Variable {
90                name: namespace, ..
91            } => format!("{namespace}.{member}"),
92            _ => member.clone(),
93        },
94        _ => String::new(),
95    }
96}
97
98/// Whether `start` reaches `target` in the call graph (a self-call counts).
99fn reaches(start: &str, target: &str, adjacency: &HashMap<&str, Vec<&str>>) -> bool {
100    if start == target {
101        return true;
102    }
103    let mut stack = vec![start];
104    let mut seen = HashSet::new();
105    while let Some(node) = stack.pop() {
106        if !seen.insert(node) {
107            continue;
108        }
109        if let Some(callees) = adjacency.get(node) {
110            for &callee in callees {
111                if callee == target {
112                    return true;
113                }
114                stack.push(callee);
115            }
116        }
117    }
118    false
119}
120
121/// The type names within an annotation: the base and every generic argument,
122/// with `[]`/`<>`/`,` stripped (`map<string, Point>` -> `map`, `string`, `Point`).
123fn type_names(annotation: &str) -> impl Iterator<Item = &str> {
124    annotation
125        .split(['<', '>', ',', '[', ']', ' '])
126        .filter(|name| !name.is_empty())
127}
128
129/// How to name a literal's type in a diagnostic.
130fn describe_literal(literal: &Literal) -> &'static str {
131    match literal {
132        Literal::Int(_) | Literal::Number(_) => "a number",
133        Literal::String(_) => "a string",
134        Literal::Bool(_) => "a bool",
135        Literal::HexColor(_) => "a color",
136        Literal::Na => "na",
137    }
138}
139
140impl<'a, O: PineOutput> Analyzer<'a, O> {
141    pub fn new(
142        builtins: &'a HashMap<String, Value<O>>,
143        loader: Option<&'a dyn LibraryLoader>,
144    ) -> Self {
145        Self {
146            diagnostics: Vec::new(),
147            loop_depth: 0,
148            builtins,
149            declarations: 0,
150            library_declared: false,
151            functions: HashMap::new(),
152            user_types: HashSet::new(),
153            fn_stack: Vec::new(),
154            call_edges: Vec::new(),
155            symbols: SymbolTable::new(),
156            scope_ids: vec![SymbolTable::GLOBAL],
157            loader,
158        }
159    }
160
161    /// The innermost open scope — where names resolve from and declarations are
162    /// recorded into.
163    fn current_scope(&self) -> ScopeId {
164        *self.scope_ids.last().expect("scope stack is never empty")
165    }
166
167    fn current_file(&self) -> FileId {
168        self.symbols.scope_file(self.current_scope())
169    }
170
171    fn current_lib(&self) -> Option<String> {
172        let file = self.current_file();
173        (file != SymbolTable::MAIN).then(|| self.symbols.file_path(file).to_string())
174    }
175
176    /// Open a nested scope in the symbol tree and make it current.
177    fn enter_scope(&mut self, kind: ScopeKind) {
178        let child = self.symbols.open_scope(self.current_scope(), kind);
179        self.scope_ids.push(child);
180    }
181
182    /// Close the current scope; its symbols stay in the table as a child scope.
183    fn exit_scope(&mut self) {
184        self.scope_ids.pop();
185    }
186
187    /// Declare a symbol, stamped with the current file.
188    fn record(&mut self, mut symbol: Symbol) -> SymbolId {
189        symbol.file = self.current_file();
190        self.symbols.declare(symbol)
191    }
192
193    /// Resolve `name` from the current scope outward.
194    fn resolve(&self, name: &str) -> Option<SymbolKind> {
195        self.symbols
196            .resolve(self.current_scope(), name)
197            .map(|symbol| symbol.kind)
198    }
199
200    /// Record a use of `name`, if it resolves to a user symbol.
201    fn record_use(&mut self, name: &str, loc: Loc) {
202        let scope = self.current_scope();
203        if let Some(id) = self.symbols.resolve_id(scope, name) {
204            let file = self.current_file();
205            self.symbols.record_use(file, loc.position(), id);
206        }
207    }
208
209    /// A declaration's user type, from an annotation or a `Type.new()` initializer.
210    fn infer_var_type(
211        &self,
212        type_annotation: Option<&String>,
213        initializer: Option<&Expr>,
214    ) -> Option<SymbolId> {
215        if let Some(annotation) = type_annotation {
216            let base = annotation.trim_end_matches("[]");
217            if let Some(id) = self.symbols.resolve_id(self.current_scope(), base) {
218                if matches!(
219                    self.symbols.symbol(id).kind,
220                    SymbolKind::Type | SymbolKind::Enum
221                ) {
222                    return Some(id);
223                }
224            }
225        }
226        // A `Type.new(...)` (or `lib.Type.new(...)`) constructor initializer.
227        if let Some(Expr::Call { callee, .. }) = initializer {
228            if let Expr::MemberAccess { object, member, .. } = callee.as_ref() {
229                if member == "new" {
230                    if let Some(id) = self.expr_type(object) {
231                        if self.symbols.symbol(id).kind == SymbolKind::Type {
232                            return Some(id);
233                        }
234                    }
235                }
236            }
237        }
238        None
239    }
240
241    /// The type/enum a symbol denotes: itself, or a variable's `type_ref`.
242    fn owner_type(&self, id: SymbolId) -> Option<SymbolId> {
243        let symbol = self.symbols.symbol(id);
244        match symbol.kind {
245            SymbolKind::Type | SymbolKind::Enum => Some(id),
246            SymbolKind::Var => symbol.type_ref,
247            _ => None,
248        }
249    }
250
251    /// The type/enum an expression's member access reads from (`Enum.Case`,
252    /// `v.field`, `lib.Point`), or `None` when the type is unknown.
253    fn expr_type(&self, expr: &Expr) -> Option<SymbolId> {
254        let id = match expr {
255            Expr::Variable { name, .. } => self.symbols.resolve_id(self.current_scope(), name)?,
256            Expr::MemberAccess { object, member, .. } => self.resolve_member(object, member)?,
257            _ => return None,
258        };
259        self.owner_type(id)
260    }
261
262    /// The declaration `object.member` points at: a library export, or a member
263    /// of the object's user type.
264    fn resolve_member(&self, object: &Expr, member: &str) -> Option<SymbolId> {
265        if let Some(module) = self.alias_module(object) {
266            return self.symbols.exported_id(module, member);
267        }
268        let owner = self.expr_type(object)?;
269        self.symbols.member_id(owner, member)
270    }
271
272    /// `object.member` where the object's full member set is known — a builtin
273    /// namespace or a resolved import — yet `member` is not among them. False
274    /// whenever the members can't be enumerated (a user variable, an unloaded
275    /// import), so no diagnostic is invented on incomplete information.
276    fn unknown_member(&self, object: &Expr, member: &str) -> bool {
277        // A resolved import alias: its export set is fully known.
278        if let Some(module) = self.alias_module(object) {
279            return self.symbols.exported_id(module, member).is_none();
280        }
281        // A builtin namespace object, not shadowed by a user declaration. Sema
282        // reads the same registry the interpreter calls into, so an absent
283        // field is exactly one a run would reject.
284        if let Expr::Variable { name, .. } = object {
285            if self.resolve(name).is_none() {
286                if let Some(Value::Object { fields, .. }) = self.builtins.get(name) {
287                    return !fields.borrow().contains_key(member);
288                }
289            }
290        }
291        false
292    }
293
294    /// The imported library's global scope, if `object` is an import alias.
295    fn alias_module(&self, object: &Expr) -> Option<ScopeId> {
296        let Expr::Variable { name, .. } = object else {
297            return None;
298        };
299        let id = self.symbols.resolve_id(self.current_scope(), name)?;
300        let symbol = self.symbols.symbol(id);
301        (symbol.kind == SymbolKind::Import)
302            .then_some(symbol.module)
303            .flatten()
304    }
305
306    fn is_builtin(&self, name: &str) -> bool {
307        self.builtins.contains_key(name)
308    }
309
310    /// The signature of the builtin the callee names (`plot` or `ta.sma`), or
311    /// `None` — a user-shadowed name or a builtin with no declared parameters.
312    fn builtin_signature(&self, callee: &Expr) -> Option<&'static BuiltinSignature> {
313        let value = match callee {
314            Expr::Variable { name, .. } => {
315                if self.resolve(name).is_some() {
316                    return None;
317                }
318                self.builtins.get(name)?.clone()
319            }
320            Expr::MemberAccess { object, member, .. } => {
321                let Expr::Variable {
322                    name: namespace, ..
323                } = object.as_ref()
324                else {
325                    return None;
326                };
327                if self.resolve(namespace).is_some() {
328                    return None;
329                }
330                match self.builtins.get(namespace)? {
331                    Value::Object { fields, .. } => fields.borrow().get(member)?.clone(),
332                    _ => return None,
333                }
334            }
335            _ => return None,
336        };
337
338        match value {
339            Value::BuiltinFunction(builtin) if !builtin.signature.params.is_empty() => {
340                Some(builtin.signature)
341            }
342            // A callable namespace object like `plot(...)` carries its `Builtin`.
343            Value::Object {
344                call: Some(builtin),
345                ..
346            } if !builtin.signature.params.is_empty() => Some(builtin.signature),
347            _ => None,
348        }
349    }
350
351    /// Check a call against the builtin's parameters: too many/few arguments, an
352    /// unknown named argument, and a literal of the wrong type.
353    fn check_builtin_args(
354        &mut self,
355        name: &str,
356        signature: &BuiltinSignature,
357        args: &[Argument],
358        loc: Loc,
359    ) {
360        let positional = args
361            .iter()
362            .filter(|arg| matches!(arg, Argument::Positional(_)))
363            .count();
364
365        if let Some(max) = signature.max_positional() {
366            if positional > max {
367                self.emit(
368                    "too-many-arguments",
369                    loc,
370                    format!("`{name}` takes at most {max} arguments, found {positional}"),
371                );
372            }
373        }
374
375        let mut index = 0;
376        for arg in args {
377            let (param, value) = match arg {
378                Argument::Positional(value) => {
379                    let param = signature.positional(index);
380                    index += 1;
381                    (param, value)
382                }
383                Argument::Named { name: label, value } => match signature.named(label) {
384                    Some(param) => (Some(param), value),
385                    None => {
386                        self.emit(
387                            "unknown-argument",
388                            loc,
389                            format!("`{name}` has no argument named `{label}`"),
390                        );
391                        continue;
392                    }
393                },
394            };
395
396            // Only a literal's type is known without inference; anything else
397            // is left to the runtime.
398            let (Some(param), Expr::Literal(literal)) = (param, value) else {
399                continue;
400            };
401            if !param.ty.accepts(literal) {
402                let found = describe_literal(literal);
403                let expected = param.ty.describe();
404                let label = param.name.clone();
405                self.emit(
406                    "argument-type",
407                    loc,
408                    format!("`{name}` expects {expected} for `{label}`, found {found}"),
409                );
410            }
411        }
412
413        // Counting (not position-matching) required params stays sound for
414        // leading-optional overloads like `ta.highest(length)`.
415        let required = signature
416            .params
417            .iter()
418            .filter(|param| param.required)
419            .count();
420        if args.len() < required {
421            self.emit(
422                "too-few-arguments",
423                loc,
424                format!(
425                    "`{name}` requires at least {required} arguments, found {}",
426                    args.len()
427                ),
428            );
429        }
430    }
431
432    /// Analyze one file in its own scope, type set, and call graph.
433    fn run_file(&mut self, program: &Program) {
434        // Types may be referenced before their declaration, so collect them first.
435        for stmt in &program.statements {
436            match stmt {
437                Stmt::TypeDecl { name, .. } | Stmt::EnumDecl { name, .. } => {
438                    self.user_types.insert(name.clone());
439                }
440                _ => {}
441            }
442        }
443        for stmt in &program.statements {
444            self.check_stmt(stmt);
445        }
446        self.detect_recursion();
447    }
448
449    /// Swap in fresh state for a library, returning the caller's to restore.
450    fn enter_file(&mut self, root: ScopeId) -> FileState {
451        FileState {
452            scope_ids: std::mem::replace(&mut self.scope_ids, vec![root]),
453            loop_depth: std::mem::take(&mut self.loop_depth),
454            functions: std::mem::take(&mut self.functions),
455            user_types: std::mem::take(&mut self.user_types),
456            declarations: std::mem::take(&mut self.declarations),
457            library_declared: std::mem::take(&mut self.library_declared),
458            fn_stack: std::mem::take(&mut self.fn_stack),
459            call_edges: std::mem::take(&mut self.call_edges),
460        }
461    }
462
463    fn exit_file(&mut self, saved: FileState) {
464        self.scope_ids = saved.scope_ids;
465        self.loop_depth = saved.loop_depth;
466        self.functions = saved.functions;
467        self.user_types = saved.user_types;
468        self.declarations = saved.declarations;
469        self.library_declared = saved.library_declared;
470        self.fn_stack = saved.fn_stack;
471        self.call_edges = saved.call_edges;
472    }
473
474    /// Analyze the library at `path` once. Registering it before the walk lets a
475    /// re-entrant import find it, which breaks cycles.
476    fn resolve_import(&mut self, path: &str, loc: Loc) -> Option<(FileId, ScopeId)> {
477        if let Some(file) = self.symbols.file_by_path(path) {
478            return Some((file, self.symbols.file_root(file)));
479        }
480        let loader = self.loader?;
481        let source = match loader.load_library(path) {
482            Ok(source) => source,
483            Err(err) => {
484                self.emit(
485                    "import-error",
486                    loc,
487                    format!("cannot load library `{path}`: {err}"),
488                );
489                return None;
490            }
491        };
492        let program = match Parser::parse_source(&source) {
493            Ok(program) => program,
494            Err(err) => {
495                self.emit(
496                    "import-parse-error",
497                    loc,
498                    format!("cannot parse library `{path}`: {err}"),
499                );
500                return None;
501            }
502        };
503        let (file, root) = self.symbols.add_file(path);
504        let saved = self.enter_file(root);
505        self.run_file(&program);
506        let is_library = self.library_declared;
507        self.exit_file(saved);
508        // Reported back in the importing file, at the `import` statement.
509        if !is_library {
510            self.emit(
511                "not-a-library",
512                loc,
513                format!("imported script `{path}` has no `library()` declaration"),
514            );
515        }
516        Some((file, root))
517    }
518
519    /// Analyze a whole program, returning the errors found.
520    pub fn analyze(mut self, program: &Program) -> Vec<Diagnostic> {
521        self.run_file(program);
522        self.diagnostics
523    }
524
525    /// Analyze a whole program, returning both the errors and the symbol table
526    /// reconstructed from the same walk.
527    pub fn into_analysis(mut self, program: &Program) -> (Vec<Diagnostic>, SymbolTable) {
528        self.run_file(program);
529        (self.diagnostics, self.symbols)
530    }
531
532    /// Report call-graph cycles as recursion (Pine forbids it), at the call site.
533    fn detect_recursion(&mut self) {
534        let cycles: Vec<CallEdge> = {
535            let mut adjacency: HashMap<&str, Vec<&str>> = HashMap::new();
536            for (caller, callee, _) in &self.call_edges {
537                adjacency.entry(caller).or_default().push(callee);
538            }
539            self.call_edges
540                .iter()
541                .filter(|(caller, callee, _)| reaches(callee, caller, &adjacency))
542                .cloned()
543                .collect()
544        };
545        for (caller, callee, pos) in cycles {
546            let message = if caller == callee {
547                format!("`{caller}` calls itself; Pine does not allow recursion")
548            } else {
549                format!("`{caller}` and `{callee}` call each other; Pine does not allow recursion")
550            };
551            self.emit("recursion", pos, message);
552        }
553    }
554
555    fn emit(&mut self, rule: &'static str, loc: Loc, message: impl Into<String>) {
556        self.diagnostics
557            .push(Diagnostic::error(rule, loc.position(), message).in_file(self.current_lib()));
558    }
559
560    fn warn(&mut self, rule: &'static str, loc: Loc, message: impl Into<String>) {
561        self.diagnostics
562            .push(Diagnostic::warning(rule, loc.position(), message).in_file(self.current_lib()));
563    }
564
565    /// Warn that a declaration shadows a built-in (Pine allows it, but warns).
566    fn check_shadow(&mut self, name: &str, loc: Loc) {
567        if self.is_builtin(name) {
568            self.warn(
569                "shadows-builtin",
570                loc,
571                format!("declaration of `{name}` shadows a built-in"),
572            );
573        }
574    }
575
576    /// Reject a type annotation naming a type that is neither a built-in nor a
577    /// declared type — including every name inside a generic like `array<Foo>`.
578    fn check_type_annotation(&mut self, annotation: Option<&String>, loc: Loc) {
579        let Some(annotation) = annotation else {
580            return;
581        };
582        for name in type_names(annotation) {
583            if !BUILTIN_TYPES.contains(&name) && !self.user_types.contains(name) {
584                self.emit("unknown-type", loc, format!("unknown type `{name}`"));
585                return;
586            }
587        }
588    }
589
590    /// Check a user-function call's argument count against its parameters.
591    fn check_call_arity(
592        &mut self,
593        name: &str,
594        supplied: usize,
595        required: usize,
596        total: usize,
597        loc: Loc,
598    ) {
599        if supplied < required {
600            self.emit(
601                "too-few-arguments",
602                loc,
603                format!("`{name}` requires at least {required} arguments, found {supplied}"),
604            );
605        } else if supplied > total {
606            self.emit(
607                "too-many-arguments",
608                loc,
609                format!("`{name}` takes at most {total} arguments, found {supplied}"),
610            );
611        }
612    }
613
614    /// Record a user function and walk its body. Declared before the body so a
615    /// self-call inside reads as recursion.
616    fn analyze_function(
617        &mut self,
618        name: &str,
619        loc: Loc,
620        params: &[FunctionParam],
621        body: &[Stmt],
622    ) -> SymbolId {
623        let scope = self.current_scope();
624        if self
625            .symbols
626            .declared_locally_in(scope, name, Namespace::Value)
627        {
628            self.emit(
629                "duplicate-declaration",
630                loc,
631                format!("`{name}` is already declared in this scope"),
632            );
633        }
634        let id = self.record(
635            Symbol::new(name, SymbolKind::Function, loc.position(), scope)
636                .with_params(params.iter().map(|p| p.name.clone()).collect()),
637        );
638        // A parameter with a default may be omitted, so it is not required.
639        let required = params.iter().filter(|p| p.default_value.is_none()).count();
640        self.functions
641            .insert(name.to_string(), (required, params.len()));
642        for param in params {
643            self.check_type_annotation(param.type_annotation.as_ref(), param.loc);
644        }
645        self.fn_stack.push(name.to_string());
646        self.function_body(
647            params.iter().map(|p| {
648                (
649                    p.name.as_str(),
650                    p.default_value.as_ref(),
651                    p.loc,
652                    p.type_annotation.as_ref(),
653                )
654            }),
655            body,
656        );
657        self.fn_stack.pop();
658        id
659    }
660
661    /// Declare `name` in the current scope, reporting a same-scope duplicate.
662    fn declare(&mut self, name: &str, kind: SymbolKind, loc: Loc) -> SymbolId {
663        let scope = self.current_scope();
664        if self
665            .symbols
666            .declared_locally_in(scope, name, kind.namespace())
667        {
668            self.emit(
669                "duplicate-declaration",
670                loc,
671                format!("`{name}` is already declared in this scope"),
672            );
673        }
674        self.record(Symbol::new(name, kind, loc.position(), scope))
675    }
676
677    /// Visit a non-loop nested block (an `if`/`else` branch) in its own scope.
678    fn block(&mut self, body: &[Stmt]) {
679        self.enter_scope(ScopeKind::Block);
680        for stmt in body {
681            self.check_stmt(stmt);
682        }
683        self.exit_scope();
684    }
685
686    /// Visit a loop body with `loop_depth` raised so `break`/`continue` are legal.
687    fn loop_body(&mut self, body: &[Stmt]) {
688        self.loop_depth += 1;
689        for stmt in body {
690            self.check_stmt(stmt);
691        }
692        self.loop_depth -= 1;
693    }
694
695    /// Visit a function body in a fresh scope with `params` bound.
696    fn function_body<'p>(
697        &mut self,
698        params: impl Iterator<Item = (&'p str, Option<&'p Expr>, Loc, Option<&'p String>)>,
699        body: &[Stmt],
700    ) {
701        self.enter_scope(ScopeKind::Function);
702        let saved_loop_depth = self.loop_depth;
703        self.loop_depth = 0;
704        let scope = self.current_scope();
705        for (name, default, loc, type_annotation) in params {
706            if let Some(default) = default {
707                self.check_expr(default);
708            }
709            self.check_shadow(name, loc);
710            if self.symbols.declared_locally(scope, name) {
711                self.emit(
712                    "duplicate-parameter",
713                    loc,
714                    format!("parameter `{name}` is declared more than once"),
715                );
716            }
717            self.record(
718                Symbol::new(name, SymbolKind::Var, loc.position(), scope)
719                    .with_type(type_annotation.cloned()),
720            );
721        }
722        for stmt in body {
723            self.check_stmt(stmt);
724        }
725        self.loop_depth = saved_loop_depth;
726        self.exit_scope();
727    }
728
729    fn check_stmt(&mut self, stmt: &Stmt) {
730        match stmt {
731            Stmt::VarDecl {
732                name,
733                initializer,
734                type_annotation,
735                loc,
736                ..
737            } => {
738                self.check_type_annotation(type_annotation.as_ref(), *loc);
739                self.check_shadow(name, *loc);
740                if let Some(Expr::Function { params, body }) = initializer {
741                    // A named function `f(x) => …`, lowered to a lambda-valued var.
742                    self.analyze_function(name, *loc, params, body);
743                } else {
744                    // Check the initializer *before* declaring the name, so a
745                    // self-reference (`x = x`) resolves against the outer scope.
746                    if let Some(init) = initializer {
747                        self.check_expr(init);
748                    }
749                    let scope = self.current_scope();
750                    if self
751                        .symbols
752                        .declared_locally_in(scope, name, Namespace::Value)
753                    {
754                        self.emit(
755                            "duplicate-declaration",
756                            *loc,
757                            format!(
758                                "`{name}` is already declared in this scope (use `:=` to reassign)"
759                            ),
760                        );
761                    }
762                    let type_ref =
763                        self.infer_var_type(type_annotation.as_ref(), initializer.as_ref());
764                    self.record(
765                        Symbol::new(name, SymbolKind::Var, loc.position(), scope)
766                            .with_type(type_annotation.clone())
767                            .with_type_ref(type_ref),
768                    );
769                }
770            }
771            Stmt::Assignment { target, value } => {
772                self.check_expr(value);
773                self.check_assign_target(target);
774            }
775            Stmt::TupleAssignment {
776                names, value, loc, ..
777            } => {
778                self.check_expr(value);
779                let scope = self.current_scope();
780                for name in names {
781                    // `_` is a discard, not a binding: it never collides and is
782                    // not recorded.
783                    if name == "_" {
784                        continue;
785                    }
786                    self.check_shadow(name, *loc);
787                    if self
788                        .symbols
789                        .declared_locally_in(scope, name, Namespace::Value)
790                    {
791                        self.emit(
792                            "duplicate-declaration",
793                            *loc,
794                            format!("`{name}` is already declared in this scope"),
795                        );
796                    }
797                    self.record(Symbol::new(name, SymbolKind::Var, loc.position(), scope));
798                }
799            }
800            Stmt::Expression(expr) => self.check_expr(expr),
801            Stmt::If {
802                condition,
803                then_branch,
804                else_if_branches,
805                else_branch,
806            } => {
807                self.check_expr(condition);
808                self.block(then_branch);
809                for (cond, body) in else_if_branches {
810                    self.check_expr(cond);
811                    self.block(body);
812                }
813                if let Some(body) = else_branch {
814                    self.block(body);
815                }
816            }
817            Stmt::For {
818                var_name,
819                from,
820                to,
821                step,
822                body,
823                loc,
824            } => {
825                self.check_expr(from);
826                self.check_expr(to);
827                if let Some(step) = step {
828                    self.check_expr(step);
829                }
830                self.enter_scope(ScopeKind::Block);
831                self.check_shadow(var_name, *loc);
832                let scope = self.current_scope();
833                self.record(Symbol::new(
834                    var_name,
835                    SymbolKind::Var,
836                    loc.position(),
837                    scope,
838                ));
839                self.loop_body(body);
840                self.exit_scope();
841            }
842            Stmt::ForIn {
843                index_var,
844                item_var,
845                collection,
846                body,
847                loc,
848            } => {
849                self.check_expr(collection);
850                self.enter_scope(ScopeKind::Block);
851                let scope = self.current_scope();
852                if let Some(idx) = index_var {
853                    self.check_shadow(idx, *loc);
854                    self.record(Symbol::new(idx, SymbolKind::Var, loc.position(), scope));
855                }
856                self.check_shadow(item_var, *loc);
857                self.record(Symbol::new(
858                    item_var,
859                    SymbolKind::Var,
860                    loc.position(),
861                    scope,
862                ));
863                self.loop_body(body);
864                self.exit_scope();
865            }
866            Stmt::While { condition, body } => {
867                self.check_expr(condition);
868                self.enter_scope(ScopeKind::Block);
869                self.loop_body(body);
870                self.exit_scope();
871            }
872            Stmt::Break { loc } => self.check_loop_keyword("break", *loc),
873            Stmt::Continue { loc } => self.check_loop_keyword("continue", *loc),
874            Stmt::FunctionDecl {
875                name,
876                params,
877                body,
878                export,
879                loc,
880            } => {
881                self.check_shadow(name, *loc);
882                let id = self.analyze_function(name, *loc, params, body);
883                if *export {
884                    self.symbols.mark_exported(id);
885                }
886            }
887            Stmt::MethodDecl {
888                name,
889                params,
890                body,
891                export,
892                loc,
893            } => {
894                // Methods overload by receiver type, so the name is not duplicate-checked.
895                let scope = self.current_scope();
896                let id = self.record(
897                    Symbol::new(name, SymbolKind::Function, loc.position(), scope)
898                        .with_params(params.iter().map(|p| p.name.clone()).collect()),
899                );
900                if *export {
901                    self.symbols.mark_exported(id);
902                }
903                for param in params {
904                    self.check_type_annotation(param.type_annotation.as_ref(), param.loc);
905                }
906                self.function_body(
907                    params.iter().map(|p| {
908                        (
909                            p.name.as_str(),
910                            p.default_value.as_ref(),
911                            p.loc,
912                            p.type_annotation.as_ref(),
913                        )
914                    }),
915                    body,
916                );
917            }
918            Stmt::TypeDecl {
919                name,
920                fields,
921                export,
922                loc,
923            } => {
924                let owner = self.declare(name, SymbolKind::Type, *loc);
925                if *export {
926                    self.symbols.mark_exported(owner);
927                }
928                for field in fields {
929                    self.check_type_annotation(Some(&field.type_annotation), field.loc);
930                    self.symbols.declare_member(
931                        owner,
932                        &field.name,
933                        field.loc.position(),
934                        Some(field.type_annotation.clone()),
935                    );
936                }
937            }
938            Stmt::EnumDecl {
939                name,
940                fields,
941                export,
942                loc,
943            } => {
944                let owner = self.declare(name, SymbolKind::Enum, *loc);
945                if *export {
946                    self.symbols.mark_exported(owner);
947                }
948                for field in fields {
949                    self.symbols
950                        .declare_member(owner, &field.name, field.loc.position(), None);
951                }
952            }
953            Stmt::Import { path, alias, loc } => {
954                let id = self.declare(alias, SymbolKind::Import, *loc);
955                if let Some((_, root)) = self.resolve_import(path, *loc) {
956                    self.symbols.set_module(id, root);
957                }
958            }
959            // `export name` re-exports an already-declared item: mark it exported.
960            Stmt::Export { item } => {
961                let name = match item {
962                    ExportItem::Function(name) | ExportItem::Type(name) => name,
963                };
964                if let Some(id) = self.symbols.resolve_id(self.current_scope(), name) {
965                    self.symbols.mark_exported(id);
966                }
967            }
968        }
969    }
970
971    fn check_loop_keyword(&mut self, keyword: &str, loc: Loc) {
972        if self.loop_depth == 0 {
973            self.emit(
974                "break-outside-loop",
975                loc,
976                format!("`{keyword}` is only valid inside a loop"),
977            );
978        }
979    }
980
981    /// Validate the left-hand side of a `:=` reassignment.
982    fn check_assign_target(&mut self, target: &Expr) {
983        match target {
984            Expr::Variable { name, loc } => match self.resolve(name) {
985                Some(SymbolKind::Var) => self.record_use(name, *loc),
986                Some(other) => self.emit(
987                    "invalid-assignment",
988                    *loc,
989                    format!("cannot assign to `{name}`, it is a {}", other.noun()),
990                ),
991                None if self.is_builtin(name) => self.emit(
992                    "reassign-builtin",
993                    *loc,
994                    format!("cannot reassign built-in `{name}`"),
995                ),
996                None => self.emit(
997                    "invalid-assignment",
998                    *loc,
999                    format!(
1000                        "cannot assign to undeclared variable `{name}` (declare it with `=` first)"
1001                    ),
1002                ),
1003            },
1004            // `obj.field := …` or `arr[i] := …`: validate the object/index.
1005            other => self.check_expr(other),
1006        }
1007    }
1008
1009    fn check_expr(&mut self, expr: &Expr) {
1010        match expr {
1011            Expr::Variable { name, loc } => {
1012                if self.resolve(name).is_none() && !self.is_builtin(name) {
1013                    self.emit(
1014                        "undeclared-variable",
1015                        *loc,
1016                        format!("undeclared variable `{name}`"),
1017                    );
1018                } else {
1019                    self.record_use(name, *loc);
1020                }
1021            }
1022            Expr::Call {
1023                callee, args, loc, ..
1024            } => {
1025                if let Expr::Variable {
1026                    name: fname,
1027                    loc: fname_loc,
1028                } = callee.as_ref()
1029                {
1030                    self.record_use(fname, *fname_loc);
1031                    if is_global_only(fname) && self.current_scope() != SymbolTable::GLOBAL {
1032                        self.emit(
1033                            "global-scope-required",
1034                            *loc,
1035                            format!("`{fname}` may only be called in the global scope"),
1036                        );
1037                    }
1038                    if SCRIPT_DECLARATIONS.contains(&fname.as_str()) {
1039                        self.declarations += 1;
1040                        if fname == "library" {
1041                            self.library_declared = true;
1042                        }
1043                        if self.declarations > 1 {
1044                            self.emit(
1045                                "duplicate-declaration",
1046                                *loc,
1047                                "a script may only have one indicator/strategy/library declaration",
1048                            );
1049                        }
1050                    }
1051                    match self.resolve(fname) {
1052                        Some(SymbolKind::Function) => {
1053                            // Record the call as an edge out of the enclosing
1054                            // function; cycles are found once the walk finishes.
1055                            if let Some(caller) = self.fn_stack.last() {
1056                                self.call_edges.push((caller.clone(), fname.clone(), *loc));
1057                            }
1058                            if let Some(&(required, total)) = self.functions.get(fname) {
1059                                self.check_call_arity(fname, args.len(), required, total, *loc);
1060                            }
1061                        }
1062                        // A value, type or enum is not callable.
1063                        Some(kind @ (SymbolKind::Var | SymbolKind::Type | SymbolKind::Enum)) => {
1064                            self.emit(
1065                                "not-callable",
1066                                *loc,
1067                                format!("`{fname}` is a {}, not a function", kind.noun()),
1068                            );
1069                        }
1070                        // An import alias is called through its members, not directly.
1071                        Some(SymbolKind::Import) => {}
1072                        None => {
1073                            if !self.is_builtin(fname) {
1074                                self.emit(
1075                                    "unknown-function",
1076                                    *loc,
1077                                    format!("unknown function `{fname}`"),
1078                                );
1079                            }
1080                        }
1081                    }
1082                } else {
1083                    self.check_expr(callee);
1084                }
1085                if let Some(signature) = self.builtin_signature(callee) {
1086                    let name = callee_name(callee);
1087                    self.check_builtin_args(&name, signature, args, *loc);
1088                }
1089                for arg in args {
1090                    match arg {
1091                        Argument::Positional(e) => self.check_expr(e),
1092                        Argument::Named { value, .. } => self.check_expr(value),
1093                    }
1094                }
1095            }
1096            Expr::Binary { left, right, .. } => {
1097                self.check_expr(left);
1098                self.check_expr(right);
1099            }
1100            Expr::Unary { expr, .. } => self.check_expr(expr),
1101            Expr::Index { expr, index, .. } => {
1102                self.check_expr(expr);
1103                self.check_expr(index);
1104            }
1105            // When the object's type is known, record the member's occurrence.
1106            Expr::MemberAccess {
1107                object,
1108                member,
1109                member_loc,
1110            } => {
1111                self.check_expr(object);
1112                if let Some(id) = self.resolve_member(object, member) {
1113                    let file = self.current_file();
1114                    self.symbols.record_use(file, member_loc.position(), id);
1115                } else if self.unknown_member(object, member) {
1116                    if let Expr::Variable { name, .. } = object.as_ref() {
1117                        self.emit(
1118                            "unknown-member",
1119                            *member_loc,
1120                            format!("`{name}` has no member `{member}`"),
1121                        );
1122                    }
1123                }
1124            }
1125            Expr::Ternary {
1126                condition,
1127                then_expr,
1128                else_expr,
1129            } => {
1130                self.check_expr(condition);
1131                self.check_expr(then_expr);
1132                self.check_expr(else_expr);
1133            }
1134            Expr::IfExpr {
1135                condition,
1136                then_expr,
1137                else_if_branches,
1138                else_expr,
1139            } => {
1140                self.check_expr(condition);
1141                self.check_expr(then_expr);
1142                for (cond, e) in else_if_branches {
1143                    self.check_expr(cond);
1144                    self.check_expr(e);
1145                }
1146                if let Some(e) = else_expr {
1147                    self.check_expr(e);
1148                }
1149            }
1150            Expr::Switch { value, cases } => {
1151                self.check_expr(value);
1152                for (pattern, result) in cases {
1153                    self.check_expr(pattern);
1154                    self.check_expr(result);
1155                }
1156            }
1157            Expr::Array(elements) => {
1158                for e in elements {
1159                    self.check_expr(e);
1160                }
1161            }
1162            // A lambda: its own scope with parameters bound.
1163            Expr::Function { params, body } => {
1164                self.function_body(
1165                    params.iter().map(|p| {
1166                        (
1167                            p.name.as_str(),
1168                            p.default_value.as_ref(),
1169                            p.loc,
1170                            p.type_annotation.as_ref(),
1171                        )
1172                    }),
1173                    body,
1174                );
1175            }
1176            Expr::Literal(_) => {}
1177        }
1178    }
1179}