LaunchKit
← All posts
· 14 min read · by The LaunchKit team · 0 views

Rails in VS Code: one extension, and eight you can uninstall

The VS Code marketplace is the worst place to find out which extensions a Rails app needs, and it is the only place anybody looks. Search it for "ruby on rails", sort by installs, and the top result is a snippet pack whose last release was in 2024, sitting above nine more that average a 2019 release date. The language server that Shopify ships, which is what actually answers your editor's questions, is not on that page at all.

Everything below was run on 2026-09-27 against VS Code 1.135.0 (commit 08d4889f9ec4a1685d257b9b95de036c8e1ce1e5, arm64) on an Apple M2 Max with 12 cores, macOS arm64-darwin25, Ruby 4.0.5. The application under the editor is a stock rails new on Rails 8.1.4 with a Post, a Comment and one controller, PostgreSQL on port 15432. The extension is shopify.ruby-lsp@0.10.6 driving the ruby-lsp gem 0.26.11 and ruby-lsp-rails 0.4.8. Every LSP response quoted here came out of a small stdio client that speaks the same JSON-RPC the editor speaks, so the answers are the ones VS Code would have received.

What the marketplace serves when you search it

Install counts come from the marketplace's own extensionquery endpoint, which anybody can hit:

curl -s -X POST 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json;api-version=7.2-preview.1' \
  -d '{"filters":[{"criteria":[{"filterType":8,"value":"Microsoft.VisualStudio.Code"},
       {"filterType":10,"value":"ruby on rails"}],"pageSize":10,"sortBy":4,"sortOrder":0}],"flags":914}'

filterType 10 is the search text, sortBy 4 is install count. Pretty-printed, that returned:

 1. bung87.rails                           Rails                      installs=445205    last=2024-11-09
 2. hridoy.rails-snippets                  Ruby on Rails              installs=407213    last=2018-04-03
 3. jemmyw.rails-fast-nav                  Rails Fast Nav             installs=45906     last=2026-06-07
 4. Vense.rails-snippets                   Rails Snippets             installs=41689     last=2019-12-15
 5. hjleochen.rails-nav                    Rails Nav                  installs=33166     last=2016-11-29
 6. atefth.ruby-on-rails-snippets          ruby-on-rails-snippets     installs=28562     last=2016-03-24
 7. tmikoss.rails-latest-migration         rails-latest-migration     installs=26390     last=2017-05-24
 8. ricardo-emerson.ruby-on-rails-develop… Ruby on Rails Development  installs=17023     last=2020-04-25
 9. endverbraucher.pack-rails              Ruby and Rails Extension P installs=11003     last=2024-06-05
10. Quoyi.rails-vscode                     Ruby on Rails Snippets     installs=10165     last=2021-01-17

Swap the search text for "ruby" and the highest-placed Ruby-specific result, at position 10 behind Copilot and Prettier, is rebornix.Ruby: 3,885,159 installs, version 0.28.1, last updated 2021-01-08. That is the extension most Rails developers have installed. wingrunr21.vscode-ruby sits just under it at 3,072,513 installs and 2021-01-05.

Shopify.ruby-lsp has 1,940,902 installs and an average rating of 2.82 from 61 raters, which is the lowest score of every extension named on this page. Solargraph's extension rates 4.73 and Sorbet's 4.75. Ratings on a language server measure how often it failed to start on somebody's Ruby version manager, and they are not a ranking of what the thing does once it is running.

One extension, and the eight it took over

Shopify.ruby-lsp@0.10.6 declares engines.vscode ^1.91.0 and exactly one dependency, vscode.git. Reading its package.json out of ~/.vscode/extensions/shopify.ruby-lsp-0.10.6/ gives the commands it contributes:

rubyLsp.runTest              -  Run current test
rubyLsp.debugTest            -  Debug current test
rubyLsp.railsGenerate        -  Rails generate
rubyLsp.railsDestroy         -  Rails destroy
rubyLsp.goToRelevantFile     -  Go to relevant file (test <> source code)
rubyLsp.showSyntaxTree       -  Show syntax tree
rubyLsp.profileCurrentFile   -  Profile current file
rubyLsp.migrateLaunchConfiguration  -  Migrate launch.json configurations from rdbg to ruby_lsp

That last line is the clearest statement anyone has made about KoichiSasada.vscode-rdbg (1,046,703 installs, last updated 2023-12-25): the replacement ships a command whose entire job is migrating your launch.json off it. The same manifest contributes debuggers: ruby_lsp and breakpoints: ruby, so the debug adapter is in the box. runTest and debugTest cover what connorshea.vscode-ruby-test-adapter (87,140 installs) did. railsGenerate and railsDestroy cover the generator palette from bung87.rails. goToRelevantFile covers jemmyw.rails-fast-nav.

