class Ladybug::Debugger

Public Class Methods

new(preload_paths: []) click to toggle source

preload_paths (optional):

paths to pre-parse into Ruby code so that
later we can quickly respond to requests for breakpoint locations
# File lib/ladybug/debugger.rb, line 17
def initialize(preload_paths: [])
  @breakpoints = []

  @to_main_thread = Queue.new
  @from_main_thread = Queue.new

  @on_pause = -> {}
  @on_resume = -> {}

  @parsed_files = {}

  @break = nil

  # Todo: consider thread safety of mutating this hash
  Thread.new do
    preload_paths.each do |preload_path|
      parse(preload_path)
    end
  end
end

Public Instance Methods

debug() { || ... } click to toggle source
# File lib/ladybug/debugger.rb, line 46
def debug
  RubyVM::InstructionSequence.compile_option = {
    trace_instruction: true
  }
  Thread.current.set_trace_func trace_func

  yield
ensure
  RubyVM::InstructionSequence.compile_option = {
    trace_instruction: false
  }
  Thread.current.set_trace_func nil
end
evaluate(expression) click to toggle source
# File lib/ladybug/debugger.rb, line 84
def evaluate(expression)
  raise InvalidExpressionError if !parseable?(expression)

  @to_main_thread.push({
    command: 'eval',
    arguments: {
      expression: expression
    }
  })

  # Block on eval, returns result
  @from_main_thread.pop
end
get_possible_breakpoints(path:, start_num:, end_num:) click to toggle source

Given a filename line number range of a requested breakpoint, give the line numbers of possible breakpoints.

In practice, start and end number tend to be the same when Chrome devtools is the client.

A breakpoint can be set at the beginning of any Ruby statement. (more details in line_numbers_with_code)

# File lib/ladybug/debugger.rb, line 143
def get_possible_breakpoints(path:, start_num:, end_num:)
  (start_num..end_num).to_a & line_numbers_with_code(path)
end
on_pause(&block) click to toggle source
# File lib/ladybug/debugger.rb, line 60
def on_pause(&block)
  @on_pause = block
end
on_resume(&block) click to toggle source
# File lib/ladybug/debugger.rb, line 64
def on_resume(&block)
  @on_resume = block
end
remove_breakpoint(breakpoint_id) click to toggle source
# File lib/ladybug/debugger.rb, line 128
def remove_breakpoint(breakpoint_id)
  filename, line_number = breakpoint_id.split(":")
  line_number = line_number.to_i

  @breakpoints.delete_if { |bp| bp[:id] == breakpoint_id }
end
resume() click to toggle source
# File lib/ladybug/debugger.rb, line 68
def resume
  @to_main_thread.push({ command: 'continue' })
end
set_breakpoint(filename:, line_number:) click to toggle source

Sets a breakpoint and returns a breakpoint object.

# File lib/ladybug/debugger.rb, line 99
def set_breakpoint(filename:, line_number:)
  possible_lines = line_numbers_with_code(filename)

  # It's acceptable for a caller to request setting a breakpoint
  # on a location where it's not possible to set a breakpoint.
  # In this case, we set a breakpoint on the next possible location.
  if !possible_lines.include?(line_number)
    line_number = possible_lines.sort.find { |ln| ln > line_number }
  end

  # Sometimes the above check will fail and there's no possible breakpoint.
  if line_number.nil?
    raise InvalidBreakpointLocationError,
          "invalid breakpoint line: #{filename}:#{line_number}"
  end

  breakpoint = {
    # We need to use the absolute path here
    # because set_trace_func ends up checking against that
    filename: File.absolute_path(filename),
    line_number: line_number,
    id: "#{filename}:#{line_number}"
  }

  @breakpoints.push(breakpoint)

  breakpoint
end
start() click to toggle source
# File lib/ladybug/debugger.rb, line 38
def start
  RubyVM::InstructionSequence.compile_option = {
    trace_instruction: true
  }

  set_trace_func trace_func
end
step_into() click to toggle source
# File lib/ladybug/debugger.rb, line 76
def step_into
  @to_main_thread.push({ command: 'step_into' })
end
step_out() click to toggle source
# File lib/ladybug/debugger.rb, line 80
def step_out
  @to_main_thread.push({ command: 'step_out' })
end
step_over() click to toggle source
# File lib/ladybug/debugger.rb, line 72
def step_over
  @to_main_thread.push({ command: 'step_over' })
end

Private Instance Methods

break_on_step?(filename:) click to toggle source

If we're in step over/in/out mode, detect if we should break even if there's not a breakpoint set here

# File lib/ladybug/debugger.rb, line 168
def break_on_step?(filename:)
  # This is an important early return;
  # we don't want to do anything w/ the callstack unless
  # we're looking for a breakpoint, because
  # that adds time to every single line of code execution
  # which makes things really slow
  return false if @break.nil?

  return false if @break == 'step_over' &&
                  @breakpoint_filename != filename

  bp_callstack = clean(@breakpoint_callstack)
  current_callstack = clean(Thread.current.backtrace_locations)

  if @break == 'step_over'
    return current_callstack[1].to_s == bp_callstack[1].to_s
  elsif @break == 'step_into'
    return current_callstack[1].to_s == bp_callstack[0].to_s
  elsif @break == 'step_out'
    return current_callstack[0].to_s == bp_callstack[1].to_s
  end
