Chapter 5 · Programming
Compiler Semantic Analysis: Symbols, Scopes and Types in C++
Learn semantic analysis in a compiler by building symbol tables, nested scopes and static type checking in C++, with diagnostics and CTest tests.
By Gianni Updated 27 min Intermediate
On this page
In Chapter 4, we built a recursive descent parser that turns a token stream into an Abstract Syntax Tree (AST). Parsing tells us whether a program has the correct grammatical structure, but a syntactically valid program can still contain invalid names, incompatible types, incorrect function calls, or impossible return behaviour.
That is the job of semantic analysis. In a compiler, semantic analysis walks the AST after parsing and checks whether the program obeys the language’s meaning-related rules. It typically resolves identifiers through a symbol table, tracks lexical scopes, performs static type checking, validates function calls and return statements, and reports semantic errors before code generation begins.
Consider this valid-looking Si program:
fn main(): int {
let value: int = true;
return missing;
}
The parser can successfully build an AST because both statements follow the grammar. The program is still invalid:
truehas typebool, butvaluewas declared asint.missingwas never declared.- A code generator should never receive either mistake.
In this chapter, we implement that semantic-analysis stage in C++. The analyser walks the AST, records declarations in a symbol table, resolves names through nested lexical scopes, checks expression and statement types, validates function calls, enforces the main signature, and confirms that non-void functions return a value on every supported control-flow path.
What You Will Learn
By the end of this chapter, you will be able to:
- explain what semantic analysis does in a compiler;
- distinguish syntax analysis from semantic analysis;
- represent variables and functions in a symbol table;
- resolve identifiers through nested lexical scopes;
- register functions before checking their bodies;
- perform static type checking on declarations, assignments and expressions;
- validate function calls, conditions and return statements;
- enforce the required
mainfunction signature; - produce semantic errors with line and column information;
- test valid and invalid programs automatically with CTest.
What Is Semantic Analysis in a Compiler?
Semantic analysis is the compiler phase that checks whether a syntactically valid program is meaningful according to the rules of the programming language.
The parser has already determined that tokens form valid declarations, statements, and expressions. Semantic analysis goes further by answering questions such as:
- Has this variable been declared before it is used?
- Which declaration does this identifier refer to?
- Does an assignment use compatible types?
- Do function arguments match the function parameters?
- Is an
iforwhilecondition abool? - Does a returned expression match the function’s declared return type?
- Can a non-void function reach its end without returning a value?
If any of these checks fail, the source can be grammatically correct while still being rejected by the compiler.
How Does Semantic Analysis Work?
The semantic analyser in this chapter works by traversing the AST with a visitor and maintaining information about the program’s declarations and types.
At a high level, it performs four jobs:
- Build symbol information. Function signatures and variable declarations are recorded.
- Resolve names and scopes. Identifier uses are matched to the correct declaration, starting with the innermost lexical scope.
- Check types and language rules. Expressions, assignments, calls, conditions, and returns are validated.
- Report semantic errors. Invalid constructs produce diagnostics using the line and column stored in the AST.
This produces a checked AST that later compiler stages can rely on instead of repeatedly re-validating basic language rules.
Semantic Analysis in the Compiler Pipeline
The first compiler stages each solve a different problem:
| Stage | Input | Question | Output |
|---|---|---|---|
| Lexer | Source characters | Can the characters form valid tokens? | Token stream |
| Parser | Token stream | Do the tokens follow the grammar? | AST |
| Semantic analyser | AST | Do names, scopes, calls and types make sense? | Checked AST |
| IR generation | Checked AST | How should the validated program be represented for lower-level processing? | Intermediate representation |
This separation keeps the compiler architecture easier to reason about and prevents code generation from receiving programs that have already violated the language rules.
Syntax Analysis vs Semantic Analysis
Syntax analysis and semantic analysis are closely related, but they validate different properties of a program. The distinction is easiest to see by comparing the front-end stages:
Lexer:
Can the characters be grouped into valid tokens?
Parser:
Are the tokens arranged according to the grammar?
Semantic analyser:
Do the declarations, names and types make sense?
For example, the parser accepts this declaration because it has the required shape:
let identifier : type = expression ;
Semantic analysis then compares the declared type with the expression type:
let enabled: bool = true; // valid
let count: int = true; // semantic error
Keeping syntax and meaning separate makes the compiler easier to reason about. Parsing only needs to understand grammatical structure, while semantic analysis can work with that structure to resolve names and types. By the time we generate IR in Chapter 6, we can assume that the checks implemented in this chapter have already passed.
Symbol Tables, Scopes and Type Checking
Three concepts drive most of the analyser in this chapter.
Symbol tables
A symbol table maps names used in source code to information the compiler knows about those declarations. In this implementation, variable symbols record a name and type, while function symbols record a name, parameter types, and return type.
Lexical scopes
A declaration is not necessarily visible everywhere. The analyser therefore keeps a stack of variable scopes. Name lookup starts in the innermost active scope and moves outward, allowing nested blocks to shadow an outer declaration without changing the outer variable.
Static type checking
Static type checking verifies type rules during compilation rather than waiting until the program runs. The analyser determines the type produced by expressions and compares those types with the requirements of declarations, operators, calls, conditions, assignments, and returns.
These three responsibilities work together. Resolving an identifier tells the analyser which symbol it refers to; the symbol provides its type; and that type is then used to validate the surrounding expression or statement.
The Semantic Rules for Si
Before implementing the analyser, it helps to make the language rules explicit. This chapter enforces the following semantic rules:
| Construct | Rule |
|---|---|
| Program | Only function declarations may appear at global scope. |
| Entry point | The program must define fn main(): int with no parameters. |
| Variables | A variable must be declared before use. |
| Redeclaration | A name cannot be declared twice in the same scope. |
| Shadowing | A nested scope may declare a variable with the same name. |
| Variable types | Variables and parameters cannot have type void. |
| Assignment | The assigned expression must match the variable type. |
| Conditions | if and while conditions must have type bool. |
| Calls | The function must exist and argument count and types must match. |
| Returns | Returned values must match the current function’s return type. |
| Arithmetic | +, -, * and / require two int operands. |
| Ordering | <, <=, > and >= require two int operands. |
| Equality | == and != require matching non-void operand types. |
Phase 1: Build the Symbol Table
The first implementation step is the symbol table, the compiler’s record of declared names and the information associated with them.
After analysing this variable declaration:
let total: int = 0;
the symbol table knows:
name: total
type: int
After registering this function:
fn add(left: int, right: int): int {
return left + right;
}
the function table knows:
name: add
parameter types: int, int
return type: int
Variables follow lexical scope rules, while functions are global in this version of Si. Keeping those namespaces separate makes the implementation small while still demonstrating the core responsibilities of name resolution.
How Nested Scopes Work
A variable declared in an outer scope remains visible in inner scopes:
fn main(): int {
let outer: int = 10;
if (true) {
out(outer);
}
return outer;
}
A variable declared inside a block disappears when that block ends:
fn main(): int {
if (true) {
let local: int = 10;
out(local);
}
return local; // Semantic error
}
We represent lexical scopes as a stack of hash maps:
function scope
↓
if-block scope
↓
nested block scope
Name lookup starts in the innermost map and moves outward until it finds a declaration.
Create SymbolTable.hpp
Create gsisi/include/SymbolTable.hpp:
#pragma once
#include <optional>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
#include "Type.hpp"
struct VariableSymbol {
std::string name;
Type type{Type::Unknown};
};
struct FunctionSymbol {
std::string name;
std::vector<Type> parameterTypes;
Type returnType{Type::Void};
};
class SymbolTable {
public:
SymbolTable() {
_variableScopes.emplace_back();
_functions.emplace(
"out",
FunctionSymbol{"out", {Type::Int}, Type::Void}
);
}
void enterScope() {
_variableScopes.emplace_back();
}
void exitScope() {
if (_variableScopes.size() <= 1) {
return;
}
_variableScopes.pop_back();
}
bool declareVariable(std::string name, Type type) {
auto& currentScope = _variableScopes.back();
const std::string key = name;
const auto result = currentScope.emplace(
key,
VariableSymbol{std::move(name), type}
);
return result.second;
}
[[nodiscard]] std::optional<VariableSymbol> lookupVariable(
const std::string& name
) const {
for (
auto scope = _variableScopes.rbegin();
scope != _variableScopes.rend();
++scope
) {
if (const auto found = scope->find(name); found != scope->end()) {
return found->second;
}
}
return std::nullopt;
}
bool declareFunction(FunctionSymbol function) {
const std::string name = function.name;
return _functions.emplace(name, std::move(function)).second;
}
[[nodiscard]] std::optional<FunctionSymbol> lookupFunction(
const std::string& name
) const {
if (const auto found = _functions.find(name); found != _functions.end()) {
return found->second;
}
return std::nullopt;
}
private:
std::vector<std::unordered_map<std::string, VariableSymbol>> _variableScopes;
std::unordered_map<std::string, FunctionSymbol> _functions;
};
The constructor registers one built-in function:
out(int): void
This lets Si programs print an integer before we build the runtime in a later chapter.
declareVariable() only checks the current scope, so nested shadowing is allowed:
let value: int = 1;
if (true) {
let value: int = 2; // Valid nested declaration
out(value);
}
lookupVariable() searches the scope stack from the inside out, so the closest declaration wins.
Phase 2: Design the Semantic Analyser
With symbol storage in place, the next step is the analyser itself. The semantic analyser is another AST visitor and uses a two-pass strategy:
Pass 1:
Register every function signature.
Pass 2:
Visit every function body and validate its contents.
The first pass records function signatures before any function body is checked. This enables forward function calls:
fn main(): int {
return add(20, 22);
}
fn add(left: int, right: int): int {
return left + right;
}
When the analyser checks main, the function table already contains add, even though its declaration appears later in the file.
Create SemanticAnalyser.hpp
Create gsisi/include/SemanticAnalyser.hpp:
#pragma once
#include <string>
#include "AST.hpp"
#include "SymbolTable.hpp"
#include "Type.hpp"
class SemanticAnalyser final : public ASTVisitor {
public:
void analyze(ProgramNode& root);
void visit(IntLiteralNode& node) override;
void visit(BoolLiteralNode& node) override;
void visit(IdentifierNode& node) override;
void visit(BinaryExpressionNode& node) override;
void visit(UnaryExpressionNode& node) override;
void visit(FunctionCallNode& node) override;
void visit(BlockNode& node) override;
void visit(LetStatementNode& node) override;
void visit(ReturnStatementNode& node) override;
void visit(FunctionDeclarationNode& node) override;
void visit(ExpressionStatementNode& node) override;
void visit(AssignmentNode& node) override;
void visit(WhileStatementNode& node) override;
void visit(IfStatementNode& node) override;
void visit(ProgramNode& node) override;
private:
SymbolTable _symbols;
Type _currentReturnType{Type::Void};
Type _lastType{Type::Unknown};
void registerFunctions(ProgramNode& root);
void validateMainFunction() const;
[[nodiscard]] bool blockAlwaysReturns(const BlockNode& block) const;
[[nodiscard]] Type evaluate(ExpressionNode& expression);
[[noreturn]] void semanticError(
const ASTNode& node,
const std::string& message
) const;
};
The analyser stores two pieces of temporary state:
_currentReturnTypeis the declared return type of the function currently being checked._lastTypeis the type produced by the most recently visited expression.
The evaluate() helper visits an expression and returns _lastType. This keeps the statement visitors concise.
Phase 3: Register Functions and Validate main
The analyser now needs its entry point, function-registration pass, main validation, return-path helper, expression evaluation helper, and diagnostic function.
Create gsisi/src/SemanticAnalyser.cpp:
#include "SemanticAnalyser.hpp"
#include <stdexcept>
#include <string>
#include <utility>
void SemanticAnalyser::analyze(ProgramNode& root) {
_symbols = SymbolTable{};
_currentReturnType = Type::Void;
_lastType = Type::Unknown;
registerFunctions(root);
validateMainFunction();
root.accept(*this);
}
void SemanticAnalyser::registerFunctions(ProgramNode& root) {
for (auto& statement : root.nodes) {
auto* function =
dynamic_cast<FunctionDeclarationNode*>(statement.get());
if (function == nullptr) {
continue;
}
FunctionSymbol symbol;
symbol.name = function->name;
symbol.returnType = function->returnType;
for (const ParameterNode& parameter : function->parameters) {
if (parameter.type == Type::Void) {
semanticError(
*function,
"parameter '" + parameter.name +
"' cannot have type void"
);
}
symbol.parameterTypes.push_back(parameter.type);
}
if (!_symbols.declareFunction(std::move(symbol))) {
semanticError(
*function,
"function '" + function->name +
"' is already declared"
);
}
}
}
void SemanticAnalyser::validateMainFunction() const {
const auto mainFunction = _symbols.lookupFunction("main");
if (!mainFunction) {
throw std::runtime_error(
"Semantic error: program must define 'fn main(): int'"
);
}
if (!mainFunction->parameterTypes.empty()) {
throw std::runtime_error(
"Semantic error: main function cannot take parameters"
);
}
if (mainFunction->returnType != Type::Int) {
throw std::runtime_error(
"Semantic error: main function must return int"
);
}
}
bool SemanticAnalyser::blockAlwaysReturns(const BlockNode& block) const {
for (const auto& statement : block.statements) {
if (dynamic_cast<const ReturnStatementNode*>(statement.get()) != nullptr) {
return true;
}
const auto* ifStatement =
dynamic_cast<const IfStatementNode*>(statement.get());
if (
ifStatement != nullptr &&
ifStatement->elseBranch != nullptr &&
blockAlwaysReturns(*ifStatement->thenBranch) &&
blockAlwaysReturns(*ifStatement->elseBranch)
) {
return true;
}
}
return false;
}
Type SemanticAnalyser::evaluate(ExpressionNode& expression) {
expression.accept(*this);
return _lastType;
}
void SemanticAnalyser::semanticError(
const ASTNode& node,
const std::string& message
) const {
throw std::runtime_error(
"Semantic error at " +
std::to_string(node.line) + ":" +
std::to_string(node.column) + ": " +
message
);
}
analyze() resets the analyser before every run. This means the same analyser object can safely analyse another AST after a successful or failed compilation.
The first pass stores every function signature in the global function table and rejects:
- Duplicate function names.
- A user-defined function named
out. - Parameters with type
void.
The entry point is validated separately. Every Si program must contain exactly this signature:
fn main(): int {
// ...
}
The main function cannot accept parameters and must return int.
Source-aware semantic errors
Most errors use the line and column stored on the AST node:
Semantic error at 4:12: undeclared variable 'missing'
The required main check happens against the global function table, so those diagnostics do not have one specific AST location.
Return-path analysis
blockAlwaysReturns() recognises the return patterns supported by the current language:
fn choose(flag: bool): int {
if (flag) {
return 1;
} else {
return 0;
}
}
Both branches return, so the function is valid.
This function is rejected:
fn choose(flag: bool): int {
if (flag) {
return 1;
}
}
When flag is false, execution can reach the end of the function without producing an int.
The return-path analysis is deliberately conservative. It recognises the control-flow patterns supported by this educational compiler, but it does not yet prove that a loop executes or evaluate constant conditions such as while (true). Those checks require more advanced control-flow reasoning.
Phase 4: Check Programs, Functions and Blocks
The next visitors validate statements and control flow while updating or querying the active symbol scopes.
Continue gsisi/src/SemanticAnalyser.cpp:
void SemanticAnalyser::visit(ProgramNode& node) {
for (auto& statement : node.nodes) {
auto* function =
dynamic_cast<FunctionDeclarationNode*>(statement.get());
if (function == nullptr) {
semanticError(
*statement,
"only function declarations are allowed at global scope"
);
}
function->accept(*this);
}
}
void SemanticAnalyser::visit(FunctionDeclarationNode& node) {
_symbols.enterScope();
const Type previousReturnType = _currentReturnType;
_currentReturnType = node.returnType;
for (const ParameterNode& parameter : node.parameters) {
if (!_symbols.declareVariable(parameter.name, parameter.type)) {
semanticError(
node,
"duplicate parameter '" + parameter.name + "'"
);
}
}
for (auto& statement : node.body->statements) {
statement->accept(*this);
}
if (
node.returnType != Type::Void &&
!blockAlwaysReturns(*node.body)
) {
semanticError(
node,
"not all control paths return " +
std::string(toString(node.returnType))
);
}
_currentReturnType = previousReturnType;
_symbols.exitScope();
}
void SemanticAnalyser::visit(BlockNode& node) {
_symbols.enterScope();
for (auto& statement : node.statements) {
statement->accept(*this);
}
_symbols.exitScope();
}
At global scope, only function declarations are allowed. Function bodies then create the variable scopes in which parameters and local declarations are resolved.
A function creates one scope containing both its parameters and its top-level local variables. We deliberately visit the statements in the function body directly instead of calling body->accept(). Calling the block visitor would create an unnecessary extra scope between parameters and top-level locals.
Nested if, else and while blocks still call the normal BlockNode visitor, so they receive their own scopes.
For example:
fn example(value: int): int {
let value: int = 10; // Error: same function scope
if (true) {
let value: int = 20; // Valid: nested scope
out(value);
}
return value;
}
Phase 5: Check Statements and Control Flow
Statements combine name resolution, type checking, and scope rules. Variable declarations introduce symbols, assignments resolve existing symbols, conditions must produce bool, and return statements must agree with the current function signature.
Continue gsisi/src/SemanticAnalyser.cpp:
void SemanticAnalyser::visit(LetStatementNode& node) {
if (node.type == Type::Void) {
semanticError(
node,
"variable '" + node.identifier +
"' cannot have type void"
);
}
const Type initialiserType = evaluate(*node.initialiser);
if (initialiserType != node.type) {
semanticError(
node,
"cannot initialise '" + node.identifier +
"' of type " + toString(node.type) +
" with " + toString(initialiserType)
);
}
if (!_symbols.declareVariable(node.identifier, node.type)) {
semanticError(
node,
"variable '" + node.identifier +
"' is already declared in this scope"
);
}
}
void SemanticAnalyser::visit(AssignmentNode& node) {
const auto variable = _symbols.lookupVariable(node.identifier);
if (!variable) {
semanticError(
node,
"undeclared variable '" + node.identifier + "'"
);
}
const Type expressionType = evaluate(*node.expression);
if (expressionType != variable->type) {
semanticError(
node,
"cannot assign " + std::string(toString(expressionType)) +
" to variable '" + node.identifier +
"' of type " + toString(variable->type)
);
}
}
void SemanticAnalyser::visit(ReturnStatementNode& node) {
if (_currentReturnType == Type::Void) {
if (node.expression != nullptr) {
semanticError(
node,
"void function cannot return a value"
);
}
return;
}
if (node.expression == nullptr) {
semanticError(
node,
"expected a return value of type " +
std::string(toString(_currentReturnType))
);
}
const Type returnedType = evaluate(*node.expression);
if (returnedType != _currentReturnType) {
semanticError(
node,
"expected return type " +
std::string(toString(_currentReturnType)) +
", got " + toString(returnedType)
);
}
}
void SemanticAnalyser::visit(IfStatementNode& node) {
if (evaluate(*node.condition) != Type::Bool) {
semanticError(node, "'if' condition must have type bool");
}
node.thenBranch->accept(*this);
if (node.elseBranch != nullptr) {
node.elseBranch->accept(*this);
}
}
void SemanticAnalyser::visit(WhileStatementNode& node) {
if (evaluate(*node.condition) != Type::Bool) {
semanticError(node, "'while' condition must have type bool");
}
node.body->accept(*this);
}
void SemanticAnalyser::visit(ExpressionStatementNode& node) {
(void)evaluate(*node.expression);
}
Variable declarations
A declaration is checked in this order:
- Reject
voidas a variable type. - Evaluate the initialiser.
- Compare the initialiser type with the declared type.
- Add the variable to the current scope.
Declaring the variable last means it cannot refer to itself:
let value: int = value + 1; // Error
Assignments
An assignment must resolve to an existing variable, and its expression must have the same type:
let count: int = 0;
count = 10; // valid
count = true; // semantic error
Return statements
A void function may use an empty return:
fn log(value: int): void {
out(value);
return;
}
It cannot return a value:
fn log(value: int): void {
return value; // Semantic error
}
A non-void function must return an expression of the declared type.
Conditions
Si does not treat integers as booleans:
if (true) {
out(1);
}
while (1 < 10) {
return 0;
}
These are valid because each condition evaluates to bool.
This is invalid:
if (10) {
out(1);
}
Phase 6: Check Function Calls and Expression Types
Expressions are where type information flows through the AST. Literals have known types, identifiers obtain their type from the symbol table, calls use function signatures, and unary or binary operators enforce their operand rules.
Finish gsisi/src/SemanticAnalyser.cpp:
void SemanticAnalyser::visit(FunctionCallNode& node) {
const auto function = _symbols.lookupFunction(node.name);
if (!function) {
semanticError(
node,
"undefined function '" + node.name + "'"
);
}
if (node.arguments.size() != function->parameterTypes.size()) {
semanticError(
node,
"function '" + node.name + "' expects " +
std::to_string(function->parameterTypes.size()) +
" argument(s), got " +
std::to_string(node.arguments.size())
);
}
for (std::size_t index = 0; index < node.arguments.size(); ++index) {
const Type actualType = evaluate(*node.arguments[index]);
const Type expectedType = function->parameterTypes[index];
if (actualType != expectedType) {
semanticError(
*node.arguments[index],
"argument " + std::to_string(index + 1) +
" of function '" + node.name +
"' expects " + toString(expectedType) +
", got " + toString(actualType)
);
}
}
_lastType = function->returnType;
}
void SemanticAnalyser::visit(IdentifierNode& node) {
const auto variable = _symbols.lookupVariable(node.name);
if (!variable) {
semanticError(
node,
"undeclared variable '" + node.name + "'"
);
}
_lastType = variable->type;
}
void SemanticAnalyser::visit(IntLiteralNode&) {
_lastType = Type::Int;
}
void SemanticAnalyser::visit(BoolLiteralNode&) {
_lastType = Type::Bool;
}
void SemanticAnalyser::visit(UnaryExpressionNode& node) {
const Type operandType = evaluate(*node.right);
switch (node.oper) {
case UnaryOp::Negate:
if (operandType != Type::Int) {
semanticError(node, "unary '-' requires an int operand");
}
_lastType = Type::Int;
return;
case UnaryOp::Not:
if (operandType != Type::Bool) {
semanticError(node, "unary '!' requires a bool operand");
}
_lastType = Type::Bool;
return;
}
semanticError(node, "unknown unary operator");
}
void SemanticAnalyser::visit(BinaryExpressionNode& node) {
const Type leftType = evaluate(*node.left);
const Type rightType = evaluate(*node.right);
switch (node.oper) {
case BinaryOp::Add:
case BinaryOp::Subtract:
case BinaryOp::Multiply:
case BinaryOp::Divide:
if (leftType != Type::Int || rightType != Type::Int) {
semanticError(
node,
"arithmetic operators require int operands"
);
}
_lastType = Type::Int;
return;
case BinaryOp::Less:
case BinaryOp::LessEqual:
case BinaryOp::Greater:
case BinaryOp::GreaterEqual:
if (leftType != Type::Int || rightType != Type::Int) {
semanticError(
node,
"ordering operators require int operands"
);
}
_lastType = Type::Bool;
return;
case BinaryOp::Equal:
case BinaryOp::NotEqual:
if (leftType == Type::Void || rightType == Type::Void) {
semanticError(
node,
"equality operators cannot compare void expressions"
);
}
if (leftType != rightType) {
semanticError(
node,
"equality operands must have the same type"
);
}
_lastType = Type::Bool;
return;
}
semanticError(node, "unknown binary operator");
}
Function calls
Function-call validation combines name resolution with type checking. A function call is valid only when:
- The function exists.
- The argument count matches.
- Each argument type matches the corresponding parameter type.
For the built-in function:
out(int): void
these calls behave as follows:
out(55); // valid
out(true); // semantic error
out(); // semantic error
The analyser assigns a call expression the function’s return type. This allows calls to participate in larger expressions:
let result: int = add(20, 22);
A void call cannot be used where an int or bool is required.
Unary expressions
Unary operators have fixed operand and result types:
| Expression | Required operand | Result |
|---|---|---|
-value | int | int |
!value | bool | bool |
Binary expressions
Arithmetic operators return int:
1 + 2
value * 10
Ordering and equality operators return bool:
value < 10
left == right
Equality requires matching non-void types. Comparing int with bool is invalid, and comparing two void calls is also invalid even though both expressions technically produce the same type.
Phase 7: Connect Semantic Analysis to main.cpp
After semantic analysis is connected to the compiler entry point, the front end has three consecutive validation stages before later code-generation work begins:
source file
↓
lexer
↓
tokens
↓
parser
↓
AST
↓
semantic analyser
↓
checked AST
Update gsisi/src/main.cpp:
#include <exception>
#include <fstream>
#include <iostream>
#include <memory>
#include <sstream>
#include <string>
#include <utility>
#include <vector>
#include "ASTPrinter.hpp"
#include "Lexer.hpp"
#include "Parser.hpp"
#include "SemanticAnalyser.hpp"
/**
* Usage: ./si_compiler <source_file.si>
*/
int main(int argc, char* argv[]) {
if (argc != 2) {
std::cerr
<< "Usage: " << argv[0]
<< " <source_file.si>\n";
return 1;
}
std::ifstream inputFile(argv[1], std::ios::binary);
if (!inputFile) {
std::cerr
<< "Error: Could not open file '"
<< argv[1] << "'\n";
return 1;
}
std::stringstream buffer;
buffer << inputFile.rdbuf();
if (inputFile.bad()) {
std::cerr
<< "Error: Failed while reading file '"
<< argv[1] << "'\n";
return 1;
}
try {
Lexer lexer(buffer.str());
std::vector<Token> tokens = lexer.tokenise();
Parser parser(std::move(tokens));
std::unique_ptr<ProgramNode> ast =
parser.parseProgram();
SemanticAnalyser analyser;
analyser.analyze(*ast);
std::cout << "Semantic analysis passed\n\n";
ASTPrinter printer(std::cout);
printer.print(*ast);
} catch (const std::exception& error) {
std::cerr
<< "Compilation error: "
<< error.what() << '\n';
return 1;
}
return 0;
}
The lexer stays alive while the parser and analyser use its tokens. This is important because each token stores its lexeme as a std::string_view into the source string owned by the lexer.
Phase 8: Add a Manual Test Program
Create gsisi/tests/test-2.si:
fn add(a: int, b: int): int {
return a + b;
}
fn main(): int {
let limit: int = 10;
let sum: int = 0;
let i: int = 1;
while (i <= limit) {
sum = add(sum, i);
i = i + 1;
}
if (sum == 55) {
out(sum);
} else {
out(0);
}
return 0;
}
After building the compiler, run the sample from the repository root:
./build/si_compiler tests/test-2.si
The command should exit successfully and begin with output similar to:
Semantic analysis passed
Program
Function add: int
...
Try introducing an error:
let sum: bool = 0;
The compiler should reject the program with a source-aware type mismatch.
Phase 9: Add Automated Semantic Tests
A manual sample is useful for seeing the analyser work end to end, but automated tests are better at preventing regressions. Semantic-analysis tests should include both accepted programs and programs that fail for one specific language rule. The test runner below uses only the C++ standard library, so no external test framework is required.
The suite covers:
- A valid program with a forward function call.
- Variable shadowing in a nested scope.
- Undeclared variables.
- Initialiser type mismatches.
- Variables used outside their scope.
- Incorrect function argument types.
- Missing return paths.
- An invalid
mainsignature. - Variables declared with type
void.
Update CMakeLists.txt
Use one front-end library for the compiler and tests:
cmake_minimum_required(VERSION 3.10)
# Name of your project
project(gsisi LANGUAGES CXX)
# Use C++20
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
file(GLOB SOURCES "src/*.cpp")
list(REMOVE_ITEM SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/src/main.cpp")
add_library(gsisi_frontend ${SOURCES})
target_include_directories(gsisi_frontend
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
)
add_executable(si_compiler src/main.cpp)
target_link_libraries(si_compiler
PRIVATE
gsisi_frontend
)
# --------------------------------------------------
# Tests
# --------------------------------------------------
option(BUILD_TESTING "Build the gsisi tests" ON)
if(BUILD_TESTING)
enable_testing()
add_executable(parser_tests
tests/ParserTests.cpp
${SOURCES}
)
add_executable(semantic_tests
tests/SemanticAnalyserTests.cpp
)
target_include_directories(parser_tests PRIVATE include)
target_link_libraries(semantic_tests PRIVATE gsisi_frontend)
add_test(
NAME parser_tests
COMMAND parser_tests
)
add_test(
NAME semantic_tests
COMMAND semantic_tests
)
add_test(
NAME semantic_sample
COMMAND si_compiler
${CMAKE_CURRENT_SOURCE_DIR}/tests/test-2.si
)
endif()
This setup intentionally uses enable_testing() instead of include(CTest). It registers the tests without generating the extra dashboard files that caused permission errors in the Docker-mounted build directory.
Two tests are registered:
semantic_testsruns the C++ unit-style test runner.semantic_sampleruns the compiler againsttests/test-2.siand checks its exit status.
Create SemanticAnalyserTests.cpp
Create gsisi/tests/SemanticAnalyserTests.cpp:
#include <functional>
#include <iostream>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
#include "Lexer.hpp"
#include "Parser.hpp"
#include "SemanticAnalyser.hpp"
namespace {
void analyse(std::string source) {
Lexer lexer(std::move(source));
std::vector<Token> tokens = lexer.tokenise();
Parser parser(std::move(tokens));
std::unique_ptr<ProgramNode> program = parser.parseProgram();
SemanticAnalyser analyser;
analyser.analyze(*program);
}
void expectValid(const std::string& source) {
analyse(source);
}
void expectInvalid(
const std::string& source,
const std::string& expectedMessage
) {
try {
analyse(source);
} catch (const std::runtime_error& error) {
const std::string actual = error.what();
if (actual.find(expectedMessage) == std::string::npos) {
throw std::runtime_error(
"Expected an error containing '" +
expectedMessage + "', got '" + actual + "'"
);
}
return;
}
throw std::runtime_error(
"Expected semantic analysis to fail"
);
}
void acceptsValidProgram() {
expectValid(R"(
fn main(): int {
let value: int = double(21);
if (value == 42) {
let visible: int = value;
out(visible);
}
return value;
}
fn double(value: int): int {
return value * 2;
}
)");
}
void allowsNestedShadowing() {
expectValid(R"(
fn main(): int {
let value: int = 1;
if (true) {
let value: int = 2;
out(value);
}
return value;
}
)");
}
void rejectsUndeclaredVariable() {
expectInvalid(R"(
fn main(): int {
return missing;
}
)", "undeclared variable 'missing'");
}
void rejectsInitialiserTypeMismatch() {
expectInvalid(R"(
fn main(): int {
let value: int = true;
return 0;
}
)", "cannot initialise 'value'");
}
void rejectsOutOfScopeVariable() {
expectInvalid(R"(
fn main(): int {
if (true) {
let local: int = 1;
}
return local;
}
)", "undeclared variable 'local'");
}
void rejectsWrongArgumentType() {
expectInvalid(R"(
fn identity(value: int): int {
return value;
}
fn main(): int {
return identity(false);
}
)", "expects int, got bool");
}
void rejectsMissingReturnPath() {
expectInvalid(R"(
fn choose(flag: bool): int {
if (flag) {
return 1;
}
}
fn main(): int {
return choose(true);
}
)", "not all control paths return int");
}
void rejectsInvalidMainSignature() {
expectInvalid(R"(
fn main(value: int): int {
return value;
}
)", "main function cannot take parameters");
}
void rejectsVoidVariable() {
expectInvalid(R"(
fn helper(): void {
return;
}
fn main(): int {
let value: void = helper();
return 0;
}
)", "cannot have type void");
}
} // namespace
int main() {
struct TestCase {
const char* name;
std::function<void()> run;
};
const std::vector<TestCase> tests{
{"accepts valid program", acceptsValidProgram},
{"allows nested shadowing", allowsNestedShadowing},
{"rejects undeclared variable", rejectsUndeclaredVariable},
{"rejects initialiser type mismatch", rejectsInitialiserTypeMismatch},
{"rejects out-of-scope variable", rejectsOutOfScopeVariable},
{"rejects wrong argument type", rejectsWrongArgumentType},
{"rejects missing return path", rejectsMissingReturnPath},
{"rejects invalid main signature", rejectsInvalidMainSignature},
{"rejects void variable", rejectsVoidVariable},
};
std::size_t passed = 0;
for (const TestCase& test : tests) {
try {
test.run();
std::cout << "[PASS] " << test.name << '\n';
++passed;
} catch (const std::exception& error) {
std::cerr
<< "[FAIL] " << test.name
<< ": " << error.what() << '\n';
}
}
std::cout
<< passed << '/' << tests.size()
<< " tests passed\n";
return passed == tests.size() ? 0 : 1;
}
The helper functions keep each test focused:
expectValid()fails if any compiler stage throws.expectInvalid()requires an error and checks that its message contains the expected text.
The tests use complete Si source strings, so each case exercises the full front end:
source
→ lexer
→ parser
→ semantic analyser
Build and Run the Tests
Configure and build from the repository root:
cmake -S . -B build
cmake --build build
Run all registered tests:
ctest --test-dir build --output-on-failure
Expected result:
Test project .../build
Start 1: parser_tests
1/3 Test #1: parser_tests ..................... Passed
Start 2: semantic_tests
2/3 Test #2: semantic_tests ................... Passed
Start 3: semantic_sample
3/3 Test #3: semantic_sample .................. Passed
100% tests passed, 0 tests failed out of 3
You can also run the test executable directly:
./build/semantic_tests
It prints each case separately:
[PASS] accepts valid program
[PASS] allows nested shadowing
[PASS] rejects undeclared variable
...
9/9 tests passed
Docker permission fallback
When the source directory is bind-mounted and the build directory was previously created by another user, delete the old build output before configuring again:
sudo rm -rf build
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure
Alternatively, keep generated files outside the mounted repository:
cmake -S /home/dev/gsisi -B /home/dev/gsisi-build
cmake --build /home/dev/gsisi-build
ctest --test-dir /home/dev/gsisi-build --output-on-failure
Common Semantic Errors a Compiler Detects
The checks implemented in this chapter cover several common categories of semantic error:
- Undeclared identifier: a variable is used without a visible declaration.
- Duplicate declaration: the same name is declared twice in one scope.
- Out-of-scope variable: a declaration is used after its block has ended.
- Type mismatch: a declaration, assignment, argument, or return value has the wrong type.
- Invalid operator operands: an operator is applied to types it does not support.
- Invalid function call: the function is missing, the number of arguments is wrong, or an argument has the wrong type.
- Invalid condition: an
iforwhilecondition does not evaluate tobool. - Missing return path: a non-void function can reach its end without returning the required type.
- Invalid entry point:
maindoes not have the required Si signature.
The exact semantic rules differ between programming languages, but name resolution, scope handling, type checking, and call validation are common responsibilities of this compiler phase.
Common Semantic Analyser Implementation Mistakes
A semantic analyser can produce confusing results when responsibilities are mixed or state is managed incorrectly. Common mistakes include:
- Checking a declaration before its initialiser. Doing so can accidentally make a variable visible inside its own initialiser.
- Looking only in the current scope. Identifier resolution normally needs to search outward through enclosing lexical scopes.
- Rejecting valid shadowing. Redeclaration in the same scope and shadowing in a nested scope are different rules.
- Registering functions only when their bodies are visited. That breaks valid forward calls unless the language explicitly forbids them.
- Treating every semantic rule as a parser rule. Grammar should describe structure; name and type rules belong in semantic analysis.
- Losing source positions. Diagnostics are much more useful when AST nodes preserve the location of the original construct.
- Sending unchecked ASTs to code generation. Later stages become simpler when they can assume semantic validation already succeeded.
Frequently Asked Questions
What is semantic analysis in compiler design?
Semantic analysis is the compiler phase that checks whether a syntactically valid program obeys the language’s meaning-related rules. It commonly performs name resolution, scope checking, type checking, function-call validation, and other consistency checks on the AST.
What is the difference between syntax analysis and semantic analysis?
Syntax analysis checks whether tokens follow the grammar. Semantic analysis assumes that grammatical structure already exists and checks whether declarations, identifier uses, types, calls, conditions, and other relationships are valid.
What is a symbol table in a compiler?
A symbol table stores information about declared names. Depending on the language and compiler, a symbol can record information such as its type, scope, function parameters, return type, storage information, or other attributes needed by later stages.
Why does semantic analysis need scopes?
Scope determines which declaration an identifier refers to at a particular point in the program. Nested lexical scopes allow inner blocks to access outer declarations while also allowing valid shadowing of an outer name.
What is static type checking?
Static type checking verifies type rules during compilation. In this chapter, the analyser checks types for variable initialisers, assignments, operators, function arguments, conditions, and return statements before code generation begins.
Why use two passes for functions?
Registering all function signatures before analysing bodies allows one function to call another function that appears later in the source file. The second pass can then validate every body with the complete global function table available.
Does semantic analysis modify the AST?
It can. Some compilers annotate AST nodes with resolved symbols or inferred types. This implementation mainly validates the existing AST and keeps temporary type information inside the analyser.
What happens after semantic analysis?
Once the semantic checks pass, the compiler has a validated AST. In this project, Chapter 6 lowers that checked tree into an intermediate representation that is easier to optimise and translate into machine-level code.
What Semantic Analysis Does Not Do Yet
The analyser now validates the language rules required by the next compiler stage, but it is intentionally small and educational. It does not yet implement:
- Implicit type conversions.
- Function overloading.
- Global variables.
- User-defined types.
- Definite-assignment analysis.
- Unreachable-code warnings.
- Full control-flow graphs.
- Constant-condition reasoning.
- Multiple diagnostics in one run.
The compiler stops after the first error. A production compiler usually collects multiple diagnostics, but fail-fast behaviour keeps this educational implementation straightforward.
Semantic Analysis: Key Takeaways
- Semantic analysis checks whether a syntactically valid AST obeys the language’s meaning-related rules.
- The symbol table records global functions and variables in nested lexical scopes.
- Name lookup searches from the innermost active scope outward.
- A two-pass strategy registers function signatures first and therefore supports forward calls.
- Static type checking validates declarations, assignments, calls, conditions, operators, and returns.
mainmust have the exact signaturefn main(): int.- Variables and parameters cannot use
void. - Return-path analysis prevents supported non-void functions from reaching the end without a value.
- Source positions produce useful semantic diagnostics.
- CTest validates both focused semantic cases and the end-to-end
tests/test-2.sisample. - The checked AST is ready to be lowered into an intermediate representation.
Next Chapter
In Chapter 6, we will take this semantically checked AST and lower it into a three-address Intermediate Representation that is easier to optimise and translate into x86-64 assembly.
Get the next part
New parts of From Source to Machine Code land in your inbox the day they publish. No spam, unsubscribe in one click.
We send your address to Kit, our email provider, and ask you to confirm by email before anything is sent. See our Privacy Statement.