The letter A styled as Alchemists logo. lchemists
Published February 1, 2025 Updated May 6, 2025
Cover
Ruby Source Parsing

One powerful tool in your debugging arsenal is the ability to quickly obtain an object’s source location and/or the original source code itself. This journey begins by using the #source_location method as found via these objects:

Typically, when using Method for example, you can obtain the source location by getting an instance of your method and asking for location information as shown below:

module Demo
  module_function

  def echo(text = "hi") = puts text
end

Demo.method(:echo).source_location
# ["/$HOME/Engineering/Misc/demo", 18]

The above tells us that the source code of the #echo method can be found in the "/$HOME/Engineering/Misc/demo.rb" file starting on Line 18. When used with a text editor, like Sublime Text, we can open our editor to the exact line as follows:

sublime /$HOME/Engineering/Misc/demo.rb:18

If using IRB Kit, we can use the esource helper to similar effect:

esource Demo, :echo

This power is made possible via the #source_location method so let’s learn more about what we can do with this information by expanding upon the above and also learn how we can dynamically acquire source code at runtime.

Parsing

There are two ways to obtain the source code of a method:

  • Disk: As shown above, disk location answers a tuple which consists of the file path and the starting line number.

  • Memory: When source only exists in memory, you’ll get a tuple as well but, instead of a file path, you’ll only get the name. In the case of IRB, this’ll be: "(irb)". Additionally, you’ll also get the starting line in IRB. When put together, the resulting tuple can look like this: ["(irb)", 5].

With the above in mind, let’s study how to obtain source code from disk and/or memory because this opens up opportunity for advanced debugging, building additional tooling, and/or using the source for dynamic evaluation at runtime. One way we can do this is by combining the information we get from #source_location with the RubyVM to acquire the full source.

⚠️ Please be aware of the following:

This module is for very limited purposes, such as debugging, prototyping, and research. Normal users must not use it. This module is not portable between Ruby implementations.

— Ruby Documentation

With the above in mind, let’s see how the RubyVM can help us.

Disk

Prior to Ruby 3.4.0, we could obtain source code via the following:

function = proc { Object.new }
ast = RubyVM::AbstractSyntaxTree.of function

ast.children.last.source
# Result: "Object.new"

This was succinct since you could obtain the source code (body) of the Proc with minimal effort. Unfortunately, the above isn’t possible in Ruby 3.4.0 and higher due to Prism being the default parser (instead of parse.y) so, instead, we can use RubyVM::InstructionSequence.of as a workaround. Example:

# The source we want to obtain.
function = proc { Object.new }

# The instruction sequence of our function.
instructions = RubyVM::InstructionSequence.of function

# The absolute file path to where our function is defined.
# (this is the rough equivalent of `#source_location`)
path = instructions.absolute_path

# Line and column info via the fourth node and code location of the instructions.
line_start, column_start, line_end, column_end = instructions.to_a.dig 4, :code_location

# The source code lines as extracted from line start and end information.
lines = File.read(path).lines[(line_start - 1)..(line_end - 1)]

# Mutated first and last lines with only characters between start and end columns.
# Care is given to UTF-8 multibyte characters (if any) using byteslice.
lines[-1] = lines.last.byteslice(...column_end)
lines[0] = lines.first.byteslice(column_start..)

# Lastly, all lines are joined as a single string.
lines.join
# Result: "{ Object.new }"

Granted, the result of "{ Object.new }" is not identical to "Object.new" in the first result but is close enough despite the fact you’ll have to remove the brackets. In the case of multi-line function, (i.e. do...end) you’ll have additional work to do in order to extract the source code but you get the idea.

Memory

In situations in which you need to dynamically obtain the source code of a function, method, etc. while in memory (like IRB), then the above disk solution won’t work. Instead, you can use the following:

# The source we want to obtain.
function = proc { Object.new }

# The instruction sequence of our function.
instructions = RubyVM::InstructionSequence.of function

# Use script lines to obtain the source code.
instructions.script_lines.join.chomp
# Result: "function = proc { Object.new }"

While the above implementation is less lines of code than the disk implementation, the result is not the same because we’re getting the entire line in which the function was defined so this means you have additional work to do in order extract and obtain the "Object.new" body of the function.

