RubyMine

Mastering Ruby Debugging: From puts to Professional Tools

Read this post in other languages:

Hello, Ruby developers!

Debugging is an essential skill in software development, and in this post, we’ll be looking at how to investigate the behavior of Ruby code. As the RubyMine team, we’ve accumulated considerable expertise in creating tools for Ruby developers, and we’re excited to share our experience and knowledge with you.

Recently, at the EuRuKo 2024 conference, our team member Dmitry Pogrebnoy presented the Demystifying Debuggers talk. This blog post is the first in a series based on that presentation, aiming to provide you with valuable insights into debugging Ruby applications.

Every Ruby programmer inevitably encounters situations where their code doesn’t behave as expected. In these moments, we all wish we had an efficient way to pinpoint the problem and fix it quickly. That’s where debugging tools come into play.

In this post, we’ll explore various tools and approaches available to Ruby developers for investigating bugs. We’ll cover several classes of tools, each with its own strengths and weaknesses. Understanding the specifics of each tool will help you choose the most effective one for your particular debugging scenario.

To make our discussion more concrete, we’ll start with a real-world example of a bug we encountered in one of our internal Ruby projects. This case study will illustrate the importance of proper debugging techniques and set the stage for our exploration of debugging tools.

Whether you’re a seasoned Ruby developer or just starting out, this guide will help you sharpen your debugging skills and tackle bugs more efficiently. Let’s get started!

A real bug case from the RubyMine team

In the RubyMine team, our development efforts extend beyond the IDE itself. We’ve created several proprietary gems that enhance the IDE’s functionality. To share some insights, we’ll explore a real-world bug we encountered in one of these gems about a year ago. We’ve isolated and simplified the code sample to focus on the core issue.

Consider the following Ruby code:

def process(thing)
 if defined? thing.to_s || defined? thing.inspect
   puts "Element is Printable"
 else
   puts "Element is Not Printable"
 end
end

process(5)               # -> Element is Printable
process(BasicObject.new) # -> Element is Printable

At first glance, this process method seems straightforward. It aims to check whether the given argument has either a to_s or an inspect method. If either method exists, process should print “Element is Printable”; otherwise, it prints “Element is Not Printable”.

At the bottom, you can see two calls of this method with their outputs. The first call process(5) produces the message “Element is Printable”. This is correct. But the second call process(BasicObject.new) looks suspicious. It takes BasicObject as an argument, but prints “Element is Printable”. This is incorrect because the BasicObject instance does not respond to either of the methods we are looking for. So apparently this code contains a bug.

Let’s take a moment to examine the process method. Can you spot the bug?

Spoiler – click to expand!

The bug lies in the if condition:

defined? thing.to_s || defined? thing.inspect

Due to Ruby’s operator precedence, the interpreter actually evaluates this as:

defined?(thing.to_s || defined?(thing.inspect))

This expression always returns “expression”, regardless of whether thing responds to to_s or inspect. As a result, the condition is always true, and our method incorrectly classifies every object as printable.

The fix is simple but illustrative of how small syntax errors can lead to significant logical flaws. We need to explicitly structure our conditions using parentheses:

def process(thing)
 if defined?(thing.to_s) || defined?(thing.inspect)
   puts "Element is Printable"
 else
   puts "Element is Not Printable"
 end
end

process(5)               # -> Element is Printable
process(BasicObject.new) # -> Element is Not Printable

With this correction, our method now accurately distinguishes between objects that implement to_s or inspect and those that don’t.

By sharing this real-world example, we hope to demonstrate that debugging is a crucial skill for all developers, regardless of experience level. It’s not just about fixing errors; it’s about understanding the intricacies of the language and writing more reliable code.

In more complex, production-level applications, such issues can be far more challenging to identify and resolve. This underscores the importance of robust debugging tools and techniques, which we’ll explore in the following sections.

Choosing the right tool

When it comes to debugging Ruby code, developers have several tools and approaches at their disposal. Let’s explore these options, starting with the basics and then moving on to more advanced techniques.

