use indexmap::IndexMap;
use crate::error::{Diagnostic, Severity};
use crate::lexer::token::StrPart;
use crate::lexer::Lexer;
use crate::parser::ast::*;
use crate::parser::Parser;
use crate::span::Span;
const BUILTINS: &[&str] = &["env", "read_file", "fail", "range"];
const EFFECTFUL: &[&str] = &["env", "read_file"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DeclKind {
Var,
Param,
Import,
Func,
Type,
}
struct Decl {
span: Span,
used: bool,
kind: DeclKind,
}
pub struct SemanticAnalyzer<'a> {
scopes: Vec<IndexMap<&'a str, Decl>>,
properties: Vec<Vec<&'a str>>,
diags: Vec<Diagnostic>,
is_root: bool,
hermetic: bool,
}
pub fn analyze<'a>(module: &Module<'a>, is_root: bool) -> Vec<Diagnostic> {
analyze_with(module, is_root, false)
}
pub fn analyze_with<'a>(module: &Module<'a>, is_root: bool, hermetic: bool) -> Vec<Diagnostic> {
let mut a = SemanticAnalyzer {
scopes: Vec::new(),
properties: Vec::new(),
diags: Vec::new(),
is_root,
hermetic,
};
a.push_scope();
for imp in &module.imports {
a.declare(imp.alias, imp.span, DeclKind::Import, false);
}
a.note_properties(&module.stmts);
for stmt in &module.stmts {
a.walk_stmt(stmt);
}
a.pop_scope();
a.diags
}
pub fn has_blocking(diags: &[Diagnostic], strict: bool) -> bool {
diags
.iter()
.any(|d| strict || d.severity == Severity::Error)
}
impl<'a> SemanticAnalyzer<'a> {
fn push_scope(&mut self) {
self.scopes.push(IndexMap::new());
self.properties.push(Vec::new());
}
fn note_properties(&mut self, stmts: &[Stmt<'a>]) {
let names: Vec<&'a str> = stmts
.iter()
.filter_map(|st| match st {
Stmt::Property { key, .. } => Some(*key),
_ => None,
})
.collect();
if let Some(top) = self.properties.last_mut() {
top.extend(names);
}
}
fn is_property(&self, name: &str) -> bool {
self.properties.iter().any(|s| s.contains(&name))
}
fn pop_scope(&mut self) {
self.properties.pop();
let scope = self.scopes.pop().expect("scope stack underflow");
for (name, decl) in scope {
if decl.used {
continue;
}
let (code, what) = match decl.kind {
DeclKind::Var => ("W0501", "variable"),
DeclKind::Import => ("W0502", "import"),
DeclKind::Func => ("W0503", "function"),
DeclKind::Type => ("W0503", "type"),
DeclKind::Param => continue,
};
self.diags.push(Diagnostic::warning(
code,
format!("unused {what} '{name}'"),
decl.span,
"never used",
));
}
}
fn declare(&mut self, name: &'a str, span: Span, kind: DeclKind, shadow: bool) {
let in_current = self.scopes.last().is_some_and(|s| s.contains_key(name));
if in_current {
self.diags.push(Diagnostic::error(
"E0301",
format!("'{name}' is already defined in this scope"),
span,
"duplicate definition",
));
return;
}
let outer = self.lookup_span(name);
match (shadow, outer) {
(false, Some(orig)) if kind == DeclKind::Var => {
let mut d = Diagnostic::error(
"E0302",
format!("'{name}' shadows an outer variable"),
span,
"add `shadow`",
);
d.secondary
.push((orig, "outer variable declared here".to_string()));
d.help = Some(format!(
"write `shadow {name} = ...` to make the shadowing explicit"
));
self.diags.push(d);
}
(true, None) => {
self.diags.push(Diagnostic::warning(
"W0303",
format!("`shadow` on '{name}' shadows nothing"),
span,
"remove `shadow`",
));
}
_ => {}
}
self.scopes.last_mut().unwrap().insert(
name,
Decl {
span,
used: false,
kind,
},
);
}
fn lookup_span(&self, name: &str) -> Option<Span> {
self.scopes
.iter()
.rev()
.skip(1)
.find_map(|s| s.get(name).map(|d| d.span))
}
fn mark_used(&mut self, name: &str, span: Span) {
for scope in self.scopes.iter_mut().rev() {
if let Some(decl) = scope.get_mut(name) {
decl.used = true;
return;
}
}
if !BUILTINS.contains(&name) {
let mut d = Diagnostic::error(
"E0504",
format!("use of undefined variable '{name}'"),
span,
"not found in any scope",
);
if self.is_property(name) {
d.help = Some(format!(
"'{name}' is a property (`{name}:`), which is exported but does not create a name — bind the value with `{name} = ...` first and give the property that binding"
));
}
self.diags.push(d);
}
}
fn mark_used_as_type(&mut self, name: &str, span: Span) {
for scope in self.scopes.iter_mut().rev() {
if let Some(decl) = scope.get_mut(name) {
decl.used = true;
return;
}
}
let mut d = Diagnostic::error(
"E0504",
format!("unknown type '{name}'"),
span,
"no such type is declared, and it is not built in",
);
let builtins = crate::parser::ast::BUILTIN_TYPE_NAMES;
let suggestion = builtins
.iter()
.find(|b| b.starts_with(name) || name.starts_with(**b))
.copied()
.or_else(|| crate::eval::nearest(builtins, name));
let list = builtins.join(", ");
d.help = Some(match suggestion {
Some(closest) => format!(
"did you mean `{closest}`? The built-in types are {list}; anything else has to be declared with `type {name} … end` or `enum {name} … end`"
),
None => format!(
"the built-in types are {list}; anything else has to be declared with `type {name} … end` or `enum {name} … end`"
),
});
self.diags.push(d);
}
fn walk_stmt(&mut self, stmt: &Stmt<'a>) {
match stmt {
Stmt::Assign {
name,
shadow,
value,
span,
} => {
self.walk_expr(value); self.declare(name, *span, DeclKind::Var, *shadow);
}
Stmt::Property { value, .. } => self.walk_expr(value),
Stmt::Assert { cond, message, .. } => {
self.walk_expr(cond);
if let Some(m) = message {
self.walk_expr(m);
}
}
Stmt::EnumDecl(en) => {
self.declare(en.name, en.span, DeclKind::Type, false);
if en.public {
self.mark_used(en.name, en.span);
}
}
Stmt::TypeDecl(schema) => {
for f in &schema.fields {
if let TypeName::Custom(name) = f.ty {
self.mark_used_as_type(name, f.ty_span);
}
if let Some(default) = &f.default {
self.walk_expr(default);
}
}
self.declare(schema.name, schema.span, DeclKind::Type, false);
if schema.public {
self.mark_used(schema.name, schema.span);
}
}
Stmt::FuncDecl {
name,
params,
body,
public,
span,
} => {
self.declare(name, *span, DeclKind::Func, false);
if *public {
self.mark_used(name, *span);
}
self.push_scope();
for p in params {
self.declare(p, *span, DeclKind::Param, false);
}
self.walk_stmt_body(body);
self.pop_scope();
}
Stmt::Block(block) => self.walk_block(block),
Stmt::Expr(e) => self.walk_expr(e),
}
}
fn walk_block(&mut self, block: &BlockDeclaration<'a>) {
self.walk_expr(&block.label);
self.push_scope();
self.note_properties(&block.body);
for stmt in &block.body {
self.walk_stmt(stmt);
}
self.pop_scope();
}
fn walk_stmt_body(&mut self, body: &[Stmt<'a>]) {
self.note_properties(body);
for stmt in body {
self.walk_stmt(stmt);
}
}
fn walk_object_body(&mut self, body: &ObjectBody<'a>) {
for (_, value, _) in &body.props {
self.walk_expr(value);
}
}
fn walk_expr(&mut self, e: &Expr<'a>) {
match e {
Expr::Literal(LitValue::InterpStr(parts), span) => {
for part in parts {
if let StrPart::Interp(src) = part {
self.walk_interp(src, *span);
}
}
}
Expr::Literal(..) => {}
Expr::Variable(name, span) => self.mark_used(name, *span),
Expr::Unary { rhs, .. } => self.walk_expr(rhs),
Expr::Binary { lhs, rhs, .. } => {
self.walk_expr(lhs);
self.walk_expr(rhs);
}
Expr::Ternary {
cond,
then,
otherwise,
..
} => {
self.walk_expr(cond);
self.walk_expr(then);
self.walk_expr(otherwise);
}
Expr::Cond {
arms, otherwise, ..
} => {
for (condition, value) in arms {
self.walk_expr(condition);
self.walk_expr(value);
}
self.walk_expr(otherwise);
}
Expr::Call { callee, args, span } => {
if let Expr::Variable(name, _) = callee.as_ref() {
if self.hermetic && EFFECTFUL.contains(name) {
let mut d = Diagnostic::error(
"E0505",
format!("{name}() is not allowed in hermetic mode"),
*span,
"no I/O is permitted here",
);
d.help = Some(format!(
"pass the value in from the host, or drop --hermetic to grant {name}() explicitly"
));
self.diags.push(d);
}
if !self.is_root && EFFECTFUL.contains(name) {
self.diags.push(Diagnostic::warning(
"W0512",
format!("effectful call {name}() in imported module"),
*span,
"imports have no I/O capability by default",
));
}
}
self.walk_expr(callee);
for a in args {
self.walk_expr(a);
}
}
Expr::MethodCall {
recv, args, lambda, ..
} => {
self.walk_expr(recv);
for a in args {
self.walk_expr(a);
}
if let Some(l) = lambda {
self.walk_expr(l);
}
}
Expr::FieldAccess { recv, .. } => self.walk_expr(recv),
Expr::Index { recv, key, .. } => {
self.walk_expr(recv);
self.walk_expr(key);
}
Expr::ObjectLiteral(body) => self.walk_object_body(body),
Expr::ListLiteral(items, _) => {
for i in items {
self.walk_expr(i);
}
}
Expr::Lambda { params, body, span } => {
self.push_scope();
for p in params {
self.declare(p, *span, DeclKind::Param, false);
}
match body {
LambdaBody::Expr(e) => self.walk_expr(e),
LambdaBody::Block(b) => self.walk_stmt_body(b),
}
self.pop_scope();
}
Expr::SchemaInstance {
schema,
schema_alias,
body,
span,
} => {
match schema_alias {
Some(alias) => self.mark_used(alias, *span),
None => self.mark_used(schema, *span),
}
self.walk_object_body(body);
}
}
}
fn walk_interp(&mut self, src: &'a str, span: Span) {
let parsed = Lexer::new(src, span.source)
.tokenize()
.and_then(|toks| Parser::new(toks).parse_expression());
match parsed {
Ok(expr) => self.walk_expr(&expr),
Err(d) => self.diags.push(Diagnostic::error(
"E0316",
format!("invalid interpolation: {}", d.message),
span,
"in #{...}",
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn diags(src: &str) -> Vec<Diagnostic> {
diags_as(src, true)
}
fn diags_as(src: &str, is_root: bool) -> Vec<Diagnostic> {
let toks = Lexer::new(src, 0).tokenize().expect("lex ok");
let module = Parser::new(toks).parse_module().expect("parse ok");
analyze(&module, is_root)
}
fn codes(src: &str) -> Vec<&'static str> {
diags(src).into_iter().map(|d| d.code).collect()
}
#[test]
fn an_unknown_field_type_says_so_and_points_at_the_field() {
let ds = diags(
"type T
a: Str
b: Int
end
x: new T
a: \"v\"
b: 1
end
",
);
let d = ds
.iter()
.find(|d| d.code == "E0504")
.expect("an unknown type is still E0504");
assert!(
d.message.contains("unknown type 'Str'"),
"expected a message about types, got: {}",
d.message
);
let help = d.help.as_deref().unwrap_or_default();
assert!(
help.contains("did you mean `String`"),
"an abbreviation of a built-in should be suggested; got: {help}"
);
assert!(
d.primary.0.start > 0,
"the diagnostic should point at the type name, not the block start"
);
}
#[test]
fn a_typo_is_suggested_and_an_unrelated_name_is_not() {
let typo = diags(
"type T
a: Intt
end
",
);
let help = typo
.iter()
.find(|d| d.code == "E0504")
.and_then(|d| d.help.clone())
.unwrap_or_default();
assert!(help.contains("did you mean `Int`"), "got: {help}");
let unrelated = diags(
"type T
a: Widget
end
",
);
let help = unrelated
.iter()
.find(|d| d.code == "E0504")
.and_then(|d| d.help.clone())
.unwrap_or_default();
assert!(
!help.contains("did you mean"),
"an unrelated name should not be guessed at; got: {help}"
);
assert!(
help.contains("String, Int"),
"the list is still useful: {help}"
);
}
#[test]
fn referring_to_a_property_explains_the_rule() {
for src in [
"limits:
a: 1
end
assert limits.a == 1, \"x\"
",
"assert limits.a == 1, \"x\"
limits:
a: 1
end
",
] {
let help = diags(src)
.iter()
.find(|d| d.code == "E0504")
.and_then(|d| d.help.clone())
.unwrap_or_default();
assert!(
help.contains("is a property") && help.contains("does not create a name"),
"expected the property hint for {src:?}, got: {help:?}"
);
}
}
#[test]
fn an_ordinary_undefined_name_gets_no_property_hint() {
let d = diags(
"x: nope + 1
",
);
let hint = d
.iter()
.find(|d| d.code == "E0504")
.and_then(|d| d.help.clone());
assert!(hint.is_none(), "unexpected hint: {hint:?}");
}
#[test]
fn dead_code_detection() {
assert_eq!(codes("x = 1"), vec!["W0501"]);
assert_eq!(
codes("import \"a.aura\" as a\nx = 1\ny = x"),
vec!["W0502", "W0501"]
); assert_eq!(codes("type T\n a: Int\nend"), vec!["W0503"]);
assert_eq!(codes("def f(x)\n a: x\nend"), vec!["W0503"]);
assert!(codes("x = 1\ny = x + 1\nassert y > 0").is_empty());
}
#[test]
fn undefined_variable_is_e0504() {
assert!(codes("x = nope + 1").contains(&"E0504"));
assert!(!codes("assert fail == fail").contains(&"E0504"));
}
#[test]
fn static_shadow_rules() {
assert!(codes("x = 1\ndomain \"d\"\n x = 2\nend").contains(&"E0302"));
assert!(codes("x = 1\nx = 2").contains(&"E0301"));
assert!(codes("shadow x = 1\nassert x == 1").contains(&"W0303"));
let src = "x = 1\ndomain \"d\"\n shadow x = 2\n assert x == 2\nend\nassert x == 1";
assert!(codes(src).is_empty());
}
#[test]
fn interpolation_uses_are_counted() {
assert!(codes("x = 1\ns = \"v#{x}\"\nassert s == \"v1\"").is_empty());
assert!(codes("s = \"v#{nope}\"\nassert s == \"\"").contains(&"E0504"));
}
#[test]
fn effectful_call_in_import_is_w0512() {
let src = "x = env(\"A\", \"b\")\nassert x == x";
assert!(!diags_as(src, true).iter().any(|d| d.code == "W0512"));
assert!(diags_as(src, false).iter().any(|d| d.code == "W0512"));
}
#[test]
fn strict_upgrades_warnings_to_blocking() {
let ds = diags("x = 1");
assert!(!has_blocking(&ds, false));
assert!(has_blocking(&ds, true));
}
}