Implement a Scope-Aware Symbol Table for Semantic Analysis

Mục tiêu: Create a Symbol Table data structure in Rust to manage identifiers and their scopes for a compiler’s semantic analysis phase. The implementation will use a stack of hash maps to handle nested lexical scopes, allowing for defining and looking up variables and functions.


Congratulations on completing the entire parsing step! You have successfully built the machinery to transform a flat stream of tokens into a rich, hierarchical Abstract Syntax Tree (AST). This is a monumental achievement. The AST perfectly represents the syntactic structure of a SimpliLang program, confirming that the code’s grammar is correct.

However, syntactic correctness is only half the story. The parser would happily accept a program like let x: int = 10 + true; because it follows the grammatical rules of a let statement. But logically, this code is nonsense—you can’t add a number and a boolean. This is where we enter the next phase of compilation: semantic analysis.

The semantic analyzer’s job is to walk the AST and enforce the language’s logical rules, or “semantics.” Its primary task is to answer questions like:

  • Has this variable been declared before it’s used?
  • Do the types in this addition operation match?
  • Does this function call provide the correct number and types of arguments?

To answer these questions, the compiler needs a form of memory to keep track of every identifier (variable, function name, etc.) it has seen and what it knows about them. This “memory” is a crucial data structure called a Symbol Table. In this task, we will design and implement the SymbolTable that will serve as the brain for our semantic analyzer.

The Role of the Symbol Table

A symbol table is a data structure that maps identifiers (strings) to information about those identifiers. At its core, it’s like a dictionary. When the compiler sees a variable named x, it will look up “x” in the symbol table to find out its type.

The most important feature a symbol table must handle is scoping. In SimpliLang, a new scope is created by a function body or an if/else block. Variables declared inside a block are only visible within that block and its sub-blocks. Our symbol table must elegantly model this “nested” or “hierarchical” nature of scopes. A common and effective way to achieve this is by modeling the symbol table as a stack of scopes, where each scope is a hash map.

  • Entering a Scope: When we enter a function or a block, we push a new, empty hash map onto the stack.
  • Defining a Symbol: When a let statement is encountered, the new variable is added to the hash map at the top of the stack (the current scope).
  • Looking Up a Symbol: When we encounter a variable name in an expression, we search for it by looking in the top hash map first. If it’s not found, we look in the one below it, and so on, all the way to the bottom (the global scope). This naturally enforces scoping rules.
  • Exiting a Scope: When we leave a block, we simply pop its hash map off the stack, effectively “forgetting” all the variables that were local to that scope.

Creating the Semantic Analysis Module

First, let’s create a new home for our semantic analyzer and its components. In your src/ directory, create a new directory named semantic.

mkdir src/semantic

Inside this new directory, create two files: mod.rs and symbol_table.rs.

touch src/semantic/mod.rs
touch src/semantic/symbol_table.rs

Now, we need to tell our project about this new module. Open your main library or binary file (src/lib.rs or src/main.rs) and declare the new module.

// In src/lib.rs or src/main.rs
pub mod ast;
pub mod lexer;
pub mod parser;
pub mod semantic; // Add this line

Implementing the SymbolTable

Now, open src/semantic/symbol_table.rs and add the following code. This will define the data structures and methods for our scope-aware symbol table.

// src/semantic/symbol_table.rs

use crate::ast::PrimitiveType;
use std::collections::HashMap;

/// Represents the information stored about a single symbol (variable or function).
///
/// In a more complex language, this enum could have more variants for types,
/// modules, etc. For SimpliLang, we only need to track variables and functions.
#[derive(Debug, Clone, PartialEq)]
pub enum SymbolInfo {
    Variable {
        var_type: PrimitiveType,
        // In the future, you could add more info here, like `is_mutable`.
    },
    Function {
        // We store parameters as a vector of their types. The names are only
        // relevant within the function's body scope.
        param_types: Vec<PrimitiveType>,
        return_type: Option<PrimitiveType>,
    },
}

/// A scope-aware symbol table implemented as a stack of hash maps.
///
/// Each `HashMap` in the `scopes` vector represents a single lexical scope.
/// The vector acts as a stack, with the current scope at the top (end of the vector).
#[derive(Debug, Default)]
pub struct SymbolTable {
    scopes: Vec<HashMap<String, SymbolInfo>>,
}

impl SymbolTable {
    /// Creates a new `SymbolTable` with an initial "global" scope.
    ///
    /// The global scope can be used for built-in functions in the future.
    pub fn new() -> Self {
        let mut table = SymbolTable::default();
        // Start with one scope already on the stack.
        table.enter_scope();
        table
    }

    /// Enters a new, nested scope.
    pub fn enter_scope(&mut self) {
        self.scopes.push(HashMap::new());
    }

    /// Exits the current scope, discarding all symbols defined within it.
    ///
    /// This will panic if you try to exit the last remaining (global) scope,
    /// which would be a logical error in the analyzer.
    pub fn exit_scope(&mut self) {
        if self.scopes.len() <= 1 {
            panic!("Cannot exit the global scope.");
        }
        self.scopes.pop();
    }

    /// Defines a new symbol in the *current* scope.
    ///
    /// Returns `true` if the symbol was successfully defined.
    /// Returns `false` if a symbol with the same name already exists in the current scope.
    pub fn define(&mut self, name: String, info: SymbolInfo) -> bool {
        // `last_mut` gets a mutable reference to the top of the stack (the current scope).
        if let Some(current_scope) = self.scopes.last_mut() {
            if current_scope.contains_key(&name) {
                // The symbol is already defined in this immediate scope.
                return false;
            }
            current_scope.insert(name, info);
            return true;
        }
        // This case should ideally not be reachable because we always have at least one scope.
        false
    }

    /// Looks up a symbol by name, searching from the current scope outwards.
    ///
    /// Returns `Some(&SymbolInfo)` if the symbol is found in any accessible scope.
    /// Returns `None` if the symbol is not found.
    pub fn lookup(&self, name: &str) -> Option<&SymbolInfo> {
        // `iter().rev()` iterates the scopes from top to bottom (current to global).
        for scope in self.scopes.iter().rev() {
            if let Some(info) = scope.get(name) {
                // Found the symbol in the current or an outer scope.
                return Some(info);
            }
        }
        // The symbol was not found in any scope.
        None
    }
}

Finally, let’s wire this up in our module file. Open src/semantic/mod.rs and re-export the contents of symbol_table.rs.

// src/semantic/mod.rs

pub mod symbol_table;

pub use symbol_table::*;

Dissecting the Implementation

  • SymbolInfo Enum: This enum is the “value” in our key-value store. It cleanly separates the kind of information we store for variables versus functions. For now, it’s simple, but it’s designed to be easily extensible.
  • SymbolTable Struct: The core of our implementation. scopes: Vec<HashMap<String, SymbolInfo>> is the stack-of-scopes design realized in Rust. We use #[derive(Default)] and implement new() for convenient creation.
  • enter_scope() / exit_scope(): These are the simple stack operations. enter_scope pushes a new HashMap, and exit_scope pops one. The panic in exit_scope is a safety measure; our semantic analyzer should never try to exit the base global scope.
  • define(): This method only ever interacts with the current scope, which we access with self.scopes.last_mut(). This correctly models how variable declarations work—they always apply to the scope they are in. It also prevents variable redeclaration within the same scope. It does not, however, prevent shadowing, which is intentional and correct behavior (a variable in an inner scope can have the same name as one in an outer scope).
  • lookup(): This is the most critical method. The for scope in self.scopes.iter().rev() loop is the magic that makes lexical scoping work. It iterates the vector in reverse, starting from the last element (the current, innermost scope) and working its way backward to the first element (the global, outermost scope). The first match it finds is the correct one, naturally handling variable shadowing.

You have now created a robust and efficient SymbolTable. This data structure is the memory and the rulebook for our compiler’s logic. It’s ready to be used by the semantic analyzer to validate the AST.

Next Steps

With the SymbolTable in place, you are ready for the next task: implementing the SemanticAnalyzer struct itself. This new struct will contain our SymbolTable and will be responsible for performing a recursive traversal of the AST. In the next task, you will set up this struct and its main traversal method, preparing it to use the symbol table to enforce the rules of SimpliLang.

Further Reading

Build the Semantic Analyzer Traversal Framework

Mục tiêu: Create the SemanticAnalyzer struct and implement the skeleton methods for traversing the Abstract Syntax Tree (AST). This task sets up the core framework for semantic analysis using the Visitor Pattern, preparing for future logical checks.


With a robust SymbolTable ready to act as our compiler’s memory, it’s time to build the ‘brain’ that will use it: the SemanticAnalyzer. The parser did a fantastic job ensuring our code is grammatically correct, but it’s the semantic analyzer that will ensure the code is logically sound. It’s the component that catches errors like trying to add a number to a boolean, using a variable before it’s declared, or calling a function with the wrong number of arguments.

To perform these checks, the analyzer must systematically walk through the entire Abstract Syntax Tree (AST) produced by the parser. This process of walking the tree is called traversal. In this task, we will implement the SemanticAnalyzer struct and the skeleton of its traversal methods. We will create the scaffolding that will visit every node in the AST, preparing us to add specific logical checks in the tasks that follow.

The Visitor Pattern: Walking the AST

