Como um computador entende e executa código?
Quando você escreve var x = 1 + 2; em JavaScript, o computador não lê isso como texto. Ele precisa:
- Quebrar o texto em pedaços mínimos (tokens) —
var,x,=,1,+,2,; - Organizar esses pedaços em uma árvore que mostre a estrutura do programa — "isto é uma declaração de variável, que tem um nome e um valor inicial, e o valor é uma soma de dois números"
- Percorrer essa árvore executando cada nó — "criar a variável
xna memória com o valor 3"
Este projeto implementa esse pipeline completo em ~700 linhas de Ruby legível, para que você veja exatamente como cada etapa funciona — e possa parar a execução em qualquer ponto para inspecionar o que está acontecendo.
- Ruby 3.2.4 (ou qualquer Ruby >= 3.0)
- Nenhuma gem externa — só usamos a biblioteca padrão (
minitestpara testes,debugpara depuração)
ruby --version # confirme que está instaladoSe não tiver:
# Ubuntu/Debian
sudo apt install ruby
# macOS (com Homebrew)
brew install rubyExecuta o pipeline completo com o script nextFrame e exibe cada etapa. Para rodar:
ruby main.rbTransforma texto em tokens. Cada token é um par (tipo, valor):
"var x = 1;" → [Token[:VAR, "var"], Token[:ID, "x"], Token[:EQ, "="], Token[:NUM, 1.0], Token[:SEMI, ";"]]
Percorre o texto caractere por caractere, agrupando:
- Dígitos → números (
Token[:NUM, 42.0]) - Letras → identificadores ou palavras-chave (
:ID,:VAR,:FUNCTION,:RETURN) - Caracteres especiais → operadores e pontuação (
:PLUS,:EQ,:LPAREN)
Saída: Array de Token (Struct com type e value).
Define as classes que representam cada elemento da linguagem:
| Classe | Significado | Exemplo |
|---|---|---|
Program |
Raiz do programa, contém statements | var x = 1; fn(); |
Block |
Bloco entre { } |
{ var x = 1; } |
VarDecl |
Declaração de variável | var x = 1; |
Assign |
Atribuição | x = x + 1; |
BinOp |
Operação binária | 1 + 2 |
Lit |
Valor literal | 42, "hello", [1,2] |
Ident |
Referência a variável | rotation |
CallExpr |
Chamada de função | foo(1, 2) |
Member |
Acesso por índice | colors[0] |
FuncDecl |
Declaração de função | function foo() {} |
ReturnStmt |
Comando return |
return x; |
ObjLit |
Objeto literal | { a: 1 } |
ArrLit |
Array literal | [1, 2, 3] |
ExprStmt |
Expressão usada como comando | foo(); |
Pega os tokens e constrói a AST:
tokens = MiniJSLexer.new("var x = 1 + 2;").tokenize
ast = MiniJSParser.new(tokens).parse
# ast = Program([VarDecl("x", BinOp(:PLUS, Lit(1), Lit(2)))])Saída: Um nó Program — a raiz da AST.
Percorre a AST e executa cada nó:
engine = MiniJSEngine.new(code)
engine.call('nextFrame', [1]) # → { "rotation" => 5.0, "color" => "blue", "frame" => 1 }
engine.variable('rotation') # → 5.0Mantém um @global_scope (Hash Ruby) com todas as variáveis e funções. Cada chamada de função cria um escopo local temporário.
Saída: Valores Ruby (Float, String, Array, Hash, nil) que representam os valores que o pseudo-JS produziria.
for f in test_*.rb; do ruby "$f"; doneComece pelo exemplo mais simples possível. Ele contém apenas 3 conceitos: declaração de variável, operador de soma e literais numéricos. Perfeito para ver o pipeline inteiro sem distrações.
Abra o arquivo que você quer investigar e insira pontos de parada. Vou mostrar como fazer em cada etapa:
Abra lexer.rb e adicione no início (após frozen_string_literal):
require 'debug'Depois, dentro do método read_next_token, insira:
def read_next_token
binding.break # <-- a execução pausa aqui a cada token
current_char = @source_code[@current_position]
# ...
endAgora execute no terminal:
ruby -e "
require_relative 'lexer'
tokens = MiniJSLexer.new('var x = 1 + 2;').tokenize
puts tokens.inspect
"O debug pausa na primeira chamada de read_next_token. Digite s (step) para ver qual caminho é seguido. Digite n (next) para ir para a próxima linha. Digite p @current_position para ver a posição atual no código fonte. Digite c (continue) para ir ao próximo token.
Repita até o token :EOF — você terá testemunhado os 7 tokens sendo criados um a um.
Abra parser.rb e insira binding.break no início do parse:
def parse
binding.break
program = Program.new
# ...
endExecute:
ruby -e "
require_relative 'lexer'
require_relative 'parser'
tokens = MiniJSLexer.new('var x = 1 + 2;').tokenize
ast = MiniJSParser.new(tokens).parse
"Quando pausar, explore:
p @tokens— veja todos os tokens esperandop @current_position— posição atual no arrayn— avance para verprogram.body << parse_statements— entre emparse_statementpara ver como ele decide se é:VAR,:FUNCTION, etc.
Abra interpreter.rb e insira binding.break no evaluate_node:
def evaluate_node(node, local_scope)
binding.break
dispatch_method = NODE_DISPATCH.fetch(node.class)
send(dispatch_method, node, local_scope)
endExecute:
ruby -e "
require_relative 'lexer'
require_relative 'parser'
require_relative 'interpreter'
tokens = MiniJSLexer.new('var x = 1 + 2;').tokenize
ast = MiniJSParser.new(tokens).parse
MiniJSInterpreter.new.run(ast)
puts 'x = ' + MiniJSInterpreter.new.run(ast).lookup_variable('x').to_s
"A cada c (continue), você verá um nó diferente sendo avaliado:
Program→ entra emevaluate_program, itera o bodyVarDecl→ entra emevaluate_variable_declaration, avalia o initializerBinOp→ entra emevaluate_binary_operation, avalia left e rightLit(1.0)→ retorna o valor1.0Lit(2.0)→ retorna o valor2.0VarDeclde novo →assign_variable("x", 3.0)— a variável nasce!
Quando você já entende como var x = 1 + 2; percorre o pipeline, está pronto para o exemplo completo. Ele adiciona 4 novos conceitos: função, array, objeto, acesso a membro (colors[colorIndex]), atribuição com efeito colateral no escopo global (rotation = rotation + 5), e retorno de objeto.
ruby main.rbIsso executa o pipeline inteiro e exibe as 4 etapas. Para mergulhar fundo, ative o binding.break no evaluate_node do interpreter.rb de novo e execute:
ruby -e "
require_relative 'lexer'
require_relative 'parser'
require_relative 'interpreter'
SCRIPT = <<~JS
var rotation = 0;
var colorIndex = 0;
var colors = ['red', 'blue', 'green', 'yellow', 'purple'];
function nextFrame(frame) {
rotation = rotation + 5;
colorIndex = (colorIndex + 1) % 5;
return { rotation: rotation, color: colors[colorIndex], frame: frame };
}
JS
engine = MiniJSEngine.new(SCRIPT)
puts engine.call('nextFrame', [1]).inspect
"Siga cada nó da AST sendo avaliado. Quando chegar em CallExpr, veja como o interpretador cria um escopo local com {"frame" => 1} e executa o corpo da função. Repare como Assign modifica o @global_scope (efeito colateral), e como ReturnStmt usa throw :return para desviar o fluxo.
Este mini-interpretador é o núcleo de processamento que um navegador web precisa para entender e executar JavaScript.
Navegador completo:
HTML → DOM (árvore de elementos)
CSS → estilos aplicados aos elementos
JS → ← VOCÊ ESTÁ AQUI (este projeto)
interpretador que executa o código
O que falta para chegar a um navegador real:
- HTML parser — transformar
<div><p>texto</p></div>em uma árvore DOM - CSS parser — entender
div { color: red; }e aplicar estilos - DOM API — funções como
document.getElementById(),element.style - Event loop — responder a cliques, teclado, temporizadores
Mas o coração da linguagem — declarar variáveis, chamar funções, criar arrays e objetos, acessar propriedades — é exatamente o que este projeto implementa. Cada peça que você debugou aqui é a mesma lógica que existe dentro do V8 (Chrome), SpiderMonkey (Firefox) e JavaScriptCore (Safari).
| Comando | Significado |
|---|---|
n |
next — avança para próxima linha |
s |
step — entra dentro do método |
c |
continue — segue até o breakpoint |
p var |
imprime o valor de var |
quit |
sai do debug |
ast.rb— entenda os blocos de construção (5 min)lexer.rb— veja como texto vira tokens (10 min)parser.rb— veja como tokens viram árvore (15 min)interpreter.rb— veja como a árvore executa (15 min)main.rb— veja o pipeline completo em ação (5 min)test_*.rb— veja como cada peça é testada (10 min)
- Pipeline de compilação/interpretação — código fonte processado em estágios
- Análise léxica — texto separado em tokens
- Análise sintática — tokens formando uma árvore (AST)
- Interpretação tree-walk — percorrer a AST para executar o programa
- Escopo léxico — global vs local (função)
- Efeito colateral — função que modifica variável global
- Mixins —
include InterpreterScopepara organizar código - Dispatch table — Hash mapeando classe → método (
NODE_DISPATCH) - Struct —
Token = Struct.new(:type, :value) - Throw/catch — fluxo não-local para implementar
return - Minitest — testes sem gems externas
- Regex — reconhecimento de padrões no lexer
- Operadores, variáveis, funções e objetos não são mágica — são árvores sendo percorridas
- Um interpretador não precisa ser complexo: ~700 linhas são suficientes para uma linguagem com escopo, arrays, objetos e funções
- O núcleo do JavaScript que roda no Chrome é o mesmo conceito — só que com milhões de linhas e décadas de otimização
Projeto educacional — sinta-se à vontade para usar, modificar e estudar.