Skip to content

Commit

Permalink
Support native integration with ruby/debug
Browse files Browse the repository at this point in the history
  • Loading branch information
st0012 committed May 25, 2023
1 parent fd49135 commit 2ea619c
Show file tree
Hide file tree
Showing 8 changed files with 395 additions and 97 deletions.
48 changes: 46 additions & 2 deletions lib/irb.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

require_relative "irb/version"
require_relative "irb/easter-egg"
require_relative "irb/debug"

# IRB stands for "interactive Ruby" and is a tool to interactively execute Ruby
# expressions read from the standard input.
Expand Down Expand Up @@ -374,6 +375,41 @@ class Abort < Exception;end

@CONF = {}

def self.debug_readline(binding:, scanner:, prompt_name:)
workspace = IRB::WorkSpace.new(binding)

if @irb
@irb.context.workspace = workspace
else
@irb = IRB::Irb.new(workspace)
conf[:IRB_RC].call(@irb.context) if conf[:IRB_RC]
conf[:MAIN_CONTEXT] = @irb.context
@irb.scanner = scanner
# When users run debugging commands, the original IRB session would be interrupted in the middle, which will cause
# that command line increase not being recorded.
# So we need to compensate that from here.
@irb.scanner.increase_line_no(1)
end

@irb.context.irb_name = prompt_name
@irb.context.with_debugger = true

# When users run:
# 1. Debugging commands
# 2. Any input that's not irb-command, like `foo = 123`
#
# :IRB_EXIT will be thrown with the input and we will just pass it to the debugger
input = catch(:IRB_EXIT) do
@irb.eval_input
end

if input
line_addition = input.include?("\n") ? input.count("\n") : 1
@irb.scanner.increase_line_no(line_addition)
end

input
end

# Displays current configuration.
#
Expand Down Expand Up @@ -915,7 +951,6 @@ def @CONF.inspect
array.join("\n")
end
end

class Binding
# Opens an IRB session where +binding.irb+ is called which allows for
# interactive debugging. You can call any methods or variables available in
Expand Down Expand Up @@ -980,7 +1015,16 @@ def irb(show_code: true)
STDOUT.print(workspace.code_around_binding) if show_code
binding_irb = IRB::Irb.new(workspace)
binding_irb.context.irb_path = File.expand_path(source_location[0])
binding_irb.run(IRB.conf)

if defined?(DEBUGGER__::SESSION)
# If we've started a debugger session and hit another binding.irb, we don't want to start an IRB session
# instead, we want to resume the debugger session.
IRB::Debug.setup
IRB::Debug.insert_debug_break
else
binding_irb.run(IRB.conf)
end

binding_irb.debug_break
end
end
92 changes: 12 additions & 80 deletions lib/irb/cmd/debug.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require_relative "nop"
require_relative "../debug"

