lib/debug/breakpoint.rb



# frozen_string_literal: true

require_relative 'color'

module DEBUGGER__
  class Breakpoint
    include SkipPathHelper

    attr_reader :key, :skip_src

    def initialize cond, command, path, do_enable: true
      @deleted = false

      @cond = cond
      @command = command
      @path = path

      setup
      enable if do_enable
    end

    def safe_eval b, expr
      b.eval(expr)
    rescue Exception => e
      puts "[EVAL ERROR]"
      puts "  expr: #{expr}"
      puts "  err: #{e} (#{e.class})"
      puts "Error caused by #{self}."
      nil
    end

    def oneshot?
      defined?(@oneshot) && @oneshot
    end

    def setup
      raise "not implemented..."
    end

    def enable
      @tp.enable
    end

    def disable
      @tp&.disable
    end

    def enabled?
      @tp.enabled?
    end

    def delete
      disable
      @deleted = true
    end

    def deleted?
      @deleted
    end

    def suspend
      if @command
        provider, pre_cmds, do_cmds = @command
        nonstop = true if do_cmds
        cmds = [*pre_cmds&.split(';;'), *do_cmds&.split(';;')]
        SESSION.add_preset_commands provider, cmds, kick: false, continue: nonstop
      end

      ThreadClient.current.on_breakpoint @tp, self
    end

    def to_s
      s = ''.dup
      s << " if: #{@cond}"        if defined?(@cond) && @cond
      s << " pre: #{@command[1]}" if defined?(@command) && @command && @command[1]
      s << " do: #{@command[2]}"  if defined?(@command) && @command && @command[2]
      s
    end

    def description
      to_s
    end

    def duplicable?
      false
    end

    def skip_path?(path)
      case @path
      when Regexp
        !path.match?(@path)
      when String
        !path.include?(@path)
      else
        super
      end
    end

    include Color

    def generate_label(name)
      colorize(" BP - #{name} ", [:YELLOW, :BOLD, :REVERSE])
    end
  end

  if RUBY_VERSION.to_f <= 2.7
    # workaround for https://bugs.ruby-lang.org/issues/17302
    TracePoint.new(:line){}.enable{}
  end

  class ISeqBreakpoint < Breakpoint
    def initialize iseq, events, oneshot: false
      @events = events
      @iseq = iseq
      @oneshot = oneshot
      @key = [:iseq, @iseq.path, @iseq.first_lineno].freeze

      super(nil, nil, nil)
    end

    def setup
      @tp = TracePoint.new(*@events) do |tp|
        delete if @oneshot
        suspend
      end
    end

    def enable
      @tp.enable(target: @iseq)
    end
  end

  class LineBreakpoint < Breakpoint
    attr_reader :path, :line, :iseq, :cond, :oneshot, :hook_call, :command

    def self.copy bp, root_iseq
      nbp = LineBreakpoint.new bp.path, bp.line,
                               cond: bp.cond, oneshot: bp.oneshot, hook_call: bp.hook_call,
                               command: bp.command, skip_activate: true
      nbp.try_activate root_iseq
      nbp
    end

    def initialize path, line, cond: nil, oneshot: false, hook_call: true, command: nil, skip_activate: false, skip_src: false
      @line = line
      @oneshot = oneshot
      @hook_call = hook_call
      @skip_src = skip_src
      @pending = false

      @iseq = nil
      @type = nil

      @key = [path, @line].freeze

      super(cond, command, path)

      try_activate unless skip_activate
      @pending = !@iseq
    end

    def setup
      return unless @type

      @tp = TracePoint.new(@type) do |tp|
        if @cond
          next unless safe_eval tp.binding, @cond
        end
        delete if @oneshot
        suspend
      end
    end

    def enable
      return unless @iseq

      if @type == :line
        @tp.enable(target: @iseq, target_line: @line)
      else
        @tp.enable(target: @iseq)
      end

    rescue ArgumentError
      puts @iseq.disasm # for debug
      raise
    end

    def activate iseq, event, line
      @iseq = iseq
      @type = event
      @line = line
      @path = iseq.absolute_path

      @key = [@path, @line].freeze
      SESSION.rehash_bps
      setup
      enable

      if @pending && !@oneshot
        DEBUGGER__.info "#{self} is activated."
      end

      @pending = false
    end

    def activate_exact iseq, events, line
      case
      when events.include?(:RUBY_EVENT_CALL)
        # "def foo" line set bp on the beginning of method foo
        activate(iseq, :call, line)
      when events.include?(:RUBY_EVENT_LINE)
        activate(iseq, :line, line)
      when events.include?(:RUBY_EVENT_RETURN)
        activate(iseq, :return, line)
      when events.include?(:RUBY_EVENT_B_RETURN)
        activate(iseq, :b_return, line)
      when events.include?(:RUBY_EVENT_END)
        activate(iseq, :end, line)
      else
        # not activated
      end
    end

    def duplicable?
      @oneshot
    end

    NearestISeq = Struct.new(:iseq, :line, :events)

    def iterate_iseq root_iseq
      if root_iseq
        is = [root_iseq]
        while iseq = is.pop
          yield iseq
          iseq.each_child do |child_iseq|
            is << child_iseq
          end
        end
      else
        ObjectSpace.each_iseq do |iseq|
          if DEBUGGER__.compare_path((iseq.absolute_path || iseq.path), self.path) &&
             iseq.first_lineno <= self.line &&
             iseq.type != :ensure # ensure iseq is copied (duplicated)
            yield iseq
          end
        end
      end
    end

    def try_activate root_iseq = nil
      nearest = nil # NearestISeq
      iterate_iseq root_iseq do |iseq|
        iseq.traceable_lines_norec(line_events = {})
        lines = line_events.keys.sort

        if !lines.empty? && lines.last >= line
          nline = lines.bsearch{|l| line <= l}
          events = line_events[nline]

          next if events == [:RUBY_EVENT_B_CALL]

          if @hook_call &&
            events.include?(:RUBY_EVENT_CALL) &&
            self.line == iseq.first_lineno
            nline = iseq.first_lineno
          end

          if !nearest || ((line - nline).abs < (line - nearest.line).abs)
            nearest = NearestISeq.new(iseq, nline, events)
          elsif @hook_call &&
                nearest.line == iseq.first_line &&
                events.include?(:RUBY_EVENT_CALL)
            nearest = NearestISeq.new(iseq, nline, events)
          end
        end
      end

      if nearest
        activate_exact nearest.iseq, nearest.events, nearest.line
      end
    end

    def to_s
      oneshot = @oneshot ? " (oneshot)" : ""

      if @iseq
        "#{generate_label("Line")} #{@path}:#{@line} (#{@type})#{oneshot}" + super
      else
        "#{generate_label("Line (pending)")} #{@path}:#{@line}#{oneshot}" + super
      end
    end

    def inspect
      "<#{self.class.name} #{self.to_s}>"
    end

    def path_is? path
      DEBUGGER__.compare_path(@path, path)
    end
  end

  class CatchBreakpoint < Breakpoint
    attr_reader :last_exc

    def initialize pat, cond: nil, command: nil, path: nil
      @pat = pat.freeze
      @key = [:catch, @pat].freeze
      @last_exc = nil

      super(cond, command, path)
    end

    def setup
      @tp = TracePoint.new(:raise){|tp|
        exc = tp.raised_exception
        next if SystemExit === exc
        next if skip_path?(tp.path)

        next if !safe_eval(tp.binding, @cond) if @cond
        should_suspend = false

        exc.class.ancestors.each{|cls|
          if @pat === cls.name
            should_suspend = true
            @last_exc = exc
            break
          end
        }
        suspend if should_suspend
      }
    end

    def to_s
      "#{generate_label("Catch")} #{@pat.inspect}"
    end

    def description
      "#{@last_exc.inspect} is raised."
    end
  end

  class CheckBreakpoint < Breakpoint
    def initialize cond:, command: nil, path: nil
      @key = [:check, cond].freeze

      super(cond, command, path)
    end

    def setup
      @tp = TracePoint.new(:line){|tp|
        next if SESSION.in_subsession? # TODO: Ractor support
        next if ThreadClient.current.management?
        next if skip_path?(tp.path)

        if need_suspend? safe_eval(tp.binding, @cond)
          suspend
        end
      }
    end

    private def need_suspend? cond_result
      map = ThreadClient.current.check_bp_fulfillment_map
      if cond_result
        if map[self]
          false
        else
          map[self] = true
        end
      else
        map[self] = false
      end
    end

    def to_s
      s = "#{generate_label("Check")}"
      s += super
      s
    end
  end

  class WatchIVarBreakpoint < Breakpoint
    def initialize ivar, object, current, cond: nil, command: nil, path: nil
      @ivar = ivar.to_sym
      @object = object
      @key = [:watch, object.object_id, @ivar].freeze

      @current = current

      super(cond, command, path)
    end

    def watch_eval(tp)
      result = @object.instance_variable_get(@ivar)
      if result != @current
        begin
          @prev = @current
          @current = result

          if (@cond.nil? || @object.instance_eval(@cond)) && !skip_path?(tp.path)
            suspend
          end
        ensure
          remove_instance_variable(:@prev)
        end
      end
    rescue Exception
      false
    end

    def setup
      @tp = TracePoint.new(:line, :return, :b_return){|tp|
        watch_eval(tp)
      }
    end

    def to_s
      value_str =
        if defined?(@prev)
          "#{@prev} -> #{@current}"
        else
          "#{@current}"
        end
      "#{generate_label("Watch")} #{@object} #{@ivar} = #{value_str}"
    end
  end

  class MethodBreakpoint < Breakpoint
    attr_reader :sig_method_name, :method, :klass

    def initialize b, klass_name, op, method_name, cond: nil, command: nil, path: nil
      @sig_klass_name = klass_name
      @sig_op = op
      @sig_method_name = method_name
      @klass_eval_binding = b
      @override_method = false

      @klass = nil
      @method = nil
      @cond_class = nil
      @key = "#{klass_name}#{op}#{method_name}".freeze

      super(cond, command, path, do_enable: false)
    end

    def setup
      @tp = TracePoint.new(:call){|tp|
        next if !safe_eval(tp.binding, @cond) if @cond
        next if @cond_class && !tp.self.kind_of?(@cond_class)

        caller_location = caller_locations(2, 1).first.to_s
        next if skip_path?(caller_location)

        suspend
      }
    end

    def eval_class_name
      return @klass if @klass
      @klass = @klass_eval_binding.eval(@sig_klass_name)
      @klass_eval_binding = nil
      @klass
    end

    def search_method
      case @sig_op
      when '.'
        @method = @klass.method(@sig_method_name)
      when '#'
        @method = @klass.instance_method(@sig_method_name)
      else
        raise "Unknown op: #{@sig_op}"
      end
    end

    def enable
      try_enable
    end

    if RUBY_VERSION.to_f <= 2.6
      def override klass
        sig_method_name = @sig_method_name
        klass.prepend Module.new{
          define_method(sig_method_name) do |*args, &block|
            super(*args, &block)
          end
        }
      end
    else
      def override klass
        sig_method_name = @sig_method_name
        klass.prepend Module.new{
          define_method(sig_method_name) do |*args, **kw, &block|
            super(*args, **kw, &block)
          end
        }
      end
    end

    def try_enable added: false
      eval_class_name
      search_method

      begin
        retried = false

        @tp.enable(target: @method)
        DEBUGGER__.info "#{self} is activated." if added

        if @sig_op == '#'
          @cond_class = @klass if @method.owner != @klass
        else # '.'
          begin
            @cond_class = @klass.singleton_class if @method.owner != @klass.singleton_class
          rescue TypeError
          end
        end

      rescue ArgumentError
        raise if retried
        retried = true

        # maybe C method
        case @sig_op
        when '.'
          begin
            override @klass.singleton_class
          rescue TypeError
            override @klass.class
          end
        when '#'
          override @klass
        end

        # re-collect the method object after the above patch
        search_method
        @override_method = true if @method
        retry
      end
    rescue Exception
      raise unless added
    end

    def sig
      @key
    end

    def to_s
      if @method
        loc = @method.source_location || []
        "#{generate_label("Method")} #{sig} at #{loc.join(':')}"
      else
        "#{generate_label("Method (pending)")} #{sig}"
      end + super
    end
  end
end