Escribimos transformadores personalizados AST en TypeScript

El equipo de TestMace está de vuelta contigo. Esta vez estamos publicando una traducción de un artículo sobre conversión de código TypeScript usando el compilador. Que tengas una buena lectura!


Introduccion


Esta es mi primera publicación, y en ella me gustaría mostrar una solución a un problema usando la API del compilador TypeScript. Para encontrar esta solución, profundicé en numerosos blogs durante mucho tiempo y digerí las respuestas en StackOverflow, por lo que para protegerte del mismo destino, compartiré todo lo que aprendí sobre una caja de herramientas tan poderosa pero mal documentada.


Conceptos clave


Los conceptos básicos de las API del compilador TypeScript (terminología del analizador, API de transformación, arquitectura en capas), árbol de sintaxis abstracta (AST), patrón de diseño de visitante, generación de código.


Pequeña recomendación


Si es la primera vez que escuchas sobre el concepto AST, te recomiendo leer este artículo de @Vaidehi Joshi . Su serie completa de artículos de basecs salió genial, te encantará.


Descripción de la tarea


En Avero, usamos GraphQL y nos gustaría agregar seguridad de tipos en los resolvers. Una vez me encontré con graphqlgen , y con él pude resolver muchos problemas relacionados con el concepto de modelos en GraphQL. No profundizaré en este tema aquí; para esto planeo escribir un artículo separado. Brevemente, los modelos describen los valores de retorno de los resolvers, y en graphqlgen estos modelos se comunican con las interfaces a través de un tipo de configuración (YAML o TypeScript con declaración de tipo).


Durante la operación, ejecutamos microservicios gRPC , y GQL en su mayor parte sirve como fachada. Ya hemos publicado interfaces TypeScript que están de acuerdo con los contratos proto , y quería usar estos tipos como modelos, pero encontré algunos problemas causados ​​por el soporte para exportar tipos y en la forma en que se implementó la descripción de nuestras interfaces (acumulando espacios de nombres, una gran cantidad de enlaces).


De acuerdo con las reglas de buen gusto para trabajar con código fuente abierto, mi primer paso fue refinar lo que ya se ha hecho en el repositorio de graphqlgen y, por lo tanto, hacer mi contribución significativa. Para implementar el mecanismo de introspección, graphqlgen utiliza el analizador @ babel / parser para leer un archivo y recopilar información sobre nombres y declaraciones de interfaz (campos de interfaz).


Cada vez que necesito hacer algo con AST, primero abro astexplorer.net y luego empiezo a actuar. Esta herramienta le permite analizar el AST creado por varios analizadores, incluidos babel / parser y el compilador TypeScript. Con astexplorer.net, puede visualizar las estructuras de datos con las que tiene que trabajar y familiarizarse con los tipos de nodos AST de cada analizador.


Eche un vistazo al ejemplo del archivo de datos de origen y el AST creado sobre esta base utilizando babel-parser:


ejemplo.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" } ] } 

La raíz del árbol (un nodo del tipo Programa ) contiene dos operadores en su cuerpo: ImportDeclaration y ExportNamedDeclaration .


En ImportDeclaration, estamos particularmente interesados ​​en dos propiedades: fuente y especificadores , que contienen información sobre el texto fuente. Por ejemplo, en nuestro caso, el valor de la fuente es igual a my_company_protos . Es imposible entender por este valor si se trata de una ruta relativa a un archivo o un enlace a un módulo externo. Esto es exactamente lo que hace el analizador.


Del mismo modo, la información de origen está contenida en ExportNamedDeclaration . Los espacios de nombres solo complican esta estructura al agregarle anidamiento arbitrario, lo que resulta en más y más QualifiedTypeIdentifiers . Esta es otra tarea que tenemos que resolver en el marco del enfoque elegido con el analizador.


¡Pero aún no he llegado a la resolución de tipos de importaciones! Dado que el analizador y el AST proporcionan de forma predeterminada una cantidad limitada de información sobre el texto fuente, para agregar esta información al árbol final, es necesario analizar todos los archivos importados. ¡Pero cada archivo puede tener sus propias importaciones!


Parece que al resolver las tareas con la ayuda del analizador, obtenemos demasiado código ... Retrocedamos y pensemos nuevamente.


Las importaciones no son importantes para nosotros, así como la estructura del archivo no es importante. Queremos poder habilitar todas las propiedades del tipo protos.user.User e incrustarlas en lugar de utilizar referencias de importación. ¿Y dónde obtener la información de tipo necesaria para crear un nuevo archivo?


Typechecker


Como descubrimos que la solución con el analizador no es adecuada para obtener información sobre los tipos de interfaces importadas, veamos el proceso de compilación de TypeScript e intentemos encontrar otra salida.


Esto es lo que viene a la mente de inmediato:


