Ruby LSP 实践:用语言服务器提升 Ruby 与 Rails 开发者体验

Ruby LSP 是某海外电商平台开发的一款全新 Ruby 语言服务器。它可以为任何支持 LSP 客户端的编辑器提供更完善的 Ruby 功能,包括代码诊断、代码折叠、格式化、语义高亮、自动补全和 Rails 相关能力,从而进一步改善 Ruby 与 Rails 开发体验。本文将介绍 Ruby LSP 的构建背景、工作原理、当前功能、使用方式以及未来规划。

Ruby LSP 实践:用语言服务器提升 Ruby 与 Rails 开发者体验

Ruby 诞生之初,就有一个明确目标:让开发者感到愉悦。过去,这一目标主要体现在 Ruby 丰富的语法和强大的表达能力上——开发者可以把注意力集中在业务逻辑上,而不是被语言规则本身束缚。

如今,工具链已经成为实现这一目标的关键组成部分。许多现代编程语言,例如 TypeScript 和 Rust,都拥有丰富而强大的工具链,用来提升开发体验。正因如此,我们构建了 Ruby LSP:一个全新的 Ruby 语言服务器。它可以为任何支持 LSP 客户端的编辑器提供额外的 Ruby 能力,让 Ruby 编码体验更加流畅。

本文将介绍 Ruby LSP 为什么被创建、它如何工作、目前支持哪些功能,以及如何安装和使用它。

为什么要构建新的 Ruby 语言服务器

Ruby 生态中已经存在一些语言服务器,例如 Sorbet、Steep、TypeProf 和 Solargraph,它们都是语言服务器协议(Language Server Protocol,LSP)的实现。

Ruby LSP 与它们的主要区别在于:Ruby LSP 试图在不进行类型检查、也不要求类型注解的前提下,尽可能准确地提供语言功能。

Ruby LSP 的两个核心目标是性能和稳定性。开发者通常对编辑器响应速度有很高期待。为了满足大型 Rails 单体应用中的使用需求,Ruby LSP 必须能够高效处理和分析成千上万个文件,而其中一些文件可能长达数千行。

此外,Ruby LSP 也被设计为一个面向特定场景的语言服务器:它不仅服务于 Ruby 本身,也面向 Ruby 与 Rails 结合使用的日常开发场景。因此,我们计划持续加入一些 Rails 相关能力,帮助开发者在日常工作中更高效地使用这个框架。

语言服务器协议 LSP 如何工作

在语言服务器出现之前,编程语言相关功能,例如“跳转到定义”或“自动格式化”,通常都是在编辑器插件中实现的。这种方式存在一系列缺点。

首先,每个编辑器使用的编程语言不同,提供给插件的 API 不同,内部设计也各不相同。这意味着插件无法在不同编辑器之间复用。如果我们为某个编辑器实现了一套 Ruby 功能,那么想让这些功能在另一个编辑器中可用,就必须重新实现一遍。

其次,由于这些功能与具体编辑器强绑定,编程语言社区很难共同改善开发者体验。每个开发者所获得的体验,很大程度上取决于他使用的编辑器。

第三,当时并没有统一规范来规定语言插件应该包含哪些能力。因此,当开发者切换编辑器时,很难期待新编辑器中也能获得与原编辑器相同的语言功能。

最后,为了支持每种编程语言的丰富能力,编辑器至少需要为每种语言开发一个插件。如果我们希望 Ruby 功能可以在十个不同编辑器中使用,就意味着相同能力需要被实现十次。

语言服务器协议正是为了解决这些问题而出现的。它规范了如何编写一个运行在开发者机器上的后台进程,并由这个后台进程为任意编辑器提供编程语言功能。同时,它也定义了一组标准能力,任何使用语言服务器的编辑器都可以调用这些能力。

在这种模式下,编辑器作为客户端启动一个后台进程,也就是语言服务器。客户端持有标准输入(STDIN)和标准输出(STDOUT)管道的句柄,并通过 JSON 与服务器通信。

