Reflection, Introspection and Tracing

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18). Reflection is a program asking questions about itself: which class is this object, which methods does it answer to, where was this method defined, who called me, which objects are alive, which line of code is running, which warnings did the interpreter raise, and how was this source turned into instructions. Ruby answers all of them from inside the language, and that is why tools such as debuggers, test frameworks, linters, profilers and documentation generators can be written in Ruby itself.

The page goes from the cheap and portable to the expensive and CRuby-specific. The first half (objects, classes, Method objects, bindings and backtraces) is plain Ruby that works on every implementation and costs almost nothing. The second half (ObjectSpace, TracePoint, Warning, Coverage, RubyVM::InstructionSequence, RubyVM::AbstractSyntaxTree and Prism) looks at the running interpreter and its compiled form, is partly CRuby-only, and some of it is documented as experimental or unstable.

It builds on Classes and Objects, Modules and Mixins and Method Lookup and super (the ancestor chain that the reflection methods report), and it is the read-only companion of Dynamic Dispatch and Method Definition (send, define_method, method_missing) and Hooks, eval and DSLs (inherited, method_added, instance_eval, eval), which change a program instead of looking at it. Exceptions and Error Handling covers raise, backtrace and cause; this page covers the Thread::Backtrace::Location objects behind them. Object Lifecycle, Freezing and Copying and Garbage Collection and Memory go further on finalizers and the heap, and IRB and Debugging, Performance, Benchmarking and Profiling, Testing with Minitest and YJIT, ZJIT and the VM show the tools that are built on these interfaces. For how other languages expose the same ideas, see Java, Go, PHP and C#.

Every example was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 and linted with RuboCop 1.92 using the configuration of the running shelf gem (double-quoted strings, NewCops: enable, Style/Documentation off, and Style/OneClassPerFile off because several small classes share one example file). The output shown is the real output; memory addresses and absolute paths are removed from it on purpose (the examples print File.basename), because they differ from run to run. The examples that need the gem are run with ruby -Ilib, so require "shelf" finds it (see the shelf gem source). The warnings example is run under plain ruby (it is also warning-free under -w) so that the default warning settings are visible. The few places where an example silences a cop on purpose carry a rubocop:disable-next comment and say why.

Reflection at a glance

Question Ruby API Section

What class is this, and what is its hierarchy?

obj.class, is_a?, Class#superclass, Class#subclasses, Module#ancestors, Module#included_modules

Asking a class or module about itself

What state does this object hold?

instance_variables, instance_variable_get, instance_variable_set, instance_variable_defined?, remove_instance_variable

Asking an object about itself

What can this object do?

methods, public_methods, private_methods, protected_methods, singleton_methods, respond_to?

Asking an object about itself

What does this class define?

instance_methods, public_instance_methods, private_instance_methods, method_defined?, constants, const_get

Asking a class or module about itself

Where is this method or constant defined?

Method#owner, Method#source_location, Module#const_source_location

Method objects

What are its parameters?

Method#parameters, Method#arity, Proc#parameters

Method objects

Which locals are visible here, and who called me?

local_variables, binding, caller, caller_locations, Thread::Backtrace::Location

Local variables, bindings and backtraces

Which objects are alive, and how big are they?

ObjectSpace.each_object, ObjectSpace.count_objects, ObjectSpace::WeakMap, require "objspace"

Looking at the heap with ObjectSpace

What is the interpreter doing right now?

TracePoint

Tracing with TracePoint

Which warnings are on, and who sees them?

Warning[], Warning.categories, Warning.warn, warn, -W

Warnings

Which lines, branches and methods ran?

Coverage

Code coverage

How was this source compiled and parsed?

RubyVM::InstructionSequence, RubyVM::AbstractSyntaxTree, Prism, Ripper

Instruction sequences, syntax trees and Prism

Asking an object about itself

An object answers three kinds of question: what is it (its class and identity), what does it hold (instance variables) and what can it do (methods). Every one of the methods below is defined on Object (most through Kernel), so it is available on nearly every value; BasicObject instances have none of them.

# frozen_string_literal: true

require "shelf"

def show(label, value) = puts("#{label.ljust(38)}#{value}")

isbn = Shelf::Isbn.new("978-0-306-40615-7")
author = Shelf::Author.new("Ursula K. Le Guin")
book = Shelf::Book.new(isbn:, title: "The Dispossessed", author:)
loan = Shelf::Loan.new(book, state: :lent)

show "class", loan.class
show "is_a?(Object)", loan.is_a?(Object)
show "instance_variables", loan.instance_variables
show "instance_variable_get(:@state)", loan.instance_variable_get(:@state).inspect
show "instance_variable_defined?(:@due)", loan.instance_variable_defined?(:@due)
loan.instance_variable_set(:@due, 14)
show "after instance_variable_set", loan.instance_variables
show "remove_instance_variable", loan.remove_instance_variable(:@due)
show "after remove", loan.instance_variables
show "public_methods(false)", loan.public_methods(false).sort
show "methods - Object.instance_methods", (loan.methods - Object.instance_methods).sort
show "respond_to?(:state)", loan.respond_to?(:state)
show "respond_to?(:initialize)", loan.respond_to?(:initialize)
show "respond_to?(:initialize, true)", loan.respond_to?(:initialize, true)
show "singleton_methods", loan.singleton_methods
show "Data members", Shelf::Book.members
show "Data to_h keys", book.to_h.keys
class                                 Shelf::Loan
is_a?(Object)                         true
instance_variables                    [:@book, :@state]
instance_variable_get(:@state)        :lent
instance_variable_defined?(:@due)     false
after instance_variable_set           [:@book, :@state, :@due]
remove_instance_variable              14
after remove                          [:@book, :@state]
public_methods(false)                 [:book, :describe, :state]
methods - Object.instance_methods     [:book, :describe, :state]
respond_to?(:state)                   true
respond_to?(:initialize)              false
respond_to?(:initialize, true)        true
singleton_methods                     []
Data members                          [:isbn, :title, :author]
Data to_h keys                        [:isbn, :title, :author]

Source: Object

loan.instance_variables lists the variables that have been assigned, in order of first assignment, and instance_variable_set creates one on the fly, as the @due line shows. This is why instance variables are not declared: an object has exactly the variables that some code has assigned to it, and reflection is the only way to see the list without reading the class. Since Ruby 3.0, reading a variable that was never assigned no longer warns (it just returns nil), so instance_variable_defined? is the way to tell "never set" from "set to `nil`".

Methods an object answers to

Call Returns

obj.methods

Public and protected methods the object responds to, from its singleton class up through the whole ancestor chain. Includes everything inherited from Object and Kernel, so it is long: subtract Object.instance_methods to see the interesting part.

obj.methods(false)

Only the singleton methods (the same as singleton_methods). The false does not mean "the class’s own methods".

obj.public_methods, obj.private_methods, obj.protected_methods

The methods of that visibility, again through the whole chain. Pass false to stop at the receiver’s own class: loan.public_methods(false) lists the methods of its singleton class and its class, but not those of modules the class includes or of Object.

obj.singleton_methods

The public and protected methods defined on the object itself, and (for a class or module) the ones it gained by extend. singleton_methods(false) leaves out the `extend`ed ones.

obj.respond_to?(name, include_all = false)

Whether a public method is available; pass true to include private and protected methods. It also consults respond_to_missing?, which methods does not.

obj.method(name), obj.public_method(name), obj.singleton_method(name)

A Method object (see Method objects) or a NameError.

respond_to? is the duck-typing question ("can I send you this message?"), and it is the one to ask in application code. The methods family answers the introspection question ("what is defined?"), and it deliberately does not see methods that exist only through method_missing: those need the respond_to_missing? hook to appear in respond_to? and method (see method_missing and respond_to_missing?).

Reading and writing state

instance_variable_get, instance_variable_set, instance_variable_defined? and remove_instance_variable take a Symbol or a String that must start with @; anything else raises NameError ("'state' is not allowed as an instance variable name"). They go around the class’s public interface, and the interface is the contract. Use them where bypassing is the point: serializers, test helpers, debuggers, Marshal, inspect and copying code. In application logic, an attr_reader or a method is almost always the better tool, because the reflective call breaks silently when the variable is renamed. A frozen object refuses instance_variable_set and remove_instance_variable with FrozenError; see Object Lifecycle, Freezing and Copying.

Ruby 4.0 adds a hook that makes Kernel#inspect print only some of the variables: if the object has a private method instance_variables_to_inspect returning an array of names, inspect shows just those. That is the supported way to keep secrets out of logs and p output without overriding inspect (see Classes and Objects).

Asking a class or module about itself

Classes and modules are objects too, so everything above works on them, and Module adds the questions that concern a definition: its place in the hierarchy, the methods and constants it defines, and its name.

# frozen_string_literal: true

require "shelf"

klass = Shelf::Catalog
puts "name                    #{klass.name}"
puts "superclass              #{klass.superclass}"
puts "ancestors               #{klass.ancestors}"
puts "included_modules        #{klass.included_modules.first(2)}"
puts "include?(Enumerable)    #{klass.include?(Enumerable)}"
puts "instance_methods(false) #{klass.instance_methods(false)}"
puts "private (own)           #{Shelf::Loan.private_instance_methods(false)}"
puts "method_defined? :map    #{klass.method_defined?(:map)}"
puts "... without inherited   #{klass.method_defined?(:map, false)}"
puts "public_method_defined?  #{klass.public_method_defined?(:add)}"
puts "constants               #{Shelf.constants.sort}"
puts "const_get               #{Shelf.const_get('Store::Memory')}"
puts "const_defined? String   #{Shelf.const_defined?(:String)} / #{Shelf.const_defined?(:String, false)}"
file, line = Shelf::Loan.const_source_location(:STATES)
puts "STATES defined at       #{File.basename(file)}:#{line}"
puts "Data subclasses         #{Data.subclasses.map(&:name).compact.grep(/\AShelf/).sort}"

# Class#subclasses is direct only; a descendants walk is a short recursion.
def descendants(klass) = klass.subclasses.flat_map { |sub| [sub, *descendants(sub)] }

shelf_errors = descendants(Exception).select { it.name&.start_with?("Shelf") }
puts "Exception descendants  #{shelf_errors}"
puts "Shelf::Error < ...      #{Shelf::Error.ancestors.take(3)}"

anonymous = Class.new
puts "anonymous name          #{anonymous.name.inspect}"
anonymous.set_temporary_name("fake<Draft>")
puts "temporary name          #{anonymous.name}"
puts "singleton attached to   #{Shelf::Loan.singleton_class.attached_object}"
begin
  Shelf::Loan.attached_object
rescue TypeError => e
  puts "attached_object error   #{e.message}"
end
name                    Shelf::Catalog
superclass              Object
ancestors               [Shelf::Catalog, Enumerable, Object, Kernel, BasicObject]
included_modules        [Enumerable, Kernel]
include?(Enumerable)    true
instance_methods(false) [:each, :add]
private (own)           [:initialize]
method_defined? :map    true
... without inherited   false
public_method_defined?  true
constants               [:Author, :Book, :Catalog, :Config, :Error, :Isbn, :Loan, :Refinements, :Store, :VERSION]
const_get               Shelf::Store::Memory
const_defined? String   true / false
STATES defined at       loan.rb:6
Data subclasses         ["Shelf::Author", "Shelf::Book", "Shelf::Isbn"]
Exception descendants  [Shelf::Error]
Shelf::Error < ...      [Shelf::Error, StandardError, Exception]
anonymous name          nil
temporary name          fake<Draft>
singleton attached to   Shelf::Loan
attached_object error   'Shelf::Loan' is not a singleton class

Source: Module and Class

