Compilador de Expressões Matemáticas em Zig — Tutorial Passo a Passo

Compilador de Expressões Matemáticas em Zig — Tutorial Passo a Passo

Neste tutorial, vamos construir um compilador de expressões matemáticas completo em Zig com três fases clássicas: lexer (análise léxica), parser (análise sintática) e avaliador (execução). O compilador respeitará precedência de operadores e suportará funções matemáticas.

O Que Vamos Construir

Nosso compilador vai:

  • Lexer: transformar texto em tokens (números, operadores, parênteses)
  • Parser: construir uma AST (Abstract Syntax Tree) com precedência correta
  • Avaliador: percorrer a AST e calcular o resultado
  • Suportar operações: +, -, *, /, ^ (potência), % (módulo)
  • Incluir funções: sin, cos, sqrt, abs, log
  • Suportar variáveis: x = 3; x * 2 + 1

Por Que Este Projeto?

Este projeto nos ensina os fundamentos de compiladores — a mesma teoria por trás de linguagens de programação reais. Lexer, parser e avaliador são as três fases básicas de qualquer compilador. Em Zig, implementamos com controle total sobre alocação da AST.

Pré-requisitos

Passo 1: Estrutura do Projeto

mkdir compilador-expressoes
cd compilador-expressoes
zig init

Passo 2: O Lexer (Análise Léxica)

O lexer transforma uma string de entrada em uma sequência de tokens.

const std = @import("std");
const io = std.io;
const mem = std.mem;
const fmt = std.fmt;
const math = std.math;

/// Tipos de token que o lexer reconhece.
const TipoToken = enum {
    numero,
    identificador,
    mais,       // +
    menos,      // -
    asterisco,  // *
    barra,      // /
    circunflexo, // ^
    percentual, // %
    abre_paren, // (
    fecha_paren, // )
    igual,      // =
    ponto_virgula, // ;
    fim,        // fim da entrada
};

const Token = struct {
    tipo: TipoToken,
    texto: []const u8,
    valor_num: ?f64,
};

