Nesta aula, vamos colocar em prática vários conceitos de Rust que você já aprendeu, construindo um parser simples. Um parser é um programa que analisa uma sequência de caracteres (como uma string) e a transforma em uma estrutura de dados mais significativa, geralmente uma árvore ou uma lista de tokens. Nosso objetivo será criar um parser para expressões aritméticas simples, como 2 + 3 * (4 - 1). Vamos dividir o problema em etapas: primeiro, a tokenização (quebrar a string em tokens), depois o parsing propriamente dito (construir uma árvore de expressão), e por fim, a avaliação da expressão. Ao longo do caminho, vamos explorar enums, estruturas, gerenciamento de estado e testes.

Este projeto é fundamental para entender como linguagens de programação são implementadas. Ao final, você terá um interpretador funcional para expressões aritméticas com precedência de operadores e parênteses. Vamos começar!

Tokenização

A primeira etapa de qualquer parser é a tokenização (ou lexing). O tokenizador (lexer) lê a string de entrada caractere por caractere e agrupa caracteres em tokens, que são unidades significativas, como números, operadores e parênteses. Por exemplo, a string 2 + 3 deve ser quebrada em tokens: Number(2), Plus, Number(3).

Em Rust, podemos representar tokens usando um enum, onde cada variante representa um tipo de token. Para números, precisamos armazenar o valor; para operadores, apenas o tipo. Vamos definir nosso enum Token da seguinte forma:

#[derive(Debug, Clone, PartialEq)]
enum Token {
    Number(i64),
    Plus,
    Minus,
    Star,
    Slash,
    LParen,
    RParen,
}

O tokenizador percorre a string e produz um vetor de tokens. Para isso, precisamos lidar com espaços em branco (ignorar), números (que podem ter múltiplos dígitos) e caracteres de operadores e parênteses. Vamos implementar uma função tokenize que retorna um Result<Vec<Token>, String> para tratar erros, como caracteres inválidos.

Exemplo de implementação:

fn tokenize(input: &str) -> Result<Vec<Token>, String> {
    let mut tokens = Vec::new();
    let mut chars = input.chars().peekable();

    while let Some(&c) = chars.peek() {
        match c {
            ' ' | '\t' | '\n' => { chars.next(); }
            '0'..='9' => {
                let mut num = String::new();
                while let Some(&c) = chars.peek() {
                    if c.is_ascii_digit() {
                        num.push(c);
                        chars.next();
                    } else {
                        break;
                    }
                }
                let n: i64 = num.parse().map_err(|e| format!("Erro ao parsear número: {}", e))?;
                tokens.push(Token::Number(n));
            }
            '+' => { tokens.push(Token::Plus); chars.next(); }
            '-' => { tokens.push(Token::Minus); chars.next(); }
            '*' => { tokens.push(Token::Star); chars.next(); }
            '/' => { tokens.push(Token::Slash); chars.next(); }
            '(' => { tokens.push(Token::LParen); chars.next(); }
            ')' => { tokens.push(Token::RParen); chars.next(); }
            _ => return Err(format!("Caractere inesperado: '{}'", c)),
        }
    }
    Ok(tokens)
}

Note que usamos peekable() para olhar o próximo caractere sem consumi-lo, o que é útil para números de múltiplos dígitos. Também tratamos erros de parseamento de número com map_err.

Estado

O parsing de expressões aritméticas requer gerenciamento de estado, pois precisamos saber em que ponto da sequência de tokens estamos. Uma abordagem comum é usar uma estrutura Parser que mantém os tokens e um índice para o token atual. Vamos criar:

struct Parser {
    tokens: Vec<Token>,
    pos: usize,
}

impl Parser {
    fn new(tokens: Vec<Token>) -> Self {
        Parser { tokens, pos: 0 }
    }

    fn peek(&self) -> Option<&Token> {
        self.tokens.get(self.pos)
    }

    fn next(&mut self) -> Option<&Token> {
        let token = self.tokens.get(self.pos);
        if token.is_some() {
            self.pos += 1;
        }
        token
    }

    fn expect(&mut self, token: &Token) -> Result<(), String> {
        match self.next() {
            Some(t) if t == token => Ok(()),
            Some(t) => Err(format!("Esperava {:?}, encontrou {:?}", token, t)),
            None => Err("Fim inesperado da entrada".to_string()),
        }
    }
}

A estrutura Parser encapsula o estado do parsing: a lista de tokens e a posição atual. Os métodos peek, next e expect permitem avançar e verificar tokens de forma segura. Esse padrão é comum em parsers recursivos descendentes.

