Skip to content
This repository has been archived by the owner on May 4, 2024. It is now read-only.

Use sorbet #67

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
4 changes: 3 additions & 1 deletion .github/workflows/ruby.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ jobs:
bundle install --jobs 4 --retry 3

- name: Run test with Rails ${{ matrix.rails_version }}
run: bundle exec rake
run: |
bundle exec rake
bundle exec srb tc

- name: Publish Test Coverage
uses: paambaati/codeclimate-action@v2.6.0
Expand Down
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ source "https://rubygems.org"

# Specify your gem's dependencies in tapping_device.gemspec
gemspec

gem 'sorbet'
6 changes: 6 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ PATH
activesupport
pastel
pry
sorbet-runtime

GEM
remote: https://rubygems.org/
Expand Down Expand Up @@ -55,6 +56,10 @@ GEM
json (>= 1.8, < 3)
simplecov-html (~> 0.10.0)
simplecov-html (0.10.2)
sorbet (0.5.5891)
sorbet-static (= 0.5.5891)
sorbet-runtime (0.5.5891)
sorbet-static (0.5.5891-universal-darwin-14)
sqlite3 (1.4.1)
thread_safe (0.3.6)
tty-color (0.5.2)
Expand All @@ -71,6 +76,7 @@ DEPENDENCIES
rake (~> 13.0)
rspec (~> 3.0)
simplecov (= 0.17.1)
sorbet
sqlite3 (>= 1.3.6)
tapping_device!

Expand Down
80 changes: 61 additions & 19 deletions lib/tapping_device.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
# typed: true
require 'sorbet-runtime'
require "active_record"
require "active_support/core_ext/module/introspection"
require "pry" # for using Method#source

require "tapping_device/types/call_site"
require "tapping_device/version"
require "tapping_device/manageable"
require "tapping_device/payload"
Expand All @@ -17,9 +20,10 @@
require "tapping_device/trackers/mutation_tracker"

class TappingDevice
extend T::Sig

CALLER_START_POINT = 3
C_CALLER_START_POINT = 2
CALLER_START_POINT = 6
C_CALLER_START_POINT = 5

attr_reader :options, :calls, :trace_point, :target

Expand All @@ -31,33 +35,39 @@ class TappingDevice
include Configurable
include Output::Helpers

sig{params(options: Hash, block: T.nilable(Proc)).void}
def initialize(options = {}, &block)
@block = block
@output_block = nil
@options = process_options(options.dup)
@calls = []
@disabled = false
@with_condition = nil
@block = T.let(block, T.nilable(Proc))
@output_block = T.let(nil, T.nilable(Proc))
@options = T.let(process_options(options.dup), T::Hash[Symbol, T.untyped])
@calls = T.let([], T::Array[Payload])
@disabled = T.let(false, T::Boolean)
@with_condition = T.let(nil, T.nilable(Proc))
TappingDevice.devices << self
end

sig{params(block: T.nilable(Proc)).void}
def with(&block)
@with_condition = block
end

sig{params(block: T.nilable(Proc)).void}
def set_block(&block)
@block = block
end

sig{void}
def stop!
@disabled = true
TappingDevice.delete_device(self)
end

sig{params(block: T.nilable(Proc)).void}
def stop_when(&block)
@stop_when = block
end

sig{returns(TappingDevice)}
def create_child_device
new_device = self.class.new(@options.merge(root_device: root_device), &@block)
new_device.stop_when(&@stop_when)
Expand All @@ -66,21 +76,24 @@ def create_child_device
new_device
end

sig{returns(TappingDevice)}
def root_device
options[:root_device]
end

sig{returns(T::Array[TappingDevice])}
def descendants
options[:descendants]
end

sig{params(object: T.untyped).returns(TappingDevice)}
def track(object)
@target = object
validate_target!

MethodHijacker.new(@target).hijack_methods! if options[:hijack_attr_methods]

@trace_point = build_minimum_trace_point(event_type: options[:event_type]) do |payload|
@trace_point = build_minimum_trace_point(Array(options[:event_type])) do |payload|
record_call!(payload)

stop_if_condition_fulfilled!(payload)
Expand All @@ -93,16 +106,19 @@ def track(object)