The AST is a tree structure, and traversing it is a classic computer science problem. We will implement a version of the Visitor Pattern. Our SemanticAnalyzer will act as the “visitor”. It will have a main analyze method that starts at the root of the AST (the Program node) and then recursively calls specialized methods for each type of node it encounters (FunctionDefinition, Block, Stmt, Expr, etc.). This creates a natural, recursive descent through the tree, allowing us to perform context-specific actions at every node.

Creating the Analyzer’s Home

First, let’s create the file where our analyzer’s logic will live. In your src/semantic/ directory, create a new file named analyzer.rs.

touch src/semantic/analyzer.rs

We also need a place to define our error types. For cleanliness, create error.rs as well.

touch src/semantic/error.rs

Defining Semantic Errors

Before we build the analyzer, let’s define what kind of errors it can produce. For now, a simple struct containing a message will suffice. We can enhance this later to include source code locations.

Open src/semantic/error.rs and add the following:

// src/semantic/error.rs

/// Represents a single logical error found during semantic analysis.
///
/// In the future, this could be expanded to include a `Span` to pinpoint
/// the error's location in the source code for better diagnostics.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SemanticError {
    pub message: String,
}

Implementing the SemanticAnalyzer Struct

Now, let’s set up the SemanticAnalyzer itself in src/semantic/analyzer.rs. It will own the SymbolTable you just built and a vector to collect any errors it finds along the way. We will also implement the skeleton of the traversal methods.

// src/semantic/analyzer.rs

use crate::ast::*;
use crate::semantic::{SymbolTable, SymbolInfo, SemanticError};

/// The SemanticAnalyzer struct, responsible for walking the AST and enforcing
/// the language's semantic rules.
pub struct SemanticAnalyzer {
    /// The scope-aware symbol table that tracks all identifiers.
    symbol_table: SymbolTable,
    /// A collection of errors found during analysis.
    errors: Vec<SemanticError>,
}

impl SemanticAnalyzer {
    /// Creates a new `SemanticAnalyzer`.
    pub fn new() -> Self {
        SemanticAnalyzer {
            symbol_table: SymbolTable::new(),
            errors: Vec::new(),
        }
    }

    /// The main entry point for semantic analysis.
    /// It takes a complete Program AST and returns a `Result`.
    /// `Ok(())` indicates success, while `Err(Vec<SemanticError>)` contains all found errors.
    pub fn analyze(mut self, program: &Program) -> Result<(), Vec<SemanticError>> {
        // We will implement a two-pass analysis for functions.
        // The first pass (to be implemented later) will register all function signatures
        // in the global scope. This allows for forward references and mutual recursion
        // (e.g., function A calls B, and B calls A).

        // For now, we proceed directly to the second pass: analyzing function bodies.
        for func in &program.functions {
            self.analyze_function_definition(func);
        }

        // After traversal is complete, check if we found any errors.
        if self.errors.is_empty() {
            Ok(())
        } else {
            Err(self.errors)
        }
    }

    /// Analyzes a single function definition.
    fn analyze_function_definition(&mut self, func: &FunctionDefinition) {
        // In a future task, we will:
        // 1. Enter a new scope for the function body.
        // 2. Define the function's parameters as variables in this new scope.
        self.analyze_block(&func.body);
        // 3. Exit the function's scope.
    }

    /// Analyzes a block of statements.
    fn analyze_block(&mut self, block: &Block) {
        // In a future task, we will enter a new scope here.
        for stmt in &block.statements {
            self.analyze_statement(stmt);
        }
        // And exit the scope here.
    }

    /// Analyzes a single statement.
    fn analyze_statement(&mut self, stmt: &Stmt) {
        // A `match` statement is the perfect way to dispatch to the correct
        // logic for each kind of statement.
        match stmt {
            Stmt::Let { initializer, .. } => {
                // We must analyze the initializer expression first.
                self.analyze_expression(initializer);
                // Future task: Add the new variable to the symbol table.
            }
            Stmt::Return(expr) => {
                self.analyze_expression(expr);
                // Future task: Check if the expression's type matches the function's return type.
            }
            Stmt::Expression(expr) => {
                // For an expression statement, we just need to analyze the expression.
                self.analyze_expression(expr);
            }
        }
    }

    /// Analyzes a single expression. This is the heart of the recursive traversal.
    fn analyze_expression(&mut self, expr: &Expr) {
        match expr {
            Expr::Literal(_) => {
                // Literals are always semantically valid by themselves. No action needed.
            }
            Expr::Variable(name) => {
                // Future task: Look up `name` in the symbol table to ensure it exists.
            }
            Expr::Binary { left, right, .. } => {
                // For a binary expression, we must recursively analyze both sides.
                self.analyze_expression(left);
                self.analyze_expression(right);
                // Future task: Perform type checking on the left and right operands.
            }
            Expr::Unary { operand, .. } => {
                // Recursively analyze the operand.
                self.analyze_expression(operand);
                // Future task: Perform type checking on the operand.
            }
            Expr::If { condition, then_block, else_block } => {
                self.analyze_expression(condition);
                // Future task: Ensure the condition is of type `bool`.
                self.analyze_block(then_block);
                if let Some(else_b) = else_block {
                    self.analyze_block(else_b);
                }
            }
            Expr::FunctionCall { arguments, .. } => {
                // Future task: Look up the function in the symbol table.
                for arg in arguments {
                    self.analyze_expression(arg);
                }
                // Future task: Check argument count and types against the function's signature.
            }
        }
    }
}

Integrating the New Modules

Finally, let’s update src/semantic/mod.rs to make our new analyzer and error types available to the rest of the compiler.

// src/semantic/mod.rs

pub mod symbol_table;
pub mod analyzer;
pub mod error;

pub use symbol_table::*;
pub use analyzer::*;
pub use error::*;

Dissecting the Traversal Framework

You have just built the complete structural skeleton for the semantic analysis phase. Let’s review the key design points:

  • Stateful Visitor: The SemanticAnalyzer holds state (symbol_table and errors). Because its methods take &mut self, changes made while visiting one part of the tree (like defining a variable) are visible to later parts of the traversal.
  • Recursive Descent: The call graph clearly shows the recursive nature of the traversal. analyze_function_definition calls analyze_block, which calls analyze_statement, which in turn calls analyze_expression. analyze_expression is the most recursive, as it calls itself for sub-expressions.
  • Error Collection: Instead of panicking or stopping at the first error, our analyzer is designed to be resilient. It will collect all the errors it can find in the errors vector and return them all at once. This provides a much better user experience.
  • Placeholders for Future Logic: The comments inside each method act as a perfect roadmap for the upcoming tasks. We’ve defined where the checks will happen; we just need to implement the what.

You have successfully created the engine for semantic analysis. This traversal framework provides the perfect structure to begin layering on the logical rules of SimpliLang.

Next Steps

The traversal skeleton is in place, but it doesn’t yet interact with our SymbolTable to manage scopes. In the very next task, you will bring the analyzer to life by implementing scope management. You will add calls to symbol_table.enter_scope() when entering a function or block, and symbol_table.exit_scope() when leaving, making your analyzer truly scope-aware.

Further Reading

Implement Scope Management in the Semantic Analyzer

Mục tiêu: Integrate lexical scope management into the semantic analyzer’s AST traversal by calling enter\_scope() and exit\_scope() on the symbol table when entering and leaving function definitions and code blocks. This will make the analyzer context-aware of variable visibility.


Excellent! You’ve successfully built the structural framework for the semantic analysis phase. With the SemanticAnalyzer struct created and its recursive AST traversal methods defined, you have a visitor ready to walk the tree. However, this visitor currently walks through the different rooms of a house without ever opening or closing the doors between them. This task is all about teaching it to manage those doors, which in our compiler are the lexical scopes.

You’ve already built the SymbolTable with its powerful stack-of-scopes model, including the enter_scope() and exit_scope() methods. Now, we will integrate these calls into our traversal logic. This will make our analyzer truly context-aware, ensuring that when it analyzes a piece of code, the symbol table accurately reflects which variables should be visible at that exact point.

The Traversal and Scope Lifecycles: A Perfect Match

The AST traversal you designed and the lifecycle of a lexical scope are perfectly synchronized. Every time our language grammar specifies a new scope, our analyzer’s traversal will enter a corresponding node in the AST. This gives us two clear rules:

  1. When traversal enters a scope-creating construct (a function definition or a code block), we must call symbol_table.enter_scope().
  2. When traversal leaves that same construct, we must call symbol_table.exit_scope().

This pairing is critical. It ensures that variables defined within a scope (like inside an if block) are “forgotten” correctly once the analyzer moves past that block, perfectly mimicking how the compiled program will behave at runtime.

Implementing Scope Management

Let’s put this into practice by modifying our SemanticAnalyzer in src/semantic/analyzer.rs. We need to update the methods that handle the two primary scope-creating constructs in SimpliLang: function definitions and blocks.

Here are the highlighted changes for your analyzer.rs file.

// src/semantic/analyzer.rs

use crate::ast::*;
use crate::semantic::{SymbolTable, SymbolInfo, SemanticError};

// ... (SemanticAnalyzer struct and `new`, `analyze` methods are unchanged)
pub struct SemanticAnalyzer {
    symbol_table: SymbolTable,
    errors: Vec<SemanticError>,
}

impl SemanticAnalyzer {
    pub fn new() -> Self { /* ... */ }
    pub fn analyze(mut self, program: &Program) -> Result<(), Vec<SemanticError>> { /* ... */ }

