# 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