end
clean(callstack) click to toggle source

remove ladybug code from a callstack and prepare it for comparison this is a hack implemenetation for now, can be made better

# File lib/ladybug/debugger.rb, line 162
def clean(callstack)
  callstack.drop_while { |frame| frame.to_s.include? "ladybug/debugger.rb" }
end
deep_child_node_types(ast) click to toggle source

Return all unique types of AST nodes under this node, including the node itself.

We memoize this because we repeatedly hit this for each AST

# File lib/ladybug/debugger.rb, line 323
def deep_child_node_types(ast)
  types = ast.children.flat_map do |child|
    deep_child_node_types(child) if child.is_a? AST::Node
  end.compact + [ast.type]

  types.uniq
end
line_numbers_with_code(path) click to toggle source

get valid breakpoint lines for a file, with a memoize cache todo: we don't really need to cache this; parsing is the slow part

# File lib/ladybug/debugger.rb, line 283
def line_numbers_with_code(path)
  ast = parse(path)
  single_statement_lines(ast)
end
parse(path) click to toggle source
# File lib/ladybug/debugger.rb, line 288
def parse(path)
  if !@parsed_files.key?(path)
    code = File.read(path)
    @parsed_files[path] = Parser::CurrentRuby.parse(code)
  end

  @parsed_files[path]
end
parseable?(expression) click to toggle source

Try parsing an expression to see if we can safely eval it

# File lib/ladybug/debugger.rb, line 150
def parseable?(expression)
  begin
    Parser::CurrentRuby.parse(expression)
  rescue Parser::SyntaxError
    return false
  end

  return true
end
single_statement_lines(ast) click to toggle source

A breakpoint can be set at the beginning of any node where there is no begin (i.e. multi-line) node anywhere under the node

# File lib/ladybug/debugger.rb, line 299
def single_statement_lines(ast)
  child_types = deep_child_node_types(ast)

  if !child_types.include?(:begin) && !child_types.include?(:kwbegin)
    expr = ast.loc.expression

    if !expr.nil?
      expr.begin.line
    else
      nil
    end
  else
    ast.children.
      select { |child| child.is_a? AST::Node }.
      flat_map { |child| single_statement_lines(child) }.
      compact.
      uniq
  end
end
trace_func() click to toggle source
# File lib/ladybug/debugger.rb, line 191
def trace_func
  proc { |event, filename, line_number, id, binding, klass, *rest|
    # This check is called a lot so perhaps worth making faster,
    # but might not matter much with small number of breakpoints in practice
    breakpoint_hit = @breakpoints.find do |bp|
      bp[:filename] == filename && bp[:line_number] == line_number
    end

    if breakpoint_hit || break_on_step?(filename: filename)
      local_variables =
        binding.local_variables.each_with_object({}) do |lvar, hash|
          hash[lvar] = binding.local_variable_get(lvar)
        end

      # todo: may want to offer classes the ability to
      # override this and define which instance variables to expose here?

      instance_variables =
        binding.eval("instance_variables").each_with_object({}) do |ivar, hash|
          hash[ivar] = binding.eval("instance_variable_get(:#{ivar})")
        end

      pause_info = {
        breakpoint_id: breakpoint_hit ? breakpoint_hit[:id] : nil,
        label: Kernel.caller_locations.first.base_label,
        local_variables: local_variables,
        instance_variables: instance_variables,
        filename: filename,
        line_number: line_number
        # call_frames: []

        # Call frames are pretty complicated...
        # call_frames: Kernel.caller_locations.first(3).map do |call_frame|
        #   {
        #     callFrameId: SecureRandom.uuid,
        #     functionName: call_frame.base_label,
        #     scopeChain: [
        #       {
        #         type: "local",
        #         startLocation: ,
        #         endLocation:
        #       }
        #     ],
        #     url: "#{"http://rails.com"}/#{filename}",
        #     this:
        #   }
        # end
      }

      @on_pause.call(pause_info)

      loop do
        # block until we get a command from the debugger thread
        message = @to_main_thread.pop

        case message[:command]
        when 'continue'
          @break = nil
          @breakpoint_filename = nil
          break
        when 'step_over'
          @break = 'step_over'
          @breakpoint_callstack = Thread.current.backtrace_locations
          @breakpoint_filename = filename
          break
        when 'step_into'
          @break = 'step_into'
          @breakpoint_callstack = Thread.current.backtrace_locations
          break
        when 'step_out'
          @break = 'step_out'
          @breakpoint_callstack = Thread.current.backtrace_locations
          break
        when 'eval'
          evaluated =
            begin
              binding.eval(message[:arguments][:expression])
            rescue
              nil
            end
          @from_main_thread.push(evaluated)
        end
      end

      # Notify the debugger thread that we've resumed
      @on_resume.call({})
    end
  }
end