💡第七篇:VSCode语言服务中,代码跳转和诊断是怎么做的?

这是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 显示红色波浪线。

相关推荐
deli00714 分钟前
1 个汉字算几个 token?实测中英文 prompt 与代码的编码差异
前端
无限聚核21 分钟前
抖音 certificate.prepare 之后怎么验券?和美团 msconsume 差在哪,无限聚核
前端·微信小程序
2603_9658966230 分钟前
Grid 网格布局|CSS 二维页面排版体系
前端·css
有味道的男人1 小时前
搜好货平台关键词商品搜索接口:分页容错与商品数据轻量化提取方案
服务器·前端·javascript
lovingsoft1 小时前
Chrome Lighthouse 详解
前端·chrome
Qlly2 小时前
全栈工程师怎么接需求?从一句「加个功能」聊到能开发、能验收
前端·后端
马可家的菠萝2 小时前
你的微信“文件传输助手”,是不是已经成了第二个网盘?
前端·后端·架构
略略略啦2 小时前
Vibe Coding 基于Spec DD和TDD的实践指南
前端
shmily麻瓜小菜鸡2 小时前
前端实现图片与PDF文字识别敏感信息打码方案
前端·pdf·状态模式
打工仔折腾 AI2 小时前
LangChain 工具调用实战:从 Tool 定义到 Agent 执行链路拆解
java·前端·人工智能·后端·python·langchain·ai agent 实战