Para avaliar a expressão, precisamos de funções que reconheçam diferentes níveis de precedência. Por exemplo, em expressões aritméticas, multiplicação e divisão têm precedência maior que adição e subtração. Vamos implementar uma gramática simples:

  • expr -> term (('+' | '-') term)*
  • term -> factor (('*' | '/') factor)*
  • factor -> NUMBER | '(' expr ')'

Essa gramática define a precedência corretamente. Vamos implementar os métodos parse_expr, parse_term e parse_factor dentro de Parser.

Enums

Usamos enums para representar tokens, mas também podemos usar enums para representar a árvore de expressão (AST - Abstract Syntax Tree). Isso permite avaliar a expressão de forma recursiva. Vamos definir:

#[derive(Debug, Clone, PartialEq)]
enum Expr {
    Number(i64),
    Add(Box<Expr>, Box<Expr>),
    Sub(Box<Expr>, Box<Expr>),
    Mul(Box<Expr>, Box<Expr>),
    Div(Box<Expr>, Box<Expr>),
}

Usamos Box para alocar na heap, pois Expr é recursivo. Cada variante representa uma operação ou um número. A função eval pode ser implementada como um método ou função que combina padrões:

fn eval(expr: &Expr) -> i64 {
    match expr {
        Expr::Number(n) => *n,
        Expr::Add(a, b) => eval(a) + eval(b),
        Expr::Sub(a, b) => eval(a) - eval(b),
        Expr::Mul(a, b) => eval(a) * eval(b),
        Expr::Div(a, b) => eval(a) / eval(b),
    }
}

Os enums são poderosos em Rust para modelar dados que podem assumir diferentes formatos. Aqui, eles nos dão segurança de tipo e permitem que o compilador verifique se todos os casos são tratados.

Vamos agora implementar os métodos de parsing que constroem a AST. Por exemplo, parse_factor:

fn parse_factor(&mut self) -> Result<Expr, String> {
    match self.next() {
        Some(Token::Number(n)) => Ok(Expr::Number(*n)),
        Some(Token::LParen) => {
            let expr = self.parse_expr()?;
            self.expect(&Token::RParen)?;
            Ok(expr)
        }
        Some(token) => Err(format!("Token inesperado: {:?}", token)),
        None => Err("Fim inesperado".to_string()),
    }
}

Note que usamos ? para propagar erros, tornando o código limpo.

Testes

Testar é essencial para garantir que nosso parser funciona. Em Rust, escrevemos testes com #[test] e rodamos com cargo test. Vamos escrever testes para a tokenização e para a avaliação de expressões.

Primeiro, um teste para tokenize:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_tokenize_simple() {
        let tokens = tokenize("2 + 3").unwrap();
        assert_eq!(tokens, vec![
            Token::Number(2),
            Token::Plus,
            Token::Number(3),
        ]);
    }

    #[test]
    fn test_tokenize_with_parens() {
        let tokens = tokenize("(1 - 2) * 3").unwrap();
        assert_eq!(tokens, vec![
            Token::LParen,
            Token::Number(1),
            Token::Minus,
            Token::Number(2),
            Token::RParen,
            Token::Star,
            Token::Number(3),
        ]);
    }

    #[test]
    fn test_tokenize_invalid_char() {
        let result = tokenize("2 + a");
        assert!(result.is_err());
    }
}

Agora testes para o parser/avaliação:

#[test]
fn test_eval_simple() {
    let tokens = tokenize("2 + 3").unwrap();
    let mut parser = Parser::new(tokens);
    let expr = parser.parse_expr().unwrap();
    assert_eq!(eval(&expr), 5);
}

#[test]
fn test_eval_precedence() {
    let tokens = tokenize("2 + 3 * 4").unwrap();
    let mut parser = Parser::new(tokens);
    let expr = parser.parse_expr().unwrap();
    assert_eq!(eval(&expr), 14);
}

#[test]
fn test_eval_parens() {
    let tokens = tokenize("(2 + 3) * 4").unwrap();
    let mut parser = Parser::new(tokens);
    let expr = parser.parse_expr().unwrap();
    assert_eq!(eval(&expr), 20);
}

Esses testes garantem que a precedência e os parênteses estão sendo tratados corretamente. Lembre-se de que, para rodar os testes, você precisa incluir a função parse_expr e os métodos auxiliares. Aqui está a implementação completa dos métodos de parsing:

impl Parser {
    fn parse_expr(&mut self) -> Result<Expr, String> {
        let mut left = self.parse_term()?;
        while let Some(token) = self.peek() {
            match token {
                Token::Plus => {
                    self.next();
                    let right = self.parse_term()?;
                    left = Expr::Add(Box::new(left), Box::new(right));
                }
                Token::Minus => {
                    self.next();
                    let right = self.parse_term()?;
                    left = Expr::Sub(Box::new(left), Box::new(right));
                }
                _ => break,
            }
        }
        Ok(left)
    }

