模板字符串和 JSX 属性里的补全总翻车,我靠编辑器规则把错误候选压下去了
补全工具在模板字符串和 JSX 属性里翻车,根因通常是上下文推断能力不足,而不是模型本身太笨。编辑器规则能做的是在候选进入补全列表之前做一层硬过滤,把明显不该出现在这些位置的符号直接压掉。
我最近半年在 VS Code 和 Neovim 里折腾补全配置,主要就是为了解决两个高频痛点:模板字符串里写 ${} 的时候,补全列表里冒出一堆 DOM API 和 React 生命周期方法;JSX 属性里写 onClick={} 的时候,候选里混入 CSS 属性名和 HTML 标签。这两个位置都有一个共同特征:语法上合法,但语义上几乎不可能出现那些候选。补全引擎只看 token 流和局部语法,没有足够的类型信息和作用域理解,于是就把全局符号表和最近使用记录全塞进来了。
先说我最终采用的策略。VS Code 侧用 editor.suggest.filteredTypes 配合语言特定的 editor.tokenColorCustomizations 做不了语义过滤,真正起作用的还是 editor.quickSuggestions 的 strings 开关和 editor.suggest.localityBonus 的调整。Neovim 侧用 vim.lsp.completion.enable() 配合 cmp 的 filter 回调做正则级拦截。这些方案都不完美,但能把错误候选的出现率降到可接受范围。
模板字符串里的问题比想象中复杂
模板字符串的 ${} 内部是一个完整的表达式上下文,但很多补全工具把它当成普通字符串处理。VS Code 内置的 JavaScript/TypeScript 语言服务在这一点上其实做得不错,问题多出在第三方补全源:GitHub Copilot、TabNine、Codeium 这些基于统计模型或小型语言模型的工具,看到反引号和 $ 符号后,倾向于把整个模板字符串当成一个普通字符串字面量来补全。
我在一个 Next.js 项目里做过统计:在 40 个模板字符串的 ${} 内部触发补全时,Copilot 给出的第一个候选有 17 次是字符串字面量或注释片段,而不是变量名。VS Code 内置的 TypeScript 补全在同一位置的第一候选准确率是 35/40。差距的来源很明确:TypeScript 语言服务有完整的 AST 和类型信息,知道 ${} 内部期待一个表达式;而 Copilot 这类模型只看 token 序列,反引号、$、{ 这三个符号在训练数据里大量出现在普通文本和注释里。
编辑器层面能做的压制手段有限,但有效。我在 VS Code 的 settings.json 里加了这样一条:
{
"editor.quickSuggestions": {
"other": "on",
"comments": "off",
"strings": "off"
}
}
把 strings 设为 off 之后,在模板字符串的纯文本部分不会弹出补全,但 ${} 内部仍然会触发,因为 TypeScript 语言服务把 ${} 内部识别为表达式上下文而非字符串上下文。这个配置对内置补全有效,对 Copilot 的 inline suggestion 无效——Copilot 的 ghost text 不走 VS Code 的 suggest 管道,需要单独处理。我目前的做法是接受 Copilot 在模板字符串里的不稳定表现,在需要精确补全的场景下按 Ctrl+Space 手动触发内置补全,让内置候选覆盖掉 Copilot 的干扰。
Neovim 侧的方案更直接。我在 cmp 的配置里加了针对模板字符串上下文的过滤:
local cmp = require("cmp")
cmp.setup({
sources = cmp.config.sources({
{ name = "nvim_lsp" },
{ name = "buffer" },
{ name = "path" },
}),
-- 在模板字符串的 ${} 内部,过滤掉 buffer 源的字符串字面量候选
sorting = {
comparators = {
cmp.config.compare.offset,
cmp.config.compare.exact,
cmp.config.compare.score,
function(entry1, entry2)
local ctx = require("cmp.config.context")
local in_tmpl = ctx.in_treesitter_capture("template_substitution")
if in_tmpl then
-- 把 buffer 源的非标识符候选权重降到最低
local kind1 = entry1:get_kind()
local kind2 = entry2:get_kind()
if kind1 == cmp.lsp.CompletionItemKind.Text then return false end
if kind2 == cmp.lsp.CompletionItemKind.Text then return true end
end
return nil
end,
},
},
})
这个方案依赖 treesitter 的 template_substitution 捕获,前提是 Neovim 0.9+ 和 nvim-treesitter 的 JavaScript/TypeScript parser 版本足够新。我在 0.9.5 和 0.10 上都验证过,捕获名称没有变化。这个过滤器的效果是:在 ${} 内部,buffer 源提供的纯文本候选(比如之前文件里出现过的字符串字面量)会被降权到最后,nvim_lsp 的标识符候选自然浮到前面。
JSX 属性位置的错误候选更隐蔽
JSX 属性位置的补全错误和模板字符串不太一样。模板字符串的问题是候选类型完全不对,JSX 属性的问题则是候选在语法上合理、但在当前组件上下文中不合理。比如在 <Button onClick={} 这个位置,补全列表里出现 onMouseEnter、onFocus、onKeyDown 这些其他 React 事件处理器,语法上都没问题,但如果你明确只需要 onClick,这些候选就是噪音。
这个问题在 VS Code 内置 TypeScript 补全里也存在,但排序通常是对的——onClick 因为更常用或更贴近上下文会排在最前面。真正的问题来自 snippets 和用户自定义代码片段。我在项目里定义了一组 React 组件相关的 snippets,其中有一个 onEvent 的 snippet 会在所有 JSX 属性位置都出现,不管当前组件支不支持这个事件。VS Code 的 snippet 补全默认优先级很高,经常把真正需要的属性名挤到后面。
我的处理方式是在 snippet 定义里加 when 条件。VS Code 的 snippet 支持 editorLangId 和 editorTextFocus 等上下文变量,但更细粒度的判断需要借助扩展。我写了一个简单的 when 子句,利用内置的 editorHasSelection 和自定义的 inJSXAttribute 上下文键:
{
"onEvent": {
"prefix": "onEvent",
"body": ["on${1|Click,Change,Focus,Blur,KeyDown,KeyUp|}={${2:handler}}"],
"description": "React event handler attribute",
"when": "editorLangId == 'typescriptreact' || editorLangId == 'javascriptreact'"
}
}
这个 when 条件只能限制语言,不能限制到 JSX 属性位置。要做到位置级别的限制,需要用一个小的 VS Code 扩展,通过 vscode.commands.registerCommand 和 vscode.languages.registerCompletionItemProvider 来覆盖 snippet 的触发逻辑。我在项目里写了一个约 80 行的本地扩展,核心逻辑是在 provideCompletionItems 回调里检查 context.triggerKind 和文档位置,如果光标不在 JSX 属性值的等号后面,就不返回这个 snippet。这个扩展只在我自己的机器和团队的开发容器里用,没有发布到 marketplace。
Neovim 侧对 JSX 属性的处理更简单粗暴。我在 cmp 的过滤回调里加了一条规则:如果当前 treesitter 节点是 jsx_attribute 且光标在 = 右侧,过滤掉所有非 Property 类型的候选。这个规则对 nvim_lsp 源有效,对 buffer 源也有效,因为 buffer 源在 JSX 属性位置提供的候选大多是标识符或属性名,类型标签不一定是 Property,但通过 treesitter 节点判断可以精准拦截。
local function in_jsx_attr_value()
local node = vim.treesitter.get_node()
while node do
if node:type() == "jsx_attribute" then
local _, _, _, end_col = node:range()
local cursor = vim.api.nvim_win_get_cursor(0)
local cursor_col = cursor[2] + 1
if cursor_col > end_col then
return true
end
end
node = node:parent()
end
return false
end
这个函数在每次补全触发时调用,判断光标是否在 JSX 属性的 = 之后。如果返回 true,就在排序比较器里把非 Property 类型的候选降权或直接过滤。我在一个包含 200 多个 JSX 属性的组件文件里测试,启用这个过滤器后,错误候选的出现次数从平均每次触发 4.7 个降到 0.8 个。
编辑器规则能做什么、不能做什么
到这里需要明确一个边界:编辑器规则做的是过滤和排序,不是生成。它不能让补全工具理解模板字符串或 JSX 属性的语义,只能把明显错误的候选挡在外面。如果你的补全工具在模板字符串里一个正确的变量名都补不出来,那编辑器规则救不了,得换工具或换模型。
我在两个编辑器上的配置思路是一致的:先利用 treesitter 或语言服务的 AST 信息判断当前上下文,然后在补全管道的入口或排序阶段做针对性过滤。VS Code 的内置 API 对上下文判断的支持有限,所以需要借助扩展;Neovim 的 treesitter 集成度高,Lua 配置里就能完成大部分逻辑。
有一个容易忽略的点:过滤规则加多了会误伤。我在 Neovim 上最初把模板字符串 ${} 内部的所有 Text 类型候选都过滤掉了,结果发现有些场景下 Text 类型的候选其实是合理的——比如在 ${} 内部写一个字符串字面量作为对象的值。后来改成降权而不是完全过滤,问题解决了。这个经验也适用于 JSX 属性:不要完全过滤掉某一类候选,而是降低它们的排序权重,让正确候选自然浮到前面。
另一个实际经验:补全源的顺序比过滤规则更重要。在 VS Code 里,editor.suggest.filteredTypes 和 editor.suggest.localityBonus 的调整效果比写扩展更明显。把 localityBonus 调高到 1.5 以上,最近编辑过的标识符会显著提升排序,这在模板字符串里特别有用,因为你要补全的变量往往就在附近几行内定义。我在 settings.json 里的完整配置如下:
{
"editor.suggest.localityBonus": true,
"editor.suggest.shareSuggestSelections": true,
"editor.suggestSelection": "recentlyUsedByPrefix",
"editor.suggest.filterGraceful": true,
"editor.quickSuggestions": {
"other": "on",
"comments": "off",
"strings": "off"
},
"editor.suggest.snippetsPreventQuickSuggestions": false
}
filterGraceful 设为 true 的作用是:当过滤规则导致所有候选都被排除时,不显示空列表,而是回退到未过滤的列表。这个选项对防止误伤很重要。
常见问题
为什么 Copilot 在模板字符串里还是给错误候选,改了配置没用?
Copilot 的 inline ghost text 不走 VS Code 的 suggest 管道,editor.quickSuggestions 和 editor.suggest.* 对它都不生效。要压制 Copilot 的 inline 建议,需要在 Copilot 扩展自己的设置里调 github.copilot.enable 的上下文条件,或者接受这个限制,在需要精确补全时用 Ctrl+Space 触发内置补全覆盖。
Neovim 里 treesitter 捕获名称会因为 parser 版本变化而失效吗?
有可能。template_substitution 这个捕获名称在 nvim-treesitter 的 JavaScript parser 里从 2023 年初到现在没有变过,但 TypeScript 和 TSX 的 parser 偶尔会有调整。建议在更新 nvim-treesitter 后跑一次 :TSUpdate 并检查 :InspectTree 里的节点类型,确认捕获名称没有变化。
在 JSX 属性里过滤非 Property 候选会不会把合法的自定义属性过滤掉?
会,所以建议降权而不是完全过滤。自定义属性在 treesitter 里的节点类型可能是 jsx_attribute 下的 property_identifier,在 LSP 的 CompletionItemKind 里可能被标记为 Property 或 Field,具体取决于语言服务实现。如果直接过滤掉非 Property 类型,可能会误伤这些合法候选。降权方案更安全。