Nous écrivons un transformateur personnalisé AST sur TypeScript

L'équipe TestMace est de retour avec vous. Cette fois, nous publions une traduction d'un article sur la conversion de code TypeScript à l'aide du compilateur. Bonne lecture!


Présentation


Ceci est mon premier article, et j'aimerais y montrer une solution à un problème en utilisant l'API du compilateur TypeScript. Pour trouver cette solution, j'ai fouillé dans de nombreux blogs pendant longtemps et j'ai digéré les réponses sur StackOverflow, donc pour vous protéger du même sort, je vais partager tout ce que j'ai appris sur une boîte à outils aussi puissante mais mal documentée.


Concepts clés


Les bases de l'API du compilateur TypeScript (terminologie de l'analyseur, API de transformation, architecture en couches), arbre de syntaxe abstraite (AST), modèle de conception des visiteurs, génération de code.


Petite recommandation


Si c'est la première fois que vous entendez parler du concept AST, je vous recommande fortement de lire cet article de @Vaidehi Joshi . Toute sa série d'articles de basecs est super, vous allez l'adorer.


Description de la tâche


Chez Avero, nous utilisons GraphQL et souhaitons ajouter une sécurité de type dans les résolveurs. Une fois que j'ai rencontré graphqlgen , j'ai pu résoudre de nombreux problèmes concernant le concept de modèles dans GraphQL. Je ne m'attarderai pas sur cette question ici - pour cela, je prévois d'écrire un article séparé. En bref, les modèles décrivent les valeurs de retour des résolveurs, et dans graphqlgen ces modèles communiquent avec les interfaces via une sorte de configuration (fichier YAML ou TypeScript avec déclaration de type).


