From 976100c1c26808b9d8c58f72442dbc01c13a790f Mon Sep 17 00:00:00 2001 From: Takashi Kokubun Date: Mon, 21 Nov 2022 00:46:22 -0800 Subject: [PATCH] Add commands to start and use the debugger (#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 --- Gemfile | 1 + README.md | 2 + lib/irb.rb | 6 +- lib/irb/cmd/backtrace.rb | 21 +++ lib/irb/cmd/break.rb | 21 +++ lib/irb/cmd/catch.rb | 21 +++ lib/irb/cmd/continue.rb | 17 ++ lib/irb/cmd/debug.rb | 12 +- lib/irb/cmd/delete.rb | 17 ++ lib/irb/cmd/finish.rb | 17 ++ lib/irb/cmd/info.rb | 31 +--- lib/irb/cmd/irb_info.rb | 34 ++++ lib/irb/cmd/next.rb | 17 ++ lib/irb/cmd/step.rb | 18 ++ lib/irb/context.rb | 16 +- lib/irb/extend-command.rb | 37 +++- lib/irb/init.rb | 5 + lib/irb/ruby-lex.rb | 4 +- test/irb/yamatanooroti/test_rendering.rb | 227 ++++++++++++++++++++++- 19 files changed, 487 insertions(+), 37 deletions(-) create mode 100644 lib/irb/cmd/backtrace.rb create mode 100644 lib/irb/cmd/break.rb create mode 100644 lib/irb/cmd/catch.rb create mode 100644 lib/irb/cmd/continue.rb create mode 100644 lib/irb/cmd/delete.rb create mode 100644 lib/irb/cmd/finish.rb create mode 100644 lib/irb/cmd/irb_info.rb create mode 100644 lib/irb/cmd/next.rb create mode 100644 lib/irb/cmd/step.rb diff --git a/Gemfile b/Gemfile index 6f6d5419c..0b985d1e4 100644 --- a/Gemfile +++ b/Gemfile @@ -11,4 +11,5 @@ group :development do gem "stackprof" if is_unix && !is_truffleruby gem "test-unit" gem "reline", github: "ruby/reline" if ENV["WITH_LATEST_RELINE"] == "true" + gem "debug" end diff --git a/README.md b/README.md index 733629b43..2f034bcc7 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ The following commands are available on IRB. * 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. ## Documentation diff --git a/lib/irb.rb b/lib/irb.rb index 0a856d392..2db99bcd4 100644 --- a/lib/irb.rb +++ b/lib/irb.rb @@ -96,6 +96,8 @@ # * 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 @@ class 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 diff --git a/lib/irb/cmd/backtrace.rb b/lib/irb/cmd/backtrace.rb new file mode 100644 index 000000000..ac4f0e0e7 --- /dev/null +++ b/lib/irb/cmd/backtrace.rb @@ -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 diff --git a/lib/irb/cmd/break.rb b/lib/irb/cmd/break.rb new file mode 100644 index 000000000..2c82413f6 --- /dev/null +++ b/lib/irb/cmd/break.rb @@ -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 diff --git a/lib/irb/cmd/catch.rb b/lib/irb/cmd/catch.rb new file mode 100644 index 000000000..8c9e086a9 --- /dev/null +++ b/lib/irb/cmd/catch.rb @@ -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 diff --git a/lib/irb/cmd/continue.rb b/lib/irb/cmd/continue.rb new file mode 100644 index 000000000..94696e4b6 --- /dev/null +++ b/lib/irb/cmd/continue.rb @@ -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 diff --git a/lib/irb/cmd/debug.rb b/lib/irb/cmd/debug.rb index d43e060c6..9e2c09610 100644 --- a/lib/irb/cmd/debug.rb +++ b/lib/irb/cmd/debug.rb @@ -11,7 +11,7 @@ class Debug < Nop ].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 @@ def execute(*args) 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 diff --git a/lib/irb/cmd/delete.rb b/lib/irb/cmd/delete.rb new file mode 100644 index 000000000..3810ae414 --- /dev/null +++ b/lib/irb/cmd/delete.rb @@ -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 diff --git a/lib/irb/cmd/finish.rb b/lib/irb/cmd/finish.rb new file mode 100644 index 000000000..de4b4f12c --- /dev/null +++ b/lib/irb/cmd/finish.rb @@ -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 diff --git a/lib/irb/cmd/info.rb b/lib/irb/cmd/info.rb index 37ecd762f..413c28642 100644 --- a/lib/irb/cmd/info.rb +++ b/lib/irb/cmd/info.rb @@ -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" - end - str - end - alias_method :to_s, :inspect - }.new + class Info < Debug + def self.transform_args(args) + args&.dump + end + + def execute(*args) + super(pre_cmds: ["info", *args].join(" ")) end end end diff --git a/lib/irb/cmd/irb_info.rb b/lib/irb/cmd/irb_info.rb new file mode 100644 index 000000000..8a4e1bd60 --- /dev/null +++ b/lib/irb/cmd/irb_info.rb @@ -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 diff --git a/lib/irb/cmd/next.rb b/lib/irb/cmd/next.rb new file mode 100644 index 000000000..2943a753f --- /dev/null +++ b/lib/irb/cmd/next.rb @@ -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 diff --git a/lib/irb/cmd/step.rb b/lib/irb/cmd/step.rb new file mode 100644 index 000000000..dbd59806f --- /dev/null +++ b/lib/irb/cmd/step.rb @@ -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 diff --git a/lib/irb/context.rb b/lib/irb/context.rb index c5d98772b..91fbb2fcf 100644 --- a/lib/irb/context.rb +++ b/lib/irb/context.rb @@ -486,9 +486,9 @@ def evaluate(line, line_no, exception: nil) # :nodoc: @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 @@ def local_variables # :nodoc: 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 diff --git a/lib/irb/extend-command.rb b/lib/irb/extend-command.rb index 7e120cf51..802c9aa6d 100644 --- a/lib/irb/extend-command.rb +++ b/lib/irb/extend-command.rb @@ -124,13 +124,48 @@ def irb_context :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" ], [ diff --git a/lib/irb/init.rb b/lib/irb/init.rb index 831d7d811..dd888f372 100644 --- a/lib/irb/init.rb +++ b/lib/irb/init.rb @@ -160,8 +160,13 @@ def IRB.init_config(ap_path) @CONF[:AT_EXIT] = [] @CONF[:COMMAND_ALIASES] = { + # Symbol aliases :'$' => :show_source, :'@' => :whereami, + # Keyword aliases + :break => :irb_break, + :catch => :irb_catch, + :next => :irb_next, } end diff --git a/lib/irb/ruby-lex.rb b/lib/irb/ruby-lex.rb index 28029bbf4..85b336fbe 100644 --- a/lib/irb/ruby-lex.rb +++ b/lib/irb/ruby-lex.rb @@ -65,9 +65,9 @@ def set_input(io, p = nil, context:, &block) 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 diff --git a/test/irb/yamatanooroti/test_rendering.rb b/test/irb/yamatanooroti/test_rendering.rb index f9a130b7d..485fa47c2 100644 --- a/test/irb/yamatanooroti/test_rendering.rb +++ b/test/irb/yamatanooroti/test_rendering.rb @@ -17,6 +17,8 @@ def setup @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 @@ def test_assignment_expression_truncate 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
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
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
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