Skip to main content

aura_lang/analysis/
mod.rs

1//! Static analysis (SPEC §6.1): a single AST pass with a scope stack
2//! mirroring the Environment. Runs before the runtime.
3//!
4//! Errors (always): E0504 undefined variable, static E0301/E0302.
5//! Warnings (promoted to errors by the caller under --strict):
6//! W0501 unused variable, W0502 unused import, W0503 unused function/type,
7//! W0303 useless shadow, W0512 effectful call in imported module.
8//! Under `hermetic`: E0505 for any effectful call at all.
9
10use indexmap::IndexMap;
11
12use crate::error::{Diagnostic, Severity};
13use crate::lexer::token::StrPart;
14use crate::lexer::Lexer;
15use crate::parser::ast::*;
16use crate::parser::Parser;
17use crate::span::Span;
18
19const BUILTINS: &[&str] = &["env", "read_file", "fail", "range"];
20const EFFECTFUL: &[&str] = &["env", "read_file"];
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23enum DeclKind {
24    Var,
25    /// Function/lambda parameters are never considered dead code (the signature may require them).
26    Param,
27    Import,
28    Func,
29    Type,
30}
31
32struct Decl {
33    span: Span,
34    used: bool,
35    kind: DeclKind,
36}
37
38pub struct SemanticAnalyzer<'a> {
39    scopes: Vec<IndexMap<&'a str, Decl>>,
40    diags: Vec<Diagnostic>,
41    is_root: bool,
42    /// Deny `env()` and `read_file()` outright, as an analysis error.
43    hermetic: bool,
44}
45
46/// `is_root = false` — analysis of an imported module (includes W0512).
47pub fn analyze<'a>(module: &Module<'a>, is_root: bool) -> Vec<Diagnostic> {
48    analyze_with(module, is_root, false)
49}
50
51/// As [`analyze`], with `hermetic` denying every effectful call (E0505).
52///
53/// The denial lives here rather than in the interpreter on purpose: an analysis
54/// error is provable without running the manifest, so `aura check --hermetic` can
55/// be a CI gate. A runtime refusal would only tell you after the fact, and only
56/// for the branch that happened to execute.
57pub fn analyze_with<'a>(module: &Module<'a>, is_root: bool, hermetic: bool) -> Vec<Diagnostic> {
58    let mut a = SemanticAnalyzer {
59        scopes: Vec::new(),
60        diags: Vec::new(),
61        is_root,
62        hermetic,
63    };
64    a.push_scope();
65    for imp in &module.imports {
66        a.declare(imp.alias, imp.span, DeclKind::Import, false);
67    }
68    for stmt in &module.stmts {
69        a.walk_stmt(stmt);
70    }
71    a.pop_scope();
72    a.diags
73}
74
75/// Whether there are blocking diagnostics: errors always, warnings too under strict (SPEC §6.1).
76pub fn has_blocking(diags: &[Diagnostic], strict: bool) -> bool {
77    diags
78        .iter()
79        .any(|d| strict || d.severity == Severity::Error)
80}
81
82impl<'a> SemanticAnalyzer<'a> {
83    fn push_scope(&mut self) {
84        self.scopes.push(IndexMap::new());
85    }
86
87    /// On leaving a scope — report dead code (SPEC §6.1, step 3).
88    fn pop_scope(&mut self) {
89        let scope = self.scopes.pop().expect("scope stack underflow");
90        for (name, decl) in scope {
91            if decl.used {
92                continue;
93            }
94            let (code, what) = match decl.kind {
95                DeclKind::Var => ("W0501", "variable"),
96                DeclKind::Import => ("W0502", "import"),
97                DeclKind::Func => ("W0503", "function"),
98                DeclKind::Type => ("W0503", "type"),
99                DeclKind::Param => continue,
100            };
101            self.diags.push(Diagnostic::warning(
102                code,
103                format!("unused {what} '{name}'"),
104                decl.span,
105                "never used",
106            ));
107        }
108    }
109
110    fn declare(&mut self, name: &'a str, span: Span, kind: DeclKind, shadow: bool) {
111        let in_current = self.scopes.last().is_some_and(|s| s.contains_key(name));
112        if in_current {
113            self.diags.push(Diagnostic::error(
114                "E0301",
115                format!("'{name}' is already defined in this scope"),
116                span,
117                "duplicate definition",
118            ));
119            return;
120        }
121        let outer = self.lookup_span(name);
122        match (shadow, outer) {
123            (false, Some(orig)) if kind == DeclKind::Var => {
124                let mut d = Diagnostic::error(
125                    "E0302",
126                    format!("'{name}' shadows an outer variable"),
127                    span,
128                    "add `shadow`",
129                );
130                d.secondary
131                    .push((orig, "outer variable declared here".to_string()));
132                d.help = Some(format!(
133                    "write `shadow {name} = ...` to make the shadowing explicit"
134                ));
135                self.diags.push(d);
136            }
137            (true, None) => {
138                self.diags.push(Diagnostic::warning(
139                    "W0303",
140                    format!("`shadow` on '{name}' shadows nothing"),
141                    span,
142                    "remove `shadow`",
143                ));
144            }
145            _ => {}
146        }
147        self.scopes.last_mut().unwrap().insert(
148            name,
149            Decl {
150                span,
151                used: false,
152                kind,
153            },
154        );
155    }
156
157    fn lookup_span(&self, name: &str) -> Option<Span> {
158        self.scopes
159            .iter()
160            .rev()
161            .skip(1)
162            .find_map(|s| s.get(name).map(|d| d.span))
163    }
164
165    /// Step 2 of SPEC §6.1: mark the nearest declaration (respects shadowing).
166    fn mark_used(&mut self, name: &str, span: Span) {
167        for scope in self.scopes.iter_mut().rev() {
168            if let Some(decl) = scope.get_mut(name) {
169                decl.used = true;
170                return;
171            }
172        }
173        if !BUILTINS.contains(&name) {
174            self.diags.push(Diagnostic::error(
175                "E0504",
176                format!("use of undefined variable '{name}'"),
177                span,
178                "not found in any scope",
179            ));
180        }
181    }
182
183    fn walk_stmt(&mut self, stmt: &Stmt<'a>) {
184        match stmt {
185            Stmt::Assign {
186                name,
187                shadow,
188                value,
189                span,
190            } => {
191                self.walk_expr(value); // the value does not see its own name
192                self.declare(name, *span, DeclKind::Var, *shadow);
193            }
194            Stmt::Property { value, .. } => self.walk_expr(value),
195            Stmt::Assert { cond, message, .. } => {
196                self.walk_expr(cond);
197                if let Some(m) = message {
198                    self.walk_expr(m);
199                }
200            }
201            Stmt::EnumDecl(en) => {
202                self.declare(en.name, en.span, DeclKind::Type, false);
203                // D12: pub is the module's API, not dead code
204                if en.public {
205                    self.mark_used(en.name, en.span);
206                }
207            }
208            Stmt::TypeDecl(schema) => {
209                for f in &schema.fields {
210                    if let TypeName::Custom(name) = f.ty {
211                        self.mark_used(name, schema.span);
212                    }
213                    // a default expression may reference variables (e.g. `= base_port`)
214                    if let Some(default) = &f.default {
215                        self.walk_expr(default);
216                    }
217                }
218                self.declare(schema.name, schema.span, DeclKind::Type, false);
219                // D12: pub is the module's API, not dead code
220                if schema.public {
221                    self.mark_used(schema.name, schema.span);
222                }
223            }
224            Stmt::FuncDecl {
225                name,
226                params,
227                body,
228                public,
229                span,
230            } => {
231                self.declare(name, *span, DeclKind::Func, false);
232                // D12: pub is the module's API, not dead code
233                if *public {
234                    self.mark_used(name, *span);
235                }
236                self.push_scope();
237                for p in params {
238                    self.declare(p, *span, DeclKind::Param, false);
239                }
240                self.walk_stmt_body(body);
241                self.pop_scope();
242            }
243            Stmt::Block(block) => self.walk_block(block),
244            Stmt::Expr(e) => self.walk_expr(e),
245        }
246    }
247
248    fn walk_block(&mut self, block: &BlockDeclaration<'a>) {
249        self.walk_expr(&block.label);
250        self.push_scope();
251        for stmt in &block.body {
252            self.walk_stmt(stmt);
253        }
254        self.pop_scope();
255    }
256
257    /// A code body (D17): statements in the caller's freshly pushed scope.
258    fn walk_stmt_body(&mut self, body: &[Stmt<'a>]) {
259        for stmt in body {
260            self.walk_stmt(stmt);
261        }
262    }
263
264    fn walk_object_body(&mut self, body: &ObjectBody<'a>) {
265        for (_, value, _) in &body.props {
266            self.walk_expr(value);
267        }
268    }
269
270    fn walk_expr(&mut self, e: &Expr<'a>) {
271        match e {
272            Expr::Literal(LitValue::InterpStr(parts), span) => {
273                for part in parts {
274                    if let StrPart::Interp(src) = part {
275                        self.walk_interp(src, *span);
276                    }
277                }
278            }
279            Expr::Literal(..) => {}
280            Expr::Variable(name, span) => self.mark_used(name, *span),
281            Expr::Unary { rhs, .. } => self.walk_expr(rhs),
282            Expr::Binary { lhs, rhs, .. } => {
283                self.walk_expr(lhs);
284                self.walk_expr(rhs);
285            }
286            Expr::Ternary {
287                cond,
288                then,
289                otherwise,
290                ..
291            } => {
292                self.walk_expr(cond);
293                self.walk_expr(then);
294                self.walk_expr(otherwise);
295            }
296            Expr::Cond {
297                arms, otherwise, ..
298            } => {
299                for (condition, value) in arms {
300                    self.walk_expr(condition);
301                    self.walk_expr(value);
302                }
303                self.walk_expr(otherwise);
304            }
305            Expr::Call { callee, args, span } => {
306                // W0512: an effectful call in an imported module (SPEC §6.1, D1)
307                if let Expr::Variable(name, _) = callee.as_ref() {
308                    // Hermetic mode: no I/O anywhere, root included. This is the
309                    // guarantee CUE, KCL and Starlark give by construction; here it
310                    // is a mode, so one repository can hold both kinds of manifest
311                    // and CI can require the strict kind per directory.
312                    if self.hermetic && EFFECTFUL.contains(name) {
313                        let mut d = Diagnostic::error(
314                            "E0505",
315                            format!("{name}() is not allowed in hermetic mode"),
316                            *span,
317                            "no I/O is permitted here",
318                        );
319                        d.help = Some(format!(
320                            "pass the value in from the host, or drop --hermetic to grant {name}() explicitly"
321                        ));
322                        self.diags.push(d);
323                    }
324                    if !self.is_root && EFFECTFUL.contains(name) {
325                        self.diags.push(Diagnostic::warning(
326                            "W0512",
327                            format!("effectful call {name}() in imported module"),
328                            *span,
329                            "imports have no I/O capability by default",
330                        ));
331                    }
332                }
333                self.walk_expr(callee);
334                for a in args {
335                    self.walk_expr(a);
336                }
337            }
338            Expr::MethodCall {
339                recv, args, lambda, ..
340            } => {
341                self.walk_expr(recv);
342                for a in args {
343                    self.walk_expr(a);
344                }
345                if let Some(l) = lambda {
346                    self.walk_expr(l);
347                }
348            }
349            Expr::FieldAccess { recv, .. } => self.walk_expr(recv),
350            Expr::Index { recv, key, .. } => {
351                self.walk_expr(recv);
352                self.walk_expr(key);
353            }
354            Expr::ObjectLiteral(body) => self.walk_object_body(body),
355            Expr::ListLiteral(items, _) => {
356                for i in items {
357                    self.walk_expr(i);
358                }
359            }
360            Expr::Lambda { params, body, span } => {
361                self.push_scope();
362                for p in params {
363                    self.declare(p, *span, DeclKind::Param, false);
364                }
365                match body {
366                    LambdaBody::Expr(e) => self.walk_expr(e),
367                    LambdaBody::Block(b) => self.walk_stmt_body(b),
368                }
369                self.pop_scope();
370            }
371            Expr::SchemaInstance {
372                schema,
373                schema_alias,
374                body,
375                span,
376            } => {
377                // `new alias.Schema` uses the import alias; the schema itself lives in another module
378                match schema_alias {
379                    Some(alias) => self.mark_used(alias, *span),
380                    None => self.mark_used(schema, *span),
381                }
382                self.walk_object_body(body);
383            }
384        }
385    }
386
387    /// `#{expr}` is parsed and analyzed as an ordinary expression — variables
388    /// used only inside an interpolation are not considered dead.
389    fn walk_interp(&mut self, src: &'a str, span: Span) {
390        let parsed = Lexer::new(src, span.source)
391            .tokenize()
392            .and_then(|toks| Parser::new(toks).parse_expression());
393        match parsed {
394            Ok(expr) => self.walk_expr(&expr),
395            Err(d) => self.diags.push(Diagnostic::error(
396                "E0316",
397                format!("invalid interpolation: {}", d.message),
398                span,
399                "in #{...}",
400            )),
401        }
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    fn diags(src: &str) -> Vec<Diagnostic> {
410        diags_as(src, true)
411    }
412
413    fn diags_as(src: &str, is_root: bool) -> Vec<Diagnostic> {
414        let toks = Lexer::new(src, 0).tokenize().expect("lex ok");
415        let module = Parser::new(toks).parse_module().expect("parse ok");
416        analyze(&module, is_root)
417    }
418
419    fn codes(src: &str) -> Vec<&'static str> {
420        diags(src).into_iter().map(|d| d.code).collect()
421    }
422
423    #[test]
424    fn dead_code_detection() {
425        assert_eq!(codes("x = 1"), vec!["W0501"]);
426        assert_eq!(
427            codes("import \"a.aura\" as a\nx = 1\ny = x"),
428            vec!["W0502", "W0501"]
429        ); // a, y
430        assert_eq!(codes("type T\n  a: Int\nend"), vec!["W0503"]);
431        assert_eq!(codes("def f(x)\n  a: x\nend"), vec!["W0503"]);
432        // used — not dead
433        assert!(codes("x = 1\ny = x + 1\nassert y > 0").is_empty());
434    }
435
436    #[test]
437    fn undefined_variable_is_e0504() {
438        assert!(codes("x = nope + 1").contains(&"E0504"));
439        // builtins are never considered undefined
440        assert!(!codes("assert fail == fail").contains(&"E0504"));
441    }
442
443    #[test]
444    fn static_shadow_rules() {
445        assert!(codes("x = 1\ndomain \"d\"\n  x = 2\nend").contains(&"E0302"));
446        assert!(codes("x = 1\nx = 2").contains(&"E0301"));
447        assert!(codes("shadow x = 1\nassert x == 1").contains(&"W0303"));
448        // a correct shadow — clean
449        let src = "x = 1\ndomain \"d\"\n  shadow x = 2\n  assert x == 2\nend\nassert x == 1";
450        assert!(codes(src).is_empty());
451    }
452
453    #[test]
454    fn interpolation_uses_are_counted() {
455        // x is used only inside #{} — not dead
456        assert!(codes("x = 1\ns = \"v#{x}\"\nassert s == \"v1\"").is_empty());
457        // an undefined variable inside #{} is caught
458        assert!(codes("s = \"v#{nope}\"\nassert s == \"\"").contains(&"E0504"));
459    }
460
461    #[test]
462    fn effectful_call_in_import_is_w0512() {
463        let src = "x = env(\"A\", \"b\")\nassert x == x";
464        assert!(!diags_as(src, true).iter().any(|d| d.code == "W0512"));
465        assert!(diags_as(src, false).iter().any(|d| d.code == "W0512"));
466    }
467
468    #[test]
469    fn strict_upgrades_warnings_to_blocking() {
470        let ds = diags("x = 1");
471        assert!(!has_blocking(&ds, false));
472        assert!(has_blocking(&ds, true));
473    }
474}