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
- Zig 0.13+ instalado (guia de instalação)
- Conhecimento de enums e union types
- Familiaridade com recursão e árvores
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
- Explore union types para ASTs mais complexas
- Veja o Interpretador Brainfuck para outra linguagem
- Construa a Máquina Virtual para compilar para bytecode
- Consulte o LSP Server para análise de linguagens