Ketik inferensi dalam jscodeshift dan TypeScript

Ketik inferensi dalam jscodeshift dan TypeScript


Dimulai dengan versi 6.0, jscodeshift mendukung kerja dengan TypeScript (TS). Dalam proses penulisan kode kode (transformasi), Anda mungkin perlu mengetahui jenis variabel yang tidak memiliki anotasi eksplisit. Sayangnya, jscodeshift tidak menyediakan sarana untuk menyimpulkan tipe dari kotak.


Pertimbangkan sebuah contoh. Misalkan kita ingin menulis transformasi yang menambahkan tipe pengembalian eksplisit untuk fungsi dan metode kelas. Yaitu memiliki di pintu masuk:


function foo(x: number) { return x; } 

Kami ingin mendapatkan output:


 function foo(x: number): number { return x; } 

Sayangnya, dalam kasus umum, solusi untuk masalah seperti itu sangat tidak penting. Berikut ini beberapa contohnya:


 function toString(x: number) { return '' + x; } function toInt(str: string) { return parseInt(str); } function toIntArray(strings: string[]) { return strings.map(Number.parseInt); } class Foo1 { constructor(public x = 0) { } getX() { return this.x; } } class Foo2 { x: number; constructor(x = 0) { this.x = x; } getX() { return this.x; } } function foo1(foo: Foo1) { return foo.getX(); } function foo2(foo: Foo2) { return foo.getX(); } 

Untungnya, masalah tipe inferensi telah diselesaikan di dalam kompiler TS. API kompiler menyediakan cara jenis inferensi yang dapat Anda gunakan untuk menulis transformasi.


Namun, Anda tidak bisa hanya mengambil dan menggunakan kompiler TS dengan mengganti parser jscodeshift. Faktanya adalah bahwa jscodeshift mengharapkan pohon sintaksis abstrak (AST) dari parser eksternal dalam format ESTree . Dan AST kompiler TS tidak.


Tentu saja, seseorang dapat menggunakan kompiler TS tanpa menggunakan jscodeshift, menulis transformasi dari awal. Atau gunakan salah satu alat yang ada di komunitas TS, misalnya, ts-morph . Tetapi bagi banyak orang, jscodeshift akan menjadi solusi yang lebih akrab dan ekspresif. Karena itu, kami akan mempertimbangkan lebih lanjut bagaimana mengatasi batasan ini.


Idenya adalah untuk mendapatkan pemetaan dari AST parser jscodeshift (selanjutnya ESTree) ke AST kompiler TS (selanjutnya TSTree), dan kemudian menggunakan alat inferensi tipe kompiler TS. Selanjutnya, kami akan mempertimbangkan dua cara untuk mengimplementasikan ide ini.


Tampilan Menggunakan Nomor Baris dan Kolom


Metode pertama menggunakan nomor baris dan kolom (posisi) node untuk menemukan pemetaan dari TSTree ke ESTree. Terlepas dari kenyataan bahwa dalam kasus umum posisi node mungkin tidak bersamaan, hampir selalu mungkin untuk menemukan tampilan yang diinginkan dalam setiap kasus tertentu.


Jadi, mari kita menulis transformasi yang akan melakukan tugas menambahkan anotasi eksplisit. Biarkan saya mengingatkan Anda, pada output kita harus mendapatkan yang berikut:


 function toString(x: number): number { return '' + x; } function toInt(str: string): number { return parseInt(str); } function toIntArray(strings: string[]): number[] { return strings.map(Number.parseInt); } class Foo1 { constructor(public x = 0) { } getX(): number { return this.x; } } class Foo2 { x: number; constructor(x = 0) { this.x = x; } getX(): number { return this.x; } } function foo1(foo: Foo1): number { return foo.getX(); } function foo2(foo: Foo2): number { return foo.getX(); } 

Pertama, kita perlu membangun TSTree dan mendapatkan typeChecker compiler typeChecker :


 const compilerOptions = { target: ts.ScriptTarget.Latest }; const program = ts.createProgram([path], compilerOptions); const sourceFile = program.getSourceFile(path); const typeChecker = program.getTypeChecker(); 

