[ruby/irb] Add commands to start and use the debugger

(https://github.com/ruby/irb/pull/449)

* Seamlessly integrate a few debug commands

* Improve the break command support

* Utilize skip_src option if available

* Add step and delete commands

* Write end-to-end tests for each debugger command

* Add documentation

* Add backtrace, info, catch commands

https://github.com/ruby/irb/commit/976100c1c2
This commit is contained in:
Takashi Kokubun 2022-11-21 00:46:22 -08:00 committed by git
parent 65e31402ae
commit c9fbc779a6
17 changed files with 484 additions and 37 deletions

View File

@ -96,6 +96,8 @@ require_relative "irb/easter-egg"
# * Show the source code around binding.irb again.
# * debug
# * Start the debugger of debug.gem.
# * break, delete, next, step, continue, finish, backtrace, info, catch
# * Start the debugger of debug.gem and run the command on it.
#
# == Configuration
#
@ -470,10 +472,6 @@ module IRB
def initialize(workspace = nil, input_method = nil)
@context = Context.new(self, workspace, input_method)
@context.main.extend ExtendCommandBundle
@context.command_aliases.each do |alias_name, cmd_name|
next if @context.symbol_alias(alias_name)
@context.main.install_alias_method(alias_name, cmd_name)
end
@signal_status = :IN_IRB
@scanner = RubyLex.new
end

21
lib/irb/cmd/backtrace.rb Normal file
View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
require_relative "debug"
module IRB
# :stopdoc:
module ExtendCommand
class Backtrace < Debug
def self.transform_args(args)
args&.dump
end
def execute(*args)
super(pre_cmds: ["backtrace", *args].join(" "))
end
end
end
# :startdoc:
end

21
lib/irb/cmd/break.rb Normal file
View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
require_relative "debug"
module IRB
# :stopdoc:
module ExtendCommand
class Break < Debug
def self.transform_args(args)
args&.dump
end
def execute(args = nil)
super(pre_cmds: "break #{args}")
end
end
end
# :startdoc:
end

21
lib/irb/cmd/catch.rb Normal file
View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
require_relative "debug"
module IRB
# :stopdoc:
module ExtendCommand
class Catch < Debug
def self.transform_args(args)
args&.dump
end
def execute(*args)
super(pre_cmds: ["catch", *args].join(" "))
end
end
end
# :startdoc:
end

17
lib/irb/cmd/continue.rb Normal file
View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
require_relative "debug"
module IRB
# :stopdoc:
module ExtendCommand
class Continue < Debug
def execute(*args)
super(do_cmds: ["continue", *args].join(" "))
end
end
end
# :startdoc:
end

View File

@ -11,7 +11,7 @@ module IRB
].map { |file| /\A#{Regexp.escape(file)}:\d+:in `irb'\z/ }
IRB_DIR = File.expand_path('..', __dir__)
def execute(*args)
def execute(pre_cmds: nil, do_cmds: nil)
unless binding_irb?
puts "`debug` command is only available when IRB is started with binding.irb"
return
@ -25,11 +25,19 @@ module IRB
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
# 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, oneshot: true, hook_call: false)
DEBUGGER__::SESSION.add_line_breakpoint(file, lineno + 1, **options)
# exit current Irb#run call
throw :IRB_EXIT
end

17
lib/irb/cmd/delete.rb Normal file
View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
require_relative "debug"
module IRB
# :stopdoc:
module ExtendCommand
class Delete < Debug
def execute(*args)
super(pre_cmds: ["delete", *args].join(" "))
end
end
end
# :startdoc:
end

17
lib/irb/cmd/finish.rb Normal file
View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
require_relative "debug"
module IRB
# :stopdoc:
module ExtendCommand
class Finish < Debug
def execute(*args)
super(do_cmds: ["finish", *args].join(" "))
end
end
end
# :startdoc:
end

View File

@ -1,31 +1,18 @@
# frozen_string_literal: false
# frozen_string_literal: true
require_relative "nop"
require_relative "debug"
module IRB
# :stopdoc:
module ExtendCommand
class Info < Nop
def execute
Class.new {
def inspect
str = "Ruby version: #{RUBY_VERSION}\n"
str += "IRB version: #{IRB.version}\n"
str += "InputMethod: #{IRB.CurrentContext.io.inspect}\n"
str += ".irbrc path: #{IRB.rc_file}\n" if File.exist?(IRB.rc_file)
str += "RUBY_PLATFORM: #{RUBY_PLATFORM}\n"
str += "LANG env: #{ENV["LANG"]}\n" if ENV["LANG"] && !ENV["LANG"].empty?
str += "LC_ALL env: #{ENV["LC_ALL"]}\n" if ENV["LC_ALL"] && !ENV["LC_ALL"].empty?
str += "East Asian Ambiguous Width: #{Reline.ambiguous_width.inspect}\n"
if RbConfig::CONFIG['host_os'] =~ /mswin|msys|mingw|cygwin|bccwin|wince|emc/
codepage = `chcp`.b.sub(/.*: (\d+)\n/, '\1')
str += "Code page: #{codepage}\n"
class Info < Debug
def self.transform_args(args)
args&.dump
end
str
end
alias_method :to_s, :inspect
}.new
def execute(*args)
super(pre_cmds: ["info", *args].join(" "))
end
end
end

34
lib/irb/cmd/irb_info.rb Normal file
View File

@ -0,0 +1,34 @@
# frozen_string_literal: false
require_relative "nop"
module IRB
# :stopdoc:
module ExtendCommand
class IrbInfo < Nop
def execute
Class.new {
def inspect
str = "Ruby version: #{RUBY_VERSION}\n"
str += "IRB version: #{IRB.version}\n"
str += "InputMethod: #{IRB.CurrentContext.io.inspect}\n"
str += ".irbrc path: #{IRB.rc_file}\n" if File.exist?(IRB.rc_file)
str += "RUBY_PLATFORM: #{RUBY_PLATFORM}\n"
str += "LANG env: #{ENV["LANG"]}\n" if ENV["LANG"] && !ENV["LANG"].empty?
str += "LC_ALL env: #{ENV["LC_ALL"]}\n" if ENV["LC_ALL"] && !ENV["LC_ALL"].empty?
str += "East Asian Ambiguous Width: #{Reline.ambiguous_width.inspect}\n"
if RbConfig::CONFIG['host_os'] =~ /mswin|msys|mingw|cygwin|bccwin|wince|emc/
codepage = `chcp`.b.sub(/.*: (\d+)\n/, '\1')
str += "Code page: #{codepage}\n"
end
str
end
alias_method :to_s, :inspect
}.new
end
end
end
# :startdoc:
end

17
lib/irb/cmd/next.rb Normal file
View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
require_relative "debug"
module IRB
# :stopdoc:
module ExtendCommand
class Next < Debug
def execute(*args)
super(do_cmds: ["next", *args].join(" "))
end
end
end
# :startdoc:
end

18
lib/irb/cmd/step.rb Normal file
View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
require_relative "debug"
module IRB
# :stopdoc:
module ExtendCommand
class Step < Debug
def execute(*args)
# Run `next` first to move out of binding.irb
super(pre_cmds: "next", do_cmds: ["step", *args].join(" "))
end
end
end
# :startdoc:
end

View File

@ -486,9 +486,9 @@ module IRB
@workspace.local_variable_set(:_, exception)
end
# Transform a non-identifier alias (ex: @, $)
# Transform a non-identifier alias (@, $) or keywords (next, break)
command, args = line.split(/\s/, 2)
if original = symbol_alias(command)
if original = command_aliases[command.to_sym]
line = line.gsub(/\A#{Regexp.escape(command)}/, original.to_s)
command = original
end
@ -545,10 +545,16 @@ module IRB
workspace.binding.local_variables
end
# Return a command name if it's aliased from the argument and it's not an identifier.
def symbol_alias(command)
# Return true if it's aliased from the argument and it's not an identifier.
def symbol_alias?(command)
return nil if command.match?(/\A\w+\z/)
command_aliases[command.to_sym]
command_aliases.key?(command.to_sym)
end
# Return true if the command supports transforming args
def transform_args?(command)
command = command_aliases.fetch(command.to_sym, command)
ExtendCommandBundle.load_command(command)&.respond_to?(:transform_args)
end
end
end

View File

@ -124,13 +124,48 @@ module IRB # :nodoc:
:irb_edit, :Edit, "cmd/edit",
[:edit, NO_OVERRIDE],
],
[
:irb_break, :Break, "cmd/break",
],
[
:irb_catch, :Catch, "cmd/catch",
],
[
:irb_next, :Next, "cmd/next",
],
[
:irb_delete, :Delete, "cmd/delete",
[:delete, NO_OVERRIDE],
],
[
:irb_step, :Step, "cmd/step",
[:step, NO_OVERRIDE],
],
[
:irb_continue, :Continue, "cmd/continue",
[:continue, NO_OVERRIDE],
],
[
:irb_finish, :Finish, "cmd/finish",
[:finish, NO_OVERRIDE],
],
[
:irb_backtrace, :Backtrace, "cmd/backtrace",
[:backtrace, NO_OVERRIDE],
[:bt, NO_OVERRIDE],
],
[
:irb_debug_info, :Info, "cmd/info",
[:info, NO_OVERRIDE],
],
[
:irb_help, :Help, "cmd/help",
[:help, NO_OVERRIDE],
],
[
:irb_info, :Info, "cmd/info"
:irb_info, :IrbInfo, "cmd/irb_info"
],
[

View File

@ -160,8 +160,13 @@ module IRB # :nodoc:
@CONF[:AT_EXIT] = []
@CONF[:COMMAND_ALIASES] = {
# Symbol aliases
:'$' => :show_source,
:'@' => :whereami,
# Keyword aliases
:break => :irb_break,
:catch => :irb_catch,
:next => :irb_next,
}
end

View File

@ -65,9 +65,9 @@ class RubyLex
false
end
else
# Accept any single-line input starting with a non-identifier alias (ex: @, $)
# Accept any single-line input for symbol aliases or commands that transform args
command = code.split(/\s/, 2).first
if context.symbol_alias(command)
if context.symbol_alias?(command) || context.transform_args?(command)
next true
end

View File

@ -17,6 +17,8 @@ begin
@irbrc_backup = ENV['IRBRC']
@irbrc_file = ENV['IRBRC'] = File.join(@tmpdir, 'temporaty_irbrc')
File.unlink(@irbrc_file) if File.exist?(@irbrc_file)
@ruby_file = File.join(@tmpdir, 'ruby_file.rb')
File.unlink(@ruby_file) if File.exist?(@ruby_file)
end
def teardown
@ -235,11 +237,234 @@ begin
EOC
end
private def write_irbrc(content)
def test_debug
write_ruby <<~'RUBY'
puts "start IRB"
binding.irb
puts "Hello"
RUBY
start_terminal(25, 80, %W{ruby -I#{@pwd}/lib #{@ruby_file}}, startup_message: 'start IRB')
write("debug\n")
write("next\n")
close
assert_include_screen(<<~EOC)
(rdbg) next # command
[1, 3] in #{@ruby_file}
1| puts "start IRB"
2| binding.irb
=> 3| puts "Hello"
EOC
end
def test_break
write_ruby <<~'RUBY'
puts "start IRB"
binding.irb
puts "Hello"
puts "World"
RUBY
start_terminal(25, 80, %W{ruby -I#{@pwd}/lib #{@ruby_file}}, startup_message: 'start IRB')
write("break 3\n")
write("continue\n")
close
assert_include_screen(<<~EOC)
(rdbg:irb) break 3
#0 BP - Line #{@ruby_file}:3 (line)
EOC
assert_include_screen(<<~EOC)
(rdbg) continue # command
[1, 4] in #{@ruby_file}
1| puts "start IRB"
2| binding.irb
=> 3| puts "Hello"
4| puts "World"
=>#0 <main> at #{@ruby_file}:3
Stop by #0 BP - Line #{@ruby_file}:3 (line)
EOC
end
def test_delete
write_ruby <<~'RUBY'
puts "start IRB"
binding.irb
puts "Hello"
binding.irb
puts "World"
RUBY
start_terminal(25, 80, %W{ruby -I#{@pwd}/lib #{@ruby_file}}, startup_message: 'start IRB')
write("break 5\n")
write("continue\n")
write("delete 0\n")
close
assert_include_screen(<<~EOC)
(rdbg:irb) delete 0
deleted: #0 BP - Line #{@ruby_file}:5 (line)
EOC
end
def test_next
write_ruby <<~'RUBY'
puts "start IRB"
binding.irb
puts "Hello"
puts "World"
RUBY
start_terminal(25, 80, %W{ruby -I#{@pwd}/lib #{@ruby_file}}, startup_message: 'start IRB')
write("next\n")
close
assert_include_screen(<<~EOC)
(rdbg:irb) next
[1, 4] in #{@ruby_file}
1| puts "start IRB"
2| binding.irb
=> 3| puts "Hello"
4| puts "World"
=>#0 <main> at #{@ruby_file}:3
EOC
end
def test_step
write_ruby <<~'RUBY'
puts "start IRB"
def foo
puts "Hello"
end
binding.irb
foo
puts "World"
RUBY
start_terminal(25, 80, %W{ruby -I#{@pwd}/lib #{@ruby_file}}, startup_message: 'start IRB')
write("step\n")
close
assert_include_screen(<<~EOC)
(rdbg:irb) step
[1, 7] in #{@ruby_file}
1| puts "start IRB"
2| def foo
=> 3| puts "Hello"
4| end
5| binding.irb
EOC
end
def test_continue
write_ruby <<~'RUBY'
puts "start IRB"
binding.irb
puts "Hello"
binding.irb
puts "World"
RUBY
start_terminal(25, 80, %W{ruby -I#{@pwd}/lib #{@ruby_file}}, startup_message: 'start IRB')
write("continue\n")
close
assert_include_screen(<<~EOC)
(rdbg:irb) continue
Hello
From: #{@ruby_file} @ line 4 :
1: puts "start IRB"
2: binding.irb
3: puts "Hello"
=> 4: binding.irb
5: puts "World"
EOC
end
def test_finish
write_ruby <<~'RUBY'
puts "start IRB"
def foo
binding.irb
puts "Hello"
end
foo
puts "World"
RUBY
start_terminal(25, 80, %W{ruby -I#{@pwd}/lib #{@ruby_file}}, startup_message: 'start IRB')
write("finish\n")
close
assert_include_screen(<<~EOC)
(rdbg:irb) finish
Hello
[1, 7] in #{@ruby_file}
1| puts "start IRB"
2| def foo
3| binding.irb
4| puts "Hello"
=> 5| end
6| foo
EOC
end
def test_backtrace
write_ruby <<~'RUBY'
puts "start IRB"
def foo
binding.irb
end
foo
RUBY
start_terminal(25, 80, %W{ruby -I#{@pwd}/lib #{@ruby_file}}, startup_message: 'start IRB')
write("backtrace\n")
close
assert_include_screen(<<~EOC)
(rdbg:irb) backtrace
=>#0 Object#foo at #{@ruby_file}:3
#1 <main> at #{@ruby_file}:5
EOC
end
def test_info
write_ruby <<~'RUBY'
puts "start IRB"
a = 1
binding.irb
RUBY
start_terminal(25, 80, %W{ruby -I#{@pwd}/lib #{@ruby_file}}, startup_message: 'start IRB')
write("info\n")
close
assert_include_screen(<<~EOC)
(rdbg:irb) info
%self = main
a = 1
EOC
end
def test_catch
write_ruby <<~'RUBY'
puts "start IRB"
binding.irb
raise NotImplementedError
RUBY
start_terminal(25, 80, %W{ruby -I#{@pwd}/lib #{@ruby_file}}, startup_message: 'start IRB')
write("catch NotImplementedError\n")
write("continue\n")
close
assert_include_screen(<<~EOC)
Stop by #0 BP - Catch "NotImplementedError"
EOC
end
private
def assert_include_screen(expected)
assert_include(result.join("\n"), expected)
end
def write_irbrc(content)
File.open(@irbrc_file, 'w') do |f|
f.write content
end
end
def write_ruby(content)
File.open(@ruby_file, 'w') do |f|
f.write content
end
end
end
rescue LoadError, NameError
# On Ruby repository, this test suit doesn't run because Ruby repo doesn't