# 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/)