The languages block claims .rb .rake .gemspec .ru .rbi .jbuilder .thor and twenty more for ruby, plus .erb .rhtml .rhtm for erb, which retires CraigMaslowski.erb (446,974 installs, last updated 2015-11-19) for syntax highlighting.

RuboCop is the one worth proving rather than reading off a manifest. On startup the server logged Auto detected formatter: rubocop_internal (direct dependency matching /^rubocop/). Then a model with body[0,20] in it produced this from textDocument/diagnostic:

line 3: [Layout/SpaceAfterComma] Layout/SpaceAfterComma: Space missing after comma.
count=1

and this from textDocument/formatting:

[{"range":{"start":{"line":0,"character":0},"end":{"line":6,"character":0}},
  "newText":"class Comment < ApplicationRecord\n  belongs_to :post\n  def preview\n    body[0, 20]\n  end\nend\n"}]

misogi.ruby-rubocop has 905,672 installs and was last updated 2021-12-05. It has nothing left to do.

The Rails half is a second process

The Rails-specific answers do not come from the language server. ruby-lsp-rails registers as an addon and boots a separate Ruby process that loads your application, and the server log says so in order:

LOG: Auto detected formatter: rubocop_internal (direct dependency matching /^rubocop/)
LOG: Detected test library: rails (bin/rails present)
LOG: Finished initializing Ruby LSP!
LOG: Activating Ruby LSP Rails add-on v0.4.8
LOG: Ruby LSP Rails booting server
LOG: Finished booting Ruby LSP Rails server

That second process is visible in ps, parented to the language server:

  PID  PPID    RSS COMMAND
54587 54573 415616 ruby /Users/…/.rvm/gems/ruby-4.0.5/bin/ruby-lsp
54648 54587  85232 /Users/…/gems/ruby-lsp-rails-0.4.8/lib/ruby_lsp/ruby_lsp_rails/server.rb

What it buys is the one feature nothing else in this list can fake. Hovering the constant on line 1 of app/models/post.rb returns markdown with the table in it:

```ruby
Post
```