Pendant le fonctionnement, nous exécutons des microservices gRPC , et GQL sert principalement de façade. Nous avons déjà publié des interfaces TypeScript qui sont conformes aux contrats de prototypage , et je voulais utiliser ces types comme modèles, mais j'ai rencontré quelques problèmes causés par la prise en charge de l'exportation de types et sous la forme dans laquelle la description de nos interfaces a été implémentée (empilement d'espaces de noms, un grand nombre de liens).


Selon les règles de bon ton pour travailler avec du code source ouvert, ma première étape a été d'affiner ce qui a déjà été fait dans le référentiel graphqlgen et ainsi apporter ma contribution significative. Pour implémenter le mécanisme d'introspection, graphqlgen utilise l' analyseur @ babel / parser pour lire un fichier et collecter des informations sur les noms et déclarations d'interface (champs d'interface).


Chaque fois que je dois faire quelque chose avec AST, j'ouvre d'abord astexplorer.net , puis je commence à jouer. Cet outil vous permet d'analyser l'AST créé par divers analyseurs, y compris babel / parser et l'analyseur du compilateur TypeScript. Avec astexplorer.net, vous pouvez visualiser les structures de données avec lesquelles vous devez travailler et vous familiariser avec les types de nœuds AST de chaque analyseur.


Jetez un œil à l'exemple du fichier de données source et de l'AST créé sur sa base à l'aide de 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" } ] } 

La racine de l'arborescence (un nœud de type Program ) contient deux opérateurs dans son corps - ImportDeclaration et ExportNamedDeclaration .


Dans ImportDeclaration, nous sommes particulièrement intéressés par deux propriétés - source et spécificateurs , qui contiennent des informations sur le texte source. Par exemple, dans notre cas, la valeur de la source est égale à my_company_protos . Il est impossible de comprendre par cette valeur s'il s'agit d'un chemin relatif vers un fichier ou d'un lien vers un module externe. C'est exactement ce que fait l'analyseur.


De même, les informations source sont contenues dans ExportNamedDeclaration . Les espaces de noms ne font que compliquer cette structure, en y ajoutant une imbrication arbitraire, à la suite de quoi de plus en plus d' Identificateurs QualifiedTypeIdentifiers apparaissent. C'est une autre tâche que nous devons résoudre dans le cadre de l'approche choisie avec l'analyseur.


Mais je n'ai même pas encore atteint la résolution des types à partir des importations! Étant donné que l'analyseur et l'AST fournissent par défaut une quantité limitée d'informations sur le texte source, puis pour ajouter ces informations à l'arborescence finale, il est nécessaire d'analyser tous les fichiers importés. Mais chacun de ces fichiers peut avoir ses propres importations!


Il semble qu'en résolvant les tâches avec l'aide de l'analyseur, nous obtenons trop de code ... Prenons un peu de recul et réfléchissons à nouveau.


Les importations ne sont pas importantes pour nous, tout comme la structure des fichiers n'est pas importante. Nous voulons pouvoir activer toutes les propriétés du type protos.user.User et les incorporer au lieu d'utiliser des références d'importation. Et où obtenir les informations de type nécessaires pour créer un nouveau fichier?


Typechecker


Puisque nous avons constaté que la solution avec l'analyseur n'est pas appropriée pour obtenir des informations sur les types d'interfaces importées, examinons le processus de compilation de TypeScript et essayons de trouver une autre issue.


Voici ce qui me vient immédiatement à l'esprit:


TypeChecker est le fondement du système de types TypeScript, et il peut être créé à partir d'une instance de Program. Il est responsable de l'interaction des caractères de divers fichiers entre eux, de la définition des types de caractères et de la vérification sémantique (par exemple, la détection des erreurs).
La première chose que TypeChecker fait est de rassembler tous les caractères de différents fichiers source dans une seule vue, puis de créer une seule table de caractères, en fusionnant les mêmes caractères (par exemple, les espaces de noms trouvés dans plusieurs fichiers différents).
Après avoir initialisé l'état initial, TypeChecker est prêt à répondre à toutes les questions sur le programme. Ces questions peuvent inclure:
Quel symbole correspond à ce nœud?
De quel type de symbole s'agit-il?
Quels caractères sont visibles dans cette partie de l'AST?
Quelles signatures sont disponibles pour déclarer une fonction?
Quelles erreurs doivent être générées pour ce fichier?

TypeChecker est exactement ce dont nous avions besoin! Ayant accès à la table des symboles et à l'API, nous pouvons répondre aux deux premières questions: Quel symbole correspond à ce nœud? De quel type de symbole s'agit-il? En fusionnant tous les caractères courants, TypeChecker sera même en mesure de résoudre le problème d'empilement des espaces de noms, mentionné plus tôt!


Alors, comment obtenez-vous cette API?


Voici un exemple que j'ai pu trouver sur le net. Il montre que TypeChecker est accessible via la méthode d'instance Program. Il a deux méthodes intéressantes - checker.getSymbolAtLocation et checker.getTypeOfSymbolAtLocation , qui ressemblent beaucoup à ce que nous recherchons.


Commençons à travailler sur le code.


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 

Nous voulons seulement déclarer un alias de type, donc nous réécrivons un peu le code:


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

TypeScript offre une protection pour chaque type de nœud, avec lequel vous pouvez trouver le type exact de nœud:



Revenons maintenant aux deux questions posées précédemment: quel symbole correspond à ce nœud? De quel type de symbole s'agit-il?


Ainsi, nous avons obtenu les noms entrés par les déclarations d'interface d'alias de type en interagissant avec la table de caractères TypeChecker . Bien que nous soyons encore au tout début du voyage, mais c'est une bonne position de départ du point de vue de l' introspection .


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

Pensons maintenant à la génération de code .


API de transformation


Comme indiqué précédemment, notre objectif est d'analyser et d'introspection le fichier source TypeScript et de créer un nouveau fichier. La conversion AST -> AST est si souvent utilisée que l'équipe TypeScript a même pensé à une API pour créer des transformateurs personnalisés !


Avant de passer à la tâche principale, nous allons essayer de créer un simple transformateur. Un merci spécial à James Garbutt pour le modèle original pour lui.


Faisons en sorte que le transformateur change les littéraux numériques en chaînes de caractères.


nombre-transformateur.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 partie la plus importante est les VisitorResult Visitor et VisitorResult :


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

L'objectif principal lors de la création d'un transformateur est d'écrire Visitor . Logiquement, il est nécessaire d'implémenter une traversée récursive de chaque nœud AST et de renvoyer un résultat VisitResult (un, plusieurs ou zéro nœuds AST). Vous pouvez configurer l'onduleur de sorte que seuls les nœuds sélectionnés répondent à la modification.


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

Ici, vous pouvez voir avec quels nœuds nous travaillerons.


Le visiteur doit effectuer deux actions principales:


  1. Remplacement de TypeAliasDeclarations par InterfaceDeclarations
  2. Conversion de TypeReferences en TypeLiterals

Solution


Voici à quoi ressemble le code visiteur:


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

J'aime l'apparence de ma solution. Il incarne la puissance de bonnes abstractions, un compilateur intelligent, des outils de développement utiles (autocomplétion VSCode, AST explorer, etc.) et des éléments d'expérience d'autres développeurs qualifiés. Son code source complet avec mises à jour peut être trouvé ici . Je ne sais pas à quel point ce sera utile pour des cas plus généraux, autres que mon affaire privée. Je voulais juste montrer les capacités de la boîte à outils du compilateur TypeScript, et aussi mettre mes réflexions sur papier pour résoudre un problème non standard qui me dérangeait depuis longtemps.


J'espère que mon exemple aidera quelqu'un à simplifier sa vie. Si vous ne comprenez pas parfaitement le sujet de l'AST, des compilateurs et des transformations, suivez les liens vers les ressources et modèles tiers que j'ai fournis, ils devraient vous aider. J'ai dû passer beaucoup de temps à étudier ces informations afin de trouver enfin une solution. Mes premières tentatives de référentiels Github privés, dont 45 // @ts-ignores et assert s, m'ont fait rougir de honte.


Ressources qui m'ont aidé:


Microsoft / TypeScript


Création d'un transformateur TypeScript


Les API du compilateur TypeScript revisitées


Explorateur AST

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


All Articles