In case this helps — and in order to contrast/compare single and multiple line behavior — here’s the same solution showing what a multi-line Proc resolves to:

function = proc do
  Object.new
end

instructions = RubyVM::InstructionSequence.of function
instructions.script_lines.join.chomp

# Result: "function = proc do\n  Object.new\nend"

The same result, roughly, applies when obtaining multi-line source from disk.

A Path Forward

The ability to obtain source via the Ruby VM is definitely handy but, remember, the Ruby Core team does not recommend using the Ruby VM in production code. So a question remains: Is there a better way forward?

The good news is yes, there is, but you’ll need to wait until Feature 21005 is resolved. I requested this feature because I’d like a better way to dynamically obtain source code without going through the Ruby VM.

This brings us back to the #source_location method. The goal is, through further enhancement, that we get line start/stop and column start/stop information. This means, you could do the following instead of reaching for the Ruby VM:

function = proc { Object.new }
function.source_location

# Result:
# [
#   "$HOME/Engineering/Misc/demo",  # Source path.
#   15,                             # Line start.
#   15,                             # Line stop.
#   0,                              # Column start.
#   29                              # Column stop.
# ]

Much better and you’d get "(irb)" when in IRB instead of file path but the line start/stop and column start/stop information would be there. I had hoped that, instead of any array, we’d get a hash. Example:

{
  path: "/Users/bkuhlmann/Engineering/Misc/demo",
  line_start: 15,
  line_stop: 15,
  column_start: 0,
  column_stop: 29
}

Unfortunately, the above would break backwards compatibility despite being easier to read and parse. Still, progress!

Gems

In case you’d like an immediate solution, there are two gems I maintain which leverage the Ruby VM to extract source code for you. The goal, once Feature 21005 lands, is to refactor both gems to use the enhanced #source_location method instead.

Marameters

The Marameters gem is a low-level gem which provides the foundational building blocks for specialized method solutions. That said — and apologies since this isn’t provided via the gem’s documentation as of yet — is to use the Any object which will obtain source code from disk or memory. Here’s an example using a Bundler Inline script:

#! /usr/bin/env ruby
# frozen_string_literal: true

# Save as `demo`, then `chmod 755 demo`, and run as `./demo`.

require "bundler/inline"

gemfile true do
  source "https://rubygems.org"

  gem "amazing_print"
  gem "debug"
  gem "marameters"
end

function = proc { Object.new }
reader = Marameters::Sourcers::Readers::Any.new

puts reader.call(function)
# Result: "{ Object.new }"

This’ll work regardless of the function being sourced from disk or memory. If you want to get fancy — and only care about procs and/or lambdas — you can use the following which properly extracts the function’s body:

function = proc { Object.new }
sourcer = Marameters::Sourcers::Function.new

puts sourcer.call(function)
# Result: "Object.new"

Initable

The Initable gem builds upon Marameters by allowing you to use a Proc as a default value so the body of the Proc can be evaluated at runtime upon initialization. Here’s an example using a Bundler Inline script:

#! /usr/bin/env ruby
# frozen_string_literal: true

# Save as `demo`, then `chmod 755 demo`, and run as `./demo`.

require "bundler/inline"

gemfile true do
  source "https://rubygems.org"

  gem "amazing_print"
  gem "cogger"
  gem "debug"
  gem "initable"
end

class Person
  include Initable[%i[req name], %i[keyreq age], [:key, :logger, proc { Cogger.new }]]

  def info = logger.info { "#{name} is #{age} years old." }
end

person = Person.new "Jill Smith", age: 40
person.info

# 🟢 [demo] Jill Smith is 40 years old.

The above builds upon everything discussed thus far by extracting the Cogger.new source code from the Proc at initialization so a Cogger instance can be instantiated.

Not bad for only a few lines of code!

Conclusion

You now know how to acquire the source code of an object either from disk or memory using the #source_location method and the RubyVM. Even better, you can leverage the Marameters and Initable gems to make use of this functionality today with the goal, once Feature 21005 lands, to only rely on the enriched #source_location information without using the RubyVM at all. Enjoy!