module IRB
# :stopdoc:
Expand All @@ -12,35 +13,32 @@ class Debug < Nop
'<internal:prelude>',
binding.method(:irb).source_location.first,
].map { |file| /\A#{Regexp.escape(file)}:\d+:in `irb'\z/ }
IRB_DIR = File.expand_path('..', __dir__)

def execute(pre_cmds: nil, do_cmds: nil)
if defined?(DEBUGGER__::SESSION)
if cmd = pre_cmds || do_cmds
throw :IRB_EXIT, cmd
else
puts "IRB is already running with a debug session."
return
end
end

unless binding_irb?
puts "`debug` command is only available when IRB is started with binding.irb"
return
end

unless setup_debugger
unless IRB::Debug.setup
puts <<~MSG
You need to install the debug gem before using this command.
If you use `bundle exec`, please add `gem "debug"` into your Gemfile.
MSG
return
end

options = { oneshot: true, hook_call: false }
if pre_cmds || do_cmds
options[:command] = ['irb', pre_cmds, do_cmds]
end
if DEBUGGER__::LineBreakpoint.instance_method(:initialize).parameters.include?([:key, :skip_src])
options[:skip_src] = true
end
IRB::Debug.insert_debug_break(pre_cmds: pre_cmds, do_cmds: do_cmds)

# To make debugger commands like `next` or `continue` work without asking
# the user to quit IRB after that, we need to exit IRB first and then hit
# a TracePoint on #debug_break.
file, lineno = IRB::Irb.instance_method(:debug_break).source_location
DEBUGGER__::SESSION.add_line_breakpoint(file, lineno + 1, **options)
# exit current Irb#run call
throw :IRB_EXIT
end
Expand All @@ -54,72 +52,6 @@ def binding_irb?
end
end
end

module SkipPathHelperForIRB
def skip_internal_path?(path)
# The latter can be removed once https://github.com/ruby/debug/issues/866 is resolved
super || path.match?(IRB_DIR) || path.match?('<internal:prelude>')
end
end

def setup_debugger
unless defined?(DEBUGGER__::SESSION)
begin
require "debug/session"
rescue LoadError # debug.gem is not written in Gemfile
return false unless load_bundled_debug_gem
end
DEBUGGER__.start(nonstop: true)
end

unless DEBUGGER__.respond_to?(:capture_frames_without_irb)
DEBUGGER__.singleton_class.send(:alias_method, :capture_frames_without_irb, :capture_frames)

def DEBUGGER__.capture_frames(*args)
frames = capture_frames_without_irb(*args)
frames.reject! do |frame|
frame.realpath&.start_with?(IRB_DIR) || frame.path == "<internal:prelude>"
end
frames
end

DEBUGGER__::ThreadClient.prepend(SkipPathHelperForIRB)
end

true
end

# This is used when debug.gem is not written in Gemfile. Even if it's not
# installed by `bundle install`, debug.gem is installed by default because
# it's a bundled gem. This method tries to activate and load that.
def load_bundled_debug_gem
# Discover latest debug.gem under GEM_PATH
debug_gem = Gem.paths.path.flat_map { |path| Dir.glob("#{path}/gems/debug-*") }.select do |path|
File.basename(path).match?(/\Adebug-\d+\.\d+\.\d+(\w+)?\z/)
end.sort_by do |path|
Gem::Version.new(File.basename(path).delete_prefix('debug-'))
end.last
return false unless debug_gem

# Discover debug/debug.so under extensions for Ruby 3.2+
ext_name = "/debug/debug.#{RbConfig::CONFIG['DLEXT']}"
ext_path = Gem.paths.path.flat_map do |path|
Dir.glob("#{path}/extensions/**/#{File.basename(debug_gem)}#{ext_name}")
end.first

# Attempt to forcibly load the bundled gem
if ext_path
$LOAD_PATH << ext_path.delete_suffix(ext_name)
end
$LOAD_PATH << "#{debug_gem}/lib"
begin
require "debug/session"
puts "Loaded #{File.basename(debug_gem)}"
true
rescue LoadError
false
end
end
end

class DebugCommand < Debug
Expand Down
19 changes: 18 additions & 1 deletion lib/irb/context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,8 @@ def main
# User-defined IRB command aliases
attr_accessor :command_aliases

attr_accessor :with_debugger

# Alias for #use_multiline
alias use_multiline? use_multiline
# Alias for #use_singleline
Expand Down Expand Up @@ -474,6 +476,8 @@ def inspect_mode=(opt)
end

def evaluate(line, line_no, exception: nil) # :nodoc:
original_line = line

@line_no = line_no
if exception
line_no -= 1
Expand All @@ -490,11 +494,24 @@ def evaluate(line, line_no, exception: nil) # :nodoc:

# Hook command-specific transformation
command_class = ExtendCommandBundle.load_command(command)

# If the debugger is activated, we can directly pass debugging command's input
if with_debugger && command_class&.ancestors&.include?(ExtendCommand::DebugCommand)
throw :IRB_EXIT, original_line
end

if command_class&.respond_to?(:transform_args)
line = "#{command} #{command_class.transform_args(args)}"
end

set_last_value(@workspace.evaluate(line, irb_path, line_no))
# Use debugger to evaluate non-command input
# Otherwise, the expression will be evaluated in the debugger's main session thread
# This is the only way to run the user's program in the expected thread
if with_debugger && !command_class
throw :IRB_EXIT, line
else
set_last_value(@workspace.evaluate(line, irb_path, line_no))
end
end

def inspect_last_value # :nodoc:
Expand Down
109 changes: 109 additions & 0 deletions lib/irb/debug.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
module IRB
module Debug
BINDING_IRB_FRAME_REGEXPS = [
'<internal:prelude>',
binding.method(:irb).source_location.first,
].map { |file| /\A#{Regexp.escape(file)}:\d+:in `irb'\z/ }
IRB_DIR = File.expand_path('..', __dir__)

class << self
def insert_debug_break(pre_cmds: nil, do_cmds: nil)
options = { oneshot: true, hook_call: false }

if pre_cmds || do_cmds
options[:command] = ['irb', pre_cmds, do_cmds]
end
if DEBUGGER__::LineBreakpoint.instance_method(:initialize).parameters.include?([:key, :skip_src])
options[:skip_src] = true
end

# To make debugger commands like `next` or `continue` work without asking
# the user to quit IRB after that, we need to exit IRB first and then hit
# a TracePoint on #debug_break.
file, lineno = IRB::Irb.instance_method(:debug_break).source_location
DEBUGGER__::SESSION.add_line_breakpoint(file, lineno + 1, **options)
end

def setup
unless defined?(DEBUGGER__::SESSION)
begin
require "debug/session"
rescue LoadError # debug.gem is not written in Gemfile
return false unless load_bundled_debug_gem
end
require 'irb/debug/ui'
DEBUGGER__::CONFIG.set_config
# We want to remember the program's main thread, so we can use it to get its status/context through debugger later
thread = Thread.current
scanner = IRB.conf[:MAIN_CONTEXT].irb.scanner
DEBUGGER__.initialize_session{ IRB::Debug::UI.new(thread, scanner) }
end

unless DEBUGGER__.respond_to?(:capture_frames_without_irb)
DEBUGGER__.singleton_class.send(:alias_method, :capture_frames_without_irb, :capture_frames)

def DEBUGGER__.capture_frames(*args)
frames = capture_frames_without_irb(*args)
frames.reject! do |frame|
frame.realpath&.start_with?(IRB_DIR) || frame.path == "<internal:prelude>"
end
frames
end

DEBUGGER__::ThreadClient.prepend(SkipPathHelperForIRB)
end

true
end

private

def binding_irb?
caller.any? do |frame|
BINDING_IRB_FRAME_REGEXPS.any? do |regexp|
frame.match?(regexp)
end
end
end

module SkipPathHelperForIRB
def skip_internal_path?(path)
# The latter can be removed once https://github.com/ruby/debug/issues/866 is resolved
super || path.match?(IRB_DIR) || path.match?('<internal:prelude>')
end
end

# This is used when debug.gem is not written in Gemfile. Even if it's not
# installed by `bundle install`, debug.gem is installed by default because
# it's a bundled gem. This method tries to activate and load that.
def load_bundled_debug_gem
# Discover latest debug.gem under GEM_PATH
debug_gem = Gem.paths.path.flat_map { |path| Dir.glob("#{path}/gems/debug-*") }.select do |path|
File.basename(path).match?(/\Adebug-\d+\.\d+\.\d+(\w+)?\z/)
end.sort_by do |path|
Gem::Version.new(File.basename(path).delete_prefix('debug-'))
end.last
return false unless debug_gem

# Discover debug/debug.so under extensions for Ruby 3.2+
ext_name = "/debug/debug.#{RbConfig::CONFIG['DLEXT']}"
ext_path = Gem.paths.path.flat_map do |path|
Dir.glob("#{path}/extensions/**/#{File.basename(debug_gem)}#{ext_name}")
end.first

# Attempt to forcibly load the bundled gem
if ext_path
$LOAD_PATH << ext_path.delete_suffix(ext_name)
end
$LOAD_PATH << "#{debug_gem}/lib"
begin
require "debug/session"
puts "Loaded #{File.basename(debug_gem)}"
true
rescue LoadError
false
end
end
end
end
end

0 comments on commit 2ea619c

Please sign in to comment.