美文网首页Node.js
[Node] 随遇而安 TypeScript(十):查找引用

[Node] 随遇而安 TypeScript(十):查找引用

作者: 何幻 | 来源:发表于2020-06-09 21:20 被阅读0次

    背景

    上文我们从 VSCode Go to Definition 出发,介绍了 TypeScript 处理多文件的过程,
    总共分为两个关键步骤,入口文件的处理,以及内置的库文件的处理。
    这两个处理过程,都会递归的查找 import 的文件,从而将整个项目都加载进来。

    此外 Go to Definition 是通过向 tsserver 发送 definitionAndBoundSpan 消息来实现的。
    tsserver 首先根据光标位置拿到最近邻的 ast 节点,
    然后读取 TypeChecker 中保存的,该节点对应的符号信息,就可以找到符号定义的位置了。

    Go to Definition 会比较简单一些,因为 TypeChecker 已经将节点与符号之间的关联关系处理好了,
    它会在定义符号的 ast 节点上,添加 symbol 属性,用到时直接取就行。
    (不出意外的话,应该是 addDeclarationToSymbol 函数 src/compiler/binder.ts#L299

    除了 Go to Definition 之外,VSCode 还提供了 Go to References 功能,
    这个功能实现起来就比较麻烦了。

    const a = 1;
    a
    a
    

    a 定义位置右键,选择 Go to References,VSCode 就会在当前界面打开一个快捷窗口,
    展示了包括 a 定义在内的 3 个引用。


    1. 启动调试

    与上文的调试方式相同,我们启动了两个 VSCode 实例,
    一个打开 vscode v1.45.1 源码,
    另一个打开 typescript v3.7.3 源码。

    具体的调试配置,需参考第七篇,然后按上一篇指出的那样,再修改其中一个配置。
    这里就不再赘述了。

    我们分别在 vscode/extensions/typescript-language-features/src/features/references.ts#L26
    以及 typescript/src/server/session.ts#L2251 打下断点,启动调试。

    TypeScript 插件(typescript-language-features)发送了名为 references 的消息,

    2. 文本搜索

    下文我们来专心看 tsserver 查找所有引用的过程。
    经过一路跟踪,我们发现业务逻辑主要在 typescript/src/services/findAllReferences.ts 这个文件中,

    export function findReferencedSymbols(...): ... {
      const node = getTouchingPropertyName(sourceFile, position);
      const referencedSymbols = Core.getReferencedSymbolsForNode(position, node, program, sourceFiles, cancellationToken);
      ...
    }
    

    它先找到光标位置的 ast 节点,然后调用了 Core.getReferencedSymbolsForNode 找出所有引用,
    位于 src/services/findAllReferences.ts#L535

    后面主要的逻辑我整理了一下,

    Core.getReferencedSymbolsForNode                      # 封装了查找引用的所有逻辑
      checker.getSymbolAtLocation                         # 获取当前 ast 节点对应的符号
      getReferencedSymbolsForSymbol
        new State                                         # 用了一个 State 对象存储查找引用过程中的信息
        getReferencesInContainerOrFiles
          for                                             # 在所有的 sourceFiles 中找
            searchForName
              getReferencesInSourceFile
                getReferencesInContainer                  # 在任何可以定义符号的容器中找
                  getPossibleSymbolReferencePositions     # 通过文本搜索的方式,找出所有可能的位置
                    for
                      getReferencesAtLocation             # 查看每个位置,是否是给定符号的引用
                        getTouchingPropertyName           # 根据位置计算 ast 节点
                        state.checker.getSymbolAtLocation # 根据节点,查 TypeChecker 看它引用了谁
                        addReference
    

    最值得一提的是 getPossibleSymbolReferencePositionssrc/services/findAllReferences.ts#L1188
    它处理所有的 sourceFile,竟然用文本搜索的方式查找同名变量。

    还好这个函数并不算太长,

    function getPossibleSymbolReferencePositions(sourceFile: SourceFile, symbolName: string, container: Node = sourceFile): readonly number[] {
      const positions: number[] = [];
    
      /// TODO: Cache symbol existence for files to save text search
      // Also, need to make this work for unicode escapes.
    
      // Be resilient in the face of a symbol with no name or zero length name
      if (!symbolName || !symbolName.length) {
        return positions;
      }
    
      const text = sourceFile.text;
      const sourceLength = text.length;
      const symbolNameLength = symbolName.length;
    
      let position = text.indexOf(symbolName, container.pos);
      while (position >= 0) {
        // If we are past the end, stop looking
        if (position > container.end) break;
    
        // We found a match.  Make sure it's not part of a larger word (i.e. the char
        // before and after it have to be a non-identifier char).
        const endPosition = position + symbolNameLength;
    
        if ((position === 0 || !isIdentifierPart(text.charCodeAt(position - 1), ScriptTarget.Latest)) &&
          (endPosition === sourceLength || !isIdentifierPart(text.charCodeAt(endPosition), ScriptTarget.Latest))) {
          // Found a real match.  Keep searching.
          positions.push(position);
        }
        position = text.indexOf(symbolName, position + symbolNameLength + 1);
      }
    
      return positions;
    }
    

    可以看到,为了查找 symbolName
    它从 sourceFile.text(代码文本) 的第一个出现位置,开始往后搜索,
    每次移动 symbolNameLength 长度的偏移量。

    这样甚至会将注释中的文本也捞出来。
    捞出来之后,再来判断是否真的是所查找的引用。

    3. 查找符号

    文本是否所查找的引用,是通过 getReferencesAtLocation 来判断的,
    位于 src/services/findAllReferences.ts#L1291

    它总共做了两件事,

    • 根据文本得到 ast 节点
    • 根据 ast 节点,用 TypeChecker 判断引用关系
    getReferencesAtLocation             # 查看每个位置,是否是给定符号的引用
      getTouchingPropertyName           # 1. 根据位置计算 ast 节点
        getTouchingToken
          getTokenAtPositionWorker
            findPrecedingToken
      state.checker.getSymbolAtLocation # 2. 根据节点,查 TypeChecker 看它引用了谁
        getSymbolAtLocation
          getSymbolAtLocation
            getSymbolOfNode
              getLateBoundSymbol
              getMergedSymbol
      addReference
    

    3.1 从文本到 ast 节点

    getReferencesAtLocation             # 查看每个位置,是否是给定符号的引用
      getTouchingPropertyName           # 1. 根据位置计算 ast 节点
        getTouchingToken
          getTokenAtPositionWorker
            findPrecedingToken
    

    getTouchingPropertyName 调用了 getTokenAtPositionWorker
    位于 src/services/utilities.ts#L705 找到了与文本位置最匹配的 ast 节点。

    /** Get the token whose text contains the position */
    function getTokenAtPositionWorker(sourceFile: SourceFile, position: number, allowPositionInLeadingTrivia: boolean, includePrecedingTokenAtEndPosition: ((n: Node) => boolean) | undefined, includeEndPosition: boolean): Node {
      let current: Node = sourceFile;
      outer: while (true) {
        // find the child that contains 'position'
        for (const child of current.getChildren(sourceFile)) {
          const start = allowPositionInLeadingTrivia ? child.getFullStart() : child.getStart(sourceFile, /*includeJsDoc*/ true);
          if (start > position) {
            // If this child begins after position, then all subsequent children will as well.
            break;
          }
    
          const end = child.getEnd();
          if (position < end || (position === end && (child.kind === SyntaxKind.EndOfFileToken || includeEndPosition))) {
            current = child;
            continue outer;
          }
          else if (includePrecedingTokenAtEndPosition && end === position) {
            const previousToken = findPrecedingToken(position, sourceFile, child);
            if (previousToken && includePrecedingTokenAtEndPosition(previousToken)) {
              return previousToken;
            }
          }
        }
    
        return current;
      }
    }
    

    大致看来,它采用了广度优先搜索,从根节点开始调用 current.getChildren 逐个判断各子节点,
    如果子节点的起始位置,已经在可能的引用位置之后了,那么所有后代节点就都不用判断了,
    否则,就继续沿着 ast 往叶子方向搜索。

    总而言之,思路是不断的缩小 ast 子树的范围。
    找到位置后,再计算这个位置之前的 token(一般前面如果不是空白字符的话,直接就返回了)。
    相关的逻辑在 findPrecedingToken src/services/utilities.ts#L775 ,就不仔细研究了。

    3.2 从 ast 节点中取出符号信息

    getReferencesAtLocation             # 查看每个位置,是否是给定符号的引用
      getTouchingPropertyName           # 1. 根据位置计算 ast 节点
        ...
      state.checker.getSymbolAtLocation # 2. 根据节点,查 TypeChecker 看它引用了谁
        getSymbolAtLocation
          getSymbolAtLocation
            getSymbolOfNode
              node.symbol
    

    有了 ast 节点之后,就可以借助 TypeChecker 获取节点的符号信息了。
    所用到的方法是 checker.getSymbolAtLocationsrc/compiler/checker.ts#L33461

    最后直接是从 node.symbol 属性中取的符号信息。

    总结

    本文介绍了 VSCode Go to References 的具体实现,它比 Go to Definition 会更复杂一些。

    tsserver 先是对 sourceFile 源代码进行全文文本搜索,
    然后查找 ast,找与上一步搜到文本位置最匹配的 ast 节点,
    最后从 ast 节点中拿到 TypeChecker 之前已挂载好的符号信息。

    值得一提的是,ast 定义符号的位置,也算是符号的一处引用,
    因此,引用是建立了从 ast 节点到符号的一种映射关系。


    参考

    vscode v1.45.1
    typescript v3.7.3

    相关文章

      网友评论

        本文标题:[Node] 随遇而安 TypeScript(十):查找引用

        本文链接:https://www.haomeiwen.com/subject/mxmetktx.html