    /// Analyzes a single function definition.
    /// A function definition creates a new scope for its parameters and body.
    fn analyze_function_definition(&mut self, func: &FunctionDefinition) {
        // NEW: Enter a new scope for the function. This scope will hold the
        // function's parameters, making them available within the function body.
        self.symbol_table.enter_scope();

        // In a future task, we will:
        // 1. Define the function's parameters as variables in this new scope.
        self.analyze_block(&func.body);

        // NEW: Exit the function's scope. All parameters and any variables
        // defined directly in the function's body (if not in a nested block)
        // are now removed from the symbol table.
        self.symbol_table.exit_scope();
    }

    /// Analyzes a block of statements.
    /// A block also introduces a new, nested scope.
    fn analyze_block(&mut self, block: &Block) {
        // NEW: Enter a new scope for the block. This is crucial for nested
        // scopes, like those created by `if/else` expressions.
        self.symbol_table.enter_scope();

        for stmt in &block.statements {
            self.analyze_statement(stmt);
        }

        // NEW: Exit the block's scope. Any variables declared with `let`
        // inside this block are now no longer visible.
        self.symbol_table.exit_scope();
    }

    // ... (analyze_statement and analyze_expression are unchanged for now)
    fn analyze_statement(&mut self, stmt: &Stmt) { /* ... */ }
    fn analyze_expression(&mut self, expr: &Expr) { /* ... */ }
}

Dissecting the Changes

The changes are small but their impact is profound.

  • In analyze_function_definition:

    • We wrap the entire analysis of the function’s body with enter_scope() and exit_scope(). This creates a dedicated scope that will eventually hold the function’s parameters.
    • Notice that analyze_block is called inside this scope.
  • In analyze_block:

    • We also wrap the analysis of the block’s statements with enter_scope() and exit_scope().
    • This might seem redundant at first glance. If a function body is a block, won’t we enter a scope twice? Yes, and that’s exactly what we want! This creates a nested structure that correctly models the language’s behavior.

Let’s visualize the scope stack for a simple function:

fn calculate(a: int) -> int { // analyze_function_definition calls enter_scope()
                               // SCOPE STACK: [Global, Function(calculate)]

    let result = a * 10;
    if result > 100 {        // analyze_block (for the 'if') calls enter_scope()
                             // SCOPE STACK: [Global, Function(calculate), If-Block]

        let bonus: int = 5;
        // 'bonus' is defined in the If-Block scope.
    }                        // analyze_block (for the 'if') calls exit_scope()
                             // SCOPE STACK: [Global, Function(calculate)]

    return result;
}                            // analyze_function_definition calls exit_scope()
                             // SCOPE STACK: [Global]

As you can see, this simple addition of two lines in each function makes our analyzer’s traversal statefully mirror the lexical scope structure of the program it’s analyzing. The SymbolTable is now always in the correct state, ready to be queried or updated by the logic that will analyze the statements and expressions within each scope.

Next Steps

Your semantic analyzer is now scope-aware! It correctly navigates the nested contexts of a program. The SymbolTable is being primed with the correct scope at every step of the traversal.

The next logical step is to start using this machinery. According to the project roadmap, the very next task is to handle let statements. You will modify analyze_statement to call symbol_table.define() when it encounters a Stmt::Let, adding the newly declared variable and its type to the current scope. This is where your analyzer will start to truly enforce the language’s rules.

Further Reading

Semantic Analyzer: Processing Variable Declarations

Mục tiêu: Update the semantic analyzer to process let statements by defining variables in the symbol table for the current scope and detecting redeclaration errors.


Excellent work setting up the scope management for your semantic analyzer! In the last task, you taught the analyzer how to follow the flow of the program, entering and exiting scopes in perfect sync with the AST traversal. The SymbolTable is now a true reflection of the current context at any point in the code.

Now, it’s time to leverage this powerful machinery. A symbol table isn’t just for tracking where we are; it’s for remembering what we’ve learned. The most fundamental piece of information to remember is the existence of variables. This task is about bridging the gap between a let statement in the AST and a concrete entry in our SymbolTable. We will teach our analyzer to process variable declarations, which is the first active rule it will enforce.

From Declaration to Definition: The Role of let

When the analyzer encounters a Stmt::Let node, its job is to perform a critical action: define a new symbol in the current scope. This action transforms a syntactic declaration into a semantic reality that the rest of the analyzer can rely on.

This process involves two key steps:

  1. Creation: We will construct a SymbolInfo::Variable object, packaging the variable’s type from the AST node.
  2. Registration: We will use the symbol_table.define() method you already created to attempt to add this new symbol to the current scope’s hash map.

Crucially, this is also where we enforce our first semantic rule: a variable cannot be redeclared in the same scope. Your define() method was cleverly designed to return false if a symbol with the same name already exists. We will now use this return value to detect this error and record it.

Implementing Variable Definition Logic

Let’s update the SemanticAnalyzer in src/semantic/analyzer.rs. The changes are focused entirely within the analyze_statement method, specifically in the match arm for Stmt::Let.

Here are the highlighted changes for your analyzer.rs file.

// src/semantic/analyzer.rs

// ... (use statements, SemanticAnalyzer struct, new, and analyze methods are unchanged)

impl SemanticAnalyzer {
    // ... (new, analyze, analyze_function_definition, analyze_block are unchanged)
    pub fn new() -> Self { /* ... */ }
    pub fn analyze(mut self, program: &Program) -> Result<(), Vec<SemanticError>> { /* ... */ }
    fn analyze_function_definition(&mut self, func: &FunctionDefinition) { /* ... */ }
    fn analyze_block(&mut self, block: &Block) { /* ... */ }

    /// Analyzes a single statement.
    fn analyze_statement(&mut self, stmt: &Stmt) {
        match stmt {
            Stmt::Let {
                var_name,
                var_type,
                initializer,
            } => {
                // First, we must analyze the initializer expression.
                // In a future task, this will tell us its type.
                self.analyze_expression(initializer);

                // --- NEW: Variable Definition Logic ---

                // 1. Create the symbol information for the new variable.
                let info = SymbolInfo::Variable {
                    var_type: var_type.clone(),
                };

                // 2. Attempt to define the new variable in the current scope.
                //    The `define` method returns `false` if the variable already exists
                //    in the *current* scope.
                if !self.symbol_table.define(var_name.clone(), info) {
                    // 3. If definition fails, it's a redeclaration error.
                    //    We create a `SemanticError` and add it to our error list.
                    let error = SemanticError {
                        message: format!("Variable '{}' is already declared in this scope.", var_name),
                    };
                    self.errors.push(error);
                }
            }
            Stmt::Return(expr) => {
                self.analyze_expression(expr);
                // Future task: Check if the expression's type matches the function's return type.
            }
            Stmt::Expression(expr) => {
                self.analyze_expression(expr);
            }
        }
    }

    // ... (analyze_expression is unchanged)
    fn analyze_expression(&mut self, expr: &Expr) { /* ... */ }
}

Dissecting the Implementation