The hierarchy

  • Module#ancestors is the method lookup chain, the same order super follows: the class, then the modules it prepends, includes or inherits, then the superclasses. It is the first thing to print when "the wrong method runs". For a class with a prepend, the prepended module appears before the class. The chain is explained in Method Lookup and super.

  • Class#superclass is the direct parent (nil for BasicObject); Module#included_modules and Module#include? ask about mixins. Both report the modules brought in by include and prepend, not those added by extend (those are on the singleton class).

  • Class#subclasses (Ruby 3.1) returns the direct subclasses, in no guaranteed order and without singleton classes. There is no descendants in Ruby 4.0, so the example above recurses; before 3.1 the standard idiom was ObjectSpace.each_object(Class).select { |c| c < klass }, which is slower and sees singleton classes (see Looking at the heap with ObjectSpace). The inherited hook in Hooks, eval and DSLs is the way to be told about subclasses as they are created, rather than asking later.

  • Class#attached_object (Ruby 3.2) goes the other way from singleton_class: given a singleton class, it returns the one object it belongs to, and raises TypeError for any other class. Module#singleton_class? tests the same thing without raising.

The methods a module defines

Call Returns

instance_methods(inherit = true)

Public and protected instance methods, including inherited ones. instance_methods(false) limits it to the module itself.

public_instance_methods, protected_instance_methods, private_instance_methods

The methods of one visibility; false again limits them to the module’s own definitions. initialize is a private instance method, which is why it is missing from instance_methods.

method_defined?(name, inherit = true), public_method_defined?, protected_method_defined?, private_method_defined?

A Boolean for one name. method_defined? is true for public and protected methods and false for private ones; the second argument (false) ignores ancestors.

undefined_instance_methods (Ruby 3.2)

The names removed with undef_method. They are not listed by instance_methods, yet they still block the lookup, so a call raises NoMethodError instead of reaching the parent.

instance_method(name), public_instance_method(name)

An UnboundMethod (see Method objects) or NameError. The public variant refuses private methods.

refinements (Ruby 3.2)

The Refinement modules created inside the module by refine; see Refinements and Monkey Patching.

Methods generated by attr_reader, Struct, Data and define_method are listed like any other. Methods that exist only through method_missing are not listed anywhere.

Constants, names and class variables

  • Module#constants lists the constants a module defines and, for a class, the inherited ones; constants(false) keeps just its own. const_get accepts a Symbol, a String, or a qualified "Store::Memory" String, and const_defined? answers without raising; both take an inherit flag, which is why Shelf.const_defined?(:String) is true (found through Object) and Shelf.const_defined?(:String, false) is false. const_get raises NameError ("uninitialized constant Shelf::Nope") for a missing name and ("wrong constant name lower") for a name that is not a constant. A missing constant first calls const_missing, the hook that autoloaders such as Zeitwerk build on (see Code Loading: require and autoload).

  • Module#const_source_location returns [file, line] for the place the constant was defined, which is how an editor jumps to a constant, and the answer for a class is where its first class keyword ran. It returns [] for constants defined in C and nil when the constant does not exist.

  • Module#name is the constant path the module was first assigned to, or nil for an anonymous one. Ruby 3.3’s set_temporary_name gives an anonymous class a readable label in error messages and inspect without making it a constant; the label must not look like a constant path ("fake<Draft>" is accepted, "Foo::Bar" and "" raise ArgumentError). Once the class is assigned to a constant it has a permanent name that replaces the temporary one, and set_temporary_name then raises RuntimeError ("can’t change permanent name").

  • Module#class_variables, class_variable_get and class_variable_defined? read @@x variables. Class variables are shared down the whole inheritance tree and are rarely the right design; see Variables, Constants and Scope.

Method objects

obj.method(:name) returns a Method, a method already bound to its receiver. Module#instance_method(:name) returns an UnboundMethod, the same method not yet bound to any receiver. Both are plain objects that describe a method and can be called; they are the reflective view of the definition, while Proc is the view of a block (see Blocks, Procs and Lambdas).

# frozen_string_literal: true

require "shelf"

def where(method)
  file, line = method.source_location
  file ? "#{File.basename(file)}:#{line}" : "(C code)"
end

describe = Shelf::Loan.instance_method(:describe)
puts describe.class
puts "owner          #{describe.owner}"
puts "name           #{describe.name}"
puts "arity          #{describe.arity}"
puts "parameters     #{describe.parameters}"
puts "location       #{where(describe)}"

init = Shelf::Loan.instance_method(:initialize)
puts "initialize     #{init.parameters}  arity #{init.arity}"

isbn = Shelf::Isbn.new("978-0-306-40615-7")
author = Shelf::Author.new("Ursula K. Le Guin")
book = Shelf::Book.new(isbn:, title: "The Dispossessed", author:)
loan = Shelf::Loan.new(book)
bound = loan.method(:describe)
puts "bound class    #{bound.class}, receiver is loan: #{bound.receiver.equal?(loan)}"
puts "call           #{bound.call}"
puts "unbind + bind  #{bound.unbind.bind_call(Shelf::Loan.new(book, state: :returned))}"

map = Shelf::Catalog.instance_method(:map)
puts "map owner      #{map.owner}, location #{where(map)}"
puts "Integer#+      #{1.method(:+).owner}, location #{where(1.method(:+))}"
puts "to_s defined   #{where(Shelf::Isbn.instance_method(:to_s))}"

isbn_init = Shelf::Isbn.new("978-0-306-40615-7").method(:initialize)
puts "Isbn#initialize owner #{isbn_init.owner}, super_method owner #{isbn_init.super_method.owner}"
puts "inspect        #{bound.inspect.sub(%r{ \S*/}, ' ')}"
puts "inspect (C)    #{1.method(:+).inspect}"
UnboundMethod
owner          Shelf::Loan
name           describe
arity          0
parameters     []
location       loan.rb:17
initialize     [[:req, :book], [:key, :state]]  arity -2
bound class    Method, receiver is loan: true
call           The Dispossessed is on the shelf
unbind + bind  The Dispossessed came back
map owner      Enumerable, location (C code)
Integer#+      Integer, location (C code)
to_s defined   isbn.rb:13
Isbn#initialize owner Shelf::Isbn, super_method owner Data
inspect        #<Method: Shelf::Loan#describe() loan.rb:17>
inspect (C)    #<Method: Integer#+(_)>

Source: Method and UnboundMethod

What a Method can tell you

Call Answer

name, original_name

The name used to look it up, and the name it was defined with. They differ for an alias: alias aliased trace_it gives name :aliased and original_name :trace_it.

owner

The class or module in which the method is defined, which is not necessarily receiver.class. Shelf::Catalog.instance_method(:map).owner is Enumerable.

receiver (Method only)

The object the method is bound to.

source_location

[file, line] of the def (for a method generated by attr_reader or define_method, the line of that call or block), or nil for a method implemented in C, as Integer#+ and Enumerable#map are.

parameters

An array of [kind, name] pairs, with kinds :req, :opt, :rest, :keyreq, :key, :keyrest, :nokey and :block. Anonymous forwarding parameters appear as [:rest, :*], [:keyrest, :**] and [:block, :&]; the parameters of C methods and of attr_writer methods have no names ().

arity

The number of required arguments, or -n-1 when there are n required ones and some optional or rest ones. A group of required keywords counts as one more required argument (def m(a, k:) has arity 2), optional keywords alone make the arity negative (def m(a, k: 1) has arity -2). See Methods and Parameters.

super_method

The Method that super would call, or nil when the method is the last one in the chain.

unbind, UnboundMethod#bind(obj), UnboundMethod#bind_call(obj, *args)

Detach a method from its receiver and attach it to another object of the right class (a TypeError otherwise: "bind argument must be an instance of Shelf::Loan"). bind_call binds and calls in one step without allocating a Method.

to_proc, curry, >>, <<, ===

A lambda that calls the method, a curried lambda, composition, and Method#=== as call (so case x when obj.method(:valid?) works).

The inspect format shows the owner, the signature in terse form and the location: #<Method: Shelf::Loan#describe() loan.rb:17>, with (_) for a C method’s single required argument. Since Ruby 3.2 an UnboundMethod no longer shows the class it was fetched from, only the owner, and two UnboundMethod`s are `== when they are the same underlying method, so String.instance_method(:object_id) == Array.instance_method(:object_id) is true. The predicates Method#public?, private? and protected? that existed in 3.1 were removed again in 3.2; ask the class (public_method_defined?) instead.

Method#source_location and Module#const_source_location are what editors and IRB’s show_source and edit commands use to find the right file (see IRB and Debugging). Method#owner is what to ask first when a method behaves unexpectedly: the owner tells you whether it came from your class, a mixin or core.

Procs and blocks

A Proc answers the same questions: parameters, arity, source_location, lambda? and binding. proc { |a, (b, c), *d, e: 1| }.parameters is : a plain proc reports its parameters as optional, because a proc tolerates missing and extra arguments, and a destructured parameter is unnamed. Proc#parameters(lambda: true) (Ruby 3.2) reports them as a lambda would (:req). Proc#binding returns the Binding the block closes over (see Local variables, bindings and backtraces).

method_missing and respond_to_missing?

A method that exists only because method_missing answers for it is invisible to every query on this page. The rule that keeps reflection honest is to define respond_to_missing? with the same condition: Ruby then reports the name from respond_to? and, more importantly, builds a Method object for it, so obj.method(:find_by_title) works, map(&obj.method(:find_by_title)) works, and Object#public_send and Enumerable#map(&:sym) behave as callers expect. methods still does not list the name, because nothing is defined. RuboCop’s Style/MissingRespondToMissing enforces the pair; the example below disables it on one class on purpose to show what the omission costs.

# frozen_string_literal: true

Book = Struct.new(:title)

# A catalogue front-end that answers find_by_<field> calls dynamically.
class Finder
  def initialize(books) = @books = books

  def method_missing(name, *args)
    return super unless name.start_with?("find_by_")

    field = name.to_s.delete_prefix("find_by_")
    @books.find { |book| book.public_send(field) == args.first }
  end

  def respond_to_missing?(name, include_private = false) = name.start_with?("find_by_") || super
end

# The same idea without respond_to_missing?: deliberately incomplete, for the comparison.
class Plain
  # rubocop:disable-next Style/MissingRespondToMissing
  def method_missing(name, *) = name.start_with?("find_by_") ? :found : super
end

finder = Finder.new([Book.new("Ubik")])
puts "dynamic call        #{finder.find_by_title('Ubik').title}"
puts "respond_to?         #{finder.respond_to?(:find_by_title)}"
puts "method object       #{finder.method(:find_by_title).call('Ubik').title}"
puts "methods listing     #{finder.methods.include?(:find_by_title)}"

plain = Plain.new
puts "plain call          #{plain.find_by_x}"
puts "plain respond_to?   #{plain.respond_to?(:find_by_x)}"
begin
  plain.method(:find_by_x)
rescue NameError => e
  puts "plain method        #{e.class}: #{e.message}"
end
begin
  finder.other
rescue NoMethodError => e
  puts "unknown name        #{e.message}"
end
dynamic call        Ubik
respond_to?         true
method object       Ubik
methods listing     false
plain call          found
plain respond_to?   false
plain method        NameError: undefined method 'find_by_x' for class 'Plain'
unknown name        undefined method 'other' for an instance of Finder

method_missing itself (when to use it, how to write it with super and full signatures, and how it compares with define_method) is in Dynamic Dispatch and Method Definition.

Local variables, bindings and backtraces

Binding

A Binding captures a point in the program: the local variables in scope, self, the current method and block, and the source location. Kernel#binding returns the current one, Proc#binding the one a block closed over, and TOPLEVEL_BINDING the one of the main script. It is the object that eval, ERB templates and binding.irb need to run code "as if it were there" (see Hooks, eval and DSLs).

# frozen_string_literal: true

# A method that hands out its own binding.
def due_date(loan_days: 14)
  start = "2026-10-01"
  puts "building a binding for #{start}"
  binding
end

