Add static variable resolution with scope tracking
- Add distance-based get_at on EnvironmentStack - Add distance-based assign_at on EnvironmentStack - Introduce ErrorSink to accumulate diagnostics across passes - Implement Resolver with a ScopeStack and per-node distance map - Extend interpreter to store locals distances for runtime lookup - Add tests for resolution behavior and error accumulation Add static variable resolution with scope tracking
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
pub mod scope_stack;
|
||||
pub mod variable_resolution;
|
||||
pub mod visit_ast;
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! A reusable lexical-scope stack for static analyses.
|
||||
//!
|
||||
//! Unlike [`EnvironmentStack`](crate::backend::environment::EnvironmentStack),
|
||||
//! which stores runtime *values*, this stores per-binding *analysis state*
|
||||
//! (for the resolver, a `bool` meaning "defined yet?"). It is generic over that
|
||||
//! state so other passes (a type checker, an unused-variable lint, ...) can
|
||||
//! reuse the same machinery.
|
||||
//!
|
||||
//! It starts **empty**: the global scope is intentionally untracked, so
|
||||
//! [`ScopeStack::resolve`] returning `None` means "not local — assume global".
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ScopeStack<T> {
|
||||
scopes: Vec<HashMap<String, T>>,
|
||||
}
|
||||
|
||||
impl<T> Default for ScopeStack<T> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ScopeStack<T> {
|
||||
pub fn new() -> Self {
|
||||
ScopeStack { scopes: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn begin_scope(&mut self) {
|
||||
self.scopes.push(HashMap::new());
|
||||
}
|
||||
|
||||
pub fn end_scope(&mut self) {
|
||||
self.scopes.pop();
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.scopes.is_empty()
|
||||
}
|
||||
|
||||
pub fn depth(&self) -> usize {
|
||||
self.scopes.len()
|
||||
}
|
||||
|
||||
/// Insert `name` with `state` into the innermost scope (no-op at global).
|
||||
pub fn declare(&mut self, name: impl Into<String>, state: T) {
|
||||
if let Some(scope) = self.scopes.last_mut() {
|
||||
scope.insert(name.into(), state);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update an existing binding in the innermost scope (no-op if absent).
|
||||
pub fn set_local(&mut self, name: &str, state: T) {
|
||||
if let Some(scope) = self.scopes.last_mut() {
|
||||
if let Some(slot) = scope.get_mut(name) {
|
||||
*slot = state;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up `name` in the innermost scope only.
|
||||
pub fn get_local(&self, name: &str) -> Option<&T> {
|
||||
self.scopes.last().and_then(|scope| scope.get(name))
|
||||
}
|
||||
|
||||
/// Whether the innermost scope already declares `name`.
|
||||
pub fn declared_in_current(&self, name: &str) -> bool {
|
||||
self.scopes
|
||||
.last()
|
||||
.map_or(false, |scope| scope.contains_key(name))
|
||||
}
|
||||
|
||||
/// Distance (in scopes) from the innermost scope to the one declaring
|
||||
/// `name`, or `None` if it isn't in any scope (i.e. global).
|
||||
pub fn resolve(&self, name: &str) -> Option<usize> {
|
||||
self.scopes
|
||||
.iter()
|
||||
.rev()
|
||||
.enumerate()
|
||||
.find_map(|(distance, scope)| scope.contains_key(name).then_some(distance))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn declare_and_get_local() {
|
||||
let mut scopes: ScopeStack<bool> = ScopeStack::new();
|
||||
scopes.begin_scope();
|
||||
scopes.declare("a", false);
|
||||
assert_eq!(scopes.get_local("a"), Some(&false));
|
||||
scopes.set_local("a", true);
|
||||
assert_eq!(scopes.get_local("a"), Some(&true));
|
||||
assert_eq!(scopes.get_local("missing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_returns_distance_from_innermost() {
|
||||
let mut scopes: ScopeStack<bool> = ScopeStack::new();
|
||||
scopes.begin_scope();
|
||||
scopes.declare("a", true);
|
||||
scopes.begin_scope();
|
||||
scopes.declare("b", true);
|
||||
assert_eq!(scopes.resolve("b"), Some(0));
|
||||
assert_eq!(scopes.resolve("a"), Some(1));
|
||||
assert_eq!(scopes.resolve("missing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadowing_and_end_scope() {
|
||||
let mut scopes: ScopeStack<bool> = ScopeStack::new();
|
||||
scopes.begin_scope();
|
||||
scopes.declare("a", true);
|
||||
scopes.begin_scope();
|
||||
scopes.declare("a", false);
|
||||
assert_eq!(scopes.get_local("a"), Some(&false));
|
||||
assert_eq!(scopes.resolve("a"), Some(0));
|
||||
scopes.end_scope();
|
||||
assert_eq!(scopes.get_local("a"), Some(&true));
|
||||
assert_eq!(scopes.resolve("a"), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_in_current_checks_only_innermost() {
|
||||
let mut scopes: ScopeStack<bool> = ScopeStack::new();
|
||||
scopes.begin_scope();
|
||||
scopes.declare("a", true);
|
||||
scopes.begin_scope();
|
||||
assert!(!scopes.declared_in_current("a"));
|
||||
scopes.declare("a", true);
|
||||
assert!(scopes.declared_in_current("a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_scope_is_untracked() {
|
||||
let mut scopes: ScopeStack<bool> = ScopeStack::new();
|
||||
// No scope pushed: declarations are no-ops and nothing resolves locally.
|
||||
scopes.declare("a", true);
|
||||
assert!(scopes.is_empty());
|
||||
assert_eq!(scopes.resolve("a"), None);
|
||||
assert_eq!(scopes.get_local("a"), None);
|
||||
}
|
||||
}
|
||||
@@ -1,95 +1,267 @@
|
||||
//! Static variable resolution (Crafting Interpreters, chapter 11).
|
||||
//!
|
||||
//! Walks the AST with the shared [`Visitor`] traversal, tracking lexical scopes
|
||||
//! in a [`ScopeStack`], and records for every variable *reference* how many
|
||||
//! scopes up its declaration lives (`locals: NodeId -> distance`). It also
|
||||
//! reports the resolution errors from chapter 11:
|
||||
//!
|
||||
//! * reading a local variable in its own initializer (`var a = a;`),
|
||||
//! * declaring two variables with the same name in one local scope,
|
||||
//! * `return` outside of any function.
|
||||
//!
|
||||
//! Diagnostics accumulate in an [`ErrorSink`] so a single pass surfaces them
|
||||
//! all. The produced `locals` map is keyed by [`NodeId`] (stable identity),
|
||||
//! ready for the interpreter to consume via distance-based lookup.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{
|
||||
backend::environment::EnvironmentStack,
|
||||
common::{
|
||||
ast::{AstNode, Expr, NodeId, Stmt},
|
||||
lox_result::{LoxError, LoxResult},
|
||||
base_value::{BaseValue, LoxFunction},
|
||||
lox_result::{ErrorSink, LoxError, LoxResult},
|
||||
},
|
||||
middleend::{
|
||||
scope_stack::ScopeStack,
|
||||
visit_ast::{walk_expr, walk_function, walk_stmt, Visitor},
|
||||
},
|
||||
frontend::source_registry::SourceSlice,
|
||||
middleend::visit_ast::{walk_expr, walk_stmt, Visitor},
|
||||
};
|
||||
|
||||
struct Resolver {
|
||||
scopes: EnvironmentStack<bool>,
|
||||
/// Tracks whether resolution is currently inside a function body, so a
|
||||
/// top-level `return` can be reported.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum FunctionType {
|
||||
None,
|
||||
Function,
|
||||
}
|
||||
|
||||
pub struct Resolver {
|
||||
scopes: ScopeStack<bool>,
|
||||
locals: HashMap<NodeId, usize>,
|
||||
errors: ErrorSink,
|
||||
current_function: FunctionType,
|
||||
}
|
||||
|
||||
impl Resolver {
|
||||
pub fn new() -> Self {
|
||||
Resolver {
|
||||
scopes: EnvironmentStack::new(),
|
||||
scopes: ScopeStack::new(),
|
||||
locals: HashMap::new(),
|
||||
errors: ErrorSink::new(),
|
||||
current_function: FunctionType::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn declare(&mut self, name: &String) {
|
||||
/// Resolve a whole program, returning the per-reference scope distances or
|
||||
/// the accumulated resolution errors.
|
||||
pub fn resolve_program(
|
||||
statements: &[AstNode<Stmt>],
|
||||
) -> Result<HashMap<NodeId, usize>, Vec<LoxError>> {
|
||||
let mut resolver = Resolver::new();
|
||||
for statement in statements {
|
||||
// Visiting only fails for fatal/internal errors, which this pass
|
||||
// never produces; user-facing diagnostics go to `errors`.
|
||||
let _ = resolver.visit_stmt(statement);
|
||||
}
|
||||
if resolver.errors.has_errors() {
|
||||
Err(resolver.errors.into_errors())
|
||||
} else {
|
||||
Ok(resolver.locals)
|
||||
}
|
||||
}
|
||||
|
||||
fn declare(&mut self, name: &str, slice: &crate::frontend::source_registry::SourceSlice) {
|
||||
if self.scopes.is_empty() {
|
||||
return; // global scope is untracked
|
||||
}
|
||||
if self.scopes.declared_in_current(name) {
|
||||
self.errors.report(LoxError::ParseError {
|
||||
source_slice: slice.clone(),
|
||||
message: format!("Already a variable named '{}' in this scope.", name),
|
||||
});
|
||||
}
|
||||
self.scopes.declare(name.to_string(), false);
|
||||
}
|
||||
|
||||
fn define(&mut self, name: &str) {
|
||||
if self.scopes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let scope = self.scopes.peek();
|
||||
let _ = self.scopes.set(name.clone(), false);
|
||||
self.scopes.set_local(name, true);
|
||||
}
|
||||
|
||||
fn define(&mut self, name: &String) {
|
||||
if self.scopes.is_empty() {
|
||||
return;
|
||||
fn resolve_local(&mut self, id: NodeId, name: &str) {
|
||||
if let Some(distance) = self.scopes.resolve(name) {
|
||||
self.locals.insert(id, distance);
|
||||
}
|
||||
let _ = self.scopes.set(name.clone(), true);
|
||||
// Not found locally: assume global, record nothing.
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_local(&mut self, id: NodeId, name: &String) {
|
||||
let depth = self.scopes.depth();
|
||||
for i in (0..depth).rev() {
|
||||
if self.scopes.scope_contains(i, name) {
|
||||
// Distance = how many scopes up from the innermost the binding lives.
|
||||
self.locals.insert(id, depth - 1 - i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Not found in any tracked scope: assume global, record nothing.
|
||||
impl Default for Resolver {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Visitor for Resolver {
|
||||
fn visit_stmt(&mut self, stmt: &AstNode<Stmt>) -> LoxResult<()> {
|
||||
match &stmt.node {
|
||||
Stmt::VarDeclaration { name, .. } => {
|
||||
self.declare(name);
|
||||
// `walk_stmt` resolves the initializer (if present).
|
||||
walk_stmt(self, stmt)?;
|
||||
self.define(name);
|
||||
Stmt::VarDeclaration {
|
||||
name, initializer, ..
|
||||
} => {
|
||||
// A function declaration is a var bound to a function literal.
|
||||
// Define its name *before* resolving the body so it can recurse;
|
||||
// a plain variable is defined *after* its initializer so that
|
||||
// `var a = a;` is caught.
|
||||
let is_function = matches!(
|
||||
initializer.as_deref().map(|node| &node.node),
|
||||
Some(Expr::Literal {
|
||||
value: BaseValue::Function(_)
|
||||
})
|
||||
);
|
||||
self.declare(name, &stmt.source_slice);
|
||||
if is_function {
|
||||
self.define(name);
|
||||
walk_stmt(self, stmt)?;
|
||||
} else {
|
||||
walk_stmt(self, stmt)?; // resolves the initializer, if any
|
||||
self.define(name);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
// NOTE: assignment is now an `Expr::Assign`, not a statement.
|
||||
// Add an `Expr::Assign` arm to `visit_expr` to resolve assignments.
|
||||
Stmt::Block { .. } => {
|
||||
self.scopes.push_new_scope();
|
||||
self.scopes.begin_scope();
|
||||
walk_stmt(self, stmt)?;
|
||||
self.scopes.pop_scope();
|
||||
self.scopes.end_scope();
|
||||
Ok(())
|
||||
}
|
||||
// Expression, Print, Return, If, While, For: default traversal.
|
||||
Stmt::Return { .. } => {
|
||||
if self.current_function == FunctionType::None {
|
||||
self.errors.report(LoxError::ParseError {
|
||||
source_slice: stmt.source_slice.clone(),
|
||||
message: "Can't return from top-level code.".to_string(),
|
||||
});
|
||||
}
|
||||
walk_stmt(self, stmt) // resolve the returned expression
|
||||
}
|
||||
// Expression, Print, If, While, For: default traversal.
|
||||
_ => walk_stmt(self, stmt),
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_expr(&mut self, expr: &AstNode<Expr>) -> LoxResult<()> {
|
||||
match &expr.node {
|
||||
Expr::Identifier { name, .. } => {
|
||||
if !self.scopes.is_empty() && self.scopes.get(name).is_ok() {
|
||||
return Err(LoxError::ParseError {
|
||||
source_slice: SourceSlice::synthetic(),
|
||||
message: "Cant read local varialbe in it own initializer".to_string(),
|
||||
Expr::Identifier { name } => {
|
||||
if self.scopes.get_local(name) == Some(&false) {
|
||||
self.errors.report(LoxError::ParseError {
|
||||
source_slice: expr.source_slice.clone(),
|
||||
message: "Can't read local variable in its own initializer.".to_string(),
|
||||
});
|
||||
}
|
||||
walk_expr(self, expr)
|
||||
self.resolve_local(expr.id, name);
|
||||
Ok(())
|
||||
}
|
||||
Expr::Assign { name, .. } => {
|
||||
walk_expr(self, expr)?; // resolve the assigned value first
|
||||
self.resolve_local(expr.id, name);
|
||||
walk_expr(self, expr)
|
||||
Ok(())
|
||||
}
|
||||
_ => walk_expr(self, expr),
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_function(&mut self, function: &LoxFunction) -> LoxResult<()> {
|
||||
let enclosing = std::mem::replace(&mut self.current_function, FunctionType::Function);
|
||||
self.scopes.begin_scope();
|
||||
// Parameters carry no source slice of their own; use the body's.
|
||||
let param_slice = function.body.source_slice.clone();
|
||||
for (param, _ty) in &function.parameters {
|
||||
self.declare(param, ¶m_slice);
|
||||
self.define(param);
|
||||
}
|
||||
walk_function(self, function)?; // resolves guard + body
|
||||
self.scopes.end_scope();
|
||||
self.current_function = enclosing;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::frontend::lexer::Lexer;
|
||||
use crate::frontend::parser::Parser;
|
||||
|
||||
fn parse(src: &str) -> Vec<AstNode<Stmt>> {
|
||||
let tokens = Lexer::new(src.to_string(), 0)
|
||||
.scans_tokens()
|
||||
.expect("source should lex");
|
||||
Parser::new(tokens).parse().expect("source should parse")
|
||||
}
|
||||
|
||||
fn resolve(src: &str) -> Result<HashMap<NodeId, usize>, Vec<LoxError>> {
|
||||
Resolver::resolve_program(&parse(src))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_variables_are_not_resolved() {
|
||||
// Top-level (global) scope is untracked, so nothing is recorded.
|
||||
let locals = resolve("x := 1; print x;").expect("should resolve");
|
||||
assert!(locals.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_read_resolves_to_distance_zero() {
|
||||
let locals = resolve("do x := 1; print x; end").expect("should resolve");
|
||||
assert_eq!(locals.len(), 1);
|
||||
assert_eq!(*locals.values().next().unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_scope_resolves_to_outer_distance() {
|
||||
let src = "do x := 1; do print x; end end";
|
||||
let locals = resolve(src).expect("should resolve");
|
||||
assert_eq!(locals.len(), 1);
|
||||
// `x` is read one scope above where it is read from.
|
||||
assert_eq!(*locals.values().next().unwrap(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_a_variable_in_its_own_initializer_is_an_error() {
|
||||
let errors = resolve("do x := x; end").expect_err("should fail");
|
||||
assert!(errors
|
||||
.iter()
|
||||
.any(|e| e.get_message().contains("its own initializer")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_declaration_in_same_scope_is_an_error() {
|
||||
let errors = resolve("do x := 1; x := 2; end").expect_err("should fail");
|
||||
assert!(errors
|
||||
.iter()
|
||||
.any(|e| e.get_message().contains("Already a variable")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn return_at_top_level_is_an_error() {
|
||||
let errors = resolve("return 1;").expect_err("should fail");
|
||||
assert!(errors.iter().any(|e| e.get_message().contains("top-level")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn return_inside_a_function_is_allowed() {
|
||||
let locals = resolve("f :: fn (n) do return n; end").expect("should resolve");
|
||||
// `n` resolves from the body block up to the parameter scope.
|
||||
assert_eq!(locals.len(), 1);
|
||||
assert_eq!(*locals.values().next().unwrap(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assignment_target_is_resolved() {
|
||||
let locals = resolve("do x := 1; x = 2; end").expect("should resolve");
|
||||
// Both the assignment target and... only the target is a reference here.
|
||||
assert!(locals.values().all(|&d| d == 0));
|
||||
assert!(!locals.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user