最近我做了一个 Zed 插件,最初目标很简单:

在编辑器里选中一段文本,快速看翻译结果,必要时直接替换成更适合代码里的命名格式。

一开始我以为这会是个“很轻”的插件:
做个右键菜单,弹出翻译结果,再加几个替换选项,应该就差不多了。

但真正开始实现后,我很快发现,问题根本不在“翻译接口怎么调”,而在于:

  • Zed 公开扩展能力里,哪种交互入口最适合这件事
  • “查看翻译”和“替换文本”其实是两种不同操作
  • 自然语言翻译和工程命名不是一回事
  • 插件本体和语言服务器应该怎么拆
  • 插件怎么保证在任意项目里都能正常启动

最后我做出来的,不是一个单纯的“翻译弹窗插件”,而是一个更贴近代码编辑场景的方案:

  • slash command 用来做主动翻译
  • code action 用来做选区翻译与替换
  • Rust Extension + Rust LSP 两部分协作
  • 翻译结果再转成多种命名格式,直接服务代码改写

这篇文章我不准备按功能列表讲,而是从插件作者视角,把这个插件的实现思路和取舍过程讲清楚。


一、我想做的不是“翻译一下”,而是“在写代码时真的好用”

如果只是做翻译,其实有太多方式了:

  • 切浏览器
  • 打开词典应用
  • 用系统翻译工具
  • 在编辑器外处理

但这些方式有一个共同问题:
它们都不够贴近写代码的上下文。

在真实编码场景里,我更常遇到的是这些需求:

  1. 选中中文字段,想快速翻成英文命名
  2. 选中英文短语,想知道中文意思
  3. 看完翻译后,希望直接替换成 camelCasesnake_case 之类
  4. 不想切面板,不想离开当前编辑流
  5. 不想只是“查词”,而是直接产出可以落进代码里的命名结果

所以我做这个插件时,核心目标不是“把翻译做进 Zed”,而是:

把翻译、命名和替换三件事,收敛进编辑器内的一个顺手工作流里。


二、这个插件实际上分成两部分,而不是一个单体

这一点是我一开始就明确下来的。
因为如果把所有能力都塞进一个扩展入口里,后面无论是交互还是能力扩展,都会变得很别扭。

2.1 第一部分:Zed Dev Extension(Rust → wasm)

这一层主要负责和 Zed 本体对接,包括:

  • 向 Zed 注册扩展
  • 提供 slash command
  • 启动语言服务器(LSP)

对应文件:

  • /Users/magegojo/Demo/youdao/src/lib.rs

它更像一个“桥接层”,职责不是做复杂业务,而是把插件能力挂进 Zed 的扩展体系里。

2.2 第二部分:Rust LSP

这一层才是核心业务层,主要负责:

  • 接收 Zed 发来的 LSP 请求
  • 读取当前选区文本
  • 调用翻译能力
  • 生成 code actions
  • 返回预览卡片和替换动作

对应文件:

  • /Users/magegojo/Demo/youdao/lsp/src/main.rs

也就是说,这个插件并不是“wasm 里把一切做完”,而是:

Zed Extension 负责接入
Rust LSP 负责交互与业务处理

这套拆法的好处很明显:

  • 扩展接入层职责更清楚
  • LSP 逻辑更容易独立调试
  • 后续如果要扩能力,不容易把入口层写乱

建议配图 1:插件整体架构图
放在这里最合适。
图里可以画成:Zed Editor -> Extension(wasm) -> Rust LSP -> 翻译能力 -> Code Action / Slash Command 返回
这张图的作用是先把“两层结构”讲清楚。


三、为什么我最后选择了 LSP,而不是做一个“右键翻译面板”

这部分其实是整个插件设计里最关键的转折点。

3.1 一开始最自然的想法,确实是右键翻译

我一开始想做的是:

  1. 选中文本
  2. 右键
  3. 看到“翻译”菜单
  4. 点一下弹结果
  5. 再决定是否替换

这很符合直觉,也很像很多传统编辑器插件的做法。

但问题在于,Zed 当前公开的扩展能力里,并没有给出那种完全自由的自定义右键 UI 注入方式

3.2 与其强行做非原生入口,不如贴近 Zed 已有工作流

我后面想清楚了一件事:

做插件,不一定要追求“最像我脑中的理想 UI”,而应该优先贴近编辑器已经稳定提供的原生入口。

对 Zed 来说,当时最合适的能力其实是:

  • slash command
  • LSP
  • textDocument/codeAction

所以我最后采用的是:

  • Rust 写 Zed 扩展
  • Rust 写 LSP
  • 通过 textDocument/codeAction 做“选中即出翻译动作”

3.3 这套方案最大的好处,不只是能实现,而是交互更自然

最终这条路线有几个明显优势:

  1. 更接近编辑器原生工作流
    用户本来就会用 code action,不需要新学一个特殊交互。

  2. 不需要切到别的面板
    翻译、预览、替换都在当前编辑流里完成。

  3. 天然适合分成“预览”和“替换”两类动作
    这点对翻译场景尤其重要。

  4. 更容易和后续命名替换结合
    code action 本来就适合“给出一组可执行动作”。

所以这一步的关键不只是“LSP 能做”,而是:

LSP 恰好是当下 Zed 里最适合承载这种编辑器内交互的方式。


四、插件具体是怎么实现的

接下来我按功能入口拆开讲。

4.1 Slash Command:适合主动输入式翻译

插件里注册了一个命令:

/youdao_translate

用户输入文本后,插件会做两件事:

  • 如果是中文,翻成英文
  • 如果是英文,翻成中文

但我没有把它停在“返回一句翻译”这一步。
因为对开发者来说,更有价值的是“翻译后直接生成命名格式”。

所以基于英文结果,我还会继续推导:

  • Title Words
  • PascalCase
  • camelCase
  • snake_case
  • kebab-case
  • CONSTANT_CASE

这意味着 slash command 不只是“翻译工具”,它同时也是一个命名辅助工具

4.2 LSP Code Action:适合选区翻译与代码替换

LSP 这边实现了这些能力:

  • initialize
  • didOpen
  • didChange
  • didClose
  • codeAction
  • executeCommand

其中最关键的是 codeActionexecuteCommand

工作过程大概是这样:

  1. 用户选中一段文本
  2. 打开 code action
  3. Zed 把当前文档和选区发给 LSP
  4. LSP 从选区中提取文本
  5. 调用翻译能力
  6. 生成预览动作和替换动作
  7. 返回给 Zed 展示

这一步是整个插件最核心的交互入口。


建议配图 2:Code Action 工作流程图
放在这里。
图内容建议:选中文本 -> Zed 发送 codeAction 请求 -> LSP 提取选区 -> 翻译 -> 生成动作 -> 返回动作列表
这张图能把“为什么选中后就能出翻译动作”讲得很直观。


五、为什么我要把动作拆成“预览”和“替换”两层

这是我在实现过程中非常明确的一次取舍。

5.1 翻译场景里,用户其实有两类需求

第一类需求是:

  • 我想先看看这段话什么意思
  • 我想确认翻译结果对不对
  • 我还不想直接改文本

第二类需求是:

  • 我已经知道意思了
  • 我希望快速替换成适合代码里的写法
  • 我不想再手动复制粘贴

如果只提供替换动作,体验会太“硬”;
如果只提供预览,又会不够高效。

所以我最后的做法是把动作拆成两层。

5.2 第一类动作:预览翻译卡片

比如会生成这样一个动作:

有道翻译:查看翻译卡片 → User Name

点开后:

  • 不替换文本
  • 只显示整理后的翻译结果卡片

这个动作的作用更像“解释”和“确认”。

5.3 第二类动作:直接替换

后面再给出一组可执行替换动作,例如:

有道翻译:替换为 Username
有道翻译:替换为 UserName(PascalCase)
有道翻译:替换为 userName(camelCase)
有道翻译:替换为 user_name(snake_case)
有道翻译:替换为 user-name(kebab-case)
有道翻译:替换为 USER_NAME(CONSTANT_CASE)

这些动作点击后,会直接改写选中的文本。

从编辑器体验上看,我觉得这种设计比“点一下立刻替换”要稳得多,也更符合开发场景。


六、翻译预览卡片为什么不能只显示一句结果

如果预览卡片只展示一条翻译,其实意义不大。
因为开发者真正关心的,通常不只是“它是什么意思”,还包括:

  • 更自然的表达是什么
  • 更紧凑的英文写法是什么
  • 适合代码命名的版本是什么
  • 各种命名格式分别长什么样