puts statements

The most basic debugging technique, requiring no setup or additional gems, is using puts statements. This method involves inserting print statements directly into your code to output variable values or execution flow information. While simple, it can be surprisingly effective for quick investigations.

Let’s apply this technique to our earlier example:

def process(thing)
 puts "defined? thing.to_s: #{defined? thing.to_s}"
 puts "defined? thing.inspect: #{defined? thing.inspect}"
 puts "defined? thing.to_s || defined? thing.inspect: #{
   defined? thing.to_s || defined? thing.inspect
 }"
 if defined? thing.to_s || defined? thing.inspect
   puts "Element is Printable"
 else
   puts "Element is Not Printable"
 end
end

process(5)
process(BasicObject.new)

This yields the following output:

defined? thing.to_s: method
defined? thing.inspect: method
defined? thing.to_s || defined? thing.inspect: expression
Element is Printable
defined? thing.to_s: 
defined? thing.inspect: 
defined? thing.to_s || defined? thing.inspect: expression
Element is Printable

The inconsistent output from these two methods calls with different arguments hints at where the problem might lie. We can see that, for BasicObject.new, both thing.to_s and thing.inspect are undefined, yet the condition still evaluates to true.

While basic puts statements are useful, several gems can make them more informative:

1. puts_debuggerer gem enhances puts output with the file name, line number, and content of this line.

For example:

require 'puts_debuggerer'
pd "defined? thing.to_s: #{defined? thing.to_s}"

Output:

[PD] example_puts_debuggerer.rb:5 in Object.process
   > pd "defined? thing.to_s: #{defined? thing.to_s}"
  => "Debug print 1: method"

2. awesome_print and similar gems provide more structured and readable output, especially useful for complex objects.

Generally puts statements are useful and might effectively help you with simple cases or when other tools don’t work for some reason. However, puts statements are really basic. They require modifying your source code every time you need to adjust an existing message or add a new one. They are usually not convenient to use because you need to restart the program whenever you modify what you are printing. 

Pros and cons of debugging using puts

Pros:

  • Simple and quick to implement.
  • Works in any Ruby environment.
  • No additional tools or setup are required.

Cons:

  • Requires modifying source code.
  • Can clutter the code if overused.
  • Forces you to restart the program if you want to change what you print.
  • Limited information compared to more advanced tools.

While puts statements are invaluable for quick checks, they become less efficient for complex scenarios or when frequent changes are needed. In such cases, more advanced tools like interactive consoles or full-fledged debuggers offer greater flexibility and power.

Interactive consoles

Interactive consoles represent the next level in bug investigation tools for Ruby developers. The two primary options are IRB and Pry, both offering powerful introspection capabilities.

To utilize interactive consoles for debugging, you typically need to insert binding.irb or binding.pry calls into your source code. When the binding command is executed, an interactive console launches, providing access to the current context and the ability to execute arbitrary expressions in this context.

Let’s use IRB in our earlier example:

def process(thing)
 binding.irb
 if defined? thing.to_s || defined? thing.inspect
   puts "Element is Printable"
 else
   puts "Element is Not Printable"
 end
end

process(5) # -> Element is Printable
process(BasicObject.new) # -> Element is Printable

When the code hits the binding.irb line, we’ll enter an interactive session:

From: 5_example_define_irb.rb @ line 2 :

    1: def process(thing)
 => 2:   binding.irb
    3:   if defined? thing.to_s || defined? thing.inspect
    4:     puts "Element is Printable"
    5:   else
    6:     puts "Element is Not Printable"
    7:   end

irb(main):001> defined? thing.to_s
=> nil
irb(main):002> defined? thing.inspect
=> nil
irb(main):003> defined? thing.to_s || defined? thing.inspect
=> "expression"
irb(main):004> exit
Element is Printable

This interaction allows us to examine the behavior of the condition’s individual parts, helping to pinpoint the issue.

Pros and cons of debugging using interactive consoles

