From d8fb3246be699dea102a23410fe7555ae2905f9c Mon Sep 17 00:00:00 2001 From: Stan Lo Date: Sun, 13 Aug 2023 19:30:30 +0100 Subject: [PATCH] Support seamless integration with ruby/debug (#575) * Support native integration with ruby/debug * Prevent using multi-irb and activating debugger at the same time Multi-irb makes a few assumptions: - IRB will manage all threads that host sub-irb sessions - All IRB sessions will be run on the threads created by IRB itself However, when using the debugger these assumptions are broken: - `debug` will freeze ALL threads when it suspends the session (e.g. when hitting a breakpoint, or performing step-debugging). - Since the irb-debug integration runs IRB as the debugger's interface, it will be run on the debugger's thread, which is not managed by IRB. So we should prevent the 2 features from being used at the same time. To do that, we check if the other feature is already activated when executing the commands that would activate the other feature. --- lib/irb.rb | 83 ++++++++++++-- lib/irb/cmd/debug.rb | 128 ++++++---------------- lib/irb/cmd/subirb.rb | 36 +++++- lib/irb/context.rb | 2 + lib/irb/debug.rb | 127 ++++++++++++++++++++++ lib/irb/debug/ui.rb | 104 ++++++++++++++++++ lib/irb/history.rb | 4 + lib/irb/ruby-lex.rb | 6 +- lib/irb/workspace.rb | 4 + test/irb/test_debug_cmd.rb | 217 +++++++++++++++++++++++++++++++++++-- test/irb/test_history.rb | 45 +++++++- 11 files changed, 642 insertions(+), 114 deletions(-) create mode 100644 lib/irb/debug.rb create mode 100644 lib/irb/debug/ui.rb diff --git a/lib/irb.rb b/lib/irb.rb index c3631715d..c884d70a6 100644 --- a/lib/irb.rb +++ b/lib/irb.rb @@ -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. @@ -373,8 +374,6 @@ module IRB class Abort < Exception;end @CONF = {} - - # Displays current configuration. # # Modifying the configuration is achieved by sending a message to IRB.conf. @@ -441,7 +440,7 @@ class Irb # Creates a new irb session def initialize(workspace = nil, input_method = nil) @context = Context.new(self, workspace, input_method) - @context.main.extend ExtendCommandBundle + @context.workspace.load_commands_to_main @signal_status = :IN_IRB @scanner = RubyLex.new(@context) end @@ -457,6 +456,38 @@ def debug_break end end + def debug_readline(binding) + workspace = IRB::WorkSpace.new(binding) + context.workspace = workspace + context.workspace.load_commands_to_main + scanner.increase_line_no(1) + + # When users run: + # 1. Debugging commands, like `step 2` + # 2. Any input that's not irb-command, like `foo = 123` + # + # Irb#eval_input will simply return the input, and we need to pass it to the debugger. + input = if IRB.conf[:SAVE_HISTORY] && context.io.support_history_saving? + # Previous IRB session's history has been saved when `Irb#run` is exited + # We need to make sure the saved history is not saved again by reseting the counter + context.io.reset_history_counter + + begin + eval_input + ensure + context.io.save_history + end + else + eval_input + end + + if input&.include?("\n") + scanner.increase_line_no(input.count("\n") - 1) + end + + input + end + def run(conf = IRB.conf) in_nested_session = !!conf[:MAIN_CONTEXT] conf[:IRB_RC].call(context) if conf[:IRB_RC] @@ -542,6 +573,18 @@ def eval_input @scanner.each_top_level_statement do |line, line_no, is_assignment| signal_status(:IN_EVAL) do begin + # If the integration with debugger is activated, we need to handle certain input differently + if @context.with_debugger + command_class = load_command_class(line) + # First, let's pass debugging command's input to debugger + # Secondly, we need to let debugger 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 !command_class || ExtendCommand::DebugCommand > command_class + return line + end + end + evaluate_line(line, line_no) # Don't echo if the line ends with a semicolon @@ -633,6 +676,12 @@ def evaluate_line(line, line_no) @context.evaluate(line, line_no) end + def load_command_class(line) + command, _ = line.split(/\s/, 2) + command_name = @context.command_aliases[command.to_sym] + ExtendCommandBundle.load_command(command_name || command) + end + def convert_invalid_byte_sequence(str, enc) str.force_encoding(enc) str.scrub { |c| @@ -986,12 +1035,32 @@ class Binding # # See IRB@Usage for more information. def irb(show_code: true) + # Setup IRB with the current file's path and no command line arguments IRB.setup(source_location[0], argv: []) + # Create a new workspace using the current binding workspace = IRB::WorkSpace.new(self) + # Print the code around the binding if show_code is 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) - binding_irb.debug_break + # Get the original IRB instance + debugger_irb = IRB.instance_variable_get(:@debugger_irb) + + irb_path = File.expand_path(source_location[0]) + + if debugger_irb + # If we're already in a debugger session, set the workspace and irb_path for the original IRB instance + debugger_irb.context.workspace = workspace + debugger_irb.context.irb_path = irb_path + # 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 irb:rdbg session. + IRB::Debug.setup(debugger_irb) + IRB::Debug.insert_debug_break + debugger_irb.debug_break + else + # If we're not in a debugger session, create a new IRB instance with the current workspace + binding_irb = IRB::Irb.new(workspace) + binding_irb.context.irb_path = irb_path + binding_irb.run(IRB.conf) + binding_irb.debug_break + end end end diff --git a/lib/irb/cmd/debug.rb b/lib/irb/cmd/debug.rb index 7d39b9fa2..9eca96421 100644 --- a/lib/irb/cmd/debug.rb +++ b/lib/irb/cmd/debug.rb @@ -1,4 +1,5 @@ require_relative "nop" +require_relative "../debug" module IRB # :stopdoc: @@ -12,37 +13,46 @@ class Debug < Nop '', 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) - unless binding_irb? - puts "`debug` command is only available when IRB is started with binding.irb" - return - end + if irb_context.with_debugger + # If IRB is already running with a debug session, throw the command and IRB.debug_readline will pass it to the debugger. + if cmd = pre_cmds || do_cmds + throw :IRB_EXIT, cmd + else + puts "IRB is already running with a debug session." + return + end + else + # If IRB is not running with a debug session yet, then: + # 1. Check if the debugging command is run from a `binding.irb` call. + # 2. If so, try setting up the debug gem. + # 3. Insert a debug breakpoint at `Irb#debug_break` with the intended command. + # 4. Exit the current Irb#run call via `throw :IRB_EXIT`. + # 5. `Irb#debug_break` will be called and trigger the breakpoint, which will run the intended command. + unless binding_irb? + puts "`debug` command is only available when IRB is started with binding.irb" + return + end - unless setup_debugger - 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 + if IRB.respond_to?(:JobManager) + warn "Can't start the debugger when IRB is running in a multi-IRB session." + 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 + unless IRB::Debug.setup(irb_context.irb) + 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 - # 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 + IRB::Debug.insert_debug_break(pre_cmds: pre_cmds, do_cmds: do_cmds) + + # exit current Irb#run call + throw :IRB_EXIT + end end private @@ -54,72 +64,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?('') - 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 == "" - 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 diff --git a/lib/irb/cmd/subirb.rb b/lib/irb/cmd/subirb.rb index 3018ab277..5ffd64641 100644 --- a/lib/irb/cmd/subirb.rb +++ b/lib/irb/cmd/subirb.rb @@ -11,8 +11,7 @@ module IRB module ExtendCommand class MultiIRBCommand < Nop - def initialize(conf) - super + def execute(*args) extend_irb_context end @@ -29,6 +28,10 @@ def extend_irb_context # this extension patches IRB context like IRB.CurrentContext require_relative "../ext/multi-irb" end + + def print_debugger_warning + warn "Multi-IRB commands are not available when the debugger is enabled." + end end class IrbCommand < MultiIRBCommand @@ -37,6 +40,13 @@ class IrbCommand < MultiIRBCommand def execute(*obj) print_deprecated_warning + + if irb_context.with_debugger + print_debugger_warning + return + end + + super IRB.irb(nil, *obj) end end @@ -47,6 +57,13 @@ class Jobs < MultiIRBCommand def execute print_deprecated_warning + + if irb_context.with_debugger + print_debugger_warning + return + end + + super IRB.JobManager end end @@ -57,6 +74,14 @@ class Foreground < MultiIRBCommand def execute(key = nil) print_deprecated_warning + + if irb_context.with_debugger + print_debugger_warning + return + end + + super + raise CommandArgumentError.new("Please specify the id of target IRB job (listed in the `jobs` command).") unless key IRB.JobManager.switch(key) end @@ -68,6 +93,13 @@ class Kill < MultiIRBCommand def execute(*keys) print_deprecated_warning + + if irb_context.with_debugger + print_debugger_warning + return + end + + super IRB.JobManager.kill(*keys) end end diff --git a/lib/irb/context.rb b/lib/irb/context.rb index 18125ff6f..43d9b5343 100644 --- a/lib/irb/context.rb +++ b/lib/irb/context.rb @@ -345,6 +345,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 diff --git a/lib/irb/debug.rb b/lib/irb/debug.rb new file mode 100644 index 000000000..dab9d1846 --- /dev/null +++ b/lib/irb/debug.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +module IRB + module Debug + BINDING_IRB_FRAME_REGEXPS = [ + '', + 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(irb) + # When debug session is not started at all + 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__::CONFIG.set_config + configure_irb_for_debugger(irb) + thread = Thread.current + + DEBUGGER__.initialize_session{ IRB::Debug::UI.new(thread, irb) } + end + + # When debug session was previously started but not by IRB + if defined?(DEBUGGER__::SESSION) && !irb.context.with_debugger + configure_irb_for_debugger(irb) + thread = Thread.current + + DEBUGGER__::SESSION.reset_ui(IRB::Debug::UI.new(thread, irb)) + end + + # Apply patches to debug gem so it skips IRB frames + 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 == "" + end + frames + end + + DEBUGGER__::ThreadClient.prepend(SkipPathHelperForIRB) + end + + true + end + + private + + def configure_irb_for_debugger(irb) + require 'irb/debug/ui' + IRB.instance_variable_set(:@debugger_irb, irb) + irb.context.with_debugger = true + irb.context.irb_name = "irb:rdbg" + end + + 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?('') + 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 diff --git a/lib/irb/debug/ui.rb b/lib/irb/debug/ui.rb new file mode 100644 index 000000000..a4ca4fdf0 --- /dev/null +++ b/lib/irb/debug/ui.rb @@ -0,0 +1,104 @@ +require 'io/console/size' +require 'debug/console' + +module IRB + module Debug + class UI < DEBUGGER__::UI_Base + def initialize(thread, irb) + @thread = thread + @irb = irb + end + + def remote? + false + end + + def activate session, on_fork: false + end + + def deactivate + end + + def width + if (w = IO.console_size[1]) == 0 # for tests PTY + 80 + else + w + end + end + + def quit n + yield + exit n + end + + def ask prompt + setup_interrupt do + print prompt + ($stdin.gets || '').strip + end + end + + def puts str = nil + case str + when Array + str.each{|line| + $stdout.puts line.chomp + } + when String + str.each_line{|line| + $stdout.puts line.chomp + } + when nil + $stdout.puts + end + end + + def readline _ + setup_interrupt do + tc = DEBUGGER__::SESSION.get_thread_client(@thread) + cmd = @irb.debug_readline(tc.current_frame.binding || TOPLEVEL_BINDING) + + case cmd + when nil # when user types C-d + "continue" + else + cmd + end + end + end + + def setup_interrupt + DEBUGGER__::SESSION.intercept_trap_sigint false do + current_thread = Thread.current # should be session_server thread + + prev_handler = trap(:INT){ + current_thread.raise Interrupt + } + + yield + ensure + trap(:INT, prev_handler) + end + end + + def after_fork_parent + parent_pid = Process.pid + + at_exit{ + DEBUGGER__::SESSION.intercept_trap_sigint_end + trap(:SIGINT, :IGNORE) + + if Process.pid == parent_pid + # only check child process from its parent + begin + # wait for all child processes to keep terminal + Process.waitpid + rescue Errno::ESRCH, Errno::ECHILD + end + end + } + end + end + end +end diff --git a/lib/irb/history.rb b/lib/irb/history.rb index 516890ac0..ae924d152 100644 --- a/lib/irb/history.rb +++ b/lib/irb/history.rb @@ -4,6 +4,10 @@ def support_history_saving? true end + def reset_history_counter + @loaded_history_lines = self.class::HISTORY.size if defined? @loaded_history_lines + end + def load_history history = self.class::HISTORY if history_file = IRB.conf[:HISTORY_FILE] diff --git a/lib/irb/ruby-lex.rb b/lib/irb/ruby-lex.rb index 7d4f8a514..282e6ef05 100644 --- a/lib/irb/ruby-lex.rb +++ b/lib/irb/ruby-lex.rb @@ -183,6 +183,10 @@ def save_prompt_to_context_io(opens, continue, line_num_offset) prompt(opens, continue, line_num_offset) end + def increase_line_no(addition) + @line_no += addition + end + def readmultiline save_prompt_to_context_io([], false, 0) @@ -220,7 +224,7 @@ def each_top_level_statement code.force_encoding(@context.io.encoding) yield code, @line_no, assignment_expression?(code) end - @line_no += code.count("\n") + increase_line_no(code.count("\n")) rescue TerminateLineInput end end diff --git a/lib/irb/workspace.rb b/lib/irb/workspace.rb index d6fa67053..2bf3d5e0f 100644 --- a/lib/irb/workspace.rb +++ b/lib/irb/workspace.rb @@ -108,6 +108,10 @@ def initialize(*main) # IRB.conf[:__MAIN__] attr_reader :main + def load_commands_to_main + main.extend ExtendCommandBundle + end + # Evaluate the given +statements+ within the context of this workspace. def evaluate(statements, file = __FILE__, line = __LINE__) eval(statements, @binding, file, line) diff --git a/test/irb/test_debug_cmd.rb b/test/irb/test_debug_cmd.rb index 35239364b..c4e4a04fd 100644 --- a/test/irb/test_debug_cmd.rb +++ b/test/irb/test_debug_cmd.rb @@ -27,10 +27,10 @@ def foo output = run_ruby_file do type "backtrace" - type "q!" + type "exit!" end - assert_match(/\(rdbg:irb\) backtrace/, output) + assert_match(/irb\(main\):001> backtrace/, output) assert_match(/Object#foo at #{@ruby_file.to_path}/, output) end @@ -46,10 +46,27 @@ def test_debug type "continue" end - assert_match(/\(rdbg\) next/, output) + assert_match(/irb\(main\):001> debug/, output) + assert_match(/irb:rdbg\(main\):002> next/, output) assert_match(/=> 2\| puts "hello"/, output) end + def test_debug_command_only_runs_once + write_ruby <<~'ruby' + binding.irb + ruby + + output = run_ruby_file do + type "debug" + type "debug" + type "continue" + end + + assert_match(/irb\(main\):001> debug/, output) + assert_match(/irb:rdbg\(main\):002> debug/, output) + assert_match(/IRB is already running with a debug session/, output) + end + def test_next write_ruby <<~'ruby' binding.irb @@ -61,7 +78,7 @@ def test_next type "continue" end - assert_match(/\(rdbg:irb\) next/, output) + assert_match(/irb\(main\):001> next/, output) assert_match(/=> 2\| puts "hello"/, output) end @@ -77,7 +94,7 @@ def test_break type "continue" end - assert_match(/\(rdbg:irb\) break/, output) + assert_match(/irb\(main\):001> break/, output) assert_match(/=> 2\| puts "Hello"/, output) end @@ -96,7 +113,7 @@ def test_delete type "continue" end - assert_match(/\(rdbg:irb\) delete/, output) + assert_match(/irb:rdbg\(main\):003> delete/, output) assert_match(/deleted: #0 BP - Line/, output) end @@ -115,11 +132,44 @@ def foo type "continue" end - assert_match(/\(rdbg:irb\) step/, output) + assert_match(/irb\(main\):001> step/, output) assert_match(/=> 5\| foo/, output) assert_match(/=> 2\| puts "Hello"/, output) end + def test_long_stepping + write_ruby <<~'RUBY' + class Foo + def foo(num) + bar(num + 10) + end + + def bar(num) + num + end + end + + binding.irb + Foo.new.foo(100) + RUBY + + output = run_ruby_file do + type "step" + type "step" + type "step" + type "step" + type "num" + type "continue" + end + + assert_match(/irb\(main\):001> step/, output) + assert_match(/irb:rdbg\(main\):002> step/, output) + assert_match(/irb:rdbg\(#\):003> step/, output) + assert_match(/irb:rdbg\(#\):004> step/, output) + assert_match(/irb:rdbg\(#\):005> num/, output) + assert_match(/=> 110/, output) + end + def test_continue write_ruby <<~'RUBY' binding.irb @@ -133,8 +183,9 @@ def test_continue type "continue" end - assert_match(/\(rdbg:irb\) continue/, output) + assert_match(/irb\(main\):001> continue/, output) assert_match(/=> 3: binding.irb/, output) + assert_match(/irb:rdbg\(main\):002> continue/, output) end def test_finish @@ -151,7 +202,7 @@ def foo type "continue" end - assert_match(/\(rdbg:irb\) finish/, output) + assert_match(/irb\(main\):001> finish/, output) assert_match(/=> 4\| end/, output) end @@ -169,7 +220,7 @@ def foo type "continue" end - assert_match(/\(rdbg:irb\) info/, output) + assert_match(/irb\(main\):001> info/, output) assert_match(/%self = main/, output) assert_match(/a = "Hello"/, output) end @@ -186,8 +237,152 @@ def test_catch type "continue" end - assert_match(/\(rdbg:irb\) catch/, output) + assert_match(/irb\(main\):001> catch/, output) assert_match(/Stop by #0 BP - Catch "ZeroDivisionError"/, output) end + + def test_exit + write_ruby <<~'RUBY' + binding.irb + puts "hello" + RUBY + + output = run_ruby_file do + type "next" + type "exit" + end + + assert_match(/irb\(main\):001> next/, output) + end + + def test_quit + write_ruby <<~'RUBY' + binding.irb + RUBY + + output = run_ruby_file do + type "next" + type "quit!" + end + + assert_match(/irb\(main\):001> next/, output) + end + + def test_prompt_line_number_continues + write_ruby <<~'ruby' + binding.irb + puts "Hello" + puts "World" + ruby + + output = run_ruby_file do + type "123" + type "456" + type "next" + type "info" + type "next" + type "continue" + end + + assert_match(/irb\(main\):003> next/, output) + assert_match(/irb:rdbg\(main\):004> info/, output) + assert_match(/irb:rdbg\(main\):005> next/, output) + end + + def test_irb_commands_are_available_after_moving_around_with_the_debugger + write_ruby <<~'ruby' + class Foo + def bar + puts "bar" + end + end + + binding.irb + Foo.new.bar + ruby + + output = run_ruby_file do + # Due to the way IRB defines its commands, moving into the Foo instance from main is necessary for proper testing. + type "next" + type "step" + type "irb_info" + type "continue" + end + + assert_include(output, "InputMethod: RelineInputMethod") + end + + def test_input_is_evaluated_in_the_context_of_the_current_thread + write_ruby <<~'ruby' + current_thread = Thread.current + binding.irb + ruby + + output = run_ruby_file do + type "debug" + type '"Threads match: #{current_thread == Thread.current}"' + type "continue" + end + + assert_match(/irb\(main\):001> debug/, output) + assert_match(/Threads match: true/, output) + end + + def test_irb_switches_debugger_interface_if_debug_was_already_activated + write_ruby <<~'ruby' + require 'debug' + class Foo + def bar + puts "bar" + end + end + + binding.irb + Foo.new.bar + ruby + + output = run_ruby_file do + # Due to the way IRB defines its commands, moving into the Foo instance from main is necessary for proper testing. + type "next" + type "step" + type 'irb_info' + type "continue" + end + + assert_match(/irb\(main\):001> next/, output) + assert_include(output, "InputMethod: RelineInputMethod") + end + + def test_debugger_cant_be_activated_while_multi_irb_is_active + write_ruby <<~'ruby' + binding.irb + a = 1 + ruby + + output = run_ruby_file do + type "jobs" + type "next" + type "exit" + end + + assert_match(/irb\(main\):001> jobs/, output) + assert_include(output, "Can't start the debugger when IRB is running in a multi-IRB session.") + end + + def test_multi_irb_commands_are_not_available_after_activating_the_debugger + write_ruby <<~'ruby' + binding.irb + a = 1 + ruby + + output = run_ruby_file do + type "next" + type "jobs" + type "continue" + end + + assert_match(/irb\(main\):001> next/, output) + assert_include(output, "Multi-IRB commands are not available when the debugger is enabled.") + end end end diff --git a/test/irb/test_history.rb b/test/irb/test_history.rb index 39f9e8275..9bf146609 100644 --- a/test/irb/test_history.rb +++ b/test/irb/test_history.rb @@ -209,7 +209,50 @@ def with_temp_stdio end end - class NestedIRBHistoryTest < IntegrationTestCase + class IRBHistoryIntegrationTest < IntegrationTestCase + def test_history_saving_with_debug + if ruby_core? + omit "This test works only under ruby/irb" + end + + write_history "" + + write_ruby <<~'RUBY' + def foo + end + + binding.irb + + foo + RUBY + + output = run_ruby_file do + type "'irb session'" + type "next" + type "'irb:debug session'" + type "step" + type "irb_info" + type "puts Reline::HISTORY.to_a.to_s" + type "q!" + end + + assert_include(output, "InputMethod: RelineInputMethod") + # check that in-memory history is preserved across sessions + assert_include output, %q( + ["'irb session'", "next", "'irb:debug session'", "step", "irb_info", "puts Reline::HISTORY.to_a.to_s"] + ).strip + + assert_equal <<~HISTORY, @history_file.open.read + 'irb session' + next + 'irb:debug session' + step + irb_info + puts Reline::HISTORY.to_a.to_s + q! + HISTORY + end + def test_history_saving_with_nested_sessions write_history ""