- [Node] 淡如止水 TypeScript (十):自动补全
- [Node] 淡如止水 TypeScript (零):开篇
- [Node] 淡如止水 TypeScript (一):准备调试
- [Node] 淡如止水 TypeScript (六):类型检查
- [Node] 淡如止水 TypeScript (三):词法分析
- [Node] 淡如止水 TypeScript (九):通信过程
- [Node] 淡如止水 TypeScript (七):代码生成
- [Node] 淡如止水 TypeScript (二):开始编译
- [Node] 淡如止水 TypeScript (四):语法分析
- [Node] 淡如止水 TypeScript (八):进程间通信
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: 1
,offset: 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 马上调用了 getCompletions
,src/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.getCompletionsAtPosition
,src/services/completions.ts#L134,
export function getCompletionsAtPosition(
...
): ... {
...
const completionData = getCompletionData(...);
...
switch (completionData.kind) {
case CompletionDataKind.Data:
return completionInfoFromData(...);
...
}
}
又调用了 getCompletionData
,获取补全数据。
其中,completionInfoFromData
是对补全数据进行的后处理,可以不细看了。
getCompletionData
,src/services/completions.ts#L778,这个函数非常长,有 1575
行,
function getCompletionData(
...
): ... {
...
let symbols: Symbol[] = [];
...
if (isRightOfDot || isRightOfQuestionDot) {
getTypeScriptMemberSymbols();
}
...
...
return {
...
symbols,
...
};
...
}
它调用 getTypeScriptMemberSymbols
,src/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
的调用链路太长了,
因此,我们专门另开一节来介绍它。
首先,在 resolveNameHelper
,src/compiler/checker.ts#L1752,第 1752
行加一个条件断点,
name === 'Console'
然后启动调试,
这样就能得到一个完整的调用链了。
从 resolveNameHelper
到 getTypeAtLocation
都是 typeChecker
的代码逻辑。
resolveNameHelper
...
typeChecker.getTypeAtLocation
getTypeScriptMemberSymbols
getCompletionData
Completions.getCompletionsAtPosition
project.getLanguageService().getCompletionsAtPosition
...
resolveNameHelper
,src/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
网友评论