💡第七篇: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) }
  }
}

在这里用_providersprovider 存起来了,然后再用 this._byExtensionhandle存起来,然后告诉 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);
  }
}

看,转了一圈,回到了 MonacoregisterDefinitionProvider:)。LanguageFeaturesService_registerDefinition接收两个参数,一个是 handle,一个是 selector。

在 Monaco 的回调中,会调用this._extHostLang.$provideDefinition,并传入 handle 以及 文件路径、文件 linecharacter 等相关信息。然后得到跳转的信息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()
    }
  })
}

didOpendidChange逻辑差不多,都会发送一个事件给 lspdidOpen 发送的是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中分析 paramsdiagnostics,然后转换成 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,然后统计 problemserror 的数量、warning 的数量。

最后通知 UI 组件的更新:this._onDidChange.fire()

总结

总结

这一篇主要拆了两条链路:代码跳转和代码诊断。

代码跳转本质上是一次"位置查询"。Monaco 触发 provideDefinition,renderer 通过 RPC 去 extensionHost 找到对应的 Provider,Provider 再向 LSP server 请求 textDocument/definition。结果返回后,renderer 把 LSP 的位置数据转换成 Monaco 能识别的 urirange,最后通过 EditorService 打开目标文件并滚动到对应位置。

代码诊断和跳转定义不太一样,它更像是语言服务主动推送结果。文档打开或变化后,插件通知 LSP server,server 分析完成后推送 textDocument/publishDiagnostics。插件把它转换成 DiagnosticCollection,再通过 RPC 推回 renderer,renderer 最后调用 monaco.editor.setModelMarkers,让 Monaco 显示红色波浪线。

相关推荐
用户938515635072 小时前
写了这么久 useState,你真的知道它在干什么吗?
前端·javascript
Csvn2 小时前
✨ TypeScript `satisfies` 操作符实战——5 个让代码更「聪明」的生产场景
前端
Healer9182 小时前
vue3页面缓存-keepAlive
前端
一个有理想的摸鱼选手2 小时前
在三维地球上以另一种视角感受巴威台风
前端·gis·ai编程
程序员黑豆4 小时前
鸿蒙应用开发:Grid组件实现九宫格布局教程
前端·华为·harmonyos
浮江雾4 小时前
Flutter第十七节-----路由管理(3)
android·开发语言·前端·javascript·flutter·入门
阿懂在掘金4 小时前
企业级命令式弹窗方案:三行代码适配已有 Dialog
前端·vue.js·前端框架
Revolution614 小时前
测试接口为什么进了生产包:前端环境变量到底在什么时候生效
前端·前端工程化
午安~婉4 小时前
Git中SSH连接
前端·git·gitee