如何在使用编译器进行类型检查之前转换 TypeScript 代码 API

How to transform TypeScript code before type-checking using Compiler API

意图

我想使用 TypeScript 的 Compiler API 来试验 TypeScript 代码中的运算符重载。具体来说,我想找到 x + y 的所有实例并将它们变成 op_add(x, y)。但是,我希望语言服务(例如 VS Code 中的 IntelliSense)了解转换并显示正确的类型。

例如在这段代码中:

interface Vector2 { x: number, y: number }
declare function op_add(x: Vector2, y: Vector2): Vector2
declare let a: Vector2, b: Vector2

let c = a + b

我希望当我将鼠标悬停在 c 上时,它会显示 Vector2


计划

为了实现这一目标,我必须:

  1. 创建一个程序,公开与 typescript 相同的 API – 与 ttypescript 公开的方式相同。
  2. 让该程序在将其传递给 typescript
  3. 之前修改源代码
  4. 让 VS Code(或任何编辑器)使用我的包而不是 typescript

处决

我首先创建了一个名为 compile.ts 的简短脚本,该脚本使用编译器 API 将名为 sample.ts 的文件解析为 AST。然后直接修改AST,把Binary(x, PlusToken, y)改成Call(op_add, x, y)。最后,它将修改后的代码打印到控制台,然后尝试发出。仅此一项还不足以实现 IDE 集成,但这是一个好的开始。

compile.ts:

import * as ts from "typescript"
import { possibleChildProperties } from "./visit";

let program = ts.createProgram(['sample.ts'], { target: ts.ScriptTarget.ES5, module: ts.ModuleKind.CommonJS })
let inputFiles = program.getSourceFiles()
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })

let outputCode: string

for (let input of inputFiles) {
  if (input.fileName === 'sample.ts') {
    ts.visitNode(input, visitor) // modifies input's AST
    outputCode = printer.printNode(ts.EmitHint.Unspecified, input, input)
    break
  }
}

console.log(outputCode) // works
let emitResult = program.emit() // fails



function visitor(node: ts.Node): ts.Node {
  if (node.kind === ts.SyntaxKind.BinaryExpression) {
    let expr = node as ts.BinaryExpression

    if (expr.operatorToken.kind === ts.SyntaxKind.PlusToken) {
      return ts.createCall(ts.createIdentifier('op_add'), [], [expr.left, expr.right])
    }
  }

  return visitChildren(node, visitor)
}

function visitChildren(node: ts.Node, visitor: ts.Visitor) {
  for (const prop of possibleChildProperties) {
    if (node[prop] !== undefined) {
      if (Array.isArray(node[prop]))
        node[prop] = node[prop].map(visitor)
      else
        node[prop] = visitor(node[prop])
    }
  }

  return node
}

sample.ts:

let a = { a: 4 }
let b = { b: 3 }
let c = a + b

console.log 输出:

let a = { a: 4 };
let b = { b: 3 };
let c = op_add(a, b);

问题

虽然代码打印机工作正常并输出正确的代码,但调用 program.emit() 会导致未指定的内部错误。这可能意味着我正在以不受支持的方式修改 AST。

/home/m93a/Dokumenty/tsc-experiments/node_modules/typescript/lib/typescript.js:100920
                throw e;
                ^

Error: start < 0
    at createTextSpan (/home/m93a/Dokumenty/tsc-experiments/node_modules/typescript/lib/typescript.js:10559:19)
    at Object.createTextSpanFromBounds (/home/m93a/Dokumenty/tsc-experiments/node_modules/typescript/lib/typescript.js:10568:16)
    at getErrorSpanForNode (/home/m93a/Dokumenty/tsc-experiments/node_modules/typescript/lib/typescript.js:13914:19)
    at createDiagnosticForNodeInSourceFile (/home/m93a/Dokumenty/tsc-experiments/node_modules/typescript/lib/typescript.js:13808:20)
    at Object.createDiagnosticForNode (/home/m93a/Dokumenty/tsc-experiments/node_modules/typescript/lib/typescript.js:13799:16)
    at error (/home/m93a/Dokumenty/tsc-experiments/node_modules/typescript/lib/typescript.js:35703:22)
    at resolveNameHelper (/home/m93a/Dokumenty/tsc-experiments/node_modules/typescript/lib/typescript.js:36602:29)
    at resolveName (/home/m93a/Dokumenty/tsc-experiments/node_modules/typescript/lib/typescript.js:36274:20)
    at getResolvedSymbol (/home/m93a/Dokumenty/tsc-experiments/node_modules/typescript/lib/typescript.js:52602:21)
    at checkIdentifier (/home/m93a/Dokumenty/tsc-experiments/node_modules/typescript/lib/typescript.js:54434:26)

问题

在 运行 类型检查器之前修改程序的 AST 的正确方法是什么? 我知道 AST 最好是只读的,但是标准 ts.visitEachChild 只能在 类型检查后使用。我自己深度克隆节点似乎也不是一个可行的选择,因为没有任何方法可以从代码生成的 AST 中创建 Program


更新

编辑 1:正如@jdaz 所注意到的,我的 sample.ts 缺少 op_add 的声明,这可能会导致问题。我将这一行添加到文件的顶部:

declare function op_add(x: {}, y: {}): string

现在出现了不同的错误——生成文件诊断失败:

/home/m93a/Dokumenty/tsc-experiments/node_modules/typescript/lib/typescript.js:100920
                throw e;
                ^