b = due_date
puts "local_variables      #{b.local_variables}"
puts "start                #{b.local_variable_get(:start)}"
puts "receiver             #{b.receiver}"
puts "source_location      #{b.source_location.then { |file, line| "#{File.basename(file)}:#{line}" }}"
puts "eval                 #{b.eval('[start, loan_days]')}"
puts "defined?             #{b.local_variable_defined?(:loan_days)} #{b.local_variable_defined?(:nope)}"
b.local_variable_set(:loan_days, 28)
puts "after set            #{b.local_variable_get(:loan_days)}"
b.local_variable_set(:fresh, 1)
puts "new variable         #{b.local_variables} (visible only through this binding)"

# Ruby 4.0: it and numbered parameters are reached through the implicit_parameter methods.
[10].each { puts "implicit (it)        #{it} #{binding.implicit_parameters} #{binding.implicit_parameter_get(:it)}" }
# rubocop:disable-next Style/ItBlockParameter
[1].each { puts "implicit (_1)        #{_1} #{binding.implicit_parameters} #{binding.local_variables}" }

# Kernel-level views of the current scope
name = "Ada"
puts "Kernel#local_variables #{local_variables}"
puts "global_variables       #{global_variables.include?(:$stdout)}"
puts "binding of a block     #{proc { name }.binding.local_variable_get(:name)}"
puts "TOPLEVEL_BINDING       #{TOPLEVEL_BINDING.receiver}"
building a binding for 2026-10-01
local_variables      [:loan_days, :start]
start                2026-10-01
receiver             main
source_location      e05_binding.rb:7
eval                 ["2026-10-01", 14]
defined?             true false
after set            28
new variable         [:fresh, :loan_days, :start] (visible only through this binding)
implicit (it)        10 [:it] 10
implicit (_1)        1 [:_1] [:b, :name]
Kernel#local_variables [:b, :name]
global_variables       true
binding of a block     Ada
TOPLEVEL_BINDING       main
  • local_variables, local_variable_get, local_variable_set and local_variable_defined? read and write locals by name. local_variable_set on a name that does not exist creates a variable that lives only inside that binding (the :fresh line above); it is invisible to the code that was running. A Symbol that is not a valid local name raises NameError.

  • receiver is self at that point, and source_location is [file, line] of the binding call.

  • Binding#eval(string) evaluates code in that scope. It is as dangerous as Kernel#eval with the same string: never build the string from input you do not control (see Security).

  • Ruby 4.0 changes how numbered parameters and it interact with bindings. Binding#local_variables no longer includes them and local_variable_get refuses them, and three new methods reach them instead: implicit_parameters (the names that exist in the scope, [:it] or [:_1]), implicit_parameter_get and implicit_parameter_defined?. The names only exist when the block actually uses it or _1; asking for :it in a block that never mentions it raises NameError.

  • The rubocop:disable-next Style/ItBlockParameter comment in the example keeps the _1 line on purpose: RuboCop would prefer it, but the point of that line is to show how _1 appears in implicit_parameters.

  • A binding keeps every local variable of its scope alive, as long as the binding itself is reachable. Storing bindings in a long-lived structure is a classic memory leak.

Kernel#local_variables is the shortcut for binding.local_variables, and Kernel#global_variables lists the global names (the special $stdout, $PROGRAM_NAME and friends, documented in Pre-defined global variables).

Who called me: caller and caller_locations

Kernel#caller(start = 1, length = nil) returns the current call stack as strings, without the current frame (caller(0) includes it). Kernel#caller_locations returns the same frames as Thread::Backtrace::Location objects, which is the form to prefer: no string parsing, and cheaper because the strings are only built if asked for. Thread.each_caller_location (Ruby 3.2) yields the frames one at a time and stops when the block breaks, so a lookup of the nearest frame does not build the whole stack.

# frozen_string_literal: true

require "shelf"

def where(loc) = "#{File.basename(loc.path)}:#{loc.lineno} #{loc.label}"
def short(lines) = lines.map { it.sub(%r{^\S*/}, "") }

class Reporter
  def report = trace_it

  def trace_it
    puts "caller(0)            #{short(caller(0))}"
    puts "caller_locations(1)  #{where(caller_locations(1, 1).first)}"
    Thread.each_caller_location { |loc| puts "each_caller_location  #{where(loc)}" }
    puts "__method__/__callee__ #{__method__.inspect} #{__callee__.inspect}"
  end
  alias aliased trace_it
end

Reporter.new.report
Reporter.new.aliased
begin
  Shelf::Isbn.new("1234")
rescue ArgumentError => e
  puts "message              #{e.message}"
  puts "backtrace            #{short(e.backtrace)}"
  puts "locations            #{e.backtrace_locations.map { where(it) }}"
  puts "location class       #{e.backtrace_locations.first.class}"
  puts "label, base_label    #{e.backtrace_locations.first.label}, #{e.backtrace_locations.first.base_label}"
end
puts "backtrace limit      #{Thread::Backtrace.limit}"
puts "__FILE__ and __dir__ #{File.basename(__FILE__)} #{File.basename(__dir__)}"
caller(0)            ["e06_backtraces.rb:12:in 'Reporter#trace_it'", "e06_backtraces.rb:9:in 'Reporter#report'", "e06_backtraces.rb:20:in '<main>'"]
caller_locations(1)  e06_backtraces.rb:9 Reporter#report
each_caller_location  e06_backtraces.rb:9 Reporter#report
each_caller_location  e06_backtraces.rb:20 <main>
__method__/__callee__ :trace_it :trace_it
caller(0)            ["e06_backtraces.rb:12:in 'Reporter#trace_it'", "e06_backtraces.rb:21:in '<main>'"]
caller_locations(1)  e06_backtraces.rb:21 <main>
each_caller_location  e06_backtraces.rb:21 <main>
__method__/__callee__ :trace_it :aliased
message              invalid ISBN: 1234
backtrace            ["isbn.rb:8:in 'Shelf::Isbn#initialize'", "e06_backtraces.rb:23:in 'Shelf::Isbn.new'", "e06_backtraces.rb:23:in '<main>'"]
locations            ["isbn.rb:8 Shelf::Isbn#initialize", "e06_backtraces.rb:23 Shelf::Isbn.new", "e06_backtraces.rb:23 <main>"]
location class       Thread::Backtrace::Location
label, base_label    Shelf::Isbn#initialize, initialize
backtrace limit      -1
__FILE__ and __dir__ e06_backtraces.rb ex
Thread::Backtrace::Location Meaning

path, absolute_path

The file as the interpreter was given it, and its absolute form (nil for -e and eval without a file).

lineno

The line of the call inside that frame.

label

The frame’s name. Since Ruby 3.4 it carries the owner: Reporter#trace_it, Shelf::Isbn.new, block in Reporter#report, <main>.

base_label

The bare method name without the owner or the block in prefix: trace_it.

__method__ is the name the current method was defined with and __callee__ the name it was called by, which differ inside an alias (the example prints :trace_it :trace_it and then :trace_it :aliased). __FILE__, __dir__, __LINE__ and $PROGRAM_NAME ($0) locate the running source; __dir__ is the absolute directory of the file, and $0 == __FILE__ is the idiom for "run only when executed as a script" (see Command-Line Programs).

An exception carries its frames: Exception#backtrace is an array of strings and Exception#backtrace_locations the matching array of locations (both nil for an exception that was never raised). Thread::Backtrace.limit returns the value of the --backtrace-limit option (-1 when unlimited), which shortens printed traces. Since Ruby 3.4, Exception#set_backtrace, raise and Thread#raise also accept an array of Thread::Backtrace::Location, which lets a library re-raise with the original frames.

Backtraces in Ruby 3.4 and 4.0

