Kelick Tech Hub

Build, learn & keep up — tech, Linux & sports news included.

C++ Compiler Analyzer Project

Advanced•2 weeks•semantic analysis, symbol tables, AST

Go beyond parsing: catch undeclared variables and type errors like a real compiler.

Share on X

What you will build

Where the mini compiler stops at 'does it parse?', the analyzer asks 'does it make sense?': it builds symbol tables across scopes, checks every variable is declared before use, and type-checks expressions. It's the semantic-analysis phase of a compiler, built as its own tool.

What you will learn

Feature checklist

Starter code

#include <map>
#include <string>
#include <vector>

enum class Type { Int, Bool, Str, Void, Error };

class Scope {
    std::map<std::string, Type> vars_;
    Scope* parent_;
public:
    explicit Scope(Scope* p = nullptr) : parent_(p) {}
    bool declare(const std::string& name, Type t) {
        if (vars_.count(name)) return false;      // redeclaration
        vars_[name] = t; return true;
    }
    Type lookup(const std::string& name) const {
        auto it = vars_.find(name);
        if (it != vars_.end()) return it->second;
        return parent_ ? parent_->lookup(name) : Type::Error;
    }
};

struct Analyzer {
    std::vector<std::string> errors;
    // Visit an expression node; return its type, logging problems.
    Type checkExpr(/* Expr* e, */ Scope& scope, const std::string& lhs,
                   const std::string& rhs) {
        Type a = scope.lookup(lhs), b = scope.lookup(rhs);
        if (a == Type::Error) errors.push_back("undeclared: " + lhs);
        if (b == Type::Error) errors.push_back("undeclared: " + rhs);
        if (a != b) errors.push_back("type mismatch in expression");
        return a;
    }
};

Full walkthrough

Step 1: Start where the mini compiler ended: an AST

This project consumes the AST the mini compiler's parser produces (same node types: let, assignment, if, while, calls, binary ops). Its job is everything the interpreter did NOT check: it walks the finished program BEFORE running it and reports problems a run might never reach. Static analysis = being the strict friend who reads the whole essay, not just the first page.

Step 2: Build scope-aware symbol tables

class Scope { std::map<std::string, Decl> vars; Scope* parent; } — entering a block pushes a Scope, leaving pops it. declare(name, type) inserts into the CURRENT scope; lookups walk the parent chain outward. Shadowing (an inner scope redeclaring an outer name) is legal but reported as a WARNING, which requires the lookup to distinguish 'found here' from 'found in a parent' — the Scope knows, because it performed the walk.

void Scope::declare(name, type, line) {
    if (vars.count(name)) error(line, "redeclaration of " + name);
    else if (lookupInParents(name)) warn(line, name + " shadows an outer declaration");
    vars[name] = {type, line, false};
}

Step 3: Type-check expressions with checkExpr()

checkExpr(node) returns the TYPE of an expression subtree: literals by kind, identifiers by their declaration, binary ops by a rule table (number + number = number; string + anything-nonstring = ERROR 'cannot add int to string'; comparisons yield bool). Undeclared identifiers error at their USE line and return a poison 'unknown' type that suppresses follow-on errors — one mistake should produce one error, not a cascade of twelve.

Step 4: Flag unused variables

Every Decl carries a used flag; reading a variable sets it. At scope exit, any declared-but-never-read variable earns a warning with its declaration line. (A variable that is assigned but never read is also flagged — decide the exact policy and apply it uniformly; an analyser that changes its mind per scope is noise, not signal.)

Step 5: Check that functions return on all paths

For each function, walk its statement tree: a return statement satisfies its path; an if WITHOUT else satisfies only the branch taken... the analysis asks 'is there ANY path from entry to the closing brace that skips a return?' An if/else where both branches return is safe; a lone if that returns is not. This is a reachability question over the AST shape — no execution, just structure.

Step 6: Collect ALL errors, then report once

The analyser never stops at the first problem: it records (line, severity, message) triples and continues with recovery types, so one run reports everything — 'undeclared variable at line 4', 'type error at line 7', 'unused variable at line 2'. Sorted by line at the end. Compilers that stop at error #1 force a fix-compile-fix loop; yours respects the programmer's time, as the checklist demands.

Step 7: Test with a program that is wrong in four ways at once

Feed it code containing: an undeclared variable, an int + string addition, a shadowed declaration, an unused variable, and a function with a missing return path. Expect exactly those diagnostics with correct line numbers and NO phantom sixth error — precision and recall, both, like the fraud detector's test. Then feed the mini compiler's demo program and demand silence.

Common mistakes

Compile and run

g++ -std=c++17 -o analyzer analyzer.cpp

Sample run

$ ./analyzer
File:
> sample.mc
Analysing sample.mc (AST from the mini compiler's parser)...

line 2: warning — 'int total' declared but never used
line 4: error — use of undeclared variable 'cont'
line 5: warning — 'int x' shadows the declaration at line 1
line 7: error — cannot add int to string ('total + name')
line 9: error — function 'grade' may finish without returning a value (path through the if has no return)

3 errors, 2 warnings — analysis complete in one pass.

$ ./analyzer
File:
> demo.mc
No errors, no warnings. This program is clean.

Try it in your browser

No browser demo is included for this project. Build the C++ version below — that is the real program.

Related Advanced projects

← All 60 projects · Classic site (Learn & Sandbox)

Download Fake GPS