首页 / 资讯中心 / 文章详情

Neovim中滚动上下文丢失?用tree-sitter实现作用域链浮窗插件

Neovim中滚动上下文丢失?用tree-sitter实现作用域链浮窗插件 ★ FEATURED ARTICLE
在 Neovim 里写大文件最让人烦躁的往往不是找不到报错而是滚着滚着自己都不知道自己滚到了哪个函数里。我大概在两三个月前被这个事烦到忍无可忍于是动手写了一个插件名字就叫context-mode。它做的事情很简单当光标在长文件里上下移动时在窗口顶部浮出一条上下文条把当前所在的函数、方法、类、甚至嵌套的 if 块一次性列出来滚动再多也不会丢。这篇文章是围绕这个项目的完整复盘从为什么做、核心原理、关键代码到实测踩坑都会讲到。适合那些想在 Neovim 里折腾类似功能的开发者也适合想了解编辑器滚动上下文这种机制到底怎么设计的人。如果你只是想找一个现成插件用文章末尾的扩展思路一样能给你启发。1. 痛点复盘滚动 800 行代码时我们到底丢掉了什么信息1.1 一个每天都会发生的小事故有个业务文件接近 800 行里面分布了五六个大方法每个方法内部又有多个条件分支。我经常遇到的情况是往上翻看一段逻辑看完想回到之前编辑的位置却发现顶部完全没有函数名提示满屏都是缩进长得差不多的代码块。我最早的习惯是按zf折叠或者直接搜索function、def这类关键字。这些操作本身不复杂但一天重复几十次之后累计的时间损耗和注意力打断就非常可观。更麻烦的是当我在一个 200 行的if分支套for、再套回调函数时折叠和搜索都救不了——因为真正需要知道的是我当前处在哪个完整作用域链里而不是某函数在第几行。1.2 为什么记住位置解决不了问题有人会建议用 mark、用g;跳转历史。但 mark 记录的是位置不是上下文。你记住的是一个光标的坐标可当你回到这个坐标时如果上下文已经变了——比如函数被重命名、代码块被折叠、窗口滚动位置不同——你依然要重新阅读周围的代码才能确认自己在哪。g;解决的是我上次改过哪里它本质上属于编辑历史不是作用域导航。所以我意识到问题不是缺少跳转手段而是缺少一个常驻的、能实时反映当前作用域归属的 UI 元素。1.3 同类方案横向对比动手之前我看过几个现成方案的实现思路各有取舍方案展示形式动态更新实现成本主要局限图形 IDE 的黏性滚动顶部多条覆盖层滚动实时无只能在特定编辑器里用context.vim当前窗口上方高亮上下文行实时低展示的是原始文本行不聚合作用域链winbar 显示当前函数窗口顶栏一层名称切换窗口时低只能显示一层无法表达嵌套context-mode本项目浮窗多行作用域链滚动实时中依赖 tree-sitter对比下来我最想要的是多行嵌套作用域链这个效果。IDE 里的黏性滚动正是长这样顶部固定显示namespace - class - method - if 块每一条对应一个层级。这种形式比单行 winbar 信息量大得多而且一眼就能看清层级关系。1.4 我给 context-mode 定的三条边界正式动手前我先给自己划了三条规定避免项目失控只做展示和锚定不做复杂跳转。第一版甚至连鼠标点击都不做先把显示当前作用域链这件事做到极致。重绘必须足够快。我给自己定的标准是单次重绘延迟不超过 16ms也就是一帧的时间否则宁可降级为定时合并重绘。不侵入 buffer 内容。任何把上下文写进当前文件区域的方案都会破坏 diff、搜索和高亮机制所以从一开始就排除在bufline上直接画文本的做法。这三条边界后来帮了大忙因为在开发过程中会出现大量这里要不要顺手加个功能的诱惑没有边界就很难收敛。2. 核心原理一棵 tree-sitter 树如何变成一条顶部锚链2.1 为什么要从 AST 拿作用域而不是从缩进猜我见过不少人第一反应是按缩进判断作用域不就行了。确实Python 代码的缩进有一定语义但绝大多数字符串里的缩进是骗人的。例如 JavaScript 里多行字符串、对象字面量、模板字符串行首缩进经常产生视觉上的伪作用域Lua 里一个长函数调用跨多行时参数列表的缩进也极其容易误判成新块。更关键的是同一个项目里会混用 2 空格、4 空格和 Tab不同语言对缩进的解读又不一样。靠缩进统计做出来的东西在第一天能用遇到真实代码库里的怪异格式就崩。所以根本解法是用语法解析器。tree-sitter 能把代码解析成有明确节点类型的 AST其中function_definition、method_definition、class_definition、if_statement这类节点天然就是作用域边界。我们只需要沿着 AST 从叶子节点往根节点走把路径上所有作用域类型的节点收集起来就得到了完整的作用域链。2.2 取光标所在作用域链的完整算法以 Neovim 的 tree-sitter 集成来举例。核心算法分三步拿到当前 buffer 的 parser并取得解析后的根节点。用named_descendant_for_range定位包含光标的最深命名节点。从该节点开始沿着parent()一路向上把所有节点类型命中作用域集合的节点记录到一个数组里最后反转数组顺序输出。有一个细节容易踩坑named_descendant_for_range里的行号是 0 起始的而vim.fn.line(.)返回的行号是 1 起始的所以取行号后必须减 1。列号同理但一般不传整个缓冲区的列传光标列即可。2.3 作用域链的显示与最高层级控制真实代码里的作用域链往往很长。一个 Lua 模块顶层定义类、类里有方法、方法里有循环、循环里有回调沿着parent()走可能得到 5 到 6 层。如果全显示出来顶部浮窗会占掉接近四分之一屏幕这就从辅助信息变成视觉灾难。我的处理方式是设置max_depth配置默认只显示最近 3 层。超出部分不显示但保留在内部数据模型里用于后续扩展比如点击跳转和历史记录。显示格式上每一层取节点的核心名字函数节点取函数名条件节点取 if、while 这类关键字类节点取类名。2.4 滚动锚定到底应该追光标还是追视口这是整个项目里最容易被误解的概念。很多人的第一直觉是把光标所在的函数显示在顶栏就够了但 IDE 黏性滚动的实际行为不是这样它显示的是当前视口顶部跨过的那一组作用域。假设你盯着文件中间修改代码光标在函数 A 内部但你把视口滚到了下方函数 B 的区域这时顶栏应该显示函数 B 的上下文链而不是函数 A 的。所以真正的锚定对象是vim.fn.line(w0)即视口第一行的 buffer 行号不是光标行号。计算逻辑变成取w0所在位置对应的最深节点然后向上收集祖先节点。这样无论光标跑到哪里只要视口在滚动顶栏就跟随视口顶部更新和 IDE 手感一致。2.5 渲染层选型为什么是浮窗Neovim 里能承载自定义 UI 的层有几个状态栏、标签栏、winbar、buffer 内虚拟文本、浮窗。我做选择时逐个否决状态栏和标签栏是全局的无法精细表达某个窗口自己的缓冲区内作用域。winbar 虽然跟随窗口但只有一行嵌套层级展示能力天然不足。buffer 内的虚拟文本虽然可以逐行插入但本质还是在修改文档的视觉内容会影响diff、消息跳转和某些插件。浮窗可以多行、可以设置zindex、可以focusablefalse避免抢焦点还能用relativewin锚定到具体窗口。所以我最终选择浮窗。每次只创建一个覆盖在窗口顶部的浮窗宽度跟随窗口宽度高度按当前作用域链条数计算内容更新时直接重设浮窗 buffer 的文本行即可。3. 骨架实现从零写出一个可用的 context-mode3.1 工程结构插件按 Neovim 常见结构组织入口和核心逻辑分离context-mode/ ├── plugin/context-mode.lua -- 加载入口 ├── lua/context_mode/init.lua -- 主模块事件与调度 ├── lua/context_mode/collector.lua -- tree-sitter 作用域收集 ├── lua/context_mode/renderer.lua -- 浮窗渲染与关闭 └── lua/context_mode/config.lua -- 默认配置plugin/context-mode.lua里只做一件事调用require(context_mode).setup()。所有逻辑都在 Lua 模块里方便插件管理器按需加载。整个项目的核心代码量不大收集器约 90 行渲染器约 120 行主模块约 80 行属于一个非常适合自己维护的中小型插件规模。3.2 事件驱动与节流浮窗 UI 需要监听四类事件光标移动、光标在插入模式移动、窗口滚动、窗口切换。在 Neovim 里对应CursorMoved、CursorMovedI、WinScrolled、WinEnter。local augroup vim.api.nvim_create_augroup(ContextMode, { clear true }) vim.api.nvim_create_autocmd({ CursorMoved, CursorMovedI }, { group augroup, callback function() require(context_mode).on_cursor_moved() end, }) vim.api.nvim_create_autocmd(WinScrolled, { group augroup, callback function() require(context_mode).on_win_scrolled() end, }) vim.api.nvim_create_autocmd({ WinEnter, BufWinEnter }, { group augroup, callback function() require(context_mode).refresh() end, })每次事件触发时先判断是否需要真正重绘如果vim.fn.line(w0)没变且当前光标没有跨入新的作用域节点直接跳过。这一步能挡住大量无意义重绘比如你在同一个函数里左右移动光标时根本不需要刷新顶栏。真正的重绘放在vim.schedule里做并用一个定时器做 8ms 的防抖。这样连续滚动时浮窗最多每 8ms 更新一次不会让界面因为高频事件卡顿。3.3 collectortree-sitter 作用域收集器的核心代码这是整个插件的灵魂。下面是简化后可运行的collector.lualocal api vim.api local DEFAULT_SCOPE_TYPES { function_definition true, method_definition true, class_definition true, class_declaration true, module_definition true, if_statement true, while_statement true, for_statement true, try_statement true, catch_clause true, } local M {} function M.collect(bufnr, row, col) local ok, parser pcall(vim.treesitter.get_parser, bufnr) if not ok or not parser then return {} end local root parser:parse()[1] if not root then return {} end -- row/col 需要是 0 起始 local node root:named_descendant_for_range(row, col, row, col) local chain {} while node do local type node:type() if M.config.scope_types[type] then local name M._name_for(node) table.insert(chain, 1, { type type, name name, start_row node:range(), }) end node node:parent() end return chain end function M._name_for(node) -- 优先取命名子节点中的第一个 id/name 节点 for child in node:iter_children() do local child_type child:type() if child_type:match(name$) or child_type:match(identifier$) then local text vim.treesitter.get_node_text(child, api.nvim_get_current_buf()) if #text 0 and #text 80 then return text end end end -- 兜底没有名字就用节点类型 return node:type() end return M注意_name_for里有一个处理如果函数节点没有显式命名比如匿名函数就回退到节点类型名。真实代码里匿名函数非常多如果只取名字顶栏会出现大段空白这个回退能保住基础可用性。3.4 renderer浮窗渲染与关闭时机渲染层要维护每个窗口对应的浮窗实例。我以一个window_state表保存winid - { floating_win, floating_buf, last_chain_key }的映射。local M { states {}, } local NS api.nvim_create_namespace(context_mode_hl) function M.update(winid, chain) local state M.states[winid] if not state then state M._create_window(winid) M.states[winid] state end local lines {} for _, item in ipairs(chain) do local text string.format(%s %s, item.type, item.name) table.insert(lines, text) end -- 浮窗内容更新 api.nvim_buf_set_lines(state.buf, 0, -1, false, lines) -- 给函数名部分加一点高亮标记这里简化为每行一个高亮 for i 1, #lines do api.nvim_buf_set_extmark(state.buf, NS, i - 1, 0, { end_col #lines[i], hl_group Comment, }) end end function M._create_window(winid) local buf api.nvim_create_buf(false, true) local win api.nvim_open_win(buf, false, { relative win, win winid, row 0, col 0, width vim.api.nvim_win_get_width(winid), height 1, style minimal, focusable false, zindex 50, noautocmd true, }) return { win win, buf buf } end function M.close(winid) local state M.states[winid] if state then pcall(api.nvim_win_close, state.win, true) pcall(api.nvim_buf_delete, state.buf, { force true }) M.states[winid] nil end end return M浮窗创建后真正影响体验的是关闭时机。我在主模块里监听WinClosed、BufLeave、BufDelete把这些事件对应的窗口状态清掉否则会积累一堆不可见的浮窗句柄内存占用会持续上涨。3.5 配置项设计默认配置需要尽量克制但又要保留足够的定制空间。我最终定了这些配置项配置项默认值作用enabletrue总开关max_depth3顶栏最多显示多少层作用域debounce_ms8重绘防抖毫秒数zindex50浮窗层级低于补全弹窗focusablefalse浮窗是否可聚焦scope_typesDEFAULT_SCOPE_TYPES哪些节点类型算作用域disable_filetypes{ help, quickfix }不做处理的部分文件类型配置合并逻辑非常常规vim.tbl_deep_extend把默认表覆盖成用户表。但scope_types不建议让用户直接覆盖整表而是合并追加否则用户想加一种语言的作用域类型时会把默认的 Lua/JS 函数节点全部丢掉。4. 踩坑日志抖动、折叠、diff 与大文件性能4.1 浮窗与主窗口的滚动赛跑第一版跑起来后最常见的问题是快速滚动时浮窗位置偶尔闪一下好像永远比主窗口慢半拍。排查链路是这样的我在WinScrolled回调里加了日志确认事件触发了。手动调用更新函数浮窗能立刻刷新排除渲染函数本身的问题。最终定位到是事件处理时序的问题WinScrolled触发时视口已经滚动到了新位置但我为了防抖把浮窗更新包进了vim.defer_fn。在快速滚动时旧定时器还没执行新的滚动事件已经把w0又推进了于是中间错开了一帧。解决办法不是去掉防抖而是拆分去重。WinScrolled回调里先同步记录最新的w0然后vim.schedule调度重绘下一次事件过来时如果w0没变直接取消排队。这样既避免重复渲染又不会出现一帧的延迟差。4.2 折叠区与不可见行导致的错误锚定折叠模式下遇到另一个坑。如果当前视口顶部正好落在折叠区域中间tree-sitter 返回的w0对应节点可能是折叠块内部的子节点但折叠块的父级折叠行在视觉上已经替换成了foldtext。此时顶栏会显示一个看起来存在、实际不可见的上下文链。我用了一个折中方案当检测到光标或视口第一行落在折叠区域内部vim.fn.foldclosed(w0) ~ -1时向上多走一层取该折叠块的父级节点作为锚定起点。效果上顶栏会显示折叠块所属的外层函数而不是折叠块内部某个不知道叫什么名字的子块。对于点击跳转要求先执行一次zv展开当前折叠这可以通过给浮窗行绑定快捷动作实现。4.3 insert 模式与补全弹窗的冲突浮窗本身设置focusablefalse后不会抢焦点但插入模式开启补全时原生补全弹窗的zindex和浮窗重叠会出现补全内容被顶栏盖住的情况。这个问题在英文代码里不明显中文注释和较长的补全项特别严重。我给出的处理是当vim.fn.pumvisible() 1时暂停顶栏刷新保留上次内容。补全关闭后再恢复。这个策略对 native completion 和主流补全插件都有效因为这些插件在补全弹出期间会持续触发CompleteChanged事件我们只需要在事件回调里做一个简单的可见性判断。另外补一个容易忽略的点创建浮窗时noautocmd true必须设否则浮窗一旦获取焦点或者关闭会连环触发WinEnter、WinClosed导致重绘递归。4.4 大文件与增量解析性能数据说话tree-sitter 本身是增量解析每次编辑后parser:parse()只重新解析受影响区域理论上性能不错。但我在 20000 行源码文件上测试发现即使增量解析每次取根节点再从上往下定位named_descendant_for_range的开销也不小尤其是再加上_name_for的文本提取。实测数据大概是这样文件规模首次解析滚动重绘延迟优化前滚动重绘延迟优化后1000 行 Lua12ms3ms1.5ms8000 行 JS40ms8ms3ms20000 行 JS120ms22ms6ms优化手段有三条缓存parser:parse()[1]得到的根节点只在changedtick变化时重新解析。_name_for里限制节点文本长度不超过 80 字符超过就截断避免把超长正则表达式整个放进顶栏。增加一个50ms 最大重绘频率的兜底。极端情况下宁可丢几帧也不能让编辑器输入手感卡顿。5. 让 context-mode 从定位工具升级成导航工具5.1 顶栏点击跳转第一版只做展示后我实际用了两周发现最缺的能力是点击顶栏某一层跳到源文件对应行。原理很简单收集器返回每个节点的start_row渲染时把行号和start_row存进浮窗 buffer 的 extmark 里然后注册MousePressedautocmd。vim.api.nvim_create_autocmd(MousePressed, { callback function() local winid vim.fn.win_getid() local state require(context_mode).get_state_by_float(winid) if state then local row vim.fn.line(.) - 1 local target state.row_to_start[row] if target then local main_win state.main_win vim.api.nvim_win_call(main_win, function() vim.api.nvim_win_set_cursor(0, { target, 0 }) vim.cmd(normal! zt) end) end end end, })不过要注意这个列表里的回调是全局的真正的插件要加窗口类型判断避免挡住其他浮窗的鼠标事件。如果你的使用场景以键盘为主也可以不启用鼠标转而给浮窗每条内容绑定数字前缀比如按1、2、3直接跳到第 1、2、3 层作用域起点这样在纯终端环境里同样顺手。5.2 把顶栏延伸成迷你 symbols 入口顶栏只显示当前链条但有时候我想跳转到文件里任意一个函数而不只是当前链条内的祖先。后来我在context-mode上叠了一个goto_symbol功能用 tree-sitter 遍历整个 buffer 根节点收集所有命名函数节点然后用vim.ui.select展示一个列表选中后跳转。这个功能的代码量只有 60 行左右但带来的体验变化很直观滚长文件时不再需要先打开大纲、再手动搜索直接在顶栏呼出一个上下文字段列表就能跳。很多这插件是不是太重了的顾虑其实可以靠复用已有的作用域收集能力来消除。5.3 上下文历史跳转不丢修改点最后加的扩展是一个上下文历史栈。原理是每次光标跨入一个新的作用域链就把旧的链条和修改点压入栈中跳转后如果需要回到原来的上下文按一下快捷键就能恢复。我自己的使用场景非常典型在某个 300 行函数里改 bug临时用gd跳去查看函数定义又跳去查一个类型引用绕了一圈之后按context-mode的上下文回退键一下就能回到刚才正在修改的函数链条位置。相比普通的 jump list这个历史记录是按作用域切换来组织的跳跃跨度大、语义清晰不会因为移动几个字符就把历史污染了。这个小功能让我意识到context-mode真正提供的能力不是一个浮窗而是当前代码位置的语义感知。一旦有了语义感知定位、跳转、历史回顾都可以在这个基础上自然生长出来。最后说点个人体会。做这个项目之前我以为难的是 tree-sitter真正写完发现难的是怎么让一个常驻 UI 在所有情况下都不让人觉得烦。光是为了滚动时不抖动和补全时不遮挡这两个问题就花掉了接近一半的调试时间。插件现在仍有很多没做完的地方比如对宏生成的代码、超大单行 JSON、以及虚拟文本密集的大文件性能上还有优化空间。但如果你也属于那种一天要在长文件里滚动八百次的人与其到处找现成方案不如照着上面的思路自己撸一个——很多边界问题只有落到自己手头才知道该往哪个方向取舍。
阅读完成 · 觉得有帮助?
咨询建站