This small block of code is doing a lot of important semantic work.

  1. Stateful Analysis: Notice that we analyze the initializer expression before we define the variable. While this doesn’t matter much right now, it will become critical when we implement type checking. This demonstrates the importance of the traversal order.
  2. Rule Enforcement: The if !self.symbol_table.define(...) block is the heart of this task. It’s our first active validation step. We are no longer just visiting nodes; we are querying and updating our state (SymbolTable) and making decisions based on the results.
  3. Redeclaration vs. Shadowing: It is crucial to remember that define() only checks the current scope (the last HashMap on the stack). This is the correct behavior. It correctly flags let x: int = 5; let x: bool = true; as an error if both are in the same block. However, it correctly allows the following, which is known as shadowing: SimpliLang let x: int = 10; if true { let x: bool = false; // This is OK! It's in a new, inner scope. } Our analyzer now correctly implements this fundamental scoping rule.
  4. Error Collection: When we detect an error, we don’t panic or stop. We create a SemanticError struct, add it to our errors vector, and continue the analysis. This allows us to report multiple errors to the user at once.

You have now successfully taught your analyzer to understand variable declarations. It can now build up a map of the variables available in every part of the program, which is the essential prerequisite for validating how those variables are used.

Next Steps

You’ve implemented the “define” side of the coin. The next logical step is to implement the “use” side. When the analyzer encounters a variable in an expression (an Expr::Variable node), it needs to check if that variable has actually been defined in the current scope or any of its parent scopes. In the next task, you will implement this check by using the symbol_table.lookup() method you built.

Further Reading

Implement Undeclared Variable Check in Semantic Analyzer

Mục tiêu: Enhance the semantic analyzer to validate variable usage. Modify the expression analysis logic to use the symbol table’s lookup method to ensure a variable is declared in an accessible scope before it is used, reporting a semantic error if the check fails.


Excellent work implementing the logic for variable declarations. You’ve successfully taught the semantic analyzer how to process let statements and populate the SymbolTable. This is the “define” half of a variable’s lifecycle. Now, we will complete the picture by implementing the “use” half: validating that a variable exists whenever it is accessed in an expression.

Enforcing the “Use Before Declare” Rule

One of the most fundamental rules in a statically-typed language like SimpliLang is that a variable must be declared before it can be used. This prevents a whole class of runtime errors and simple typos. The parser can’t check this rule; it happily creates an Expr::Variable node for any identifier it sees. It is the semantic analyzer’s job, armed with our scope-aware SymbolTable, to enforce this crucial constraint.

When our analyzer’s traversal reaches an Expr::Variable(name) node, it must ask the SymbolTable a simple question: “Does a variable with this name exist in the current scope or any of the parent scopes?”

  • If the answer is yes, the usage is valid.
  • If the answer is no, we have found an “undeclared variable” error, which is a critical semantic issue.

Your SymbolTable is already perfectly equipped for this with its lookup() method, which intelligently searches from the innermost scope outwards, correctly handling lexical scoping and variable shadowing.

Implementing the Variable Usage Check

Let’s modify the SemanticAnalyzer in src/semantic/analyzer.rs to perform this check. The change is highly localized to the analyze_expression method, inside the match arm for Expr::Variable.

Here are the highlighted changes for your analyzer.rs file.

// src/semantic/analyzer.rs

// ... (use statements, SemanticAnalyzer struct, and other methods are unchanged)

impl SemanticAnalyzer {
    // ... (new, analyze, analyze_function_definition, analyze_block, analyze_statement are unchanged)
    pub fn new() -> Self { /* ... */ }
    pub fn analyze(mut self, program: &Program) -> Result<(), Vec<SemanticError>> { /* ... */ }
    fn analyze_function_definition(&mut self, func: &FunctionDefinition) { /* ... */ }
    fn analyze_block(&mut self, block: &Block) { /* ... */ }
    fn analyze_statement(&mut self, stmt: &Stmt) { /* ... */ }

    /// Analyzes a single expression. This is the heart of the recursive traversal.
    fn analyze_expression(&mut self, expr: &Expr) {
        match expr {
            Expr::Literal(_) => {
                // Literals are always semantically valid. No action needed.
            }
            Expr::Variable(name) => {
                // --- NEW: Variable Usage Check ---

                // 1. Look up the variable by name in the symbol table.
                //    The `lookup` method correctly searches from the current scope outwards.
                if self.symbol_table.lookup(name).is_none() {
                    // 2. If `lookup` returns `None`, the variable has not been declared
                    //    in any accessible scope. This is a semantic error.
                    let error = SemanticError {
                        message: format!("Undeclared variable '{}'.", name),
                    };
                    self.errors.push(error);
                }
            }
            Expr::Binary { left, right, .. } => {
                // For a binary expression, we must recursively analyze both sides.
                self.analyze_expression(left);
                self.analyze_expression(right);
                // Future task: Perform type checking on the left and right operands.
            }
            // ... (other expression arms are unchanged)
            Expr::Unary { operand, .. } => {
                self.analyze_expression(operand);
            }
            Expr::If { condition, then_block, else_block } => {
                self.analyze_expression(condition);
                self.analyze_block(then_block);
                if let Some(else_b) = else_block {
                    self.analyze_block(else_b);
                }
            }
            Expr::FunctionCall { arguments, .. } => {
                for arg in arguments {
                    self.analyze_expression(arg);
                }
            }
        }
    }
}

Dissecting the Implementation

This small addition is incredibly powerful and completes the core logic of variable resolution.

  1. The lookup() Call: self.symbol_table.lookup(name) is the central operation. It performs the scope-aware search you designed. If x is defined in a global scope and also shadowed in a local scope, lookup("x") will correctly find the local one first. If a variable goes out of scope (because its block was exited), it will no longer be found by lookup.
  2. The .is_none() Check: The lookup method returns an Option<&SymbolInfo>. Checking for is_none() is the idiomatic Rust way to see if the lookup failed.
  3. Meaningful Error Reporting: When the check fails, we generate a clear and specific error message, Undeclared variable 'x'.. This is far more helpful to a user than a generic “semantic error.” We add it to our errors vector and continue analysis, allowing us to find other potential errors in the program.

With this logic, your analyzer can now detect one of the most common programming errors. The interplay between define() in the Stmt::Let arm and lookup() in the Expr::Variable arm forms a complete system for managing and validating the lifecycle of variables in your language.

Next Steps

You’ve now ensured that every variable used in an expression has been declared. But that’s only part of the puzzle. We know the variable exists, but we’re not yet using the information about it—specifically, its type.

The next logical task is to implement type checking for binary expressions. You will enhance the Expr::Binary arm in analyze_expression to not only visit the left and right sides but to also determine their types and check if they are compatible with the operator being used (e.g., ensuring you don’t add an int to a bool). This will require a modification to analyze_expression so that it returns the type of the expression it just analyzed.

Further Reading

Implement Type Checking for Binary Expressions

Mục tiêu: Refactor the semantic analyzer’s analyze\_expression method to return the type of an expression. Implement the type checking logic for binary expressions, handling arithmetic, comparison, and equality operators according to the language’s rules.


Of course! Let’s continue with your journey to build the SimpliLang compiler.

You’ve successfully implemented the core variable resolution logic. Your semantic analyzer can now distinguish between a declared variable and an undeclared one, a fundamental check for any compiler. This means you have successfully validated the existence of variables. The next and more profound step is to validate their usage. It’s not enough to know a variable x exists; we must ensure that an operation like x + 10 is only allowed if x is a number.

This is the essence of type checking. We will now upgrade our analyzer from a simple existence checker into a true type system referee. We’ll start with the most common place types interact: binary expressions.

The Foundational Refactor: Making the Analyzer Type-Aware

Until now, our analyze_expression method has been a one-way street. It traverses into an expression but doesn’t report back what it found. Its return type is (), the unit type, signifying “no information.”

To perform type checking, we must change this. The method needs to return the type of the expression it just analyzed. This allows the analysis of a larger expression to be built upon the results of its sub-expressions. For example, to know the type of (a + b), we first need to find the types of a and b.

We will refactor the signature of analyze_expression to return an Option<PrimitiveType>.

  • Some(PrimitiveType): The analysis was successful, and this is the type of the expression.
  • None: A semantic error was found within the expression (like an undeclared variable). Returning None allows us to gracefully stop checking the current branch of the AST, preventing a cascade of confusing, follow-on errors.

Let’s begin by updating the signature and the simple “leaf” expressions in src/semantic/analyzer.rs.

// src/semantic/analyzer.rs

// ... (other code)

// A small but useful helper for error messages.
// This requires you to add `impl std::fmt::Display for PrimitiveType` in your ast/types.rs file.
// Example: `impl Display for PrimitiveType { ... write!(f, "int") ... }`
use std::fmt::Display; 

// ... (SemanticAnalyzer struct and other methods)

impl SemanticAnalyzer {
    // ... (new, analyze, etc.)

    /// Analyzes a single expression and returns its type if valid.
    // CHANGED: The method now returns an Option<PrimitiveType>.
    fn analyze_expression(&mut self, expr: &Expr) -> Option<PrimitiveType> {
        match expr {
            Expr::Literal(value) => match value {
                // The type of a literal is straightforward.
                LiteralValue::Int(_) => Some(PrimitiveType::Int),
                LiteralValue::Float(_) => Some(PrimitiveType::Float),
                LiteralValue::Bool(_) => Some(PrimitiveType::Bool),
            },
            Expr::Variable(name) => {
                // For a variable, we look up its type in the symbol table.
                if let Some(info) = self.symbol_table.lookup(name) {
                    if let SymbolInfo::Variable { var_type } = info {
                        // We found the variable and it is a variable, return its type.
                        Some(*var_type)
                    } else {
                        // This handles cases like `let my_func = 5;` where a function name is shadowed.
                        self.errors.push(SemanticError {
                            message: format!("'{}' is not a variable.", name),
                        });
                        None
                    }
                } else {
                    // This is the same undeclared variable error as before.
                    self.errors.push(SemanticError {
                        message: format!("Undeclared variable '{}'.", name),
                    });
                    None // Return None on error.
                }
            }
            Expr::Binary { left, op, right } => {
                // This is where our main logic will go.
                // We'll fill this in next.
                unimplemented!() // Placeholder for now
            }
            // ... other arms will be updated later ...
            _ => unimplemented!(), // We'll address other expression types as needed
        }
    }
}

We also need to update the call sites in analyze_statement to accommodate this change. Since the type information isn’t used at the statement level, we can simply ignore the result.

// src/semantic/analyzer.rs

// ... (inside impl SemanticAnalyzer)

fn analyze_statement(&mut self, stmt: &Stmt) {
    match stmt {
        Stmt::Let { initializer, .. } => {
            // We analyze the initializer, but we don't need its type here.
            // We'll use it in a later task for assignment type checking.
            let _ = self.analyze_expression(initializer); // CHANGED
            // ... (rest of the logic for defining the variable)
        }
        Stmt::Return(expr) => {
            let _ = self.analyze_expression(expr); // CHANGED
        }
        Stmt::Expression(expr) => {
            let _ = self.analyze_expression(expr); // CHANGED
        }
    }
}

Implementing Binary Expression Type Checking

With the foundation laid, we can now implement the core logic for this task. Inside the Expr::Binary match arm, we will:

  1. Recursively call analyze_expression on the left and right operands to get their types.
  2. If either analysis failed (returned None), we stop immediately.
  3. Based on the operator (+, *, ==, etc.), we enforce the rules of SimpliLang:
    • Arithmetic operators (+, -, *, /) require both operands to be of the same numeric type (int or float). The result is of that same type.
    • Comparison operators (<, >) also require the same numeric type. The result is always a bool.
    • Equality operators (==, !=) require the operands to be of the same type (int, float, or bool). The result is always a bool.

Here is the complete implementation for the Expr::Binary arm.

// Replace the `Expr::Binary` arm in `src/semantic/analyzer.rs`'s `analyze_expression` method.

            Expr::Binary { left, op, right } => {
                // 1. Recursively analyze the left and right sub-expressions to find their types.
                let left_type_opt = self.analyze_expression(left);
                let right_type_opt = self.analyze_expression(right);

                // 2. Check for errors in the sub-expressions. If either returned `None`,
                //    an error has already been recorded, so we propagate `None` upwards.
                if left_type_opt.is_none() || right_type_opt.is_none() {
                    return None;
                }

                // We can now safely unwrap to get the concrete types.
                let left_type = left_type_opt.unwrap();
                let right_type = right_type_opt.unwrap();

                // 3. Match on the specific binary operator to apply the correct type rules.
                match op {
                    // --- Arithmetic Operators ---
                    BinaryOp::Add | BinaryOp::Subtract | BinaryOp::Multiply | BinaryOp::Divide => {
                        // Rule: Operands must be of the same type.
                        if left_type != right_type {
                            self.errors.push(SemanticError {
                                message: format!(
                                    "Type mismatch: cannot apply operator {:?} to types '{}' and '{}'.",
                                    op, left_type, right_type
                                ),
                            });
                            return None;
                        }
                        // Rule: Operands must be numeric.
                        if left_type == PrimitiveType::Int || left_type == PrimitiveType::Float {
                            // The result of an arithmetic operation has the same type as its operands.
                            Some(left_type)
                        } else {
                            self.errors.push(SemanticError {
                                message: format!(
                                    "Arithmetic operator {:?} can only be applied to numeric types, not '{}'.",
                                    op, left_type
                                ),
                            });
                            None
                        }
                    }

                    // --- Comparison Operators ---
                    BinaryOp::LessThan | BinaryOp::GreaterThan => {
                        // Rule: Operands must be of the same type.
                        if left_type != right_type {
                            self.errors.push(SemanticError {
                                message: format!(
                                    "Type mismatch: cannot compare types '{}' and '{}'.",
                                    left_type, right_type
                                ),
                            });
                            return None;
                        }
                        // Rule: Operands must be numeric for ordering comparisons.
                        if left_type == PrimitiveType::Int || left_type == PrimitiveType::Float {
                            // The result of any comparison is always a boolean.
                            Some(PrimitiveType::Bool)
                        } else {
                            self.errors.push(SemanticError {
                                message: format!(
                                    "Comparison operators can only be applied to numeric types, not '{}'.",
                                    left_type
                                ),
                            });
                            None
                        }
                    }

                    // --- Equality Operators ---
                    BinaryOp::Equal | BinaryOp::NotEqual => {
                        // Rule: Operands must be of the same type for equality checks.
                        if left_type != right_type {
                            self.errors.push(SemanticError {
                                message: format!(
                                    "Type mismatch: cannot check for equality between types '{}' and '{}'.",
                                    left_type, right_type
                                ),
                            });
                            return None;
                        }
                        // SimpliLang allows equality checks on all primitive types.
                        // The result of any equality check is always a boolean.
                        Some(PrimitiveType::Bool)
                    }
                }
            }

You have now implemented a crucial piece of the semantic analyzer. It no longer just checks for existence; it actively validates the logical consistency of expressions according to the rules of your language, providing clear, specific error messages when those rules are broken.

Next Steps

With the ability to determine the type of any expression, you are now perfectly positioned for the next set of validation tasks. The next logical step is to implement type checking for assignments. In the Stmt::Let arm, you will compare the type you get from analyzing the initializer expression against the variable’s declared var_type to ensure they match.

Further Reading

Implement Assignment Type Safety in Semantic Analyzer

Mục tiêu: Enhance the semantic analyzer to enforce type safety for let statements. Update the analyze\_statement method to compare a variable’s declared type with the type of its initializer expression, and report a SemanticError if they don’t match.


Excellent! In the last task, you performed a foundational refactoring, upgrading your analyze_expression method to return the type of the expression it analyzes. This was a critical step that transformed your analyzer from a simple existence checker into a true type-aware system. Now, you are perfectly positioned to use that returned type information to enforce one of the most important rules in a statically-typed language: assignment type safety.

When a programmer writes let x: int = ...;, they are making a contract with the compiler. They are asserting that the variable x will always hold an integer. It is our semantic analyzer’s job to be the vigilant enforcer of this contract. It must look at the expression on the right-hand side of the equals sign and verify that its type matches the type declared on the left. This prevents a vast category of logical errors, such as accidentally assigning a boolean to a variable that’s expected to be a number.

Leveraging the Type-Aware Analyzer

The hard work is already done. Your analyze_expression function now acts as a “type oracle.” We can give it any expression from our AST, and it will tell us its type (or tell us that the expression contains an error). The logic for checking an assignment in a let statement is therefore a straightforward comparison:

  1. Analyze the initializer expression to get its type.
  2. Retrieve the variable’s declared type from the Stmt::Let AST node.
  3. Compare the two types. If they are not identical, we have found a type mismatch error.

Let’s implement this crucial check. The changes are focused entirely within the analyze_statement method, specifically the arm that handles the Stmt::Let variant.

Implementing the Assignment Check

Update the analyze_statement function in your src/semantic/analyzer.rs file with the following logic. We are changing how we call analyze_expression and adding the new comparison logic.

// In src/semantic/analyzer.rs

// ... (inside the `impl SemanticAnalyzer` block)

fn analyze_statement(&mut self, stmt: &Stmt) {
    match stmt {
        Stmt::Let {
            var_name,
            var_type,
            initializer,
        } => {
            // --- UPDATED: Assignment Type Checking Logic ---

            // 1. Analyze the initializer expression to determine its actual type.
            //    We capture the returned `Option<PrimitiveType>` instead of ignoring it.
            let initializer_type_opt = self.analyze_expression(initializer);

            // 2. Check if the initializer expression was valid.
            //    The `if let Some(...)` pattern is an idiomatic way to proceed only
            //    if the `Option` contains a value. If it was `None`, an error
            //    (like "undeclared variable") was already found and we can't
            //    perform a meaningful type check here.
            if let Some(initializer_type) = initializer_type_opt {
                // 3. Compare the variable's declared type with the initializer's actual type.
                //    If they don't match, it's a semantic error.
                if *var_type != initializer_type {
                    let error = SemanticError {
                        message: format!(
                            "Type mismatch: cannot assign value of type '{}' to a variable declared as '{}'.",
                            initializer_type, var_type
                        ),
                    };
                    self.errors.push(error);
                }
            }

            // The logic for defining the variable in the symbol table remains unchanged.
            // We do this even if there's a type error to prevent a cascade of subsequent
            // "undeclared variable" errors, which would be confusing.
            let info = SymbolInfo::Variable {
                var_type: var_type.clone(),
            };
            if !self.symbol_table.define(var_name.clone(), info) {
                let error = SemanticError {
                    message: format!("Variable '{}' is already declared in this scope.", var_name),
                };
                self.errors.push(error);
            }
        }
        Stmt::Return(expr) => {
            // This will be updated in a future task to check against the function's return type.
            let _ = self.analyze_expression(expr);
        }
        Stmt::Expression(expr) => {
            let _ = self.analyze_expression(expr);
        }
    }
}

Code Breakdown

This small change has a huge impact on your compiler’s ability to catch bugs.

  1. Capturing the Type: We’ve changed let _ = self.analyze_expression(initializer); to let initializer_type_opt = self.analyze_expression(initializer);. We are now capturing the valuable information that the method returns.
  2. Safe Unwrapping with if let: The if let Some(initializer_type) = initializer_type_opt is a cornerstone of safe Rust programming. It checks if the Option is Some and, if it is, extracts the value into the initializer_type variable, all in one concise line. This elegantly handles the case where the initializer expression itself had an error, preventing our type checker from crashing or producing confusing follow-on errors.
  3. The Comparison: if *var_type != initializer_type is the core of the check. We compare the type from the AST node with the type we just determined from the right-hand side. The dereference *var_type is needed because var_type is a reference (&PrimitiveType) in this context.
  4. Clear Error Messaging: The error message Type mismatch: cannot assign value of type 'bool' to a variable declared as 'int'. is specific, actionable, and tells the programmer exactly what they did wrong. This is the hallmark of a well-designed compiler.
  5. Error Resilience: A subtle but important design choice is that we still call self.symbol_table.define() even after finding a type mismatch. Why? Imagine the code let x: int = true; let y = x;. If we failed to define x because of the type error, the analysis of the second line would then produce a second error: “Undeclared variable ‘x’”. This is confusing. By always defining the variable, we ensure that we only report the single, root cause of the problem—the type mismatch.

Your semantic analyzer is now significantly more powerful. It validates not just the existence of variables, but the logical consistency of how they are initialized.

Next Steps

You’ve successfully checked types for binary operations and now for assignments. The next logical set of rules to enforce involves functions. You need to validate that a function call is legitimate: does the function exist? Are you passing the right number of arguments? Do the types of the arguments match the function’s definition? In the next task, you will implement validation for function calls, which will involve querying the SymbolTable for SymbolInfo::Function.

Further Reading

Implement Two-Pass Semantic Analysis for Function Calls

Mục tiêu: Implement a two-pass semantic analysis strategy in a Rust-based compiler to handle forward references of functions. The first pass registers all function signatures in the symbol table. The second pass performs a full analysis of function bodies, including validating function calls for existence, arity (number of arguments), and argument types.


Of course! Let’s continue building the semantic analysis phase of your SimpliLang compiler.

You have done an excellent job implementing type checking for assignments. By leveraging the type information returned from analyze_expression, you’ve taught the analyzer to enforce the “contract” of a let statement, a massive step forward in ensuring logical correctness.

Now, we will apply this same powerful, type-aware analysis to another fundamental language construct: function calls. Validating a function call is a multi-faceted process that goes beyond a simple type comparison. It involves checking the function’s existence, its arity (the number of arguments it expects), and the type of each argument provided.

The Challenge of Forward References and the Two-Pass Solution

Before we can check a call like foo();, we must have information about the function foo in our SymbolTable. But what if the program is structured like this?

fn main() -> int {
    return foo(10); // We see a call to `foo` here...
}

fn foo(n: int) -> int {
    return n * 2;   // ...but `foo` is only defined later!
}

If we analyze the program top-to-bottom in a single pass, when we analyze main, foo will not yet be in our symbol table, leading to an incorrect “undeclared function” error.

The classic solution to this problem is a two-pass analysis.

  1. Pass 1 (Signatures): We will make a quick first pass over all the function definitions in the program and register only their signatures (name, parameter types, and return type) in the global scope of our SymbolTable. This pass does not look inside the function bodies.
  2. Pass 2 (Bodies): We will then perform the full, deep traversal of each function’s body, just as we have been setting up. By the time this pass begins, the symbol table is already populated with all function signatures, so any function can call any other function, regardless of their order in the source file.

Step 1: Implementing the First Pass

Let’s modify the main analyze method in src/semantic/analyzer.rs to implement this two-pass strategy.

// In src/semantic/analyzer.rs

// ... (inside the `impl SemanticAnalyzer` block)

pub fn analyze(mut self, program: &Program) -> Result<(), Vec<SemanticError>> {
    // --- NEW: Pass 1 - Register all function signatures ---
    // We iterate through all function definitions and add their signature to the
    // global scope in the symbol table. This allows for forward references.
    for func in &program.functions {
        let param_types = func.parameters.iter().map(|(_, ty)| ty.clone()).collect();

        let info = SymbolInfo::Function {
            param_types,
            return_type: func.return_type.clone(),
        };

        // We define the function in the global scope.
        if !self.symbol_table.define(func.name.clone(), info) {
            // This would mean two functions have the same name, which is an error.
            self.errors.push(SemanticError {
                message: format!("Function '{}' is already defined.", func.name),
            });
        }
    }

    // --- Pass 2 - Analyze function bodies ---
    // Now that all function signatures are in the symbol table, we can safely
    // analyze the bodies of each function.
    for func in &program.functions {
        self.analyze_function_definition(func);
    }

    if self.errors.is_empty() {
        Ok(())
    } else {
        Err(self.errors)
    }
}

Step 2: Implementing Full Function Call Validation

With the two-pass system in place, we can now confidently implement the logic in the Expr::FunctionCall arm of analyze_expression. This is where we will enforce all the rules of a valid function call. We will also fill in the logic for the remaining expression types (Unary and If) to complete our expression analysis.

Here is the updated and completed analyze_expression method.

// In src/semantic/analyzer.rs

// ... (inside the `impl SemanticAnalyzer` block)

fn analyze_expression(&mut self, expr: &Expr) -> Option<PrimitiveType> {
    match expr {
        Expr::Literal(value) => match value {
            LiteralValue::Int(_) => Some(PrimitiveType::Int),
            LiteralValue::Float(_) => Some(PrimitiveType::Float),
            LiteralValue::Bool(_) => Some(PrimitiveType::Bool),
        },
        Expr::Variable(name) => {
            if let Some(info) = self.symbol_table.lookup(name) {
                if let SymbolInfo::Variable { var_type } = info {
                    Some(var_type.clone())
                } else {
                    self.errors.push(SemanticError {
                        message: format!("'{}' is a function, not a variable.", name),
                    });
                    None
                }
            } else {
                self.errors.push(SemanticError {
                    message: format!("Undeclared variable '{}'.", name),
                });
                None
            }
        }
        Expr::Binary { left, op, right } => {
            // This logic is unchanged from the previous step.
            let left_type_opt = self.analyze_expression(left);
            let right_type_opt = self.analyze_expression(right);

            if let (Some(left_type), Some(right_type)) = (left_type_opt, right_type_opt) {
                // ... (the existing type checking logic for binary operators)
            }
            None // Return None if sub-expressions had errors
        }

        // NEW: Fill in the logic for unary expressions
        Expr::Unary { op, operand } => {
            if let Some(operand_type) = self.analyze_expression(operand) {
                match op {
                    UnaryOp::Negate => {
                        // Negation is only valid for numeric types.
                        if operand_type == PrimitiveType::Int || operand_type == PrimitiveType::Float {
                            Some(operand_type) // The type of the result is unchanged.
                        } else {
                            self.errors.push(SemanticError {
                                message: format!(
                                    "Cannot apply negation to non-numeric type '{}'.",
                                    operand_type
                                ),
                            });
                            None
                        }
                    }
                }
            } else {
                None // Propagate error from operand
            }
        }

        // NEW: Fill in the logic for `if` expressions
        Expr::If { condition, then_block, else_block } => {
            // Rule: The condition of an `if` expression must be a boolean.
            if let Some(cond_type) = self.analyze_expression(condition) {
                if cond_type != PrimitiveType::Bool {
                    self.errors.push(SemanticError {
                        message: format!(
                            "If condition must be of type 'bool', but found '{}'.",
                            cond_type
                        ),
                    });
                }
            }

            self.analyze_block(then_block);
            if let Some(else_b) = else_block {
                self.analyze_block(else_b);
            }

            // NOTE on Language Design: Determining the type of an `if` expression
            // is complex with the current AST, as blocks do not inherently return
            // a value. A more advanced compiler might use control flow analysis to
            // infer the type from `return` statements. For now, we cannot assign a
            // reliable type to the `if` expression itself. Returning `None` ensures
            // it cannot be used where a typed value is expected, like `let x = if...`.
            None
        }

        // NEW: The core logic for function call validation
        Expr::FunctionCall { fn_name, arguments } => {
            // 1. Existence Check: Look up the function name in the symbol table.
            let func_info = self.symbol_table.lookup(fn_name);

            match func_info {
                Some(SymbolInfo::Function { param_types, return_type }) => {
                    // We found it, and it's a function! Clone the info we need.
                    let param_types = param_types.clone();
                    let return_type = return_type.clone();

                    // 2. Arity Check: Compare the number of arguments to parameters.
                    if arguments.len() != param_types.len() {
                        self.errors.push(SemanticError {
                            message: format!(
                                "Function '{}' expects {} arguments, but {} were provided.",
                                fn_name,
                                param_types.len(),
                                arguments.len()
                            ),
                        });
                        return None; // Arity mismatch is a fatal error for this call.
                    }

                    // 3. Argument Type Check: Analyze each argument and compare its type.
                    for (arg_expr, expected_type) in arguments.iter().zip(param_types.iter()) {
                        if let Some(arg_type) = self.analyze_expression(arg_expr) {
                            if arg_type != *expected_type {
                                self.errors.push(SemanticError {
                                    message: format!(
                                        "Type mismatch in function call '{}'. Expected argument of type '{}', but found '{}'.",
                                        fn_name, expected_type, arg_type
                                    ),
                                });
                            }
                        }
                        // If `analyze_expression` returned `None`, an error was already
                        // recorded for that argument, so we don't need to add another one.
                    }

                    // 4. Return the function's declared return type.
                    // If return_type is `None`, this call is to a "void" function.
                    // As discussed with `if`, such a call doesn't have a value type.
                    return_type
                }
                Some(SymbolInfo::Variable { .. }) => {
                    // The name exists, but it's a variable, not a function.
                    self.errors.push(SemanticError {
                        message: format!("'{}' is a variable, not a function, and cannot be called.", fn_name),
                    });
                    None
                }
                None => {
                    // The name doesn't exist in any accessible scope.
                    self.errors.push(SemanticError {
                        message: format!("Undeclared function '{}'.", fn_name),
                    });
                    None
                }
            }
        }
    }
}

Code Breakdown

You have just implemented a complete and robust validation system for function calls.

  1. The Two-Pass System: By populating the symbol table with function signatures first, your analyzer now correctly handles function calls regardless of their order in the source file, supporting both forward references and mutual recursion.
  2. Detailed Validation Logic: Inside the Expr::FunctionCall arm, you are performing a cascade of checks, each building on the last:
    • Existence and Type: The match on the result of lookup cleanly separates the three possibilities: it’s a function, it’s a variable, or it doesn’t exist. Each case produces a specific, helpful error message.
    • Arity Check: A simple len() comparison provides a critical check that prevents calling a function with the wrong number of arguments.
    • Argument Type Check: The zip iterator is a beautiful, idiomatic Rust pattern that lets us iterate over the provided arguments and the expected parameter types in lockstep. For each pair, we recursively analyze the argument expression and compare its resulting type with the expected type, catching any mismatches.
  3. Completed Expression Analysis: You also filled in the logic for Unary and If expressions. For Unary, you correctly check for numeric types. For If, you enforce the crucial rule that the condition must be a boolean, and you’ve noted the design implications of its type with the current AST.

Your semantic analyzer is now feature-complete for the core SimpliLang specification! It can validate variables, scopes, assignments, binary operations, and function calls.

Next Steps

You have successfully validated almost every semantic rule. The final pieces of the puzzle involve functions. In the next task, you will implement the logic to enforce correct function return types, ensuring that the type of the expression in a return statement matches the function’s declared return signature.

Further Reading

Implement Function Return Type Validation in Semantic Analyzer

Mục tiêu: Complete the semantic analyzer by adding logic to enforce correct function return types. This involves modifying the analyzer to track the current function context and validating the type of the value in a return statement against the function’s declared return type.


You have done a phenomenal job, bringing the semantic analyzer from a simple skeleton to a powerful validation engine. It can now manage scopes, resolve variables, and enforce type safety for binary operations, assignments, and even complex function calls. You’ve implemented the sophisticated two-pass system, which is a hallmark of a serious compiler. You are now at the final task of core semantic validation: enforcing function return types.

The Final Contract: Upholding the Function’s Promise

A function’s signature, like fn get_score() -> int, is a promise to the rest of the program. It guarantees that any call to this function will ultimately produce an integer. The return statement is the mechanism by which this promise is fulfilled. It is the semantic analyzer’s duty to act as the final checkpoint, ensuring that the value being returned perfectly matches the type promised in the function’s signature.

To do this, our analyzer needs to be aware of the context it’s in. When it encounters a return statement, it must ask, “What is the expected return type of the function I am currently inside?”

The Solution: Tracking the Current Function Context

We will solve this by adding a small piece of state to our SemanticAnalyzer: a field that tracks the expected return type of the current function being analyzed.

  1. We’ll add a field current_function_return_type: Option<Option<PrimitiveType>> to the SemanticAnalyzer struct.
  2. When the traversal enters a function definition, we will set this field to that function’s return type.
  3. When the traversal leaves the function, we will restore the field to its previous state.
  4. When we encounter a Stmt::Return, we will use this field to validate the type of the returned expression.

This is also the perfect time to implement a piece of logic we’ve deferred until now: defining the function’s parameters as variables in its scope. This is a prerequisite for correctly analyzing the function’s body.

Let’s modify src/semantic/analyzer.rs to implement this final, crucial check.

Step 1: Update the SemanticAnalyzer Struct

First, add the new field to the struct definition and initialize it in the new function.

// In src/semantic/analyzer.rs

// ... (use statements)

pub struct SemanticAnalyzer {
    symbol_table: SymbolTable,
    errors: Vec<SemanticError>,
    /// Tracks the return type of the function currently being analyzed.
    /// `None` -> not in a function.
    /// `Some(None)` -> in a function with no return type (void).
    /// `Some(Some(type))` -> in a function with the given return type.
    current_function_return_type: Option<Option<PrimitiveType>>,
}

impl SemanticAnalyzer {
    pub fn new() -> Self {
        SemanticAnalyzer {
            symbol_table: SymbolTable::new(),
            errors: Vec::new(),
            // Initialize to `None` as we start outside of any function.
            current_function_return_type: None,
        }
    }
    // ...

Step 2: Manage Context and Define Parameters

Next, let’s update analyze_function_definition to manage this new context and to define the function’s parameters in the newly created scope.

// In src/semantic/analyzer.rs, inside `impl SemanticAnalyzer`

fn analyze_function_definition(&mut self, func: &FunctionDefinition) {
    // 1. Manage the function context for return type checking.
    // We save the previous context in case of nested functions (not supported yet, but good practice).
    let previous_return_type = self.current_function_return_type.clone();
    self.current_function_return_type = Some(func.return_type.clone());

    // 2. Enter a new scope for the function's parameters and body.
    self.symbol_table.enter_scope();

    // 3. Define parameters as variables in the new scope.
    for (param_name, param_type) in &func.parameters {
        let info = SymbolInfo::Variable {
            var_type: param_type.clone(),
        };
        // We can reuse the same check as for `let` statements.
        if !self.symbol_table.define(param_name.clone(), info) {
            self.errors.push(SemanticError {
                message: format!(
                    "Parameter '{}' is already declared in this function.",
                    param_name
                ),
            });
        }
    }

    // 4. Analyze the function's body.
    self.analyze_block(&func.body);

    // 5. Exit the function's scope.
    self.symbol_table.exit_scope();

    // 6. Restore the previous function context.
    self.current_function_return_type = previous_return_type;
}

Step 3: Implement the Return Statement Check

Finally, let’s implement the core validation logic inside the analyze_statement method.

// In src/semantic/analyzer.rs, inside `impl SemanticAnalyzer`

fn analyze_statement(&mut self, stmt: &Stmt) {
    match stmt {
        // ... (Stmt::Let logic is unchanged)

        Stmt::Return(expr) => {
            // We are handling a `return` statement.
            match &self.current_function_return_type {
                // Case 1: We are not inside any function. This is an error.
                None => {
                    self.errors.push(SemanticError {
                        message: "Cannot use 'return' outside of a function.".to_string(),
                    });
                }
                // Case 2: We are in a function that should not return a value (void).
                Some(None) => {
                    self.errors.push(SemanticError {
                        message: "Function with no return type cannot return a value.".to_string(),
                    });
                }
                // Case 3: We are in a function that expects a return value.
                Some(Some(expected_type)) => {
                    // Analyze the expression being returned to find its type.
                    if let Some(actual_type) = self.analyze_expression(expr) {
                        // Check if the actual type matches the expected type.
                        if actual_type != *expected_type {
                            self.errors.push(SemanticError {
                                message: format!(
                                    "Type mismatch: function is declared to return '{}', but found '{}'.",
                                    expected_type, actual_type
                                ),
                            });
                        }
                    }
                    // If `analyze_expression` returned `None`, an error was already
                    // logged for the expression itself, so we don't need to add another one.
                }
            }
        }

        // ... (Stmt::Expression logic is unchanged)
    }
}

Dissecting the Final Implementation

You have now completed the entire semantic analysis phase for the core SimpliLang features.

  1. Context-Aware Analysis: By adding and managing current_function_return_type, your analyzer is no longer just a visitor; it’s a stateful agent that understands its current environment, which is essential for context-sensitive rules like return type checking.
  2. Complete Parameter Handling: Your analyzer now correctly populates the function’s scope with its parameters before analyzing the body, allowing the function body to use its parameters just as it would any other local variable.
  3. Robust Return Validation: The match statement inside the Stmt::Return arm is exhaustive. It handles all logical possibilities:

    • Returning from outside a function.
    • Returning a value from a “void” function.
    • Returning a value of the wrong type.
    • Returning a value of the correct type.

This comprehensive set of checks makes your compiler’s front-end incredibly robust, capable of catching a wide range of common programmer errors before they ever become runtime bugs.

Next Steps and Potential Enhancements

This is a massive milestone that marks the completion of the semantic analysis step. You have built a complete compiler front-end (lexer, parser, and semantic analyzer). The AST is now not just syntactically correct, but also semantically validated.

The next major step in the project roadmap is Step 6: Code Generation. You will now transition from being a language analyst to being a machine instructor. You will traverse the validated AST one last time, and for each node, you will generate the equivalent low-level instructions in LLVM Intermediate Representation (IR).

As a potential enhancement for semantic analysis, you could implement more advanced checks: * Control Flow Analysis: Check that every possible path in a function with a return type ends with a return statement. * Dead Code Detection: Identify code that can never be reached (e.g., code after a return statement in a block).

Further Reading

Refactor Semantic Error Reporting to a Structured Enum

Mục tiêu: Refactor the compiler’s error reporting mechanism by replacing a string-based error struct with a structured SemanticError enum. This centralizes error definitions and uses Rust’s Display trait to separate error identification from its textual representation, improving clarity, maintainability, and extensibility.


Excellent! You’ve successfully implemented all of the core validation rules for SimpliLang, culminating in the robust enforcement of function return types. Your semantic analyzer is now a powerful engine that can walk the Abstract Syntax Tree and verify its logical consistency. Throughout this process, you’ve been diligently adding SemanticError structs to a collection. This final task in the semantic analysis step is about formalizing and improving that very mechanism, ensuring our error reporting is as meaningful and maintainable as possible.

The Philosophy of Compiler Error Reporting

A compiler’s primary job is to translate code, but its secondary, and arguably more user-facing job, is to be a helpful guide when the code is wrong. You’ve already adopted a crucial design philosophy: collecting errors instead of failing fast. By pushing errors into a Vec and continuing the analysis, you allow the compiler to report multiple, independent issues at once. A user seeing all five mistakes in their function at once is far more productive than a user who has to recompile five times to fix them one by one.

While our current approach of using formatted strings for error messages works, we can make it more robust and structured. Relying on format! strings spreads the responsibility of crafting error messages throughout the analyzer. A better approach is to define a structured enum for our errors. This centralizes the error definitions and separates the identification of an error from its textual representation.

Refactoring to a Structured SemanticError Enum

We will refactor our SemanticError from a simple struct containing a String into a more descriptive enum. Each variant of the enum will represent a specific kind of semantic error and will hold the context-specific data needed to describe that error.

Step 1: Redefine the SemanticError Enum

First, let’s update src/semantic/error.rs. We will replace the struct with an enum and implement the Display trait to handle the formatting of the error messages. This is a powerful Rust pattern that cleanly separates data from presentation.

Open src/semantic/error.rs and replace its contents with the following:

// src/semantic/error.rs

use crate::ast::PrimitiveType;
use std::fmt::{self, Display};

/// Represents the specific kind of a semantic error.
/// Each variant holds the necessary information to generate a meaningful error message.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SemanticErrorKind {
    UndeclaredVariable(String),
    UndeclaredFunction(String),
    NotAVariable(String),
    NotAFunction(String),
    RedeclaredVariable(String),
    RedeclaredFunction(String),
    RedeclaredParameter(String),
    WrongArgumentCount {
        fn_name: String,
        expected: usize,
        found: usize,
    },
    TypeMismatch {
        expected: PrimitiveType,
        found: PrimitiveType,
        context: String,
    },
    InvalidOperation {
        op: String,
        type1: PrimitiveType,
        type2: Option<PrimitiveType>, // For binary or unary ops
    },
    IfConditionNotBool(PrimitiveType),
    ReturnOutsideFunction,
    ReturnValueFromVoidFunction,
}

/// Represents a single logical error found during semantic analysis.
// For now, it's a wrapper around our new enum. Later, we can add a `Span`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SemanticError {
    pub kind: SemanticErrorKind,
}

