Inferência de tipo em jscodeshift e TypeScript

Inferência de tipo em jscodeshift e TypeScript


A partir da versão 6.0, o jscodeshift suporta o trabalho com TypeScript (TS). No processo de gravação do modo de código (transformações), pode ser necessário descobrir o tipo de variável que não possui uma anotação explícita. Infelizmente, o jscodeshift não fornece um meio de inferir tipos da caixa.


Considere um exemplo. Suponha que desejemos escrever uma transformação que adicione um tipo de retorno explícito para funções e métodos de classe. I.e. Tendo na entrada:


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

Queremos obter a saída:


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

Infelizmente, no caso geral, a solução para esse problema é muito trivial. Aqui estão apenas alguns exemplos:


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

Felizmente, o problema de inferência de tipo já foi resolvido dentro do compilador TS. A API do compilador fornece um meio de inferência de tipo que você pode usar para gravar a transformação.


No entanto, você não pode simplesmente usar e usar o compilador TS, substituindo o analisador jscodeshift. O fato é que o jscodeshift espera uma árvore de sintaxe abstrata (AST) de analisadores externos no formato ESTree . E o compilador TS AST não é.


Obviamente, pode-se usar o compilador TS sem usar o jscodeshift, escrevendo uma transformação do zero. Ou use uma das ferramentas que existem na comunidade TS, por exemplo, ts-morph . Mas para muitos, o jscodeshift será uma solução mais familiar e expressiva. Portanto, consideraremos ainda mais como contornar essa limitação.


A idéia é obter o mapeamento do analisador jscodeshift AST (doravante ESTree) para o compilador TS AST (doravante TSTree) e, em seguida, usar a ferramenta de inferência do tipo compilador TS. A seguir, consideraremos duas maneiras de implementar essa ideia.


Exibir usando números de linhas e colunas


O primeiro método usa números de linha e coluna (posições) de nós para encontrar o mapeamento de TSTree para ESTree. Apesar do fato de que, no caso geral, as posições dos nós podem não coincidir, é quase sempre possível encontrar a exibição desejada em cada caso específico.


Então, vamos escrever uma transformação que executará a tarefa de adicionar anotações explícitas. Deixe-me lembrá-lo, na saída, devemos obter o seguinte:


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

Primeiro, precisamos criar um TSTree e obter o tipo de compilador typeChecker :


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

Em seguida, crie um mapeamento de ESTree para TSTree usando a posição inicial. Para fazer isso, usaremos um Map dois níveis (o primeiro nível é para linhas, o segundo nível é para colunas, o resultado é um nó 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)); 

Você deve ajustar o número da linha, porque no TSTree, os números de linha começam em zero e no ESTree, em um.


Em seguida, precisamos percorrer todas as funções e métodos das classes, verificar o tipo de retorno e, se for null , adicionar uma anotação de 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(); 

Eu tive que ajustar o código para obter o nó do método de classe, porque na posição inicial do nó do método no ESTree no TSTree é o nó do identificador do método (portanto, usamos parent ).


Por fim, escrevemos o código para receber a anotação do 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) ) ) ); } 

Listagem completa:


 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 o analisador typescript-eslint


Como mostrado acima, embora a exibição usando as posições dos nós funcione, ela não fornece um resultado exato e às vezes requer "ajuste manual". Uma solução mais geral seria escrever um mapeamento explícito dos nós ESTree no TSTree. É assim que o analisador de projeto typescript-eslint funciona. Nós vamos usá-lo.


Primeiro, precisamos substituir o analisador jscodeshift embutido no analisador typescript-eslint . No caso mais simples, o código fica assim:


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

No entanto, teremos que complicar um pouco o código para obter o mapeamento dos nós ESTree para TSTree e typeChecker . Para isso, typescript-eslint usa a função parseAndGenerateServices . Para que tudo funcione, devemos passar o caminho para o arquivo .ts e o caminho para o arquivo de configuração tsconfig.json . Como não há maneira direta de fazer isso, você deve usar a variável 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 obter um conjunto estendido de ferramentas do analisador typescript-eslint, chamamos a função parseWithServices para a qual passamos os parâmetros necessários (em outros casos, ainda usamos a função j ):


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

Resta apenas escrever código para ignorar e modificar as funções e métodos das 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(); 

Deve-se notar que tivemos que substituir o seletor ClassMethod por ClassMethod para ignorar os métodos de classe (o código de acesso ao valor de retorno do método também mudou um pouco). Estas são as especificidades do analisador typescript-eslint. O getReturnType função getReturnType é idêntico ao usado anteriormente.


Listagem completa:


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

Prós e contras das abordagens


Abordagem de linhas e colunas


Prós:


  • Não requer a substituição do analisador interno jscodeshift.
  • Flexibilidade de transferência de textos de configuração e de origem (você pode transferir arquivos e linhas / objetos na memória, veja abaixo).

Contras:


  • A exibição dos nós por posição é imprecisa e, em alguns casos, requer ajuste.

Abordagem do analisador typescript-eslint


Prós:


  • Mapeamento preciso de nós de um AST para outro.

Contras:


  • A estrutura AST do analisador typescript-eslint é um pouco diferente do analisador interno jscodeshift.
  • A necessidade de usar arquivos para transferir a configuração e a origem do TS.

Conclusão


É fácil adicionar a primeira abordagem aos projetos existentes, como ele não requer redefinição de analisador, mas é provável que o mapeamento de nós AST exija ajuste.


A decisão sobre a segunda abordagem é melhor tomada com antecedência, caso contrário, talvez você precise gastar tempo depurando o código devido à estrutura AST alterada. Por outro lado, você terá um mapeamento completo de alguns nós para outros (e vice-versa).


PS


Foi mencionado acima que, ao usar o analisador TS, é possível transferir configurações e textos de origem na forma de arquivos e na forma de objetos na memória. A transferência da configuração como um objeto e a transferência do texto de origem como um arquivo foram consideradas no exemplo. A seguir, é apresentado um código de funções que permite ler a configuração de um arquivo:


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

E crie um programa TS a partir da linha:


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

Referências


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


All Articles