summaryrefslogtreecommitdiff
path: root/lib/irb/cmd/debug.rb
blob: 7d39b9fa2772f348f680a2716f1d5c87fe94c435 (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
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
require_relative "nop"

module IRB
  # :stopdoc:

  module ExtendCommand
    class Debug < Nop
      category "Debugging"
      description "Start the debugger of debug.gem."

      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

      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
      def self.category
        "Debugging"
      end

      def self.description
        command_name = self.name.split("::").last.downcase
        "Start the debugger of debug.gem and run its `#{command_name}` command."
      end
    end
  end
end