AST do TypeScript: lendo e transformando código

1. Introdução à AST (Árvore Sintática Abstrata)

A Árvore Sintática Abstrata (AST) é uma representação hierárquica da estrutura gramatical do código fonte, abstraindo detalhes sintáticos como espaçamento, pontuação e comentários. Diferente da CST (Árvore Sintática Concreta), que preserva cada caractere, a AST foca apenas na estrutura essencial do programa.

O pipeline de processamento segue: código fonte → tokenização (lexer) → parsing → AST. Cada nó da AST representa um constructo da linguagem, como funções, variáveis ou expressões.

2. O Compilador TypeScript e sua API de AST

O compilador TypeScript expõe uma API robusta para trabalhar com AST. O ponto de partida é ts.createSourceFile:

import * as ts from 'typescript';

const code = `
function greet(name: string): string {
  return "Hello, " + name;
}
`;

const sourceFile = ts.createSourceFile(
  'example.ts',
  code,
  ts.ScriptTarget.Latest,
  true
);

console.log(sourceFile.statements.length); // 1

Os tipos fundamentais de nós incluem:
- SourceFile: raiz da AST
- Statement: declarações como FunctionDeclaration, VariableStatement
- Expression: expressões como BinaryExpression, CallExpression
- Identifier: nomes de variáveis, funções
- Literal: valores literais (string, number, boolean)

Para percorrer a AST manualmente:

function visitNode(node: ts.Node, depth: number = 0) {
  console.log(' '.repeat(depth) + ts.SyntaxKind[node.kind]);
  ts.forEachChild(node, child => visitNode(child, depth + 1));
}

visitNode(sourceFile);

3. Lendo e Navegando na AST

O ts.SyntaxKind é um enum que identifica cada tipo de nó. Funções auxiliares como ts.isFunctionDeclaration() facilitam a navegação:

function extractFunctionDeclarations(sourceFile: ts.SourceFile): ts.FunctionDeclaration[] {
  const functions: ts.FunctionDeclaration[] = [];

  function visit(node: ts.Node) {
    if (ts.isFunctionDeclaration(node)) {
      functions.push(node);
    }
    ts.forEachChild(node, visit);
  }

  visit(sourceFile);
  return functions;
}

const functions = extractFunctionDeclarations(sourceFile);
functions.forEach(fn => {
  if (fn.name) {
    console.log(`Function: ${fn.name.text}`);
  }
});

Para encontrar variáveis não utilizadas:

function findUnusedVariables(sourceFile: ts.SourceFile): string[] {
  const declared = new Set<string>();
  const used = new Set<string>();

  function visit(node: ts.Node) {
    if (ts.isVariableDeclaration(node) && node.name) {
      declared.add(node.name.getText());
    }
    if (ts.isIdentifier(node) && node.parent && !ts.isVariableDeclaration(node.parent)) {
      used.add(node.text);
    }
    ts.forEachChild(node, visit);
  }

  visit(sourceFile);
  return [...declared].filter(v => !used.has(v));
}

4. Transformando a AST com Visitantes

O padrão Visitante permite modificar a AST de forma controlada. Usamos ts.visitNode e ts.visitEachChild:

function addLoggingToFunctions(sourceFile: ts.SourceFile): ts.SourceFile {
  function visitor(node: ts.Node): ts.Node {
    if (ts.isFunctionDeclaration(node) && node.body) {
      const logStatement = ts.factory.createExpressionStatement(
        ts.factory.createCallExpression(
          ts.factory.createIdentifier('console.log'),
          undefined,
          [ts.factory.createStringLiteral(`Entering function: ${node.name?.text || 'anonymous'}`)]
        )
      );

      const updatedBody = ts.factory.createBlock([
        logStatement,
        ...node.body.statements
      ]);

      return ts.factory.updateFunctionDeclaration(
        node,
        node.modifiers,
        node.asteriskToken,
        node.name,
        node.typeParameters,
        node.parameters,
        node.type,
        updatedBody
      );
    }
    return ts.visitEachChild(node, visitor, undefined);
  }

  return ts.visitNode(sourceFile, visitor) as ts.SourceFile;
}

