ADRIANOLAUREANO← Artigos
SÉRIE CRIVO · 01/03RUSTANÁLISE ESTÁTICA

O que é um linter? Construindo um analisador de código do zero em Rust

O editor sublinha um trecho que compila e talvez até funcione. Como ele percebeu o problema sem executar uma única instrução do programa?

ARTIGO 21 · CRIVO 0.1 · DA AST AO DIAGNÓSTICO

3regras locais

1AST compartilhada

0programas executados

CRIVO · O CÓDIGO VISTO POR DENTRO
FONTE PULSOse ativo == verdadeiro {
 mostre(total)
}
CRIVO 0.1estrutura · relações · intenção
DIAGNÓSTICOcondição redundanteativo == verdadeiro
──────┬──────────
simplifique para ativo

VOCABULÁRIO VISUAL

As peças que entram em cena neste capítulo.

01Linterprograma que examina código sem executá-lo
02ASTárvore que guarda a estrutura do programa
03Spanintervalo exato no arquivo-fonte
04Regraverificação independente e configurável
05Visitorpercurso organizado pelos nós da árvore
06Diagnósticoproblema explicado com localização e ação
Os nomes técnicos aparecem depois do problema que resolvem — use este mapa para consultar, não para decorar.
00
Um linter é um leitor crítico de código.
Ele analisa sem executar

Um linter é uma ferramenta que lê código-fonte e procura padrões suspeitos, redundantes, confusos ou contrários às regras de um projeto. Ele não precisa iniciar o programa para descobrir que uma variável nunca foi usada ou que uma comparação booleana repete uma informação já conhecida.

Isso é análise estática: obter evidências a partir do texto e das estruturas derivadas dele. “Estática” não significa simples. O Crivo precisará reconhecer tokens, montar a árvore sintática, acompanhar declarações e usos e, nos próximos capítulos, estudar caminhos de controle.

PROGRAMA PULSO                         O CRIVO
var ativo = verdadeiro;               lê a fonte
se ativo == verdadeiro {              cria tokens e AST
    mostre("ok");                     percorre relações
}                                     produz diagnósticos

                                      nunca executa mostre
Observe a fronteira: o Crivo descreve o programa, mas não chama suas funções.
FerramentaPergunta principalExecuta o programa?
Parsera forma pertence à gramática?não
Compiladorcomo traduzir o programa?não necessariamente
Linterhá evidências de problema ou melhoria?não
Testeo comportamento observado corresponde ao esperado?sim
Debuggero que acontece durante uma execução?sim
00
Nosso linter se chama Crivo.
O que construiremos de verdade

O nome vem da ideia de peneirar: a fonte Pulso atravessa várias camadas e cada regra retém uma evidência específica. O Crivo é escrito em Rust, recebe um arquivo .pulso, produz diagnósticos com linha e coluna e retorna um código de saída útil para terminal e automação.

arquivo.pulso
     ↓ Lexer::scan
Vec<Token + Span>
     ↓ Parser::program
Vec<Stmt> — a AST
     ↓ Analyzer::block
Vec<Diagnostic>
     ↓ renderer
arquivo:linha:coluna aviso[regra]: mensagem

Neste primeiro estado, o Crivo 0.1 implementa três regras: variável não usada, expressão sem efeito e comparação booleana redundante. Não faremos buscas com contains. Cada regra trabalha sobre nós da AST e posições produzidas pelo frontend.

00
Antes das regras, construímos uma entrada confiável.
Bytes, tokens e spans

O lexer caminha pelos bytes UTF-8 sem confundir byte com coluna. Para cada token, preservamos o intervalo no arquivo e a posição percebida pelo leitor:

pub struct Span {
    pub start: usize,
    pub end: usize,
    pub line: usize,
    pub column: usize,
}

pub struct Token {
    pub kind: Kind,
    pub span: Span,
}

start é inclusivo e end é exclusivo. Essa convenção permite recortar &source[start..end] e, no capítulo 3, aplicar uma correção sem procurar novamente o texto. O lexer reconhece palavras da Pulso, identificadores, literais, delimitadores e operadores. Um caractere desconhecido produz erro de sintaxe com posição; não é ignorado silenciosamente.

O parser consome esses tokens e cria Stmt::Var, Stmt::If, Stmt::Expr e expressões como Expr::Name e Expr::Binary. A AST não guarda apenas “há uma variável”: ela liga o nome ao span exato que originou o nó.

00
Vamos executar o Crivo antes de estudar suas peças.
Uma referência concreta para o restante do capítulo
$ unzip crivo-0.1.2-artigo-01.zip
$ cd crivo-0.1
$ cargo test
$ cargo run -- check exemplos/inicio.pulso

