# Ruby Development Mac Tahoe Setup
Adding Ruby development on top of an Apple Silicon Mac already configured per [[General_Development_Mac_Tahoe_Setup]]. Most of the stack carries over — Ghostty, Starship, Git, GitHub CLI, VS Code, Claude Code, and the zsh setup all work identically. This guide only covers the Ruby-specific additions.
> [!tip] AI assistance is optional
> The reference to Claude Code above assumes you completed Part 7 of the General guide. If you skipped it (which is fine — your dev environment works without any AI) or use a different AI tool (Copilot, Cursor, Continue, etc.), this guide's instructions still work — the language toolchain doesn't depend on any AI being present.
> [!info] Coexistence
> C, Python, Ruby, Go, and Rust can all live on the same system without interfering. Each language's toolchain installs to its own prefix: `uv` under `~/.local/share/uv/`, `rv` under `~/.rv/`, Go under `~/go/`, Rust under `~/.rustup/` and `~/.cargo/`, C/CMake under Homebrew. No conflicts.
---
## Version manager choice: `rv`
Apple ships a system Ruby at `/usr/bin/ruby`, but **never use it for development** — it's old, locked to the OS version, and modifying its gems requires `sudo`. Use a version manager.
The 2026 landscape:
| Tool | Best for | Speed | Notes |
|------|----------|-------|-------|
| **`rv`** | Ruby-only users | ⚡ fastest | Rust-based, Astral-style, `uv`'s direct Ruby analog |
| **`mise`** | Multi-language users | ⚡ fast | Replaces rbenv + nvm + pyenv in one tool |
| `rbenv` | Legacy / established workflows | Slower (shims) | Still works, widely documented |
| `chruby` | Minimalists | Fast | Lightweight, no shims |
| `rvm` | Don't bother for new installs | Slow | Heavy, legacy |
**Recommendation: `rv`.** Since you're already using `uv` for Python, `rv` offers the same mental model — pre-compiled runtimes, fast install, single binary, no shims. If you later need Node.js or Elixir alongside Ruby, switch to `mise`.
### Install `rv`
```bash
brew install rv
```
Add its shell hook to `~/.zshrc`:
```bash
echo 'eval "$(rv shell init zsh)"' >> ~/.zshrc
source ~/.zshrc
```
Verify:
```bash
rv --version
```
### Install Ruby versions
```bash
rv ruby install 3.3
rv ruby install 3.2
rv ruby list
# Set global default
rv ruby pin 3.3 --global
```
Verify:
```bash
ruby -v
which ruby
gem env
```
---
## Bundler — the dependency manager
Bundler is Ruby's equivalent of `pip` + `venv` combined. It's bundled with modern Ruby (no install needed), but upgrade once:
```bash
gem update --system
gem install bundler
bundle --version
```
Configure Bundler to install gems into a project-local `vendor/bundle/` directory by default (keeps project environments isolated, similar to Python venvs):
```bash
bundle config set --global path 'vendor/bundle'
```
> [!info] Why project-local gems?
> Without this config, `bundle install` installs gems globally for the current Ruby version. That works, but it means different projects can silently conflict on gem versions. Setting `path` to `vendor/bundle` gives each project its own isolated gem directory, just like `uv`'s `.venv/`.
---
## VS Code extensions for Ruby
```bash
# Shopify's Ruby LSP — modern language server, actively maintained, from Shopify
code --install-extension Shopify.ruby-lsp
# Rubocop — linter and formatter integration
code --install-extension rubocop.vscode-rubocop
# ERB (embedded Ruby) syntax for Rails templates
code --install-extension aliariff.vscode-erb-beautify
# RSpec test runner
code --install-extension connorshea.vscode-ruby-test-adapter
```
> [!info] Ruby LSP vs older Ruby extensions
> The official "Ruby" extension (Peckett) and "Solargraph" are older alternatives. Shopify's Ruby LSP has become the standard in 2026 — better performance, actively developed, and production-tested on Shopify's massive Ruby codebases. If you see tutorials recommending Solargraph, they're older than 2024.
---
## VS Code settings
Append to `~/Library/Application Support/Code/User/settings.json`:
```json
{
// ── Ruby ──────────────────────────────────────────────────
"[ruby]": {
"editor.defaultFormatter": "Shopify.ruby-lsp",
"editor.tabSize": 2,
"editor.insertSpaces": true,
"editor.formatOnSave": true,
"editor.rulers": [100]
},
// Ruby LSP — auto-detect Ruby version from .ruby-version
"rubyLsp.rubyVersionManager": {
"identifier": "custom",
"command": "rv shell init zsh"
},
"rubyLsp.formatter": "rubocop",
"rubyLsp.enabledFeatures": {
"codeActions": true,
"diagnostics": true,
"documentHighlights": true,
"documentLink": true,
"documentSymbols": true,
"foldingRanges": true,
"formatting": true,
"hover": true,
"inlayHint": true,
"onTypeFormatting": true,
"selectionRanges": true,
"semanticHighlighting": true,
"completion": true,
"codeLens": true,
"definition": true,
"workspaceSymbol": true,
"signatureHelp": true,
"typeHierarchy": true
}
}
```
---
## Full demo: A calculator program
This builds the same four-function calculator as the C version, but Ruby-idiomatic.
### Create the project
```bash
cd ~/projects
mkdir calc-ruby && cd calc-ruby
rv ruby pin 3.3
```
### `Gemfile` — dependency spec
```ruby
source "https://rubygems.org"
ruby "3.3"
gem "rspec", "~> 3.13", group: :test
gem "rubocop", "~> 1.68", group: :development
gem "rubocop-rspec", "~> 3.0", group: :development
```
Install the gems (into `vendor/bundle/` thanks to the earlier config):
```bash
bundle install
```
This creates `Gemfile.lock` (commit it to git — it's the reproducible lockfile).
### `lib/calculator.rb` — the core logic
```ruby
# frozen_string_literal: true
module Calculator
module_function
def add(a, b) = a + b
def sub(a, b) = a - b
def mul(a, b) = a * b
def div(a, b)
raise ZeroDivisionError, "division by zero" if b.zero?
a.fdiv(b)
end
OPERATIONS = {
"+" => :add,
"-" => :sub,
"*" => :mul,
"x" => :mul,
"/" => :div
}.freeze
def calculate(a, op, b)
method_name = OPERATIONS[op] or
raise ArgumentError, "unknown operator '#{op}'"
public_send(method_name, a.to_f, b.to_f)
end
end
```
### `bin/calc` — the command-line entry point
```ruby
#!/usr/bin/env ruby
# frozen_string_literal: true
require_relative "../lib/calculator"
def usage
warn "Usage: #{File.basename($PROGRAM_NAME)} <number> <op> <number>"
warn " op: + - * /"
warn "Example: #{File.basename($PROGRAM_NAME)} 6 + 7"
exit 1
end
usage unless ARGV.length == 3
begin
a, op, b = ARGV
result = Calculator.calculate(a, op, b)
# Print as integer if it came out whole, otherwise as a float
puts(result == result.to_i ? result.to_i : result)
rescue ArgumentError, ZeroDivisionError => e
warn "Error: #{e.message}"
exit 1
end
```
Make it executable:
```bash
chmod +x bin/calc
```
### `spec/calculator_spec.rb` — tests with RSpec
```ruby
# frozen_string_literal: true
require_relative "../lib/calculator"
RSpec.describe Calculator do
describe ".calculate" do
it "adds two numbers" do
expect(Calculator.calculate("6", "+", "7")).to eq(13.0)
end
it "subtracts two numbers" do
expect(Calculator.calculate("10", "-", "3")).to eq(7.0)
end
it "multiplies two numbers (with * or x)" do
expect(Calculator.calculate("4", "*", "5")).to eq(20.0)
expect(Calculator.calculate("4", "x", "5")).to eq(20.0)
end
it "divides two numbers" do
expect(Calculator.calculate("20", "/", "4")).to eq(5.0)
end
it "raises on division by zero" do
expect { Calculator.calculate("5", "/", "0") }
.to raise_error(ZeroDivisionError)
end
it "raises on unknown operator" do
expect { Calculator.calculate("1", "?", "2") }
.to raise_error(ArgumentError, /unknown operator/)
end
end
end
```
### `.rspec` — RSpec defaults
```
--require spec_helper
--format documentation
--color
```
### `spec/spec_helper.rb`
```ruby
# frozen_string_literal: true
RSpec.configure do |config|
config.expect_with :rspec do |c|
c.syntax = :expect
end
end
```
### `.rubocop.yml` — linter config
```yaml
AllCops:
TargetRubyVersion: 3.3
NewCops: enable
SuggestExtensions: false
Style/Documentation:
Enabled: false
Metrics/MethodLength:
Max: 20
```
### `.gitignore`
```
vendor/bundle/
.bundle/
*.gem
.rspec_status
```
### Run everything
```bash
# Run the program
./bin/calc 6 + 7
./bin/calc 10 - 3
./bin/calc 4 x 5
./bin/calc 20 / 4
# Run tests
bundle exec rspec
# Lint
bundle exec rubocop
# Auto-fix safe lint issues
bundle exec rubocop -a
```
### Debug in VS Code
Ruby LSP includes a debugger (`debug` gem, which ships with modern Ruby). Create `.vscode/launch.json`:
```json
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug calc",
"type": "ruby_lsp",
"request": "launch",
"program": "${workspaceFolder}/bin/calc",
"args": ["6", "+", "7"],
"cwd": "${workspaceFolder}"
},
{
"name": "Debug RSpec",
"type": "ruby_lsp",
"request": "launch",
"program": "${workspaceFolder}/vendor/bundle/ruby/3.3.0/bin/rspec",
"cwd": "${workspaceFolder}"
}
]
}
```
Set a breakpoint in `lib/calculator.rb`, hit **F5**, and you'll step through the code with full variable inspection.
### Publish to GitHub
Same workflow as any project (see [[General_Development_Mac_Tahoe_Setup#Publishing a repo to GitHub]]):
```bash
git init
git add .
git commit -m "Initial commit: four-function calculator"
gh repo create calc-ruby --private --source=. --remote=origin --push
```
### `CLAUDE.md` for this project
```markdown
# Project: calc-ruby
## Purpose
Four-function command-line calculator in Ruby — pedagogical example.
## Conventions
- Ruby 3.3 via rv (.ruby-version pins it)
- Bundler with project-local gems (vendor/bundle/)
- Tests in spec/ with RSpec, documentation-format output
- Lint/format with RuboCop
## Commands
- Install deps: `bundle install`
- Run: `./bin/calc <a> <op> <b>`
- Test: `bundle exec rspec`
- Lint: `bundle exec rubocop`
- Auto-fix: `bundle exec rubocop -a`
## Style
- 2-space indent, 100-column limit
- `# frozen_string_literal: true` at top of every .rb file
- snake_case methods and variables, CamelCase classes/modules
- Endless methods (def f(x) = ...) for simple one-liners
```
---
## Full demo: A calculator GUI
Same calculator, wrapped in a graphical interface. This uses **Glimmer DSL for LibUI**, a pure-Ruby declarative GUI library that wraps `libui-ng` to render native controls on each platform.
### Why Glimmer DSL for LibUI?
| Option | Pros | Cons |
|--------|------|------|
| **Glimmer DSL for LibUI** | Pure Ruby gem, native macOS controls, declarative DSL, zero system dependencies beyond the gem | Still maturing; smaller community than Qt |
| **Shoes4** | Beginner-friendly, designed for teaching | Requires JRuby |
| **wxRuby3** | Mature, comprehensive wxWidgets bindings | Larger install, less idiomatic Ruby |
| **Tk (Ruby/Tk)** | Ships with Ruby | Dated look, sometimes broken on modern macOS |
| **Qt via qml/qt-ruby** | Polished | Bridge libraries have fallen out of maintenance |
For a pedagogical calculator in Ruby, Glimmer DSL for LibUI is the right default. It installs as a single gem and produces native-looking controls on Apple Silicon macOS.
### Add Glimmer to the project
Add to your `Gemfile`:
```ruby
gem "glimmer-dsl-libui", "~> 0.13"
```
Then:
```bash
bundle install
```
### `bin/calc-gui` — the GUI entry point
```ruby
#!/usr/bin/env ruby
# frozen_string_literal: true
require "glimmer-dsl-libui"
require_relative "../lib/calculator"
class CalculatorGui
include Glimmer
BUTTON_GRID = [
%w[7 8 9 /],
%w[4 5 6 *],
%w[1 2 3 -],
%w[0 . = +]
].freeze
def initialize
@current = ""
@stored = nil
@pending_op = nil
@display_text = "0"
end
def launch
root_window.show
end
private
def root_window
window("Calculator", 260, 360) {
margined true
vertical_box {
# Display
@display = entry {
text @display_text
read_only true
}
# Button grid
BUTTON_GRID.each do |row|
horizontal_box {
row.each do |label|
button(label) {
on_clicked { handle_press(label) }
}
end
}
end
# Clear button spans the bottom
button("Clear") {
on_clicked { reset_and_update_display }
}
}
}
end
def handle_press(label)
case label
when /\A[0-9.]\z/
@current += label
update_display(@current)
when "+", "-", "*", "/"
apply_pending
@pending_op = label
when "="
apply_pending
@pending_op = nil
end
end
def apply_pending
return if @current.empty?
value = @current.to_f
if @stored.nil? || @pending_op.nil?
@stored = value
else
begin
@stored = Calculator.calculate(@stored.to_s, @pending_op, value.to_s)
rescue ZeroDivisionError, ArgumentError
update_display("Error")
reset_state
return
end
end
formatted = @stored == @stored.to_i ? @stored.to_i.to_s : @stored.to_s
update_display(formatted)
@current = ""
end
def update_display(text)
@display_text = text
@display.text = text if @display
end
def reset_state
@current = ""
@stored = nil
@pending_op = nil
end
def reset_and_update_display
reset_state
update_display("0")
end
end
CalculatorGui.new.launch if $PROGRAM_NAME == __FILE__
```
Make it executable:
```bash
chmod +x bin/calc-gui
```
### Tests for the GUI state machine
The state machine (`handle_press`, `apply_pending`, `reset_state`) is pure logic and can be unit-tested without invoking the GUI. Create `spec/calculator_gui_spec.rb`:
```ruby
# frozen_string_literal: true
require_relative "../bin/calc-gui"
RSpec.describe CalculatorGui do
let(:gui) { described_class.new }
describe "state machine" do
it "starts with display '0'" do
expect(gui.instance_variable_get(:@display_text)).to eq("0")
end
it "accumulates digits" do
%w[1 2 3].each { |d| gui.send(:handle_press, d) }
expect(gui.instance_variable_get(:@current)).to eq("123")
end
it "performs simple addition" do
%w[6 + 7 =].each { |k| gui.send(:handle_press, k) }
expect(gui.instance_variable_get(:@display_text)).to eq("13")
end
it "chains operations left-to-right" do
# 2 + 3 * 4 = (2+3)*4 = 20
%w[2 + 3 * 4 =].each { |k| gui.send(:handle_press, k) }
expect(gui.instance_variable_get(:@display_text)).to eq("20")
end
it "performs division" do
%w[2 0 / 4 =].each { |k| gui.send(:handle_press, k) }
expect(gui.instance_variable_get(:@display_text)).to eq("5")
end
it "shows 'Error' on division by zero" do
%w[5 / 0 =].each { |k| gui.send(:handle_press, k) }
expect(gui.instance_variable_get(:@display_text)).to eq("Error")
end
it "clears state" do
%w[5 + 3].each { |k| gui.send(:handle_press, k) }
gui.send(:reset_and_update_display)
expect(gui.instance_variable_get(:@display_text)).to eq("0")
expect(gui.instance_variable_get(:@current)).to eq("")
expect(gui.instance_variable_get(:@stored)).to be_nil
expect(gui.instance_variable_get(:@pending_op)).to be_nil
end
it "handles decimal input" do
%w[1 . 5 + 2 . 5 =].each { |k| gui.send(:handle_press, k) }
expect(gui.instance_variable_get(:@display_text)).to eq("4")
end
end
end
```
### Run everything
```bash
# Run the CLI
./bin/calc 6 + 7
# Run the GUI
./bin/calc-gui
# Run all tests (CLI + GUI)
bundle exec rspec
```
> [!info] Headless testing
> Since the GUI tests only exercise the state machine (not the `root_window.show` rendering), they run headlessly and take no longer than the CLI tests. If you needed to exercise actual rendering, Glimmer DSL for LibUI offers integration test helpers but requires a display connection.
---
## Starship prompt — Ruby auto-detection
Starship's built-in `[ruby]` module shows the active Ruby version when a `.rb` file, `Gemfile`, or `.ruby-version` is in the directory. No config needed, but to customize append to `~/.config/starship.toml`:
```toml
[ruby]
format = "[$symbol($version )]($style)"
symbol = " "
style = "bold red"
```
Your prompt will now show something like:
```
~/projects/calc-ruby main 3.3.6 ❯
```
---
## Managing gems globally (the `uv tool` equivalent)
For gems with CLI tools you want available anywhere (not just in a project), use `rv run` or install into a global gem set. The closest analog to `uv tool install`:
```bash
# Install a CLI gem into the active Ruby version
gem install --user-install standard
# Or run a gem's CLI without installing, via rv
rv run -- gem install standard
```
For frequent use, add this to `~/.zshrc` so user-installed gem binaries are on PATH:
```bash
export PATH="$HOME/.local/share/gem/ruby/3.3.0/bin:$PATH"
```
---
## Troubleshooting
> [!warning] `which ruby` shows `/usr/bin/ruby` even after installing with rv
> The `rv shell init zsh` line in `~/.zshrc` isn't loading. Verify with `grep 'rv shell' ~/.zshrc`, then open a fresh terminal (not just `source ~/.zshrc` — rv uses a hook that needs a new session on first setup).
> [!warning] `bundle install` installs gems globally instead of in vendor/bundle
> Run `bundle config set --global path 'vendor/bundle'` once to set the global default, then `rm -rf .bundle/` in any project and re-run `bundle install`.
> [!warning] Ruby LSP VS Code extension says "Cannot find ruby"
> Open the workspace from inside a terminal that has rv active (run `code .` from Ghostty, not from Spotlight). VS Code inherits the shell's PATH.
> [!warning] `gem install` asks for sudo
> You're using system Ruby at `/usr/bin/ruby`. Verify `which ruby` shows an rv path. If not, check `~/.zshrc` for the rv init line and open a fresh terminal.
---
## Summary — the one-shot Ruby addition
> [!warning] This is a checklist, not a script
> The block below is **not a shell script you can save and run.** It's a sequence of commands to **copy and paste into your terminal one block at a time**, in order. The `source ~/.zshrc` step and the `rv ruby` commands that follow it depend on the previous shell setup having completed first. After running these, you also need to **append the recommended Ruby settings block** from earlier in this guide to `~/Library/Application Support/Code/User/settings.json` — without that, the Ruby LSP extension won't know which version manager you're using.
```bash
# Version manager + Ruby
brew install rv
echo 'eval "$(rv shell init zsh)"' >> ~/.zshrc
source ~/.zshrc
rv ruby install 3.3
rv ruby pin 3.3 --global
# Configure Bundler for project-local gems
gem update --system
gem install bundler
bundle config set --global path 'vendor/bundle'
# VS Code extensions
code --install-extension Shopify.ruby-lsp
code --install-extension rubocop.vscode-rubocop
code --install-extension aliariff.vscode-erb-beautify
code --install-extension connorshea.vscode-ruby-test-adapter
# Verify
ruby -v && bundle --version && gem --version
```
About three minutes on top of the general setup.
---
## Related notes
- [[General_Development_Mac_Tahoe_Setup]]
- [[Python_Development_Mac_Tahoe_Setup]]
- [[C_Development_Mac_Tahoe_Setup]]
- [[Go_Development_Mac_Tahoe_Setup]]
- [[Rust_Development_Mac_Tahoe_Setup]]
- [Bundler Cheat Sheet](https://bundler.io/)
- [RSpec Patterns](https://www.betterspecs.org/)