以“跳转到定义”为例:当开发者触发该功能时,编辑器会通过标准输入发送一个 JSON 请求,请求语言服务器查找当前光标位置对应的定义。服务器收到请求后执行查找逻辑,再通过标准输出以 JSON 格式返回结果。编辑器接收到响应后,将其转换为具体动作,例如跳转到定义所在文件的对应位置。

用 JSON 标准化请求和响应格式,打破了编辑器与编程语言功能实现之间的耦合。客户端层只需要负责把开发者操作转换成 JSON 请求,并把 JSON 响应转换成编辑器动作。它只与具体编辑器相关,通常可以内置在编辑器中,也可以作为插件提供。

从客户端角度看,由于请求和响应结构是统一的,因此无论面对哪种编程语言,“跳转到定义”在交互上都是同一类操作。

另一方面,服务器负责实现编程语言相关的具体细节,但它完全不需要关心连接它的是哪个编辑器。Ruby 语言服务器只需要处理查找定义、格式化代码或显示 Ruby 诊断信息等任务。无论哪个编辑器使用这个服务器,这些功能的实现都不需要改变。

因此,任何在语言服务器中实现的功能,都可以被所有实现了 LSP 客户端的编辑器使用。这让语言社区能够更容易地共同改善开发体验,也避免了同一功能在不同编辑器中反复实现。

Ruby LSP 是什么

顾名思义,Ruby LSP 是 Ruby 语言对 LSP 协议的实现。它为任何具备 LSP 客户端能力的编辑器提供额外的 Ruby 功能。

接下来,我们将进一步介绍它的工作原理、当前支持的能力,以及未来规划。

Ruby LSP 如何理解 Ruby 代码

在语言服务器中,语言功能请求大体可以分为两类:位置相关请求和非位置相关请求。

位置相关请求,是指光标当前位置至关重要的功能。“跳转到定义”就是最常见的例子。光标的具体位置会决定你想跳转到哪个常量或方法的定义。

非位置相关请求,则是针对整个文件计算出的功能,光标位置并不重要。例如,“代码折叠范围”请求会计算当前文件中所有可以展开或折叠的位置。

实现这两类功能的方法并不相同,因此值得分别说明。本文重点介绍 Ruby LSP 如何实现非位置相关请求,尤其是代码折叠范围请求。

文档同步:让语言服务器感知编辑器状态

当开发者在编辑器中编写代码时,服务器需要知道当前打开文件的状态。跟踪文件内容变化的过程,称为文档同步。

编辑器会向服务器发送通知,告知相关文档发生了变化。最基本的三类通知包括:

  • textDocument/didOpen:当文件在编辑器中打开时发送,通知中包含文件 URI 和当前内容;
  • textDocument/didChange:当文件被修改时发送,例如用户输入内容时。根据服务器配置不同,通知中可能包含文件完整文本,也可能只包含变更列表;
  • textDocument/didClose:当文件在编辑器界面中关闭时发送。

服务器需要利用这些通知维护一份已打开文档的记录。

当服务器收到类似“代码折叠范围”这样的功能请求时,请求参数中并不包含文件内容,只会发送 URI,用来标识编辑器当前访问的文件。因此,服务器必须始终知道每个已打开文档的内容及其对应路径。

解析文件:从 Ruby 代码到 AST

语言服务器与编辑器同步文档时,获得的是原始文本。同步完成后,服务器拿到的是一个包含当前文件 Ruby 代码的字符串。为了正确分析这些代码,我们需要一个包含丰富信息的数据结构,用来描述文本中存在的所有 Ruby 结构。

将 Ruby 代码字符串解析后,可以得到一棵抽象语法树(Abstract Syntax Tree,AST)。AST 是代码结构的对象化表示。Ruby LSP 使用 Syntax Tree gem 来获取这种表示。Syntax Tree 构建在 Ruby 内部解析器 Ripper 之上,为解析结果提供了一层对象模型。

