7

Lab - Analisador Léxico I

Introdução7.1

Agora que o nosso ambiente de desenvolvimento está devidamente configurado e o nosso sistema de build com o CMake está operacional, chegamos ao momento de sujar as mãos com código de verdade. O nosso objetivo neste laboratório é preparar o terreno para o algoritmo de análise léxica.

Um compilador não "lê" o código da mesma forma que nós lemos um livro. Para o computador, o arquivo de código fonte é apenas uma sequência longa e contínua de bytes. O trabalho do Analisador Léxico (ou Scanner) é agrupar esses caracteres em unidades mínimas de significado chamadas Tokens.

Nesta aula, não implementaremos a lógica de identificação de todos os tokens ainda. Em vez disso, focaremos na infraestrutura necessária para que isso aconteça: definiremos o que é um Token, criaremos a estrutura do nosso Lexer e implementaremos a rotina capaz de ler um arquivo de texto do disco e carregá-lo inteiramente na memória RAM, preparando o buffer para ser consumido pelo nosso algoritmo.

Definindo o Token7.2

A unidade fundamental do nosso compilador é o Token. Você pode imaginar o Token como sendo a "palavra" de uma frase. Em vez de lidarmos com letras individuais 'i', 'f', '(', ')', queremos lidar com conceitos abstratos como TOK_IF, TOK_LPAREN, TOK_RPAREN.

