第一次意识到这个问题的场景我记得很清楚:在三千行的函数里排一个 bug,滚着滚着就看忘了自己到底在哪个作用域里,函数返回值是什么类型,循环嵌套到第几层。连续翻了十几屏,最后还是得一路滚回顶部去确认入口参数。后来很多编辑器其实都加了类似“面包屑”“当前函数名”的辅助,但始终没有一个顺手、可解释、能自己改的体验。于是我自己折腾了一个轻量级的工具思路,名字就叫 context-mode。
什么是 context-mode?说白了就是让编辑器始终知道“你现在在看哪一屏”,并且在当前可视区域顶部或侧边固定展示这个上下文。它不是状态栏上那行只显示文件名的小字,而是会随着光标位置滚动、实时更新的结构提示条。它可以是一个插件,也可以是一套脚本机制,甚至能集成到终端工具链里。解决的是长文件浏览时空间感丢失的问题,适合所有喜欢用编辑器处理大文件、读框架源码、排查复杂问题的开发者。
这篇文章不是介绍某个高大上的框架,而是把我从灵光一闪到实现、从难用到顺手踩过的坑和最终定下来的方案完整地拆给你看,代码可以直接抄,思路完全可以换成你自己常用的编辑器来实现。
1. 项目背景与整体设计思路
1.1 先还原一下痛点:长文件里的“我在哪”
我们平时写代码,很少一次性从第一行看到最后一行,更多是在多个函数之间来回跳。问题在于,编辑器默认展示的是一块无高度的文本切片。就好像你钻进了一座大楼,只看得到当前脚下的这一层楼梯,却完全不知道自己在几楼、是什么区域。这在现代工程动辄几千行一个文件的环境下,特别难受。
有些编辑器会在滚动条上画小地图,有的在底部状态栏显示当前函数名,但它们都有一个问题:信息位置和视觉注意力不在一起。状态栏离视线太远,小地图的抽象度太高。不能在一屏之内同时回答“我在哪个函数里”和“这个函数的入口参数是什么”。
context-mode 最初的诉求就两点:上下文的显示位置必须固定,且不能占用太多屏幕;上下文的内容必须和当前光标所在位置精准关联,而不是整个文件的静态描述。它不是又一个小地图,它更像是一根始终悬浮在视野内的“楼层指示牌”。
1.2 设计目标:一条极简的上下文提示行
因为我不想为它单独开一个侧边栏,也不打算把它做成一个复杂的面板,最终明确就是这么几点:
- 在编辑器可视区域顶部渲染一行 sticky 区域,用来显示当前所处的函数、class、循环块或 Markdown 标题层级。
- 这一行的内容随着光标所在结构变化,光标离开某个函数后自动切换。
- 支持多层上下文同时展示,比如“Class UserService -> Method getUserById -> for loop i=0..n”,但每一层用分隔符串起来,一行放不下时折叠旧层。
- 渲染开销必须可以忽略,不能因为统计上下文导致编辑卡顿。
这个设计放在最终实现里,其实就是三个模块:结构解析器、上下文状态维护、渲染行。结构解析器负责回答“当前光标属于哪个节点”,状态维护负责记录“上一次的上下文是什么”,渲染行负责把结果画出来。
1.3 技术选型:能借力就别重造轮子
我自己常用的环境是 Neovim,所以最初的实现是基于 Lua 写的插件。但在正式动手前,我对照了三种解析方案:
- 直接走 LSP 的 documentSymbol 请求,得到的是语义层面的符号树,干净准确。
- 用 Tree-sitter 的语法树做节点定位,可以拿到非常精细的语法节点。
- 用正则匹配做兜底,适合那些没有语言服务器也没有语法解析器支持的文件类型。
最后定下来的组合是:优先 Tree-sitter,因为它的增量解析性能好;LSP 作为符号顺序的校准;正则方案只用于 Markdown、日志等非结构化文本。这个选择在后续使用里帮了我大忙——Tree-sitter 能提供实时解析,大文件下也不会明显拖慢光标移动。
2. 核心原理拆解:编辑器如何知道“当前上下文”
2.1 从语法树到上下文栈
绝大多数编程语言都有括号层级或者缩进层级。比如一个函数嵌套了一个循环,循环里又有 if 分支,那么当前光标所在的上下文,就是一个从文件根部开始不断向下深入的栈:
源文件 └── class UserService └── method getUserById └── for loop └── if user != null如果只是粗暴地从第一行往下找,每次光标移动都重新扫一遍,性能必然爆炸。Tree-sitter 的聪明之处在于,它能维护一棵增量更新的语法树,知道当前光标落在哪个 leaf 节点上。接下来只需要从这个节点逐级向上查 parent,把每一层有“命名意义”的节点结构记录下来,就得到了上文那个栈。
所以 context-mode 的第一个核心不是“怎么显示”,而是“怎么用最小成本拿到节点祖先链”。在 Neovim 里,这可以简化为vim.treesitter.get_node()获取光标节点,然后循环node:parent()获取祖先节点。每往上走一层,就判断节点类型是否是我们关心的一组集合,比如function_declaration、class_specification、if_statement。
2.2 判断“要不要刷新”:比你想的更讲究
如果光标每移动一个字符就重新计算上下文,即使有语法树,渲染频率也会让界面显得很神经质。所以我对刷新时机做了一个非常关键的调整:
- 光标在行内移动时,不刷新。
- 光标跨行移动时,只比较目标行号是否还落在当前最内层节点的范围内。
- 只有离开当前节点范围,才重新向上收集祖先链。
这个逻辑可以打个比方:你站在一个房间里,只要还没走出房门,不需要每次挪椅子都重新看一遍楼层导览;只有当你跨过门槛进入走廊或另一个房间,才需要重新看一下楼层。实际实现时,可以用当前节点的start_row和end_row和光标行号做一次区间判断。
一个很容易被忽略的细节是:不同语言里函数体的结束行判定不同。Python 里函数体结束可能是下一个同缩进代码块开始的上一行,而 JS 里可能是}所在的那一行。统一用 Tree-sitter 节点范围反而最准确,因为它已经把语言的结束规则处理好了。
2.3 渲染策略:不要做“永远置顶”,要做“上下文行”
我一开始用的是win_setheight创建一个真正独立的顶部窗口,里面显示上下文。但很快发现一个问题:新建窗口会挤压主编辑区的可视范围,当我反复滚动时,顶部窗口的存在感太强。后来换成了在当前窗口内通过虚拟文本和 extmark 渲染一条 sticky 效果,这才真正达到“像在读长文时书签一直浮在眼前”的感觉。
具体渲染不复杂:在当前窗口的第一行位置插入一个 extmark,然后给这一行配置virt_text和virt_text_pos=overlay。内容就是当前上下文栈的文本表示。关键是不要让它挡住代码本身的第一个行。如果页面本身有滚动,则需要让第一行始终可见,Neovim 里可以用nvim_buf_set_extmark的sticky特性配合hl_mode。
这个设计和 state 更新需要严格分开。状态更新可以异步,渲染尽量同步且轻量。我甚至建议在非交互模式(比如打开文件、长时间编辑后批量解析)时,上下文更新可以延迟 80 到 120 毫秒,避免 CPU 突然飙高。
2.4 上下文栈的容量和压缩规则
上下文不可能无限展示。如果文件里嵌套了七八层,再叠加 class、function、closure、loop,一行文本很容易爆掉。我的压缩规则是:
- 最多显示最近三层有效上下文。
- 中间层用缩略名,比如只显示方法名,不带参数列表,除非是当前最内层。
- 最内层上下文可以带上关键签名,比如
getUserById(id: number): Promise<User>,前提是 LSP 能提供。 - 如果超过三层,用“...”代替中间省略层。
这样处理之后,视觉上基本上稳定在一行以内。实测在 15 寸笔记本屏幕上,即使是最复杂的 React 组件文件,也不会出现长到换行的上下文行。
3. 实操过程:从零实现一个最小可用的 context-mode
3.1 搭建插件骨架与事件注册
我先在 Neovim 的配置目录里创建了一个名为context-mode的插件目录,实际只有一个lua/context_mode/init.lua文件。启动时通过autocmd CursorMoved,CursorMovedI,TextChanged,TextChangedI来捕获光标移动和文本变更事件。事件处理函数里先做行号判断,再决定要不要刷新上下文。
具体事件注册代码看起来像这样:
local group = vim.api.nvim_create_augroup("ContextMode", { clear = true }) vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, { group = group, callback = function() context_mode.update() end, }) vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { group = group, callback = function() context_mode.reset_cache() end, })这里有一个很容易踩的坑:TextChanged在撤销、插入回车这类操作中触发很频繁,如果把解析逻辑直接放到回调里,很容易卡。我自己的做法是给文本变更事件单独加一个 debounce 定时器,200 毫秒内多次触发只执行最后一次。
3.2 解析上下文:Tree-sitter 节点遍历
拿到当前光标节点后,向上收集的有效节点类型表会因语言而异。我维护了一个language_node_map配置,比如 C/C++ 会关心class_specifier、function_definition、for_statement;Python 会关心class_definition、function_definition、if_statement;Markdown 则关心atx_heading和thematic_break。
核心遍历逻辑简化后如下:
local function get_context_stack(bufnr, row, col) local parser = vim.treesitter.get_parser(bufnr) local root = parser:parse()[1]:root() local node = root:named_descendant(row, col) local stack = {} while node do local type = node:type() if config.valid_types[type] then table.insert(stack, 1, format_node(node, type)) end node = node:parent() end return stack end这段代码的重要细节是named_descendant,不是descendant。因为语法树的匿名节点如括号、逗号也会占据坐标,用命名节点可以跳过没有实际意义的符号。
3.3 用 LSP 补充语义信息
Tree-sitter 能告诉你“这是一个函数”,但不一定知道函数签名里每个参数的类型。这时候就轮到 LSP 了。我会在当前最内层上下文切换时,异步调用textDocument/documentSymbol或textDocument/hover,把得到的签名信息缓存到一个context_symbol_cache表里,key 是bufnr .. ":" .. line。
异步的好处是不影响光标移动。就算 LSP 返回慢了一拍,上下文行也还是先用 Tree-sitter 的结果渲染,后续再补上签名。实际体验下来,几乎感觉不到从“只有函数名”到“带参数列表”的过渡延迟。
3.4 渲染 sticky 上下文行
渲染部分我用 extmark 而不是独立窗口,因为这个方案在不同主题和不同布局下的兼容性都更好。示例:
local function render_context(bufnr, stack) local text = table.concat(stack, " > ") local ns = vim.api.nvim_create_namespace("context_mode") vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) if #stack == 0 then return end vim.api.nvim_buf_set_extmark(bufnr, ns, 0, 0, { virt_text = { { " " .. text .. " ", "ContextModeText" } }, virt_text_pos = "overlay", hl_mode = "combine", priority = 1000, }) end注意这里我固定放在第 0 行第 0 列,但真正使用时,如果你开启了行号栏或滚动冻结,可能需要根据win_get_position动态计算。否则可能出现上下文行偏移到代码第一行之后的问题。
4. 配置、快捷键与个性化
4.1 控制响应速度的几个配置项
我把和性能相关的参数都抽成了 config 表格,便于不同机器调优:
max_context_depth = 3,控制上下文栈最大显示层级。debounce_ms = 80,控制文本变更后的刷新延迟。enabled_filetypes = { "python", "javascript", "typescript", "rust", "c", "cpp" },限制启用范围,避免在超大日志文件里误触发。show_icon = false,如果终端支持 icon 也可以开启,但我觉得纯文本更干净。
这些配置最好在插件加载时读取,不要每次事件回调都重新读。我自己曾经犯过一个错误,把 config 读取写进了 render 函数里,每次渲染都会多一次 table 遍历,虽然不至于卡,但多少有些浪费。
4.2 让 context-mode 成为一个“可操作”的入口
只显示上下文还不够,很多时候我希望能直接从当前上下文跳到结构定义处。于是我给 context-mode 增加了两个快捷键:
- 按住
[c跳到当前上下文的父节点开头。 - 按住
]c跳到下一个同级节点,比如下一个函数。
跳转实现可以从上下文栈里取出最内层节点的 start_row,再用nvim_win_set_cursor跳转。如果配合foldtext,还能在折叠代码的时候直接用上下文行变成折叠标题。这其实是很自然的组合:折叠后代码看不见了,只剩上下文行描述,反而让文件结构更清晰。
4.3 处理非代码文件的特殊情况
传统 Tree-sitter 对 Markdown 也能解析,但不是所有文件类型都有语法支持。对纯文本的日志文件,我提供了一个正则兜底模式:匹配^\[.*\]这种时间戳,或匹配以四个空格开头的缩进行来计算嵌套层级。
这个兜底效果不算完美,但能覆盖大部分场景。我给它的定位是“聊胜于无”,因为 context-mode 的核心使用场景还是代码阅读,日志文件更适合用专业的日志浏览器。
5. 常见问题与排查技巧实录
5.1 移动光标时偶发放大卡顿
最典型的症状是:光标停在一行的中间,每次左右移动都感觉渲染有轻微延迟。我在排查时先关了所有其它插件,确认不是环境问题,后来发现原因是每次光标移动都是在拿到语法树节点后才判断是否需要刷新,而named_descendant在大文件里并不便宜。
解决办法是行号判断前置:先比较当前行号和当前上下文的最近一次节点范围,如果还在范围内,直接 return,根本不用走 Tree-sitter。加上这个判断后,行内移动的耗时就变成了纯粹的 Lua 数字比较,可以忽略不计。
5.2 进入插入模式后上下文消失
插入模式下光标移动的事件类型和普通模式不同,CursorMovedI需要单独注册。另一个原因则是插入模式下修改文本后语法树变了,但原有节点对象还持有旧范围,导致判断失效。我就在文本变更时主动清空节点缓存,让下一次取节点时重新解析。
这个问题的另一个表现是输入中文等组合字符时 extmark 被移动。后来我给渲染行设置了right_gravity = false和strict = false,让上下文行始终固定在窗口顶部,而不是跟着插入的文本跑。
5.3 终端下颜色渲染不对
context-mode 的显示行用了自定义高亮组ContextModeText,有些终端配色方案里背景色和前景色对比度太低,导致文字看不清。我刚开始只是复制了别人的高亮代码,没注意到那个配置是自己查询 term 背景后动态生成的颜色。换到 dark 主题后就出问题。
最终做法是放弃固定颜色,改用bg背景底色配合fg = "none",让上下文行像一块半透明的磨砂标签。这样即使终端换主题,也不会出现刺眼的颜色冲突。
5.4 问题速查表
| 症状 | 可能原因 | 解决思路 |
|---|---|---|
| 上下文行闪个不停 | 没有做行号范围判断 | 在 Tree-sitter 查询前先比较光标是否仍在原节点范围内 |
| 大文件打开缓慢 | 启动时对全文做了一次完整解析 | 延迟到首次光标移动后再初始化,并限制最大文件尺寸 |
| LSP 签名偶尔不匹配 | 文档符号是异步更新 | 增加基于 line 的缓存 key 并在文本变更时失效 |
| 上下文行遮挡代码首行 | extmark 优先级或坐标错误 | 使用priority=1000并检查是否启用了foldcolumn |
这些坑都很小,但每一个都会在关键时刻打击使用信心。我自己的经验是:做这类工具,一定要先保证“在任何情况下都不影响正常编辑”,再考虑“上下文信息有多丰富”。
6. 后续扩展方向与我的一些体会
目前 context-mode 已经稳定用了很长一段时间,后来我又给它加了一个小功能:在session保存时会记录每个文件的上下文位置,下次打开直接恢复。这个效果很适合调试场景,中断了几天后打开文件,还是停在当初研究的那个函数,上下文行也会自动恢复。
如果你也想做一个类似的工具,我建议不要一开始就想着支持几十种语言。先挑你日常编辑最多的那一种,比如 Python 或 JavaScript,把解析、渲染、性能这一整条链路跑通,再去抽象配置层。这样你会对“什么时候用 Tree-sitter、什么时候靠 LSP、什么时候干脆正则兜底”有非常直观的判断。
这种小工具最有意思的地方在于,它逼着你去理解编辑器底层的语法树和事件模型。无论是 Neovim 的 extmark 还是 VS Code 的 decorations,底层原理其实都差不多。一旦弄明白,以后再想做代码折叠、大纲导航、差异对比这一类功能,都会顺手很多。