private

def build_minimum_trace_point(event_type:)
TracePoint.new(*event_type) do |tp|
sig{params(event_types: T::Array[Symbol]).returns(TracePoint)}
def build_minimum_trace_point(event_types)
# sorbet doesn't accept splat arguments
# see https://sorbet.org/docs/error-reference#7019
T.unsafe(TracePoint).new(*event_types) do |tp|
next unless filter_condition_satisfied?(tp)

filepath, line_number = get_call_location(tp)
payload = build_payload(tp: tp, filepath: filepath, line_number: line_number)
call_site = get_call_location(tp)
payload = build_payload(tp: tp, call_site: call_site)

unless @options[:force_recording]
next if is_tapping_device_call?(tp)
next if should_be_skipped_by_paths?(filepath)
next if should_be_skipped_by_paths?(call_site.filepath)
next unless with_condition_satisfied?(payload)
next if payload.is_private_call? && @options[:ignore_private]
next if !payload.is_private_call? && @options[:only_private]
Expand All @@ -112,18 +128,22 @@ def build_minimum_trace_point(event_type:)
end
end

sig{void}
def validate_target!; end

sig {params(tp: TracePoint).returns(T::Boolean)}
def filter_condition_satisfied?(tp)
false
end

# this needs to be placed upfront so we can exclude noise before doing more work
sig {params(filepath: String).returns(T::Boolean)}
def should_be_skipped_by_paths?(filepath)
options[:exclude_by_paths].any? { |pattern| pattern.match?(filepath) } ||
(options[:filter_by_paths].present? && !options[:filter_by_paths].any? { |pattern| pattern.match?(filepath) })
end

sig {params(tp: TracePoint).returns(T::Boolean)}
def is_tapping_device_call?(tp)
if tp.defined_class == TappingDevice::Trackable || tp.defined_class == TappingDevice
return true
Expand All @@ -136,20 +156,22 @@ def is_tapping_device_call?(tp)
end
end

sig {params(payload: Payload).returns(T::Boolean)}
def with_condition_satisfied?(payload)
@with_condition.blank? || @with_condition.call(payload)
end

def build_payload(tp:, filepath:, line_number:)
sig {params(tp: TracePoint, call_site: Types::CallSite).returns(Payload)}
def build_payload(tp:, call_site:)
Payload.init({
target: @target,
receiver: tp.self,
method_name: tp.callee_id,
method_object: get_method_object_from(tp.self, tp.callee_id),
arguments: collect_arguments(tp),
return_value: (tp.return_value rescue nil),
filepath: filepath,
line_number: line_number,
filepath: call_site.filepath,
line_number: call_site.line_number,
defined_class: tp.defined_class,
trace: get_traces(tp),
is_private_call?: tp.defined_class.private_method_defined?(tp.callee_id),
Expand All @@ -158,6 +180,7 @@ def build_payload(tp:, filepath:, line_number:)
})
end

sig {params(target: T.untyped, method_name: Symbol).returns(T.nilable(Method))}
def get_method_object_from(target, method_name)
Object.instance_method(:method).bind(target).call(method_name)
rescue NameError
Expand All @@ -166,10 +189,15 @@ def get_method_object_from(target, method_name)
nil
end

sig {params(tp: TracePoint, padding: Integer).returns(Types::CallSite)}
def get_call_location(tp, padding: 0)
caller(get_trace_index(tp) + padding).first.split(":")[0..1]
traces = caller(get_trace_index(tp) + padding) || []
target_trace = traces.first || ""
filepath, line_number = target_trace.split(":")
Types::CallSite.new(filepath: filepath || "", line_number: line_number || "")
end

sig {params(tp: TracePoint).returns(Integer)}
def get_trace_index(tp)
if tp.event == :c_call
C_CALLER_START_POINT
Expand All @@ -178,15 +206,17 @@ def get_trace_index(tp)
end
end

sig {params(tp: TracePoint).returns(T::Array[String])}
def get_traces(tp)
if with_trace_to = options[:with_trace_to]
trace_index = get_trace_index(tp)
caller[trace_index..(trace_index + with_trace_to)]
Array(caller[trace_index..(trace_index + with_trace_to)])
else
[]
end
end

