Escrevemos o transformador personalizado AST no TypeScript

A equipe do TestMace está de volta com você. Desta vez, estamos publicando uma tradução de um artigo sobre conversão de código TypeScript usando o compilador. Boa leitura!


1. Introdução


Este é o meu primeiro post e gostaria de mostrar uma solução para um problema usando a API do compilador TypeScript. Para encontrar essa solução, entrei em vários blogs por um longo tempo e digeri as respostas no StackOverflow. Para protegê-lo do mesmo destino, compartilharei tudo o que aprendi sobre uma caixa de ferramentas tão poderosa, mas mal documentada.


Conceitos chave


Noções básicas da API do compilador TypeScript (terminologia do analisador, API de transformação, arquitetura em camadas), árvore de sintaxe abstrata (AST), padrão de design do visitante, geração de código.


Pequena recomendação


Se esta é sua primeira vez que ouve falar sobre o conceito AST, recomendo a leitura deste artigo por @Vaidehi Joshi . Toda a sua série de artigos da basecs saiu ótima, você vai adorar.


Descrição da tarefa


Na Avero, usamos o GraphQL e gostaríamos de adicionar segurança de tipo nos resolvedores. Uma vez me deparei com o graphqlgen e com ele pude resolver muitos problemas relacionados ao conceito de modelos no GraphQL. Não vou me aprofundar nesta questão aqui - por isso pretendo escrever um artigo separado. Em resumo, os modelos descrevem os valores de retorno dos resolvedores e, em graphqlgen, esses modelos se comunicam com as interfaces por meio de um tipo de configuração (arquivo YAML ou TypeScript com declaração de tipo).


Durante a operação, executamos microsserviços gRPC , e o GQL geralmente serve como fachada. Já publicamos interfaces TypeScript que estão de acordo com os contratos proto , e eu queria usar esses tipos como modelos, mas me deparei com alguns problemas causados ​​pelo suporte à exportação de tipos e pela maneira como a descrição de nossas interfaces é implementada (acumulando namespaces, um grande número de links).


De acordo com as regras de bom gosto para trabalhar com código-fonte aberto, meu primeiro passo foi refinar o que já foi feito no repositório graphqlgen e, assim, dar minha contribuição significativa. Para implementar o mecanismo de introspecção, o graphqlgen usa o analisador @ babel / parser para ler um arquivo e coletar informações sobre nomes e declarações de interface (campos de interface).


Toda vez que preciso fazer algo com o AST, abro o astexplorer.net e começo a atuar. Essa ferramenta permite analisar o AST criado por vários analisadores, incluindo babel / parser e o analisador de compilador TypeScript. Com o astexplorer.net, você pode visualizar as estruturas de dados com as quais precisa trabalhar e se familiarizar com os tipos de nós AST de cada analisador.


Veja o exemplo do arquivo de dados de origem e o AST criado com base no babel-parser:


example.ts
import { protos } from 'my_company_protos' export type User = protos.user.User; 

