DEV Community

Vitor Lobo
Vitor Lobo

Posted on • Edited on

2

Explorando Abordagens AST

Tabela de Conteúdo


Introdução

A construção e análise de Abstract Syntax Trees (AST) são pilares da análise estática e transformação de código, permitindo que ferramentas "compreendam" a estrutura do código-fonte ver artigo anterior. Neste cenário, PMD e Semgrep se destacam como ferramentas open-source que se integram profundamente com ASTs. Este artigo explora as abordagens distintas que cada uma adota.

O Paradigma "Trees That Grow": Extensibilidade da AST

Imagine uma árvore crescendo ao longo do tempo. À medida que se desenvolve, novas folhas, galhos e frutos podem surgir. Essa metáfora ilustra o desafio da extensibilidade das ASTs. O artigo Trees That Grow propõe um idioma de programação engenhoso (baseado em funções no nível de tipos) para enfrentar esse desafio.

No desenvolvimento de compiladores e analisadores, frequentemente necessitamos adicionar novas informações (como metadados de localização, resolução de nomes e anotações de tipos inferidos) à AST em diferentes etapas. As abordagens tradicionais, no entanto, frequentemente resultam em código duplicado, estruturas difíceis de manter e soluções ad-hoc que prejudicam a manutenibilidade e a evolução do software.

Para contornar essas limitações, Trees That Grow introduz tipos parametrizados (ExpX ξ, onde ξ atua como um descritor de extensão) e famílias de tipos (type family XLit ξ, type family XVar ξ). Essa estratégia inovadora permite a adição dinâmica de campos, sem a necessidade de redefinir toda a estrutura da árvore. Além disso, o uso de pattern synonyms contribui para uma maior legibilidade e simplifica a manipulação da AST.

O artigo Data Types à la Carte, também relevante, apresenta uma perspectiva interessante ao propor uma abordagem modular para definir e compor linguagens e suas ASTs.

Adotar a técnica Trees That Grow traz consigo uma série de vantagens:

  • Suporte a recursos avançados: Compatibilidade com GADTs e tipos existenciais, garantindo a flexibilidade necessária para lidar com diferentes linguagens e ferramentas.
  • Redução do "boilerplate": Eliminação do excesso de código repetitivo, facilitando a manutenção e o desenvolvimento.
  • Extensibilidade bidirecional: A capacidade de adicionar tanto novos campos quanto novos construtores, resultando em um sistema mais modular e adaptável.

Essa abordagem foi testada e comprovada no desenvolvimento do Glasgow Haskell Compiler (GHC). Em um projeto com uma AST complexa, com 97 tipos e 321 construtores, a adoção de Trees That Grow desempenhou um papel crucial na redução de inconsistências e na simplificação da evolução do código.

PMD vs. Semgrep: Uma Comparação de Abordagens

O PMD é uma ferramenta bem estabelecida para análise estática, amplamente utilizada em linguagens como Java e JavaScript, e implementada em Java. Embora sua abordagem seja bem documentada, ela apresenta desafios relacionados à consistência e segurança dos dados, especialmente em sistemas de grande escala. A principal fonte desses desafios reside na gestão de estados mutáveis, que podem levar a um código mais complexo e difícil de depurar e manter. Artigos como Out of the Tar Pit argumentam que a mutabilidade excessiva é um dos principais fatores que contribuem para a complexidade do software.

O PMD utiliza o padrão Visitor, uma abordagem imperativa na qual classes percorrem a AST nó a nó, executando ações específicas com base no tipo do nó encontrado. Embora amplamente utilizado, esse padrão tende a gerar código com alta mutabilidade de estado e efeitos colaterais, o que pode dificultar a depuração e aumentar a complexidade.

PMD: O Padrão Visitor e Desafios da Mutabilidade

Para ilustrar como o PMD lida com a AST, podemos consultar exemplos práticos no GitHub:

O trecho de código abaixo exemplifica a implementação de um nó AST para análise de páginas JSP:

package net.sourceforge.pmd.lang.jsp.ast;

public final class ASTContent extends AbstractJspNode {

