jscodeshift和TypeScript中的类型推断

jscodeshift和TypeScript中的类型推断


从6.0版开始, jscodeshift支持使用TypeScript(TS)。 在编写代码模式(转换)的过程中,您可能需要找出没有显式注释的变量类型。 不幸的是,jscodeshift并没有提供从框中推断类型的方法。


考虑一个例子。 假设我们要编写一个转换,为类函数和方法添加一个显式的返回类型。 即 在入口处:


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

我们想要获得输出:


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

不幸的是,在一般情况下,解决此类问题的方法非常简单。 这里只是几个例子:


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

幸运的是,类型推断问题已经在TS编译器内部解决。 编译器API提供了一种类型推断方法,可用于编写转换。


但是,您不能只是通过覆盖jscodeshift解析器来使用和使用TS编译器。 事实是jscodeshift期望来自外部解析器的ESTree格式的抽象语法树(AST)。 而TS编译器AST不是。


当然,可以使用TS编译器而不使用jscodeshift,从头开始编写转换。 或使用TS社区中存在的一种工具,例如ts-morph 。 但是对于许多人来说,jscodeshift将是一个更熟悉和更具表现力的解决方案。 因此,我们将进一步考虑如何解决此限制。


这个想法是获取从jscodeshift解析器AST(以下称为ESTree)到TS编译器AST(以下称为TSTree)的映射,然后使用TS编译器类型推断工具。 下面将考虑两种实现该想法的方法。


使用行号和列号显示


第一种方法使用节点的行号和列号(位置)来查找从TSTree到ESTree的映射。 尽管在通常情况下节点的位置可能不一致,但几乎总是可以在每种特定情况下找到所需的显示。


因此,让我们编写一个转换,该转换将执行添加显式注释的任务。 让我提醒您,在输出中,我们应该得到以下信息:


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

首先,我们需要构建一个TSTree并获取typeChecker编译器typeChecker


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

接下来,使用起始位置构建从ESTree到TSTree的映射。 为此,我们将使用两级Map (第一级用于行,第二级用于列,结果是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)); 

有必要调整行号,因为 在TSTree中,行号从零开始,而在ESTree中,行号从一开始。


接下来,我们需要遍历类的所有函数和方法,检查返回类型,如果它为null ,则添加该类型的注释:


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

我必须调整代码以获取类方法节点,因为 TSTree中ESTree中方法节点的开始位置处是方法标识符的节点(因此,我们使用parent )。


最后,我们编写代码以接收返回类型的注释:


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

完整清单:


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

使用typescript-eslint解析器


如上所示,尽管使用节点位置的显示有效,但无法给出准确的结果,有时需要“手动调整”。 一个更通用的解决方案是编写ESTree节点到TSTree的显式映射。 这就是typescript-eslint项目解析器的工作方式。 我们将使用它。


首先,我们需要将内置的jscodeshift 解析器覆盖为typescript -eslint解析器 。 在最简单的情况下,代码如下所示:


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

但是,我们必须稍微复杂一些代码才能获得ESTree节点到TSTree和typeChecker的映射。 为此,typescript-eslint使用parseAndGenerateServices函数。 为了使一切正常,我们必须将路径传递到.ts文件,并将路径传递到tsconfig.json配置文件。 由于没有直接方法可以执行此操作,因此必须使用全局变量(哦!):


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

每次我们想要获得扩展的typescript-eslint解析器工具集时,我们都调用parseWithServices函数,在其中传递必要的参数(在其他情况下,我们仍使用j函数):


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

剩下的只是编写代码来绕过和修改类的功能和方法:


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

应该注意的是, MethodDefinition绕过类方法,我们必须用ClassMethod替换ClassMethod选择器(对方法返回值的访问代码也有所更改)。 这是typescript-eslint解析器的详细信息。 getReturnType函数getReturnType与以前使用的getReturnType相同。


完整清单:


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

方法的利弊


行列法


优点:


  • 它不需要重写jscodeshift内置解析器。
  • 传输配置和源文本的灵活性(您可以传输内存中的文件和行/对象,请参见下文)。

缺点:


  • 按位置显示的节点不准确,在某些情况下需要调整。

解析器方法typescript-eslint


优点:


  • 节点从一个AST到另一个的准确映射。

缺点:


  • typescript-eslint解析器的AST结构与jscodeshift内置解析器略有不同。
  • 需要使用文件来传输TS配置和源。

结论


第一种方法很容易添加到现有项目中,因为 它不需要解析器重新定义,但是AST节点的映射可能需要调整。


关于第二种方法的决定最好事先确定,否则由于更改了AST结构,您可能不得不花时间调试代码。 另一方面,您将具有一些节点到其他节点的完整映射(反之亦然)。


聚苯乙烯


上面提到,使用TS解析器时,您可以将配置和源文本既作为文件又作为内存中的对象进行传输。 在该示例中考虑了将配置作为对象传输以及将源文本作为文件传输。 以下是功能代码,可让您从文件中读取配置:


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

并从以下行创建一个TS程序:


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

参考文献


Source: https://habr.com/ru/post/zh-CN480304/


All Articles