// Implement the `Display` trait to control how our error is printed.
// This is where we define the user-facing error messages.
impl Display for SemanticError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match &self.kind {
            SemanticErrorKind::UndeclaredVariable(name) => {
                write!(f, "Undeclared variable '{}'.", name)
            }
            SemanticErrorKind::UndeclaredFunction(name) => {
                write!(f, "Undeclared function '{}'.", name)
            }
            SemanticErrorKind::NotAVariable(name) => {
                write!(f, "'{}' is not a variable.", name)
            }
            SemanticErrorKind::NotAFunction(name) => {
                write!(f, "'{}' is not a function and cannot be called.", name)
            }
            SemanticErrorKind::RedeclaredVariable(name) => {
                write!(f, "Variable '{}' is already declared in this scope.", name)
            }
            SemanticErrorKind::RedeclaredFunction(name) => {
                write!(f, "Function '{}' is already defined.", name)
            }
            SemanticErrorKind::RedeclaredParameter(name) => {
                write!(f, "Parameter '{}' is already declared in this function.", name)
            }
            SemanticErrorKind::WrongArgumentCount { fn_name, expected, found } => {
                write!(f, "Function '{}' expects {} arguments, but {} were provided.", fn_name, expected, found)
            }
            SemanticErrorKind::TypeMismatch { expected, found, context } => {
                write!(f, "Type mismatch {}: expected type '{}', but found '{}'.", context, expected, found)
            }
            SemanticErrorKind::InvalidOperation { op, type1, type2 } => {
                if let Some(t2) = type2 {
                    write!(f, "Cannot apply operator {} to types '{}' and '{}'.", op, type1, t2)
                } else {
                    write!(f, "Cannot apply operator {} to type '{}'.", op, type1)
                }
            }
            SemanticErrorKind::IfConditionNotBool(found) => {
                write!(f, "If condition must be of type 'bool', but found '{}'.", found)
            }
            SemanticErrorKind::ReturnOutsideFunction => {
                write!(f, "Cannot use 'return' outside of a function.")
            }
            SemanticErrorKind::ReturnValueFromVoidFunction => {
                write!(f, "Function with no return type cannot return a value.")
            }
        }
    }
}

