Kami menulis AST transformator khusus pada TypeScript

Tim TestMace kembali bersama Anda. Kali ini kami menerbitkan terjemahan artikel tentang konversi kode TypeScript menggunakan kompiler. Selamat membaca!


Pendahuluan


Ini adalah posting pertama saya, dan di dalamnya saya ingin menunjukkan solusi untuk satu masalah menggunakan API kompiler TypeScript. Untuk menemukan solusi ini, saya mempelajari banyak blog untuk waktu yang lama dan mencerna jawaban di StackOverflow, jadi untuk melindungi Anda dari nasib yang sama, saya akan membagikan semua yang saya pelajari tentang kotak alat yang sangat kuat namun tidak terdokumentasi dengan baik.


Konsep kunci


Dasar-dasar API kompiler TypeScript (terminologi parser, API transformasi, arsitektur berlapis), pohon sintaksis abstrak (AST), pola desain pengunjung, pembuatan kode.


Rekomendasi kecil


Jika ini adalah pertama kalinya Anda mendengar tentang konsep AST, saya sangat merekomendasikan membaca artikel ini oleh @Vaidehi Joshi . Seluruh seri artikelnya dari basec keluar dengan hebat, Anda akan menyukainya.


Deskripsi tugas


Di Avero, kami menggunakan GraphQL dan ingin menambahkan keamanan tipe di resolvers. Setelah saya menemukan graphqlgen , dan dengan itu saya bisa menyelesaikan banyak masalah mengenai konsep model dalam GraphQL. Saya tidak akan membahas masalah ini di sini - untuk ini saya berencana untuk menulis artikel terpisah. Singkatnya, model menggambarkan nilai-nilai pengembalian resolvers, dan dalam graphqlgen model ini berkomunikasi dengan antarmuka melalui semacam konfigurasi (file YAML atau TypeScript dengan deklarasi tipe).


Selama operasi, kami menjalankan microservices gRPC , dan GQL sebagian besar berfungsi sebagai fasad. Kami telah menerbitkan antarmuka TypeScript yang sesuai dengan kontrak proto , dan saya ingin menggunakan jenis ini sebagai model, tetapi saya mengalami beberapa masalah yang disebabkan oleh dukungan untuk jenis ekspor dan dalam bentuk di mana deskripsi antarmuka kami diimplementasikan (menumpuk ruang nama, sejumlah besar tautan).


Menurut aturan selera yang baik untuk bekerja dengan kode sumber terbuka, langkah pertama saya adalah memperbaiki apa yang telah dilakukan dalam repositori graphqlgen dan dengan demikian memberikan kontribusi yang berarti bagi saya. Untuk menerapkan mekanisme introspeksi, graphqlgen menggunakan parser @ babel / parser untuk membaca file dan mengumpulkan informasi tentang nama antarmuka dan deklarasi (bidang antarmuka).


Setiap kali saya perlu melakukan sesuatu dengan AST, saya membuka astexplorer.net terlebih dahulu , dan kemudian mulai bertindak. Alat ini memungkinkan Anda untuk menganalisis AST yang dibuat oleh berbagai parser, termasuk babel / parser dan parser kompiler TypeScript. Menggunakan astexplorer.net, Anda bisa memvisualisasikan struktur data yang harus Anda kerjakan dan menjadi terbiasa dengan jenis-jenis node AST dari masing-masing pengurai.


Lihatlah contoh file data sumber dan AST, dibuat atas dasar menggunakan 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" } ] } 

Akar pohon (simpul dari jenis Program ) berisi dua operator di dalam tubuhnya - ImportDeclaration dan ExportNamedDeclaration .


Dalam ImportDeclaration, kami sangat tertarik pada dua properti - sumber dan penentu, yang berisi informasi tentang teks sumber. Misalnya, dalam kasus kami, nilai sumber sama dengan my_company_protos . Tidak mungkin untuk memahami dengan nilai ini apakah ini adalah jalur relatif ke file atau tautan ke modul eksternal. Inilah yang dilakukan parser.


Demikian pula, informasi sumber terkandung dalam ExportNamedDeclaration . Namespaces hanya memperumit struktur ini dengan menambahkan bersarang secara sewenang-wenang padanya, menghasilkan semakin banyak QualifiedTypeIdentifiers . Ini adalah tugas lain yang harus kita selesaikan dalam kerangka pendekatan yang dipilih dengan parser.


Tetapi saya bahkan belum mencapai resolusi jenis dari impor! Karena parser dan AST secara default menyediakan sejumlah informasi terbatas tentang teks sumber, maka untuk menambahkan informasi ini ke pohon akhir, perlu untuk mengurai semua file yang diimpor. Tetapi setiap file tersebut dapat memiliki impornya sendiri!


Tampaknya menyelesaikan tugas dengan bantuan parser, kami mendapatkan terlalu banyak kode ... Mari kita mundur dan berpikir lagi.


Impor tidak penting bagi kami, sama seperti struktur file tidak penting. Kami ingin dapat mengaktifkan semua properti dari tipe protos.user.User dan menyematkannya alih-alih menggunakan referensi impor. Dan di mana mendapatkan informasi jenis yang diperlukan untuk membuat file baru?


Typechecker


Karena kami menemukan bahwa solusi dengan parser tidak cocok untuk mendapatkan informasi tentang jenis antarmuka yang diimpor, mari kita lihat proses mengkompilasi TypeScript dan mencoba mencari jalan keluar lain.


Inilah yang langsung terlintas dalam pikiran:


TypeChecker adalah dasar dari sistem tipe TypeScript, dan dapat dibuat dari turunan Program. Dia bertanggung jawab untuk interaksi karakter dari berbagai file satu sama lain, mengatur jenis karakter dan melakukan verifikasi semantik (misalnya, deteksi kesalahan).
Hal pertama yang dilakukan TypeChecker adalah mengumpulkan semua karakter dari file sumber yang berbeda ke dalam satu tampilan, dan kemudian membuat tabel karakter tunggal, menggabungkan karakter yang sama (misalnya, namespaces ditemukan dalam beberapa file berbeda).
Setelah menginisialisasi keadaan awal, TypeChecker siap memberikan jawaban atas pertanyaan apa pun tentang program. Pertanyaan-pertanyaan ini dapat meliputi:
Simbol mana yang sesuai dengan simpul ini?
Jenis simbol apa ini?
Karakter apa yang terlihat di bagian AST ini?
Tanda tangan apa yang tersedia untuk mendeklarasikan suatu fungsi?
Kesalahan apa yang harus dikeluarkan untuk file ini?

TypeChecker adalah persis apa yang kami butuhkan! Memiliki akses ke tabel simbol dan API, kita dapat menjawab dua pertanyaan pertama: Simbol apa yang sesuai dengan simpul ini? Jenis simbol apa ini? Dengan menggabungkan semua karakter umum, TypeChecker bahkan akan dapat menyelesaikan masalah dengan menumpuk ruang nama yang disebutkan sebelumnya!


Jadi bagaimana Anda bisa sampai ke API ini?


Berikut adalah salah satu contoh yang dapat saya temukan di internet. Ini menunjukkan bahwa TypeChecker dapat diakses melalui metode instance Program. Ini memiliki dua metode menarik - checker.getSymbolAtLocation dan checker.getTypeOfSymbolAtLocation , yang terlihat sangat mirip dengan apa yang kita cari.


Mari mulai mengerjakan kodenya.


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

Kami hanya tertarik untuk mendeklarasikan jenis alias, jadi kami menulis ulang sedikit kode:


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

TypeScript memberikan perlindungan untuk setiap jenis simpul, yang dengannya Anda dapat mengetahui jenis simpul yang tepat:



Sekarang kembali ke dua pertanyaan yang diajukan sebelumnya: Simbol mana yang sesuai dengan simpul ini? Jenis simbol apa ini?


Jadi, kami mendapatkan nama-nama yang dimasukkan oleh deklarasi antarmuka alias tipe dengan berinteraksi dengan tabel karakter TypeChecker . Meskipun kita masih berada di awal perjalanan, tetapi ini adalah posisi awal yang baik dari sudut pandang introspeksi .


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

Sekarang mari kita pikirkan tentang pembuatan kode .


API transformasi


Seperti yang dinyatakan sebelumnya, tujuan kami adalah mem-parsing dan mengintrospeksi file sumber TypeScript dan membuat file baru. AST -> Konversi AST sangat sering digunakan sehingga tim TypeScript bahkan memikirkan API untuk membuat transformer khusus !


Sebelum beralih ke tugas utama, kami akan mencoba membuat transformator sederhana. Terima kasih khusus kepada James Garbutt untuk template asli untuknya.


Mari kita buat transformator mengubah literal angka menjadi 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"; 

Bagian terpenting darinya adalah VisitorResult Visitor and VisitorResult :


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

Tujuan utama saat membuat transformator adalah untuk menulis Pengunjung . Secara logis, perlu untuk mengimplementasikan traversal rekursif dari setiap node AST dan mengembalikan hasil VisitResult (satu, beberapa, atau nol node AST). Anda dapat mengkonfigurasi inverter sehingga hanya node yang dipilih yang merespons perubahan.


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

Di sini Anda dapat melihat simpul mana yang akan kami gunakan.


Pengunjung harus melakukan dua tindakan utama:


  1. Mengganti TypeAliasDeclarations dengan InterfaceDeclarations
  2. Mengubah TypeReferences menjadi TypeLiterals

Solusi


Seperti inilah kode Pengunjung:


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

Saya suka bagaimana solusi saya terlihat. Ini mewujudkan kekuatan abstraksi yang baik, kompiler yang cerdas, alat pengembangan yang berguna (VSCode pelengkapan otomatis, penjelajah AST, dll.) Dan sedikit pengalaman dari pengembang terampil lainnya. Kode sumber lengkapnya dengan pembaruan dapat ditemukan di sini . Saya tidak yakin seberapa berguna untuk kasus yang lebih umum, selain pribadi saya. Saya hanya ingin menunjukkan kemampuan toolkit compiler TypeScript, dan juga menuliskan pemikiran saya di atas kertas untuk menyelesaikan masalah non-standar yang lama mengganggu saya.


Saya berharap teladan saya akan membantu seseorang menyederhanakan hidup mereka. Jika topik AST, kompiler, dan transformasi tidak sepenuhnya dipahami oleh Anda, maka klik tautan ke sumber daya dan templat pihak ketiga yang saya berikan, mereka akan membantu Anda. Saya harus menghabiskan banyak waktu mempelajari informasi ini untuk akhirnya menemukan solusi. Upaya pertama saya di repositori pribadi Github, termasuk 45 // @ts-ignores dan tegaskan, membuat saya memerah karena malu.


Sumber daya yang membantu saya:


Microsoft / TypeScript


Membuat Transformer TypeScript


API compiler TypeScript ditinjau kembali


AST explorer

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


All Articles