ast.json
 { "type": "Program", "start": 0, "end": 80, "loc": { "start": { "line": 1, "column": 0 }, "end": { "line": 3, "column": 36 } }, "comments": [], "range": [ 0, 80 ], "sourceType": "module", "body": [ { "type": "ImportDeclaration", "start": 0, "end": 42, "loc": { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 42 } }, "specifiers": [ { "type": "ImportSpecifier", "start": 9, "end": 15, "loc": { "start": { "line": 1, "column": 9 }, "end": { "line": 1, "column": 15 } }, "imported": { "type": "Identifier", "start": 9, "end": 15, "loc": { "start": { "line": 1, "column": 9 }, "end": { "line": 1, "column": 15 }, "identifierName": "protos" }, "name": "protos", "range": [ 9, 15 ], "_babelType": "Identifier" }, "importKind": null, "local": { "type": "Identifier", "start": 9, "end": 15, "loc": { "start": { "line": 1, "column": 9 }, "end": { "line": 1, "column": 15 }, "identifierName": "protos" }, "name": "protos", "range": [ 9, 15 ], "_babelType": "Identifier" }, "range": [ 9, 15 ], "_babelType": "ImportSpecifier" } ], "importKind": "value", "source": { "type": "Literal", "start": 23, "end": 42, "loc": { "start": { "line": 1, "column": 23 }, "end": { "line": 1, "column": 42 } }, "extra": { "rawValue": "my_company_protos", "raw": "'my_company_protos'" }, "value": "my_company_protos", "range": [ 23, 42 ], "_babelType": "StringLiteral", "raw": "'my_company_protos'" }, "range": [ 0, 42 ], "_babelType": "ImportDeclaration" }, { "type": "ExportNamedDeclaration", "start": 44, "end": 80, "loc": { "start": { "line": 3, "column": 0 }, "end": { "line": 3, "column": 36 } }, "specifiers": [], "source": null, "exportKind": "type", "declaration": { "type": "TypeAlias", "start": 51, "end": 80, "loc": { "start": { "line": 3, "column": 7 }, "end": { "line": 3, "column": 36 } }, "id": { "type": "Identifier", "start": 56, "end": 60, "loc": { "start": { "line": 3, "column": 12 }, "end": { "line": 3, "column": 16 }, "identifierName": "User" }, "name": "User", "range": [ 56, 60 ], "_babelType": "Identifier" }, "typeParameters": null, "right": { "type": "GenericTypeAnnotation", "start": 63, "end": 79, "loc": { "start": { "line": 3, "column": 19 }, "end": { "line": 3, "column": 35 } }, "typeParameters": null, "id": { "type": "QualifiedTypeIdentifier", "start": 63, "end": 79, "loc": { "start": { "line": 3, "column": 19 }, "end": { "line": 3, "column": 35 } }, "qualification": { "type": "QualifiedTypeIdentifier", "start": 63, "end": 74, "loc": { "start": { "line": 3, "column": 19 }, "end": { "line": 3, "column": 30 } }, "qualification": { "type": "Identifier", "start": 63, "end": 69, "loc": { "start": { "line": 3, "column": 19 }, "end": { "line": 3, "column": 25 }, "identifierName": "protos" }, "name": "protos", "range": [ 63, 69 ], "_babelType": "Identifier" }, "range": [ 63, 74 ], "_babelType": "QualifiedTypeIdentifier" }, "range": [ 63, 79 ], "_babelType": "QualifiedTypeIdentifier" }, "range": [ 63, 79 ], "_babelType": "GenericTypeAnnotation" }, "range": [ 51, 80 ], "_babelType": "TypeAlias" }, "range": [ 44, 80 ], "_babelType": "ExportNamedDeclaration" } ] } 

A raiz da árvore (um nó do tipo Programa ) contém dois operadores em seu corpo - ImportDeclaration e ExportNamedDeclaration .


Em ImportDeclaration, estamos particularmente interessados ​​em duas propriedades - fonte e especificadores , que contêm informações sobre o texto fonte. Por exemplo, no nosso caso, o valor da fonte é igual a my_company_protos . É impossível entender por esse valor se esse é um caminho relativo para um arquivo ou um link para um módulo externo. É exatamente isso que o analisador faz.


Da mesma forma, as informações de origem estão contidas em ExportNamedDeclaration . Os namespaces apenas complicam essa estrutura, adicionando aninhamento arbitrário a ela, como resultado do qual aparecem cada vez mais QualifiedTypeIdentifiers . Essa é outra tarefa que precisamos resolver no quadro da abordagem escolhida com o analisador.


Mas ainda não cheguei à resolução de tipos de importações! Como o analisador e o AST, por padrão, fornecem uma quantidade limitada de informações sobre o texto de origem, para adicionar essas informações à árvore final, é necessário analisar todos os arquivos importados. Mas cada um desses arquivos pode ter suas próprias importações!


Parece que resolvendo as tarefas com a ajuda do analisador, temos muito código ... Vamos dar um passo atrás e pensar novamente.


As importações não são importantes para nós, assim como a estrutura do arquivo não é importante. Queremos habilitar todas as propriedades do tipo protos.user.User e incorporá-las em vez de usar referências de importação. E onde obter as informações de tipo necessárias para criar um novo arquivo?


Typechecker


Como descobrimos que a solução com o analisador não é adequada para obter informações sobre os tipos de interfaces importadas, vejamos o processo de compilação do TypeScript e tentamos encontrar outra saída.


Aqui está o que imediatamente vem à mente:


TypeChecker é a base do sistema de tipos TypeScript e pode ser criado a partir de uma instância do Program. Ele é responsável pela interação de caracteres de vários arquivos entre si, definindo tipos de caracteres e realizando verificação semântica (por exemplo, detecção de erros).
A primeira coisa que o TypeChecker faz é coletar todos os caracteres de diferentes arquivos de origem em uma única exibição e criar uma tabela de caracteres única, mesclando os mesmos caracteres (por exemplo, espaços de nome encontrados em vários arquivos diferentes).
Após inicializar o estado inicial, o TypeChecker está pronto para fornecer respostas a quaisquer perguntas sobre o programa. Essas perguntas podem incluir:
Qual símbolo corresponde a este nó?
Que tipo de símbolo é esse?
Quais caracteres são visíveis nesta parte do AST?
Quais assinaturas estão disponíveis para declarar uma função?
Quais erros devem ser gerados para este arquivo?

