附加元件

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,這些 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