0. 回顾

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

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

  1. class ... extends Session {
  2. ...
  3. listen() {
  4. rl.on("line", (input: string) => {
  5. ...
  6. });
  7. ...
  8. }
  9. }

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

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

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

1. 补全概述

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

我们先来宏观的看一下,

  1. getCompletions
  2. project.getLanguageService().getCompletionsAtPosition
  3. Completions.getCompletionsAtPosition
  4. getCompletionData
  5. getTypeScriptMemberSymbols
  6. typeChecker.getTypeAtLocation
  7. ...
  8. completionInfoFromData
  9. sort
  10. compareStringsCaseSensitiveUI
  11. compareWithCallback
  12. new Intl.Collator

(1)getCompletions 调用了语言服务 project.getLanguageService()getCompletionsAtPosition 来获取补全列表。
里面又调用了 typeChecker 来获取类型信息。

(2)上述过程返回补全列表之后,getCompletions 函数中,再对列表进行排序。

2. 关于补全位置

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

  1. console.

淡如止水 TypeScript (十):自动补全 - 图1
我们看到输入 . 之后,VSCode 弹出了补全下拉框,里面总共有 28 项目。
现在,我们想探索一下,tsserver 到底是怎样计算出这个列表的。

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

  1. const path = require('path');
  2. const { spawn } = require('child_process');
  3. const root = '/Users/.../TypeScript'; // <- 这是 TypeScript 源码仓库的根目录
  4. const child = spawn('node', [
  5. '--inspect-brk=9002',
  6. path.join(root, 'bin/tsserver'),
  7. ]);
  8. child.stdout.on('data', data => {
  9. console.log(data.toString());
  10. });
  11. child.on('close', code => {
  12. console.log(code);
  13. });
  14. const filePath = path.join(root, 'debug/index.ts');
  15. const openFile = {
  16. seq: 0,
  17. type: 'request',
  18. command: 'open',
  19. arguments: {
  20. file: filePath,
  21. }
  22. };
  23. const getCompletions = {
  24. seq: 1,
  25. type: 'request',
  26. command: 'completions',
  27. arguments: {
  28. file: filePath,
  29. line: 1,
  30. offset: 9,
  31. }
  32. };
  33. child.stdin.write(`${JSON.stringify(openFile)}\n`);
  34. child.stdin.write(`${JSON.stringify(getCompletions)}\n`);

注意到 line: 1offset: 9,这两个值正是 VSCode 提示的行列号,Ln 1, Col 9
淡如止水 TypeScript (十):自动补全 - 图2

3. 补全逻辑

3.1 回顾:消息处理过程

淡如止水 TypeScript (十):自动补全 - 图3

我们先启动 client 端,然后再 attach 到 tsserver,
淡如止水 TypeScript (十):自动补全 - 图4

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

第一次是 open command,我们暂且略过,
淡如止水 TypeScript (十):自动补全 - 图5

第二次就是 completions command,重点看它,
淡如止水 TypeScript (十):自动补全 - 图6

3.2 getCompletions

处理完消息之后,TypeScript 马上调用了 getCompletionssrc/server/session.ts#L1585
它是补全逻辑的入口函数,
淡如止水 TypeScript (十):自动补全 - 图7

图中,最下方的 (ananymous function) 就是 tsserver 监听 stdin 的回调函数了。
淡如止水 TypeScript (十):自动补全 - 图8

  1. export class Session implements EventSender {
  2. ...
  3. private getCompletions(...): ... {
  4. ...
  5. const completions = project.getLanguageService().getCompletionsAtPosition(file, position, {
  6. ...
  7. });
  8. ...
  9. const entries = mapDefined<...>(completions.entries, entry => {
  10. ...
  11. }).sort((a, b) => compareStringsCaseSensitiveUI(a.name, b.name));
  12. ...
  13. }
  14. ...
  15. }

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