Error: Debug Failure. Expected -2 >= 0
    at Object.createFileDiagnostic (/home/m93a/Dokumenty/tsc-experiments/node_modules/typescript/lib/typescript.js:17868:18)
    at grammarErrorAtPos (/home/m93a/Dokumenty/tsc-experiments/node_modules/typescript/lib/typescript.js:69444:36)
    at checkGrammarForAtLeastOneTypeArgument (/home/m93a/Dokumenty/tsc-experiments/node_modules/typescript/lib/typescript.js:68771:24)
    at checkGrammarTypeArguments (/home/m93a/Dokumenty/tsc-experiments/node_modules/typescript/lib/typescript.js:68777:17)
    at checkCallExpression (/home/m93a/Dokumenty/tsc-experiments/node_modules/typescript/lib/typescript.js:59255:18)
    at checkExpressionWorker (/home/m93a/Dokumenty/tsc-experiments/node_modules/typescript/lib/typescript.js:61687:28)
    at checkExpression (/home/m93a/Dokumenty/tsc-experiments/node_modules/typescript/lib/typescript.js:61597:38)
    at checkExpressionCached (/home/m93a/Dokumenty/tsc-experiments/node_modules/typescript/lib/typescript.js:61275:38)
    at checkVariableLikeDeclaration (/home/m93a/Dokumenty/tsc-experiments/node_modules/typescript/lib/typescript.js:63983:69)
    at checkVariableDeclaration (/home/m93a/Dokumenty/tsc-experiments/node_modules/typescript/lib/typescript.js:64051:20)

这可能是一种 hacky 方式,但既然您已经有了修改后的源代码,为什么不从中构建一个新的 AST?例如:

const newSource = ts.createSourceFile(
        'newSource.ts',
        outputCode,
        ts.ScriptTarget.ES5,
        true
)
const newProgram = ts.createProgram(['newSource.ts'], { target: ts.ScriptTarget.ES5, module: ts.ModuleKind.CommonJS })
let emitResult = newProgram.emit()

这避免了对原始 AST 的更改并且运行时没有错误。

你已经接近你的代码了。您似乎遇到的第一个问题是发生的源代码文件检查,本质上 Debug Failure. Expected -2 >= 0 错误是说当尝试将 AST 与源代码匹配时它失败了。

第二个问题是您需要修改现有的 AST 树,而 visitNode 正在生成新的 AST 树。这也必须尽早完成(在 emit 被称为 AFAIK 之前),否则 TypeChecker 可能会使用原始 AST 而不是更新后的 AST。

下面是应该可以解决这两个问题的访问者函数示例。请注意,这确实是 hacky 和脆弱的,预计它会经常崩溃。

旧:

function visitor(node: ts.Node): ts.Node {
  if (node.kind === ts.SyntaxKind.BinaryExpression) {
    let expr = node as ts.BinaryExpression

    if (expr.operatorToken.kind === ts.SyntaxKind.PlusToken) {
      return ts.createCall(ts.createIdentifier('op_add'), [], [expr.left, expr.right])
    }
  }

  return visitChildren(node, visitor)
}

新:

function visitor(node: ts.Node): ts.Node {
  if (node.kind === ts.SyntaxKind.BinaryExpression) {
    let expr = node as ts.BinaryExpression;

    if (expr.operatorToken.kind === ts.SyntaxKind.PlusToken) {
      const newIdentifierNode = ts.createIdentifier('op_add');
      const newCallNode = ts.createCall(newIdentifierNode, [], [expr.left, expr.right]);
      newCallNode.flags = node.flags;
      newCallNode.pos = node.pos;
      newCallNode.end = node.end;
      newCallNode.parent = node.parent;
      newCallNode.typeArguments = undefined;

      Object.getOwnPropertyNames(node).forEach((prop) => {
          delete node[prop];
      });
      Object.getOwnPropertyNames(newCallNode).forEach((prop) => {
          node[prop] = newCallNode[prop];
      });
      return node;
    }
  }

  return visitChildren(node, visitor);
}

继续你的 和你显然没有克服的问题:

更换AST节点后,或创建新节点后,您可以同步新的虚拟文本和每个节点

    ts.setSourceMapRange(newnode, ts.getSourceMapRange(node));
    ts.setCommentRange(newnode, ts.getCommentRange(node));

那将从访客内部调用。

但是,出现的任何错误将(可能)与原始文本不同步 - 与您不接受建议创建新的非虚拟真实中间文本文件的答案中的问题相同。

这是一种可能的解决方法。我在一个部分相关的问题中使用了部分相似的方法,并且我已将其调整为我认为应该在您的问题中起作用的方法。

编写一个转换器 tf-overload 来执行您指定的特定词法转换。

  • 通过 1
    • 编译:原始源代码AST,报错。 过滤掉关于“+”的不兼容参数的错误。 (不要认为这是一个好方法,...但是)。显示其他错误。
    • 不发射。
  • 第 2 轮(可选)
    • 使用 tf-overload.
    • 将原始 AST 转换为新的 AST
    • 编译:新AST,如有错误,请报告,但可能位置不对。
  • 通过 3
    • before 插槽中使用转换器 tf-overload 调用 emit
    • 当从 emit 中调用时,会为您执行源映射转换。因此您的调试器将显示相对于原始源文件的正确位置。 Emit 不会进行符号检查,但是来自 emit 阶段的任何 warnings/errors 都会在编辑器中显示正确的位置。

您不会获得希望悬停在 'c' 上的 UI 特殊信息。为此,您需要与 ttypescriptts-patch 或一些等效的(或自己滚动)集成,并确保您的编辑器指向正确修改的打字稿库。此外,为了对其他人有用,他们还需要正确的设置。不小的壮举。

但是,这取决于您计划的细节和实际应用。每次添加都调用 vector2 可能会导致大量开销。您可能希望在编译 typeChecker 后使用符号信息,以确保您只对数字的 arrays/tuples 调用 vector2。然后你就不需要过滤错误,而是创建你自己的错误。这是更多的工作。您可以在运行时检查数组长度。