Todo programa começa como um arquivo de texto. O interpretador não consegue "entender" esse texto de uma vez: ele precisa quebrá-lo em partes pequenas e reconhecíveis antes de qualquer análise gramatical. Esse primeiro passo é o trabalho do Lexer — também chamado de analisador léxico ou tokenizador.
A pergunta central que o Lexer responde é:
Como transformar uma sequência de caracteres em uma lista de tokens?
Neste artigo vamos ver o conceito de análise léxica e depois mergulhar na implementação real do Lexer da Drax, a minha linguagem: como os tokens são representados, como literais e identificadores são lidos, como palavras-chave são reconhecidas e como erros são reportados.
Para o programador, um programa é uma lista de instruções. Para o computador, é só uma sequência de bytes. Entre esses dois mundos existe uma fronteira: a análise léxica.
Considere o trecho de código Drax:
nota = 7
if nota >= 6 print("aprovado")
Para um humano, é óbvio onde terminam nota, 7, if… Mas o computador precisa de regras claras para agrupar caracteres em unidades com significado. Essas unidades são os tokens:
flowchart LR
Source["nota = 7<br/>if nota >= 6<br/>print aprovado"] --> Lexer["Lexer"]
Lexer --> Tokens["nota | = | 7 | if | nota | >= | 6 | ( | aprovado | )"]
O Lexer não entende o que o programa faz — ele só reconhece o que o texto é. Quem interpreta o significado vem depois: o parser e o interpretador.
Um token é uma unidade atômica da linguagem: uma palavra-chave, um identificador, um operador, um literal. Cada token carrega três informações essenciais:
| Campo | Exemplo | O que significa |
|---|---|---|
| Tipo | D_TOKEN_IDENT |
A categoria sintática do token |
| Lexeme | nota |
O texto exato lido da fonte |
| Posição | linha 1, coluna 1 | Onde o token aparecia no arquivo (para erros e debugging) |
O tipo é um enum; o lexeme é um ponteiro para o texto (ou uma fatia dele); a posição é usada para mensagens de erro precisas — nada de "erro de sintaxe" sem dizer onde.
o Drax define todos os tokens possíveis em um enum único, d_token_type, em src/dlexer.c:
typedef enum d_token_type {
/* literais */
D_TOKEN_NUMBER, D_TOKEN_STRING, D_TOKEN_IDENT,
/* palavras-chave */
D_TOKEN_FUN, D_TOKEN_IF, D_TOKEN_ELSE, D_TOKEN_WHILE,
D_TOKEN_FOR, D_TOKEN_RETURN, D_TOKEN_BREAK, D_TOKEN_CONTINUE,
D_TOKEN_TRUE, D_TOKEN_FALSE, D_TOKEN_NIL,
D_TOKEN_AND, D_TOKEN_OR, D_TOKEN_NOT,
/* pontuação */
D_TOKEN_LPAREN, D_TOKEN_RPAREN,
D_TOKEN_LBRACE, D_TOKEN_RBRACE,
D_TOKEN_LBRACKET, D_TOKEN_RBRACKET,
D_TOKEN_COMMA, D_TOKEN_DOT,
/* operadores */
D_TOKEN_ASSIGN, D_TOKEN_EQ, D_TOKEN_NE,
D_TOKEN_LT, D_TOKEN_LE, D_TOKEN_GT, D_TOKEN_GE,
D_TOKEN_PLUS, D_TOKEN_MINUS, D_TOKEN_STAR, D_TOKEN_SLASH,
D_TOKEN_PERCENT,
D_TOKEN_EOF, D_TOKEN_ERROR
} d_token_type;
Palavras-chave são tokens próprios — não D_TOKEN_IDENT — porque o parser precisa distingui-las de identificadores sem consultar uma tabela a cada uso. if nunca é uma variável.
O struct do token é simples:
typedef struct d_token {
d_token_type type;
const char* lexeme; /* início do texto no buffer */
int length; /* tamanho do lexeme */
int line;
int col;
} d_token;
Guardar length junto com lexeme evita alocar strings — o lexeme é uma fatia do buffer original.
A implementação está em src/dlexer.c. O estado do Lexer é o buffer de código-fonte e a posição atual:
typedef struct d_lexer {
const char* source;
int start; /* início do lexeme atual */
int current; /* posição de leitura */
int line;
int col;
} d_lexer;
A API pública é uma função que consome a fonte e produz um token por vez:
d_token dlex_next_token(d_vm* vm, d_lexer* lexer);
start marca onde o token atual começa; current é o ponteiro de leitura. Quando o token termina, o lexeme é source[start..current]. Esse padrão é o coração de quase todo lexer manual.
O fluxo de um token:
flowchart TB
A["source[start]"] --> B["Qual o caractere?"]
B -->|"letra ou _"| C["Ler identificador<br/>ou palavra-chave"]
B -->|"dígito"| D["Ler número"]
B -->|"aspas"| E["Ler string"]
B -->|"operador"| F["Ler operador<br/>máximo munch"]
B -->|"espaço ou comentário"| G["Descartar e avançar"]
B -->|"outro"| H["Token de erro"]
C --> I["Retorna token"]
D --> I
E --> I
F --> I
I --> J["current avança<br/>start = current"]
Identificadores são sequências de letras, dígitos e _, começando com letra ou _. A regra no Drax:
if (isalpha(peek()) || peek() == '_') {
while (isalnum(peek()) || peek() == '_') advance();
return identifier_token(lexer); /* palavra-chave ou IDENT */
}
Palavras-chave são identificadores reservados. Duas abordagens comuns:
D_TOKEN_IDENT e o parser compara o texto.o Drax usa a primeira, com uma busca simples:
static d_token_type check_keyword(const char* lexeme, int length) {
if (length == 2 && strncmp(lexeme, "if", 2) == 0) return D_TOKEN_IF;
if (length == 2 && strncmp(lexeme, "or", 2) == 0) return D_TOKEN_OR;
if (length == 3 && strncmp(lexeme, "fun", 3) == 0) return D_TOKEN_FUN;
if (length == 3 && strncmp(lexeme, "nil", 3) == 0) return D_TOKEN_NIL;
/* ... demais palavras-chave ... */
return D_TOKEN_IDENT;
}
Por que no Lexer? Porque o parser ganha um código mais limpo — if (token.type == D_TOKEN_IF) em vez de comparar strings — e fica impossível usar uma palavra-chave como nome de variável, o que causaria erros confusos mais tarde.
o Drax suporta inteiros e floats. O Lexer lê os dígitos e, se achar um . seguido de dígito, vira float:
if (isdigit(peek())) {
while (isdigit(peek())) advance();
if (peek() == '.' && isdigit(peek2())) {
advance();
while (isdigit(peek())) advance();
}
return number_token(lexer);
}
A conversão do texto para valor acontece no parser ou na compilação — o Lexer só delimita os caracteres.
Strings começam com " (o Drax também aceita '). O loop consome tudo até a aspa de fechamento, tratando o escape \":
while (peek() != '"' && !is_at_end()) {
if (peek() == '\\') { advance(); advance(); } /* escape */
else advance();
}
Uma string não terminada até o fim do arquivo é um erro léxico clássico:
if (is_at_end()) {
return error_token(lexer, "string não terminada");
}
Alguns operadores são compostos: =, ==, >=, !=. O Lexer precisa decidir quantos caracteres agrupar. A regra universal é o máximo munch: sempre consumir o maior operador possível.
Para ler >=, o Lexer lê > e olha o próximo caractere:
if (peek() == '>') {
advance();
if (peek() == '=') { advance(); return ge_token(); }
return gt_token();
}
Sem essa regra, a >= b poderia ser lido como a > = b — dois tokens — e quebraria a gramática. O máximo munch garante uma leitura gananciosa e previsível.
Espaços, tabs e quebras de linha não geram tokens — o Lexer os descarta avançando e atualizando line/col. no Drax, os comentários começam com ## (como nos testes da linguagem, ex.: tests/drax/gc.dx):
## este comentário é ignorado pelo Lexer
soma = 1 + 2
O Lexer pula até o fim da linha:
if (peek() == '#' && peek2() == '#') {
while (peek() != '\n' && !is_at_end()) advance();
}
O importante: o Lexer remove comentários e espaços, mas guarda a posição. Assim, um erro na linha 40 aponta para a linha real do arquivo, não para uma linha "lógica" compactada.
Quando um caractere não se encaixa em nenhuma regra, o Lexer produz um token de erro:
if (peek() == '@') {
return error_token(lexer, "caractere inesperado '@'");
}
O erro precisa de posição e mensagem. no Drax, error_token cria um token com type = D_TOKEN_ERROR, lexeme apontando para o caractere e a mensagem guardada no lexer.
A estratégia do Lexer: reportar o erro e continuar a partir do próximo caractere — é o parser que decide parar. Isso permite ao desenvolvedor ver vários erros em uma única execução em vez de um por vez.
A análise léxica é linear: cada caractere é visitado uma única vez, O(n) para um arquivo de n caracteres. Não há backtracking — o máximo munch precisa no máximo de um caractere de lookahead (peek2), que é constante.
Trade-offs de design:
Lexer manual vs gerado (lex/flex, re2c): um lexer manual é mais código, mas dá controle total sobre mensagens de erro, é fácil de depurar e não adiciona dependências. Para uma linguagem pequena como o Drax, é a escolha certa.
Palavra-chave no Lexer vs no Parser: resolver no Lexer simplifica o parser, mas significa que novas palavras-chave exigem mexer no Lexer — e quebrariam código existente, pois palavras-chave são reservadas.
Lookahead: manter 1-2 caracteres de lookahead mantém o lexer simples e rápido. Linguagens com sintaxe mais exótica (regex literais, strings multilinha) precisam de mais contexto — um sinal de que a complexidade migrou para o Lexer.
O Lexer transforma a pergunta "o que este texto é?" em "quais são os tokens?":
flowchart LR
Source[Texto-fonte] --> Scan[Ler lexemes] --> Classify[Classificar tokens] --> Tokens[Tokens + posições]
no Drax, isso é tangível: d_lexer percorre source com start/current, dlex_next_token retorna um d_token por vez com tipo, lexeme e posição, e as regras de máximo munch, strings e números garantem que a fronteira entre tokens seja sempre bem definida. Espaços e comentários são descartados, e erros léxicos viram tokens D_TOKEN_ERROR para o parser reportar.
Com a sequência de tokens em mãos, o próximo passo é a análise sintática — o parser que transforma a lista de tokens em uma estrutura. Mas isso é assunto do próximo artigo.