xi-editor 插件架构全解析:基于 RPC 的多语言异步插件系统
【免费下载链接】xi-editorA modern editor with a backend written in Rust.项目地址: https://gitcode.com/gh_mirrors/xie/xi-editor
本文以 docs/docs/plugin.md 为核心骨架,结合 xi-editor 仓库中
rust/core-lib/src/plugins/、python/xi_plugin/等目录的真实实现,系统讲解 xi-editor 插件系统的设计哲学、进程模型、RPC 协议与增量编辑机制。读完本文,你将理解 xi 如何用异步 RPC 支撑任意语言编写的插件、如何解决插件编辑与用户编辑并发冲突,以及如何基于manifest.toml声明一个可被核心加载的插件。注意:原文档明确标注“mostly of historical interest”,高层设计至今仍然成立,但实现细节一直在演进,阅读源码时请以仓库当前代码为准。
设计哲学:异步、多语言、零绑定
几乎所有严肃的编程编辑器都提供某种扩展机制——常见的形式是脚本语言 + 编辑器对象绑定。xi-editor 走出了完全不同的一条路:
- 插件必须是异步的:插件通过 RPC(远程过程调用)与核心通信,前端(front-end)与后端(back-end)进程内部不提供任何语言绑定;
- 插件可以用任何语言编写:只要该语言能读写标准输入输出、能解析 JSON 即可;
- 慢插件不应阻塞输入:一个拖沓的插件不能影响打字或其他编辑操作;
- 崩溃插件不应导致数据丢失:插件进程与核心进程相互隔离,插件崩溃最多丢失该插件自身的状态。
这条哲学在仓库中得到了一致贯彻。核心侧在 rust/core-lib/src/plugins/mod.rs 的start_plugin_process中,为每个插件单独派生一个宿主线程,用ProcCommand::new(&plugin_desc.exec_path)启动插件子进程,并通过RpcLoop(来自xi_rpccrate)在 stdin/stdout 上跑起 RPC 主循环。插件进程异常退出时,调用core.plugin_exit(id, err)通知核心,而不会影响编辑器主体。
并发编辑的对齐难题
异步化的诱人之处显而易见,但真正的挑战在于:插件可能在你打字的同时主动产生编辑(例如自动缩进模式中插入缩进、自动补全右括号)。这些编辑与用户的编辑必须被调和(reconcile)。原文档给出了一个重要观察:
当用户编辑只涉及文本、插件编辑只涉及富文本注解(如语法高亮产生的 span)时,两者不存在根本冲突,系统最终会收敛到“对同一文本输入以批处理方式运行插件”所达到的状态——这可以作为增量计算类插件的正确性判据。
xi 的应对思路是采用某种形式的操作变换(operational transform)或差分同步(differential synchronization)。原文档还指出,这条路如果走通,一个额外的红利是让协作编辑(collaborative editing)变得实际可行——因为协作编辑本质上是多个并发编辑来源的调和问题。
不是一切都是插件
需要强调:在 xi 中并非一切都是插件。虽然前端与后端之间的通信同样由 RPC 中介,但其协议与插件协议完全不同(前端协议可参考 docs/docs/frontend-protocol.md)。
便利库(convenience library)与语言策略
绝大多数插件预期构建在一个便利库之上,该库提供:
- 缓冲区内容的缓存访问;
- 对 RPC 协议底层细节的抽象封装。
同时这个库必须保持小而简单,以便为多种语言提供实现。原文档的计划是:先用 Python 快速迭代插件协议与库,再用 Go 和 Rust 实现更高性能的版本。仓库现状与这一计划吻合——python/xi_plugin/目录下已有一套完整的 Python 实现,而 Rust 侧rust/core-lib/src/plugins/是宿主实现,rust/plugin-lib/与rust/lsp-lib/是 Rust 侧的插件库雏形。
包管理:交给生态
xi不提供包管理器,而是复用现有机制——用apt-get、brew、chocolatey安装插件应该同样可用。另一种思路是发布一个“发行版”:把 xi 的前端、后端与一组精选插件打包在一起(类似 Anaconda 之于 IPython 的做法)。
基本架构:进程、触发与配置文件
触发器(trigger)与配置文件
何时启动插件、如何启动插件,本身就是一个复杂问题。答案的起点是配置文件,它描述了:
- 触发器(trigger):何时调用插件;
- 插件的路径:可执行文件在哪;
- 一些选项。
触发器的形态包括:
- 键盘/菜单命令:用户按下快捷键或点击菜单项;
- 编程语言:本质上是基于文件扩展名的选择器;
- 其他事件的钩子:例如“每次保存前运行 gofmt”。
配置文件还声明插件期望的协议版本,xi 在实际使用中会尽量兼容一个版本区间。
三个调用层级
原文档定义了三种调用粒度,这一设计后来在 rust/core-lib/src/plugins/manifest.rs 中落地为PluginScope枚举:
| 层级 | 语义 | 源码对应 |
|---|---|---|
| one-shot(单次) | 编辑器启动子进程 → 执行一次 RPC → RPC 完成后关闭进程 | PluginScope::SingleInvocation |
| per-buffer(按缓冲区) | 进程在缓冲区存活期间一直保持打开;多个缓冲区需要同一插件时,xi 会启动多个实例 | PluginScope::BufferLocal(且是Default默认值) |
| editor-global(编辑器全局) | 单个进程需要处理多个缓冲区的 RPC,请求中携带 buffer id | PluginScope::Global |
PluginScope还提供了is_global()便捷方法,以及默认值实现(BufferLocal),见 manifest.rs。
启动性能与 TOML 格式
加载插件信息对启动时间影响巨大。xi 的策略是:启动时加载所有配置文件,但延迟执行插件二进制。因此配置文件格式必须解析极快。原文档倾向TOML(同时考虑 YAML,因为解析新版 Sublime Text 语法定义需要它)。
仓库的最终选择印证了这一点:核心侧 rust/core-lib/src/plugins/catalog.rs 的find_all_manifests在指定路径下查找manifest.toml;load_manifest用toml::from_str::<PluginDescription>解析,并支持相对路径归一化(./开头的exec_path会基于 manifest 所在目录做 canonicalize)。
开发期配置:编译时调用
为方便开发,配置文件可以指示插件在调用时编译(例如go run或cargo run)。但这一机制不应用于发布和分发插件——发布插件应当提供编译好的可执行文件。
配置文件的 include 机制
原文档设想配置文件可以include 另一个配置文件,这是处理可选插件(optional plugins)的首选方式:可选插件存放在默认不会被编辑器启动时处理的目录中,用户可编辑空间里的一个配置文件可以指向它。这样用户就能按需启用可选插件。
真实世界的 manifest.toml
仓库中两个插件给出了实际可参考的 manifest 写法。最精简的形式只需三个必填字段(见 rust/sample-plugin/manifest.toml):
# The plugin manifest describes the plugin and its capabilities. # At the very least it must contain these three fields: name = "sample-plugin" version = "0.0" exec_path = "./bin/xi-sample-plugin"而 rust/syntect-plugin/manifest.toml 展示了完整的字段:scope = "global"(全局作用域)、activations = ["autorun"](随编辑器启动自动运行)、commands = [],以及数十个[[languages]]表项,每个语言声明name、extensions、scope,可选first_line_match正则。这些语言定义会被PluginCatalog::make_languages_map聚合成核心可用的Languages映射,见 catalog.rs。
对应地,manifest.rs 中的PluginDescription结构完整刻画了 manifest 的 schema:
pub struct PluginDescription { pub name: String, pub version: String, pub scope: PluginScope, // 默认 BufferLocal pub exec_path: PathBuf, // Windows 平台自动附加 .exe 扩展名 pub activations: Vec<PluginActivation>, pub commands: Vec<Command>, pub languages: Vec<LanguageDefinition>, }其中PluginActivation定义了三种触发方式(见 manifest.rs):
Autorun:只要可用就运行;OnSyntax(LanguageId):当指定语法处于激活状态时运行;OnCommand:响应特定命令时运行。
PluginDescription通过#[serde(rename_all = "snake_case")]与 TOML/JSON 字段一一对应;platform_exec_path反序列化器在 Windows 上自动把路径补上.exe后缀(有对应单元测试platform_exec_path,见 manifest.rs)。
插件命令声明
manifest 还允许插件声明自定义命令(commands),核心通过Command、CommandArgument、PlaceholderRpc等结构描述它们(见 manifest.rs):
Command:包含人类可读标题、描述、命令 RPC 模板(PlaceholderRpc)与参数列表;CommandArgument:每个参数有标题、描述、key与arg_type(Number/Int/PosInt/Bool/String/Choice),Choice类型必须提供options(构造函数中有assert!校验);PlaceholderRpc:用method+params模板 +RpcType(Notification/Request)描述一条任意方法的 RPC。
test_serde_command单元测试(manifest.rs)展示了如何用 JSON 声明一个带“布尔参数 + 枚举选择参数”的命令,其中rpc_cmd.params里非参数键(如non_arg)会被原样透传给插件。
读访问缓冲区:快照、窗口与增量
初始窗口:以光标为中心的一兆字节
当插件附着到一个缓冲区时(对 one-shot 或 per-buffer 插件而言即启动时),xi 首先发送以光标为中心的一兆字节(1MB)缓冲区窗口。插件随后可通过 RPC 请求更多子串。
这一常数在 Python 侧被实现为MAX_FETCH_SIZE = 1024 * 1024(见 python/xi_plugin/host.py),每次get_data请求默认最多拉取 1MB。
快照语义
关键语义是:这些请求访问的是缓冲区的快照(snapshot),即使此刻用户正在并发编辑,插件读到的也是一致的数据视图。当本次 RPC 完成时,快照被释放,随后缓冲区的编辑会以**增量(delta)**的形式发送给所有订阅该缓冲区的插件。增量既可能触发新计算(如重新应用语法着色),也是便利库的核心工作对象。
便利库中的缓存:LineCache
“请求任意子串、或迭代所有行,由缓存与 RPC 在缓存未命中时兜底”这一设计,在 Python 侧落地为 python/xi_plugin/cache.py 的LineCache:
LineCache行为像一个行列表。可以用lines[idx]访问单行;如果某行不在缓存中,会发起一次阻塞式抓取,直到数据到达。
它维护offsets(每行起始字节偏移)、total_bytes与revision,提供linecol_for_offset(字节偏移 → 行列)、previous_word(前一个词)、get_data(按需分段拉取)、apply_update(把核心发来的增量应用到缓存)等方法。同一文件底部还有一组基于MockPeer的单元测试(test_linebuffer_init、test_update、test_linecol等),验证了缓存与增量应用的正确性。
增量 RPC 的数据结构
RPC 层面对应get_data请求与响应,定义在 rust/core-lib/src/plugins/rpc.rs:
PluginRequest::GetData { start, unit, max_size, rev }:按TextUnit取数据;TextUnit两种单位:Utf8(按字节偏移,保证返回合法 UTF-8 且包含该偏移)与Line(按行号,返回从指定行开始的块);GetDataResponse { chunk, offset, first_line, first_line_offset }:返回数据块及位置信息。
TextUnit::resolve_offset(rpc.rs)负责把“UTF-8 字节偏移”或“行号”解析为具体字节偏移,并做越界检查。
核心 → 插件的更新是PluginUpdate(rpc.rs),包含view_id、delta(Option<RopeDelta>,仅当增量超大时为None,此时插件应清缓存并手动抓取)、new_len、new_line_count、rev、undo_group(插件可回传给核心以关联撤销组)、edit_type与author。
写访问缓冲区:建议、调和与撤销组
插件既可以在 RPC 处理过程中发送增量,也可以自发地发送增量。这些增量分为两类:
- 文本缓冲区的编辑:如缩进、自动补全括号(electric brackets);
- 富文本 span:如语法高亮。
重要语义:这些增量只是“建议”(suggestions)。核心可能需要把它们与其他编辑调和,甚至可能丢弃。xi 会向插件回传结果,表明增量是原样接受、还是被修改。一个更聪明的插件可以基于更新的缓冲区信息重试——这看起来就是差分同步(differential synchronization)的合理实现路径。
PluginEdit:优先级与光标语义
插件发回的编辑在 RPC 层被建模为PluginEdit(rust/core-lib/src/plugins/rpc.rs),值得逐字段解读:
pub struct PluginEdit { pub rev: u64, // 插件所基于的版本 pub delta: RopeDelta, // 编辑增量 pub priority: u64, // 合并策略:最高优先级的编辑最后应用 pub after_cursor: bool, // 插入文本是否倾向位于光标右侧 pub undo_group: Option<usize>, pub author: String, // 编辑来源标识(插件名、'core' 等) }其中priority决定并发编辑合并时的解决策略——优先级最高的编辑最后应用;after_cursor控制插入文本相对用户光标的位置偏好;undo_group使插件的编辑能与用户动作关联为同一撤销步骤。
Python 侧对应的发送封装在 python/xi_plugin/host.py 的PluginPeer.edit,通过editRPC 携带view_id、plugin_id与edit载荷;而 python/xi_plugin/edit.py 提供Edit对象与EDIT_PRIORITY_NORMAL等优先级常量。
其他插件响应
原文档列出插件可能产生的其他响应类型,包括:
- 填充补全菜单(completion menu);
- 显示状态消息(status messages);
- 弹出模态对话框(modal dialogs)——原文档对此打了问号;
- “还有什么?”——原文档承认这是一个开放清单。
三种异步模式
原文档预期了三种异步模式,并坦言“可能不会全部实现”。
1. 同步模式(synchronous)
在同步模式下,缓冲区的新编辑被阻塞,直到插件的 RPC 完成。这样插件产生的增量可以原样应用,不存在并发冲突的可能。这是最简单的模式,但不推荐——它会造成打字延迟(typing lag)。
2. 正常模式(normal)
即前文描述的常规流程:在一个 RPC 的生命周期内,插件操作的是缓冲区的只读快照;RPC 完成前不向插件发送更多增量。RPC 结束时:
- xi 核心把插件产生的增量与其他并发编辑合并;
- 核心向插件发送通知,说明这些增量如何被解决(原样接受 / 被修改)。
在这种模式下,插件的异步性在很大程度上被隐藏了;简单的插件可以完全信任核心去调和增量,无需任何额外动作。
3. 完全异步模式(fully asynchronous)
在完全异步模式下,编辑一旦发生,核心立即把增量发送给插件。原文档的设想是:每个增量携带一个“generation number(代际编号)”,而获取缓冲区内容的查询要引用具体的 generation number。
模式之间的微妙关系
正常模式与完全异步模式的差别,可能只是插件如何处理增量通知的选择问题:如果插件把增量批量积压到 RPC 完成后再处理,那实际上就是正常模式——这也像是便利库的天然职责。但同步模式必须由编辑器显式配合:在 RPC 进行期间禁止并发编辑。从源码结构看,仓库核心侧通过Plugin::update发送异步请求、并由回调接收结果(见 rust/core-lib/src/plugins/mod.rs 的send_rpc_request_async),为正常/完全异步模式提供了基础。
安全
原文档在“Security”一节几乎空白(仅有“Plugins can potentially”几个字便中断),说明当时安全模型尚未定型。基于当前仓库可以确认的现状是:插件作为独立子进程运行(见 rust/core-lib/src/plugins/mod.rs),通过 stdin/stdout 与核心交互,天然享有进程级隔离——插件崩溃不会导致编辑器数据丢失。但插件仍然拥有与用户同等的文件系统权限(例如did_save钩子、exec_path指向任意可执行文件),因此只应安装可信来源的插件。安全边界的具体设计在当时仍是开放问题。
开放问题:语法高亮的增量计算
行级状态机的原理
现代语法高亮的先进做法是:在每行开头显式存储一个高亮状态。一般来说,这个状态是一个“begin/end 嵌套规则栈”,理论上无界,实践中取值数量很少。于是语法高亮的基本步骤就是一个纯函数:
输入:某行的行状态 + 该行的文本;输出:该行的富文本 span 集合 + 下一行的起始行状态。
便利库的一大部分工作,将围绕基于这些原语的高效增量计算展开。关键观察是:
处理一个增量时,如果(增量改动部分之后)到达的某行状态与之前相同,就可以停止处理——后续所有高亮的计算结果都不会改变。
当然,输入/*可能引发状态级联直到文档末尾(这也是很多“electric 模式”会自动补全闭合*/的原因之一,就是为了保持括号/注释平衡)。
状态存哪里?
开放问题是:这个行状态应该存储在哪里?
- 原文档倾向存在插件侧;
- 但也有充分理由让核心充当一个“数据库”,即使对超大文件也能高效存储这些信息。
值得注意的是,行状态本质上可以视为缓存——因为它总能通过从缓冲区开头扫描来重建。
在仓库中,这一方向的具体形态是 rust/core-lib/src/plugins/rpc.rs 定义的 span 相关 RPC:插件用AddScopes注册作用域层级,用UpdateSpans { start, len, spans, rev }提交ScopeSpan(start/end/scope_id),或用UpdateAnnotations提交通用DataSpan(data为任意 JSON 值)。拼写检查插件 python/spellcheck.py 就是一个把该机制用于注解(而非文本编辑)的实例:它先view.add_scopes([['invalid.illegal.spellcheck']])注册 scope,再对拼错的词view.update_spans(...)打上scope_id。
额外用例(ambitious)
原文档列出若干野心勃勃的扩展场景,它们需要复杂的 UI 接线,短期内不太可能实现,但值得思考:
- 版本控制访问:展示 diff,甚至提供交互式合并的 UI;
- 调试器嵌入:标注断点等(应该相当直接);
- 源码导航:包括引用层级(reference hierarchies)。
与其他编辑器的横向对比
原文档通过对照说明 xi 设计的独特性(此处不附外部链接,仅转述其要点):
- Neovim:同样是异步 RPC 架构,GUI 前端被视为另一种形式的插件;
- Sublime Text:通过 Python 绑定暴露编辑器对象(views、windows、regions 等);
- Vis:提供 Lua 绑定做进程内扩展,语法高亮基于 PEG。
在“编辑器原型”方面,原文档提到Swiboe(“一切都是插件”)与Wi(完全异步、Go 编写);在“基础技术”方面,涉及差分同步、**操作变换(operational transformation)**与JSON-RPC 2.0 规范——这些正是 xi 调和并发编辑的理论基础,也与 docs/docs/crdt.md 等文档讨论的数据结构主题一脉相承。
结语:从文档到代码的验证路径
如果想把本文的设计描述与真实代码一一对上,推荐按以下路径阅读仓库:
- manifest 声明:rust/sample-plugin/manifest.toml(最小三字段)与 rust/syntect-plugin/manifest.toml(完整字段);
- manifest 解析:rust/core-lib/src/plugins/manifest.rs(
PluginDescription/PluginScope/PluginActivation); - 插件发现:rust/core-lib/src/plugins/catalog.rs(
find_all_manifests/load_manifest); - 进程与 RPC 宿主:rust/core-lib/src/plugins/mod.rs(
start_plugin_process/Plugin方法集)与 rust/core-lib/src/plugins/rpc.rs(双向 RPC 类型全集); - Python 便利库:python/xi_plugin/host.py(
PluginHost/PluginPeer)、python/xi_plugin/cache.py(LineCache)、python/xi_plugin/plugin.py(Plugin/GlobalPlugin基类); - 示例插件:python/echo_plugin.py(演示行缓存)、python/spellcheck.py(演示 span 注解)、rust/sample-plugin/src/main.rs(Rust 侧最小插件)。
总体而言,xi-editor 的插件架构是一个“以 RPC 为唯一契约、以进程隔离为安全边界、以快照 + 增量 + 优先级合并为并发策略”的系统。理解这套设计,不仅有助于为 xi 编写插件,也能为设计任何“多语言、异步、可崩溃”的编辑器扩展体系提供参考。
【免费下载链接】xi-editorA modern editor with a backend written in Rust.项目地址: https://gitcode.com/gh_mirrors/xie/xi-editor
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考