TypeChecker é exatamente o que precisávamos! Tendo acesso à tabela de símbolos e à API, podemos responder às duas primeiras perguntas: Qual símbolo corresponde a este nó? Que tipo de símbolo é esse? Ao mesclar todos os caracteres comuns, o TypeChecker poderá resolver o problema com a acumulação de namespaces, mencionada anteriormente!


Então, como você chega a essa API?


Aqui está um exemplo que eu poderia encontrar na rede. Ele mostra que TypeChecker pode ser acessado através do método de instância do programa. Ele tem dois métodos interessantes - checker.getSymbolAtLocation e checker.getTypeOfSymbolAtLocation , que se parecem muito com o que estamos procurando.


Vamos começar a trabalhar no código.


models.ts
 import { protos } from './my_company_protos' export type User = protos.user.User; 

my_company_protos.ts
 export namespace protos { export namespace user { export interface User { username: string; info: protos.Info.User; } } export namespace Info { export interface User { name: protos.Info.Name; } export interface Name { firstName: string; lastName: string; } } } 

ts-alias.ts
 import ts from "typescript"; // hardcode our input file const filePath = "./src/models.ts"; // create a program instance, which is a collection of source files // in this case we only have one source file const program = ts.createProgram([filePath], {}); // pull off the typechecker instance from our program const checker = program.getTypeChecker(); // get our models.ts source file AST const source = program.getSourceFile(filePath); // create TS printer instance which gives us utilities to pretty print our final AST const printer = ts.createPrinter(); // helper to give us Node string type given kind const syntaxToKind = (kind: ts.Node["kind"]) => { return ts.SyntaxKind[kind]; }; // visit each node in the root AST and log its kind ts.forEachChild(source, node => { console.log(syntaxToKind(node.kind)); }); 

 $ ts-node ./src/ts-alias.ts prints ImportDeclaration TypeAliasDeclaration EndOfFileToken 

Como estamos interessados ​​apenas em declarar um alias de tipo, reescrevemos o código um pouco:


kind-printer.ts
 ts.forEachChild(source, node => { if (ts.isTypeAliasDeclaration(node)) { console.log(node.kind); } }) // prints TypeAliasDeclaration 

O TypeScript fornece proteção para cada tipo de nó, com o qual você pode descobrir o tipo exato de nó:



Agora, voltando às duas perguntas que foram feitas anteriormente: Qual símbolo corresponde a este nó? Que tipo de símbolo é esse?


Portanto, obtivemos os nomes digitados pelas declarações da interface de alias de tipo interagindo com a tabela de caracteres TypeChecker . Enquanto ainda estamos no começo da jornada, essa é uma boa posição inicial do ponto de vista da introspecção .