3.3 补全列表

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

  1. function getCompletionsAtPosition(...): ... {
  2. ...
  3. return Completions.getCompletionsAtPosition(
  4. ...
  5. );
  6. }

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

  1. export function getCompletionsAtPosition(
  2. ...
  3. ): ... {
  4. ...
  5. const completionData = getCompletionData(...);
  6. ...
  7. switch (completionData.kind) {
  8. case CompletionDataKind.Data:
  9. return completionInfoFromData(...);
  10. ...
  11. }
  12. }

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

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

  1. function getCompletionData(
  2. ...
  3. ): ... {
  4. ...
  5. let symbols: Symbol[] = [];
  6. ...
  7. if (isRightOfDot || isRightOfQuestionDot) {
  8. getTypeScriptMemberSymbols();
  9. }
  10. ...
  11. ...
  12. return {
  13. ...
  14. symbols,
  15. ...
  16. };
  17. ...
  18. }

它调用 getTypeScriptMemberSymbolssrc/services/completions.ts#L1076 修改了 symbols 变量,
symbols 变量中包含了补全数据。
淡如止水 TypeScript (十):自动补全 - 图9

然而,getTypeScriptMemberSymbols 调用链路特别长,
淡如止水 TypeScript (十):自动补全 - 图10

  1. function getTypeScriptMemberSymbols(): void {
  2. ...
  3. if (!isTypeLocation) {
  4. let type = typeChecker.getTypeAtLocation(node).getNonOptionalType();
  5. ...
  6. }
  7. }

调用了 typeChecker 来获取类型信息,补全数据原来在 type 中。
淡如止水 TypeScript (十):自动补全 - 图11

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

  1. project.getLanguageService().getCompletionsAtPosition
  2. Completions.getCompletionsAtPosition
  3. getCompletionData // 获取补全数据
  4. getTypeScriptMemberSymbols // 从 type 中获取补全数据
  5. typeChecker.getTypeAtLocation
  6. ...
  7. completionInfoFromData // 后处理

淡如止水 TypeScript (十):自动补全 - 图12

4. typeChecker.getTypeAtLocation

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

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

  1. name === 'Console'

淡如止水 TypeScript (十):自动补全 - 图13

然后启动调试,
淡如止水 TypeScript (十):自动补全 - 图14
这样就能得到一个完整的调用链了。

resolveNameHelpergetTypeAtLocation 都是 typeChecker 的代码逻辑。

  1. resolveNameHelper
  2. ...
  3. typeChecker.getTypeAtLocation
  4. getTypeScriptMemberSymbols
  5. getCompletionData
  6. Completions.getCompletionsAtPosition
  7. project.getLanguageService().getCompletionsAtPosition
  8. ...

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

  1. function resolveNameHelper(
  2. ...
  3. if (!result) {
  4. ...
  5. if (!excludeGlobals) {
  6. result = lookup(globals, name, meaning);
  7. }
  8. }
  9. ...
  10. return result;
  11. }

lookup 从全局配置中,查询与 name 关联的结果。
由于我们设置好了条件断点,这里的 name 值为 Console
淡如止水 TypeScript (十):自动补全 - 图15

globals 是预先计算好的很多个键值对,
淡如止水 TypeScript (十):自动补全 - 图16

我们关心的 Console 补全列表,在 541 这个位置,
淡如止水 TypeScript (十):自动补全 - 图17

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


总结

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

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

  1. getCompletions
  2. project.getLanguageService().getCompletionsAtPosition
  3. Completions.getCompletionsAtPosition
  4. getCompletionData
  5. getTypeScriptMemberSymbols
  6. typeChecker.getTypeAtLocation
  7. ...
  8. resolveNameHelper
  9. lookup
  10. completionInfoFromData
  11. sort
  12. compareStringsCaseSensitiveUI
  13. compareWithCallback
  14. new Intl.Collator

参考

TypeScript v3.7.3