Para usar transformações completas:

const result = ts.transform(sourceFile, [context => {
  return sourceFile => {
    function visitor(node: ts.Node): ts.Node {
      // transformação aqui
      return ts.visitEachChild(node, visitor, context);
    }
    return ts.visitNode(sourceFile, visitor);
  };
}]);

const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
const transformedCode = printer.printFile(result.transformed[0] as ts.SourceFile);

5. Análise Estática com a AST

Para análise mais profunda, usamos o TypeChecker:

function getTypeInfo(sourceFile: ts.SourceFile): void {
  const program = ts.createProgram({
    rootNames: [sourceFile.fileName],
    options: {}
  });
  const checker = program.getTypeChecker();

  function visit(node: ts.Node) {
    if (ts.isVariableDeclaration(node) && node.initializer) {
      const type = checker.getTypeAtLocation(node);
      console.log(`Variable ${node.name.getText()}: ${checker.typeToString(type)}`);
    }
    ts.forEachChild(node, visit);
  }

  visit(sourceFile);
}

Para gerar documentação automática a partir de JSDoc:

function extractJSDoc(sourceFile: ts.SourceFile): Array<{ name: string; docs: string }> {
  const docs: Array<{ name: string; docs: string }> = [];

  function visit(node: ts.Node) {
    const jsDoc = ts.getJSDocTags(node);
    if (jsDoc.length > 0 && (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node))) {
      docs.push({
        name: node.name?.text || 'unknown',
        docs: jsDoc.map(tag => `${tag.tagName.text}: ${tag.comment}`).join('\n')
      });
    }
    ts.forEachChild(node, visit);
  }

  visit(sourceFile);
  return docs;
}

6. Ferramentas Práticas e Integração

Usando ts-morph para simplificar manipulações:

import { Project, SyntaxKind } from 'ts-morph';

const project = new Project();
const sourceFile = project.createSourceFile('temp.ts', `
function oldName() {
  return 42;
}
`);

// Renomear função
sourceFile.getFunction('oldName')?.rename('newName');

// Adicionar import
sourceFile.addImportDeclaration({
  moduleSpecifier: './utils',
  namedImports: ['helper']
});

console.log(sourceFile.getText());

Para criar regras ESLint personalizadas:

// Regra ESLint que proíbe console.log
export const rule = {
  create(context) {
    return {
      CallExpression(node) {
        if (node.callee.type === 'MemberExpression' &&
            node.callee.object.name === 'console' &&
            node.callee.property.name === 'log') {
          context.report({
            node,
            message: 'console.log não é permitido'
          });
        }
      }
    };
  }
};

7. Boas Práticas e Armadilhas Comuns

Performance: Evite percorrer toda a AST quando possível. Use ts.forEachChild com early exit:

function findFirstFunction(node: ts.Node): ts.FunctionDeclaration | undefined {
  let found: ts.FunctionDeclaration | undefined;
  ts.forEachChild(node, child => {
    if (found) return; // early exit
    if (ts.isFunctionDeclaration(child)) {
      found = child;
    } else {
      found = findFirstFunction(child);
    }
  });
  return found;
}

Preservação de formatação: Use ts.createPrinter com ts.PrinterOptions para manter espaçamento:

const printer = ts.createPrinter({
  newLine: ts.NewLineKind.LineFeed,
  removeComments: false
});

Limitações: A AST não resolve tipos ou escopos. Para isso, use TypeChecker. Lembre-se que modificações na AST podem quebrar referências.

Debugging: Visualize a AST com ts.SyntaxKind e use ts.createPrinter para ver o resultado das transformações.

function debugAST(node: ts.Node, indent: string = '') {
  console.log(`${indent}${ts.SyntaxKind[node.kind]} [${node.getStart()}-${node.getEnd()}]`);
  ts.forEachChild(node, child => debugAST(child, indent + '  '));
}

Referências