这是vscode架构的第七篇,基于mini-vscode
前言
上一篇我们聊了 LSP 是什么,以及它为什么能让 VSCode 支持不同语言的代码理解能力。知道了 LSP 的整体结构之后,接下来就可以继续往下看:这些能力到底是怎么落到编辑器里的?
比如我们最常用的两个功能:代码跳转和代码诊断。一个负责告诉编辑器"这个符号定义在哪里",一个负责告诉编辑器"这个文件哪里有问题"。这一篇就基于 mini-vscode 的实现,把 Provider、LSP、Monaco 之间的调用链路串起来,看看从插件返回结果,到编辑器真正跳转和画红线,中间都发生了什么。
在编写插件的时候,有很多 Provider,这些 Provider 模式到底在提供什么?
提供的是能力,语言信息,是当编辑器遇到某个语言功能请求的时候,应该返回什么结构化结果:
- CompletionItemProvider: 补全项
- DefinitionProvider: 符号定义的位置在哪里
- HoverProvider:鼠标悬停时显示什么内容
- ReferenceProvider:当前的符号有哪些引用的位置
- DocumentSymbolProvider:这个文件里有哪些类、函数、变量结构
- CodeActionProvider:当前位置有哪些 quick fix、refactor 的动作
共同的逻辑链路是什么:
- renderer 层发出问题
- 插件 lsp 回答问题
- renderer 合并结果、显示 UI
跳转定义是怎么做的?
跳转定义的整个链路是:
- ctrl + 鼠标左键,选中某个变量
- monaco 触发获取变量定义的位置的回调
- 回调中,会将操作的当前文件,当前行,选中的 character 一并告诉 lsp
- lsp 经过一通计算,然后将结果返回 renderer 层的 monaco
- monaco 会根据返回的结果,做最后的跳转处理,可能是在当前文件跳转,也可能是跳转到其他文件
下面我们来看细节
选中某个变量
鼠标选中某个变量,就会触发 Monaco 的 registerDefinitionProvider的事件:
javascript
monaco.languages.registerDefinitionProvider(selector, {
provideDefinition: async (model, position)=>{
const dtos = await this._extHostLang.$provideDefinition(...)
}
})
其中会调用this._extHostLang.$provideDefinition来获取定义的来源信息。这个信息怎么来的,我们从插件那边开始看起
插件注册 Provider
javascript
vscode.languages.registerDefinitionProvider(['typescript', 'typescriptreact'], {
async provideDefinition(document, position) {
// ...
}
})
在插件注册 provider 会调用registerDefinitionProvider
vscode API:
javascript
function createVSCodeApi(
rpc: RPCProtocol,
extHostCommands: ExtHostCommands,
extHostLanguageFeatures: ExtHostLanguageFeatures,
extHostDocuments: ExtHostDocuments,
extHostDiagnostics: ExtHostDiagnostics,
extensionId: string
){
return {
command: {
languages:{
registerDeginitionProvider(selector: LanguageSelector, provider: DefinitionProvider){
return extHostLanguageFeatures.registerDefinitionProvider(
extensionId,
normalizeSelector(selector),
provider
)
}
}
}
}
}
在registerDefinitionProvider中,会调用 extHostLanguageFeatures.registerDefinitionProvider,并且传入三个参数:
- extensionId: 插件的 name
- selector:指定触发的语言,比如 language、typescriptlanguage
- provider:含有
provideDefinition方法的对象
ExteHostLanguageFeatures:
javascript
export class ExtHostLanguageFeatures {
registerDefinitionProvider(
extensionId: string,
selector: string[],
provider: DefinitionProvider
): { dispose(): void } {
const handle = this._nextHandle++
this._providers.set(handle, provider)
const owned = this._byExtension.get(extensionId) ?? []
owned.push(handle)
this._byExtension.set(extensionId, owned)
this._mainProxy.$registerDefinitionProvider(handle, selector)
return { dispose: () => this._unregister(extensionId, handle) }
}
}
在这里用_providers把 provider 存起来了,然后再用 this._byExtension把 handle存起来,然后告诉 renderer 层对应的 handler 以及 selector,这里的意思是如果匹配到了对应的语言类型,就调用 handle 对应的 provide
为什么会用两个 map 存 provider 的相关信息,只用
_providers不就可以了?是为了支持两种查询方式,运行时按照 handle 查找,注销拓展时用拓展 name 查找,注销所有的 provider
Monaco 注册 provider 的回调
this._mainProxy实际上是来自 renderer 进程的LanguageFeaturesService:
javascript
export class LanguageFeaturesService {
private _registerDefinition(handle: number, selector: string[]): void {
const disposable = monaco.languages.registerDefinitionProvider(selector, {
provideDefinition: async (model, position) => {
const dtos = await this._extHostLang.$provideDefinition(handle, this._uri(model), {
line: position.lineNumber - 1, // monaco 1-based → vscode 0-based
character: position.column - 1,
});
// 确保每个定义目标文件都有 model:否则 Monaco 的 peek/跳转走
// createModelReference 时会抛 "Model not found"(目标未打开,或 scheme 不匹配)。
await Promise.all(dtos.map((d) => this._ensureModel(d.uri.path)));
return dtos.map((dto) => ({
// 用 Uri.parse 与 @monaco-editor/react 的 model uri 对齐(scheme 为空串)
uri: monaco.Uri.parse(dto.uri.path),
range: {
startLineNumber: dto.range.start.line + 1, // vscode 0-based → monaco 1-based
startColumn: dto.range.start.character + 1,
endLineNumber: dto.range.end.line + 1,
endColumn: dto.range.end.character + 1,
},
}));
},
});
this._providers.set(handle, disposable);
}
}
看,转了一圈,回到了 Monaco 的 registerDefinitionProvider:)。LanguageFeaturesService的_registerDefinition接收两个参数,一个是 handle,一个是 selector。
在 Monaco 的回调中,会调用this._extHostLang.$provideDefinition,并传入 handle 以及 文件路径、文件 line、character 等相关信息。然后得到跳转的信息dtos
javascript
this._extHostLang = rpc.getProxy<ExtHostLanguageFeaturesShape>(ExtHostContext.ExtHostLanguageFeatures);
this._extHostLang是来自 extensionHost 进程的,下面看看具体逻辑:
javascript
class ExHostLanguageFeatures {
async $provideDefinition(
handle: number,
resource: UriComponents,
position: IPosition
): Promise<ILocationDto[]> {
const provider = this._providers.get(handle)
if (!provider) return []
const doc = this.documents.getDocument(resource)
const result = await provider.provideDefinition(doc, new Position(position.line, position.character))
if (!result) return []
const locations = Array.isArray(result) ? result : [result]
return locations.map(loc => ({
uri: loc.uri.toJSON(),
range: {
start: { line: loc.range.start.line, character: loc.range.start.character },
end: { line: loc.range.end.line, character: loc.range.end.character }
}
}))
}
}
逻辑很简单,拿到 handle 对应的 provider 对象,调用provideDefinition对象,获取到 result 后,再对结果做简单的转换就结束了。
再回到插件中注册 provider 的地方搂一眼:
javascript
vscode.languages.registerDefinitionProvider(['typescript', 'typescriptreact'], {
async provideDefinition(document, position) {
// 获取connect对象
const c = server.getConnection() || (await server.ensureConnection(findRoot(document.uri.path)))
// 发送获取定义信息的请求
const res = await c.request('textDocument/definition', {
textDocument: { uri: pathToUri(document.uri.path) },
position: { line: position.line, character: position.character }
})
const locs = Array.isArray(res) ? res : res ? [res] : []
return locs.map(l => {
const r = l.range
return new vscode.Location(
vscode.Uri.file(uriToPath(l.uri)),
new vscode.Range(r.start.line, r.start.character, r.end.line, r.end.character)
)
})
}
})
先获取 connect 对象,然后发送获取定义的请求。
到这里是不是就串起来了?大呼一声,原来是这么设计的😯
但还没结束,从拿到跳转信息,到触发跳转之间,还有一段路要走。
加油,快到了⛽️
Monaco 跳转
javascript
monaco.editor.registerEditorOpener({
openCodeEditor: (_source, resource, selectionOrPosition) => {
let line = 1;
let column = 1;
if (selectionOrPosition) {
if ("lineNumber" in selectionOrPosition) {
line = selectionOrPosition.lineNumber;
column = selectionOrPosition.column;
} else {
line = selectionOrPosition.startLineNumber;
column = selectionOrPosition.startColumn;
}
}
// 打开目标文件并定位(复用 13.1 的 revealPosition,行列为 monaco 1-based)
void this.editorService.revealPosition(resource.path, line, column);
return true;
},
});
当 Monaco 拿到跳转信息后,就会调用openCodeEditor回调执行真正的跳转动作。
没错,跳转的动作也是外包的,这也正常,因为每个编辑器的设计都不一样
editorService.revealPosition:
javascript
export class EditorService {
async revealPosition(path: string, line: number, column: number): Promise<void> {
await this.openEditor(path);
// 记下待处理定位:若对应视图此刻还没订阅事件,挂载时会来 consume
this._pendingReveals.set(path, { line, column });
this._onDidRequestReveal.fire({ path, line, column });
}
}
这里为跳转做了两件事情:
- Monaco 打开对应的文件。这里更新 this.tabs 中的 activePath 就好了。
- Monaco 滚动到对应的位置。这个动作要 Moanco 自己来做。先设置滚动的位置,然后触发事件
MonacoEditor:
javascript
const editorService = useService(IEditorService)
const reveal = useCallback((line: number, column: number): void => {
// Monaco编辑器实例
const ed = editorRef.current
if (!ed) return
ed.revealLineInCenter(line)
ed.setPosition({ lineNumber: line, column })
ed.focus()
}, [])
editorService.onDidRequestReveal(ev => {
if (ev.path !== path) return
reveal(ev.line, ev.column)
editorService.consumeReveal(path)
})
this._onDidRequestReveal.fire的执行,会触发editorService.onDidRequestReveal的回调,然后就操作 Monaco 滚到对应的位置。
就这样,结束了
代码诊断是怎么做的
和获取定义不同的是,代码诊断是 lsp 主动 push 的。插件这边会监听 VSCode 文档的 open 与 change 事件,如果被触发,就会调用 didOpen/didChange
javascript
async function didOpen(doc) {
const c = await server.ensureConnection(findRoot(doc.uri.path))
versions.set(doc.uri.path, 1)
c.notify('textDocument/didOpen', {
textDocument: {
uri: pathToUri(doc.uri.path),
languageId: doc.languageId || 'typescript',
version: 1,
text: doc.getText()
}
})
}
didOpen和didChange逻辑差不多,都会发送一个事件给 lsp,didOpen 发送的是textDocument/didOpen,当 lsp 接收到内容后,就会主动展开分析。然后发送一个textDocument/publishDiagnostics给插件:
javascript
function dispatch(msg) {
if (msg.id !== undefined && msg.method !== undefined) {
// ...
} else if (msg.id !== undefined) {
// ...
} else if (msg.method) {
onNotification(msg.method, msg.params)
}
}
插件用 onNotification 来接收:
javascript
const diagnostics = vscode.languages.createDiagnosticCollection('ts-lsp')
function onNotification(method, params) {
if (method === 'textDocument/publishDiagnostics') {
const p = uriToPath(params.uri)
const diags = (params.diagnostics || []).map(d => {
const r = d.range
const diag = new vscode.Diagnostic(
new vscode.Range(r.start.line, r.start.character, r.end.line, r.end.character),
d.message,
lspSeverityToVscode(d.severity)
)
if (d.source) diag.source = d.source
return diag
})
diagnostics.set({ scheme: 'file', path: p }, diags)
console.log(`[ts-lsp] diagnostics ${p} → ${diags.length} 条`)
}
}
在onNotification中分析 params 的diagnostics,然后转换成 VSCode 内部的数据格式。然后放入diagnostics对象中。
diagnostics对象是什么?来自哪里?
javascript
const diagnostics = vscode.languages.createDiagnosticCollection('ts-lsp')
javascript
export function createVSCodeApi(
rpc: RPCProtocol,
extHostCommands: ExtHostCommands,
extHostLanguageFeatures: ExtHostLanguageFeatures,
extHostDocuments: ExtHostDocuments,
extHostDiagnostics: ExtHostDiagnostics,
extensionId: string
){
return {
languages: {
createDiagnosticCollection(name?: string) {
return extHostDiagnostics.createCollection(extensionId, name)
}
}
}
}
javascript
class ExHostDiagnostics {
createCollection(extensionId: string, name?: string): DiagnosticCollection {
const owner = name || `ext-diag-${this._seq++}`
const collection = new DiagnosticCollection(owner, entries =>
this._mainProxy.$changeMany(owner, entries))
const owned = this._byExtension.get(extensionId) ?? []
owned.push(collection)
this._byExtension.set(extensionId, owned)
return collection
}
}
createCollection返回的是new DiagnosticCollection()的构造结果,构造函数接收了两个参数,一个是 owner,即拓展的 name,一个是回调函数。
再往collection里面看:
javascript
class DiagnosticCollection {
constructor(
readonly name: string,
private readonly push: (entries: [UriComponents, IMarkerDto[]][]) => void
) {}
set(uri: UriComponents, diagnostics?: Diagnostic[]): void {
const markers = (diagnostics ?? []).map(toMarkerDto)
const k = key(uri)
if (markers.length === 0) this._uris.delete(k)
else this._uris.set(k, uri)
this.push([[uri, markers]])
}
}
这里做了两件事:
- 保存当前这个
DiagnosticCollection曾经给哪些文件设置过诊断 - 调用 this.push
这里的 this.push 是什么东西?
往上看new DiagnosticCollection()传的第二个参数--回调函数,就是 push。
javascript
entries => this._mainProxy.$changeMany(owner, entries)
这里调用了 RPC 函数,其实就是调用 renderer 层的函数,传了两个东西过去,一个是拓展的 name,一个 uri 对应的一系列 diagnostic,也就是对应文件的诊断信息
$changeMany:
javascript
class LanguageFeatureService {
/** 扩展宿主按 owner 批量更新诊断 → 缓存 + 设到对应 model 的 marker */
private _changeMany(owner: string, entries: [UriComponents, IMarkerDto[]][]): void {
let perOwner = this._markers.get(owner);
if (!perOwner) {
perOwner = new Map<string, IMarkerDto[]>();
this._markers.set(owner, perOwner);
}
for (const [uri, dtos] of entries) {
if (dtos.length === 0) perOwner.delete(uri.path);
else perOwner.set(uri.path, dtos);
// 坑 #1:按 path 找 model(model 的 uri.scheme 可能是空串,不能重建 uri 去匹配)
const model = monaco.editor.getModels().find((m) => m.uri.path === uri.path);
if (model) monaco.editor.setModelMarkers(model, owner, dtos.map(toMonacoMarker));
}
}
}
在_changeMany存储了真正的 url 对应的诊断信息,这也是全 vscode 唯一存储的地方。真正让 Monaco 显示诊断信息是在最后的两句:
javascript
const model = monaco.editor.getModels().find((m) => m.uri.path === uri.path);
if (model) monaco.editor.setModelMarkers(model, owner, dtos.map(toMonacoMarker));
诊断信息不仅在 Monaco 显示,还在 output 中显示
javascript
export class DiagnosticsService {
constructor() {
monaco.editor.onDidChangeMarkers(() => this._recompute())
this._recompute()
}
private _recompute(): void {
const markers = monaco.editor.getModelMarkers({})
this._problems = markers
.map(m => ({
path: m.resource.path,
fileName: m.resource.path.split('/').pop() ?? m.resource.path,
line: m.startLineNumber,
column: m.startColumn,
message: m.message,
severity: toSeverity(m.severity),
source: m.source
}))
.sort(
(a, b) =>
rank(b.severity) - rank(a.severity) ||
a.path.localeCompare(b.path) ||
a.line - b.line ||
a.column - b.column
)
let errors = 0
let warnings = 0
for (const p of this._problems) {
if (p.severity === 'error') errors++
else if (p.severity === 'warning') warnings++
}
this._counts = { errors, warnings, total: this._problems.length }
this._onDidChange.fire()
}
}
在DiagnosticsService中会监听 Monaco 的 Marker 信息的变动,如果有变动,就会调用this._recompute。
this._recompute中会读取获得 markers,然后统计 problems、error 的数量、warning 的数量。
最后通知 UI 组件的更新:this._onDidChange.fire()
总结
总结
这一篇主要拆了两条链路:代码跳转和代码诊断。
代码跳转本质上是一次"位置查询"。Monaco 触发 provideDefinition,renderer 通过 RPC 去 extensionHost 找到对应的 Provider,Provider 再向 LSP server 请求 textDocument/definition。结果返回后,renderer 把 LSP 的位置数据转换成 Monaco 能识别的 uri 和 range,最后通过 EditorService 打开目标文件并滚动到对应位置。
代码诊断和跳转定义不太一样,它更像是语言服务主动推送结果。文档打开或变化后,插件通知 LSP server,server 分析完成后推送 textDocument/publishDiagnostics。插件把它转换成 DiagnosticCollection,再通过 RPC 推回 renderer,renderer 最后调用 monaco.editor.setModelMarkers,让 Monaco 显示红色波浪线。