/// Lexer que transforma texto em tokens.
const Lexer = struct {
    fonte: []const u8,
    pos: usize,

    const Self = @This();

    pub fn init(fonte: []const u8) Self {
        return .{ .fonte = fonte, .pos = 0 };
    }

    fn caracterAtual(self: *const Self) ?u8 {
        if (self.pos < self.fonte.len) return self.fonte[self.pos];
        return null;
    }

    fn avancar(self: *Self) void {
        if (self.pos < self.fonte.len) self.pos += 1;
    }

    fn pularEspacos(self: *Self) void {
        while (self.pos < self.fonte.len and
            (self.fonte[self.pos] == ' ' or self.fonte[self.pos] == '\t')) : (self.pos += 1)
        {}
    }

    /// Retorna o próximo token.
    pub fn proximoToken(self: *Self) Token {
        self.pularEspacos();

        if (self.pos >= self.fonte.len) {
            return .{ .tipo = .fim, .texto = "", .valor_num = null };
        }

        const c = self.fonte[self.pos];

        // Operadores e pontuação
        const token_simples: ?TipoToken = switch (c) {
            '+' => .mais,
            '-' => .menos,
            '*' => .asterisco,
            '/' => .barra,
            '^' => .circunflexo,
            '%' => .percentual,
            '(' => .abre_paren,
            ')' => .fecha_paren,
            '=' => .igual,
            ';' => .ponto_virgula,
            else => null,
        };

        if (token_simples) |tipo| {
            const texto = self.fonte[self.pos .. self.pos + 1];
            self.pos += 1;
            return .{ .tipo = tipo, .texto = texto, .valor_num = null };
        }

        // Número (incluindo decimal)
        if (c >= '0' and c <= '9' or c == '.') {
            const inicio = self.pos;
            while (self.pos < self.fonte.len and
                (self.fonte[self.pos] >= '0' and self.fonte[self.pos] <= '9' or
                self.fonte[self.pos] == '.')) : (self.pos += 1)
            {}
            const texto = self.fonte[inicio..self.pos];
            const valor = fmt.parseFloat(f64, texto) catch 0.0;
            return .{ .tipo = .numero, .texto = texto, .valor_num = valor };
        }

        // Identificador (variável ou função)
        if ((c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or c == '_') {
            const inicio = self.pos;
            while (self.pos < self.fonte.len and
                ((self.fonte[self.pos] >= 'a' and self.fonte[self.pos] <= 'z') or
                (self.fonte[self.pos] >= 'A' and self.fonte[self.pos] <= 'Z') or
                (self.fonte[self.pos] >= '0' and self.fonte[self.pos] <= '9') or
                self.fonte[self.pos] == '_')) : (self.pos += 1)
            {}
            return .{ .tipo = .identificador, .texto = self.fonte[inicio..self.pos], .valor_num = null };
        }

        // Caractere desconhecido: pular
        self.pos += 1;
        return self.proximoToken();
    }
};

Passo 3: A AST (Árvore Sintática Abstrata)

/// Nós da AST representam a estrutura da expressão.
const NoAST = union(enum) {
    numero: f64,
    variavel: []const u8,
    binario: struct {
        op: TipoToken,
        esquerda: *const NoAST,
        direita: *const NoAST,
    },
    unario: struct {
        op: TipoToken,
        operando: *const NoAST,
    },
    chamada_funcao: struct {
        nome: []const u8,
        argumento: *const NoAST,
    },
    atribuicao: struct {
        nome: []const u8,
        valor: *const NoAST,
    },
};

Passo 4: O Parser (Análise Sintática)

Usamos recursive descent parsing com níveis de precedência:

/// Parser recursivo descendente que constrói a AST.
/// Precedência (baixa para alta): atribuição, soma, produto, potência, unário, átomo.
const Parser = struct {
    lexer: Lexer,
    token_atual: Token,
    allocator: mem.Allocator,

    const Self = @This();

    pub fn init(fonte: []const u8, allocator: mem.Allocator) Self {
        var p = Self{
            .lexer = Lexer.init(fonte),
            .token_atual = undefined,
            .allocator = allocator,
        };
        p.token_atual = p.lexer.proximoToken();
        return p;
    }

    fn avancar(self: *Self) void {
        self.token_atual = self.lexer.proximoToken();
    }

    fn criarNo(self: *Self, no: NoAST) !*const NoAST {
        const ptr = try self.allocator.create(NoAST);
        ptr.* = no;
        return ptr;
    }

    /// Ponto de entrada: parseia uma expressão ou atribuição.
    pub fn parsear(self: *Self) !*const NoAST {
        return self.parsearAtribuicao();
    }

    fn parsearAtribuicao(self: *Self) !*const NoAST {
        if (self.token_atual.tipo == .identificador) {
            const nome = self.token_atual.texto;
            // Olhar se o próximo token é '='
            var lexer_backup = self.lexer;
            var token_backup = self.token_atual;
            self.avancar();

            if (self.token_atual.tipo == .igual) {
                self.avancar();
                const valor = try self.parsearSoma();
                return self.criarNo(.{ .atribuicao = .{ .nome = nome, .valor = valor } });
            }

            // Não é atribuição: restaurar estado
            self.lexer = lexer_backup;
            self.token_atual = token_backup;
        }
        return self.parsearSoma();
    }

    /// Parseia adição e subtração (menor precedência).
    fn parsearSoma(self: *Self) !*const NoAST {
        var esquerda = try self.parsearProduto();

        while (self.token_atual.tipo == .mais or self.token_atual.tipo == .menos) {
            const op = self.token_atual.tipo;
            self.avancar();
            const direita = try self.parsearProduto();
            esquerda = try self.criarNo(.{ .binario = .{
                .op = op,
                .esquerda = esquerda,
                .direita = direita,
            } });
        }

        return esquerda;
    }

    /// Parseia multiplicação, divisão e módulo.
    fn parsearProduto(self: *Self) !*const NoAST {
        var esquerda = try self.parsearPotencia();

        while (self.token_atual.tipo == .asterisco or
            self.token_atual.tipo == .barra or
            self.token_atual.tipo == .percentual)
        {
            const op = self.token_atual.tipo;
            self.avancar();
            const direita = try self.parsearPotencia();
            esquerda = try self.criarNo(.{ .binario = .{
                .op = op,
                .esquerda = esquerda,
                .direita = direita,
            } });
        }

        return esquerda;
    }

    /// Parseia potência (associativa à direita).
    fn parsearPotencia(self: *Self) !*const NoAST {
        var base = try self.parsearUnario();

        if (self.token_atual.tipo == .circunflexo) {
            self.avancar();
            const expoente = try self.parsearPotencia(); // recursão à direita!
            base = try self.criarNo(.{ .binario = .{
                .op = .circunflexo,
                .esquerda = base,
                .direita = expoente,
            } });
        }

        return base;
    }

    /// Parseia operadores unários (negação).
    fn parsearUnario(self: *Self) !*const NoAST {
        if (self.token_atual.tipo == .menos) {
            self.avancar();
            const operando = try self.parsearUnario();
            return self.criarNo(.{ .unario = .{ .op = .menos, .operando = operando } });
        }
        return self.parsearAtomo();
    }

    /// Parseia átomos: números, variáveis, funções, parênteses.
    fn parsearAtomo(self: *Self) !*const NoAST {
        switch (self.token_atual.tipo) {
            .numero => {
                const valor = self.token_atual.valor_num.?;
                self.avancar();
                return self.criarNo(.{ .numero = valor });
            },
            .identificador => {
                const nome = self.token_atual.texto;
                self.avancar();

                // Verificar se é chamada de função
                if (self.token_atual.tipo == .abre_paren) {
                    self.avancar();
                    const arg = try self.parsearSoma();
                    if (self.token_atual.tipo == .fecha_paren) self.avancar();
                    return self.criarNo(.{ .chamada_funcao = .{ .nome = nome, .argumento = arg } });
                }

                return self.criarNo(.{ .variavel = nome });
            },
            .abre_paren => {
                self.avancar();
                const expr = try self.parsearSoma();
                if (self.token_atual.tipo == .fecha_paren) self.avancar();
                return expr;
            },
            else => {
                return self.criarNo(.{ .numero = 0.0 });
            },
        }
    }
};

Passo 5: O Avaliador

/// Avaliador que percorre a AST e calcula o resultado.
const Avaliador = struct {
    variaveis: std.StringHashMap(f64),

    const Self = @This();

    pub fn init(allocator: mem.Allocator) Self {
        var av = Self{ .variaveis = std.StringHashMap(f64).init(allocator) };
        // Constantes pré-definidas
        av.variaveis.put("pi", math.pi) catch {};
        av.variaveis.put("e", math.e) catch {};
        return av;
    }

    pub fn deinit(self: *Self) void {
        self.variaveis.deinit();
    }

    /// Avalia um nó da AST e retorna o resultado numérico.
    pub fn avaliar(self: *Self, no: *const NoAST) f64 {
        switch (no.*) {
            .numero => |v| return v,
            .variavel => |nome| {
                return self.variaveis.get(nome) orelse 0.0;
            },
            .binario => |b| {
                const esq = self.avaliar(b.esquerda);
                const dir = self.avaliar(b.direita);
                return switch (b.op) {
                    .mais => esq + dir,
                    .menos => esq - dir,
                    .asterisco => esq * dir,
                    .barra => if (dir != 0) esq / dir else math.inf(f64),
                    .circunflexo => math.pow(f64, esq, dir),
                    .percentual => @mod(esq, dir),
                    else => 0.0,
                };
            },
            .unario => |u| {
                const val = self.avaliar(u.operando);
                return switch (u.op) {
                    .menos => -val,
                    else => val,
                };
            },
            .chamada_funcao => |f| {
                const arg = self.avaliar(f.argumento);
                if (mem.eql(u8, f.nome, "sin")) return @sin(arg);
                if (mem.eql(u8, f.nome, "cos")) return @cos(arg);
                if (mem.eql(u8, f.nome, "sqrt")) return @sqrt(arg);
                if (mem.eql(u8, f.nome, "abs")) return @abs(arg);
                if (mem.eql(u8, f.nome, "log")) return @log(arg);
                if (mem.eql(u8, f.nome, "exp")) return @exp(arg);
                return 0.0;
            },
            .atribuicao => |a| {
                const val = self.avaliar(a.valor);
                self.variaveis.put(a.nome, val) catch {};
                return val;
            },
        }
    }
};

Passo 6: Função Main — REPL Interativo

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const stdout = io.getStdOut().writer();
    const stdin = io.getStdIn().reader();

    var avaliador = Avaliador.init(allocator);
    defer avaliador.deinit();

    try stdout.print(
        \\
        \\  ==========================================
        \\     COMPILADOR DE EXPRESSOES - Zig
        \\  ==========================================
        \\  Operadores: + - * / ^ %
        \\  Funcoes: sin, cos, sqrt, abs, log, exp
        \\  Constantes: pi, e
        \\  Variaveis: x = 42; x * 2
        \\  Digite 'sair' para encerrar.
        \\  ==========================================
        \\
        \\
    , .{});

    var buf: [1024]u8 = undefined;
    // Arena para as ASTs
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();

    while (true) {
        try stdout.print("calc> ", .{});

        const linha = stdin.readUntilDelimiter(&buf, '\n') catch |err| {
            if (err == error.EndOfStream) break;
            return err;
        };

        const trimmed = mem.trim(u8, linha, " \t\r\n");
        if (trimmed.len == 0) continue;
        if (mem.eql(u8, trimmed, "sair") or mem.eql(u8, trimmed, "exit")) break;

        // Reset arena entre expressões
        _ = arena.reset(.retain_capacity);
        const arena_alloc = arena.allocator();

        var parser = Parser.init(trimmed, arena_alloc);
        const ast = parser.parsear() catch {
            try stdout.print("  Erro de sintaxe\n", .{});
            continue;
        };

        const resultado = avaliador.avaliar(ast);

        // Formatar resultado (inteiro se possível, senão decimal)
        if (resultado == @trunc(resultado) and @abs(resultado) < 1e15) {
            try stdout.print("  = {d:.0}\n", .{resultado});
        } else {
            try stdout.print("  = {d:.6}\n", .{resultado});
        }
    }
}

