我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

VSCode 语言插件:Provider 实现跳转、补全、悬停与 LSP 升级路径

VSCode 语言插件:Provider 实现跳转、补全、悬停与 LSP 升级路径 简介面向 VSCode 插件开发者介绍跳转到定义、自动补全、悬停提示三种语言服务能力的实现方法。通过 vscode.languages 提供的 Provider 注册机制详细说明 registerDefinitionProvider、registerCompletionItemProvider 与 registerHoverProvider 的用法并以 package.json 依赖包跳转作为完整示例展示如何获取当前文件、定位光标所在单词、解析依赖关系并返回跳转位置这一过程可迁移到其他语言与框架。同时覆盖自动补全触发逻辑与悬停提示内容组装记录高亮范围不可控等实际踩坑问题并给出处理思路。这份资料共包含 1 个 PDF 文档大小 248KB内容集中、步骤清晰适合已具备 VSCode 扩展开发基础、希望快速掌握语言服务功能的中级前端开发者。目前已有 45541 人学习下载全文虽短但信息密度较高读者可从中获得现成的代码思路、正则表达式匹配依赖、工程目录识别以及 node_modules 路径拼接等关键细节也能了解如何避免常见误区最终应用到生产级插件开发场景中。1. 跳转、补全、悬停不是白送的是插件用 Provider 换来的「VSCode 右键没有跳转到定义」是搜索量很大的问题。多数人以为是配置坏了真实原因更底层VSCode 本身不提供「跳转到定义」这个动作它只提供 Provider 插槽由插件往里塞实现。装了 C/C 插件才有跳转装了 Python 插件才有补全没装时这些功能是空的。下面按我的做法走一遍用 TypeScript 从零写一个最小插件把跳转到定义、自动补全、悬停提示三条 Provider 链路跑通。适合两类人写过一点 TS、想给内部 DSL 做编辑体验的工程师以及想搞懂这三个功能触发链路的同学。看完你会知道它们是什么、怎么做、坑在哪、什么时候该升级成 Language Server。2. 先拆开 Provider 机制三个功能背后的 API 与触发链路2.1 十几个 Provider 里跳转、补全、悬停是最常用的三块VSCode 的扩展能力被拆成了很多 contribution point 和名字空间。菜单、快捷键、主题、状态栏是一类语言类功能集中在 vscode.languages 下形式上是一组 registerXxxProvider 方法。每个方法对应一种能力registerDefinitionProvider 管跳转、registerCompletionItemProvider 管补全、registerHoverProvider 管悬停此外还有签名帮助、文档诊断、格式化、重命名、代码操作等加起来十几个。这些注册方法的入参结构非常一致第一个参数是文档选择器DocumentSelector告诉 VSCode「这种 Provider 只服务哪些语言、哪些 scheme 的文件」第二个参数是一个对象里面是实现特定接口的方法。例如跳转的接口是 provideDefinition(document, position, token)补全是 provideCompletionItems(document, position, token, context)悬停是 provideHover(document, position, token)。理解 Provider 机制的关键是它的调用方向不是你的代码主动去给编辑器塞数据而是用户在编辑器里做某个动作按下 F12、输入一个字符、鼠标悬停VSCode 把当前文档和光标位置传进你的方法你返回结果或 undefined。返回 undefined 表示「这个位置我没有定义可跳」VSCode 会继续询问下一个同语言的 Provider全部问完没有就显示「没有跳转结果」。这也解释了为什么「右键没有跳转到定义」经常查不出问题不是 VSCode 坏了而是没有任何 Provider 对这个位置返回结果。C/C 场景里可能是扩展被禁用自定义 DSL 里则往往是你根本没注册 Provider或者 documentSelector 的 language 写错了这个问题在第 4 章展开。2.2 自动补全的触发链triggerCharacters、word pattern 与 resolve 模型补全和跳转有个本质区别跳转是用户主动发起一次请求补全是在用户连续输入的过程中被反复调用的。每敲一个字符VSCode 都要决定要不要问一下 Provider 有没有候选词。这个「要不要问」由触发条件决定。触发条件有几种。第一是显式触发用户按 CtrlSpaceVSCode 无条件把所有 Provider 问一遍。第二是字符触发registerCompletionItemProvider 的第三个参数 triggerCharacters 传入一个字符数组比如[:, .]那么用户输入到这些字符时Provider 被调用。第三是输入字母数字过程中的连续触发这种场景里 VSCode 默认会询问补全 Provider。很多人抱怨「vscode 写 c 没有代码提示」多半是语言服务器没给任何候选或者触发条件没满足不是 VSCode 本身的问题。对自定义 DSLtriggerCharacters 是控制补全成本最直接的手段。比如后面要做的 toy 语言ref:后面的任务名补全只在输入:之后触发而不是从第一个字母就开始不停解析。代码里最好对 TriggerKind.Invoke 和 TriggerKind.Character 都做处理手动按补全键时即使当前行不满足正则也值得给一次候选。还需要理解 CompletionItem 的延迟模型。provideCompletionItems 返回候选列表给 UI 展示当光标落在某个候选上时VSCode 调用可选方法 resolveCompletionItem(item, token)让你把 detail、documentation 等较重的信息再填上。这个设计是为大候选集服务的先都返回轻量项选中的那个再补重型信息。对小型 DSL我一般直接在 provideCompletionItems 里填全省一个往返。2.3 选型先写 Provider 还是直接上 Language Server跳转、补全、悬停只是语言能力的一部分实现方案有两条路。一条是直接在插件里注册 Provider自己解析文档、返回结果适合单文件、语法简单、文件体量小的场景典型是配置文件、脚本 DSL、宏定义语言。另一条是走 LSPLanguage Server Protocol插件本体只负责启动一个语言服务器进程通过 JSON-RPC 通信把文档内容、光标位置、用户请求发过去服务器返回结果跳转、补全、悬停诊断都是服务器上的方法。怎么选我一般看三个条件文件之间有没有引用关系、文件多大、解析要多久。只有单文件内部引用、文件几百行内、正则或手写解析能毫秒级完成直接用 Provider 就好开发成本低一个数量级。如果跨文件跳转比如工程里 import 了一个模块想跳进它的定义或者解析要建立索引和缓存那 LSP 是正经答案因为 Extension Host 进程跑的是插件的 JS 宿主环境重解析会卡住所有插件的消息循环。判断项纯 ProviderLSP典型文件规模单文件几百行多文件、跨目录引用代码量一个 TS 文件搞定至少 client server 两个工程调试难度F5 直接断点JSON-RPC 日志、进程分开调适合场景内部 DSL、配置文件、脚本语言通用编程语言、大型工程嵌入式场景是个典型分界像 eide 这类用于 GD32/STM32 工程的外壳插件如果只是点个编译按钮、看个串口那纯 Provider 加命令就够了但要做整个工程的符号跳转靠手写 Provider 扫描整个 workspace 会很痛苦最后还是得落到类似 clangd 的语言服务器上。我自己做内部 DSL 的惯例是先写纯 Provider 验证交互跑通跳转、补全、悬停后再评估值不值得套一层 LSP。3. 手写一个带跳转、补全、悬停的 toy 语言插件3.1 初始化最小工程package.json 的 activationEvents、main 与 languages先从空目录开始。常见做法是用官方脚手架yo code生成 TS 模板但第一次练习时我更建议手写三个文件package.json、tsconfig.json、src/extension.ts。这样每行配置都能理解出问题也好定位。下面用 .toy 后缀的 DSL 做例子语法极简task build { ... }定义任务ref: build引用任务。先写 package.json{ name: toy-lang-support, displayName: Toy Language Support, version: 0.0.1, engines: { vscode: ^1.85.0 }, main: ./out/extension.js, activationEvents: [onLanguage:toy], contributes: { languages: [ { id: toy, extensions: [.toy], aliases: [Toy] } ], commands: [] } }main指向编译后的 JS 入口activationEvents声明「当用户打开 toy 语言文件时激活扩展」这决定扩展何时被加载。新版 VSCode 会为 contributes.languages 自动生成激活事件但显式写出来最保险也方便排查contributes.languages把 .toy 后缀绑定到 toy 语言 ID。language id 可以随意起但它必须和代码里 documentSelector 的 language 字段一致。编译配置要注意 strict 别开太狠Provider 接口里返回 undefined 是常态strict 下需要显式标注类型。tsconfig 里 target 用 es2020module 用 commonjsoutDir 指向 out。写完直接npm install --save-dev types/vscode1.85 typescript再npx tsc -p ./编译按 F5 就能打开 Extension Development Host 窗口调试。3.2 跳转到定义建符号表按位置返回 Location跳转的核心只有两步解析文档里的符号知道「某个名字在哪里定义」然后在用户把光标放在引用位置时返回那个定义的位置。第一步和语言无关就是文本解析第二步才是真正调用 VSCode API。先把符号表封装成一个类。对 toy 语言定义语法是task 名字 {引用语法是ref: 名字解析用正则足够import * as vscode from vscode; class ToySymbolTable { // key 是符号名value 是定义位置 private defs new Mapstring, vscode.Position(); update(document: vscode.TextDocument) { this.defs.clear(); const text document.getText(); const re /^task\s(\w)\s*\{/gm; let m: RegExpExecArray | null; while ((m re.exec(text))) { const offset m.index m[0].indexOf(m[1]); const pos document.positionAt(offset); this.defs.set(m[1], pos); } } find(name: string): vscode.Position | undefined { return this.defs.get(name); } names(): string[] { return Array.from(this.defs.keys()); } }update在每次文档变化时被调用把整个文件重新扫一遍。对大文件这么做不明智但 toy 语言就几百行性能不是问题。document.positionAt(offset)把字符串偏移量转成行列号这一步很重要因为你解析出来的永远是文本 offset而 VSCode 的 API 只认 Position不转换就会跳错行。然后注册跳转 Providerexport function activate(context: vscode.ExtensionContext) { const table new ToySymbolTable(); // 先喂一次当前文档否则打开文件后第一次跳转是空的 if (vscode.window.activeTextEditor?.document.languageId toy) { table.update(vscode.window.activeTextEditor.document); } context.subscriptions.push( vscode.workspace.onDidChangeTextDocument((e) { if (e.document.languageId toy) table.update(e.document); }) ); const definitionProvider vscode.languages.registerDefinitionProvider(toy, { provideDefinition(document, position) { // 只处理 ref: name 这种引用行 const lineText document.lineAt(position).text; const match /^ref:\s*(\w)/.exec(lineText); if (!match) return undefined; const target table.find(match[1]); if (!target) return undefined; return new vscode.Location(document.uri, target); }, }); context.subscriptions.push(definitionProvider); }几个值得抠的细节。registerDefinitionProvider第一个参数传字符串 toy 是 DocumentSelector 的简写等价于{ language: toy }返回 undefined 表示「这行不是引用或者引用的符号不存在」VSCode 会继续找其他 Provider这样和别的扩展不会打架。返回 Location 时uri 用 document.uri表示目标在同一个文件跨文件跳转时换成目标文件的 uri 即可。provideDefinition里我只看光标所在行不做全文正则。因为 VSCode 调用它时已经知道光标在哪重新 getText() 全文档属于浪费这样一个符号表类配三个 Provider 才是干净的架构。3.3 自动补全构造 CompletionItem控制 insertText 与 range补全 Provider 的接口是provideCompletionItems。它返回的候选可以是一个数组也可以是CompletionList对象后者能额外控制 isIncomplete 等行为。对这个 toy 语言我想做两件事输入ref:之后列出所有 task 名输入task时给出一段完整的任务模板。const completionProvider vscode.languages.registerCompletionItemProvider( toy, { provideCompletionItems(document, position) { const lineText document.lineAt(position).text; const beforeCursor lineText.slice(0, position.character); // 场景 1: ref: 后面的任务名补全 if (/ref:\s*\w*$/.test(beforeCursor)) { const items: vscode.CompletionItem[] []; for (const name of table.names()) { const item new vscode.CompletionItem( name, vscode.CompletionItemKind.Variable ); item.detail task; item.documentation new vscode.MarkdownString( 引用这个任务的定义。 ); items.push(item); } return items; } // 场景 2: task 关键字后插入任务模板 if (/task\s\w*$/.test(beforeCursor)) { const snippet new vscode.CompletionItem( new-task, vscode.CompletionItemKind.Snippet ); snippet.insertText new vscode.SnippetString( ${1:name} {\n cmd: ${2:命令}\n desc: ${3:描述}\n} ); snippet.documentation new vscode.MarkdownString( 插入一个 task 定义块。 ); return [snippet]; } return undefined; }, }, :, . );注意insertText的类型给固定字符串时用默认的 item插入的是name本身给带占位符的代码片段时必须把insertText设成SnippetString否则$1、${2:cmd}会被当成普通文本插入。triggerCharacters 传了[:, .]正好覆盖ref:的冒号触发场景。CompletionItemKind会影响图标和排序偏好把 task 名标成 Variable、模板标成 Snippet比全用 Text 友好。还有一个容易忽略的 range。默认情况下 VSCode 会把光标前的 word 替换掉当前行是ref: bui时插入build只会替换bui这个片段行为正好符合预期。如果你不想让插入替换任何字符需要显式给item.range设置一个空范围。provideCompletionItems返回 undefined 时 VSCode 会继续询问别的 Provider对 toy 这种私人格式没关系但通用插件里返回 undefined 前要想清楚是不是真的没有候选。3.4 悬停提示Hover、MarkdownString 与 word range悬停是三者里最省事的用户把鼠标停在一个词上不动VSCode 调用provideHover(document, position)你返回一段 Markdown 内容就完了。难点只有一个怎么知道光标停在哪个词上以及悬停面板显示多久不消失。const hoverProvider vscode.languages.registerHoverProvider(toy, { provideHover(document, position) { const wordRange document.getWordRangeAtPosition( position, /[\w-]/ ); if (!wordRange) return undefined; const word document.getText(wordRange); const defPos table.find(word); const lineText document.lineAt(position).text; const markdown new vscode.MarkdownString(); if (lineText.includes(ref:)) { markdown.appendMarkdown(**${word}** 指向 \task\ 定义\n\n); if (defPos) { markdown.appendMarkdown(定义在第 ${defPos.line 1} 行。); } else { markdown.appendMarkdown(定义未找到。); } } else { markdown.appendMarkdown(当前符号${word}); } return new vscode.Hover(markdown, wordRange); }, });getWordRangeAtPosition的第二个参数是正则告诉它「词」的边界定义。不传时默认按字母数字下划线切符号里如果带中划线就会切不完整所以这里显式传/[\w-]/。返回的 Hover 对象第二个参数是 range它决定悬停框的锚点位置与关闭时机省略 range 时悬停框很快就会消失体验差别很明显。悬停内容用 MarkdownString 组装这是 VSCode 对 hover 内容的标准要求。注意不要在 Markdown 里拼接未经过滤的原始文本文档内容来自外部输入时appendMarkdown可能注入链接或样式。对内部 DSL 风险低但这是习惯问题显示用户数据时尽量用 code 包裹能少很多玄学问题。到这里跳转、补全、悬停三条链路都跑通了。编译后按 F5 打开 Extension Development Host 窗口新建 .toy 文件就能试。4. Provider 联调避坑右键没有跳转、补全不刷新、悬停一闪而过4.1 右键没有跳转到定义检查 activationEvents 和 documentSelector现象插件安装成功代码也注册了 definitionProvider右键菜单却没有「转到定义」按 F12 也没有反应。原因分两层。第一层扩展压根没被激活。activationEvents 里写 onLanguage:toy但用户打开的文件扩展名不是 .toy或者语言 ID 没匹配上扩展一直处于未激活状态Provider 自然不存在。第二层扩展激活了但 documentSelector 的 language 写成了toy-lang而 contributes.languages.id 写的是toy两边对不上。解决在命令面板执行 Developer: Running Extensions看扩展有没有进入激活状态再看 documentSelector 与 languages 配置是否一致。我常用的定位手段是在 activate() 里加一行console.log(toy-lang activated)扩展宿主窗口的调试控制台立刻能看到输出。这个问题对应搜索热词「vscode 右键没有跳转到定义」的大多数场景不是设置项能修的。4.2 补全不随输入刷新triggerCharacters 只在你按下的字符里生效现象输入ref:时补全出现一次继续输入ref: bui候选列表不刷新甚至消失。原因registerCompletionItemProvider 的第三个参数 triggerCharacters 是在默认触发之外额外声明「这些字符被输入时自动询问 Provider」。如果你的补全逻辑只处理了冒号场景那么后续输入b、u、i时provider 可能被调用但没有返回新候选或者候选里没带 range导致列表还停在旧状态。另一个常见情况是 beforeCursor 的正则写死为ref:后必须紧跟空格用户没打空格就不满足。解决把正则放宽成/ref:\s*\w*$/。如果希望整个单词输入过程都持续刷新候选可以在 provideCompletionItems 里返回new vscode.CompletionList(items, true)第二个参数 isIncompletetrue 告诉 VSCode「这次候选不全继续输入时请再问一次」。注意这个参数对长候选集有性能代价小语言里无所谓但别在大工程里无脑开。4.3 悬停内容一闪而过range 边界与 executeHoverProvider 验证现象鼠标放在build上悬停框出现不到半秒就消失有时干脆不出现。原因没有给 Hover 传 range或者返回的 range 覆盖范围太小。VSCode 依据 range 决定「鼠标在这块区域内悬停都算命中」如果返回了一个单字符的 range鼠标稍微抖动就移出区域框立刻关闭。解决用document.getWordRangeAtPosition(position, /[\w-]/)拿到完整词范围再传给 Hover。验证时用命令面板执行 Developer: Inspect Editor Tokens and Scopes能看到光标处的词范围与 languageId更彻底的是在代码里调用vscode.languages.executeHoverProvider(uri, position)返回值里能看到 hover 内容的 markdown 是否组装正确。这个命令是调试 Provider 的后悔药比肉眼快得多。4.4 输入一个字符 CPU 飙高文档解析没做节流现象补全功能上线后在几百行的 .toy 文件里输入Extension Host 进程 CPU 从 2% 跳到 60%输入明显卡顿。原因onDidChangeTextDocument 每次变化都全量重新 getText() 和正则匹配补全请求路径上还重复解析。对 toy 这种小语言单次解析可能只要 5ms问题不在单次而在频次——打字是每键一事件再叠加补全触发的多次回调放大效应很可观。解决给文档更新加一个 300ms 的防抖补全 Provider 只读符号表快照解析统一放在防抖之后的 update 里let timer: NodeJS.Timeout | undefined; vscode.workspace.onDidChangeTextDocument((e) { if (e.document.languageId ! toy) return; if (timer) clearTimeout(timer); timer setTimeout(() table.update(e.document), 300); });这条血泪经验对任何插件都适用解析结果只算一次Provider 全部只读它不要在 provideCompletionItems 里再调 document.getText()。4.5 多根工作区与文件重命名符号表失效的隐蔽场景现象用多文件夹 workspace 打开工程跳转在第一个文件夹正常第二个文件夹里全部失效重命名文件后跳转指向旧位置。原因初版实现缓存了文档 uri 的字符串而 VSCode 里 Uri 区分 scheme、authority、path跨 workspace 文件时 path 前缀不同直接用字符串相等判断来源容易误伤。重命名是另一个坑onDidChangeTextDocument 只覆盖内容变化文件被重命名或删除时不触发缓存里的旧路径还在。解决符号表不要存 uri 字符串改成存vscode.Uri对象跳转时用vscode.workspace.getWorkspaceFolder(uri)判断归属监听vscode.workspace.onDidDeleteFiles和onDidRenameFiles把相关符号清掉。多根场景还有一个边界workspaceFolders可能为 undefined单文件模式遍历前先判空。这类问题只在特定布局下出现调试时最好把 workspace 拆成两个文件夹复现。5. 进阶Snippet 模板、跨文件索引与 Provider 触发验证5.1 给补全加上带参数位的 Snippet 模板前面给的模板补全只覆盖了task 名字 {的结构。实际任务描述语言里task 经常带默认字段、环境变量甚至前置任务列表。用 SnippetString 可以把这些都揉进一次补全const snippet new vscode.CompletionItem( task-with-env, vscode.CompletionItemKind.Snippet ); snippet.insertText new vscode.SnippetString( task ${1:name} {\n cmd: ${2:命令}\n env: ${3:KEYVALUE}\n needs: ${4:前置任务逗号分隔}\n }\n ); snippet.detail 带环境变量与依赖的任务模板;${1:name}是第一停靠点用户敲 Tab 依次跳到 name、命令、KEYVALUE、前置任务四个位置。注意 Snippet 模板里的$符号要转义成$$才能输出成字面量如果语言定义里大量出现美元符号这条容易忘我在这里翻过车。5.2 跨文件跳转findFiles 建索引的边界单文件符号表应付不了真实的内部 DSL一般会把 task 拆到多个文件。跨文件跳转要解决两个问题知道所有文件里有哪些符号知道符号对应的文件和位置。做法是维护两级结构每个文件一个符号表再加全局名字到「文件 位置」的映射。文件新增、删除、重命名配合全量重扫对几十个文件可行到几千个文件时全量扫描就会卡应该用vscode.workspace.findFiles(**/*.toy)做后台索引并按目录分片。这个方向做深了其实就是 LSP 的 workspace/symbol 请求。等你开始维护脏标记、增量解析、依赖图就说明该切到语言服务器方案了纯 Provider 这套会越写越脆。5.3 用 execute 系列命令验证 Provider 返回值写插件最痛苦的是不知道 Provider 有没有被调用、返回了什么。我的习惯是注册一个调试命令用vscode.languages.executeHoverProvider直接调内部 API把结果打到 OutputChannelconst output vscode.window.createOutputChannel(toy-lang); context.subscriptions.push(output); context.subscriptions.push( vscode.commands.registerCommand(toy.debugHover, async () { const editor vscode.window.activeTextEditor; if (!editor) return; const result await vscode.languages.executeHoverProvider( editor.document.uri, editor.selection.active ); output.clear(); result.forEach((h) output.appendLine(h.contents.map(String).join(\n)) ); }) );executeHoverProvider返回 Hover[]executeDefinitionProvider 返回 Location[]executeCompletionItemProvider 返回 CompletionList。这三条命令的返回对象都能序列化看输出里的内容比打断点快。我做完任何一个语言特性都会先跑一遍这三个 execute 命令确认返回结构再手动触发 UI 行为Provider 里不写 console.log而是写output.appendLine因为控制台在扩展宿主里不一定能稳定看到。这个习惯帮我省掉大部分「为什么没生效」的猜谜时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表