The text of a backtrace changed twice, and any code or test that parses it needs to know:

  • Ruby 3.4 quotes with apostrophes instead of a backtick and a quote, adds the class or module name ('Reporter#trace_it', 'Shelf::Isbn.new'), and drops the extra frames that rescue and ensure used to add.

  • Ruby 4.0 stops showing interpreter-internal frames: methods implemented with <internal:...> Ruby code (such as Array#fetch_values) now appear as if they were C methods, in the caller’s file and line. The "wrong number of arguments" error names the owner (in 'Object#add' instead of in 'add'), and ErrorHighlight prints a snippet for the caller and the definition:

x23.rb:1:in 'Object#add': wrong number of arguments (given 1, expected 2) (ArgumentError)

    caller: x23.rb:2
    | add(1)
      ^^^
    callee: x23.rb:1
    | def add(x, y) = x + y
          ^^^
	from x23.rb:2:in '<main>'

Looking at the heap with ObjectSpace

ObjectSpace is the interface to the object heap of a CRuby process. It answers "what is alive?" and "how much does it cost?", which are questions about the whole process rather than about one object. Its methods are diagnostics: they are meant for tests, debugging sessions and tools, and several of them are slow or depend on the garbage collector, so they do not belong on a hot path.

# frozen_string_literal: true

require "objspace"
require "shelf"

isbn = Shelf::Isbn.new("978-0-306-40615-7")
author = Shelf::Author.new("Ursula K. Le Guin")
catalog = Shelf::Catalog.new
3.times { |i| catalog.add(Shelf::Book.new(isbn:, title: "Book #{i}", author:)) }

# Walking the heap
puts "live Books        #{ObjectSpace.each_object(Shelf::Book).count}"
shelf_classes = ObjectSpace.each_object(Class).select { it.name&.start_with?("Shelf::") }
puts "Shelf classes     #{shelf_classes.map(&:name).sort}"
names = []
count = ObjectSpace.each_object(Shelf::Author) { names << it.name }
puts "each_object block #{count} #{names}"

# Counting objects
before = ObjectSpace.count_objects
Array.new(1000) { Object.new }.size
after = ObjectSpace.count_objects
puts "count_objects     keys #{before.keys.first(3)}, T_OBJECT grew #{after[:T_OBJECT] - before[:T_OBJECT] >= 1000}"
puts "memsize_of        #{ObjectSpace.memsize_of('x' * 10_000) > 10_000}"
puts "memsize_of(1)     #{ObjectSpace.memsize_of(1)}"

# Where was this object allocated?
ObjectSpace.trace_object_allocations do
  book = Shelf::Book.new(isbn:, title: "Traced", author:)
  file = ObjectSpace.allocation_sourcefile(book)
  puts "allocated at      #{File.basename(file)}:#{ObjectSpace.allocation_sourceline(book)}"
  puts "allocating method #{ObjectSpace.allocation_method_id(book).inspect}"
end

# Weak references
weak = ObjectSpace::WeakMap.new
weak[author] = "cached value"
puts "WeakMap           #{weak[author]} (size #{weak.size})"
keys = ObjectSpace::WeakKeyMap.new
keys[author.name] = :seen
puts "WeakKeyMap        #{keys[author.name]} #{keys.getkey(author.name).equal?(author.name)}"

# _id2ref is deprecated in Ruby 4.0
Warning[:deprecated] = true
Warning.extend(Module.new { def warn(msg, category: nil) = puts("[#{category.inspect}] #{msg}") })
id = author.object_id
puts "_id2ref found it  #{ObjectSpace._id2ref(id).equal?(author)}"
live Books        3
Shelf classes     ["Shelf::Author", "Shelf::Book", "Shelf::Catalog", "Shelf::Config", "Shelf::Error", "Shelf::Isbn", "Shelf::Loan", "Shelf::Store::Memory"]
each_object block 1 ["Ursula K. Le Guin"]
count_objects     keys [:TOTAL, :FREE, :T_OBJECT], T_OBJECT grew true
memsize_of        true
memsize_of(1)     0
allocated at      e07_objectspace.rb:29
allocating method :new
WeakMap           cached value (size 1)
WeakKeyMap        seen true
[:deprecated] e07_objectspace.rb:47: warning: ObjectSpace._id2ref is deprecated
_id2ref found it  true

Walking and counting objects

  • ObjectSpace.each_object([module]) { |obj| ... } calls the block for every living, non-immediate object (optionally only instances of module, which can be a class or a module) and returns how many it found; without a block it returns an Enumerator. Immediate values (small integers, nil, true, static Symbols and flonums) are not objects on the heap and are never yielded. The result depends on garbage that has not been collected yet, so counts can be higher than expected; call GC.start first when a test needs an exact number. each_object(Class) also yields every singleton class (dozens, even in a bare process), so filter with singleton_class? or by name. The documentation warns that once the first Ractor.new has run, each_object stops yielding objects that are not shareable.

  • ObjectSpace.count_objects returns a Hash of counts by internal type (:TOTAL, :FREE, :T_OBJECT, :T_STRING, :T_ARRAY, :T_HASH, …​). The example compares two snapshots to confirm that a thousand objects were allocated. It is a cheap, coarse tool for leak hunting: take a snapshot, run the suspect code in a loop, take another and compare the types that grew. It accepts a Hash to fill, so a measurement can avoid allocating a new one.

  • ObjectSpace.garbage_collect is GC.start; see Garbage Collection and Memory.

  • Use ObjectSpace.each_object to find live instances for debugging, never for application logic such as a registry. A registry is a Hash filled by initialize or by the inherited hook, and it behaves the same on every run.

Weak references and finalizers

ObjectSpace::WeakMap holds both keys and values weakly and compares keys by identity. ObjectSpace::WeakKeyMap (Ruby 3.3) holds only its keys weakly, keeps values strongly, compares keys by value with eql?, and offers getkey to fetch the stored key object, which makes it the right structure for an interning cache keyed by a string. WeakMap#delete was added in 3.3. Both let an entry disappear once nothing else refers to the key, so the size is only meaningful right after you have looked at it, and their entries are not stable across a garbage collection.

ObjectSpace.define_finalizer(obj, callable) and undefine_finalizer register code to run after an object has been collected. The callable must not hold a reference to the object, or it will never be collected, and finalizers run late and in unspecified order; the rules are in Object Lifecycle, Freezing and Copying.

_id2ref is deprecated

ObjectSpace._id2ref(id) turns an object_id back into the object. Ruby 4.0 deprecates it (Feature #15408): it prints warning: ObjectSpace._id2ref is deprecated when deprecation warnings are on (-W:deprecated or -w; the example turns the category on and routes the message to puts so you can see its category). An id says nothing about whether its object is still alive, so the lookup could always fail or find a different object. Hold the object itself, or keep a WeakMap when you need a cache that does not keep objects alive.

The objspace extension

require "objspace" adds a diagnostic layer to the same module:

Method Purpose

ObjectSpace.memsize_of(obj), memsize_of_all([klass])

Bytes used by one object (without what it references) or by all objects of a class. It returns 0 for immediates, as in the example.

ObjectSpace.count_objects_size, count_symbols, count_nodes, count_tdata_objects, count_imemo_objects

Byte counts and counts per internal type, for leak and bloat analysis.

ObjectSpace.trace_object_allocations { ... }, allocation_sourcefile, allocation_sourceline, allocation_class_path, allocation_method_id, allocation_generation

Records where each object was allocated while the block runs, then answers "which line created this?". It slows the program noticeably, so use it in a diagnostic run only.

ObjectSpace.reachable_objects_from(obj), reachable_objects_from_root

The object graph, to find out what keeps an object alive.

ObjectSpace.dump(obj), dump_all(output: ...), dump_shapes

JSON descriptions of one object or of the whole heap, for offline analysis with heap-analysis tools.

The allocation example reports allocating method :new because the object was created by Class#new on the line shown. Profilers that report allocations per line (see Performance, Benchmarking and Profiling) are built on exactly these calls.

Tracing with TracePoint

TracePoint is the object-oriented interface to the interpreter’s event hooks. You describe the events you want, give a block, and Ruby calls the block each time one of them happens, with a TracePoint argument that describes the event. It replaces the old Kernel#set_trace_func, whose callback received six positional arguments and which is only kept for compatibility; new code should not use it. Debuggers (the debug gem), coverage tools, profilers, loggers that print "who called what" and test frameworks that detect unexpected calls are built on it.

# frozen_string_literal: true

require "shelf"

isbn = Shelf::Isbn.new("978-0-306-40615-7")
author = Shelf::Author.new("Ursula K. Le Guin")
book = Shelf::Book.new(isbn:, title: "The Dispossessed", author:)
catalog = Shelf::Catalog.new

calls = []
trace = TracePoint.new(:call, :return) do |tp|
  next unless tp.defined_class.to_s.start_with?("Shelf::")

  line = "#{tp.event.to_s.ljust(6)} #{tp.defined_class}##{tp.method_id} at #{File.basename(tp.path)}:#{tp.lineno}"
  line += " params #{tp.parameters}" if tp.event == :call
  calls << line
end

puts "enabled before   #{trace.enabled?}"
trace.enable do
  catalog.add(book)
  catalog.count
end
puts "enabled after    #{trace.enabled?}"
puts calls
enabled before   false
enabled after    false
call   Shelf::Catalog#add at catalog.rb:13 params [[:req, :book]]
call   Shelf::Store::Memory#add at memory.rb:11 params [[:req, :book]]
return Shelf::Store::Memory#add at memory.rb:14
return Shelf::Catalog#add at catalog.rb:16
call   Shelf::Catalog#each at catalog.rb:18 params [[:block, :&]]
call   Shelf::Store::Memory#all at memory.rb:9 params []
return Shelf::Store::Memory#all at memory.rb:9
return Shelf::Catalog#each at catalog.rb:22

Source: TracePoint

The example listens to :call and :return, keeps only the classes in the Shelf:: namespace and records the event, the method, the file and line, and the parameters. The Shelf::Catalog#add call, the Shelf::Store::Memory#add call it makes and the two returns appear in call-stack order. Enumerable#count is implemented in C, so it produces no :call event, but the Ruby method Catalog#each that it invokes does.

Events

If you do not list events, a TracePoint listens to every event, and the documentation says not to depend on that set, which can change. Always list the events you need.

Event Fires

:line

Before a new line of Ruby code runs. The highest-volume event.

:call, :return

On entering and leaving a method written in Ruby: def, endless methods and define_method methods. The accessors made by attr_reader and Struct are implemented in C and report :c_call.

:c_call, :c_return

On entering and leaving a method written in C, such as String#upcase or Array#each.

:b_call, :b_return

On entering and leaving a block, proc or lambda body.

:a_call, :a_return

Shorthand for all call events (:call, :b_call, :c_call) and all return events.

:class, :end

On entering and leaving a class or module body (not Class.new or Module.new).

:raise

When an exception is raised.

:rescue

When a raised exception is rescued by Ruby code (Ruby 3.3). Only Ruby-level rescue clauses count.

:thread_begin, :thread_end

When a thread starts and finishes (fired in the new thread).

:fiber_switch

When control passes to another fiber.

:script_compiled

When new code is compiled by eval, instance_eval with a string, load or require.

# frozen_string_literal: true

def risky = raise(ArgumentError, "boom")

def guarded
  risky
rescue ArgumentError => e
  e.message
end

log = []
events = %i[raise rescue class end b_call c_call script_compiled]
trace = TracePoint.new(*events) do |tp|
  case tp.event
  when :raise then log << "raise            #{tp.raised_exception.class} at line #{tp.lineno}"
  when :rescue then log << "rescue           #{tp.raised_exception.message.inspect} at line #{tp.lineno}"
  when :c_call then log << "c_call           #{tp.defined_class}##{tp.method_id}" if tp.method_id == :upcase
  when :script_compiled then log << "script_compiled  #{tp.eval_script.inspect}"
  else log << "#{tp.event.to_s.ljust(16)} line #{tp.lineno}"
  end
end

trace.enable
guarded
class Draft
  VERSION = 1
end
[1].each { |n| n }
"shelf".upcase
eval("1 + 1", binding, __FILE__, __LINE__)
trace.disable
puts log
raise            ArgumentError at line 3
rescue           "boom" at line 7
class            line 25
end              line 27
b_call           line 28
c_call           String#upcase
script_compiled  "1 + 1"

The log shows the order of events for a method that raises and rescues, a class body, a block, a C method and an eval. :class reports the line of the class keyword and :end the line of the matching end. The example uses a plain enable and disable pair so that the events of an enable block (its own :b_call) are not recorded.

Attributes of the event

Inside the block, the argument answers questions about the event. Calling an attribute that the current event does not define raises RuntimeError ("not supported by this event").

Attribute Meaning

event

The event name as a Symbol.

path, lineno

The file and line of the event.

method_id, callee_id

The name the method was defined with, and the name it was called by (they differ for an alias: the callee_id of a call through alias_method :copy, :orig is :copy and the method_id is :orig).

defined_class

The class or module that owns the method, which for a singleton method is the singleton class. Use tp.self.class for the receiver’s class.

self

The receiver at the time of the event.

parameters

The parameter list of the method or block, in the format of Method#parameters.

binding

A Binding of the code that is running, so tp.binding.local_variables and local_variable_get work. It is nil for :c_call and :c_return (Ruby 3.2), because C methods have no locals.

return_value

The value being returned, on :return, :c_return and :b_return.

raised_exception

The exception, on :raise and :rescue.

eval_script, instruction_sequence

On :script_compiled: the source string given to eval (it is nil when the code came from a file), and the compiled RubyVM::InstructionSequence. CRuby only.

Two rules protect the interpreter from confusing itself. The TracePoint argument is only valid inside the block, and touching it later raises RuntimeError ("access from outside"); copy the values you need instead of keeping the object. And TracePoints are active per Ractor: enabling one in the main Ractor does not trace code that runs in another.

Turning it on and off, and limiting its scope

enable and disable return the previous state, so enable returns false the first time and true the second. enable { ... } activates the hook for the duration of a block, and disable { ... } suspends it for a block while it is on. TracePoint.trace(*events) { ... } creates and enables in one step. enabled? tells you which state it is in, and inspect prints #<TracePoint:disabled> or #<TracePoint:enabled>, and inside the hook the event, the method and the location.

# frozen_string_literal: true

require "shelf"

isbn = Shelf::Isbn.new("978-0-306-40615-7")
author = Shelf::Author.new("Ursula K. Le Guin")
book = Shelf::Book.new(isbn:, title: "The Dispossessed", author:)
catalog = Shelf::Catalog.new

# 1. target: limits the hook to one method's code
seen = []
only_add = TracePoint.new(:call) { |tp| seen << "#{tp.defined_class}##{tp.method_id}" }
only_add.enable(target: Shelf::Catalog.instance_method(:add)) do
  catalog.add(book)
  catalog.each(&:itself)
end
puts "target: add only      #{seen}"

# 2. an enable block traces the current thread only (since 3.2)
events = []
thread_hook = TracePoint.new(:thread_begin, :thread_end) { |tp| events << tp.event }
thread_hook.enable { Thread.new { 1 }.join }
puts "default thread scope  #{events}"
thread_hook.enable(target_thread: nil) { Thread.new { 1 }.join }
puts "target_thread: nil    #{events}"

# 3. enable / disable return the previous state
hook = TracePoint.new(:c_call) { nil }
puts "enable, enable        #{hook.enable} #{hook.enable}"
puts "disable, disable      #{hook.disable} #{hook.disable}"

# 4. TracePoint.trace enables at once; hooks are not re-entered
own_code = ->(tp) { tp.defined_class == Object || tp.defined_class.to_s.start_with?("Shelf") }
nested = []
outer = TracePoint.trace(:call) do |tp|
  nested << "#{tp.defined_class}##{tp.method_id}" if own_code.call(tp)
  catalog.add(book) if tp.method_id == :first_call
end
def first_call = 1
first_call
outer.disable
puts "no re-entrance        #{nested}"
reentrant = []
outer = TracePoint.trace(:call) do |tp|
  reentrant << "#{tp.defined_class}##{tp.method_id}" if own_code.call(tp)
  TracePoint.allow_reentry { catalog.each(&:itself) } if tp.method_id == :first_call
end
first_call
outer.disable
puts "allow_reentry         #{reentrant}"
target: add only      ["Shelf::Catalog#add"]
default thread scope  []
target_thread: nil    [:thread_begin, :thread_end]
enable, enable        false true
disable, disable      true false
no re-entrance        ["Object#first_call"]
allow_reentry         ["Object#first_call", "Shelf::Catalog#each", "Shelf::Store::Memory#all"]
  • target: restricts the hook to one method, UnboundMethod, Proc or other code object for which RubyVM::InstructionSequence.of returns an instruction sequence. The example traces only Catalog#add even though Catalog#each is also called. With target_line: it narrows further to one line, but only together with the :line event (otherwise ArgumentError: "target_line is specified, but line event is not specified"). Targeted tracing is the cheap form, because the interpreter instruments just that code.

  • target_thread: limits the hook to one thread. Since Ruby 3.2, enable with a block and without target, target_line or target_thread defaults to the current thread, which is why the first thread_begin hook sees nothing: the event is fired in the new thread. Pass target_thread: nil to listen on every thread.

  • While a hook runs, other TracePoint hooks are not called, so a hook that calls traced methods does not recurse (the catalog.add inside the hook is invisible to it). TracePoint.allow_reentry { ... } (Ruby 3.1) lifts that restriction inside its block; it raises RuntimeError if reentrance is already allowed, and the hook must make sure it does not trigger itself endlessly.

  • TracePoint.new needs a block (ArgumentError: "must be called with a block") and known events (ArgumentError: "unknown event: nope").

What tracing costs

Every enabled event adds work to the interpreter, and :line, :call and :c_call happen constantly. Tracing a tiny method in a loop is several times slower than running it untraced on the same machine, and enabling a TracePoint can force the VM to throw away optimized code: Ruby 4.0 adds an invalidate_everything counter to the default YJIT statistics that counts the times all compiled code was invalidated because of a TracePoint (and it fixed a bug where TracePoints stopped working after a garbage collection). Practical rules:

  • Enable a hook for the narrowest scope: a block, a target, a thread, and only the events you need.

  • Filter inside the block as the first statement (next unless ...), because the block is called for every event of the chosen kinds, including those from gems and from the standard library.

  • Never leave a tracer enabled in production unless that is its purpose. Use it in tests, development and diagnostics; for steady-state observation, prefer sampling profilers (see Performance, Benchmarking and Profiling).

  • A tracer that reads tp.binding or tp.parameters on every event is the most expensive kind; ask for them only when you report.

A reusable call tracer

The class below is the useful core of most "print every call" tools: it wraps a TracePoint, keeps only one namespace, indents by call depth and summarizes return values by type so that logs do not contain object addresses.

# frozen_string_literal: true

require "shelf"

# Records an indented call tree for the methods of one namespace.
class CallTracer
  def initialize(namespace) = @namespace = namespace

  def trace(&)
    lines = []
    depth = 0
    hook = TracePoint.new(:call, :return) do |point|
      next unless point.defined_class.to_s.start_with?(@namespace)

      depth -= 1 if point.event == :return
      lines << "#{'  ' * depth}#{describe(point)}"
      depth += 1 if point.event == :call
    end
    hook.enable(&)
    lines
  end

  private

  def describe(point)
    name = "#{point.defined_class.to_s.delete_prefix('Shelf::')}##{point.method_id}"
    point.event == :call ? "-> #{name}" : "<- #{name} = #{summary(point.return_value)}"
  end

  def summary(value)
    case value
    in String | Symbol | Integer | nil | true | false then value.inspect
    else value.class.to_s.delete_prefix("Shelf::")
    end
  end
end

isbn = Shelf::Isbn.new("978-0-306-40615-7")
author = Shelf::Author.new("Ursula K. Le Guin")
book = Shelf::Book.new(isbn:, title: "The Dispossessed", author:)
catalog = Shelf::Catalog.new.add(book)

tracer = CallTracer.new("Shelf::")
first = tracer.trace { Shelf::Loan.new(book).describe }
puts first
puts
second = tracer.trace { catalog.map(&:title) }
puts second
-> Loan#initialize
<- Loan#initialize = :available
-> Loan#describe
<- Loan#describe = "The Dispossessed is on the shelf"

-> Catalog#each
  -> Store::Memory#all
  <- Store::Memory#all = Array
<- Catalog#each = Array

The second trace shows the limit of :call events: Enumerable#map is a C method and is not listed, only the Ruby method Catalog#each that it calls. Add :c_call to see C methods, at a much higher cost.

Warnings

Ruby’s warnings are an introspection channel too: the interpreter reports questionable code and deprecated features through one hook, Warning.warn, and a program can switch categories on and off, filter them and capture them. This is how test suites turn warnings into failures and how libraries announce their own deprecations.

Verbosity and categories

Two independent settings decide what is printed.

  • $VERBOSE has three states. false (the default, -W1) prints the important warnings. true (-w or -W2) prints every warning, including style and unused-code diagnostics (assigned but unused variable, method redefined). nil (-W0) prints none: Kernel#warn does nothing at all.

  • Warning categories are Boolean switches for groups of warnings. Warning.categories (Ruby 3.4) lists them: :deprecated, :experimental, :performance and :strict_unused_block.

Category Covers Default

:deprecated

Features slated for removal: mutating a chilled string literal (Ruby 3.4), ObjectSpace._id2ref (4.0), the old behaviour of keyword arguments, the definition of the top-level constant ::Ruby (3.4).

off; -w and -W:deprecated turn it on

:experimental

Features still marked experimental, such as the Ractor API in 4.0.

on; -W:no-experimental turns it off

:performance (Ruby 3.3)

Hints about slow patterns, such as redefining a core method the interpreter and JIT optimize (String#freeze, Integer#+; Ruby 3.4).

off; -W:performance turns it on

:strict_unused_block (Ruby 3.4)

A block passed to a method that never uses it (the block passed to 'Object#m' defined at ... may be ignored).

off; -W:strict_unused_block turns it on. Under -w the same unused-block warning is shown for every such method.

$ ruby -W:deprecated script.rb      # one category on
$ ruby -W:no-experimental script.rb # one category off
$ ruby -w script.rb                 # verbose: $VERBOSE = true, deprecated on
$ ruby -W0 script.rb                # silent: $VERBOSE = nil
$ RUBYOPT="-W:deprecated" ruby script.rb

The same switches exist in code: Warning[:deprecated] = true and Warning[:experimental] read and write a category, and an unknown name raises ArgumentError ("unknown category: nope"). RUBYOPT applies the options to every Ruby process started from that shell, which is how CI jobs raise the level of the whole test run without editing scripts.

Emitting, capturing and filtering

  • Kernel#warn(*messages, uplevel: nil, category: nil) writes to $stderr through Warning.warn. uplevel: n prefixes the message with the file and line of the caller n frames up and the word warning: (the standard form for deprecation messages, so users see their line, not yours). category: (Ruby 3.0) names the category, and the message is dropped if that category is off, which is how a library emits a deprecation that stays quiet by default.

  • Warning.warn(message, category: nil) is the single method every warning passes through, from the interpreter and from Kernel#warn. Overriding it redirects, filters or collects warnings. The documentation recommends extending Warning with a module that calls super for the default behaviour, and says never to redefine the instance method Warning#warn. A hook should accept **kwargs to survive additions to the signature.

  • Ruby only calls Warning.warn for categories that are switched on, so a capture hook sees exactly what would have been printed.

# frozen_string_literal: true

# Capture every warning instead of printing it (useful in tests).
module WarningCapture
  Entry = Data.define(:category, :message)

  def self.entries = @entries ||= []

  def warn(message, category: nil)
    WarningCapture.entries << Entry.new(category:, message: message.chomp)
    nil
  end
end
Warning.extend(WarningCapture)

def report(label)
  before = WarningCapture.entries.size
  yield
  fresh = WarningCapture.entries.drop(before)
  puts "#{label.ljust(26)} #{fresh.empty? ? 'nothing' : fresh.map { [it.category, it.message] }}"
end

puts "categories                 #{Warning.categories}"
puts "defaults                   #{Warning.categories.to_h { [it, Warning[it]] }}"
report("warn, no category") { warn "plain" }
report("deprecated off") { warn "old way", category: :deprecated }
Warning[:deprecated] = true
report("deprecated on") { warn "old way", category: :deprecated }
report("uplevel: 0") { warn "careful", uplevel: 0 }
report("chilled string") { eval("text = 'lit'; text << 'x'", binding, __FILE__, __LINE__) }
report("Warning.warn direct") { Warning.warn("direct\n") }
report("$VERBOSE = nil") do
  $VERBOSE = nil
  warn "silenced"
ensure
  $VERBOSE = false
end
begin
  warn "bad", category: :nope
rescue ArgumentError => e
  puts "unknown category           #{e.class}: #{e.message}"
end
Warning[:experimental] = true
report("experimental feature") { Ractor.new { 1 }.value }
categories                 [:deprecated, :experimental, :performance, :strict_unused_block]
defaults                   {deprecated: false, experimental: true, performance: false, strict_unused_block: false}
warn, no category          [[nil, "plain"]]
deprecated off             nothing
deprecated on              [[:deprecated, "old way"]]
uplevel: 0                 [[nil, "e11_warnings.rb:29: warning: careful"]]
chilled string             [[:deprecated, "e11_warnings.rb:30: warning: literal string will be frozen in the future (run with --debug-frozen-string-literal for more information)"]]
Warning.warn direct        [[nil, "direct"]]
$VERBOSE = nil             nothing
unknown category           ArgumentError: unknown category: nope
experimental feature       [[:experimental, "e11_warnings.rb:44: warning: Ractor API is experimental and may change in future versions of Ruby."]]

This example runs under plain ruby (not -w), so the defaults are visible: deprecated is off until the script turns it on, and experimental is already on. It shows each point in turn:

  • a plain warn reaches the hook with no category;

  • a category: :deprecated warning disappears while the category is off and arrives once it is on;

  • uplevel: 0 prefixes the file and line (e11_warnings.rb:29: warning: careful);

  • a chilled string mutated in eval produces the real Ruby 3.4+ deprecation, with the :deprecated category;

  • $VERBOSE = nil silences warn; and

  • the Ractor warning (the Ractor API is experimental in Ruby 4.0, see Ractors) arrives with the :experimental category.

A test helper that raises on any captured warning is the standard way to keep a code base free of deprecations before the next Ruby upgrade; see What’s New and Migrating Older Code and Testing with Minitest. The community warning gem wraps this hook with ready-made filters; it is not part of Ruby.

Code coverage

Coverage (in the standard library, require "coverage") counts how often each line, branch and method of the files loaded while it is running was executed. It is the engine under test-coverage tools such as the community SimpleCov gem (https://github.com/simplecov-ruby/simplecov); the documentation labels the module experimental, and it measures the whole process, not one thread.

The one rule that surprises everyone: start Coverage before the code to measure is loaded. A file that was already loaded when Coverage.start ran is not instrumented, and a file reloaded with load after the start is measured from then on.

# frozen_string_literal: true

require "coverage"

# Coverage must be started before the code to measure is loaded.
Coverage.start(lines: true, branches: true, methods: true)
require "shelf"

isbn = Shelf::Isbn.new("978-0-306-40615-7")
author = Shelf::Author.new("Ursula K. Le Guin")
book = Shelf::Book.new(isbn:, title: "The Dispossessed", author:)
Shelf::Loan.new(book, state: :lent).describe

result = Coverage.result
path = result.keys.find { it.end_with?("shelf/loan.rb") }
data = result.fetch(path)
puts "modes   #{data.keys}"

puts "lines (number: hits)"
data[:lines].each_with_index do |hits, index|
  puts "  #{index + 1}: #{hits}" if hits && index.between?(9, 21)
end

puts "methods"
data[:methods].each do |(klass, name, first, _, last, _), hits|
  puts "  #{klass}##{name} lines #{first}-#{last} called #{hits}x"
end

puts "branches"
data[:branches].each do |(kind, _, line, *), arms|
  puts "  #{kind} at line #{line}"
  arms.each { |(arm, _, arm_line, *), hits| puts "    #{arm} (line #{arm_line}): #{hits}" }
end

puts "running? #{Coverage.running?}, supported?(:eval) #{Coverage.supported?(:eval)}"
modes   [:lines, :branches, :methods]
lines (number: hits)
  10: 1
  11: 1
  13: 1
  14: 1
  17: 1
  18: 1
  19: 0
  20: 1
  21: 0
methods
  Shelf::Loan#describe lines 17-23 called 1x
  Shelf::Loan#initialize lines 10-15 called 1x
branches
  unless at line 11
    else (line 11): 1
    then (line 11): 0
  case at line 18
    in (line 19): 0
    in (line 20): 1
    in (line 21): 0
    else (line 18): 0
running? false, supported?(:eval) true

Source: Coverage

Mode Result for each file

lines: true

An array indexed by line number minus one: the execution count, or nil for lines that cannot execute (end, else, comments, blank lines). In the loan example, the in line that matches :lent ran once and the other in lines never did.

branches: true

A Hash from each conditional ([:case, id, first_line, first_column, last_line, last_column]) to a Hash of its arms with their own counts. if and unless (also as modifiers and the ternary), case with when or in, while and the safe navigation operator &. are tracked.

methods: true

A Hash from [class, name, first_line, first_column, last_line, last_column] to the call count, which finds methods that nothing ever calls.

oneshot_lines: true

An array of the line numbers that ran at least once, with no counts. Much cheaper than lines, and the right mode when you only need to know whether code ran. It cannot be combined with lines.

eval: true (Ruby 3.2)

Also measure code compiled by eval and its relatives. Without it only files are measured.

:all or Coverage.start with no argument

Coverage.start(:all) turns on lines (not oneshot_lines, which cannot be combined with it), branches and methods; it also measures eval in 4.0.7, which the documentation does not state. With no argument the legacy format is used: the result is the bare array of line counts rather than a Hash.

The control methods are Coverage.start (setup followed by resume), setup (prepare without counting), resume and suspend (Ruby 3.1) to count only part of a run, peek_result to read the numbers without stopping, result to read and stop (result(stop: false, clear: true) keeps running and resets the counters), running?, state (one of :idle, :suspended, :running), supported?(mode) (Ruby 3.2) and line_stub(file), which returns the all-zero lines array for a file so that unloaded files can be reported at 0 %.

# frozen_string_literal: true

require "coverage"
require "fileutils"
require "tmpdir"

# Writes a small source file defining method `name` and returns its path.
def write_source(dir, name)
  path = File.join(dir, "#{name}.rb")
  File.write(path, "def #{name}(days)\n  if days > 14\n    :overdue\n  else\n    :ok\n  end\nend\n")
  path
end

dir = Dir.mktmpdir

# setup / resume / suspend / peek_result / result
first_path = write_source(dir, "first")
Coverage.setup(oneshot_lines: true)
load first_path
puts "state after setup     #{Coverage.state}"
Coverage.resume
first(3)
Coverage.suspend
first(30)
Coverage.resume
puts "peek (not stopping)   #{Coverage.peek_result.values.first}"
first(30)
puts "result                #{Coverage.result.values.first}"
puts "state after result    #{Coverage.state}"

# result formats and line_stub
second_path = write_source(dir, "second")
Coverage.start(lines: true)
load second_path
second(3)
puts "lines per source line #{Coverage.result[second_path][:lines]}"
puts "line_stub             #{Coverage.line_stub(second_path)}"
third_path = write_source(dir, "third")
Coverage.start
load third_path
third(3)
puts "legacy array format   #{Coverage.result[third_path]}"

# code run through eval is measured only with eval: true (Ruby 3.2)
Coverage.setup(lines: true, eval: true)
Coverage.resume
# rubocop:disable-next Style/EvalWithLocation
eval("a = 1\nb = a + 1\n", binding, "generated.rb", 1)
puts "eval: true            #{Coverage.result}"
FileUtils.remove_entry(dir)
state after setup     suspended
peek (not stopping)   {oneshot_lines: [2, 5]}
result                {oneshot_lines: [2, 5, 3]}
state after result    idle
lines per source line [1, 1, 0, nil, 1, nil, nil]
line_stub             [0, 0, 0, nil, 0, nil, nil]
legacy array format   [1, 1, 0, nil, 1, nil, nil]
eval: true            {"generated.rb" => {lines: [1, 1]}}

In the second example, first(30) ran while coverage was suspended and left no trace, so line 3 (:overdue) appears in the result only after the second first(30) that ran after resume. The RuboCop cop Style/EvalWithLocation is silenced for the eval line because the invented file name generated.rb is the point: a measured eval is reported under the name you give it.

Coverage.result raises RuntimeError ("coverage measurement is not enabled") when nothing is running, and start after setup without a result raises as well; keep calls paired. Coverage is slower than a plain run, but it keeps its counters inside the interpreter instead of calling a Ruby block for every line the way a :line TracePoint does. A line count of 1 does not mean "tested": it means the line ran, which is why branch coverage and good assertions matter more than the percentage (see Testing with Minitest).

Instruction sequences, syntax trees and Prism

Between the text of a program and its execution, CRuby parses the source into a syntax tree and compiles the tree into YARV instructions (see YJIT, ZJIT and the VM). Three APIs expose those stages, and they differ greatly in how far you can rely on them.

API Stage Stability

RubyVM::InstructionSequence

Compiled YARV bytecode

CRuby only. The documentation states that the behaviour of its methods can change in any version and that the instruction output will almost certainly change as Ruby changes.

RubyVM::AbstractSyntaxTree

Syntax tree from the legacy parse.y parser

CRuby only and documented as experimental: the order and number of child nodes are not guaranteed. It points to Prism as the stable choice.

Prism (require "prism")

Syntax tree from the parser CRuby itself uses (default since Ruby 3.4)

The official, versioned API. A default gem since Ruby 3.3, portable across Ruby implementations, with a documented node set.

Ripper (require "ripper")

Event-based lexer and parser API

Older and still shipped. Prism provides a Ripper-compatible Prism::Translation::Ripper and translation layers for the parser and ruby_parser gems.

The whole RubyVM module exists only on MRI (the reference implementation), and the documentation says normal users must not use it. Use these APIs to learn, to debug and to build tools that you control the Ruby version of, not in application code.

RubyVM::InstructionSequence

An instruction sequence (an iseq) is what a method, block or file compiles to. InstructionSequence.compile(source, file) compiles a string, compile_file a file, of(method_or_proc) returns the iseq of an existing method or block (nil for C methods), and disasm prints the instructions. eval runs a compiled iseq, to_a returns its contents as data and to_binary / load_from_binary serialize it, which is the basis of bytecode caches. label, path, base_label, first_lineno and absolute_path identify it, compile_option and compile_option= read and change the compiler switches (specialized instructions, inline constant caches, frozen_string_literal, and so on).

# frozen_string_literal: true

source = <<~RUBY
  def late_fee(days, rate: 5)
    days > 14 ? (days - 14) * rate : 0
  end
RUBY

iseq = RubyVM::InstructionSequence.compile(source, "late_fee.rb")
puts "label, path        #{iseq.label}, #{iseq.path}"
puts "first_lineno       #{iseq.first_lineno}"
puts "to_a header        #{iseq.to_a.first(3)}"
puts

# Disassemble a method that already exists in this process.
class Calculator
  def double(value) = value * 2
end

method_iseq = RubyVM::InstructionSequence.of(Calculator.instance_method(:double))
puts method_iseq.disasm
options = RubyVM::InstructionSequence.compile_option.slice(:inline_const_cache, :specialized_instruction)
puts "compile options    #{options}"
puts "eval a fresh iseq  #{RubyVM::InstructionSequence.compile('6 * 7').eval}"
puts "of a C method      #{RubyVM::InstructionSequence.of(1.method(:+)).inspect}"
label, path        <compiled>, late_fee.rb
first_lineno       1
to_a header        ["YARVInstructionSequence/SimpleDataFormat", 4, 0]

== disasm: #<ISeq:double@e14_instruction_sequence.rb:17 (17,2)-(17,31)>
local table (size: 1, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 1] value@0<Arg>
0000 getlocal_WC_0                          value@0                   (  17)[Ca]
0002 putobject                              2
0004 opt_mult                               <calldata!mid:*, argc:1, ARGS_SIMPLE>[CcCr]
0006 leave                                  [Re]
compile options    {inline_const_cache: true, specialized_instruction: true}
eval a fresh iseq  42
of a C method      nil

The disassembly of value * 2 is five instructions: load the local value, push the constant 2, call the specialized multiplication opt_mult, and leave. The bracketed flags at the end of a line ([Ca], [CcCr], [Re]) mark call information and trace points. You will meet the same listing from the command line, with no code at all:

$ ruby --dump=insns -e 'def double(v) = v * 2'
== disasm: #<ISeq:<main>@-e:1 (1,0)-(1,21)>
0000 definemethod                           :double, double           (   1)[Li]
0003 putobject                              :double
0005 leave

== disasm: #<ISeq:double@-e:1 (1,0)-(1,21)>
local table (size: 1, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 1] v@0<Arg>
0000 getlocal_WC_0                          v@0                       (   1)[Ca]
0002 putobject                              2
0004 opt_mult                               <calldata!mid:*, argc:1, ARGS_SIMPLE>[CcCr]
0006 leave                                  [Re]

ruby --dump=parsetree prints the Prism syntax tree of a program in the same way, and adding --dump=-optimize shows the code without the compiler’s optimizations (opt_mult becomes a plain send). Besides compile, there are compile_prism and compile_parsey, which pick the parser explicitly; compile uses the default one.

An iseq also drives the target: option of TracePoint (Tracing with TracePoint), the :script_compiled event’s instruction_sequence attribute, and the iseq-based Coverage line_stub described above.

RubyVM::AbstractSyntaxTree

RubyVM::AbstractSyntaxTree.parse(string), parse_file(path) and of(method_or_proc) return a tree of RubyVM::AbstractSyntaxTree::Node objects. A node has a type (:DEFN, :OPCALL, :LVAR, …​), children, and first_lineno, first_column, last_lineno, last_column; with keep_tokens: true (Ruby 3.2) it also has tokens and all_tokens, with error_tolerant: true (Ruby 3.2) the parser returns a partial tree for broken code, and Node#locations (Ruby 3.4) returns RubyVM::AbstractSyntaxTree::Location objects with the positions of each token.

# frozen_string_literal: true

source = <<~RUBY
  def price(days)
    days * 2 + 1
  end
RUBY

def dump(node, depth = 0)
  indent = "  " * depth
  return puts("#{indent}#{node.inspect}") unless node.is_a?(RubyVM::AbstractSyntaxTree::Node)

  puts "#{indent}#{node.type} #{node.first_lineno}:#{node.first_column}-#{node.last_lineno}:#{node.last_column}"
  return if node.type == :ARGS # ten parameter slots, skipped here

  node.children.each { dump(it, depth + 1) }
end

tree = RubyVM::AbstractSyntaxTree.parse(source)
puts "root type   #{tree.type}"
dump(tree.children.last)

begin
  RubyVM::AbstractSyntaxTree.of(method(:dump))
rescue RuntimeError => e
  puts "of(method)  #{e.message}"
end

tokens = RubyVM::AbstractSyntaxTree.parse("puts 1 + 2", keep_tokens: true).tokens
puts "tokens      #{tokens.first(4).map { it[1..2] }}"
root type   SCOPE
DEFN 1:0-3:3
  :price
  SCOPE 1:0-3:3
    [:days]
    ARGS 1:10-1:14
    OPCALL 2:2-2:14
      OPCALL 2:2-2:10
        LVAR 2:2-2:6
          :days
        :*
        LIST 2:9-2:10
          INTEGER 2:9-2:10
            2
          nil
      :+
      LIST 2:13-2:14
        INTEGER 2:13-2:14
          1
        nil
of(method)  cannot get AST for ISEQ compiled by prism
tokens      [[:tIDENTIFIER, "puts"], [:tSP, " "], [:tINTEGER, "1"], [:tSP, " "]]

The tree of the three-line price method shows the quirks that make the API unsuitable as a tool foundation: days * 2 + 1 is an OPCALL of + whose receiver is another OPCALL, an operator call has a LIST of arguments, and the parameter list ARGS is a ten-slot array of mostly nil and numbers whose meaning is only in the C source.

The last lines of the output hide the most important pitfall. AbstractSyntaxTree.of(method) needs a method compiled by the legacy parser. Since Ruby 3.4 the default parser is Prism, so the iseq of any method in your program was compiled by Prism and of raises RuntimeError ("cannot get AST for ISEQ compiled by prism"); on Ruby 3.3 the same call returns the tree. parse and parse_file keep working because they always use the legacy parser, and they print the legacy parser’s own diagnostics to $stderr under -w; ruby --parser=parse.y restores the old behaviour of of. Code that needs to analyse source it finds through Method#source_location should read the file and parse it with Prism, as in the next section.

Prism

Prism is a portable, error-tolerant recursive-descent parser for Ruby. It is both a C library, which CRuby uses internally, and a gem with a Ruby API for any tool that needs to parse Ruby. It became the default parser of CRuby in 3.4 (selected with --parser=prism or --parser=parse.y), and the Ruby documentation names it as the official API for parsing Ruby code that works across implementations. Because it is error tolerant, broken code still yields a tree and a list of errors. Prism versions follow the gem, not Ruby’s version: Ruby 4.0.0 bundled Prism 1.7.0 and the 4.0.7 used here reports Prism::VERSION 1.8.1.

The entry points are Prism.parse(source, version: ...), parse_file, parse_success?, parse_comments, lex and dump. A Prism::ParseResult has value (the ProgramNode), errors, warnings, comments and magic_comments. Every node is a typed class (DefNode, CallNode, LocalVariableReadNode, ItLocalVariableReadNode, …​), with named accessors instead of positional children, a location with start_line, end_line, start_column and slice, and accept for visitors. Prism::Visitor walks the tree, and you override visit_*_node for the nodes you care about, calling super to continue into the children.

# frozen_string_literal: true

require "prism"
require "ripper"
require "shelf"

source = <<~RUBY
  # Price a loan.
  def price(days, rate: 2)
    days * rate + 1
  end
RUBY

result = Prism.parse(source)
puts "result class      #{result.class}, success? #{result.success?}"
puts "errors, warnings  #{result.errors.size}, #{result.warnings.size}"
puts "comments          #{result.comments.map { it.location.slice }}"

method_node = result.value.statements.body.first
puts "node              #{method_node.class} #{method_node.name.inspect}"
puts "lines             #{method_node.location.start_line}-#{method_node.location.end_line}"
puts "parameters        #{method_node.parameters.requireds.map(&:name)} #{method_node.parameters.keywords.map(&:name)}"
puts "source slice      #{method_node.location.slice.lines.first.chomp}"
puts "first tokens      #{Prism.lex('a = 1').value.map { |token, _state| token.type }}"
puts "parse_success?    #{Prism.parse_success?('1 + 2')} #{Prism.parse_success?('1 +')}"

broken = Prism.parse("def price(\n  1 +\nend")
broken.errors.each { puts "  line #{it.location.start_line}: #{it.message} (#{it.type})" }
puts "tree still built  #{broken.value.class}"

# A visitor walks the tree; override only the node types you care about.
class MethodCollector < Prism::Visitor
  attr_reader :found

  def initialize
    super
    @found = []
  end

  def visit_def_node(node)
    @found << "#{node.name}:#{node.location.start_line}"
    super
  end
end

# Reflection finds the file; Prism parses it.
isbn_file = Shelf::Isbn.instance_method(:to_s).source_location.first
collector = MethodCollector.new
Prism.parse_file(isbn_file).value.accept(collector)
puts "defs in isbn.rb   #{collector.found}"

# The same question under two language versions: `it` is a method call in 3.3 and a parameter in 3.4.
%w[3.3 3.4].each do |version|
  call = Prism.parse("[1].map { it }", version:).value.statements.body.first
  puts "it under #{version}      #{call.block.body.body.first.class}"
end

# Ripper is the older, event-based API; Prism ships a drop-in translation of it.
puts "Ripper.sexp       #{Ripper.sexp('1 + 2')}"
puts "Prism's Ripper    #{Prism::Translation::Ripper.sexp('1 + 2') == Ripper.sexp('1 + 2')}"
puts "Prism::VERSION    #{Prism::VERSION}"
result class      Prism::ParseResult, success? true
errors, warnings  0, 0
comments          ["# Price a loan."]
node              Prism::DefNode :price
lines             2-4
parameters        [:days] [:rate]
source slice      def price(days, rate: 2)
first tokens      [:IDENTIFIER, :EQUAL, :INTEGER, :EOF]
parse_success?    true false
  line 2: unexpected integer; expected a `)` to close the parameters (def_params_term_paren)
  line 2: unexpected '+', expecting end-of-input (expect_eol_after_statement)
  line 2: unexpected 'end', expected a receiver for unary `+` (unary_receiver)
  line 3: unexpected 'end', assuming it is closing the parent method definition (unexpected_token_close_context)
tree still built  Prism::ProgramNode
defs in isbn.rb   ["initialize:6", "to_s:13", "valid?:17"]
it under 3.3      Prism::CallNode
it under 3.4      Prism::ItLocalVariableReadNode
Ripper.sexp       [:program, [[:binary, [:@int, "1", [1, 0]], :+, [:@int, "2", [1, 4]]]]]
Prism's Ripper    true
Prism::VERSION    1.8.1

What the example shows, in order:

  • a successful parse (success?, no errors) and the typed DefNode with its name, lines, parameters and the exact source slice, plus the comment that was found;

  • Prism.lex returning tokens, and parse_success? as the quick validity check;

  • a broken file producing four errors, each with a line, a message and a stable type symbol, and still a tree;

  • a Prism::Visitor subclass collecting the methods of isbn.rb, a file that was found through reflection (source_location) and parsed with Prism, the pattern behind every documentation, lint and refactoring tool;

  • the version: option: it in [1].map { it } is parsed as a method call when the target is Ruby 3.3 and as the implicit block parameter (ItLocalVariableReadNode) when it is 3.4, which is what lets a tool analyse code for an older Ruby than the one it runs on; and

  • Ripper.sexp compared with the same call on Prism::Translation::Ripper, which returns the same result for that input.

Prism::Translation::Parser and RubyParser produce the trees that the community parser and ruby_parser gems return, so tools written for those gems (RuboCop’s cops are built on the parser AST) can run on Prism without rewriting. Ripper (Ripper.sexp, Ripper.lex, Ripper.tokenize) is still available and is how older tools such as syntax highlighters work; for new code, choose Prism.

Which one to use

You want to …​ Use

Find where a method or constant is defined

Method#source_location, Module#const_source_location

Read, search or rewrite Ruby source in a tool

Prism

Keep working with a tool written for the parser or ruby_parser gem

Prism::Translation::Parser or RubyParser

Understand why Ruby is slow or what a construct compiles to

RubyVM::InstructionSequence#disasm or ruby --dump=insns

Check syntax without running a program

ruby -c, or Prism.parse_success?

See which line of source a backtrace frame points at

Thread::Backtrace::Location#lineno; for the exact expression, RubyVM::AbstractSyntaxTree.node_id_for_backtrace_location, which returns the id of the syntax node the frame points at

Run Ruby code produced by a program

Not these APIs: see Hooks, eval and DSLs and treat the text as untrusted

Putting it together: an API report

The pieces combine into small tools. This one prints, for each class, the methods it defines itself with their visibility, a signature rebuilt from Method#parameters, and the file and line from source_location. It uses only Module and Method reflection, so it works on any Ruby.

# frozen_string_literal: true

require "shelf"

# Renders Method#parameters back into something that looks like a signature.
module Signature
  FORMATS = {
    req: "%<name>s", opt: "%<name>s = ...", rest: "*%<name>s", keyreq: "%<name>s:",
    key: "%<name>s: ...", keyrest: "**%<name>s", block: "&%<name>s"
  }.freeze
  ANONYMOUS = %i[* ** &].freeze

  def self.render(parameters)
    parameters.map do |kind, name|
      format(FORMATS.fetch(kind), name: ANONYMOUS.include?(name) ? nil : name)
    end.join(", ")
  end
end

# The instance methods a class defines itself: visibility, method and location.
def own_methods(klass)
  %i[public private].flat_map do |visibility|
    klass.public_send(:"#{visibility}_instance_methods", false).map { [visibility, klass.instance_method(it)] }
  end
end

def location(method)
  file, line = method.source_location
  file ? "#{File.basename(file)}:#{line}" : "(generated)"
end

def api_report(klass)
  puts klass
  own_methods(klass).sort_by { |_, method| method.source_location&.last || 0 }.each do |visibility, method|
    signature = "#{method.name}(#{Signature.render(method.parameters)})"
    puts format("  %<visibility>-7s #%<signature>-34s %<where>s", visibility:, signature:, where: location(method))
  end
end

[Shelf::Isbn, Shelf::Loan, Shelf::Catalog, Shelf::Store::Memory].each { api_report(it) }
Shelf::Isbn
  public  #digits()                           (generated)
  private #initialize(digits:)                isbn.rb:6
  public  #to_s()                             isbn.rb:13
  private #valid?(value)                      isbn.rb:17
Shelf::Loan
  public  #state()                            loan.rb:8
  public  #book()                             loan.rb:8
  private #initialize(book, state: ...)       loan.rb:10
  public  #describe()                         loan.rb:17
Shelf::Catalog
  private #initialize(store: ...)             catalog.rb:8
  public  #add(book)                          catalog.rb:13
  public  #each(&)                            catalog.rb:18
Shelf::Store::Memory
  private #initialize()                       memory.rb:7
  public  #all()                              memory.rb:9
  public  #add(book)                          memory.rb:11

The report shows details that a reading of the source hides: Shelf::Isbn#digits is "generated" because the reader that Data.define creates has no Ruby source; Loan#state and Loan#book both point at line 8, the attr_reader call; the private initialize is listed with its keyword parameter digits: for Isbn because the Data class overrides it; Catalog#each(&) shows the anonymous block parameter. A documentation generator, an IDE outline and a "public API changed" check in CI are all variations of this loop.

Costs, safety and portability

  • Speed. respond_to?, instance_variable_get and Method objects are cheap enough for ordinary code, though a Method allocation per call adds up in a tight loop. ObjectSpace.each_object, count_objects_size, allocation tracing and every TracePoint are diagnostic tools with real cost. Never put them in a request path.

  • Safety. Reflective calls take names, and names that come from outside are input: obj.public_send(params[:action]), Object.const_get(params[:type]), Binding#eval and instance_variable_set(params[:name], ...) let a caller reach methods, classes and state that you never meant to expose. Allow-list the names, and read Security.

  • Encapsulation. Reflection ignores private (instance_variable_get, send, instance_method of a private method) by design. Using it in application code ties the caller to the implementation. Tests are the usual exception, and even there prefer the public interface; see Visibility and Encapsulation.

  • Portability. RubyVM exists only on MRI, and several ObjectSpace methods, TracePoint#instruction_sequence and the objspace extension are documented as MRI specific. Method, Binding, caller_locations and the Module queries are core Ruby and the safe subset. The shared, versioned API for parsing is Prism.

  • Stability. Everything under RubyVM and the output of disasm, ObjectSpace.count_objects keys, TracePoint.stat and the text of backtrace lines and warnings can change in any release (3.4 changed backtraces and 4.0 changed them again). Do not parse them in code you ship; use the structured APIs (caller_locations, Warning.warn with category:, Prism).

  • Retention. Binding objects, Method objects with a bound receiver, Proc#binding and a TracePoint argument kept after the hook returns all hold on to their scope or receiver; collect values, not the reflective objects.

Where older books and tutorials are outdated

Older statement Current behaviour

Ruby parses and executes a program "line by line".

The whole file is parsed first (with Prism since 3.4), compiled to YARV bytecode, and then executed, possibly by YJIT (3.2) or ZJIT (4.0, experimental). RubyVM::InstructionSequence shows the bytecode and Prism the tree.

Use set_trace_func to trace execution.

Use TracePoint (since 2.0): named events, filtered by event, target, thread, with attributes instead of six positional arguments. set_trace_func is a compatibility API.

Find subclasses with `ObjectSpace.each_object(Class).select {

c

c < Base }`.

Class#subclasses (3.1) is direct, faster and excludes singleton classes. A descendants method does not exist in 4.0.

caller returns strings that you split on colons.

caller_locations (and Thread.each_caller_location, 3.2) return objects with path, lineno, label and base_label. The string format changed in 3.4 and 4.0.

ObjectSpace._id2ref(id) finds an object by id.

Deprecated in 4.0 (Feature #15408); a deprecation warning appears under -w.

Method#public?, private? and protected? tell the visibility of a method object.

Added in 3.1, removed in 3.2. Use public_method_defined? and the related predicates on the module.

Ripper is the way to parse Ruby from Ruby.

Prism is the official parser API (default gem since 3.3, default parser since 3.4). Ripper still works and Prism provides a Ripper translation.

RubyVM::AbstractSyntaxTree.of(method) returns the tree of a method.

It works only for methods compiled by the legacy parser; with Prism as the default it raises RuntimeError unless Ruby runs with --parser=parse.y. Use Prism on the file instead.

Hash#inspect and Method#inspect formats such as {:a=>1} and #<Method: String#upcase>.

Hashes print {a: 1} since 3.4; Method#inspect includes the parameters and the location (#<Method: Shelf::Loan#describe() loan.rb:17>), and UnboundMethod#inspect no longer names the class it was fetched from (3.2).

Warnings are all-or-nothing, controlled by $VERBOSE or -w.

Categories (:deprecated, :experimental, :performance, :strict_unused_block) can be switched one by one with -W:category or Warning[]=, listed with Warning.categories (3.4), and sent to a hook with Warning.warn(message, category:).

Coverage measures only lines.

It has lines, branches, methods, oneshot_lines and eval modes, and can be suspended and resumed (3.1).

ObjectSpace::WeakMap is the only weak container.

ObjectSpace::WeakKeyMap (3.3) holds keys weakly, compares them with eql? and keeps values strongly; WeakMap#delete exists since 3.3.

Common exceptions and warnings

The messages below are the real ones from Ruby 4.0.7.

# frozen_string_literal: true

require "coverage"
require "prism"
require "shelf"

def attempt(label)
  yield
  puts "#{label}: no error"
rescue StandardError, ScriptError => e
  puts "#{label}\n  #{e.class}: #{e.message.lines.first.chomp}"
end

loan = Shelf::Loan.new(nil)

attempt("instance_variable_get without @") { loan.instance_variable_get("state") }
attempt("instance_variable_set on frozen") { Shelf::Isbn.new("978-0-306-40615-7").instance_variable_set(:@x, 1) }
attempt("method with a missing name") { loan.method(:nope) }
attempt("instance_method with a missing name") { Shelf::Loan.instance_method(:nope) }
attempt("public_instance_method on private") { Shelf::Loan.public_instance_method(:initialize) }
attempt("bind to the wrong class") { Shelf::Loan.instance_method(:describe).bind(Object.new) }
attempt("const_get missing constant") { Shelf.const_get(:Nope) }
attempt("const_get with a bad name") { Shelf.const_get("lower") }
attempt("attached_object on a class") { Shelf::Loan.attached_object }
attempt("TracePoint with a bad event") { TracePoint.new(:nope) { nil } }
attempt("TracePoint without a block") { TracePoint.new(:call) }
attempt("TracePoint attribute misuse") do
  TracePoint.new(:call, &:return_value).enable { Shelf::Catalog.new }
end
attempt("TracePoint attribute outside hook") do
  saved = nil
  TracePoint.new(:call) { saved = it }.enable { Shelf::Catalog.new }
  saved.lineno
end
attempt("Coverage.result when not running") { Coverage.result }
attempt("Warning with an unknown category") { Warning[:nope] }
attempt("AST.of a Prism-compiled method") { RubyVM::AbstractSyntaxTree.of(loan.method(:describe)) }
attempt("compile broken source") { RubyVM::InstructionSequence.compile("def (") }
attempt("Prism with an unknown version") { Prism.parse("1", version: "2.6") }
instance_variable_get without @
  NameError: 'state' is not allowed as an instance variable name
instance_variable_set on frozen
  FrozenError: can't modify frozen Shelf::Isbn: #<data Shelf::Isbn digits="9780306406157">
method with a missing name
  NameError: undefined method 'nope' for class 'Shelf::Loan'
instance_method with a missing name
  NameError: undefined method 'nope' for class 'Shelf::Loan'
public_instance_method on private
  NameError: method 'initialize' for class 'Shelf::Loan' is private
bind to the wrong class
  TypeError: bind argument must be an instance of Shelf::Loan
const_get missing constant
  NameError: uninitialized constant Shelf::Nope
const_get with a bad name
  NameError: wrong constant name lower
attached_object on a class
  TypeError: 'Shelf::Loan' is not a singleton class
TracePoint with a bad event
  ArgumentError: unknown event: nope
TracePoint without a block
  ArgumentError: must be called with a block
TracePoint attribute misuse
  RuntimeError: not supported by this event
TracePoint attribute outside hook
  RuntimeError: access from outside
Coverage.result when not running
  RuntimeError: coverage measurement is not enabled
Warning with an unknown category
  ArgumentError: unknown category: nope
AST.of a Prism-compiled method
  RuntimeError: cannot get AST for ISEQ compiled by prism
compile broken source
  SyntaxError: <compiled>:1: syntax errors found
Prism with an unknown version
  ArgumentError: invalid version: 2.6
Exception or warning Typical cause

NameError ("undefined method 'x' for class 'Foo'")

method, instance_method or public_instance_method for a name that is not defined, or defined as private. NameError#name and NameError#receiver tell you which.

NameError ("'state' is not allowed as an instance variable name")

instance_variable_get("state") without the @.

NameError ("uninitialized constant Shelf::Nope", "wrong constant name lower")

const_get with a missing constant, or with a name that is not capitalized.

NameError ("local variable 'zz' is not defined for #<Binding:…​>")

Binding#local_variable_get for a variable that is not in scope; also "implicit parameter 'it' is not defined" when the block does not use it.

TypeError ("bind argument must be an instance of Shelf::Loan")

UnboundMethod#bind with an object of the wrong class (use bind_call carefully for the same reason).

TypeError ("'Shelf::Loan' is not a singleton class")

Class#attached_object on an ordinary class.

FrozenError

instance_variable_set or remove_instance_variable on a frozen object, such as every Data instance.

ArgumentError ("unknown event: nope", "must be called with a block", "target_line is specified, but line event is not specified")

Mistakes in TracePoint.new and enable.

RuntimeError ("not supported by this event")

A TracePoint attribute that the current event does not have, such as return_value on :call.

RuntimeError ("access from outside")

Reading a TracePoint argument after the hook has returned.

RuntimeError ("coverage measurement is not enabled", "coverage measurement is already setup")

Coverage.result or peek_result with nothing running; Coverage.start after setup.

ArgumentError ("unknown category: nope")

Warning[:nope] or warn "...", category: :nope.

RuntimeError ("cannot get AST for ISEQ compiled by prism")

RubyVM::AbstractSyntaxTree.of on a method in Ruby 3.4 or later.

ArgumentError ("invalid version: 2.6")

Prism.parse with a version: that Prism does not know; the accepted values depend on the Prism version.

SyntaxError

RubyVM::InstructionSequence.compile or Kernel#eval with source that does not parse.

warning: ObjectSpace._id2ref is deprecated

_id2ref in Ruby 4.0 with deprecation warnings enabled.

Pitfalls

  • obj.methods(false) is not "the methods the class defines"; it is the singleton methods. Use obj.class.instance_methods(false) for the class’s own, or public_methods(false) for singleton plus class.

  • instance_methods hides private methods, so a class can look empty (initialize is private). Ask for private_instance_methods(false) as well, or respond_to?(name, true).

  • Comparing Method#owner with receiver.class is wrong for inherited and mixed-in methods: Catalog.new.method(:map).owner is Enumerable.

  • Method#source_location is nil for C methods and const_source_location is [] for constants defined in C; check before indexing. For attr_* and define_method it points at the generating call, not at a def.

  • method_missing without respond_to_missing? breaks respond_to?, method and anything built on them.

  • ObjectSpace.each_object(Class) includes singleton classes. Counting objects with it is also affected by garbage that has not yet been collected.

  • A TracePoint block runs for every matching event in every file, including gems. Filter first and narrow the scope. The hook cannot trace code it calls itself unless you use allow_reentry.

  • A TracePoint attribute used on the wrong event raises; a stored TracePoint argument raises later. binding is nil for C events.

  • TracePoint#enable { } is scoped to the current thread only. Use target_thread: nil for others.

  • Coverage.start after require measures nothing for the files already loaded. Start it first, at the top of the test helper or the script that loads the code.

  • $VERBOSE = nil turns off warn entirely, even your own deprecation messages. Prefer Warning[:deprecated] = false when you only want to silence one category.

  • RubyVM::AbstractSyntaxTree.of raises with Prism-compiled code (3.4+). Do not build tools on it.

  • Parsing a backtrace or a warning message with a regexp breaks on the next Ruby upgrade: 3.4 changed the quoting and added owners, 4.0 changed internal frames and the ArgumentError text.

  • binding and eval run code that was never written in the file you are reading. Do not pass them input from outside.

Version notes

Version Change that concerns reflection and tracing

3.0

Warning.warn accepts category:; deprecation warnings stay off by default; reading an unset instance variable no longer warns; Fiber#backtrace.

3.1

Class#subclasses; Coverage.suspend and resume; TracePoint.allow_reentry; Thread::Backtrace.limit; Method#public? and relatives added (removed again in 3.2).

3.2

Class#attached_object; Module#refinements, Module#undefined_instance_methods and Module#const_added; Thread.each_caller_location; Proc#parameters(lambda:); Coverage.supported? and Coverage.setup(eval: true); TracePoint#enable defaults to the current thread inside a block; TracePoint#binding is nil for C events; RubyVM::AbstractSyntaxTree gains error_tolerant: and keep_tokens:; UnboundMethod#== and a shorter inspect.

3.3

Module#set_temporary_name; ObjectSpace::WeakKeyMap and WeakMap#delete; the :rescue TracePoint event; the :performance warning category; Prism added as a default gem.

3.4

Prism is the default parser; backtraces and error messages use apostrophes and owner names, and drop rescue/ensure frames; Warning.categories and the :strict_unused_block category; Exception#set_backtrace accepts locations; RubyVM::AbstractSyntaxTree::Node#locations; the top-level ::Ruby constant is reserved; AbstractSyntaxTree.of fails for Prism-compiled code.

4.0

Binding#implicit_parameters, implicit_parameter_get and implicit_parameter_defined?; Kernel#inspect honours instance_variables_to_inspect; ObjectSpace._id2ref deprecated; backtraces hide internal frames and ArgumentError shows caller and callee snippets; YJIT statistics gain invalidate_everything; TracePoints work after GC (bug fix); Prism 1.7.0 (1.8.1 in 4.0.7).

4.1 (preview)

Not released as of 2026-10-18. This page makes no claim about it.

References