美文网首页Node.js
[Node] 淡如止水 TypeScript (十):自动补全

[Node] 淡如止水 TypeScript (十):自动补全

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

    0. 回顾

    上文我们向 tsserver 发送了两条消息,然后跟踪了进程间的通信过程。
    并没有深入到 tsserver 源码中去看消息是怎么处理的。

    需要留意的有以下两点:
    (1)使用 child.stdin.write 向子进程写入消息内容时,应以 \n 结尾
    这是因为 tsserver 监控了 .on('line', xxx => ...) 事件,src/tsserver/server.ts#L574
    消息不以 \n 结尾,会被认为尚未发送完。

    class ... extends Session {
      ...
    
      listen() {
        rl.on("line", (input: string) => {
          ...
        });
    
        ...
      }
    }
    

    (2)tsserver 子进程自动后,在不接受任何父进程的消息前,会向父进程返回一条消息,

    Content-Length: 76
    
    {"seq":0,"type":"event","event":"typingsInstallerPid","body":{"pid":19563}}
    

    在进行调试时,很容易被这条消息干扰到。

    1. 补全概述

    本文用 completions command 为例,来介绍 TypeScript 补全是怎样实现的。

    我们先来宏观的看一下,

    getCompletions
      project.getLanguageService().getCompletionsAtPosition
        Completions.getCompletionsAtPosition
          getCompletionData
            getTypeScriptMemberSymbols
              typeChecker.getTypeAtLocation
                ...
          completionInfoFromData
      sort
        compareStringsCaseSensitiveUI
          compareWithCallback
            new Intl.Collator
    

    (1)getCompletions 调用了语言服务 project.getLanguageService()getCompletionsAtPosition 来获取补全列表。
    里面又调用了 typeChecker 来获取类型信息。
    (2)上述过程返回补全列表之后,getCompletions 函数中,再对列表进行排序。

    2. 关于补全位置

    修改 debug/index.ts 文件内容如下,

    console.
    

    我们看到输入 . 之后,VSCode 弹出了补全下拉框,里面总共有 28 项目。
    现在,我们想探索一下,tsserver 到底是怎样计算出这个列表的。

    为此,我们需要修改 client 端的 index.js 文件,向 tsserver 发送 completions command。

    const path = require('path');
    const { spawn } = require('child_process');
    
    const root = '/Users/.../TypeScript';  // <- 这是 TypeScript 源码仓库的根目录
    
    const child = spawn('node', [
      '--inspect-brk=9002',
      path.join(root, 'bin/tsserver'),
    ]);
    
    child.stdout.on('data', data => {
      console.log(data.toString());
    });
    
    child.on('close', code => {
      console.log(code);
    });
    
    const filePath = path.join(root, 'debug/index.ts');
    
    const openFile = {
      seq: 0,
      type: 'request',
      command: 'open',
      arguments: {
        file: filePath,
      }
    };
    const getCompletions = {
      seq: 1,
      type: 'request',
      command: 'completions',
      arguments: {
        file: filePath,
        line: 1,
        offset: 9,
      }
    };
    
    child.stdin.write(`${JSON.stringify(openFile)}\n`);
    child.stdin.write(`${JSON.stringify(getCompletions)}\n`);
    

    注意到 line: 1offset: 9,这两个值正是 VSCode 提示的行列号,Ln 1, Col 9

    3. 补全逻辑

    3.1 回顾:消息处理过程

    我们先启动 client 端,然后再 attach 到 tsserver,


    我们知道 tsserver 启动后,会主动向主进程返回一条消息,这里不再赘述。
    上文的例子中,client 端通过 child.stdin.write 发送了两条消息,
    因此,server 端启动之后,会收到两次消息。

    第一次是 open command,我们暂且略过,

    第二次就是 completions command,重点看它,

    3.2 getCompletions

    处理完消息之后,TypeScript 马上调用了 getCompletionssrc/server/session.ts#L1585
    它是补全逻辑的入口函数,

    图中,最下方的 (ananymous function) 就是 tsserver 监听 stdin 的回调函数了。

    export class Session implements EventSender {
      ...
    
      private getCompletions(...): ... {
        ...
        const completions = project.getLanguageService().getCompletionsAtPosition(file, position, {
          ...
        });
    
        ...
        const entries = mapDefined<...>(completions.entries, entry => {
          ...
        }).sort((a, b) => compareStringsCaseSensitiveUI(a.name, b.name));
    
        ...
      }
      ...
    }
    

    getCompletions 先调用 project.getLanguageService().getCompletionsAtPosition 获取补全列表。
    然后再调用 sort compareStringsCaseSensitiveUI 进行排序。

    3.3 补全列表

    我们主要看补全数据是从哪里来的,即,project.getLanguageService().getCompletionsAtPosition
    src/services/services.ts#L1446

    function getCompletionsAtPosition(...): ... {
      ...
      return Completions.getCompletionsAtPosition(
        ...
      );
    }
    

    它调用了 Completions.getCompletionsAtPositionsrc/services/completions.ts#L134

    export function getCompletionsAtPosition(
      ...
    ): ... {
      ...
    
      const completionData = getCompletionData(...);
      ...
    
      switch (completionData.kind) {
        case CompletionDataKind.Data:
          return completionInfoFromData(...);
        ...
      }
    }
    

    又调用了 getCompletionData,获取补全数据。
    其中,completionInfoFromData 是对补全数据进行的后处理,可以不细看了。

    getCompletionDatasrc/services/completions.ts#L778,这个函数非常长,有 1575 行,

    function getCompletionData(
      ...
    ): ... {
      ...
      let symbols: Symbol[] = [];
      ...
    
      if (isRightOfDot || isRightOfQuestionDot) {
        getTypeScriptMemberSymbols();
      }
      ...
    
      ...
      return {
        ...
        symbols,
        ...
      };
    
      ...
    }
    

    它调用 getTypeScriptMemberSymbolssrc/services/completions.ts#L1076 修改了 symbols 变量,
    symbols 变量中包含了补全数据。

    然而,getTypeScriptMemberSymbols 调用链路特别长,

    function getTypeScriptMemberSymbols(): void {
      ...
    
      if (!isTypeLocation) {
        let type = typeChecker.getTypeAtLocation(node).getNonOptionalType();
        ...
      }
    }
    

    调用了 typeChecker 来获取类型信息,补全数据原来在 type 中。

    因此,补全数据是 typeChecker 计算出来的,

    project.getLanguageService().getCompletionsAtPosition
      Completions.getCompletionsAtPosition
        getCompletionData             // 获取补全数据
          getTypeScriptMemberSymbols  // 从 type 中获取补全数据
            typeChecker.getTypeAtLocation
              ...
        completionInfoFromData        // 后处理
    

    4. typeChecker.getTypeAtLocation

    由于 typeChecker.getTypeAtLocation 的调用链路太长了,
    因此,我们专门另开一节来介绍它。

    首先,在 resolveNameHelpersrc/compiler/checker.ts#L1752,第 1752 行加一个条件断点,

    name === 'Console'
    

    然后启动调试,



    这样就能得到一个完整的调用链了。

    resolveNameHelpergetTypeAtLocation 都是 typeChecker 的代码逻辑。

    resolveNameHelper
    ...
    typeChecker.getTypeAtLocation
    getTypeScriptMemberSymbols
    getCompletionData
    Completions.getCompletionsAtPosition
    project.getLanguageService().getCompletionsAtPosition
    ...
    

    resolveNameHelpersrc/compiler/checker.ts#L1442,调用了 lookup

    function resolveNameHelper(
      ...
    
      if (!result) {
        ...
    
        if (!excludeGlobals) {
          result = lookup(globals, name, meaning);
        }
      }
      
      ...
      return result;
    }
    

    lookup 从全局配置中,查询与 name 关联的结果。
    由于我们设置好了条件断点,这里的 name 值为 Console

    globals 是预先计算好的很多个键值对,

    我们关心的 Console 补全列表,在 541 这个位置,

    至于 globals 是怎么计算出来的,我们就不再展开了。


    总结

    本文介绍了 tsserver 中补全相关的实现逻辑,从发送 completions command 出发,
    我们跟到了 typeChecker 里面,找到了补全信息源头位置。

    拿到源数据之后,tsserver 又进行了一些排序处理,最终返回给父进程。
    以下就是完整的调用链路了,

    getCompletions
      project.getLanguageService().getCompletionsAtPosition
        Completions.getCompletionsAtPosition
          getCompletionData
            getTypeScriptMemberSymbols
              typeChecker.getTypeAtLocation
                ...
                resolveNameHelper
                  lookup
          completionInfoFromData
      sort
        compareStringsCaseSensitiveUI
          compareWithCallback
            new Intl.Collator
    

    参考

    TypeScript v3.7.3

    相关文章

      网友评论

        本文标题:[Node] 淡如止水 TypeScript (十):自动补全

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