Extending Ruby LSP with Prism

thunderbong1 pts0 comments

Extending Ruby LSP with Prism | Janko Marohnić

May 26, 2026

Extending Ruby LSP with Prism

Ruby LSP is a wonderful language server built on top of Prism, Rubydex and RBS. It implements a variety of features that enrich the code editing experience in Ruby projects. Its add-on architecture allows extending it with Rails features, Rubocop support and custom functionality.

Coming from Vim, I was really used to rails.vim. When I switched to Zed, I started using Ruby LSP. In some ways I felt like I’ve gained superpowers, as now I had all these modern editor features that are possible because my Ruby code is actually being parsed. On the other hand, I found there were some features I was missing.

One such feature was following render calls in view templates. Rails.vim offered a gf (“go to file”) mapping, which when hovering over a render call would take me to the partial being rendered. In LSP terminology this functionality is called “go to definition”. If I were to implement it, I knew it had to live in the Rails add-on.

LSP mechanics

Before we can get into the weeds, we need to establish how language servers work. The Language Server Protocol (LSP) defines JSON-RPC messaging between a code editor and a server process.

When you hover over a Ruby class constant and hold cmd, Zed will send a message to Ruby LSP in the following format:

"jsonrpc": "2.0",<br>"id": 5,<br>"method": "textDocument/definition",<br>"params": {<br>"textDocument": {<br>"uri": "file:///path/to/source.rb"<br>},<br>"position": { "line": 7, "character": 27 }

Since Ruby LSP indexes all constant declarations on initialization, it can then respond with the location where the constant is defined:

"id": 5,<br>"result": [<br>"targetUri": "file:///path/to/class.rb",<br>"targetRange": {<br>"start": { "line": 8, "character": 2 },<br>"end": { "line": 253, "character": 5 }<br>},<br>"targetSelectionRange": {<br>"start": { "line": 8, "character": 9 },<br>"end": { "line": 8, "character": 19 }<br>],<br>"jsonrpc": "2.0"

Here we can see Ruby LSP returned the location of the class ... end block (targetRange) as well as the constant name the editor should select (targetSelectRange). Notice that the result field is an array, allowing for multiple locations in case the class is re-opened more than once (which Zed will open a multibuffer).

Custom add-on

Back to the task at hand. I needed to test it out my Ruby LSP extension locally first. It turns out Ruby LSP will automatically pick up any add-ons in your project directory, they just need to match **/ruby_lsp/**/addon.rb. So, I put mine in lib/ruby_lsp/my_app/addon.rb:

# lib/ruby_lsp/my_app/addon.rb<br>require "ruby_lsp/addon"

module RubyLsp<br>module MyApp<br>class Addon RubyLsp::Addon<br>def activate(global_state, outgoing_queue)<br>outgoing_queue Notification.window_log_message("Activated My App addon")<br>end<br>end<br>end<br>end

If everything works correctly, after restarting your Ruby LSP server, you should see the “Activated My App addon” message in the language server logs.

When Ruby LSP receives a textDocument/definition request, it calls #create_definition_listener on every add-on with some parameters, allowing them to add their own locations to the response. Let’s override it:

# lib/ruby_lsp/my_app/addon.rb<br># ...<br>require_relative "definition"

module RubyLsp<br>module MyApp<br>class Addon RubyLsp::Addon<br># ...<br>def create_definition_listener(...)<br>Definition.new(...)<br>end<br>end<br>end<br>end

# lib/ruby_lsp/my_app/definition.rb<br>module RubyLsp<br>module MyApp<br>class Definition<br>def initialize(response_builder, uri, node_context, dispatcher)<br>@response_builder = response_builder<br>@path = uri.to_standardized_path<br>@node_context = node_context<br>@dispatcher = dispatcher<br>end<br>end<br>end<br>end

Prism drilling

Ruby LSP will use Prism to parse the source document where we activated go-to-definition, set up a dispatcher for walking the AST, and save context of the AST node you’re hovering over. In our case, we want to react on partial names passed to render calls. Since these are strings, let’s register a listener for entering string nodes:

# lib/ruby_lsp/my_app/definition.rb<br>module RubyLsp<br>module MyApp<br>class Definition<br>def initialize(response_builder, uri, node_context, dispatcher)<br># ...<br>dispatcher.register(self, :on_string_node_enter)<br>end

def on_string_node_enter(node)<br># ...<br>end<br>end<br>end<br>end

First thing’s first, we can only support following render calls inside HTML+ERB templates, as in helpers we don’t have the view/controller context. So, let’s early return otherwise:

def on_string_node_enter(node)<br>return unless html_erb?<br>end

private

def html_erb?<br>@path&.match?(/\.html(\+\w+)?\.erb/) # handle template variants<br>end

Unlike Solargraph, Ruby LSP has ERB support that can extract Ruby code, allowing it to provide the same features as for regular Ruby files. Templating languages like Slim and Haml have a much more complex grammars, making it difficult to know where Ruby code is, so as of this writing they’re not supported.

We’ll return if the string node is not a “partial argument”, which we’ll define...

ruby addon definition class module ruby_lsp

Related Articles