--- name: ruby description: "Use when writing or refactoring plain Ruby outside Rails — scripts, CLIs, gems, libraries: Enumerable chains and blocks, module mixins and value objects, Bundler/gemspec packaging, whether metaprogramming earns its keep, Minitest or RSpec, Ruby 3.4/4.0 frozen-string hygiene. NOT Rails, ActiveRecord, or ActiveSupport idioms (that is `rails`)." tags: [ruby, gems, bundler, metaprogramming, rspec, minitest, enumerable] recommends: [rails, testing-web, secure-coding] profiles: [] origin: risco --- # Idiomatic Ruby, the language Write Ruby a senior Rubyist would sign off on: expression-oriented, block-driven, leaning on `Enumerable` and `Comparable` instead of hand-rolled loops, with `frozen_string_literal` hygiene and a real `Gemfile`/`.gemspec` when you package. This is **Ruby the language and its non-Rails ecosystem** — blocks, modules, gems, metaprogramming, Minitest/RSpec. Delegate: Rails apps, ActiveRecord models, controllers, migrations, views, Hotwire — anything that loads Rails or uses ActiveSupport/ActiveRecord idioms — to [`rails`](../rails/SKILL.md); the same task in another language to [`python`](../python/SKILL.md), [`go`](../go/SKILL.md), [`rust`](../rust/SKILL.md), [`elixir`](../elixir/SKILL.md), [`php`](../php/SKILL.md) or [`typescript`](../typescript/SKILL.md); the CI pipeline that runs your Ruby tests to [`github-actions`](../github-actions/SKILL.md) — this skill writes the tests, not the workflow. Targets **Ruby 4.0** (released 2025-12-25) on **3.4** semantics: Prism is the default parser, string literals warn on mutation without a magic comment, `it` is an implicit block param, and `Set` is now a core class. ## Mental model Internalize these before writing a line: 1. **Everything is an expression and returns a value.** `if`, `case`, blocks, method bodies all yield a value — name and return it instead of threading a flag. 2. **Everything is an object receiving messages.** `5.times`, `nil.to_a`, `"x".freeze`. There are no primitives to special-case. 3. **Reach for `Enumerable` before you write a loop.** A manual index loop in Ruby is almost always a `map`/`select`/`reduce` you have not spotted yet. 4. **Prefer returning new values to mutating in place.** Mutation is allowed but it is the exception you justify, not the default. 5. **Freeze your string literals.** New files start with `# frozen_string_literal: true`. 6. **Dynamism is the trap, not the feature.** Metaprogramming is a scalpel; if a plain method, a `Struct`, or a `Data` does the job, use that. ## Blocks, procs, lambdas, yield Blocks are the spine of Ruby. A method takes a block implicitly via `yield`, or explicitly with `&block` when it must store or forward it. ```ruby # frozen_string_literal: true # Implicit: yield runs the caller's block and returns its value. def with_timing start = Process.clock_gettime(Process::CLOCK_MONOTONIC) result = yield [result, Process.clock_gettime(Process::CLOCK_MONOTONIC) - start] end # Explicit: capture the block as a Proc to forward or store it. def retrying(times, &block) attempts = 0 begin block.call rescue StandardError (attempts += 1) < times ? retry : raise end end ``` `proc` vs `lambda` differ in two ways that bite: | Aspect | `proc` / block | `lambda` / `->` | |--------|----------------|-----------------| | Arity | Lenient — extra args dropped, missing become `nil` | Strict — wrong count raises `ArgumentError` | | `return` | Returns from the enclosing method (can surprise) | Returns from the lambda only | Rule: use a **lambda** for a function-like value you call yourself; use a **block/proc** for the "do this with each element" pattern. For cheap accumulation prefer `each_with_object` (returns the seed) or `reduce`; use `tap` (returns the receiver) for side effects and `then` for piping a value through a transform. ```ruby # Bad: index loop building a result by mutation. result = [] (0...items.length).each { |i| result << items[i].upcase if items[i].size > 3 } # Good: intent reads top-to-bottom, no index, no pre-seeded array. result = items.select { |s| s.size > 3 }.map(&:upcase) ``` ## Enumerable & Comparable Most loops map to one named method. Pick by **intent**, not by habit: | You want to… | Use | |---------------------------------------|------------------------------| | Transform every element | `map` | | Keep / drop by predicate | `select` / `reject` | | Fold into one value | `reduce` / `sum` / `min_by` | | Build a Hash or accumulate into a seed| `each_with_object` | | Count occurrences | `tally` | | Bucket by a key | `group_by` | | First match | `find` (`detect`) | | Pair index with element | `each_with_index` | Make your own classes first-class collections by including `Enumerable` and defining `each`; get `<`, `>`, `between?`, `clamp`, `sort` for free by including `Comparable` and defining `<=>`. ```ruby # frozen_string_literal: true class Playlist include Enumerable def initialize(tracks) = @tracks = tracks def each(&) = @tracks.each(&) # one method unlocks map/select/sort/... end class Version include Comparable attr_reader :major, :minor def initialize(major, minor) @major = major @minor = minor end def <=>(other) = [major, minor] <=> [other.major, other.minor] end ``` ## Modules & composition Ruby favors composition through mixins over deep inheritance. - `include M` — adds `M`'s instance methods to instances (the default mixin). - `prepend M` — inserts `M` *before* the class in the lookup chain, so `M`'s methods can wrap the originals via `super` (cleaner than aliasing for decoration). - `extend M` — adds `M`'s methods as *class/singleton* methods. Rule: reach for a **module mixin** when the same behavior is shared across unrelated classes; reserve inheritance for a genuine is-a hierarchy. Namespace with `module Foo` to avoid global constant clashes. Need to patch a class without polluting it globally? Use a **refinement** (`refine … using`) — a scoped, reversible scalpel — never a bare monkey-patch. Full refinement scoping and `prepend`-wrapping in [references/metaprogramming.md](references/metaprogramming.md). ## Value objects: Data vs Struct ```ruby # frozen_string_literal: true Point = Data.define(:x, :y) # immutable value object (Ruby 3.2+) origin = Point.new(x: 0, y: 0) # no setters; with(x: 1) returns a copy Cursor = Struct.new(:x, :y) # mutable; has setters and is Enumerable ``` | Need | Reach for | |--------------------------------------------|---------------| | Immutable value with equality + `with` | `Data.define` | | Mutable record, or array/positional access | `Struct.new` | Default to `Data` for value semantics; pick `Struct` only when you genuinely need mutation or positional/`to_a` behavior. ## Metaprogramming, sparingly Decision rule, in order: **could a plain method, a `Struct`, or a `Data` do this?** If yes, do that. Only when the method *names themselves* are dynamic (driven by data or config) does metaprogramming earn its keep. - Prefer `define_method` (defines real, introspectable methods) over `method_missing` (intercepts at call time, slower, invisible to `respond_to?` and tooling). - If you *must* use `method_missing`, you **must** also define `respond_to_missing?` — otherwise `respond_to?`, `method`, and duck-typing all lie about your object. - Never `eval` a string built from user input. ```ruby # Good: dynamic method names, but real methods that introspect and document themselves. class Config def initialize(**data) = @data = data %i[host port timeout].each do |key| define_method(key) { @data[key] } define_method("#{key}=") { |value| @data[key] = value } end end ``` Building a real DSL, `instance_eval`/`class_eval`, and the full `method_missing` + `respond_to_missing?` pairing live in [references/metaprogramming.md](references/metaprogramming.md). ## Gems & Bundler Bundler is the universal dependency manager. `bundle init` creates a `Gemfile`; `bundle install` resolves and writes `Gemfile.lock`; run project binaries with `bundle exec` so they use the locked versions. - Use the **pessimistic constraint** `~> 2.3` (allows `2.x >= 2.3`, blocks `3.0`) for libraries; pin tighter only when forced. - **Commit `Gemfile.lock` for applications** (reproducible installs); for a *library* gem, do not commit the lock — let consumers resolve. - Scaffold a gem with `bundle gem NAME`, which generates the `lib/`, `test/`/`spec/`, `.gemspec`, and Rakefile layout. ```ruby # my_gem.gemspec Gem::Specification.new do |spec| spec.name = "my_gem" spec.version = MyGem::VERSION # SemVer: MAJOR.MINOR.PATCH spec.summary = "One-line description." spec.authors = ["You"] spec.files = Dir["lib/**/*.rb"] spec.required_ruby_version = ">= 3.4" spec.add_dependency "thor", "~> 1.3" end ``` Full `bundle gem` tree, publishing flow (`gem build` / `gem push`), and Rake test tasks are in [references/gems-and-testing.md](references/gems-and-testing.md). ## Ruby 4.0 / 3.4 hygiene ```ruby # frozen_string_literal: true # without it, 3.4+ warns under -W:deprecated on mutation nums.map { it * 2 } # `it` is the implicit single block param (3.4+) seen = Set.new([1, 2, 3]) # Set is a core class in 4.0 — no `require "set"` ``` - The magic comment freezes every string literal in the file; mutating one then raises `FrozenError`. This is the cause of most "my string froze after upgrading" reports. - `it` reads cleaner than `_1` for one-arg blocks. - Ruby 4.0 ships **ZJIT** (a new JIT written in Rust, successor to YJIT) and removed `Ractor.yield`/`Ractor#take` in favor of `Ractor::Port`. ## Testing Both ship a clean path; choose by ceremony tolerance: | Framework | Pick when… | |-----------|-----------| | **Minitest** | You want the default — ships with Ruby, tiny surface, `assert`-style or spec-style, fast. | | **RSpec** | You want the expressive DSL and large ecosystem (`describe`/`context`/`it`/`expect`, `let`, mocks). | ```ruby # test/tally_test.rb (Minitest) require "minitest/autorun" require "tally" class TallyTest < Minitest::Test def test_counts_words assert_equal({ "a" => 2, "b" => 1 }, Tally.count("a b a")) end end ``` ```ruby # spec/tally_spec.rb (RSpec) RSpec.describe Tally do subject(:counts) { described_class.count("a b a") } it { is_expected.to eq("a" => 2, "b" => 1) } end ``` Run via `bundle exec rake test` (Minitest) or `bundle exec rspec` (RSpec). Fuller examples with `let`/`subject`/`context` are in [references/gems-and-testing.md](references/gems-and-testing.md). ## Linting & typing (optional layers) - **Linter:** Standard (zero-config, "no bikeshadding" wrapper) when you want one less argument; RuboCop (`.rubocop.yml`, every cop opt-in/out) when the team wants control. - **Types:** two coexisting systems — **RBS** (official signatures in separate `.rbs` files, checked by Steep) and **Sorbet** (inline `sig` blocks, with experimental inline-RBS-comment support). Add types only on a library's public surface or a hot, bug-prone core — not on throwaway scripts. RuboCop vs Standard config sits in [references/gems-and-testing.md](references/gems-and-testing.md). ## Anti-patterns | Anti-pattern | Why it bites | Do instead | |--------------|--------------|------------| | Monkey-patching a core class globally | Action-at-a-distance; collides with gems and future Ruby | A refinement (scoped) or a wrapper/helper | | `method_missing` without `respond_to_missing?` | `respond_to?`/`method`/duck-typing all lie | Pair them, or prefer `define_method` | | Mutating a frozen string literal | `FrozenError` after the magic comment | `dup` before mutating, or build a new string | | `for x in coll` loops | Leaks the loop var; un-idiomatic | `coll.each`/`map`/`select` | | `rescue Exception` (bare) | Swallows `SignalException`, `NoMemoryError`, exit | `rescue StandardError` (or specific classes) | | Not committing `Gemfile.lock` for an app | Non-reproducible installs across machines | Commit it for apps; omit only for library gems | | `eval` of user-derived input | Remote code execution | Parse explicitly; never `eval` untrusted data | | Over-DSLing config a Hash would serve | Hidden control flow, slow `method_missing` | A plain Hash or `Data` object |