Getting Ruby-lsp and RSpec Working in a monorepo with Zed

If you’ve opened a monorepo in Zed and seen ruby-lsp crash repeatedly (often appearing as a “Server reset the connection” error), this post is for you.
I'll walk through why it happens, fix the language server, fix the test runner tasks, and end with a clean, reusable configuration.
The examples use a real-world layout: a Rails API and a React frontend living side by side in one repository. The repo root is monorepo/. Crucially, there is no Gemfile at the root — it's one level down in server/. That single fact is the source of every problem below.
monorepo/
├── client/ # React + Vite frontend
├── server/ # Rails API — Gemfile lives HERE
│ ├── Gemfile
│ ├── Gemfile.lock
│ └── spec/
└── .git/
Prerequisites: the gems that make this work out of the box
Zed's Ruby extension doesn't bundle a language server — it expects to run one out of your project's gems. For the experience described here (definitions, completion, diagnostics, plus Rails- and RSpec-aware features) you want these in the Rails app's Gemfile, conventionally in the :development group:
# server/Gemfile
group :development do
gem 'ruby-lsp', require: false # the language server itself
gem 'ruby-lsp-rails', '~> 0.4' # Rails awareness: routes, schema, associations
gem 'ruby-lsp-rspec', require: false # RSpec test discovery + "run test" code lens
end
What each one buys you:
| Gem | What it adds |
|---|---|
ruby-lsp |
Core LSP: go-to-definition, hover, completion, document symbols, diagnostics |
ruby-lsp-rails |
Rails-specific navigation — jump to schema columns, associations, routes; runtime introspection |
ruby-lsp-rspec |
Detects RSpec examples, exposes per-example/per-file "run test" actions, and feeds the test explorer |
Why it breaks: Zed launches tools from the worktree root
Zed runs language servers (and tasks) from the worktree root — the folder you opened. When you open monorepo/, Zed starts ruby-lsp like this:
cd /path/to/monorepo # worktree root
bundle exec ruby-lsp # ← no Gemfile here!
bundle walks up the directory tree looking for a Gemfile, finds nothing (the root has none, and there's nothing above it either), and bails with:
Could not locate Gemfile or .bundle/ directory
ruby-lsp dies, Zed restarts it, it dies again — hence the Server reset the connection loop.
This is not a ruby-lsp bug. It's a working-directory mismatch: the tool assumes a single-project layout where the Gemfile sits at the root, but in a monorepo the Ruby project is in a subdirectory.
The mise wrinkle (version managers)
Before fixing paths, check how Ruby is installed. Many setups use a version manager like mise, rbenv, or asdf. I use mise.
$ which ruby
/home/me/.local/share/mise/installs/ruby/3/bin/ruby
$ which bundle
/home/me/.local/share/mise/installs/ruby/3/bin/bundle
The catch: Zed may not launch with your shell's full environment, so the version manager's activation might not run. That means ruby/bundle may not be on PATH for the language server process even after you fix the directory.
The robust answer is to invoke the version manager's shims, which resolve the correct Ruby per-directory without needing shell activation:
$ ls ~/.local/share/mise/shims/
bundle ruby ...
We'll lean on these shims so the config works no matter how Zed was started.
Three ways to fix the language server
Option 1 — Open the subproject directly (simplest)
Just open server/ as its own Zed project:
zed ~/dev/monorepo/server
Now the worktree root is server/, the Gemfile is right there, and ruby-lsp starts cleanly. Open client/ in a separate window for frontend work.
Trade-off: you lose the single-window view of the whole monorepo. Fine if you context-switch between front and back end infrequently.
Option 2 — Add the subfolder as a second worktree
Keep the root open, then in Zed: Cmd/Ctrl-Shift-P → "Add Folder to Project" → pick ~/dev/monorepo/server.
Zed runs language servers per worktree, so the server worktree gets its own ruby-lsp launched from a directory that has a Gemfile. You keep both client/ and server/ visible.
Trade-off: it's a per-machine UI action, not something you can commit and share with the team.
Option 3 — A wrapper script (committable, shareable)
This is the option that lives in the repo and "just works" for everyone who clones it.
The idea: tell Zed to launch ruby-lsp through a small shell script that (a) cds into server/ and (b) uses the version-manager shim. Zed still launches it from the root, but the script corrects the working directory before exec'ing bundle.
.zed/ruby-lsp.sh (at the repo root):
#!/usr/bin/env bash
# Zed launches ruby-lsp from the worktree root, but the Rails app
# (and its Gemfile) lives in server/. cd there and use mise shims so
# the correct Ruby/bundle is resolved regardless of launch context.
set -euo pipefail
cd "\((dirname "\)0")/../server"
exec "\(HOME/.local/share/mise/shims/bundle" exec ruby-lsp "\)@"
Two details that make this resilient:
cd "\((dirname "\)0")/../server"resolves relative to the script's own location, not the caller's cwd. Move the repo anywhere; it still works.exec "$HOME/.local/share/mise/shims/bundle"uses the shim by absolute path, sidestepping anyPATHgaps in Zed's launch environment. (Usingrbenv? Point at~/.rbenv/shims/bundle. Usingasdf?~/.asdf/shims/bundle.)
Make it executable:
chmod +x .zed/ruby-lsp.sh
.zed/settings.json (at the repo root) tells Zed to use it:
{
"lsp": {
"ruby-lsp": {
"binary": {
"path": ".zed/ruby-lsp.sh"
}
}
}
}
Smoke-test it before trusting it. A healthy LSP server reads from stdin and waits silently — so "no error and it hangs" is success:
$ timeout 5 .zed/ruby-lsp.sh < /dev/null
# (no output, runs until the 5s timeout) →
If instead you see Could not locate Gemfile, the cd path is wrong; if you see command not found, the shim path is wrong.
Finally, restart the server in Zed: Cmd/Ctrl-Shift-P → "editor: restart language server" (or reload the window).
Don't forget the tasks — same root cause
Fixing the language server isn't the whole story. Zed tasks (the test runner bindings) launch from the worktree root too, so a naive RSpec task hits the exact same wall:
Could not locate Gemfile or .bundle/ directory
⏵ Task `test server/spec/.../organizations_spec.rb` finished with exit code: 10
⏵ Command: /usr/bin/zsh -i -c 'bundle'
Here's a task definition that looks reasonable but is broken in a monorepo — server/.zed/tasks.json:
[
{
"label": "test $ZED_RELATIVE_FILE",
"command": "bundle",
"args": ["exec", "rspec", "\"$ZED_RELATIVE_FILE\""],
"cwd": "$ZED_WORKTREE_ROOT",
"tags": ["ruby-test"]
}
]
Two bugs, both stemming from the root/subdir split:
cwd: "$ZED_WORKTREE_ROOT"points at the repo root — no Gemfile there.bundlefails.$ZED_RELATIVE_FILEis relative to the worktree root, so it expands toserver/spec/.../organizations_spec.rb. The moment you fix the cwd to beserver/, that path is wrong — there's noserver/server/spec/....
The fix
Point cwd into server/, and switch to the absolute file path so it's correct no matter where the command runs:
[
{
"label": "test $ZED_RELATIVE_FILE",
"command": "bundle",
"args": ["exec", "rspec", "\"$ZED_FILE\""],
"cwd": "$ZED_WORKTREE_ROOT/server",
"tags": ["ruby-test"]
}
]
What changed and why:
| Field | Before | After | Why |
|---|---|---|---|
cwd |
$ZED_WORKTREE_ROOT |
$ZED_WORKTREE_ROOT/server |
So bundle finds the Gemfile |
| file arg | $ZED_RELATIVE_FILE |
$ZED_FILE |
Absolute path — immune to the cwd change |
The label keeps $ZED_RELATIVE_FILE because there it's just display text, and server/spec/... reads nicely in the task picker.
Handy Zed task variables
| Variable | Expands to |
|---|---|
$ZED_WORKTREE_ROOT |
Absolute path of the opened folder |
$ZED_FILE |
Absolute path of the active file |
$ZED_RELATIVE_FILE |
Active file path, relative to the worktree root |
$ZED_FILENAME |
Just the file name |
$ZED_DIRNAME |
Absolute path of the active file's directory |
$ZED_SYMBOL |
Nearest symbol to the cursor (great for "run one test") |
A nice follow-on: use \(ZED_SYMBOL or \)ZED_ROW to run a single example under the cursor:
{
"label": "test (line) \(ZED_RELATIVE_FILE:\)ZED_ROW",
"command": "bundle",
"args": ["exec", "rspec", "\"\(ZED_FILE:\)ZED_ROW\""],
"cwd": "$ZED_WORKTREE_ROOT/server",
"tags": ["ruby-test"]
}
Verify the task command by hand
Before clicking "run" in Zed, reproduce exactly what the task does:
$ cd ~/dev/monorepo/server
$ ~/.local/share/mise/shims/bundle exec rspec \
"/home/me/dev/monorepo/server/spec/requests/api/super_admin/organizations_spec.rb" \
--dry-run
Randomized with seed 38341
.................................
33 examples, 0 failures
--dry-run loads everything without executing — a fast way to confirm the path and Gemfile resolve correctly.
Final layout
monorepo/
├── .zed/
│ ├── ruby-lsp.sh # cd into server/ + mise shim, then exec bundle exec ruby-lsp
│ └── settings.json # routes the ruby-lsp binary to the wrapper
├── client/
└── server/
├── .zed/
│ └── tasks.json # RSpec task with cwd=server and absolute $ZED_FILE
├── Gemfile
└── spec/
.zed/settings.json
{
"lsp": {
"ruby-lsp": {
"binary": { "path": ".zed/ruby-lsp.sh" }
}
}
}
.zed/ruby-lsp.sh
#!/usr/bin/env bash
set -euo pipefail
cd "\((dirname "\)0")/../server"
exec "\(HOME/.local/share/mise/shims/bundle" exec ruby-lsp "\)@"
server/.zed/tasks.json
[
{
"label": "test $ZED_RELATIVE_FILE",
"command": "bundle",
"args": ["exec", "rspec", "\"$ZED_FILE\""],
"cwd": "$ZED_WORKTREE_ROOT/server",
"tags": ["ruby-test"]
}
]
Takeaways
One root cause, two symptoms. In a monorepo, the Ruby project isn't at the worktree root, and anything that runs
bundle— the language server and tasks alike — needs its working directory pointed at the subfolder.Prefer absolute paths in task args. Once you move
cwd, root-relative variables like\(ZED_RELATIVE_FILEsilently point at the wrong place.\)ZED_FILEdoesn't care where you run from.Use version-manager shims by absolute path. Editors don't always inherit your shell's environment; shims make Ruby resolution deterministic.
A wrapper script is the shareable fix. Options 1 and 2 are great for a single developer, but a committed
.zed/ruby-lsp.sh+settings.jsonmeans every teammate gets a working setup on clone.Smoke-test before trusting. An LSP that hangs silently is healthy; a task that loads N examples with
--dry-runproves the path is right — both catch misconfiguration before it interrupts your flow.
Happy hacking.



