我们在TypeScript上编写自定义转换器AST

TestMace团队再次与您联系。 这次,我们将发布有关使用编译器进行TypeScript代码转换的文章的翻译。 祝您阅读愉快!


引言


这是我的第一篇文章,其中我想展示使用TypeScript 编译器API解决一个问题的解决方案。 为了找到这种解决方案,我花了很长时间研究了很多博客,并在StackOverflow上消化了答案,因此,为了保护您免受同样的命运,我将分享我所学到的关于这样一个功能强大但文档记录欠佳的工具箱的所有信息。


关键概念


TypeScript编译器API(解析器术语,转换API,分层体系结构),抽象语法树(AST),Visitor设计模式,代码生成的基础。


小推荐


如果这是您第一次了解AST概念,我强烈建议您阅读@Vaidehi Joshi的 这篇文章 。 她来自basecs的整个系列文章都很棒,您一定会喜欢的。


任务说明


在Avero 我们使用GraphQL,并希望在解析器中添加类型安全性。 一旦我遇到了graphqlgen ,就可以解决GraphQL中有关模型概念的许多问题。 我不会在这里研究这个问题-为此,我打算写一篇单独的文章。 简要地说,模型描述了解析器的返回值,在graphqlgen中,这些模型通过一种配置(具有类型声明的YAML或TypeScript)与接口进行通信。


在运行过程中,我们运行gRPC微服务,而GQL在大多数情况下用作基础。 我们已经发布了符合原始合同的 TypeScript接口,我想将这些类型用作模型,但是由于支持导出类型以及实现接口描述的方式(堆积名称空间,链接)。


根据使用开放源代码的良好规则,我的第一步是完善graphqlgen存储库中已完成的工作,从而做出有意义的贡献。 为了实现自省机制,graphqlgen使用@ babel / parser解析器来读取文件并收集有关接口名称和声明(接口字段)的信息。


每当我需要对AST进行操作时,我都会先打开astexplorer.net ,然后开始行动。 该工具允许您分析由各种解析器(包括babel /解析器和TypeScript编译器解析器)创建的AST。 使用astexplorer.net,您可以可视化要使用的数据结构,并熟悉每个解析器的AST节点的类型。


看一下源数据文件和使用babel-parser在其基础上创建的AST的示例:


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

树的根( 程序类型的节点)在其主体中包含两个运算符-ImportDeclarationExportNamedDeclaration


ImportDeclaration中,我们对两个属性特别感兴趣-sourcespecifiers ,它们包含有关源文本的信息。 例如,在我们的例子中,source的值等于my_company_protos 。 通过该值无法理解这是文件的相对路径还是外部模块的链接。 这正是解析器所做的。


同样,源信息包含在ExportNamedDeclaration中 。 命名空间只会通过在结构上添加任意嵌套来使该结构复杂化,从而导致越来越多的QualifiedTypeIdentifiers 。 这是我们必须在使用解析器的所选方法的框架中解决的另一项任务。


但是我还没有达到输入类型的解析度! 假定默认情况下解析器和AST提供有关源文本的信息量有限,则要将此信息添加到最终树中,必须解析所有导入的文件。 但是每个这样的文件都可以有自己的导入!


似乎在解析器的帮助下解决任务,我们得到了太多的代码……让我们退后一步,再三考虑。


导入对我们并不重要,就像文件结构不重要一样。 我们希望能够启用protos.user.User类型的所有属性并嵌入它们,而不是使用导入引用。 在哪里获得创建新文件所需的类型信息?


类型检查器


由于我们发现解析器的解决方案不适合获取有关导入接口类型的信息,因此让我们看一下TypeScript 的编译过程,并尝试寻找另一种出路。


这是立即想到的:


TypeChecker是TypeScript类型系统的基础,可以从Program实例创建它。 他负责各种文件中字符之间的交互,设置字符类型并进行语义验证(例如,错误检测)。
TypeChecker要做的第一件事是将来自不同源文件的所有字符收集到一个视图中,然后创建一个字符表,合并相同的字符(例如,在几个不同文件中找到的名称空间)。
初始化初始状态后,TypeChecker准备提供有关程序的任何问题的答案。 这些问题可能包括:
哪个符号对应于此节点?
这是什么类型的符号?
在AST的此部分中可以看到哪些字符?
哪些签名可用于声明功能?
该文件应输出什么错误?

TypeChecker正是我们需要的! 可以访问符号表和API,我们可以回答前两个问题: 该节点对应什么符号? 这是什么类型的符号? 通过合并所有常见字符, TypeChecker甚至可以解决名称空间堆积的问题,这在前面已经提到过!


那么您如何获得此API?


这是我可以在网上找到的一个示例。 它显示可以通过Program实例方法访问TypeChecker 。 它有两个有趣的方法checker.getSymbolAtLocationchecker.getTypeOfSymbolAtLocation ,它们看起来与我们所寻找的非常相似。


让我们开始编写代码。


模型
 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 

我们只对声明类型别名感兴趣,因此我们将代码重写一下:


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

TypeScript为每种类型的节点提供保护,通过它可以找到确切的节点类型:



现在回到前面提出的两个问题: 哪个符号对应于此节点? 这是什么类型的符号?


因此,通过与TypeChecker字符进行交互,我们获得了由类型别名接口声明输入的名称。 尽管我们仍处于旅程的开始,但是从内省的角度来看,这是一个很好的起点。


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

现在让我们考虑代码生成


转换API


如前所述,我们的目标是解析和内省TypeScript源文件并创建一个新文件。 AST-> AST转换非常常用,TypeScript团队甚至想到了用于创建自定义转换器的API!


在继续执行主要任务之前,我们将尝试创建一个简单的转换器。 特别感谢James Garbutt为他准备的原始模板


让我们使转换器将数字文字转换为字符串。


数字转换器
 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"; 

其中最重要的部分是VisitorVisitorResult


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

创建转换器的主要目的是编写Visitor 。 从逻辑上讲,有必要实现每个AST节点的递归遍历并返回VisitResult结果(一个,几个或零个AST节点)。 您可以配置逆变器,以便只有选定的节点才能响应更改。


输入输出
 // 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; } } } 

在这里,您可以看到我们将使用的节点。


访客必须执行两个主要操作:


  1. InterfaceDeclarations替换TypeAliasDeclarations
  2. TypeReferences转换为TypeLiterals

解决方案


访客代码如下所示:


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

我喜欢我的解决方案。 它体现了良好的抽象,强大的编译器,有用的开发工具(自动完成VSCode,AST资源管理器等)的力量以及其他熟练开发人员的丰富经验。 它的完整源代码以及更新可以在这里找到。 除了我的私人情况,我不确定这对更一般的情况会有多大用处。 我只是想展示TypeScript编译器工具包的功能,并把我的想法写在纸上,以解决困扰我很长时间的非标准问题。


我希望我的榜样可以帮助某人简化生活。 如果您没有完全理解AST,编译器和转换的主题,请访问我提供的第三方资源和模板的链接,它们将为您提供帮助。 为了最终找到解决方案,我不得不花费大量时间研究此信息。 我第一次尝试使用私有的Github存储库,包括45 // @ts-ignores和assert s,使我感到羞耻。


对我有帮助的资源:


Microsoft / TypeScript


创建一个TypeScript变形金刚


重访TypeScript编译器API


AST浏览器

Source: https://habr.com/ru/post/zh-CN457770/


All Articles