exemplos/inicio.pulso:2:1 aviso[unused-variable]:
  variável nunca_usada declarada e nunca usada
exemplos/inicio.pulso:3:1 aviso[no-effect-expression]:
  expressão sem efeito
exemplos/inicio.pulso:4:4 aviso[redundant-boolean]:
  comparação booleana redundante

O primeiro -- separa argumentos do Cargo dos argumentos do nosso binário. Quando instalarmos o executável, a forma curta será crivo check arquivo.pulso. O download não presume essa instalação.

O código de saída é 0 sem diagnósticos, 1 quando regras encontram problemas e 2 para uso incorreto, arquivo ilegível ou sintaxe inválida. Assim, “o processo terminou” não é confundido com “o código passou no Crivo”.

01
Se compila, por que o editor reclama?
O primeiro sublinhado do Crivo

Considere este programa Pulso:

var ativo = verdadeiro;
se ativo == verdadeiro {
    2 + 2;
    mostre("pronto");
}
var tentativas = 3;

A sintaxe está correta. O compilador consegue gerar bytecode ou código nativo. Durante a execução, o texto aparece. Mesmo assim, há três sinais úteis: comparar um booleano com verdadeiro é redundante; 2 + 2 calcula um valor descartado; tentativas nunca é lida.

A pergunta que guia a série

Já ensinamos o computador a entender, executar e compilar a Pulso. Agora vamos ensiná-lo a examinar o código e apontar problemas antes que o programa seja executado.

CategoriaExemploQuem costuma detectar
Erro de sintaxevar x = ;parser
Erro de compilaçãonome inexistente ou tipo incompatívelcompilador
Erro de execuçãodivisão por zero num caminho percorridoruntime
Avisovariável nunca lidacompilador ou linter
Qualidadecondição redundantelinter

A fronteira não é universal: rustc, Clippy, ESLint e Ruff fazem escolhas diferentes. O nosso contrato é simples: o compilador garante que a Pulso possa ser traduzida; o Crivo procura padrões suspeitos e explica por que merecem atenção.

02
Uma árvore, três destinos.
Reaproveitando o frontend da Pulso

Nos artigos da linguagem Pulso e da VM Cadência, caracteres viraram tokens e tokens viraram uma árvore sintática abstrata — a AST. Não vamos reconstruir essa entrada.

FONTE PULSO
     │ lexer + parser compartilhados
     ▼
    AST
  ┌──┼───────────────┐
  ▼  ▼               ▼
INTERPRETADOR      COMPILADOR          CRIVO
executa nós        transforma nós      examina e enriquece nós
produz valores     produz bytecode/IR   produz diagnósticos
A mesma estrutura responde a perguntas diferentes.

O interpretador precisa saber o que fazer agora. O compilador pergunta como representar isto em outra máquina. O linter pergunta o que este trecho revela quando relacionado ao restante do programa. Nenhum diagnóstico exige chamar a VM ou o runtime.

03
A AST sabe o significado; o diagnóstico precisa do endereço.
Spans ligam nós aos caracteres originais

Uma AST mínima poderia guardar apenas Binary(ativo, Equal, verdadeiro). Isso é suficiente para executar, mas insuficiente para sublinhar exatamente == verdadeiro. Cada nó relevante passa a carregar um span: o intervalo semiaberto [início, fim) em bytes UTF-8.

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Span {
    pub start: usize,
    pub end: usize,
}

pub struct Spanned<T> {
    pub node: T,
    pub span: Span,
}
se ativo == verdadeiro
   │     └───────┬───┘
   │          bytes 9..21
   └ ativo: bytes 3..8

SourceFile converte offset → linha e coluna
Span preserva offset → destaque exato
Linha e coluna são apresentação; offsets são a coordenada interna estável.

Bytes, caracteres e colunas não são sinônimos quando há acentos. O SourceFile guarda os inícios de linha e usa o texto original para converter o intervalo somente na hora de renderizar.

04
Uma regra não deve conhecer todas as outras.
Do amontoado de ifs a uma arquitetura extensível
pub trait Rule {
    fn name(&self) -> &'static str;
    fn check_expr(&self, _expr: &Spanned<Expr>, _ctx: &mut Context) {}
    fn check_stmt(&self, _stmt: &Spanned<Stmt>, _ctx: &mut Context) {}
}

pub struct Context<'a> {
    pub source: &'a SourceFile,
    pub diagnostics: Vec<Diagnostic>,
}

O contrato não promete que toda regra visite todo nó. Métodos padrão deixam cada implementação interessada apenas no que precisa. Um registro de regras recebe objetos independentes; o analisador controla o percurso uma única vez.

