foto Jean Tux jeaanca
Jean Tux
Compiladores Aula 1 de 5
Lexer — Análise Léxica
Ver sequência completa

Lexer — Análise Léxica

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.


1. O problema da análise léxica

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.

2. O que é um token

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.

3. Os tokens do Drax

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.

4. O Lexer do Drax

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"]

5. Identificadores e palavras-chave

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:

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.

6. Literais

Números

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

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");
}

7. Operadores e a regra do máximo munch

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.

8. Espaços e comentários

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.

9. Erros léxicos

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.

10. Complexidade e trade-offs

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.

11. Conclusão

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.