所以我在预览卡片里整理的是一组结构化内容,而不是一句孤立的翻译。

6.1 预览卡片里我会放这些内容

  • 原文
  • 主翻译结果
  • 紧凑英文 / 完整英文
  • 各种命名格式

这样点开第一个动作时,体验就更接近“看一张说明卡片”,而不是立刻执行替换。


建议配图 3:翻译预览卡片示意图
放在这一节后面最合适。
图中可以展示一段选中文本,比如“用户名”,然后右侧是卡片内容:原文、主翻译、Title Words、PascalCase、camelCase、snake_case 等。
这张图非常适合做文章里的“核心效果图”。


七、真正让这个插件对开发有帮助的,不是翻译,而是命名格式生成

我在做的过程中越来越明确:

只做翻译,工具价值其实有限;翻译后能直接生成工程命名,价值才会明显提高。

7.1 自然语言翻译和工程命名不是一回事

比如“用户名”翻译出来,词典结果可能是完整短语;
但代码里需要的,往往不是一段自然语言,而是可落地的命名格式。

再比如下面这些输入:

  • 用户名
  • user name
  • Username
  • user_name

它们表面不同,但实际都可以规整到同一个词序上。

7.2 我做了一层词语拆分和格式统一

这一层的目标不是“翻译得更文学”,而是“统一成适合程序命名的词序”。

最终我会输出:

  • User Name
  • Username
  • UserName
  • userName
  • user_name
  • user-name
  • USER_NAME

这样做的关键在于,它把翻译结果从“可读文本”变成了“可直接写进代码的名字”。


八、为什么这个插件尽量挂到更大范围的语言集上

这个能力本质上和具体编程语言关系不大。

它不是 Rust 专用,也不是 TypeScript 专用。
只要用户在编辑器里选中的是一段文本,就可能需要这个能力:

  • 变量命名
  • 字段命名
  • 配置项命名
  • 注释翻译
  • 文档片段翻译

所以我最终在 extension.toml 里做的是:

  • 给 language server 配比较大的覆盖语言集
  • 尽可能兼容常见代码文件、配置文件、标记语言、脚本语言

这样插件就不会只局限在个别语言里,而是能在更广泛的文件类型中工作。

从插件设计上看,这一步的意义是:

把能力定义成“编辑器级文本处理工具”,而不是“某门语言的翻译插件”。


九、实现过程中,我主要踩了哪些坑

这一部分我觉得比功能介绍更有价值。
因为很多问题不是“会不会写代码”,而是“编辑器能力边界到底在哪”。

9.1 Zed 不能直接做任意右键 UI

问题:
一开始我希望做一个完全自定义的右键翻译菜单或浮层,但 Zed 当前公开扩展能力并没有给出这种自由度很高的 UI 注入方式。

解决:
改走 LSP code action,利用编辑器已有入口完成交互。

经验:
做 Zed 插件时,优先贴近原生入口,比强行做自定义 UI 更稳。


9.2 “预览”和“替换”是两种不同交互

问题:
用户有时只是想先看翻译,有时则想直接替换。如果只给一类动作,体验会失衡。

解决:
把动作拆成两层:

  • 第一个动作:查看翻译卡片
  • 后续动作:直接替换成不同命名格式

经验:
编辑器里的“理解型操作”和“修改型操作”最好分开。


9.3 Hover 并不适合做主入口

问题:
我一开始也尝试过 hover 风格的方案,但 hover 是基于光标位置,而不是基于用户明确选区。对中文尤其不稳定,容易拿到过长文本或错误边界。

解决:
放弃把 hover 当主交互,改成:

选中 -> code action -> 第一个动作预览卡片

经验:
中文场景下,选区比 hover 更可靠。


9.4 多语言服务器并存时会有能力冲突

问题:
比如 Rust 文件里本来就有 rust-analyzer,它自己也会提供 hover、code action、diagnostics。
如果翻译插件也强依赖这些入口,就可能出现优先级和展示混杂问题。

解决:
我没有把主流程压在 hover 上,而是把核心入口固定在“选区后的 code action”。

经验:
和已有语言服务器共存时,要尽量选一个稳定、不会和主 LSP 强冲突的入口。


9.5 LSP 启动路径不能依赖当前项目

