附加元件
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