sig {params(tp: TracePoint).returns(T::Hash[Symbol, T.untyped])}
def collect_arguments(tp)
parameters =
if RUBY_VERSION.to_f >= 2.6
Expand All @@ -200,6 +230,7 @@ def collect_arguments(tp)
end
end

sig {params(options: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped])}
def process_options(options)
options[:filter_by_paths] ||= config[:filter_by_paths]
options[:exclude_by_paths] ||= config[:exclude_by_paths]
Expand All @@ -214,22 +245,30 @@ def process_options(options)

options[:descendants] ||= []
options[:root_device] ||= self

options[:exclude_by_paths] << /sorbet-runtime/
options
end

sig {params(tp: TracePoint).returns(T::Boolean)}
def is_from_target?(tp)
comparsion = tp.self
is_the_same_record?(comparsion) || target.__id__ == comparsion.__id__
end

sig {params(comparsion: T.untyped).returns(T::Boolean)}
def is_the_same_record?(comparsion)
return false unless options[:track_as_records]

if target.is_a?(ActiveRecord::Base) && comparsion.is_a?(target.class)
primary_key = target.class.primary_key
target.send(primary_key) && target.send(primary_key) == comparsion.send(primary_key)
else
false
end
end

sig {params(payload: Payload).void}
def record_call!(payload)
return if @disabled

Expand All @@ -242,17 +281,20 @@ def record_call!(payload)
end
end

sig {params(payload: Payload).void}
def write_output!(payload)
@output_writer.write!(payload)
end

sig {params(payload: Payload).void}
def stop_if_condition_fulfilled!(payload)
if @stop_when&.call(payload)
stop!
root_device.stop!
end
end

sig {returns(T::Hash[Symbol, T.untyped])}
def config
TappingDevice.config
end
Expand Down
1 change: 1 addition & 0 deletions lib/tapping_device/configurable.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# typed: false
require "active_support/configurable"
require "active_support/concern"

Expand Down
1 change: 1 addition & 0 deletions lib/tapping_device/exceptions.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# typed: true
class TappingDevice
class Exception < StandardError
end
Expand Down
1 change: 1 addition & 0 deletions lib/tapping_device/manageable.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# typed: true
class TappingDevice
module Manageable

Expand Down
1 change: 1 addition & 0 deletions lib/tapping_device/method_hijacker.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# typed: true
class TappingDevice
class MethodHijacker
attr_reader :target
Expand Down
1 change: 1 addition & 0 deletions lib/tapping_device/output.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# typed: false
require "tapping_device/output/payload"
require "tapping_device/output/writer"
require "tapping_device/output/stdout_writer"
Expand Down
3 changes: 3 additions & 0 deletions lib/tapping_device/output/file_writer.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# typed: true
class TappingDevice
module Output
class FileWriter < Writer
sig {params(options: Hash, output_block: Proc).void}
def initialize(options, output_block)
@path = options[:log_file]

Expand All @@ -9,6 +11,7 @@ def initialize(options, output_block)
super
end

sig {params(payload: TappingDevice::Payload).void}
def write!(payload)
output = generate_output(payload)

Expand Down
9 changes: 5 additions & 4 deletions lib/tapping_device/output/payload.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# typed: true
require "pastel"

class TappingDevice
module Output
class Payload < Payload
UNDEFINED = "[undefined]"
class Payload < TappingDevice::Payload
UNDEFINED_MARK = "[undefined]"
PRIVATE_MARK = " (private)"

PASTEL = Pastel.new
Expand Down Expand Up @@ -127,8 +128,8 @@ def generate_string_result(obj, inspect)
array_to_string(obj, inspect)
when Hash
hash_to_string(obj, inspect)
when UNDEFINED
UNDEFINED
when UNDEFINED_MARK
UNDEFINED_MARK
when String
"\"#{obj}\""
when nil
Expand Down
2 changes: 2 additions & 0 deletions lib/tapping_device/output/stdout_writer.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# typed: true
class TappingDevice
module Output
class StdoutWriter < Writer
sig {params(payload: TappingDevice::Payload).void}
def write!(payload)
puts(generate_output(payload))
end
Expand Down