AST 包含了文件中的所有相关信息。通过 AST,我们可以知道文件中定义的所有内容,以及 Ruby 如何理解这些内容。模块、类、方法、变量和其他结构都会被表示为对象,这些对象以树状结构组织在一起,并包含各自相关信息。

有了 AST 后,分析文件的最后一步就是遍历这棵树,并收集所需数据。

使用访问者模式分析代码结构

为了分析 AST,我们需要遍历其中定义的所有内容,也就是树中的每个节点,并收集过程中发现的数据。

为此,Ruby LSP 使用了访问者模式。访问者模式也是 Syntax Tree 提供的能力之一。它使我们能够将“如何遍历树中每个节点”的逻辑,与“某个具体请求需要做什么”的逻辑分离开来。

以一个 Post 类为例,假设我们希望编辑器能够展开和折叠类定义与方法定义中的代码。我们需要一种方式来找到所有类节点和方法节点,并将它们收集到折叠范围列表中。根据 LSP 规范,我们至少需要向编辑器返回每个范围的起始行、结束行和类型。

实现这个请求时,可以先创建一个新的折叠范围访问器,让它继承自 Syntax Tree 中定义的基础访问器。这个访问器会以 AST 和一个空的范围数组作为初始化参数。随后,我们会在这个数组中累积结果。

运行该请求的过程,就是访问 AST,也就是遍历其中每一个节点,然后返回范围数组。在遍历过程中,我们会把遇到的目标结果加入数组。

完成这个请求的最后一步,是定义遇到特定类型节点时应该执行的行为。我们可以通过重写基础访问器中的特定访问方法来实现这一点。比如,重写 visit_class 方法,就可以定义每次遇到类定义时要执行的逻辑,而类定义正是我们希望支持折叠的节点。

需要注意的是,在重写方法中仍然要调用基础访问器的实现,以保留遍历 AST 中每个节点的默认行为。否则,访问器可能在遇到类定义时就停止继续向下遍历。

在这里,AST 遍历逻辑和具体请求逻辑之间的分离更加明显:查找文件中所有节点的通用能力来自父类,而折叠范围的业务逻辑则被清晰封装在访问器中。

为了完整支持 Post 类中的折叠能力,我们还需要处理方法定义节点。与类节点类似,只需要针对正确的节点类型重写对应访问方法,并向范围数组中添加更多结果即可。

这个简单的访问器已经足以收集编辑器折叠所有类定义和方法定义所需的信息,并且不受文件结构影响。这也是 Ruby LSP 实现非位置相关请求时首选的策略。

Ruby LSP 当前支持的功能

关于最新支持功能,可以参考 Ruby LSP 文档。按照本文撰写时的状态,Ruby LSP 已经支持以下能力。

代码操作

Ruby LSP 支持基于 RuboCop 的快速修复,用来修复违规问题。它也支持重构操作,例如将一段代码提取为变量。

诊断信息

Ruby LSP 可以在编辑器中显示 RuboCop 检查出的违规问题,让开发者更早发现代码问题。

文档高亮

当光标位于某个实体上时,编辑器可以高亮显示当前文件中所有相同实体。

文档链接

Ruby LSP 支持通过特殊源代码注释跳转到 gem 的源代码。

文档符号

Ruby LSP 可以使用所有可用的 Ruby 结构填充编辑器大纲和面包屑导航,并支持对结构进行模糊搜索。例如在某些主流编辑器中,开发者可以通过快捷键快速搜索文档符号。

代码折叠范围

Ruby LSP 可以确定 Ruby 代码中哪些位置可以被折叠和展开。

格式化

Ruby LSP 可以在保存时修复 RuboCop 违规问题。对于未使用 RuboCop 的应用,也支持基于 Syntax Tree 的代码格式化。

悬停提示