    ASTContent(int id) {
        super(id);
    }

    @Override
    protected <P, R> R acceptVisitor(JspVisitor<? super P, ? extends R> visitor, P data) {
        return visitor.visit(this, data);
    }
}
Enter fullscreen mode Exit fullscreen mode

Esse código define um nó AST chamado ASTContent, que faz parte da análise de arquivos JSP dentro do PMD. Ele estende AbstractJspNode e implementa um método acceptVisitor, seguindo o padrão Visitor para permitir a navegação da AST.

Outro exemplo relevante:

package net.sourceforge.pmd.lang.rule;

import net.sourceforge.pmd.lang.ast.AstVisitor;
import net.sourceforge.pmd.lang.ast.Node;
import net.sourceforge.pmd.reporting.RuleContext;

public abstract class AbstractVisitorRule extends AbstractRule {
    @Override
    public void apply(Node target, RuleContext ctx) {
        AstVisitor<RuleContext, ?> visitor = buildVisitor();
        assert visitor != null : "Rule should provide a non-null visitor";

        target.acceptVisitor(visitor, ctx);
    }

    public abstract AstVisitor<RuleContext, ?> buildVisitor();
}
Enter fullscreen mode Exit fullscreen mode

O código AbstractVisitorRule define uma abordagem padronizada para aplicar regras no PMD utilizando o padrão Visitor para percorrer a AST.

A estrutura central desse código gira em torno do método apply, que recebe um nó da AST (Node target) e um contexto (RuleContext ctx), delegando a visita do nó a um AstVisitor, retornado pelo método abstrato buildVisitor().

A abordagem tem um problema clássico de estado mutável e acoplamento. Como apply apenas injeta o visitante sem controle sobre como ele modifica o RuleContext, qualquer mudança de estado ocorre implicitamente dentro do AstVisitor. Isso pode gerar efeitos colaterais difíceis de rastrear, algo comum em designs imperativos.

Semgrep: A Elegância da Análise Funcional

Em nítido contraste, o Semgrep adota uma abordagem funcional, priorizando a imutabilidade e a composição de funções para transformar e analisar ASTs. Em vez de percorrer a árvore sintática de maneira explícita, o Semgrep trata a AST como uma estrutura de dados que pode ser manipulada diretamente por meio de padrões.

Implementado na linguagem OCaml, o Semgrep se beneficia do poder do pattern matching, que facilita a decomposição da árvore e a aplicação de transformações sem modificar os nós originais. Essa característica resulta em um código mais conciso, declarativo e fácil de compreender.

Exemplo de estrutura de AST no Semgrep:

open Printf

type t = node list

and node =
  | Ellipsis
  | Long_ellipsis
  | Metavar of string (* identifier "FOO" only without "$" *)
  | Metavar_ellipsis of string (* same *)
  | Long_metavar_ellipsis of string (* same *)
  | Bracket of char * t * char
  | Word of string
  | Newline
  | Other of string
[@@deriving show]

let check ast =
  let metavariables = ref [] in
  let add name mv =
    match List.assoc_opt name !metavariables with
    | None -> metavariables := (name, mv) :: !metavariables
    | Some mv2 ->
        if mv2 <> mv then
          failwith
            (sprintf
               "error in aliengrep pattern. Inconsistent use of the \
                metavariable %S in %s"
               name (show ast))
  in
  let rec check_node = function
    | Ellipsis
    | Long_ellipsis ->
        ()
    | (Metavar name | Metavar_ellipsis name | Long_metavar_ellipsis name) as
      kind ->
        add name kind
    | Bracket (_open, seq, _close) -> check_seq seq
    | Word _str -> ()
    | Newline -> ()
    | Other _str -> ()
  and check_seq seq = List.iter check_node seq in
  check_seq ast
Enter fullscreen mode Exit fullscreen mode

Nesse código, temos um modelo funcional de AST que define diferentes tipos de nós (node) usados para análise de padrões. Essa estrutura permite que padrões escritos no Semgrep sejam convertidos diretamente em expressões regulares (PCRE regex), possibilitando buscas eficientes no código-fonte. A função check percorre os nós da AST de forma declarativa e valida o uso de metavariáveis sem precisar modificar diretamente a estrutura da árvore.