Step 2: Update the Analyzer to Use the Structured Enum

Now, we will go through src/semantic/analyzer.rs and replace every instance of self.errors.push(SemanticError { message: format!(...) }) with the creation of our new, structured SemanticErrorKind.

Here are the highlighted changes across the SemanticAnalyzer:

// In src/semantic/analyzer.rs

// ... (other parts of the analyzer)

// In `analyze` method, for function redeclaration:
// OLD:
// self.errors.push(SemanticError { message: format!("Function '{}' is already defined.", func.name) });
// NEW:
self.errors.push(SemanticError { kind: SemanticErrorKind::RedeclaredFunction(func.name.clone()) });

// In `analyze_function_definition`, for parameter redeclaration:
// OLD:
// self.errors.push(SemanticError { message: format!("Parameter '{}' is already declared in this function.", param_name) });
// NEW:
self.errors.push(SemanticError { kind: SemanticErrorKind::RedeclaredParameter(param_name.clone()) });

// In `analyze_statement`, for `let` redeclaration:
// OLD:
// self.errors.push(SemanticError { message: format!("Variable '{}' is already declared in this scope.", var_name) });
// NEW:
self.errors.push(SemanticError { kind: SemanticErrorKind::RedeclaredVariable(var_name.clone()) });

// In `analyze_statement`, for `let` type mismatch:
// OLD:
// message: format!("Type mismatch: cannot assign value of type '{}' to a variable declared as '{}'.", initializer_type, var_type)
// NEW:
self.errors.push(SemanticError {
    kind: SemanticErrorKind::TypeMismatch {
        expected: var_type.clone(),
        found: initializer_type,
        context: format!("in assignment to variable '{}'", var_name),
    }
});