当鼠标悬停在 Rails DSL 方法上时,Ruby LSP 可以显示相关文档。

内联提示

Ruby LSP 可以提示未指定错误类型时 rescue 的默认行为。

输入时格式化

Ruby LSP 支持在输入时自动补全 end 标记,自动补全字符串插值花括号和块参数管道符,也可以在下一行自动延续注释。

选择范围

Ruby LSP 支持基于代码结构扩展或缩小选择范围,也就是智能选择范围。具体快捷键因编辑器而异。

语义高亮

Ruby LSP 能够根据 Ruby 对代码的理解方式,为代码提供一致的语义高亮。

自动补全

在编写 require 语句时,编辑器可以在输入第一个正斜杠后自动补全可能的路径。

YJIT 与 YARP:提升 Ruby LSP 性能的关键技术

由于语言服务器对性能要求极高,许多语言服务器并不是用它们所服务的目标语言编写的。例如,某 Python 语言服务器使用 TypeScript 编写,Sorbet 使用 C++ 编写。

Ruby LSP 则不同,它完全使用纯 Ruby 编写。

这种方式有许多优势。借助 Ruby 的易用性,我们可以快速迭代和开发功能;通过 Syntax Tree gem,我们可以使用 Ruby 自带的解析器 Ripper 来分析文件;同时,我们还能访问应用实际运行时的完整 Ruby 环境。

另一方面,性能始终是需要重点关注的问题,因为解析和分析 Ruby 文件都是耗时操作。

基于这些原因,Ruby 近期围绕性能优化所做的许多改进,例如 YJIT、对象形状优化和可变宽度分配,对 Ruby LSP 都非常有价值。这些改进有助于 Ruby LSP 更好地实现性能目标。

如果你使用的是 Ruby LSP 的编辑器扩展,并且 Ruby 3.2 已经启用 YJIT 编译,那么 Ruby LSP 将默认使用 YJIT,从而获得明显的性能提升。

Ruby LSP 相关的另一个重要项目是 YARP,也就是 Yet Another Ruby Parser。这个项目旨在创建一个新的 Ruby 解析器,用来替代现有解析器,并补足现有解析器缺失的两个关键能力:可移植性和容错性。

这两点对现代开发工具非常重要。

可移植性意味着解析器可以与不同编程语言集成。这为使用更适合具体任务的语言构建高性能工具打开了空间。

容错性同样重要。开发者写代码时,代码很少始终处于完整、正确的状态。语言服务器看到的大多数内容,往往都是用户输入过程中的临时代码,其中可能包含语法错误。容错解析器意味着,即使代码存在语法错误,语言服务器仍然可以继续提供功能,同时指出错误所在以及修复方式。

如何使用 Ruby LSP

本节以支持 LSP 的主流代码编辑器为例进行说明,但 Ruby LSP 可以与任何支持 LSP 的编辑器配合使用。

最新安装信息可以参考 Ruby LSP 扩展文档。要配置 Ruby LSP,可以按以下步骤操作:

  1. 从编辑器应用商店或扩展市场安装 Ruby LSP 扩展;
  2. 如果使用 Ruby 版本管理器,例如 rbenv,需要配置扩展以使用对应版本管理器;
  3. 将 ruby-lsp gem 添加到项目的 bundle 中;
  4. 完成配置后,可以在编辑器的语言状态区域查看服务器状态和配置。

如果 Ruby LSP 设置正确,你应该能够看到服务器状态和配置信息。

Ruby LSP 未来规划

为了在大型文件和大型代码库上获得足够好的性能,让编辑器交互保持快速响应,并达到我们期望的功能完整度,包括 Rails、GraphQL 和其他工具相关能力,未来还需要重点解决三个方向的问题。

并行处理

如果不采用并行处理,很难满足语言服务器的性能要求。功能越多,发送到服务器的请求就越多,而且这些请求经常会成批出现。主要瓶颈在于:如果服务器处理请求队列的速度跟不上,编辑器就会出现延迟。