TypeChecker es la base del sistema de tipos TypeScript, y se puede crear desde una instancia del Programa. Es responsable de la interacción de los caracteres de varios archivos entre sí, configurando tipos de caracteres y realizando una verificación semántica (por ejemplo, detección de errores).
Lo primero que hace TypeChecker es recopilar todos los caracteres de diferentes archivos de origen en una vista y luego crear una tabla de caracteres única, fusionando los mismos caracteres (por ejemplo, espacios de nombres encontrados en varios archivos diferentes).
Después de inicializar el estado inicial, TypeChecker está listo para proporcionar respuestas a cualquier pregunta sobre el programa. Estas preguntas pueden incluir:
¿Qué símbolo corresponde a este nodo?
¿Qué tipo de símbolo es este?
¿Qué caracteres son visibles en esta parte del AST?
¿Qué firmas están disponibles para declarar una función?
¿Qué errores se deben generar para este archivo?

¡TypeChecker es exactamente lo que necesitábamos! Al tener acceso a la tabla de símbolos y a la API, podemos responder las dos primeras preguntas: ¿Qué símbolo corresponde a este nodo? ¿Qué tipo de símbolo es este? ¡Al fusionar todos los caracteres comunes, TypeChecker incluso podrá resolver el problema con la acumulación de espacios de nombres que se mencionó anteriormente!


Entonces, ¿cómo llegas a esta API?


Aquí hay un ejemplo que pude encontrar en la red. Muestra que se puede acceder a TypeChecker a través del método de instancia del Programa. Tiene dos métodos interesantes: checker.getSymbolAtLocation y checker.getTypeOfSymbolAtLocation , que se parecen mucho a lo que estamos buscando.


Comencemos a trabajar en el código.


modelos.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 

Solo estamos interesados ​​en declarar un alias de tipo, por lo que reescribimos el código un poco:


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

TypeScript proporciona protección para cada tipo de nodo, con el que puede averiguar el tipo exacto de nodo:



Ahora volvamos a las dos preguntas que se plantearon anteriormente: ¿Qué símbolo corresponde a este nodo? ¿Qué tipo de símbolo es este?


Entonces, obtuvimos los nombres ingresados ​​por las declaraciones de la interfaz de alias de tipo al interactuar con la tabla de caracteres TypeChecker . Aunque todavía estamos al comienzo del viaje, esta es una buena posición de partida desde el punto de vista de la introspección .


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 }); } }); 

Ahora pensemos en la generación de código .


API de transformación


Como se indicó anteriormente, nuestro objetivo es analizar e introspectar el archivo fuente de TypeScript y crear un nuevo archivo. La conversión AST -> AST se usa con tanta frecuencia que el equipo de TypeScript incluso pensó en una API para crear transformadores personalizados .


Antes de pasar a la tarea principal, intentaremos crear un transformador simple. Un agradecimiento especial a James Garbutt por la plantilla original para él.


Hagamos que el transformador cambie los literales numéricos a los de cadena.


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"; 

La parte más importante son las VisitorResult Visitor y VisitorResult :


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

El objetivo principal al crear un transformador es escribir Visitor . Lógicamente, es necesario implementar un recorrido recursivo de cada nodo AST y devolver un resultado VisitResult (uno, varios o cero nodos AST). Puede configurar el inversor para que solo los nodos seleccionados respondan al cambio.


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; } } } 

Aquí puede ver con qué nodos trabajaremos.


El visitante debe realizar dos acciones principales:


  1. Reemplazo de declaraciones de tipo Alias con declaraciones de interfaz
  2. Conversión de TypeReferences a TypeLiterals

Solución


Así es como se ve el código de visitante:


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)) ); 

Me gusta cómo se ve mi solución. Incorpora el poder de buenas abstracciones, un compilador inteligente, herramientas de desarrollo útiles (VSCode de autocompletado, explorador AST, etc.) y un poco de experiencia de otros desarrolladores expertos. Su código fuente completo con actualizaciones se puede encontrar aquí . No estoy seguro de lo útil que será para casos más generales, aparte de mi privado. Solo quería mostrar las capacidades del kit de herramientas del compilador TypeScript, y también poner mis pensamientos en papel para resolver un problema no estándar que me molestó durante mucho tiempo.


Espero que mi ejemplo ayude a alguien a simplificar sus vidas. Si el tema de AST, compiladores y transformaciones no es completamente comprendido por usted, entonces siga los enlaces a recursos y plantillas de terceros que le proporcioné, deberían ayudarlo. Tuve que pasar mucho tiempo estudiando esta información para finalmente encontrar una solución. Mis primeros intentos de repositorios privados de Github, incluyendo 45 // @ts-ignores y afirmaciones, me hicieron sonrojar de vergüenza.


Recursos que me ayudaron:


Microsoft / TypeScript


Crear un transformador de TypeScript


API del compilador de TypeScript revisitada


Explorador AST

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


All Articles