Esse percurso organizado é o visitor. Ele entra numa declaração, visita sua expressão, desce para operandos e blocos e volta. Centralizar a caminhada evita que cada regra esqueça um ramo da AST.

fn visit_expr(expr: &Spanned<Expr>, rules: &[Box<dyn Rule>], ctx: &mut Context) {
    for rule in rules { rule.check_expr(expr, ctx); }
    match &expr.node {
        Expr::Binary { left, right, .. } => {
            visit_expr(left, rules, ctx);
            visit_expr(right, rules, ctx);
        }
        Expr::Call { callee, args } => { /* visita todos */ }
        _ => {}
    }
}
Decisão de engenharia

Uma caminhada compartilhada é pequena e previsível. No Crivo 0.2 algumas análises ganharão fases próprias, porque resolver nomes e fluxo exige estado que não cabe numa visita local.

05
Um bom diagnóstico não diz apenas “código ruim”.
Regra, severidade, lugar, evidência e ação
aviso[boolean-comparison]: comparação booleana redundante
  ┌─ exemplos/inicio.pulso:2:10
  │
2 │ se ativo == verdadeiro {
  │          ^^^^^^^^^^^^^ use apenas `ativo`
  │
  = a comparação produz o mesmo valor que a variável

O modelo guarda rule, severity, message, arquivo, span, rótulo e nota. A renderização calcula linha, coluna e trecho. Separar os dados da aparência permitirá produzir JSON no terceiro artigo sem reexecutar análise.

06
Três regras, três perguntas diferentes.
O primeiro conjunto útil do Crivo

Condição booleana redundante

Quando uma expressão x == verdadeiro aparece, a regra sugere x. Para x == falso, a forma equivalente seria !x, mas a versão 0.1 apenas diagnostica: ainda não temos um aplicador seguro.

Expressão sem efeito

Literais e operações aritméticas puras, usadas como comando, calculam e descartam. Chamadas não entram nessa lista porque podem imprimir, gravar ou alterar estado; sem um sistema de efeitos, o Crivo escolhe ser conservador.

Variável declarada e nunca usada

Ao entrar num bloco, registramos declarações; ao encontrar uma leitura, marcamos o nome. Ao sair, emitimos os não usados. Esta versão lida com um bloco simples. Escopos aninhados, parâmetros e sombreamento ficam para o 0.2 — e a limitação aparece claramente no diagnóstico e no README.

RegraDetectaNão presume
boolean-comparisonx == verdadeiroque toda comparação pode ser removida
no-effect-expression2 + 2;que chamadas são puras
unused-variabledeclaração nunca lidaescopos complexos nesta versão
07
Crivo 0.1: executável, observável e testado.
A prova está no comportamento
$ cargo run -- check exemplos/inicio.pulso
3 diagnósticos · 0 erros · 3 avisos

$ cargo test
test booleano_redundante ... ok
test expressao_sem_efeito ... ok
test variavel_nao_usada ... ok
test preserva_linha_coluna_utf8 ... ok

Os testes criam uma fonte, passam pelo frontend da Pulso, executam o analisador e comparam os diagnósticos. Não há VM no caminho. Um teste instala um contador no executor fictício e comprova que permanece em zero.

O que construímos

O Crivo 0.1 já possui fonte com posições, AST reaproveitada, visitor, regras independentes, diagnósticos acionáveis, CLI e testes. Mas ainda enxerga cada árvore localmente. Para perceber uma variável escondida, um retorno ausente ou uma atribuição perdida, ele precisará compreender relações.

08
Fontes e limites do laboratório.
Onde o Crivo didático termina

O desenho foi guiado pela documentação oficial do Clippy sobre regras, pela especificação de diagnósticos do Language Server Protocol e pela arquitetura já construída para a Pulso. O Crivo não tenta cobrir a linguagem Rust, inferência industrial, macros ou compatibilidade de milhares de regras. Seu objetivo é deixar todo o mecanismo visível.

PROJETO COMPLETO · ESTADO EXATO DESTE CAPÍTULO

Baixe e execute o Crivo 0.1.

O pacote é independente, inclui o código-fonte em Rust, exemplos Pulso, testes e README. Ele contém somente o que foi construído até este artigo; as versões anteriores permanecem disponíveis em seus próprios endereços.

Baixar Crivo 0.1 (.zip)Rust · Pulso · análise estática · testes

O que este artigo fez você pensar?

Dúvidas, experiências e contrapontos ajudam a próxima pessoa a enxergar o assunto por outro ângulo.

Todos passam por moderação. Ao enviar, você concorda com a política de privacidade.

Receba os próximos artigos.

Uma mensagem quando uma nova investigação estiver pronta. Só isso.