Selanjutnya, buat pemetaan dari ESTree ke TSTree menggunakan posisi awal. Untuk melakukan ini, kita akan menggunakan Map dua tingkat (level pertama untuk baris, level kedua untuk kolom, hasilnya adalah simpul TSTree):


 const locToTSNodeMap = new Map(); const esTreeNodeToTSNode = ({ loc: { start: { line, column } } }) => locToTSNodeMap.has(line) ? locToTSNodeMap.get(line).get(column) : undefined; (function buildLocToTSNodeMap(node) { const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); const nextLine = line + 1; if (!locToTSNodeMap.has(nextLine)) locToTSNodeMap.set(nextLine, new Map()); locToTSNodeMap.get(nextLine).set(character, node); ts.forEachChild(node, buildLocToTSNodeMap); }(sourceFile)); 

Perlu untuk menyesuaikan nomor baris, seperti di TSTree, nomor baris mulai dari nol, dan di ESTree, dari satu.


Selanjutnya, kita perlu melihat-lihat semua fungsi dan metode kelas, memeriksa jenis kembali dan jika itu null , tambahkan anotasi jenis:


 const ast = j(source); ast .find(j.FunctionDeclaration) .forEach(({ value }) => { if (value.returnType === null) value.returnType = getReturnType(esTreeNodeToTSNode(value)); }); ast .find(j.ClassMethod, { kind: 'method' }) .forEach(({ value }) => { if (value.returnType === null) value.returnType = getReturnType(esTreeNodeToTSNode(value).parent); }); return ast.toSource(); 

Saya harus menyesuaikan kode untuk mendapatkan node metode kelas, karena pada posisi awal node metode di ESTree di TSTree adalah node dari metode identifier (oleh karena itu kami menggunakan parent ).


Akhirnya, kami menulis kode untuk menerima anotasi dari tipe pengembalian:


 function getReturnTypeFromString(typeString) { let ret; j(`function foo(): ${typeString} { }`) .find(j.FunctionDeclaration) .some(({ value: { returnType } }) => ret = returnType); return ret; } function getReturnType(node) { return getReturnTypeFromString( typeChecker.typeToString( typeChecker.getReturnTypeOfSignature( typeChecker.getSignatureFromDeclaration(node) ) ) ); } 

Daftar lengkap:


 import * as ts from 'typescript'; export default function transform({ source, path }, { j }) { const compilerOptions = { target: ts.ScriptTarget.Latest }; const program = ts.createProgram([path], compilerOptions); const sourceFile = program.getSourceFile(path); const typeChecker = program.getTypeChecker(); const locToTSNodeMap = new Map(); const esTreeNodeToTSNode = ({ loc: { start: { line, column } } }) => locToTSNodeMap.has(line) ? locToTSNodeMap.get(line).get(column) : undefined; (function buildLocToTSNodeMap(node) { const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); const nextLine = line + 1; if (!locToTSNodeMap.has(nextLine)) locToTSNodeMap.set(nextLine, new Map()); locToTSNodeMap.get(nextLine).set(character, node); ts.forEachChild(node, buildLocToTSNodeMap); }(sourceFile)); function getReturnTypeFromString(typeString) { let ret; j(`function foo(): ${typeString} { }`) .find(j.FunctionDeclaration) .some(({ value: { returnType } }) => ret = returnType); return ret; } function getReturnType(node) { return getReturnTypeFromString( typeChecker.typeToString( typeChecker.getReturnTypeOfSignature( typeChecker.getSignatureFromDeclaration(node) ) ) ); } const ast = j(source); ast .find(j.FunctionDeclaration) .forEach(({ value }) => { if (value.returnType === null) value.returnType = getReturnType(esTreeNodeToTSNode(value)); }); ast .find(j.ClassMethod, { kind: 'method' }) .forEach(({ value }) => { if (value.returnType === null) value.returnType = getReturnType(esTreeNodeToTSNode(value).parent); }); return ast.toSource(); } export const parser = 'ts'; 

Menggunakan parser typescript-eslint