// In `analyze_statement`, for `return` checks:
// OLD:
// message: "Cannot use 'return' outside of a function.".to_string(),
// NEW:
self.errors.push(SemanticError { kind: SemanticErrorKind::ReturnOutsideFunction });
// OLD:
// message: "Function with no return type cannot return a value.".to_string(),
// NEW:
self.errors.push(SemanticError { kind: SemanticErrorKind::ReturnValueFromVoidFunction });
// OLD:
// message: format!("Type mismatch: function is declared to return '{}', but found '{}'.", expected_type, actual_type)
// NEW:
self.errors.push(SemanticError {
    kind: SemanticErrorKind::TypeMismatch {
        expected: expected_type.clone(),
        found: actual_type,
        context: "in return statement".to_string(),
    }
});

// In `analyze_expression`, for `Variable` checks:
// OLD:
// message: format!("'{}' is a function, not a variable.", name),
// NEW:
self.errors.push(SemanticError { kind: SemanticErrorKind::NotAVariable(name.clone()) });
// OLD:
// message: format!("Undeclared variable '{}'.", name),
// NEW:
self.errors.push(SemanticError { kind: SemanticErrorKind::UndeclaredVariable(name.clone()) });

// In `analyze_expression`, for `Binary` checks:
// For type mismatch:
self.errors.push(SemanticError {
    kind: SemanticErrorKind::InvalidOperation {
        op: format!("{:?}", op),
        type1: left_type,
        type2: Some(right_type),
    }
});
// For non-numeric arithmetic/comparison:
self.errors.push(SemanticError {
    kind: SemanticErrorKind::InvalidOperation {
        op: format!("{:?}", op),
        type1: left_type,
        type2: None, // Or we could add more specific error variants
    }
});