**Definitions**: [post.rb](file:///…/app/models/post.rb#L1,1-8,4)

[Schema](file:///…/db/schema.rb)

### Columns
- **id**: integer (PK)
- **title**: string
- **body**: text
- **published_at**: datetime
- **created_at**: datetime - not null
- **updated_at**: datetime - not null

That is aki77.rails-db-schema (106,004 installs) without the extension. Two more come from the same addon. textDocument/definition on the symbol in has_many :comments, dependent: :destroy resolves to app/models/comment.rb, a jump no constant lookup can make because :comments is not a constant. And textDocument/codeLens on the controller returns one lens per action:

[{"range":{"start":{"line":1,"character":2},"end":{"line":3,"character":5}},
  "command":{"title":"GET /posts(.:format)","command":"rubyLsp.openFile",
             "arguments":[["file:///…/config/routes.rb#L2"]]}}]

Put ruby-lsp and ruby-lsp-rails in your own Gemfile under group :development rather than letting the extension resolve them. The reason is in the next-to-last section.

What it costs: about five seconds and 493 MB

The gem ships a subcommand for this, so the number does not need a stopwatch:

$ bundle exec ruby-lsp --time-index
Ruby LSP v0.26.11: Indexing took 6.15753 seconds and generated:
- Accessor: 6303
- Class: 6499
- Constant: 7296
- InstanceVariable: 16527
- Method: 56621
- Module: 9149
- SingletonClass: 2410

For scale, the same binary run in a directory holding one file and no Gemfile took 2.86755 seconds and produced 33,697 methods. So a stock Rails 8 application and its 96 gems cost about 3.3 extra seconds and 22,924 extra methods of index, once, at startup.

Through the protocol the wall clock is shorter, because the editor measures from the initialized notification rather than from process start: three runs gave 5,476 ms, 4,767 ms and 4,847 ms, with initialize itself answering in 881 ms and 1,017 ms on two separate boots. Steady state after one file was opened, read with ps -Ao rss=,command=, was 420,240 KB for the server and 84,000 KB for the Rails runner. Call it 493 MB per workspace, before VS Code's own extension host, and multiply it by the number of Rails windows you keep open.

Excluding gems from the index is not worth the settings entry

rubyLsp.indexing takes excludedGems, and the obvious candidates are the two heaviest gems nobody navigates into. find -name '*.rb' | wc -l puts rubocop at 946 files and brakeman at 551, well ahead of activerecord's 399. Both are indexed by default: initial_excluded_gems in ruby_indexer/configuration.rb only drops dependencies whose groups == [:development], and the stock Rails 8 Gemfile files brakeman, bundler-audit and rubocop-rails-omakase under group :development, :test.

Excluding all five rubocop gems plus brakeman, three runs each through the protocol:

default run 1: 5476 ms    excluded run 1: 4145 ms
default run 2: 4767 ms    excluded run 2: 4289 ms
default run 3: 4847 ms    excluded run 3: 4140 ms

Best of three against best of three: 4,767 ms down to 4,140 ms. Six hundred milliseconds, once per editor session, in exchange for losing go-to-definition on every cop class. Leave it alone. The setting earns its place on a monorepo with hundreds of gems, and that is a different measurement than this one.

One related trap: the .index.yml file that older blog posts tell you to write still loads, and server.rb:1342 answers it with window/showMessage at WARNING level saying "The .index.yml configuration file is deprecated. Please use editor settings to configure the index."

The schema disappears from the hover when the database is unreachable

Point config/database.yml at a port with nothing on it (15999 instead of 15432) and restart the server. The log is identical, down to the last line:

LOG: Activating Ruby LSP Rails add-on v0.4.8
LOG: Ruby LSP Rails booting server
LOG: Finished booting Ruby LSP Rails server

No warning, no window/showMessage, no diagnostic. The hover on Post just gets shorter:

"```ruby\nPost\n```\n\n**Definitions**: [post.rb](file:///…/app/models/post.rb#L1,1-8,4)"

The [Schema] link and the whole ### Columns block are gone. Go-to-definition on :comments still resolves to app/models/comment.rb in the same session, because that answer comes from the static index rather than from the booted application. So the failure is partial and silent: half the addon keeps working, and the half that stopped is the half you installed it for.

This is worth knowing on a Monday morning when Postgres is not running yet, and it is worth knowing on any project whose config/database.yml needs an environment variable your editor's process does not have. The tell is that columns stop appearing in hovers while everything else feels normal.

Scopes are in the outline and nowhere else

scope :published, -> { where.not(published_at: nil) } shows up in textDocument/documentSymbol alongside has_many :comments, which is why the VS Code breadcrumb bar lists it. Hovering Post.published at the call site in the controller returns nil.

Grepping the addon explains it in one line: "scope" appears once in the whole of ruby-lsp-rails-0.4.8, at lib/ruby_lsp/ruby_lsp_rails/document_symbol.rb:54, in the list of macros the outline recognises. Nothing feeds scopes into the index, so nothing can complete them, hover them or jump to them. Same for the reader methods generated from your columns: post.published_at is not a definition Ruby LSP knows about.

ERB gets constants and a guess

rubyLsp.erbSupport defaults to true and the server does parse .erb, so this is not the "no support" story it is often written up as. On this template:

<h1>Posts: <%= Post.published.count %></h1>
<% @posts.each do |post| %>
  <p><%= post.summary %></p>
<% end %>

hovering Post returns the constant and textDocument/definition on it jumps to the model. Hovering summary returns the method, with a line that is the most honest thing in the protocol:

```ruby
summary()
```

Guessed receiver: Post

That string is built at ruby_lsp/listeners/hover.rb:318. The receiver was inferred from the variable being called post, not from any knowledge of what @posts contains. Rename the block parameter to p and the guess is gone.

Three requests return nothing at all on the same file. textDocument/documentSymbol returns [], so ERB files have no outline and no breadcrumbs. textDocument/diagnostic returns null, so RuboCop does not see your templates. textDocument/formatting returns null, which is why "format on save" silently does nothing in a view and does the right thing in the model next to it.

Herb is the ERB extension, and its formatter looks broken until it is not

marcoroth.herb-lsp 0.11.0 was published on 2026-09-24 and has 20,020 installs, against 276,777 for aliariff.vscode-erb-beautify and 251,793 for manuelpuyol.erb-linter, so roughly 14 and 13 times behind. It is still the one to install, because it is built on the same parser that becomes the default ERB engine in Rails 8.2, and because it is the only one of the three with a release this year.

Driven over stdio the same way, it identifies itself as Herb Language Server 0.11.0 (main, 2026-09-24 09:36:11) and advertises documentFormattingProvider, documentSymbolProvider, definitionProvider and codeActionProvider. Open a template with an omitted closing tag and it pushes two diagnostics from two different layers on the same line:

line 3: [OMITTED_CLOSING_TAG_ERROR] Element `<li>` at (4:1) has its closing tag omitted. While valid
        HTML, consider adding an explicit `</li>` closing tag at (5:0) for clarity, or set
        `strict: false` to allow this.
line 4: [html-require-closing-tags] Missing explicit closing tag for `<li>`. Use `</li>` instead of
        relying on implicit tag closing.

Open a well-formed one and it volunteers a Rails-specific configuration diagnostic nobody asks for and everybody should act on: herb-config-framework-option, "No framework is set in .herb.yml, so Herb assumes plain ruby templates. Set framework to one of ruby, actionview, hanami, or sinatra".

Now the part that cost an hour. languageServerHerb.formatter.enabled defaults to false. Turn it on, answer the server's workspace/configuration request with it, send workspace/didChangeConfiguration for good measure, and textDocument/formatting on the file with the missing </li> still returns []. That reads as an extension that does not work, and the obvious conclusion is wrong.

Running the formatter's own CLI on the same bytes settles it:

$ ./node_modules/.bin/herb-format - < show.html.erb

⚠️  Experimental Preview: The formatter is in early development. Please report any unexpected
behavior or bugs to https://github.com/marcoroth/herb/issues/new?template=formatting-issue.md

<div class="post"><h1><%= @post.title %></h1></div>
<li><%= c.preview %>

Unchanged, same as the language server. Give it a file the parser accepts and it reindents immediately:

$ ./node_modules/.bin/herb-format - < valid.html.erb
<div class="post">
  <h1><%= @post.title %></h1>
  <p><%= @post.body %></p>
</div>

The empty edit list was the parser refusing the file, not the formatter failing to load. An omitted </li> is valid HTML5 and Herb rejects it by default, so every template in your app that leans on implicit tag closing is a template the formatter will decline, in silence, forever, until you close the tag or set strict: false.

One more thing found on the way, which only shows up outside the editor. Spawning the server the way the extension spawns it fails on a clean machine:

$ node ~/.vscode/extensions/marcoroth.herb-lsp-0.11.0/dist/herb-language-server.js --stdio
Error: Cannot find module 'vscode-html-languageservice'
Require stack:
- /Users/…/.vscode/extensions/marcoroth.herb-lsp-0.11.0/dist/herb-language-server.js

dist/herb-language-server.js:52 does require("vscode-html-languageservice") and the published VSIX contains no node_modules. Installing that one package into a scratch directory and pointing NODE_PATH at it was enough to get every result above. Whether VS Code's own extension host resolves it some other way is not something this page tested.

The directory it writes into your repository

If ruby-lsp is not in your Gemfile, the extension writes a .ruby-lsp/ directory at the workspace root holding a Gemfile, a Gemfile.lock, a freshness_hash, and a .gitignore whose entire contents are *. The generated Gemfile says what it is:

# This custom gemfile is automatically generated by the Ruby LSP.
# It should be automatically git ignored, but in any case: do not commit it to your repository.

eval_gemfile(File.expand_path("../../Gemfile", __dir__))
gem "ruby-lsp", ">= 0.18.0", require: false, group: :development
gem "debug", require: false, group: :development, platforms: :mri

Declaring both gems yourself skips all of it. The scratch app used for this page has ruby-lsp and ruby-lsp-rails in group :development and never grew a .ruby-lsp/; a bare directory without them grew one on the first run.

Skip the extension pack

Shopify.ruby-extensions-pack@0.1.14 is published under the display name "Ruby", which is what most people search for. Its extensionPack field is two entries:

["sorbet.sorbet-vscode-extension", "Shopify.ruby-lsp"]

Installing it pulled in sorbet.sorbet-vscode-extension@0.3.47 alongside the one extension you wanted. If your Rails app has no sorbet/config, that is a second language client starting up and failing on every window.

What this page does not cover

Solargraph was not run. castwide.solargraph has 1,267,844 installs and a 4.73 rating and it is the serious alternative to Ruby LSP; nothing here measures it, and the absence of a comparison is not a verdict on it. Neither was Sorbet, beyond noting that the extension pack installs it.

Everything was measured on macOS arm64. Windows and Linux differ most in exactly the place these ratings come from, which is Ruby version manager detection, and rubyLsp.rubyVersionManager has entries for asdf, mise, rbenv, rv and chruby that none of this exercises.

Cursor is installed on this machine and was not tested. It reads the same marketplace, but it resolves extensions through its own registry, and a version number checked here would not be the one it serves.

Nothing here measures the editor's own latency. Every number is a language server answering a request on a stdio pipe; how long VS Code takes to paint the result on top of that is a different question with a different tool.

#rails #ruby #tooling

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.