Inférence de type dans jscodeshift et TypeScript

Inférence de type dans jscodeshift et TypeScript


À partir de la version 6.0, jscodeshift prend en charge l'utilisation de TypeScript (TS). Lors de l'écriture du mode de codage (transformations), vous devrez peut-être trouver le type de variable qui n'a pas d'annotation explicite. Malheureusement, jscodeshift ne fournit pas de moyen pour déduire les types de la boîte.


Prenons un exemple. Supposons que nous voulons écrire une transformation qui ajoute un type de retour explicite pour les fonctions et méthodes de classe. C'est-à-dire avoir à l'entrée:


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

Nous voulons obtenir la sortie:


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

Malheureusement, dans le cas général, la solution à un tel problème est très simple. Voici quelques exemples:


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

Heureusement, le problème d'inférence de type a déjà été résolu dans le compilateur TS. L'API du compilateur fournit un moyen d'inférence de type que vous pouvez utiliser pour écrire la transformation.


Cependant, vous ne pouvez pas simplement prendre et utiliser le compilateur TS en remplaçant l'analyseur jscodeshift. Le fait est que jscodeshift attend un arbre de syntaxe abstraite (AST) des analyseurs externes au format ESTree . Et le compilateur TS AST ne l'est pas.


Bien sûr, on pourrait utiliser le compilateur TS sans utiliser jscodeshift, en écrivant une transformation à partir de zéro. Ou utilisez l'un des outils qui existent dans la communauté TS, par exemple ts-morph . Mais pour beaucoup, jscodeshift sera une solution plus familière et expressive. Par conséquent, nous examinerons plus en détail comment contourner cette limitation.


L'idée est d'obtenir le mappage de l'analyseur jscodeshift AST (ci-après ESTree) vers le compilateur TS AST (ci-après TSTree), puis d'utiliser l'outil d'inférence de type de compilateur TS. Ensuite, nous examinerons deux façons de mettre en œuvre cette idée.


Affichage à l'aide des numéros de ligne et de colonne


La première méthode utilise les numéros de ligne et de colonne (positions) des nœuds pour trouver le mappage de TSTree à ESTree. Malgré le fait que dans le cas général les positions des nœuds peuvent ne pas coïncider, il est presque toujours possible de trouver l'affichage souhaité dans chaque cas spécifique.


Écrivons donc une transformation qui effectuera la tâche d'ajouter des annotations explicites. Permettez-moi de vous rappeler qu'à la sortie, nous devrions obtenir ce qui suit:


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

Tout d'abord, nous devons construire un TSTree et obtenir le compilateur typeChecker :


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

Ensuite, créez un mappage de ESTree à TSTree en utilisant la position de départ. Pour ce faire, nous utiliserons une Map deux niveaux (le premier niveau est pour les lignes, le deuxième niveau est pour les colonnes, le résultat est un nœud 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)); 

Il est nécessaire d'ajuster le numéro de ligne, dans TSTree, les numéros de ligne commencent à zéro et dans ESTree à partir de un.


Ensuite, nous devons faire le tour de toutes les fonctions et méthodes des classes, vérifier le type de retour et s'il est null , ajouter une annotation du type:


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

J'ai dû ajuster le code pour obtenir le nœud de la méthode de classe, car à la position de départ du nœud de méthode dans ESTree dans TSTree se trouve le nœud de l'identificateur de méthode (nous utilisons donc le parent ).


Enfin, nous Ă©crivons le code pour recevoir l'annotation du type de retour:


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

Liste complète:


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

Utilisation de l'analyseur typescript-eslint


Comme indiqué ci-dessus, bien que l'affichage utilisant les positions des nœuds fonctionne, il ne donne pas un résultat exact et nécessite parfois un "réglage manuel". Une solution plus générale consisterait à écrire un mappage explicite des nœuds ESTree sur TSTree. Voici comment fonctionne l'analyseur de projet typescript-eslint . Nous allons l'utiliser.


Tout d'abord, nous devons remplacer l' analyseur jscodeshift intégré par l' analyseur typescript-eslint . Dans le cas le plus simple, le code ressemble à ceci:


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

Cependant, nous devrons compliquer un peu le code pour obtenir le mappage des nœuds ESTree sur TSTree et typeChecker . Pour cela, typescript-eslint utilise la fonction parseAndGenerateServices . Pour que tout fonctionne, nous devons lui transmettre le chemin d'accès au fichier .ts et le chemin d'accès au fichier de configuration tsconfig.json . Puisqu'il n'y a pas de moyen direct de le faire, vous devez utiliser la variable globale (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); } }; 

Chaque fois que nous voulons obtenir un ensemble étendu d'outils d'analyse syntaxique-eslint, nous appelons la fonction parseWithServices à laquelle nous transmettons les paramètres nécessaires (dans d'autres cas, nous utilisons toujours la fonction j ):


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

Il ne reste plus qu'à écrire du code pour contourner et modifier les fonctions et méthodes des classes:


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

Il est à noter que nous avons dû remplacer le sélecteur ClassMethod par ClassMethod MethodDefinition de contourner les méthodes de classe (le code d'accès à la valeur de retour de la méthode a également un peu changé). Ce sont les spécificités de l'analyseur typescript-eslint. Le getReturnType fonction getReturnType est identique à celui utilisé précédemment.


Liste complète:


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

Avantages et inconvénients des approches


Approche par lignes et colonnes


Avantages:


  • Il ne nĂ©cessite pas de remplacer l'analyseur intĂ©grĂ© jscodeshift.
  • FlexibilitĂ© de transfert de la configuration et des textes sources (vous pouvez transfĂ©rer Ă  la fois des fichiers et des lignes / objets en mĂ©moire, voir ci-dessous).

Inconvénients:


  • L'affichage des nĹ“uds par position est inexact et nĂ©cessite dans certains cas un rĂ©glage.

Approche parseur typescript-eslint


Avantages:


  • Mappage prĂ©cis des nĹ“uds d'un AST Ă  un autre.

Inconvénients:


  • La structure AST de l'analyseur typescript-eslint est lĂ©gèrement diffĂ©rente de l'analyseur intĂ©grĂ© jscodeshift.
  • La nĂ©cessitĂ© d'utiliser des fichiers pour transfĂ©rer la configuration et la source TS.

Conclusion


La première approche est facile à ajouter aux projets existants, car il ne nécessite pas de redéfinition de l'analyseur, mais la cartographie des nœuds AST est susceptible de nécessiter un ajustement.


Il est préférable de prendre la décision sur la deuxième approche à l'avance, sinon vous devrez peut-être passer du temps à déboguer le code en raison de la structure AST modifiée. D'un autre côté, vous aurez une correspondance complète de certains nœuds avec d'autres (et vice versa).


PS


Il a été mentionné ci-dessus que lorsque vous utilisez l'analyseur TS, vous pouvez transférer des configurations et des textes source à la fois sous forme de fichiers et sous forme d'objets en mémoire. Le transfert de la configuration en tant qu'objet et le transfert du texte source en tant que fichier ont été pris en compte dans l'exemple. Voici un code de fonctions qui vous permet de lire la configuration à partir d'un fichier:


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

Et créez un programme TS à partir de la ligne:


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

Les références


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


All Articles