Inferencia de tipos en jscodeshift y TypeScript

Inferencia de tipos en jscodeshift y TypeScript


A partir de la versión 6.0, jscodeshift admite trabajar con TypeScript (TS). En el proceso de escribir el modo de código (transformaciones), es posible que necesite averiguar el tipo de variable que no tiene una anotación explícita. Desafortunadamente, jscodeshift no proporciona un medio para inferir tipos desde el cuadro.


Considera un ejemplo. Supongamos que queremos escribir una transformación que agregue un tipo de retorno explícito para las funciones y métodos de la clase. Es decir teniendo en la entrada:


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

Queremos obtener la salida:


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

Desafortunadamente, en el caso general, la solución a este problema es muy poco trivial. Estos son solo algunos ejemplos:


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

Afortunadamente, el problema de inferencia de tipo ya se ha resuelto dentro del compilador de TS. La API del compilador proporciona un medio de inferencia de tipos que puede usar para escribir la transformación.


Sin embargo, no puede simplemente tomar y usar el compilador TS anulando el analizador jscodeshift. El hecho es que jscodeshift espera un árbol de sintaxis abstracta (AST) de los analizadores externos en el formato ESTree . Y el compilador TS AST no lo es.


Por supuesto, uno podría usar el compilador de TS sin usar jscodeshift, escribiendo una transformación desde cero. O use una de las herramientas que existen en la comunidad TS, por ejemplo, ts-morph . Pero para muchos, jscodeshift será una solución más familiar y expresiva. Por lo tanto, consideraremos más a fondo cómo sortear esta limitación.


La idea es obtener el mapeo del analizador AST jscodeshift (en adelante ESTree) al AST compilador TS (en adelante TSTree), y luego usar la herramienta de inferencia de tipo compilador TS. A continuación, consideraremos dos formas de implementar esta idea.


Mostrar utilizando números de fila y columna


El primer método usa números de fila y columna (posiciones) de nodos para encontrar la asignación de TSTree a ESTree. A pesar de que, en el caso general, las posiciones de los nodos pueden no coincidir, casi siempre es posible encontrar la pantalla deseada en cada caso específico.


Entonces, escribamos una transformación que realice la tarea de agregar anotaciones explícitas. Permítame recordarle que en la salida deberíamos obtener lo siguiente:


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

Primero, necesitamos construir un TSTree y obtener el compilador de typeChecker :


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

A continuación, cree una asignación de ESTree a TSTree utilizando la posición inicial. Para hacer esto, usaremos un Map dos niveles (el primer nivel es para filas, el segundo nivel es para columnas, el resultado es un nodo 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)); 

Es necesario ajustar el número de línea, como en TSTree, los números de línea comienzan desde cero, y en ESTree, desde uno.


A continuación, debemos repasar todas las funciones y métodos de las clases, verificar el tipo de retorno y, si es null , agregar una anotación del tipo:


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

Tuve que ajustar el código para obtener el nodo del método de clase, porque en la posición inicial del nodo del método en ESTree en TSTree se encuentra el nodo del identificador del método (por lo tanto, utilizamos parent ).


Finalmente, escribimos el código para recibir la anotación del tipo de retorno:


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

Listado completo:


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

Usando el analizador de mecanografiado-eslint


Como se muestra arriba, aunque la pantalla que usa las posiciones de los nodos funciona, no da un resultado exacto y a veces requiere "ajuste manual". Una solución más general sería escribir una asignación explícita de nodos ESTree a TSTree. Así es como funciona el analizador de proyectos de mecanografiado-eslint . Lo usaremos


Primero, tenemos que anular el analizador jscodeshift incorporado al analizador de mecanografía-eslint . En el caso más simple, el código se ve así:


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

Sin embargo, tendremos que complicar un poco el código para obtener la asignación de los nodos ESTree a TSTree y typeChecker . Para esto, typecript-eslint usa la función parseAndGenerateServices . Para que todo funcione, debemos pasarle la ruta al archivo .ts y la ruta al archivo de configuración tsconfig.json . Como no hay una forma directa de hacer esto, debe usar la variable 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); } }; 

Cada vez que queremos obtener un conjunto extendido de herramientas de analizador de mecanografía-eslint, llamamos a la función parseWithServices a la que pasamos los parámetros necesarios (en otros casos, todavía usamos la función j ):


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

Solo queda escribir código para omitir y modificar las funciones y métodos de las clases:


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

Cabe señalar que tuvimos que reemplazar el selector ClassMethod con ClassMethod para evitar los métodos de clase (el código de acceso al valor de retorno del método también ha cambiado un poco). Estos son los detalles del analizador de mecanografía-eslint. El getReturnType función getReturnType es idéntico al utilizado anteriormente.


Listado completo:


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

Pros y contras de los enfoques


Enfoque de fila y columna


Pros:


  • No requiere anular el analizador incorporado jscodeshift.
  • Flexibilidad para transferir la configuración y los textos fuente (puede transferir archivos y líneas / objetos en la memoria, ver más abajo).

Contras:


  • La visualización de los nodos por posición es inexacta y, en algunos casos, requiere un ajuste.

Enfoque del analizador typecript-eslint


Pros:


  • Mapeo preciso de nodos de un AST a otro.

Contras:


  • La estructura AST del analizador mecanografiado eslint es ligeramente diferente del analizador incorporado jscodeshift.
  • La necesidad de usar archivos para transferir la configuración y la fuente de TS.

Conclusión


El primer enfoque es fácil de agregar a los proyectos existentes, ya que no requiere redefinición del analizador, pero es probable que la asignación de los nodos AST requiera un ajuste.


La mejor manera de tomar la decisión sobre el segundo enfoque es de antemano, de lo contrario es posible que deba pasar un tiempo depurando el código debido a la estructura AST modificada. Por otro lado, tendrá una asignación completa de algunos nodos a otros (y viceversa).


PS


Se mencionó anteriormente que cuando se usa el analizador TS, puede transferir configuraciones y textos fuente tanto en forma de archivos como en forma de objetos en la memoria. La transferencia de la configuración como un objeto y la transferencia del texto fuente como un archivo se consideraron en el ejemplo. El siguiente es un código de funciones que le permite leer la configuración de un archivo:


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

Y cree un programa TS desde la línea:


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

Referencias


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


All Articles