插件
Ruby LSP 插件系统目前处于实验阶段,API 可能会发生变化
需要帮助编写插件?请考虑加入 Ruby DX Slack 工作区 中的 #ruby-lsp-addons
频道。
动机和目标
特定于某些工具或框架的编辑器功能可能非常强大。通常,语言服务器旨在为特定的编程语言(如 Ruby!)提供功能,而不是特定工具。这是合理的,因为并非每个程序员都使用相同的工具组合。
鉴于生态系统中大量的工具,在 Ruby LSP 中包含特定于工具的功能将无法很好地扩展。这也会造成作者推出新功能的瓶颈。另一方面,构建单独的工具会增加碎片化,这往往会增加用户配置其开发环境所需的工作量。
出于这些原因,Ruby LSP 附带了一个插件系统,作者可以使用该系统通过特定于工具的功能来增强基本 LSP 的行为,旨在
- 允许 gem 作者从他们自己的 gem 中导出 Ruby LSP 插件
- 允许由开发人员当前正在处理的应用程序中存在的插件增强 LSP 功能
- 不需要用户进行额外的配置
- 与 Ruby LSP 的基本功能无缝集成
- 为插件作者提供 Ruby LSP 使用的整个静态分析工具包
指南
构建 Ruby LSP 插件时,请参考以下指南以确保良好的开发人员体验。
- 性能优先于功能。单个缓慢的请求可能会导致编辑器缺乏响应
- 有两种类型的 LSP 请求:自动请求(例如:语义高亮)和用户发起的请求(转到定义)。自动请求的性能对于响应至关重要,因为它们在用户每次键入时都会执行
- 尽可能避免重复工作。如果某些东西可以计算一次并进行记忆化,例如配置,那就这样做
- 不要直接改变 LSP 状态。插件有时可以访问重要的状态,例如文档对象,这些对象不应该直接改变,而应该通过 LSP 规范提供的机制来改变 - 例如文本编辑
- 不要过度通知用户。这通常很烦人,并且会转移人们对当前任务的注意力
- 在正确的时间显示正确的上下文。在添加可视化功能时,请考虑信息对用户相关的**时间**,以避免污染编辑器
构建 Ruby LSP 插件
注意:Ruby LSP 使用 Sorbet。我们建议在插件中也使用 Sorbet,这允许作者从 Ruby LSP 声明的类型中获益。
例如,请查看 Ruby LSP Rails,这是一个用于提供 Rails 相关功能的 Ruby LSP 插件。
激活插件
Ruby LSP 根据放置在 ruby_lsp
文件夹内的 addon.rb
文件的存在来发现插件。例如,my_gem/lib/ruby_lsp/my_gem/addon.rb
。此文件必须声明插件类,该类可用于在服务器启动时执行任何必要的激活。
项目还可以为仅适用于特定应用程序的功能定义自己的私有插件。只要在工作区内存在匹配 ruby_lsp/**/addon.rb
的文件(不一定在根目录),它就会被 Ruby LSP 加载。
# frozen_string_literal: true
require "ruby_lsp/addon"
module RubyLsp
module MyGem
class Addon < ::RubyLsp::Addon
# Performs any activation that needs to happen once when the language server is booted
def activate(global_state, message_queue)
end
# Performs any cleanup when shutting down the server, like terminating a subprocess
def deactivate
end
# Returns the name of the add-on
def name
"Ruby LSP My Gem"
end
# Defining a version for the add-on is mandatory. This version doesn't necessarily need to match the version of
# the gem it belongs to
def version
"0.1.0"
end
end
end
end
监听器
插件的一个重要组成部分是监听器。所有 Ruby LSP 请求都是处理特定节点类型的监听器。
监听器与 Prism::Dispatcher
协同工作,后者负责在解析 Ruby 代码期间分派事件。每个事件都对应于正在解析的代码的抽象语法树 (AST) 中的特定节点。
这是一个简单的监听器示例
# frozen_string_literal: true
class MyListener
def initialize(dispatcher)
# Register to listen to `on_class_node_enter` events
dispatcher.register(self, :on_class_node_enter)
end
# Define the handler method for the `on_class_node_enter` event
def on_class_node_enter(node)
$stderr.puts "Hello, #{node.constant_path.slice}!"
end
end
dispatcher = Prism::Dispatcher.new
MyListener.new(dispatcher)
parse_result = Prism.parse("class Foo; end")
dispatcher.dispatch(parse_result.value)
# Prints
# => Hello, Foo!
在此示例中,监听器已注册到分派器,以侦听 :on_class_node_enter
事件。当在代码解析期间遇到类节点时,将输出带有类名称的问候消息。
这种方法允许在单轮 AST 访问中捕获所有插件响应,从而大大提高了性能。
增强功能
有两种方法可以增强 Ruby LSP 功能。一种是处理发生在调用站点且不改变项目中存在的声明的 DSL。一个很好的例子是 Rails 的 validate
方法,它接受一个符号,该符号表示一个被动态调用的方法。这种风格的 DSL 是我们所说的调用站点 DSL。
class User < ApplicationRecord
# From Ruby's perspective, `:something` is just a regular symbol. It's Rails that defines this as a DSL and specifies
# that the argument represents a method name.
#
# If an add-on wanted to handle go to definition or completion for these symbols, then it would need to enhance the
# handling for call site DSLs
validate :something
private
def something
end
end
增强 Ruby LSP 的第二种方法是处理声明 DSL。这些是使用元编程创建声明的 DSL。再举一个 Rails 的例子,belongs_to
是一个 DSL,它会改变当前类并根据传递给它的参数添加额外的方法。
添加额外声明的 DSL 应该通过索引增强来处理。
class User < ApplicationRecord
# When this method is invoked, a bunch of new methods will be defined in the `User` class, such as `company` and
# `company=`. By informing the Ruby LSP about the new methods through an indexing enhancement, features such as
# go to definition, completion, hover, signature help and workspace symbol will automatically pick up the new
# declaration
belongs_to :company
end
处理调用站点 DSL
为了增强请求,插件必须创建一个监听器,该监听器将收集额外的结果,这些结果将自动附加到基本语言服务器响应中。此外,Addon
必须实现一个实例化监听器的工厂方法。在实例化监听器时,还要注意传入一个 ResponseBuilders
对象。此对象应用于将响应返回到 Ruby LSP。
例如:为了在 Ruby LSP 的基本悬停行为之上,在悬停时添加一条消息“你好!”。,我们可以使用以下监听器实现。
# frozen_string_literal: true
module RubyLsp
module MyGem
class Addon < ::RubyLsp::Addon
def activate(global_state, message_queue)
@message_queue = message_queue
@config = SomeConfiguration.new
end
def deactivate
end
def name
"Ruby LSP My Gem"
end
def version
"0.1.0"
end
def create_hover_listener(response_builder, node_context, dispatcher)
# Use the listener factory methods to instantiate listeners with parameters sent by the LSP combined with any
# pre-computed information in the add-on. These factory methods are invoked on every request
Hover.new(response_builder, @config, dispatcher)
end
end
class Hover
# The Requests::Support::Common module provides some helper methods you may find helpful.
include Requests::Support::Common
# Listeners are initialized with the Prism::Dispatcher. This object is used by the Ruby LSP to emit the events
# when it finds nodes during AST analysis. Listeners must register which nodes they want to handle with the
# dispatcher (see below).
# Listeners are initialized with a `ResponseBuilders` object. The listener will push the associated content
# to this object, which will then build the Ruby LSP's response.
# Additionally, listeners are instantiated with a message_queue to push notifications (not used in this example).
# See "Sending notifications to the client" for more information.
def initialize(response_builder, config, dispatcher)
@response_builder = response_builder
@config = config
# Register that this listener will handle `on_constant_read_node_enter` events (i.e.: whenever a constant read
# is found in the code)
dispatcher.register(self, :on_constant_read_node_enter)
end
# Listeners must define methods for each event they registered with the dispatcher. In this case, we have to
# define `on_constant_read_node_enter` to specify what this listener should do every time we find a constant
def on_constant_read_node_enter(node)
# Certain builders are made available to listeners to build LSP responses. The classes under
# `RubyLsp::ResponseBuilders` are used to build responses conforming to the LSP Specification.
# ResponseBuilders::Hover itself also requires a content category to be specified (title, links,
# or documentation).
@response_builder.push("Hello!", category: :documentation)
end
end
end
end
处理声明 DSL
插件可以告知 Ruby LSP 通过元编程进行的声明。通过确保索引中填充了所有声明,诸如转到定义、悬停、补全、签名帮助和工作区符号等功能将全部自动工作。
为了实现这一点,插件必须创建一个索引增强类并注册它。这是一个如何执行此操作的示例。假设一个 gem 定义了这个 DSL
class MyThing < MyLibrary::ParentClass
# After invoking this method from the `MyLibrary::ParentClass`, a method called `new_method` will be created,
# accepting a single required parameter named `a`
my_dsl_that_creates_methods
# Produces this with meta-programming
# def my_method(a); end
end
这是你可以编写一个增强功能来教 Ruby LSP 理解该 DSL 的方法
class MyIndexingEnhancement < RubyIndexer::Enhancement
# This on call node handler is invoked any time during indexing when we find a method call. It can be used to insert
# more entries into the index depending on the conditions
def on_call_node_enter(node)
return unless @listener.current_owner
# Return early unless the method call is the one we want to handle
return unless node.name == :my_dsl_that_creates_methods
# Create a new entry to be inserted in the index. This entry will represent the declaration that is created via
# meta-programming. All entries are defined in the `entry.rb` file.
#
# In this example, we will add a new method to the index
location = node.location
# Create the array of signatures that this method will accept. Every signatures is composed of a list of
# parameters. The parameter classes represent each type of parameter
signatures = [
RubyIndexer::Entry::Signature.new([RubyIndexer::Entry::RequiredParameter.new(name: :a)])
]
@listener.add_method(
"new_method", # Name of the method
location, # Prism location for the node defining this method
signatures # Signatures available to invoke this method
)
end
# This method is invoked when the parser has finished processing the method call node.
# It can be used to perform cleanups like popping a stack...etc.
def on_call_node_leave(node); end
end
最后,我们需要在插件激活期间在索引中注册我们的增强功能一次。
module RubyLsp
module MyLibrary
class Addon < ::RubyLsp::Addon
def activate(global_state, message_queue)
# Register the enhancement as part of the indexing process
global_state.index.register_enhancement(MyIndexingEnhancement.new(global_state.index))
end
def deactivate
end
def name
"MyLibrary"
end
def version
"0.1.0"
end
end
end
end
完成!这样,Ruby LSP 应该自动处理对 my_dsl_that_creates_methods
的调用,并创建运行时可用的声明的准确表示。
注册格式化程序
Gem 还可以提供一个供 Ruby LSP 使用的格式化程序。为此,插件必须创建一个格式化程序运行程序并注册它。如果用户配置的 rubyLsp.formatter
选项与注册的标识符匹配,则使用该格式化程序。
class MyFormatterRubyLspAddon < RubyLsp::Addon
def name
"My Formatter"
end
def activate(global_state, message_queue)
# The first argument is an identifier users can pick to select this formatter. To use this formatter, users must
# have rubyLsp.formatter configured to "my_formatter"
# The second argument is a class instance that implements the `FormatterRunner` interface (see below)
global_state.register_formatter("my_formatter", MyFormatterRunner.new)
end
end
# Custom formatter
class MyFormatter
# If using Sorbet to develop the add-on, then include this interface to make sure the class is properly implemented
include RubyLsp::Requests::Support::Formatter
# Use the initialize method to perform any sort of ahead of time work. For example, reading configurations for your
# formatter since they are unlikely to change between requests
def initialize
@config = read_config_file!
end
# IMPORTANT: None of the following methods should mutate the document in any way or that will lead to a corrupt state!
# Provide formatting for a given document. This method should return the formatted string for the entire document
def run_formatting(uri, document)
source = document.source
formatted_source = format_the_source_using_my_formatter(source)
formatted_source
end
# Provide diagnostics for the given document. This method must return an array of `RubyLsp::Interface::Diagnostic`
# objects
def run_diagnostic(uri, document)
end
end
向客户端发送通知
有时,插件可能需要向客户端发送异步信息。例如,一个缓慢的请求可能希望指示进度,或者可以在后台计算诊断信息,而不会阻塞语言服务器。
为此,所有插件在激活时都会收到消息队列,这是一个可以接收客户端通知的线程队列。插件应保留对该消息队列的引用,并将其传递给有兴趣使用它的监听器。
注意:不要在任何地方关闭消息队列。Ruby LSP 将在适当的时候处理关闭消息队列。
module RubyLsp
module MyGem
class Addon < ::RubyLsp::Addon
def activate(global_state, message_queue)
@message_queue = message_queue
end
def deactivate; end
def name
"Ruby LSP My Gem"
end
def version
"0.1.0"
end
def create_hover_listener(response_builder, node_context, index, dispatcher)
MyHoverListener.new(@message_queue, response_builder, node_context, index, dispatcher)
end
end
class MyHoverListener
def initialize(message_queue, response_builder, node_context, index, dispatcher)
@message_queue = message_queue
@message_queue << Notification.new(
message: "$/progress",
params: Interface::ProgressParams.new(
token: "progress-token-id",
value: Interface::WorkDoneProgressBegin.new(kind: "begin", title: "Starting slow work!"),
),
)
end
end
end
end
注册文件更新事件
默认情况下,当 Ruby 源代码被修改时,Ruby LSP 会侦听以 .rb
结尾的文件的更改,以持续更新其索引。如果您的插件使用通过文件配置的工具(如 RuboCop 及其 .rubocop.yml
),您可以注册对这些文件的更改,并在配置更改时做出反应。
注意:除了您自己注册的事件外,您还将收到来自 ruby-lsp
和其他插件的事件。
module RubyLsp
module MyGem
class Addon < ::RubyLsp::Addon
def activate(global_state, message_queue)
register_additional_file_watchers(global_state, message_queue)
end
def deactivate; end
def version
"0.1.0"
end
def name
"My Addon"
end
def register_additional_file_watchers(global_state, message_queue)
# Clients are not required to implement this capability
return unless global_state.supports_watching_files
message_queue << Request.new(
id: "ruby-lsp-my-gem-file-watcher",
method: "client/registerCapability",
params: Interface::RegistrationParams.new(
registrations: [
Interface::Registration.new(
id: "workspace/didChangeWatchedFilesMyGem",
method: "workspace/didChangeWatchedFiles",
register_options: Interface::DidChangeWatchedFilesRegistrationOptions.new(
watchers: [
Interface::FileSystemWatcher.new(
glob_pattern: "**/.my-config.yml",
kind: Constant::WatchKind::CREATE | Constant::WatchKind::CHANGE | Constant::WatchKind::DELETE,
),
],
),
),
],
),
)
end
def workspace_did_change_watched_files(changes)
if changes.any? { |change| change[:uri].end_with?(".my-config.yml") }
# Do something to reload the config here
end
end
end
end
end
依赖约束
在我们为插件 API 找到一个好的设计时,必然会发生重大更改。为避免插件意外破坏编辑器功能,您应该定义插件所依赖的版本。有两种方法可以实现此目的。
对 ruby-lsp 具有运行时依赖项的插件
对于对 ruby-lsp
gem 具有运行时依赖项的插件,您可以简单地使用常规的 gemspec 约束来定义支持的版本。
spec.add_dependency("ruby-lsp", "~> 0.6.0")
对 ruby-lsp 没有运行时依赖项的插件
对于定义在不希望对 ruby-lsp
具有运行时依赖项的其他 gem 中的插件,请使用以下 API 来确保兼容性。
如果 Ruby LSP 自动升级到插件不支持的版本,则该插件将根本不会被激活,并且会发出警告,并且该功能将不可用。作者必须更新以确保与当前 API 状态兼容。
# Declare that this add-on supports the base Ruby LSP version v0.18.0, but not v0.19 or above
#
# If the Ruby LSP is upgraded to v0.19.0, this add-on will fail gracefully to activate and a warning will be printed
RubyLsp::Addon.depend_on_ruby_lsp!("~> 0.18.0")
module RubyLsp
module MyGem
class Addon < ::RubyLsp::Addon
def activate(global_state, message_queue)
end
def deactivate; end
def version
"0.1.0"
end
def name
"My Addon"
end
end
end
end
测试插件
在为插件编写单元测试时,务必牢记,代码在开发者编写过程中很少处于最终状态。因此,请务必测试代码仍不完整时的有效场景。
例如,如果您正在编写与 require
相关的特性,请不要只测试 require "library"
。考虑用户在输入时可能遇到的中间状态。此外,还要考虑不常见但仍然有效的 Ruby 语法。
# Still no argument
require
# With quotes autocompleted, but no content on the string
require ""
# Using uncommon, but valid syntax, such as invoking require directly on Kernel using parenthesis
Kernel.require("library")
Ruby LSP 导出一个测试助手,该助手创建一个服务器实例,其中文档已使用所需内容初始化。这有助于测试您的插件与语言服务器的集成。
插件会自动加载,因此只需执行所需的语言服务器请求即可包含您的插件的贡献。
require "test_helper"
require "ruby_lsp/test_helper"
class MyAddonTest < Minitest::Test
def test_my_addon_works
source = <<~RUBY
# Some test code that allows you to trigger your add-on's contribution
class Foo
def something
end
end
RUBY
with_server(source) do |server, uri|
# Tell the server to execute the definition request
server.process_message(
id: 1,
method: "textDocument/definition",
params: {
textDocument: {
uri: uri.to_s,
},
position: {
line: 3,
character: 5
}
}
)
# Pop the server's response to the definition request
result = server.pop_response.response
# Assert that the response includes your add-on's contribution
assert_equal(123, result.response.location)
end
end
end