Este design evita a necessidade de um visitante explícito e mantém a análise mais previsível, sem efeitos colaterais. As operações são puras e previsíveis, e a estrutura de match no OCaml torna o código mais legível. O código abaixo ilustra a modelagem da AST no Semgrep, definindo os tipos de nós:

open Printf

type t = node list

and node =
  | Ellipsis
  | Long_ellipsis
  | Metavar of string  (* Variável como "FOO", sem "$" *)
  | Metavar_ellipsis of string  (* Versão com elipses *)
  | Long_metavar_ellipsis of string
  | Bracket of char * t * char  (* Um nó delimitado por colchetes *)
  | Word of string  (* Um identificador normal *)
  | Newline
  | Other of string  (* Qualquer outro caractere *)
[@@deriving show]
Enter fullscreen mode Exit fullscreen mode

O código abaixo verifica a consistência da AST, garantindo que as metavariáveis sejam usadas corretamente.

let check ast =
  let metavariables = ref [] in
  let add name mv =
    match List.assoc_opt name !metavariables with
    | None -> metavariables := (name, mv) :: !metavariables
    | Some mv2 ->
        if mv2 <> mv then
          failwith
            (sprintf
               "Erro no padrão Semgrep: Uso inconsistente da metavariável %S em %s"
               name (show ast))
  in
  let rec check_node = function
    | Ellipsis
    | Long_ellipsis -> ()
    | (Metavar name | Metavar_ellipsis name | Long_metavar_ellipsis name) as kind ->
        add name kind
    | Bracket (_open, seq, _close) -> check_seq seq
    | Word _str | Newline | Other _str -> ()
  and check_seq seq = List.iter check_node seq in
  check_seq ast
Enter fullscreen mode Exit fullscreen mode

No Semgrep, esse modelo de AST permite definir padrões para busca de código declarativamente, sem a necessidade de percorrer manualmente a árvore sintática.

Diferenças Chave: Escolhendo a Ferramenta Certa

Aspecto PMD Semgrep
Paradigma Imperativo (Visitor) Funcional (Pattern Matching)
Navegação da AST Manual, nó a nó Automática via padrões
Mutabilidade Estado interno modificável Estruturas imutáveis
Complexidade Mais código boilerplate Código conciso e modular
Facilidade de Extensão Requer classes visitantes Padrões declarativos

Em resumo, o PMD oferece um controle granular sobre a análise, mas exige mais código para percorrer a AST. Já o Semgrep, com sua abordagem funcional, prioriza a modularidade e a previsibilidade.

A arquitetura do Semgrep, fundamentada em pattern matching e funções puras, possibilita uma análise estática mais declarativa e segura. Seu uso de OCaml e imutabilidade minimiza efeitos colaterais, tornando-o uma ferramenta poderosa para busca e transformação de código.

Artigos como Why Functional Programming Matters ressaltam como a abordagem funcional contribui para a modularidade e reduz a complexidade do código.

A escolha entre as abordagens imperativas e funcionais para análise de ASTs depende de diversos fatores, como o tamanho da árvore, os requisitos de desempenho e a familiaridade da equipe com os paradigmas. A abordagem imperativa (PMD) pode oferecer maior eficiência em termos de desempenho, mas exige maior atenção ao gerenciamento de estado. A abordagem funcional (Semgrep), por outro lado, favorece a clareza, a modularidade e a segurança, embora possa apresentar desafios de otimização.

Independentemente da abordagem escolhida, a compreensão dos conceitos de imutabilidade, funções puras e composição é fundamental para aprimorar a qualidade e a segurança do código.

Sentry image

Hands-on debugging session: instrument, monitor, and fix

Join Lazar for a hands-on session where you’ll build it, break it, debug it, and fix it. You’ll set up Sentry, track errors, use Session Replay and Tracing, and leverage some good ol’ AI to find and fix issues fast.

RSVP here →

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay