From fb1560b701a00c9fb3cabcbaf8e3c2355b52e107 Mon Sep 17 00:00:00 2001 From: Mike Blumtritt Date: Sun, 16 Jun 2024 22:01:25 +0200 Subject: [PATCH] Extend tests and samples --- README.md | 65 +++--- examples/foo.rb | 51 ++-- examples/kernel_calls.rb | 1 - lib/im-lost.rb | 279 ++++++++++++---------- lib/im-lost/version.rb | 2 +- spec/helper.rb | 2 +- spec/lib/im-lost_spec.rb | 491 ++++++++++++++++++++------------------- spec/support/matcher.rb | 32 +++ 8 files changed, 497 insertions(+), 426 deletions(-) create mode 100644 spec/support/matcher.rb diff --git a/README.md b/README.md index e344541..6255f00 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,13 @@ end # output will look like # > IO#<<(?) -# /projects/test.rb:1 +# /examples/test.rb:1 # > IO#write(*) -# /projects/test.rb:1 +# /examples/test.rb:1 # > IO#puts(*) -# /projects/test.rb:2 +# /examples/test.rb:2 # > IO#write(*) -# /projects/test.rb:2 +# /examples/test.rb:2 ``` When you need to know if exceptions are raised and handled you can use `ImLost.trace_exceptions`: @@ -42,11 +42,11 @@ end # output will look like # x Errno::EEXIST: File exists @ rb_sysopen - / -# /projects/test.rb:2 +# /examples/test.rb:2 # ! Errno::EEXIST: File exists @ rb_sysopen - / -# /projects/test.rb:3 +# /examples/test.rb:3 # x RuntimeError: something went wrong! -# /projects/test.rb:4 +# /examples/test.rb:4 ``` When you like to know if a code point is reached, `ImLost.here` will help: @@ -94,7 +94,6 @@ class Foo def bar = :bar end -ImLost.trace_results = true ImLost.trace(Foo) my_foo = Foo.create(value: :foo!) @@ -111,47 +110,55 @@ ImLost.vars(my_foo) # output will look like # > Foo.create(:foo!) -# /projects/foo.rb:25 +# /examples/foo.rb:24 # > Foo.new(*) -# /projects/foo.rb:6 +# /examples/foo.rb:6 # < Foo.new(*) -# = # +# /examples/foo.rb:6 +# = # # < Foo.create(:foo!) -# = # +# /examples/foo.rb:24 +# = # # > Foo#foo(1, *[], :none, **{}, &nil) -# /projects/foo.rb:28 +# /examples/foo.rb:27 # > Foo#bar() -# /projects/foo.rb:15 +# /examples/foo.rb:15 # < Foo#bar() +# /examples/foo.rb:15 # = :bar # < Foo#foo(1, *[], :none, **{}, &nil) +# /examples/foo.rb:27 # = "1-none-[]-{}-bar" -# = /projects/foo.rb:29 -# instance variables: -# @value: "1-none-[]-{}-bar" +# * /examples/foo.rb:28 +# > instance variables +# @value: "1-none-[]-{}-bar" # > Foo#foo(2, *[:a, :b, :c], :some, **{:name=>:value}, &nil) -# /projects/foo.rb:31 +# /examples/foo.rb:30 # > Foo#bar() -# /projects/foo.rb:15 +# /examples/foo.rb:15 # < Foo#bar() +# /examples/foo.rb:15 # = :bar # < Foo#foo(2, *[:a, :b, :c], :some, **{:name=>:value}, &nil) +# /examples/foo.rb:30 # = "2-some-[a,b,c]-{:name=>:value}-bar" -# = /projects/foo.rb:32 -# instance variables: -# @value: "2-some-[a,b,c]-{:name=>:value}-bar" -# > Foo#foo(3, *[], nil, **{}, &#) -# /projects/foo.rb:34 +# * /examples/foo.rb:31 +# > instance variables +# @value: "2-some-[a,b,c]-{:name=>:value}-bar" +# > Foo#foo(3, *[], nil, **{}, &#) +# /examples/foo.rb:33 # > Foo#bar() -# /projects/foo.rb:15 +# /examples/foo.rb:15 # < Foo#bar() +# /examples/foo.rb:15 # = :bar # 3--[]-{}-bar -# < Foo#foo(3, *[], nil, **{}, &#) +# < Foo#foo(3, *[], nil, **{}, &#) +# /examples/foo.rb:33 # = nil -# = /projects/foo.rb:35 -# instance variables: -# @value: "3--[]-{}-bar" +# * /examples/foo.rb:34 +# > instance variables +# @value: "3--[]-{}-bar" ``` See [examples dir](./examples) for moreā€¦ diff --git a/examples/foo.rb b/examples/foo.rb index ac21211..7cc7933 100644 --- a/examples/foo.rb +++ b/examples/foo.rb @@ -19,7 +19,6 @@ def foo(arg, *args, key: nil, **kw_args, &block) def bar = :bar end -ImLost.trace_results = true ImLost.trace(Foo) my_foo = Foo.create(value: :foo!) @@ -36,44 +35,52 @@ def bar = :bar # output will look like # > Foo.create(:foo!) -# /projects/foo.rb:25 +# /examples/foo.rb:24 # > Foo.new(*) -# /projects/foo.rb:6 +# /examples/foo.rb:6 # < Foo.new(*) -# = # +# /examples/foo.rb:6 +# = # # < Foo.create(:foo!) -# = # +# /examples/foo.rb:24 +# = # # > Foo#foo(1, *[], :none, **{}, &nil) -# /projects/foo.rb:28 +# /examples/foo.rb:27 # > Foo#bar() -# /projects/foo.rb:15 +# /examples/foo.rb:15 # < Foo#bar() +# /examples/foo.rb:15 # = :bar # < Foo#foo(1, *[], :none, **{}, &nil) +# /examples/foo.rb:27 # = "1-none-[]-{}-bar" -# = /projects/foo.rb:29 -# instance variables: -# @value: "1-none-[]-{}-bar" +# * /examples/foo.rb:28 +# > instance variables +# @value: "1-none-[]-{}-bar" # > Foo#foo(2, *[:a, :b, :c], :some, **{:name=>:value}, &nil) -# /projects/foo.rb:31 +# /examples/foo.rb:30 # > Foo#bar() -# /projects/foo.rb:15 +# /examples/foo.rb:15 # < Foo#bar() +# /examples/foo.rb:15 # = :bar # < Foo#foo(2, *[:a, :b, :c], :some, **{:name=>:value}, &nil) +# /examples/foo.rb:30 # = "2-some-[a,b,c]-{:name=>:value}-bar" -# = /projects/foo.rb:32 -# instance variables: -# @value: "2-some-[a,b,c]-{:name=>:value}-bar" -# > Foo#foo(3, *[], nil, **{}, &#) -# /projects/foo.rb:34 +# * /examples/foo.rb:31 +# > instance variables +# @value: "2-some-[a,b,c]-{:name=>:value}-bar" +# > Foo#foo(3, *[], nil, **{}, &#) +# /examples/foo.rb:33 # > Foo#bar() -# /projects/foo.rb:15 +# /examples/foo.rb:15 # < Foo#bar() +# /examples/foo.rb:15 # = :bar # 3--[]-{}-bar -# < Foo#foo(3, *[], nil, **{}, &#) +# < Foo#foo(3, *[], nil, **{}, &#) +# /examples/foo.rb:33 # = nil -# = /projects/foo.rb:35 -# instance variables: -# @value: "3--[]-{}-bar" +# * /examples/foo.rb:34 +# > instance variables +# @value: "3--[]-{}-bar" diff --git a/examples/kernel_calls.rb b/examples/kernel_calls.rb index 26ab92a..e99cf01 100644 --- a/examples/kernel_calls.rb +++ b/examples/kernel_calls.rb @@ -9,7 +9,6 @@ require 'im-lost' -ImLost.trace_results = true ImLost.trace(Kernel, Object, Module, Class, self) do puts '=' * 79 pp Class.new diff --git a/lib/im-lost.rb b/lib/im-lost.rb index f2125f4..d946957 100644 --- a/lib/im-lost.rb +++ b/lib/im-lost.rb @@ -26,9 +26,10 @@ def caller_locations=(value) # # The output device used to write information. - # This should be an `IO` device or any other object responding to `#puts`. + # This should be an `IO` device or any other object responding to `#<<` + # like a Logger. # - # `$stderr` is configured by default. + # `STDERR` is configured by default. # # @example Write to a file # ImLost.output = File.new('./trace', 'w') @@ -50,8 +51,13 @@ def caller_locations=(value) attr_reader :output def output=(value) - return @output = value if value.respond_to?(:puts) - raise(ArgumentError, "invalid output device - #{value.inspect}") + return @output = value if defined?(value.<<) + raise( + NoMethodError, + "undefined method `<<' for an instance of #{ + Kernel.instance_method(:class).bind(value).call + }" + ) end # @@ -89,11 +95,11 @@ def trace_calls=(value) # # # output will look like # # x Errno::EEXIST: File exists @ rb_sysopen - / - # # /projects/test.rb:2 + # # /examples/test.rb:2 # # ! Errno::EEXIST: File exists @ rb_sysopen - / - # # /projects/test.rb:3 + # # /examples/test.rb:3 # # x RuntimeError: something went wrong! - # # /projects/test.rb:4 + # # /examples/test.rb:4 # # @param with_locations [Boolean] wheter the locations should be included # into the exception trace information @@ -113,7 +119,7 @@ def trace_exceptions(with_locations: true) # # Enables/disables tracing of returned valuess of method calls. - # This is disabled by default. + # This is enabled by default. # # @attribute [r] trace_results # @return [Boolean] whether return values will be traced @@ -157,7 +163,7 @@ def trace_results=(value) def here(test = true) return test if !test || (block_given? && !(test = yield)) loc = Kernel.caller_locations(1, 1)[0] - @output.puts(": #{loc.path}:#{loc.lineno}") + @output << "* #{loc.path}:#{loc.lineno}\n" test end @@ -181,13 +187,13 @@ def here(test = true) # # # output will look like # # > IO#<<(?) - # # /projects/test.rb:1 + # # /examples/test.rb:1 # # > IO#write(*) - # # /projects/test.rb:1 + # # /examples/test.rb:1 # # > IO#puts(*) - # # /projects/test.rb:2 + # # /examples/test.rb:2 # # > IO#write(*) - # # /projects/test.rb:2 + # # /examples/test.rb:2 # # @overload trace(*args) # @param args [[Object]] one or more objects to be traced @@ -209,6 +215,14 @@ def trace(*args, &block) args.size == 1 ? _trace_b(args[0], &block) : _trace_all_b(args, &block) end + # + # Test if a given object is currently traced. + # + # @param arg [Object] object to be tested + # @return [Boolean] wheter the object is beeing traced + # + def traced?(obj) = @trace.key?(obj) + # # Stop tracing objects. # @@ -225,8 +239,8 @@ def trace(*args, &block) # @return [nil] when none of the objects was traced before # def untrace(*args) - ret = args.filter_map { @trace.delete(_1.__id__) ? _1 : nil } - args.size == 1 ? ret[0] : ret + args = args.filter_map { @trace.delete(_1) } + args.size < 2 ? args[0] : args end # @@ -289,119 +303,102 @@ def untrace_all! # @return [Object] the given object # def vars(object) - traced = @trace.delete(object.__id__) - return _local_vars(object) if Binding === object + out = Out.new + traced = @trace.delete(object) + return _local_vars(out, object) if Binding === object location = Kernel.caller_locations(1, 1)[0] - @output.puts("= #{location.path}:#{location.lineno}") - if Thread === object - _thread_vars(object) - elsif @fiber_supported && Fiber === object - _fiber_vars(object) - elsif defined?(object.instance_variables) - _instance_vars(object) - else - @output.puts(' !!! unable to retrieve vars') - end + out << "* #{location.path}:#{location.lineno}" + return _thread_vars(out, object) if Thread === object + return _fiber_vars(out, object) if @fiber_supported && Fiber === object + return _instance_vars(out, object) if defined?(object.instance_variables) + out << ' !!! unable to retrieve vars' object ensure @trace[traced] = traced if traced + out.flush(@output) end private - def as_sig(prefix, info, args) - args = args.join(', ') - case info.self - when Class, Module - "#{prefix} #{info.self}.#{info.method_id}(#{args})" - else - "#{prefix} #{info.defined_class}##{info.method_id}(#{args})" - end + def _can_trace?(arg) + (id = arg.__id__) != __id__ && id != @output.__id__ end def _trace(arg) - id = arg.__id__ - @trace[id] = id if __id__ != id && @output.__id__ != id + @trace[arg] = arg if _can_trace?(arg) arg end def _trace_all(args) - args.each do |arg| - arg = arg.__id__ - @trace[arg] = arg if __id__ != arg && @output.__id__ != arg - end + args.each { |arg| @trace[arg] = arg if _can_trace?(arg) } args end def _trace_b(arg) - id = arg.__id__ - return yield(arg) if __id__ == id || @output.__id__ == id + return yield(arg) if @trace.key?(arg) || !_can_trace?(arg) begin - @trace[id] = id + @trace[arg] = arg yield(arg) ensure - @trace.delete(id) if id + @trace.delete(arg) end end def _trace_all_b(args) - ids = + temp = args.filter_map do |arg| - arg = arg.__id__ - @trace[arg] = arg if __id__ != arg && @output.__id__ != arg + @trace[arg] = arg if !@trace.key?(arg) && _can_trace?(arg) end yield(args) ensure - ids.each { @trace.delete(_1) } + temp.each { @trace.delete(_1) } end - def _local_vars(binding) - @output.puts("= #{binding.source_location.join(':')}") - _print_vars('local variables', binding.local_variables) do |name| + def _local_vars(out, binding) + out << "* #{binding.source_location.join(':')}" + out.vars('local variables', binding.local_variables) do |name| binding.local_variable_get(name) end binding end - def _thread_vars(thread) - @output.puts(" #{_thread_identifier(thread)}") - fvars = thread.keys - unless fvars.empty? - _print_vars('fiber-local variables', fvars) { thread[_1] } - end - _print_vars('thread variables', thread.thread_variables) do |name| + def _thread_vars(out, thread) + out << " #{_thread_identifier(thread)}" + flv = thread.keys + out.vars('fiber-local variables', flv) { thread[_1] } unless flv.empty? + out.vars('thread variables', thread.thread_variables) do |name| thread.thread_variable_get(name) end + thread end - def _fiber_vars(fiber) + def _fiber_vars(out, fiber) if Fiber.current == fiber storage = fiber.storage || {} - return _print_vars('fiber storage', storage.keys) { storage[_1] } + out.vars('fiber storage', storage.keys) { storage[_1] } + else + out << ' !!! given Fiber is not the current Fiber' << + " #{fiber.inspect}" end - @output.puts( - ' !!! given Fiber is not the current Fiber', - " #{fiber}" - ) + fiber end - def _instance_vars(object) - _print_vars('instance variables', object.instance_variables) do |name| - object.instance_variable_get(name) + def _instance_vars(out, object) + out.vars('instance variables', object.instance_variables) do |n| + object.instance_variable_get(n) end + object end def _thread_identifier(thread) "#{THREAD_STATE[thread.status] || thread.status} Thread #{ - @thread_id[thread] + if defined?(thread.native_thread_id) + thread.native_thread_id + else + thread.__id__ + end } #{thread.name}".rstrip end - - def _print_vars(kind, names) - return @output.puts(" ") if names.empty? - @output.puts(" > #{kind}") - names.sort!.each { @output.puts(" #{_1}: #{yield(_1).inspect}") } - end end # @@ -538,6 +535,30 @@ def initialize(&block) end end + class Out + def initialize(*lines) = (@lines = lines) + def <<(str) = @lines << str + def location(loc) = @lines << " #{loc.path}:#{loc.lineno}" + def flush(dev) = dev << (@lines << nil).join("\n") + + def sig(prefix, info, args) + args = args.join(', ') + @lines << case info.self + when Class, Module + "#{prefix} #{info.self}.#{info.method_id}(#{args})" + else + "#{prefix} #{info.defined_class}##{info.method_id}(#{args})" + end + end + + def vars(kind, names) + return @lines << " " if names.empty? + @lines << " > #{kind}" + names.sort!.each { @lines << " #{_1}: #{yield(_1).inspect}" } + end + end + private_constant :Out + ARG_SIG = { rest: '*', keyrest: '**', block: '&' }.compare_by_identity.freeze NO_NAME = { :* => 1, :** => 1, :& => 1 }.compare_by_identity.freeze THREAD_STATE = { @@ -547,83 +568,85 @@ def initialize(&block) private_constant :ARG_SIG, :NO_NAME, :THREAD_STATE @trace = {}.compare_by_identity - @caller_locations = true - @output = $stderr.respond_to?(:puts) ? $stderr : STDERR - - @timer = - TimerStore.new do |title, location, time| - @output.puts( - "T #{title}: #{time ? "#{time} sec." : 'created'}", - " #{location.path}:#{location.lineno}" - ) - end + @caller_locations = @exception_locations = true + @output = STDERR + + @timer = TimerStore.new { |title, location, time| @output << <<~TIMER_MSG } + T #{title}: #{time ? "#{time} sec." : 'created'} + #{location.path}:#{location.lineno} + TIMER_MSG TimerStore.private_class_method(:new) @trace_calls = [ TracePoint.new(:c_call) do |tp| - next if !@trace.key?(tp.self.__id__) || tp.path == __FILE__ - @output.puts(as_sig('>', tp, tp.parameters.map { ARG_SIG[_1[0]] || '?' })) - @output.puts(" #{tp.path}:#{tp.lineno}") if @caller_locations + next if !@trace.key?(tp.self) || tp.path == __FILE__ + out = Out.new + out.sig('>', tp, tp.parameters.map { ARG_SIG[_1[0]] || '?' }) + out.location(tp) if @caller_locations + out.flush(@output) end, TracePoint.new(:call) do |tp| - next if !@trace.key?(tp.self.__id__) || tp.path == __FILE__ + next if !@trace.key?(tp.self) || tp.path == __FILE__ ctx = tp.binding - @output.puts( - as_sig( - '>', - tp, - tp.parameters.map do |kind, name| - next name if NO_NAME.key?(name) - "#{ARG_SIG[kind]}#{ctx.local_variable_get(name).inspect}" - end - ) + out = Out.new + out.sig( + '>', + tp, + tp.parameters.map do |kind, name| + next name if NO_NAME.key?(name) + "#{ARG_SIG[kind]}#{ctx.local_variable_get(name).inspect}" + end ) - next unless @caller_locations - loc = ctx.eval('caller_locations(4,1)')[0] - @output.puts(" #{loc.path}:#{loc.lineno}") + out.location(ctx.eval('caller_locations(4,1)')[0]) if @caller_locations + out.flush(@output) end ] @trace_results = [ TracePoint.new(:c_return) do |tp| - next if !@trace.key?(tp.self.__id__) || tp.path == __FILE__ - @output.puts(as_sig('<', tp, tp.parameters.map { ARG_SIG[_1[0]] || '?' })) - @output.puts(" = #{tp.return_value.inspect}") + next if !@trace.key?(tp.self) || tp.path == __FILE__ + out = Out.new + out.sig('<', tp, tp.parameters.map { ARG_SIG[_1[0]] || '?' }) + out.location(tp) if @caller_locations + out << " = #{tp.return_value.inspect}" + out.flush(@output) end, TracePoint.new(:return) do |tp| - next if !@trace.key?(tp.self.__id__) || tp.path == __FILE__ + next if !@trace.key?(tp.self) || tp.path == __FILE__ ctx = tp.binding - @output.puts( - as_sig( - '<', - tp, - tp.parameters.map do |kind, name| - next name if NO_NAME.key?(name) - "#{ARG_SIG[kind]}#{ctx.local_variable_get(name).inspect}" - end - ) + out = Out.new + out.sig( + '<', + tp, + tp.parameters.map do |kind, name| + next name if NO_NAME.key?(name) + "#{ARG_SIG[kind]}#{ctx.local_variable_get(name).inspect}" + end ) - @output.puts(" = #{tp.return_value.inspect}") + out.location(ctx.eval('caller_locations(4,1)')[0]) if @caller_locations + out << " = #{tp.return_value.inspect}" + out.flush(@output) end ] supported = RUBY_VERSION.to_f < 3.3 ? %i[raise] : %i[raise rescue] @trace_exceptions = TracePoint.new(*supported) do |tp| - ex = tp.raised_exception.inspect - @output.puts( - "#{tp.event == :raise ? 'x' : '!'} #{ex[0] == '#' ? ex[2..-2] : ex}" - ) - @output.puts(" #{tp.path}:#{tp.lineno}") if @exception_locations + ex = tp.raised_exception + mark, parent = tp.event == :rescue ? ['!', ex.cause] : 'x' + ex = ex.inspect + out = Out.new("#{mark} #{ex[0] == '#' ? ex[2..-2] : ex}") + while parent + ex = parent.inspect + out << " [#{ex[0] == '#' ? ex[2..-2] : ex}]" + parent = parent.cause + end + out.location(tp) if @exception_locations + out.flush(@output) end - @fiber_supported = !!(defined?(Fiber.current) && defined?(Fiber.storage)) - @thread_id = - if defined?(Thread.current.native_thread_id) - lambda(&:native_thread_id) - else - lambda(&:__id__) - end + @fiber_supported = + !!(defined?(Fiber.current) && defined?(Fiber.current.storage)) - self.trace_calls = true + self.trace_calls = self.trace_results = true end diff --git a/lib/im-lost/version.rb b/lib/im-lost/version.rb index a472847..d65fcd7 100644 --- a/lib/im-lost/version.rb +++ b/lib/im-lost/version.rb @@ -2,5 +2,5 @@ module ImLost # The version number of the gem. - VERSION = '1.1.0' + VERSION = '1.2.0' end diff --git a/spec/helper.rb b/spec/helper.rb index 5d85f55..3d2a2b5 100644 --- a/spec/helper.rb +++ b/spec/helper.rb @@ -4,7 +4,7 @@ RSpec.configure(&:disable_monkey_patching!) -require 'stringio' +require_relative 'support/matcher' RE_FLOAT = '[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?' diff --git a/spec/lib/im-lost_spec.rb b/spec/lib/im-lost_spec.rb index ebb205d..72dda51 100644 --- a/spec/lib/im-lost_spec.rb +++ b/spec/lib/im-lost_spec.rb @@ -1,38 +1,30 @@ # frozen_string_literal: true class TestSample - def initialize - @state = :created - end - - def add(arg0, arg1) - @result = arg0 + arg1 - end + attr_reader :foo + attr_accessor :bar - def add_kw(arg0:, arg1:) - @result = arg0 + arg1 + def initialize + @foo = 20 + @bar = 22 end - def add_block(arg0, &block) = add(arg0, block&.call || 42) + def add(arg0, arg1) = arg0 + arg1 + def add_kw(arg0:, arg1:) = arg0 + arg1 + def add_block(arg, &block) = arg + block[] def map(*args) = args.map(&:to_s) def insp(**kw_args) = kw_args.inspect def fwd(...) = add(...) - def foo = :foo - def bar = :bar end RSpec.describe ImLost do let(:sample) { TestSample.new } - let(:output) { ImLost.output.string } - - before { ImLost.output = StringIO.new } - after { ImLost.untrace_all! } it 'has defined default attributes' do is_expected.to have_attributes( caller_locations: true, trace_calls: true, - trace_results: false + trace_results: true ) end @@ -44,96 +36,97 @@ def bar = :bar ImLost.trace(sample) end - it 'traces method calls' do - sample.foo - sample.bar + after { ImLost.untrace_all! } - expect(output).to eq "> TestSample#foo()\n> TestSample#bar()\n" + it 'traces method calls' do + expect { sample.foo + sample.bar }.to write <<~OUTPUT + > TestSample#foo() + > TestSample#bar() + OUTPUT end it 'includes arguments in call signatures' do - sample.add(21, 21) - - expect(output).to eq "> TestSample#add(21, 21)\n" + expect { sample.add(21, 21) }.to write "> TestSample#add(21, 21)\n" end it 'includes keyword arguments in call signatures' do - sample.add_kw(arg0: 21, arg1: 21) - - expect(output).to eq "> TestSample#add_kw(21, 21)\n" + expect { sample.add_kw(arg0: 21, arg1: 21) }.to write( + "> TestSample#add_kw(21, 21)\n" + ) end it 'includes block arguments in call signatures' do - block = proc { 42 } - sample.add_block(21, &block) - - expect(output).to eq <<~OUTPUT - > TestSample#add_block(21, &#{block.inspect}) - > TestSample#add(21, 42) - OUTPUT + block = proc { 22 } + expect { sample.add_block(20, &block) }.to write( + "> TestSample#add_block(20, &#{block.inspect})\n" + ) end it 'includes splat arguments' do - sample.map(1, 2, 3, 4) - - expect(output).to eq "> TestSample#map(*[1, 2, 3, 4])\n" + expect { sample.map(1, 2, 3, 4) }.to write( + "> TestSample#map(*[1, 2, 3, 4])\n" + ) end it 'includes empty splat arguments' do - sample.map - - expect(output).to eq "> TestSample#map(*[])\n" + expect { sample.map }.to write "> TestSample#map(*[])\n" end it 'includes keyword splat arguments' do - sample.insp(a: 1, b: 2) - - expect(output).to eq "> TestSample#insp(**{:a=>1, :b=>2})\n" + expect { sample.insp(a: 1, b: 2) }.to write( + "> TestSample#insp(**{:a=>1, :b=>2})\n" + ) end it 'includes empty keyword splat arguments' do - sample.insp - - expect(output).to eq "> TestSample#insp(**{})\n" + expect { sample.insp }.to write "> TestSample#insp(**{})\n" end it 'handles argument forwarding' do - sample.fwd(40, 2) + expected = + if RUBY_VERSION.to_f < 3.1 + <<~OUTPUT + > TestSample#fwd(*, &) + > TestSample#add(40, 2) + OUTPUT + else + <<~OUTPUT + > TestSample#fwd(*, **, &) + > TestSample#add(40, 2) + OUTPUT + end - if RUBY_VERSION.to_f < 3.1 - expect(output).to eq <<~OUTPUT - > TestSample#fwd(*, &) - > TestSample#add(40, 2) - OUTPUT - else - expect(output).to eq <<~OUTPUT - > TestSample#fwd(*, **, &) - > TestSample#add(40, 2) - OUTPUT - end + expect { sample.fwd(40, 2) }.to write expected + end + + it 'can check if an object is traced' do + expect(ImLost.traced?(sample)).to be true + expect(ImLost.untrace(sample)).to be sample + expect(ImLost.traced?(sample)).to be false + expect(ImLost.traced?(BasicObject.new)).to be false end - it 'can trace an object in a block only' do - example = TestSample.new - example.foo - ImLost.trace(example) { |obj| obj.add(20, 22) } - example.foo + it 'can trace temporary' do + another = TestSample.new - expect(output).to eq "> TestSample#add(20, 22)\n" + expect do + another.map + ImLost.trace(another) { |obj| obj.add(20, 22) } + another.map + end.to write "> TestSample#add(20, 22)\n" end it 'can include caller locations' do ImLost.caller_locations = true - sample.foo - expect(output).to eq <<~OUTPUT + expect { sample.foo }.to write <<~OUTPUT > TestSample#foo() - #{__FILE__}:#{__LINE__ - 4} + #{__FILE__}:#{__LINE__ - 2} OUTPUT end end - context 'trace method call results' do + context 'trace method results' do before do ImLost.trace_calls = false ImLost.caller_locations = false @@ -141,172 +134,202 @@ def bar = :bar ImLost.trace(sample) end - it 'traces method call results' do - sample.foo - sample.bar + after { ImLost.untrace_all! } - expect(output).to eq <<~OUTPUT + it 'traces method call results' do + expect { sample.foo + sample.bar }.to write <<~OUTPUT < TestSample#foo() - = :foo + = 20 < TestSample#bar() - = :bar + = 22 OUTPUT end it 'includes arguments in call signatures' do - sample.add(21, 21) - - expect(output).to eq "< TestSample#add(21, 21)\n = 42\n" + expect { sample.add(21, 21) }.to write( + "< TestSample#add(21, 21)\n = 42\n" + ) end it 'includes block arguments in call signatures' do - block = proc { 42 } - sample.add_block(21, &block) - - expect(output).to eq <<~OUTPUT - < TestSample#add(21, 42) - = 63 - < TestSample#add_block(21, &#{block.inspect}) - = 63 + block = proc { 20 } + + expect { sample.add_block(22, &block) }.to write <<~OUTPUT + < TestSample#add_block(22, &#{block.inspect}) + = 42 OUTPUT end it 'includes splat arguments' do - sample.map(1, 2, 3, 4) - - expect(output).to eq <<~OUTPUT + expect { sample.map(1, 2, 3, 4) }.to write <<~OUTPUT < TestSample#map(*[1, 2, 3, 4]) = ["1", "2", "3", "4"] OUTPUT end it 'includes empty splat arguments' do - sample.map - expect(output).to eq "< TestSample#map(*[])\n = []\n" + expect { sample.map }.to write "< TestSample#map(*[])\n = []\n" end it 'includes keyword splat arguments' do - sample.insp(a: 1, b: 2) - - expect(output).to eq <<~OUTPUT + expect { sample.insp(a: 1, b: 2) }.to write <<~OUTPUT < TestSample#insp(**{:a=>1, :b=>2}) = "{:a=>1, :b=>2}" OUTPUT end it 'includes empty keyword splat arguments' do - sample.insp - - expect(output).to eq "< TestSample#insp(**{})\n = \"{}\"\n" + expect { sample.insp }.to write "< TestSample#insp(**{})\n = \"{}\"\n" end it 'handles argument forwarding' do - sample.fwd(40, 2) - - if RUBY_VERSION.to_f < 3.1 - expect(output).to eq <<~OUTPUT - < TestSample#add(40, 2) - = 42 - < TestSample#fwd(*, &) - = 42 - OUTPUT - else - expect(output).to eq <<~OUTPUT - < TestSample#add(40, 2) - = 42 - < TestSample#fwd(*, **, &) - = 42 - OUTPUT - end + expected = + if RUBY_VERSION.to_f < 3.1 + <<~OUTPUT + < TestSample#add(40, 2) + = 42 + < TestSample#fwd(*, &) + = 42 + OUTPUT + else + <<~OUTPUT + < TestSample#add(40, 2) + = 42 + < TestSample#fwd(*, **, &) + = 42 + OUTPUT + end + + expect { sample.fwd(40, 2) }.to write expected end - it 'can trace an object`s call results in a block only' do - example = TestSample.new - example.foo - ImLost.trace(example) { |obj| obj.add(20, 22) } - example.foo + it 'can trace temporary' do + another = TestSample.new - expect(output).to eq "< TestSample#add(20, 22)\n = 42\n" + expect do + another.map + ImLost.trace(another) { |obj| obj.add(20, 22) } + another.map + end.to write "< TestSample#add(20, 22)\n = 42\n" end end context '.trace_exceptions' do it 'traces exceptions and rescue blocks' do - raise_location = "#{__FILE__}:#{__LINE__ + 4}" - rescue_location = "#{__FILE__}:#{__LINE__ + 4}" - - ImLost.trace_exceptions do - raise(ArgumentError, 'not the answer - 21') - rescue ArgumentError - # nop - end - if RUBY_VERSION.to_f < 3.3 - expect(output).to eq <<~OUTPUT + expect do + ImLost.trace_exceptions do + raise(ArgumentError, 'not the answer - 21') + rescue ArgumentError + # nop + end + end.to write <<~OUTPUT x ArgumentError: not the answer - 21 - #{raise_location} + #{__FILE__}:#{__LINE__ - 6} OUTPUT else - expect(output).to eq <<~OUTPUT + expect do + ImLost.trace_exceptions do + raise(ArgumentError, 'not the answer - 21') + rescue ArgumentError + # nop + end + end.to write <<~OUTPUT x ArgumentError: not the answer - 21 - #{raise_location} + #{__FILE__}:#{__LINE__ - 6} ! ArgumentError: not the answer - 21 - #{rescue_location} + #{__FILE__}:#{__LINE__ - 7} OUTPUT end end it 'allows to disable location information' do - ImLost.trace_exceptions(with_locations: false) do - raise(ArgumentError, 'not the answer - 21') - rescue ArgumentError - # nop - end - - if RUBY_VERSION.to_f < 3.3 - expect(output).to eq "x ArgumentError: not the answer - 21\n" - else - expect(output).to eq <<~OUTPUT - x ArgumentError: not the answer - 21 - ! ArgumentError: not the answer - 21 - OUTPUT - end - end - - it 'allows to be stacked' do - raise_location = "#{__FILE__}:#{__LINE__ + 5}" - rescue_location = "#{__FILE__}:#{__LINE__ + 5}" + expected = + if RUBY_VERSION.to_f < 3.3 + "x ArgumentError: not the answer - 21\n" + else + <<~OUTPUT + x ArgumentError: not the answer - 21 + ! ArgumentError: not the answer - 21 + OUTPUT + end - ImLost.trace_exceptions(with_locations: false) do - ImLost.trace_exceptions(with_locations: true) do - raise(ArgumentError, 'not the answer - 42') + expect do + ImLost.trace_exceptions(with_locations: false) do + raise(ArgumentError, 'not the answer - 21') rescue ArgumentError # nop end - raise(ArgumentError, 'not the answer - 21') - rescue ArgumentError - # nop - end - begin - raise(NotImplementedError) - rescue NotImplementedError - # nop - end + end.to write expected + end + it 'allows to be stacked' do if RUBY_VERSION.to_f < 3.3 - expect(output).to eq <<~OUTPUT + expect do + ImLost.trace_exceptions(with_locations: false) do + ImLost.trace_exceptions(with_locations: true) do + raise(ArgumentError, 'not the answer - 42') + rescue ArgumentError + # nop + end + raise(ArgumentError, 'not the answer - 21') + rescue ArgumentError + # nop + end + end.to write <<~OUTPUT x ArgumentError: not the answer - 42 - #{raise_location} + #{__FILE__}:#{__LINE__ - 10} x ArgumentError: not the answer - 21 OUTPUT else - expect(output).to eq <<~OUTPUT + expect do + ImLost.trace_exceptions(with_locations: false) do + ImLost.trace_exceptions(with_locations: true) do + raise(ArgumentError, 'not the answer - 42') + rescue ArgumentError + # nop + end + raise(ArgumentError, 'not the answer - 21') + rescue ArgumentError + # nop + end + end.to write <<~OUTPUT x ArgumentError: not the answer - 42 - #{raise_location} + #{__FILE__}:#{__LINE__ - 10} ! ArgumentError: not the answer - 42 - #{rescue_location} + #{__FILE__}:#{__LINE__ - 11} + x ArgumentError: not the answer - 21 + ! ArgumentError: not the answer - 21 + OUTPUT + end + end + + if RUBY_VERSION.to_f >= 3.3 + it 'prints exception tree for rescued exceptions' do + expect do + ImLost.trace_exceptions(with_locations: false) do + begin + begin + raise(ArgumentError, 'not the answer - 21') + rescue ArgumentError + raise NoMethodError + end + rescue NoMethodError + raise NotImplementedError + end + rescue NotImplementedError + nil + end + end.to write <<~OUTPUT x ArgumentError: not the answer - 21 ! ArgumentError: not the answer - 21 + x NoMethodError: NoMethodError + ! NoMethodError: NoMethodError + [ArgumentError: not the answer - 21] + x NotImplementedError: NotImplementedError + ! NotImplementedError: NotImplementedError + [NoMethodError: NoMethodError] + [ArgumentError: not the answer - 21] OUTPUT end end @@ -314,50 +337,37 @@ def bar = :bar context 'trace locations' do it 'writes call location' do - ImLost.here - - expect(output).to eq ": #{__FILE__}:#{__LINE__ - 2}\n" + expect { ImLost.here }.to write "* #{__FILE__}:#{__LINE__}\n" end it 'writes only when given condition is truethy' do - ImLost.here(1 < 2) - ImLost.here(1 > 2) - - expect(output).to eq ": #{__FILE__}:#{__LINE__ - 3}\n" + expect do + ImLost.here(1 > 2) + ImLost.here(1 < 2) + end.to write "* #{__FILE__}:#{__LINE__ - 1}\n" end it 'returns given argument' do - expect(ImLost.here(:foo)).to be :foo - expect(output).to eq ": #{__FILE__}:#{__LINE__ - 1}\n" - end + ImLost.output = StringIO.new # prevent output - it 'writes only when given block result is truethy' do - ImLost.here { 1 < 2 } - ImLost.here { 1 > 2 } - - expect(output).to eq ": #{__FILE__}:#{__LINE__ - 3}\n" - end - - it 'returns block result' do - expect(ImLost.here { :foo }).to be :foo - expect(output).to eq ": #{__FILE__}:#{__LINE__ - 1}\n" + obj = Object.new + expect(ImLost.here(obj)).to be obj end end context 'dump vars' do it 'prints instance variables' do - sample.add(22, 20) - ImLost.vars(sample) - - expect(output).to eq <<~OUTPUT - = #{__FILE__}:#{__LINE__ - 3} + expect { ImLost.vars(sample) }.to write <<~OUTPUT + * #{__FILE__}:#{__LINE__ - 1} > instance variables - @result: 42 - @state: :created + @bar: 22 + @foo: 20 OUTPUT end it 'returns given object' do + ImLost.output = StringIO.new # prevent output + expect(ImLost.vars(sample)).to be sample end @@ -365,10 +375,8 @@ def bar = :bar let(:sample) { BasicObject.new } it 'it prints an error message' do - ImLost.vars(sample) - - expect(output).to eq <<~OUTPUT - = #{__FILE__}:#{__LINE__ - 3} + expect { ImLost.vars(sample) }.to write <<~OUTPUT + * #{__FILE__}:#{__LINE__ - 1} !!! unable to retrieve vars OUTPUT end @@ -376,20 +384,21 @@ def bar = :bar context 'when a Binding is given' do it 'prints local variables' do - test = :test - sample = test.to_s - test = sample - ImLost.vars(binding) - - expect(output).to eq <<~OUTPUT - = #{__FILE__}:#{__LINE__ - 3} + expect do + test = :foo_bar_baz + sample = test.to_s + ImLost.vars(binding) + end.to write <<~OUTPUT + * #{__FILE__}:#{__LINE__ - 2} > local variables - sample: "test" - test: "test" + sample: "foo_bar_baz" + test: :foo_bar_baz OUTPUT end it 'returns given bindig' do + ImLost.output = StringIO.new # prevent output + expect(ImLost.vars(binding)).to be_a Binding end end @@ -405,37 +414,29 @@ def bar = :bar after { thread.join } it 'prints thread variables' do - thread[:var] = 41 - ImLost.vars(thread.join) - location = "#{__FILE__}:#{__LINE__ - 1}" - - if defined?(thread.native_thread_id) - expect(output).to eq <<~OUTPUT - = #{location} - terminated Thread - > fiber-local variables - var: 21 - > thread variables - result: 42 - OUTPUT - else - expect(output).to eq <<~OUTPUT - = #{location} - terminated Thread #{thread.__id__} - > fiber-local variables - var: 21 - > thread variables - result: 42 - OUTPUT - end + expect do + thread[:var] = 41 + ImLost.vars(thread.join) + end.to write <<~OUTPUT + * #{__FILE__}:#{__LINE__ - 2} + terminated Thread#{ + " #{thread.__id__}" unless defined?(thread.native_thread_id) + } + > fiber-local variables + var: 21 + > thread variables + result: 42 + OUTPUT end it 'returns given thread' do + ImLost.output = StringIO.new # prevent output + expect(ImLost.vars(thread)).to be thread end end - if defined?(Fiber.current) && defined?(Fiber.storage) + if defined?(Fiber.current) && defined?(Fiber.current.storage) context 'when the current Fiber is given' do before do Fiber[:var1] = 22 @@ -444,10 +445,8 @@ def bar = :bar end it 'prints the fiber storage' do - ImLost.vars(Fiber.current) - - expect(output).to eq <<~OUTPUT - = #{__FILE__}:#{__LINE__ - 3} + expect { ImLost.vars(Fiber.current) }.to write <<~OUTPUT + * #{__FILE__}:#{__LINE__ - 1} > fiber storage var1: 22 var2: 20 @@ -456,6 +455,8 @@ def bar = :bar end it 'returns given fiber' do + ImLost.output = StringIO.new # prevent output + expect(ImLost.vars(Fiber.current)).to be Fiber.current end end @@ -466,10 +467,8 @@ def bar = :bar after { fiber.kill if defined?(fiber.kill) } # Ruby > v3.3.0 it 'it prints an error message' do - ImLost.vars(fiber) - - expect(output).to eq <<~OUTPUT - = #{__FILE__}:#{__LINE__ - 3} + expect { ImLost.vars(fiber) }.to write <<~OUTPUT + * #{__FILE__}:#{__LINE__ - 1} !!! given Fiber is not the current Fiber #{fiber.inspect} OUTPUT @@ -481,6 +480,10 @@ def bar = :bar end context '.timer' do + let(:output) { ImLost.output.string } + let(:reset_output!) { ImLost.output = StringIO.new } + + before { ImLost.output = StringIO.new } after { ImLost.timer.delete(ImLost.timer.ids) } it 'supports attributes #count, #empty?, #ids' do @@ -509,7 +512,7 @@ def bar = :bar it 'prints runtime information for an anonymous timer' do id = ImLost.timer.create - ImLost.output = StringIO.new # reset output + reset_output! ImLost.timer[id] location = Regexp.escape("#{__FILE__}:#{__LINE__ - 1}") @@ -518,7 +521,7 @@ def bar = :bar it 'prints runtime information for a named timer' do ImLost.timer.create(:tt2) - ImLost.output = StringIO.new # reset output + reset_output! ImLost.timer[:tt2] location = Regexp.escape("#{__FILE__}:#{__LINE__ - 1}") @@ -529,7 +532,7 @@ def bar = :bar it 'prints the runtime of all timers' do ImLost.timer.create(:first) second = ImLost.timer.create - ImLost.output = StringIO.new # reset output + reset_output! ImLost.timer.all location = Regexp.escape("#{__FILE__}:#{__LINE__ - 1}") diff --git a/spec/support/matcher.rb b/spec/support/matcher.rb new file mode 100644 index 0000000..190d48f --- /dev/null +++ b/spec/support/matcher.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'stringio' + +RSpec::Matchers.define :write do |expected| + match do |actual| + next false unless actual.is_a? Proc + oa = ImLost.output + ImLost.output = StringIO.new + actual.call + expect(@actual = ImLost.output.string).to eq expected + ensure + ImLost.output = oa if oa + end + + failure_message { <<~MESSAGE } + expected: #{expected.inspect} + got: #{@actual.inspect} + + diff: #{differ.diff_as_string(@actual, expected)} + MESSAGE + + def differ + prep = ->(o) { RSpec::Matchers::Composable.surface_descriptions_in(o) } + RSpec::Support::Differ.new( + object_preparer: prep, + color: RSpec::Matchers.configuration.color? + ) + end + + supports_block_expectations +end