问题:
如果语言服务器启动逻辑写成“从当前项目目录找 LSP”,那么 Zed 打开别的工程时就会出错,甚至把插件 LSP 错当成当前项目 workspace 的一部分。

解决:
改成始终从插件自身目录启动 LSP,而不是依赖用户当前打开的项目目录。

经验:
做编辑器插件时,必须明确区分:

  • 插件自己的安装 / 构建目录
  • 用户当前工程目录

这一点如果不分清,后面很容易出莫名其妙的问题。


9.6 “看起来像翻译”,但实际上要服务代码命名

问题:
自然语言翻译结果往往偏完整表达,但代码命名更需要紧凑、规范、便于拼接。

解决:
我把结果分成三层组织:

  1. 更自然的主翻译
  2. 更适合命名的紧凑写法
  3. 各种工程命名格式输出

经验:
开发者工具不能只停留在“懂意思”,还要往“能直接用到代码里”再走一步。


9.7 开发阶段和交付阶段的产物管理不同

问题:
开发阶段可以依赖 cargo runtarget/...,但实际安装 dev extension 时,产物路径和可执行文件必须明确。

解决:
我会保留并验证这些关键产物:

  • extension.wasm
  • target/release/youdao-translate-lsp

同时确认:

  • wasm 扩展能编译
  • LSP release 二进制能编译
  • release 版本能响应 LSP 请求

经验:
插件能编译,不等于插件能交付。
真正可安装、可加载、可运行,才算完成闭环。


十、最后形成的稳定交互方案是什么

经过几轮取舍后,我现在比较满意的交互是这样的:

10.1 选区场景

例如用户选中:

用户名

然后打开 Code Actions。

10.2 第一个动作:先看翻译卡片

例如:

有道翻译:查看翻译卡片 → User Name

点击后先确认翻译结果和命名候选。

10.3 后续动作:需要时直接替换

例如继续选择:

  • Username
  • UserName
  • userName
  • user_name
  • user-name
  • USER_NAME

这样整个流程的优点是:

  • 比 hover 稳
  • 比单纯 slash command 快
  • 比直接替换更安全
  • 更符合代码编辑语境

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述


十一、这次做 Zed 插件,我总结出的几条核心经验

这部分基本是我做完以后最想留下来的东西。

11.1 优先贴近 Zed 原生入口

不要先想“我要做一个自定义浮层”或“我要接管右键 UI”,
先想这些问题:

  • 能不能放进 code action
  • 能不能放进 slash command
  • 能不能走 LSP 标准能力

这样实现成功率更高,也更不容易和编辑器本体打架。

11.2 “预览”和“执行”最好拆开

如果一个插件既承担“帮助理解内容”的职责,又承担“直接修改代码”的职责,最好分两步:

  1. 先预览
  2. 再替换

这样会明显更稳,也更符合编辑器操作习惯。

11.3 中文文本场景下,选区比 hover 更可靠

hover 很适合英文符号说明,
但涉及中文短语、自然语言边界时,选区往往更稳定、更可控。

11.4 插件路径和项目路径必须分清

这一点是编辑器插件里非常容易踩坑的地方。
只要牵涉语言服务器启动,就必须明确:

  • 插件在哪里
  • LSP 从哪里启动
  • 当前打开项目在哪里

这三者不能混。

11.5 工具型插件真正的价值,在于能落到代码改写上

只做翻译,价值有限;
翻译 + 命名 + 替换,才真正能帮开发者省掉编辑器里的重复动作。


十二、总结

这个 Zed 插件做下来,我最大的感受是:

编辑器插件的难点,很多时候不在“功能能不能写出来”,而在“能不能嵌进原生工作流里”。

如果只是做一个翻译结果弹窗,其实很容易停留在“能看,但不够顺手”。
而我这次更想做的是:

  • slash command 支持主动翻译
  • LSP code action 支持选区交互
  • 用预览卡片承接“先理解”
  • 用命名格式替换承接“再执行”
  • 让翻译这件事真正进入代码编辑流

从结果看,这个插件最终更像一个开发命名辅助工具,而不只是一个“有道翻译入口”。

如果后面我继续做 Zed 插件,这次最大的经验大概就是一句话:

先贴近编辑器原生能力,再去设计自己的交互层;先解决开发者真正会执行的动作,再考虑展示形式。

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