    fn parse_term(&mut self) -> Result<Expr, String> {
        let mut left = self.parse_factor()?;
        while let Some(token) = self.peek() {
            match token {
                Token::Star => {
                    self.next();
                    let right = self.parse_factor()?;
                    left = Expr::Mul(Box::new(left), Box::new(right));
                }
                Token::Slash => {
                    self.next();
                    let right = self.parse_factor()?;
                    left = Expr::Div(Box::new(left), Box::new(right));
                }
                _ => break,
            }
        }
        Ok(left)
    }
}

Boas práticas e observações finais

Ao construir um parser, é importante separar as etapas de tokenização e parsing. Isso facilita a manutenção e permite testar cada parte independentemente. Use enums para representar tokens e AST, pois eles tornam o código mais expressivo e seguro. Sempre trate erros de forma explícita, usando Result e ? para propagar.

Além disso, considere a possibilidade de estender o parser para suportar unários, potências, etc. Isso pode ser feito adicionando novas variantes ao enum e ajustando a gramática. Lembre-se de que a precedência é controlada pela estrutura das funções de parsing, então mudanças na gramática exigem ajustes nessas funções.

Por fim, escreva testes abrangentes para cobrir casos de borda, como divisão por zero (embora nosso parser não verifique isso) e entradas inválidas. Isso aumenta a confiança na correção do código.

Referências

Exercícios

  1. Adicione suporte para números negativos no tokenizador e no parser. Por exemplo, -5 + 3 deve ser avaliado como -2. Dica: trate o sinal de menos unário no fator.
  2. ✓ Resposta: Para suportar números negativos, você pode modificar o tokenizador para reconhecer um sinal de menos seguido de um dígito como um número negativo, ou modificar o parser para tratar o sinal de menos como unário. Uma abordagem é, no parse_factor, se o token atual for Minus, consuma-o e retorne Expr::Neg (adicionando uma variante ao enum). Exemplo:
    fn parse_factor(&mut self) -> Result<Expr, String> {
        // ...
        Some(Token::Minus) => {
            self.next();
            let expr = self.parse_factor()?;
            Ok(Expr::Neg(Box::new(expr)))
        }
        // ...
    }
    E adicione Neg(Box<Expr>) ao enum e trate na avaliação: Expr::Neg(e) => -eval(e).
  3. Implemente a operação de módulo (%). Adicione o token Percent e o tratamento no parser e na avaliação.
  4. ✓ Resposta: Adicione a variante Percent ao enum Token, trate o caractere % no tokenizador. No parser, adicione o tratamento no parse_term (mesma precedência de * e /) e crie a variante Expr::Mod. Na avaliação, use %.
  5. Escreva um teste que verifique que uma expressão com divisão por zero retorna um erro (ou use panic). Como você lidaria com isso?
  6. ✓ Resposta: Você pode modificar a função eval para retornar Result<i64, String> e verificar se o divisor é zero. Exemplo: Expr::Div(a, b) => { let divisor = eval(b)?; if divisor == 0 { Err("Divisão por zero".to_string()) } else { Ok(eval(a)? / divisor) } }. Escreva um teste que chama eval e espera um erro.
  7. Estenda o parser para suportar variáveis simples (ex.: x = 5; x + 2). Você precisará de um mapa de variáveis. Isso é mais complexo, mas tente implementar uma versão básica.
  8. ✓ Resposta: Uma abordagem é adicionar um token Ident(String) e um token Assign. No parser, crie uma função parse_statement que reconhece uma atribuição e armazena em um HashMap<String, i64>. Para avaliação, Expr::Var(String) consulta o mapa. Isso envolve modificar o tokenizador, o enum de tokens, o parser e a avaliação. Exemplo simples: let mut vars = HashMap::new(); e passe para eval.
  9. Refatore o código para usar thiserror ou anyhow para tratamento de erros. Mostre como você faria isso.
  10. ✓ Resposta: Adicione thiserror como dependência. Defina um enum de erro customizado:
    #[derive(Debug, Error)]
    enum ParseError {
        #[error("caractere inesperado: {0}")]
        UnexpectedChar(char),
        #[error("erro de parse: {0}")]
        Parse(String),
        #[error("erro de avaliação: {0}")]
        Eval(String),
    }
    Modifique as funções para retornar Result<_, ParseError> e use ? para converter erros com From. Isso centraliza o tratamento de erros.