随着 Ruby LSP 实现更多 LSP 规范中的能力,并行处理很可能成为必要条件。由于大多数请求彼此独立,能够同时运行多个请求,对应对快速增长的请求队列非常关键。

代码库索引

LSP 规范中的许多能力都需要了解当前代码库。一个常见例子是“跳转到定义”:服务器必须知道每个方法和常量在哪里定义,才能判断应该跳转到哪里。

悬停提示、签名帮助和工作区符号搜索,也都依赖对代码库的理解。悬停提示可以在光标悬停到方法或常量上时显示相关信息;签名帮助可以在调用方法时显示方法签名;工作区符号可以在整个代码库中模糊搜索常量或方法。

为了提供这些功能,Ruby LSP 需要构建并维护一个代码库索引,其中包含项目文件中定义的每个常量和方法。这包括构建初始索引,在文件被添加、删除或修改时同步索引,并可能通过缓存机制避免每次 LSP 激活时都从头构建索引。

插件系统

为特定工具或框架提供专属功能,对开发者非常有帮助。

以 Rails 应用为例,Ruby LSP 可以提供保存文件时热重载、在控制器动作和对应路由之间跳转、鼠标悬停在模型上时显示列信息等能力。

这种做法可以显著提升开发者效率,但要在 Ruby LSP 主代码库中为每个工具或框架维护这些特定功能,并不现实。这不仅会让代码库过于复杂,也会让那些希望为编辑器提供定制化体验的工具受到限制,因为所有相关代码都必须进入同一个代码库。

此外,不同项目会使用不同工具,因此有必要在默认行为和插件行为之间保持一定程度的分离。

为了以可扩展的方式将这些能力聚合到 Ruby LSP 中,同时让工具和框架维护者能够自由提供编辑器功能,我们需要为服务器设计一个插件系统。

最终,这套系统将允许工具从自己的代码库中导出插件,再由 Ruby LSP 加载这些插件,以增强现有功能或添加工具专属能力。它的工作方式有点类似 Rack 中间件。例如,可以由 Rails 导出一个插件,为 Ruby LSP 提供与 Rails 框架相关的编辑器功能。

结语:Ruby 工具链与开发者体验的未来

提升 Ruby 编码体验,与 Ruby 一直以来“让开发者感到愉悦”的目标高度一致。Ruby 工具链的未来令人期待。

从更广义的研发效能来看,开发者体验不仅来自语言服务器、编辑器插件和代码智能提示,也来自需求、开发、测试、发布与知识沉淀之间是否形成顺畅闭环。类似 PingCode 这样的智能化研发管理工具,可以将团队目标、客户反馈、需求评审、开发任务、测试发布和 Wiki 知识库串联起来,并打通研发过程中使用的其他工具,让研发数据在同一流程中持续流转;而类似 Worktile 这样的通用项目协作系统,则可以承载任务、项目、文档、日历、甘特图、工时和审批等协作场景,帮助团队在复杂项目中保持信息透明和执行可追踪。

我们希望你尝试使用 Ruby LSP,并从中获得更流畅、更愉悦的编码体验。如果你有任何反馈,或在代码库中遇到问题,也欢迎提出。

开源软件在大型工程体系中扮演着越来越重要的角色。Ruby LSP 的建设说明,优秀的开发者体验不仅来自语言本身,也来自围绕语言建立起来的工具链、协议、编辑器能力和社区协作。对于 Ruby 开发者而言,这正是一个值得期待的方向。

文章包含AI辅助创作:Ruby LSP 实践:用语言服务器提升 Ruby 与 Rails 开发者体验,发布者:su,转载请注明出处:https://worktile.com/kb/p/3981088

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
su的头像su

发表回复

登录后才能评论
注册PingCode 在线客服
站长微信
站长微信
电话联系

400-800-1024

工作日9:30-21:00在线

分享本页
返回顶部