checker-example.ts
 ts.forEachChild(source, node => { if (ts.isTypeAliasDeclaration(node)) { const symbol = checker.getSymbolAtLocation(node.name); const type = checker.getDeclaredTypeOfSymbol(symbol); const properties = checker.getPropertiesOfType(type); properties.forEach(declaration => { console.log(declaration.name); // prints username, info }); } }); 

Agora vamos pensar na geração de código .


API de transformação


Como afirmado anteriormente, nosso objetivo é analisar e examinar o arquivo de origem TypeScript e criar um novo arquivo. A conversão AST -> AST é tão frequentemente usada que a equipe do TypeScript pensou em uma API para criar transformadores personalizados !


Antes de prosseguir para a tarefa principal, tentaremos criar um transformador simples. Agradecimentos especiais a James Garbutt pelo modelo original para ele.


Vamos fazer com que o transformador mude literais numéricos para caracteres de string.


number-transformer.ts
 const source = ` const two = 2; const four = 4; `; function numberTransformer<T extends ts.Node>(): ts.TransformerFactory<T> { return context => { const visit: ts.Visitor = node => { if (ts.isNumericLiteral(node)) { return ts.createStringLiteral(node.text); } return ts.visitEachChild(node, child => visit(child), context); }; return node => ts.visitNode(node, visit); }; } let result = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS }, transformers: { before: [numberTransformer()] } }); console.log(result.outputText); /* var two = "2"; var four = "4"; 

A parte mais importante disso são as VisitorResult Visitor e VisitorResult :


 type Visitor = (node: Node) => VisitResult<Node>; type VisitResult<T extends Node> = T | T[] | undefined; 

O objetivo principal ao criar um transformador é escrever Visitor . Logicamente, é necessário implementar uma passagem recursiva de cada nó AST e retornar um resultado do VisitResult (um, vários ou zero nós AST). Você pode configurar o inversor para que apenas os nós selecionados respondam à alteração.


input-output.ts
 // input export namespace protos { // ModuleDeclaration export namespace user { // ModuleDeclaration // Module Block export interface User { // InterfaceDeclaration username: string; // username: string is PropertySignature info: protos.Info.User; // TypeReference } } export namespace Info { export interface User { name: protos.Info.Name; // TypeReference } export interface Name { firstName: string; lastName: string; } } } // this line is a TypeAliasDeclaration export type User = protos.user.User; // protos.user.User is a TypeReference // output export interface User { username: string; info: { // info: { .. } is a TypeLiteral name: { // name: { .. } is a TypeLiteral firstName: string; lastName: string; } } } 

Aqui você pode ver com quais nós trabalharemos.


O visitante deve executar duas ações principais:


  1. Substituindo TypeAliasDeclarations por InterfaceDeclarations
  2. Convertendo TypeReferences em TypeLiterals

Solução


É assim que o código do visitante se parece:


aliasTransformer.ts
 import path from 'path'; import ts from 'typescript'; import _ from 'lodash'; import fs from 'fs'; const filePath = path.resolve(_.first(process.argv.slice(2))); const program = ts.createProgram([filePath], {}); const checker = program.getTypeChecker(); const source = program.getSourceFile(filePath); const printer = ts.createPrinter(); const typeAliasToInterfaceTransformer: ts.TransformerFactory<ts.SourceFile> = context => { const visit: ts.Visitor = node => { node = ts.visitEachChild(node, visit, context); /* Convert type references to type literals interface IUser { username: string } type User = IUser <--- IUser is a type reference interface Context { user: User <--- User is a type reference } In both cases we want to convert the type reference to it's primitive literals. We want: interface IUser { username: string } type User = { username: string } interface Context { user: { username: string } } */ if (ts.isTypeReferenceNode(node)) { const symbol = checker.getSymbolAtLocation(node.typeName); const type = checker.getDeclaredTypeOfSymbol(symbol); const declarations = _.flatMap(checker.getPropertiesOfType(type), property => { /* Type references declarations may themselves have type references, so we need to resolve those literals as well */ return _.map(property.declarations, visit); }); return ts.createTypeLiteralNode(declarations.filter(ts.isTypeElement)); } /* Convert type alias to interface declaration interface IUser { username: string } type User = IUser We want to remove all type aliases interface IUser { username: string } interface User { username: string <-- Also need to resolve IUser } */ if (ts.isTypeAliasDeclaration(node)) { const symbol = checker.getSymbolAtLocation(node.name); const type = checker.getDeclaredTypeOfSymbol(symbol); const declarations = _.flatMap(checker.getPropertiesOfType(type), property => { // Resolve type alias to it's literals return _.map(property.declarations, visit); }); // Create interface with fully resolved types return ts.createInterfaceDeclaration( [], [ts.createToken(ts.SyntaxKind.ExportKeyword)], node.name.getText(), [], [], declarations.filter(ts.isTypeElement) ); } // Remove all export declarations if (ts.isImportDeclaration(node)) { return null; } return node; }; return node => ts.visitNode(node, visit); }; // Run source file through our transformer const result = ts.transform(source, [typeAliasToInterfaceTransformer]); // Create our output folder const outputDir = path.resolve(__dirname, '../generated'); if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir); } // Write pretty printed transformed typescript to output directory fs.writeFileSync( path.resolve(__dirname, '../generated/models.ts'), printer.printFile(_.first(result.transformed)) ); 

Eu gosto da aparência da minha solução. Ele incorpora o poder de boas abstrações, um compilador inteligente, ferramentas de desenvolvimento úteis (preenchimento automático VSCode, AST explorer etc.) e bits de experiência de outros desenvolvedores qualificados. Seu código fonte completo com atualizações pode ser encontrado aqui . Não tenho certeza de quão útil será para casos mais gerais, além do meu caso particular. Eu só queria mostrar os recursos do kit de ferramentas do compilador TypeScript e também colocar meus pensamentos no papel para resolver um problema não-padrão que me incomodou por um longo tempo.


Espero que meu exemplo ajude alguém a simplificar suas vidas. Se o tópico AST, compiladores e transformações não for totalmente compreendido por você, siga os links para recursos e modelos de terceiros que forneci, eles devem ajudá-lo. Eu tive que gastar muito tempo estudando essas informações para finalmente encontrar uma solução. Minhas primeiras tentativas em repositórios particulares do Github, incluindo 45 // @ts-ignores e assert s, me fizeram corar de vergonha.


Recursos que me ajudaram:


Microsoft / TypeScript


Criando um transformador TypeScript


APIs do compilador TypeScript revisitadas


AST explorer

Source: https://habr.com/ru/post/pt457770/


All Articles