Skip to main content

Command Palette

Search for a command to run...

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

Updated
9 min read
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 any PATH gaps in Zed's launch environment. (Using rbenv? Point at ~/.rbenv/shims/bundle. Using asdf? ~/.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:

  1. cwd: "$ZED_WORKTREE_ROOT" points at the repo root — no Gemfile there. bundle fails.

  2. $ZED_RELATIVE_FILE is relative to the worktree root, so it expands to server/spec/.../organizations_spec.rb. The moment you fix the cwd to be server/, that path is wrong — there's no server/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_FILE silently point at the wrong place. \)ZED_FILE doesn'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.json means 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-run proves the path is right — both catch misconfiguration before it interrupts your flow.

Happy hacking.