Para organizar isso em C, precisamos de duas coisas fundamentais: um identificador numérico para cada tipo de token (um enum) e uma string que represente o nome desse token para fins de debug (um char*). Normalmente, programadores C escrevem isso duas vezes, uma no enum e outra em um array de strings. Contudo, isso viola o princípio DRY (Don't Repeat Yourself) e é propenso a erros. Se você adicionar um token no enum e esquecer de adicionar a string correspondente, seu compilador pode imprimir lixo de memória ao tentar debugar.

Para resolver isso de forma elegante, utilizaremos uma técnica avançada do pré-processador C chamada X-Macros.

Abra o arquivo include/compiler/token.h onde vamos definir uma lista mestra de tokens. A ideia é definir uma macro TOKEN_LIST que contém chamadas para uma macro indefinida X.

#ifndef COMPILER_TOKEN_H_
#define COMPILER_TOKEN_H_

// Lista Mestra de Tokens
// Baseada na sintaxe: x: int = 5; if { } loop < >
#define TOKEN_LIST \
    X(TOK_LPAREN)    X(TOK_RPAREN) \
    X(TOK_LBRACE)    X(TOK_RBRACE) \
    X(TOK_COLON)     X(TOK_SEMICOLON) \
    X(TOK_COMMA)     X(TOK_EQUAL) \
    X(TOK_PLUS)      X(TOK_MINUS) \
    X(TOK_STAR)      X(TOK_SLASH) \
    X(TOK_GT)        X(TOK_LT) \
    X(TOK_ERROR)     X(TOK_EOF)

// Passo 1: Definir o Enum
typedef enum {
#define X(name) name,
    TOKEN_LIST
#undef X
} TokenType;

// Passo 2: Definir o Array de Strings (para debug)
static const char* const TokenNames[] = {
#define X(name) #name,
    TOKEN_LIST
#undef X
};

// Estrutura do Token
// Note o uso de 'String View' (ponteiro + tamanho) para evitar alocações.
typedef struct {
    TokenType type;
    const char *start; // Aponta para o início do lexema no código fonte
    int length;        // Tamanho do lexema
    int line;          // Para mensagens de erro
} Token;

void token_print(Token *token);
void token_println(Token *token);

#endif

Observe como é útil a utilização das X-macros para nos ajudar a evitar a repetição. Definimos a lista TOKEN_LIST apenas uma vez. Onde cada entrada começa com X seguindo e parênteses como nome que queremos dar dentro. Com essa lista definida, podemos pedir para o compilador do C expandir os elementos com padrões diferentes, que nós definimos. Vejamos como usamos nos dois casos:

  1. Na primeira passada, definimos X(name) como name,. Quando o pré-processador expande TOKEN_LIST dentro do enum, ele gera os nomes que colocamos na lista sem nenhuma informação extra se não a vírgula que colocamos, ficando TOK_PLUS, TOK_IDENTIFIER, ....
  2. Na segunda passada, redefinimos X(name) como #name, (o operador # transforma o argumento em string, vai colocar o nosso nome entre aspas). Dessa forma, quando expandido dentro de TokenNames, ele gera "TOK_PLUS", "TOK_IDENTIFIER", ....

Portanto, a ordem e a existência dos nomes estarão sempre garantidas de estarem sincronizadas. Necessitando que nós apenas adicionemos o token na lista principal para garantir que será criando tanto no enum quanto na lista de TokenNames.

Observem que, estamos criando um enum e o TokenNames com os memos nomes, apesar desse segundo ser uma lista de strings. Estamos fazendo isso para conseguirmos imprimir na tela o nome do Token, para nos auxiliar no debug. Dito isso, vamos implementar a função de impressão em src/compiler/token.c:

#include <stdio.h>
#include "compiler/token.h"

void token_print(Token *token) {
    // %s diz apenas que é uma string fornecida por TokenNames[token->type]
    
    // Utilização do %.*s:
    // O '*' diz ao printf que o tamanho da string vem de um
    // argumento (token->length). Isso nos permite imprimir o
    // lexema diretamente do código fonte original sem precisar
    // copiar bytes ou alocar novas strings.
    printf("<%s, \"%.*s\">", 
           TokenNames[token->type], 
           token->length, 
           token->start);
}

void token_println(Token *token) {
    token_print(token);
    printf("\n");
}

O Lexer e o Algoritmo de Sentinelas7.3

Com o token definido, precisamos da estrutura que vai varrer o código fonte. O nosso analisador léxico precisa ser extremamente rápido, idealmente, para esse tipo de problema podemos esperar no melhor cenário uma complexidade $O(n)$, onde $n$ é o tamanho do arquivo fonte. Isso significa que devemos evitar cópias desnecessárias de strings e alocações de memória constantes.

Para atingir essa performance, utilizaremos uma estratégia de duas sentinelas (ponteiros) navegando sobre um único buffer gigante que contém todo o código fonte. Abra o arquivo include/compiler/lexer.h. Vamos definir a estrutura que manterá o estado da nossa leitura.

#ifndef LEXER_H_
#define LEXER_H_

#include "compiler/token.h"

typedef struct {
    const char *source;  // O código fonte completo
    const char *start;   // Início do token atual
    const char *current; // Cursor de leitura atual
    int line;            // Contador de linhas
} Lexer;

void lexer_init(Lexer *lexer, const char *source);
Token lexer_next_token(Lexer *lexer);

#endif

Visualizar como esses ponteiros funcionam é crucial. O algoritmo que utilizaremos é frequentemente chamado de Two Pointers (Dois Ponteiros). Imagine que estamos lendo a declaração x: int.

  1. Sincronização: No início, tanto start quanto current apontam para o x.
  2. Avanço (Scan): O ponteiro current avança, consumindo caracteres. Ele lê o x.
  3. Emissão: Ao encontrar o caractere :, o lexer percebe que o identificador acabou. Emitimos um token cujo lexema vai de start até (mas não incluindo) current.
  4. Reset: Após emitir o token, puxamos o ponteiro start para alcançar o current. Agora ambos estão prontos para começar a ler o próximo token (o dois-pontos).

Implementando a Lógica do Scanner7.4

Para tornar esse "balé" de ponteiros possível no código, precisamos de algumas funções auxiliares em src/compiler/lexer.c antes de escrevermos o loop principal. Elas servirão como nossa caixa de ferramentas para manipular a memória. Abra src/compiler/lexer.c e vamos implementar a lógica real.

Adicione estas funções estáticas (privadas) no topo do arquivo. Elas encapsulam a aritmética de ponteiros, tornando o código principal muito mais limpo e legível.

#include <string.h>
#include "compiler/lexer.h"

void lexer_init(Lexer *lexer, const char *source) {
    lexer->source = source;
    lexer->start = source;
    lexer->current = source;
    lexer->line = 1;
}

// Implementaremos lexer_next_token no próximo capítulo
Token lexer_next_token(Lexer *lexer) {
    // Placeholder para o loop principal
    Token token;
    token.type = TOK_EOF;
    return token; 
}

Compiladores, em sua maioria, não se importam com espaços, tabulações ou quebras de linha (com exceção de linguagens como Python). Na nossa linguagem, queremos ignorar completamente essa "sujeira" entre os tokens úteis. Ainda em src/compiler/lexer.c, adicione esta função. Ela é responsável por avançar o current silenciosamente sempre que encontrar formatação irrelevante.

// Consome espaços, tabs e quebras de linha.
// Isso garante que o Lexer foque apenas no código útil.
static void skip_whitespace(Lexer *lexer) {
    for (;;) {
        char c = *lexer->current;
        switch (c) {
            case ' ':
            case '\r':
            case '\t':
                advance(lexer);
                break;
            case '\n':
                lexer->line++; // Importante: contar linhas para mensagens de erro precisas
                advance(lexer);
                break;
            default:
                return; // Encontramos algo que não é espaço. O trabalho aqui acabou.
        }
    }
}

Agora vamos substituir aquele placeholder que deixamos anteriormente. Esta é a função que o Parser chamará repetidamente. A cada chamada, ela deve me entregar um token completo. Nesta etapa, implementaremos o reconhecimento dos símbolos da nossa linguagem (:, =, {, }, etc.), deixando identificadores e números para a próxima aula. Adicione, ainda no mesmo arquivo as funções abaixo:

void lexer_init(Lexer *lexer, const char *source) {
    lexer->source = source;
    lexer->start = source;
    lexer->current = source;
    lexer->line = 1;
}

Token lexer_next_token(Lexer *lexer) {
    // 1. Limpa a sujeira (espaços/tabs/newlines) antes de começar
    skip_whitespace(lexer);

    // 2. Marca o início do novo token
    lexer->start = lexer->current;

    // 3. Verifica se o arquivo acabou
    if (is_at_end(lexer)) return make_token(lexer, TOK_EOF);

    // 4. Lê o próximo caractere e decide o que fazer
    char c = advance(lexer);

    switch (c) {
        // Delimitadores
        case '(': return make_token(lexer, TOK_LPAREN);
        case ')': return make_token(lexer, TOK_RPAREN);
        case '{': return make_token(lexer, TOK_LBRACE);
        case '}': return make_token(lexer, TOK_RBRACE);
        case ';': return make_token(lexer, TOK_SEMICOLON);
        case ',': return make_token(lexer, TOK_COMMA);

        // Operadores e Pontuação Específica da Linguagem
        case ':': return make_token(lexer, TOK_COLON); // Usado em "x: int"
        case '=': return make_token(lexer, TOK_EQUAL); // Usado em "x = 5"
        
        // Matemática
        case '+': return make_token(lexer, TOK_PLUS);
        case '-': return make_token(lexer, TOK_MINUS);
        case '*': return make_token(lexer, TOK_STAR);
        case '/': return make_token(lexer, TOK_SLASH);

        // Relacional
        case '<': return make_token(lexer, TOK_LT);
        case '>': return make_token(lexer, TOK_GT);

        // Se chegou aqui, é algo que ainda não sabemos ler (letras ou números)
        default:
            return error_token(lexer, "Caractere inesperado.");
    }
}

Entendendo o Fluxo de Execução7.5

Você pode estar se perguntando: "Mas o código lê e para no primeiro token. E o whitespace que vem depois? Ele não atrapalha o próximo?". A resposta está no ciclo de chamadas. Lembre-se que lexer_next_token será chamada repetidamente dentro de um loop (veja o main.c abaixo).

  • Chamada 1: O Lexer é chamado. A primeira coisa que ele faz é skip_whitespace. Ele ignora os espaços iniciais, lê o primeiro token (digamos, +) e retorna. O cursor current parou após o +, mas antes dos próximos espaços.
  • Chamada 2: O Lexer é chamado novamente para pegar o próximo item. A primeira coisa que ele faz? skip_whitespace de novo! Agora ele vai consumir os espaços que estavam depois do + até encontrar o próximo caractere útil.

Assim, a cada nova chamada, a função "limpa a mesa" antes de começar a trabalhar.

O Ponto de Entrada: Lendo do Disco7.6

Agora precisamos conectar tudo no src/main.c. O nosso compilador deve ser capaz de receber o caminho de um arquivo como argumento via linha de comando, abrir esse arquivo, ler seu conteúdo para uma string e inicializar o lexer.

Esta é uma operação padrão em C, mas requer cuidado com a gestão de memória. Precisamos descobrir o tamanho do arquivo, alocar a memória exata necessária e garantir que a string termine com um caractere nulo \0 para segurança.

Abra o arquivo src/main.c e substitua o código anterior por este:

#include <stdio.h>
#include <stdlib.h>
#include "compiler/lexer.h"

// Função auxiliar para ler todo o conteúdo de um arquivo
static char* read_file(const char* path) {
    FILE* file = fopen(path, "rb");
    if (file == NULL) {
        fprintf(stderr, "Erro: Não foi possível abrir o arquivo \"%s\".\n", path);
        exit(74);
    }

    // Move o cursor para o final para descobrir o tamanho
    fseek(file, 0L, SEEK_END);
    size_t fileSize = ftell(file);
    rewind(file); // Volta para o início

    // Aloca memória: tamanho do arquivo + 1 para o caractere nulo '\0'
    char* buffer = (char*)malloc(fileSize + 1);
    if (buffer == NULL) {
        fprintf(stderr, "Erro: Memória insuficiente para ler \"%s\".\n", path);
        exit(74);
    }

    // Lê o arquivo para o buffer
    size_t bytesRead = fread(buffer, sizeof(char), fileSize, file);
    if (bytesRead < fileSize) {
        fprintf(stderr, "Erro: Falha na leitura do arquivo \"%s\".\n", path);
        exit(74);
    }

    buffer[bytesRead] = '\0'; // Garante o final da string
    fclose(file);
    return buffer;
}

int main(int argc, char* argv[]) {
    // Verifica se o usuário passou o argumento
    if (argc != 2) {
        fprintf(stderr, "Uso: compiler [caminho_do_arquivo]\n");
        return 64;
    }

    const char* filePath = argv[1];
    
    // 1. Carrega o arquivo na memória
    char* source = read_file(filePath);

    // 2. Inicializa o Lexer com o código fonte
    Lexer lexer;
    lexer_init(&lexer, source);

    printf("Sucesso! Arquivo lido e Lexer inicializado.\n");
    printf("Conteúdo do arquivo:\n%s\n", source);

    // Limpeza
    free(source);
    return 0;
}

O fluxo que implementamos aqui segue as boas práticas: alocamos exatamente a memória necessária usando fseek/ftell e garantimos uma sentinela nula \0 ao final do buffer. Isso permite que nosso Lexer navegue sem medo de invadir memória proibida.

  1. Verificação de Argumentos (argc != 2): O programa exige exatamente um argumento além do seu próprio nome. Se o usuário digitar apenas ./build/compiler sem um arquivo, ele receberá uma mensagem de ajuda.
  2. fseek e ftell: Usamos essas funções para "viajar" até o final do arquivo e perguntar ao sistema operacional quantos bytes ele tem. Isso nos permite alocar (malloc) exatamente a quantidade de memória necessária, nem um byte a mais, nem a menos.
  3. Sentinela Nulo: Adicionamos manualmente \0 ao final do buffer. Isso é crítico. As funções de string em C e o nosso futuro Lexer precisam saber onde o texto termina para não invadir memória proibida.

Testando a Implementação7.7

Como ainda não implementamos identificadores ou números, testaremos apenas a estrutura de símbolos.

  1. Crie um arquivo chamado teste.slang na raiz do projeto (ou onde preferir) e escreva o seguinte "código" dentro dele
: = { }
+ - < >
  1. Abra o terminal e recompile o projeto:
cmake --build build
  1. Execute o compilador passando o arquivo de teste:
./build/compiler teste.slang

Se tudo estiver correto, você verá uma saída como a mostrada abaixo representando o conteúdo do seu arquivo impresso no terminal. Isso prova que nosso sistema de leitura de arquivos (IO) e a estrutura básica de memória estão prontos para receber a lógica de análise léxica restante.

=== Iniciando Análise Léxica ===
<TOK_COLON, ":">
<TOK_EQUAL, "=">
<TOK_LBRACE, "{">
<TOK_RBRACE, "}">
<TOK_PLUS, "+">
<TOK_MINUS, "-">
<TOK_LT, "<">
<TOK_GT, ">">
<TOK_EOF, "">
=== Análise Concluída ===

Próximos Passos7.8

Com a infraestrutura pronta, estamos preparados para o desafio algorítmico. No próximo capítulo, Lab - Analisador Léxico II, expandiremos o lexer_next_token para reconhecer as estruturas que faltam da nossa linguagem como os Identificadores e Literais (5, 3.14, 'a', 'b', "literal de texto"), completando nosso analisador léxico.