Seperti yang ditunjukkan di atas, meskipun tampilan menggunakan posisi node berfungsi, itu tidak memberikan hasil yang tepat dan kadang-kadang membutuhkan "penyetelan manual". Solusi yang lebih umum adalah menulis pemetaan eksplisit ESTree node ke TSTree. Ini adalah cara kerja parser proyek typescript-eslint . Kami akan menggunakannya.


Pertama, kita perlu mengganti parser jscodeshift built-in ke parser typescript-eslint . Dalam kasus paling sederhana, kode ini terlihat seperti ini:


 export const parser = { parse(source) { return typescriptEstree.parse(source); } }; 

Namun, kita harus sedikit menyulitkan kode untuk mendapatkan pemetaan node ESTree ke TSTree dan typeChecker . Untuk ini, typescript-eslint menggunakan fungsi parseAndGenerateServices . Agar semuanya berfungsi, kita harus meneruskan path ke file .ts dan path ke file konfigurasi tsconfig.json . Karena tidak ada cara langsung untuk melakukan ini, Anda harus menggunakan variabel global (oh!):


 const parserState = {}; function parseWithServices(j, source, path, projectPath) { parserState.options = { filePath: path, project: projectPath }; return { ast: j(source), services: parserState.services }; } export const parser = { parse(source) { if (parserState.options !== undefined) { const options = parserState.options; delete parserState.options; const { ast, services } = typescriptEstree.parseAndGenerateServices(source, options); parserState.services = services; return ast; } return typescriptEstree.parse(source); } }; 

Setiap kali kita ingin mendapatkan seperangkat alat pengurai typescript-eslint yang diperluas, kita memanggil fungsi parseWithServices , di mana kita melewati parameter yang diperlukan (dalam kasus lain, kita masih menggunakan fungsi j ):


 const { ast, services: { program, esTreeNodeToTSNodeMap } } = parseWithServices(j, source, path, tsConfigPath); const typeChecker = program.getTypeChecker(); const esTreeNodeToTSNode = ({ original }) => esTreeNodeToTSNodeMap.get(original); 

Tinggal menulis kode untuk mem-bypass dan memodifikasi fungsi dan metode kelas:


 ast .find(j.FunctionDeclaration) .forEach(({ value }) => { if (value.returnType === null) value.returnType = getReturnType(esTreeNodeToTSNode(value)); }); ast .find(j.MethodDefinition, { kind: 'method' }) .forEach(({ value }) => { if (value.value.returnType === null) value.value.returnType = getReturnType(esTreeNodeToTSNode(value)); }); return ast.toSource(); 

Perlu dicatat bahwa kita harus mengganti selektor ClassMethod dengan ClassMethod untuk mem-bypass metode kelas (kode akses ke nilai balik metode juga sedikit berubah). Ini adalah spesifik dari parser naskah-skrip eslint. getReturnType fungsi getReturnType identik dengan yang digunakan sebelumnya.


Daftar lengkap:


 import * as typescriptEstree from '@typescript-eslint/typescript-estree'; export default function transform({ source, path }, { j }, { tsConfigPath }) { const { ast, services: { program, esTreeNodeToTSNodeMap } } = parseWithServices(j, source, path, tsConfigPath); const typeChecker = program.getTypeChecker(); const esTreeNodeToTSNode = ({ original }) => esTreeNodeToTSNodeMap.get(original); function getReturnTypeFromString(typeString) { let ret; j(`function foo(): ${typeString} { }`) .find(j.FunctionDeclaration) .some(({ value: { returnType } }) => ret = returnType); return ret; } function getReturnType(node) { return getReturnTypeFromString( typeChecker.typeToString( typeChecker.getReturnTypeOfSignature( typeChecker.getSignatureFromDeclaration(node) ) ) ); } ast .find(j.FunctionDeclaration) .forEach(({ value }) => { if (value.returnType === null) value.returnType = getReturnType(esTreeNodeToTSNode(value)); }); ast .find(j.MethodDefinition, { kind: 'method' }) .forEach(({ value }) => { if (value.value.returnType === null) value.value.returnType = getReturnType(esTreeNodeToTSNode(value)); }); return ast.toSource(); } const parserState = {}; function parseWithServices(j, source, path, projectPath) { parserState.options = { filePath: path, project: projectPath }; return { ast: j(source), services: parserState.services }; } export const parser = { parse(source) { if (parserState.options !== undefined) { const options = parserState.options; delete parserState.options; const { ast, services } = typescriptEstree.parseAndGenerateServices(source, options); parserState.services = services; return ast; } return typescriptEstree.parse(source); } }; 

