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.
Comments
No comments yet. Be the first.