ruby

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 metaprogram

SKILL.md

GitHub

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; the same task in another language to python, go, rust, elixir, php or typescript; the CI pipeline that runs your Ruby tests to github-actions — 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.

# 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.

# 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 <=>.

# 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.

Value objects: Data vs Struct

# 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.
# 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.

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.
# 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.

Ruby 4.0 / 3.4 hygiene

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

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.

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