// In `analyze_expression`, for `Unary` check:
// OLD:
// message: format!("Cannot apply negation to non-numeric type '{}'.", operand_type)
// NEW:
self.errors.push(SemanticError {
    kind: SemanticErrorKind::InvalidOperation {
        op: "negation (-)".to_string(),
        type1: operand_type,
        type2: None,
    }
});

// In `analyze_expression`, for `If` check:
// OLD:
// message: format!("If condition must be of type 'bool', but found '{}'.", cond_type)
// NEW:
self.errors.push(SemanticError { kind: SemanticErrorKind::IfConditionNotBool(cond_type) });

// In `analyze_expression`, for `FunctionCall` checks:
// OLD:
// message: format!("Function '{}' expects {} arguments, but {} were provided.", fn_name, param_types.len(), arguments.len())
// NEW:
self.errors.push(SemanticError {
    kind: SemanticErrorKind::WrongArgumentCount {
        fn_name: fn_name.clone(),
        expected: param_types.len(),
        found: arguments.len(),
    }
});
// OLD:
// message: format!("Type mismatch in function call '{}'. Expected argument of type '{}', but found '{}'.", fn_name, expected_type, arg_type)
// NEW:
self.errors.push(SemanticError {
    kind: SemanticErrorKind::TypeMismatch {
        expected: *expected_type,
        found: arg_type,
        context: format!("in argument to function '{}'", fn_name),
    }
});
// OLD:
// message: format!("'{}' is a variable, not a function, and cannot be called.", fn_name),
// NEW:
self.errors.push(SemanticError { kind: SemanticErrorKind::NotAFunction(fn_name.clone()) });
// OLD:
// message: format!("Undeclared function '{}'.", fn_name),
// NEW:
self.errors.push(SemanticError { kind: SemanticErrorKind::UndeclaredFunction(fn_name.clone()) });

The End Result: A More Meaningful System

By performing this refactor, you haven’t changed what errors your compiler can detect, but you’ve dramatically improved how it handles them.

  • Clarity: The code in the analyzer is now clearer. SemanticErrorKind::UndeclaredVariable(name) is more explicit than a formatted string.
  • Maintainability: All error message formatting is centralized in the Display implementation for SemanticError. If you want to rephrase an error message, you only need to change it in one place.
  • Extensibility: Adding a new type of semantic error is now a clean, two-step process: add a variant to the enum, and add a corresponding match arm in the Display implementation.

This marks the successful completion of the entire semantic analysis step. Your compiler’s front-end is now complete. It can lex, parse, and semantically validate SimpliLang code, producing a verified AST and a collection of meaningful, user-friendly errors. This is a truly massive accomplishment.

Next Steps and Potential Enhancements

You are now ready to venture into the compiler’s “back-end.” The next major step in the project roadmap is Step 6: Code Generation. You will traverse the validated AST one final time, but instead of checking for errors, you will emit LLVM Intermediate Representation (IR) for each node, translating the high-level logic of SimpliLang into the low-level instructions that LLVM understands.

Further Reading