Pros:

  • More complex and flexible than puts statements.
  • Partially allows for on-the-fly investigation.
  • No need to predetermine all debugging output.

Cons:

  • Still requires source code modification.
  • Requires you to set predefined introspection points that cannot be changed at runtime.
  • Forces you to restart the program if you want to change introspection points.

While interactive consoles offer more power than simple puts statements, they still have limitations. For complex debugging scenarios or when fine-grained control over execution is needed, full-featured debuggers provide even more capabilities.

Debuggers

Debuggers represent the pinnacle of tools available for investigating bugs in Ruby code. They offer capabilities far beyond simple puts statements and interactive consoles, providing full control over program execution. This powerful feature set allows developers to:

  • Pause execution at a specified point using breakpoints.
  • Inspect and modify variables in real time.
  • Examine the call stack at every breakpoint.
  • Step through code line by line.
  • Evaluate expressions in the current context.

Let’s explore the three main debuggers for Ruby:

1. byebug gem

  • Default debugger for Ruby 2.5.X, Ruby 2.6.X, Rails 5, and Rails 6.
  • Comes with all the essential features you’d expect from a debugger like breakpoints, stepping, context, and stack introspection.
  • For Rails applications, it requires modification of the application source code. You usually need to place a special call in your code to start the debugger at a certain place.
  • Has noticeable performance overheads that make it less usable for complicated applications.

2. debug gem

  • Supports only Ruby versions starting from 2.7.
  • Has no performance overheads on supported Ruby versions.
  • For Rails applications, debug, similar to byebug, requires modification of the application source code.
  • Bundled with Ruby starting from version 3.1.

3. RubyMine debugger

  • Supports Ruby versions 2.3 and later – so almost all possible versions of Ruby your application could use.
  • Has no performance overheads on any of the supported versions of Ruby.
  • No need to modify the code to use the debugger.
  • Provides a user-friendly UI out of the box that streamlines debugging.

Despite its extensive feature set, debuggers might be difficult to use in some specific configurations. While debuggers are powerful, they’re most effective when combined with other debugging techniques. The choice of debugger often depends on your specific project and configuration requirements, Ruby version, and personal preferences.

Conclusion

Debugging in Ruby is both an art and a science, presenting challenges that can be overcome with the right tools. As we’ve explored in this post, Ruby developers have a rich toolkit at their disposal, ranging from simple puts statements to sophisticated debuggers.

Each debugging approach we’ve discussed has its strengths:

  • puts statements offer quick, straightforward insights, ideal for simple issues or when other tools are unavailable.
  • Interactive consoles like IRB and Pry provide a more dynamic environment, allowing for deep context introspection and complex expression evaluation.
  • Full-fledged debuggers, such as the byebug and debug gems, as well as the RubyMine debugger, offer comprehensive control over program execution, enabling developers to dissect even the most intricate bugs.

The journey from encountering an unexpected bug to pinpointing its exact cause often requires a combination of these tools, along with methodical investigation and sometimes a bit of creative problem-solving. By understanding the strengths and limitations of each debugging tool, you can select the most appropriate approach for each unique situation.

As the RubyMine team, we’re particularly interested in how our debugging tools serve the Ruby community. We encourage you to explore the RubyMine debugger and share your experiences in the comments below or create an issue in the issue tracker. Your fellow developers will surely appreciate your insight.

Looking ahead, our next post will delve deeper into the inner workings of debuggers. We’ll explore their internal mechanisms and even tackle an exciting challenge: creating a basic debugger from scratch. This exploration will enhance your understanding of debugging tools and provide deeper insights into Ruby’s internals.

Meanwhile, take advantage of the advanced debugger in RubyMine. Download the latest RubyMine version from our website or via the free Toolbox App.

Remember, effective debugging is more than just finding and fixing errors – it’s about understanding your code at a fundamental level. Each debugging session is an opportunity to learn, improve, and write more robust Ruby code.

Stay curious, keep exploring, and happy debugging!

The RubyMine team

image description