summaryrefslogtreecommitdiff
path: root/lib/irb/cmd/debug.rb
blob: 2c09c99cf026c57005d1c6b86d5c1802629e2b9c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
require_relative "nop"

module IRB
  # :stopdoc:

  module ExtendCommand
    class Debug < Nop
      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__)

      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

        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

        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)
        # exit current Irb#run call
        throw :IRB_EXIT
      end

      private

      def binding_irb?
        caller.any? do |frame|
          BINDING_IRB_FRAME_REGEXPS.any? do |regexp|
            frame.match?(regexp)
          end
        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
        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+
        debug_so = Gem.paths.path.flat_map do |path|
          Dir.glob("#{path}/extensions/**/#{File.basename(debug_gem)}/debug/debug.so")
        end.first

        # Attempt to forcibly load the bundled gem
        if debug_so
          $LOAD_PATH << debug_so.delete_suffix('/debug/debug.so')
        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