Pro dan kontra pendekatan


Pendekatan Baris dan Kolom


Pro:


  • Itu tidak perlu mengesampingkan parser built-in jscodeshift.
  • Fleksibilitas mentransfer konfigurasi dan teks sumber (Anda dapat mentransfer file dan baris / objek dalam memori, lihat di bawah).

Cons:


  • Tampilan node berdasarkan posisi tidak akurat dan dalam beberapa kasus memerlukan penyesuaian.

Parser mendekati naskah-eslint


Pro:


  • Pemetaan node yang akurat dari satu AST ke yang lain.

Cons:


  • Struktur AST dari parser typescript-eslint sedikit berbeda dari parser bawaan jscodeshift.
  • Kebutuhan untuk menggunakan file untuk mentransfer konfigurasi dan sumber TS.

Kesimpulan


Pendekatan pertama mudah ditambahkan ke proyek yang ada, karena itu tidak memerlukan redefinisi parser, tetapi pemetaan node AST cenderung membutuhkan penyesuaian.


Keputusan tentang pendekatan kedua sebaiknya dibuat terlebih dahulu, jika tidak, Anda mungkin harus menghabiskan waktu men-debug kode karena struktur AST yang berubah. Di sisi lain, Anda akan memiliki pemetaan penuh dari beberapa node ke yang lain (dan sebaliknya).


PS


Disebutkan di atas bahwa ketika menggunakan parser TS, Anda dapat mentransfer konfigurasi dan sumber teks baik dalam bentuk file dan dalam bentuk objek di memori. Transfer konfigurasi sebagai objek dan transfer teks sumber sebagai file dipertimbangkan dalam contoh. Berikut ini adalah kode fungsi yang memungkinkan Anda membaca konfigurasi dari file:


 class TsDiagnosticError extends Error { constructor(err) { super(Array.isArray(err) ? err.map(e => e.messageText).join('\n') : err.messageText); this.diagnostic = err; } } function tsGetCompilerOptionsFromConfigFile(tsConfigPath, basePath = '.') { const { config, error } = ts.readConfigFile(tsConfigPath, ts.sys.readFile); if (error) throw new TsDiagnosticError(error); const { options, errors } = ts.parseJsonConfigFileContent(config, tsGetCompilerOptionsFromConfigFile.host, basePath); if (errors.length !== 0) throw new TsDiagnosticError(errors); return options; } tsGetCompilerOptionsFromConfigFile.host = { fileExists: ts.sys.fileExists, readFile: ts.sys.readFile, readDirectory: ts.sys.readDirectory, useCaseSensitiveFileNames: true }; 

Dan buat program TS dari baris:


 function tsCreateStringSourceCompilerHost(mockPath, source, compilerOptions, setParentNodes) { const host = ts.createCompilerHost(compilerOptions, setParentNodes); const getSourceFileOriginal = host.getSourceFile.bind(host); const readFileOriginal = host.readFile.bind(host); const fileExistsOriginal = host.fileExists.bind(host); host.getSourceFile = (fileName, languageVersion, onError, shouldCreateNewSourceFile) => { return fileName === mockPath ? ts.createSourceFile(fileName, source, languageVersion) : getSourceFileOriginal(fileName, languageVersion, onError, shouldCreateNewSourceFile); }; host.readFile = (fileName) => { return fileName === mockPath ? source : readFileOriginal(fileName); }; host.fileExists = (fileName) => { return fileName === mockPath ? true : fileExistsOriginal(fileName); }; return host; } function tsCreateStringSourceProgram(source, compilerOptions, mockPath = '_source.ts') { return ts.createProgram([mockPath], compilerOptions, tsCreateStringSourceCompilerHost(mockPath, source, compilerOptions)); } 

Referensi


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


All Articles