插件

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