Testes

test "lexer - tokens basicos" {
    var lex = Lexer.init("3 + 4");
    const t1 = lex.proximoToken();
    try std.testing.expectEqual(TipoToken.numero, t1.tipo);
    try std.testing.expectApproxEqAbs(@as(f64, 3.0), t1.valor_num.?, 0.001);
    try std.testing.expectEqual(TipoToken.mais, lex.proximoToken().tipo);
    try std.testing.expectEqual(TipoToken.numero, lex.proximoToken().tipo);
}

test "avaliador - soma simples" {
    const allocator = std.testing.allocator;
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();

    var parser = Parser.init("3 + 4", arena.allocator());
    const ast = try parser.parsear();

    var av = Avaliador.init(allocator);
    defer av.deinit();
    try std.testing.expectApproxEqAbs(@as(f64, 7.0), av.avaliar(ast), 0.001);
}

test "avaliador - precedencia" {
    const allocator = std.testing.allocator;
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();

    var parser = Parser.init("2 + 3 * 4", arena.allocator());
    const ast = try parser.parsear();

    var av = Avaliador.init(allocator);
    defer av.deinit();
    try std.testing.expectApproxEqAbs(@as(f64, 14.0), av.avaliar(ast), 0.001);
}

test "avaliador - parenteses" {
    const allocator = std.testing.allocator;
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();

    var parser = Parser.init("(2 + 3) * 4", arena.allocator());
    const ast = try parser.parsear();

    var av = Avaliador.init(allocator);
    defer av.deinit();
    try std.testing.expectApproxEqAbs(@as(f64, 20.0), av.avaliar(ast), 0.001);
}

test "avaliador - negacao" {
    const allocator = std.testing.allocator;
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();

    var parser = Parser.init("-5 + 3", arena.allocator());
    const ast = try parser.parsear();

    var av = Avaliador.init(allocator);
    defer av.deinit();
    try std.testing.expectApproxEqAbs(@as(f64, -2.0), av.avaliar(ast), 0.001);
}

Compilando e Executando

zig build run

# Sessão de exemplo:
# calc> 2 + 3 * 4
#   = 14
# calc> (2 + 3) * 4
#   = 20
# calc> x = 10
#   = 10
# calc> x ^ 2 + 1
#   = 101
# calc> sqrt(144)
#   = 12
# calc> sin(pi / 2)
#   = 1.000000

Conceitos Aprendidos

  • Lexer para análise léxica (tokenização)
  • Recursive descent parser com precedência de operadores
  • AST (Abstract Syntax Tree) com union types
  • Arena allocator para gerenciamento eficiente de nós
  • Pattern matching em union types com switch

Próximos Passos

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.