diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9aa6de --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +tmp/ +pkg/ +local/ +doc/ +.yardoc/ +.vscode/ +*.lock +.rubocop.* diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..f47563d --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require helper diff --git a/.yardopts b/.yardopts new file mode 100644 index 0000000..c067c8b --- /dev/null +++ b/.yardopts @@ -0,0 +1,10 @@ +--readme README.md +--title 'im-lost' +--charset utf-8 +--markup markdown +--tag comment +--hide-tag comment +lib/**/*.rb +- +README.md +LICENSE diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..9131453 --- /dev/null +++ b/Gemfile @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +group :development, :test do + gem 'bundler', require: false + gem 'rake', require: false +end + +group :test do + gem 'rspec', require: false +end + +group :development do + gem 'webrick', require: false + gem 'yard', require: false +end + +gemspec diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5216c4f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Mike Blumtritt + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..50d99a8 --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +# ImLost ![version](https://img.shields.io/gem/v/im-lost?label=) + +If you have overlooked something again and don't really understand what your code is doing. If you have to maintain this application but can't really find your way around and certainly can't track down that stupid error. If you feel lost in all that code, here's the gem to help you out! + +ImLost helps you by analyzing function calls of objects, informing you about exceptions and logging your way through your code. In short, ImLost is your debugging helper! + +- Gem: [rubygems.org](https://rubygems.org/gems/im-lost) +- Source: [github.com](https://github.com/mblumtritt/im-lost) +- Help: [rubydoc.info](https://rubydoc.info/gems/im-lost/ImLost) + +## Description + +If you like to undertsand method call details you get a call trace with `ImLost.trace`: + +```ruby +File.open('test.txt', 'w') do |file| + ImLost.trace(file) do + file << 'hello ' + file.puts(:world!) + end +end +# output will look like +# > IO#<<(?) +# /projects/test.rb:1 +# > IO#write(*) +# /projects/test.rb:1 +# > IO#puts(*) +# /projects/test.rb:2 +# > IO#write(*) +# /projects/test.rb:2 +``` + +When you need to know if exceptions are raised and handled you can use `ImLost.trace_exceptions`: + +```ruby +ImLost.trace_exceptions do + File.write('/', 'test') +rescue SystemCallError + raise('something went wrong!') +end +# output will look like +# x Errno::EEXIST: File exists @ rb_sysopen - / +# /projects/test.rb:2 +# ! Errno::EEXIST: File exists @ rb_sysopen - / +# /projects/test.rb:3 +# x RuntimeError: something went wrong! +# /projects/test.rb:4 +``` + +When you like to know if and when a code point is reached, `ImLost.here` will help: + +```ruby +ImLost.here +``` + +## Example + +```ruby +require 'im-lost' + +class Foo + def self.create(value:) = new(value) + + attr_reader :value + + def initialize(value) + @value = value + end + + def foo(arg, *args, key: nil, **kw_args, &block) + @value = "#{arg}-#{key}-[#{args.join(',')}]-#{kw_args.inspect}-#{bar}" + block ? block.call(@value) : @value + end + + def bar = :bar +end + +ImLost.trace_results = true +ImLost.trace(Foo) + +my_foo = Foo.create(value: :foo!) +ImLost.trace(my_foo) + +my_foo.foo(1, key: :none) +my_foo.foo(2, :a, :b, :c, key: :some, name: :value) +my_foo.foo(3) { puts _1 } + +# output will look like +# > Foo.create(:foo!) +# /projects/foo.rb:25 +# > Foo.new(*) +# /projects/foo.rb:6 +# < Foo.new(*) +# = # +# < Foo.create(:foo!) +# = # +# > Foo#foo(1, *[], :none, **{}, &nil) +# /projects/foo.rb:28 +# > Foo#bar() +# /projects/foo.rb:15 +# < Foo#bar() +# = :bar +# < Foo#foo(1, *[], :none, **{}, &nil) +# = "1-none-[]-{}-bar" +# > Foo#foo(2, *[:a, :b, :c], :some, **{:name=>:value}, &nil) +# /projects/foo.rb:29 +# > Foo#bar() +# /projects/foo.rb:15 +# < Foo#bar() +# = :bar +# < Foo#foo(2, *[:a, :b, :c], :some, **{:name=>:value}, &nil) +# = "2-some-[a,b,c]-{:name=>:value}-bar" +# > Foo#foo(3, *[], nil, **{}, &#) +# /projects/foo.rb:30 +# > Foo#bar() +# /projects/foo.rb:15 +# < Foo#bar() +# = :bar +# 3--[]-{}-bar +# < Foo#foo(3, *[], nil, **{}, &#) +# = nil +``` + +See [examples dir](./examples) for moreā€¦ + +## Installation + +You can install the gem in your system with + +```shell +gem install im-lost +``` + +or you can use [Bundler](http://gembundler.com/) to add ImLost to your own project: + +```shell +bundle add im-lost +``` + +After that you only need one line of code to have everything together + +```ruby +require 'im-lost' +``` diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..0fe247e --- /dev/null +++ b/Rakefile @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +$stdout.sync = $stderr.sync = true + +require 'bundler/gem_tasks' + +require 'rspec/core/rake_task' +RSpec::Core::RakeTask.new(:test) { _1.ruby_opts = %w[-w] } + +require 'yard' + +CLEAN << '.yardoc' +CLOBBER << 'doc' + +YARD::Rake::YardocTask.new(:doc) { _1.stats_options = %w[--list-undoc] } + +desc 'Run YARD development server' +task('doc:dev' => :clobber) { exec('yard server --reload') } + +task(:default) { exec('rake --tasks') } diff --git a/examples/foo.rb b/examples/foo.rb new file mode 100644 index 0000000..03d79e7 --- /dev/null +++ b/examples/foo.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require_relative '../lib/im-lost' + +class Foo + def self.create(value:) = new(value) + + attr_reader :value + + def initialize(value) + @value = value + end + + def foo(arg, *args, key: nil, **kw_args, &block) + @value = "#{arg}-#{key}-[#{args.join(',')}]-#{kw_args.inspect}-#{bar}" + block ? block.call(@value) : @value + end + + def bar = :bar +end + +ImLost.trace_results = true +ImLost.trace(Foo) + +my_foo = Foo.create(value: :foo!) +ImLost.trace(my_foo) + +my_foo.foo(1, key: :none) +my_foo.foo(2, :a, :b, :c, key: :some, name: :value) +my_foo.foo(3) { puts _1 } + +# output will look like +# > Foo.create(:foo!) +# /projects/foo.rb:25 +# > Foo.new(*) +# /projects/foo.rb:6 +# < Foo.new(*) +# = # +# < Foo.create(:foo!) +# = # +# > Foo#foo(1, *[], :none, **{}, &nil) +# /projects/foo.rb:28 +# > Foo#bar() +# /projects/foo.rb:15 +# < Foo#bar() +# = :bar +# < Foo#foo(1, *[], :none, **{}, &nil) +# = "1-none-[]-{}-bar" +# > Foo#foo(2, *[:a, :b, :c], :some, **{:name=>:value}, &nil) +# /projects/foo.rb:29 +# > Foo#bar() +# /projects/foo.rb:15 +# < Foo#bar() +# = :bar +# < Foo#foo(2, *[:a, :b, :c], :some, **{:name=>:value}, &nil) +# = "2-some-[a,b,c]-{:name=>:value}-bar" +# > Foo#foo(3, *[], nil, **{}, &#) +# /projects/foo.rb:30 +# > Foo#bar() +# /projects/foo.rb:15 +# < Foo#bar() +# = :bar +# 3--[]-{}-bar +# < Foo#foo(3, *[], nil, **{}, &#) +# = nil diff --git a/examples/kernel_calls.rb b/examples/kernel_calls.rb new file mode 100644 index 0000000..26ab92a --- /dev/null +++ b/examples/kernel_calls.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +puts <<~INFO + + This example traces calls for very basic Ruby objects when a new Class is + generated and pretty_print is automatically loaded. + +INFO + +require 'im-lost' + +ImLost.trace_results = true +ImLost.trace(Kernel, Object, Module, Class, self) do + puts '=' * 79 + pp Class.new + puts '=' * 79 +end diff --git a/im-lost.gemspec b/im-lost.gemspec new file mode 100644 index 0000000..e510c2b --- /dev/null +++ b/im-lost.gemspec @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require_relative 'lib/im-lost/version' + +Gem::Specification.new do |spec| + spec.name = 'im-lost' + spec.version = ImLost::VERSION + spec.summary = 'Your debugging helper.' + spec.description = <<~DESCRIPTION + If you have overlooked something again and don't really understand what + your code is doing. If you have to maintain this application but can't + really find your way around and certainly can't track down that stupid + error. If you feel lost in all that code, here's the gem to help you out! + + ImLost helps you by analyzing function calls of objects, informing you + about exceptions and logging your way through your code. In short, ImLost + is your debugging helper! + DESCRIPTION + + spec.author = 'Mike Blumtritt' + spec.license = 'MIT' + spec.homepage = 'https://github.com/mblumtritt/im-lost' + spec.metadata['source_code_uri'] = spec.homepage + spec.metadata['bug_tracker_uri'] = "#{spec.homepage}/issues" + spec.metadata['documentation_uri'] = 'https://rubydoc.info/gems/im-lost' + spec.metadata['rubygems_mfa_required'] = 'true' + + spec.required_ruby_version = '>= 3.0' + + spec.files = Dir['lib/**/*'] + Dir['examples/**/*'] + spec.extra_rdoc_files = %w[README.md LICENSE] +end diff --git a/lib/im-lost.rb b/lib/im-lost.rb new file mode 100644 index 0000000..93a9a5c --- /dev/null +++ b/lib/im-lost.rb @@ -0,0 +1,341 @@ +# frozen_string_literal: true + +module ImLost + class << self + # + # Enables/disables to include code location into traced call information. + # This is enabled by default. + # + # @return [Boolean] whether code location will be included + # + attr_reader :caller_locations + + def caller_locations=(value) + @caller_locations = value ? true : false + end + + # + # The output device used to write information. + # This should be an `IO` device or any other object responding to `#puts`. + # + # `$stderr` is configured by default. + # + # @example Write to a file + # ImLost.output = File.new('./trace', 'w') + # + # @example Write temporary into a memory stream + # require 'stringio' + # + # original = ImLost.output + # begin + # ImLost.output = StringIO.new + # # ... collect trace information + # puts(ImLost.output.string) # or whatever + # ensure + # ImLost.output = original + # end + # + # @return [#puts] the output device + # + attr_reader :output + + def output=(value) + return @output = value if value.respond_to?(:puts) + raise(ArgumentError, "invalid output device - #{value.inspect}") + end + + # + # Enables/disables tracing of method calls. + # This is enabled by default. + # + # @attribute [r] trace_calls + # @return [Boolean] whether method calls will be traced + # + def trace_calls = @trace_calls[0].enabled? + + def trace_calls=(value) + if value + @trace_calls.each(&:enable) unless trace_calls + elsif trace_calls + @trace_calls.each(&:disable) + end + end + + # + # Traces execptions raised within a given block. + # + # @example Trace exception and rescue handling + # ImLost.trace_exceptions do + # File.write('/', 'test') + # rescue SystemCallError + # raise('something went wrong!') + # end + # # output will look like + # # x Errno::EEXIST: File exists @ rb_sysopen - / + # # /projects/test.rb:2 + # # ! Errno::EEXIST: File exists @ rb_sysopen - / + # # /projects/test.rb:3 + # # x RuntimeError: something went wrong! + # # /projects/test.rb:4 + # + # @param with_locations [Boolean] wheter the locations should be included + # into the exception trace information + # @yieldreturn [Object] return result + # + def trace_exceptions(with_locations: true) + return unless block_given? + we = @trace_exceptions.enabled? + el = @exception_locations + @exception_locations = with_locations + @trace_exceptions.enable unless we + yield + ensure + @trace_exceptions.disable unless we + @exception_locations = el + end + + # + # Enables/disables tracing of returned valuess of method calls. + # This is disabled by default. + # + # @attribute [r] trace_results + # @return [Boolean] whether return values will be traced + # + def trace_results = @trace_results[0].enabled? + + def trace_results=(value) + if value + @trace_results.each(&:enable) unless trace_results + elsif trace_results + @trace_results.each(&:disable) + end + end + + # + # Print the call location conditionally. + # + # @example simply print location + # ImLost.here + # + # @example print location when instance variable is empty + # ImLost.here(@name.empty?) + # + # @example print location when instance variable is nil or empty + # ImLost.here { @name.nil? || @name.empty? } + # + # @overload here + # Prints the caller location. + # @return [true] + # + # @overload here(test) + # Prints the caller location when given argument is truthy. + # @param test [Object] + # @return [Object] test + # + # @overload here + # Prints the caller location when given block returns a truthy result. + # @yield When the block returns a truthy result the location will be print + # @yieldreturn [Object] return result + # + 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}") + test + end + + # + # Trace objects. + # + # The given arguments can be any object instance or module or class. + # + # @example trace method calls of an instance variable for a while + # ImLost.trace(@file) + # # ... + # ImLost.untrace(@file) + # + # @example temporary trace method calls + # File.open('test.txt', 'w') do |file| + # ImLost.trace(file) do + # file << 'hello ' + # file.puts(:world!) + # end + # end + # output will look like + # > IO#<<(?) + # /projects/test.rb:1 + # > IO#write(*) + # /projects/test.rb:1 + # > IO#puts(*) + # /projects/test.rb:2 + # > IO#write(*) + # /projects/test.rb:2 + # + # @overload trace(*args) + # @param args [[Object]] one or more objects to be traced + # @return [[Object]] the traced object(s) + # Start tracing the given objects. + # @see untrace + # @see untrace_all! + # + # + # @overload trace(*args) + # @param args [[Object]] one or more objects to be traced + # @yieldparam args [Object] the traced object(s) + # @yieldreturn [Object] return result + # Traces the given object(s) inside the block only. + # The object(s) will not be traced any longer after the block call. + # + def trace(*args, &block) + return block&.call if args.empty? + return args.size == 1 ? _trace(args[0]) : _trace_all(args) unless block + args.size == 1 ? _trace_b(args[0], &block) : _trace_all_b(args, &block) + end + + # + # Stop tracing objects. + # + # @example trace some objects for some code lines + # traced_vars = ImLost.trace(@file, @client) + # # ... + # ImLost.untrace(*traced_vars) + # + # @see trace + # + # @param args [[Object]] one or more objects which should not longer be + # traced + # @return [[Object]] the object(s) which are not longer be traced + # @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 + end + + # + # Stop tracing any object. + # (When you are really lost and just like to stop tracing of all your + # objects.) + # + # @see trace + # + # @return [self] itself + # + def untrace_all! + @trace = {}.compare_by_identity + self + end + + protected + + 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 + end + + private + + def _trace(arg) + @trace[arg.__id__] = 1 if self != arg && @output != arg + arg + end + + def _trace_all(args) + args.each do |arg| + @trace[arg.__id__] = 1 if arg != self && @output != arg + end + args + end + + def _trace_b(arg) + @trace[id = arg.__id__] = 1 if self != arg && @output != arg + yield(arg) + ensure + @trace.delete(id) if id + end + + def _trace_all_b(args) + ids = + args.filter_map do |arg| + next if self == arg || @output == arg + @trace[id = arg.__id__] = 1 + id + end + yield(args) + ensure + ids.each { @trace.delete(_1) } + end + end + + ARG_SIG = { rest: '*', keyrest: '**', block: '&' }.compare_by_identity.freeze + NO_NAME = %i[* ** &].freeze + EX_PREFIX = { raise: 'x', rescue: '!' }.freeze + private_constant :ARG_SIG, :NO_NAME, :EX_PREFIX + + @trace = {}.compare_by_identity + @caller_locations = true + @output = $stderr.respond_to?(:puts) ? $stderr : STDERR + + @trace_calls = [ + TracePoint.new(:c_call) do |tp| + next unless @trace.key?(tp.self.__id__) + @output.puts(as_sig('>', tp, tp.parameters.map { ARG_SIG[_1[0]] || '?' })) + @output.puts(" #{tp.path}:#{tp.lineno}") if @caller_locations + end, + TracePoint.new(:call) do |tp| + next unless @trace.key?(tp.self.__id__) + ctx = tp.binding + @output.puts( + as_sig( + '>', + tp, + tp.parameters.map do |kind, name| + next name if NO_NAME.include?(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}") + end + ] + + @trace_results = [ + TracePoint.new(:c_return) do |tp| + next unless @trace.key?(tp.self.__id__) + @output.puts(as_sig('<', tp, tp.parameters.map { ARG_SIG[_1[0]] || '?' })) + @output.puts(" = #{tp.return_value.inspect}") + end, + TracePoint.new(:return) do |tp| + next unless @trace.key?(tp.self.__id__) + ctx = tp.binding + @output.puts( + as_sig( + '<', + tp, + tp.parameters.map do |kind, name| + next name if %i[* ** &].include?(name) + "#{ARG_SIG[kind]}#{ctx.local_variable_get(name).inspect}" + end + ) + ) + @output.puts(" = #{tp.return_value.inspect}") + end + ] + + supported = RUBY_VERSION >= '3.3.0' ? %i[raise rescue] : %i[raise] + @trace_exceptions = + TracePoint.new(*supported) do |tp| + ex = tp.raised_exception.inspect + @output.puts("#{EX_PREFIX[tp.event]} #{ex[0] == '#' ? ex[2..-2] : ex}") + @output.puts(" #{tp.path}:#{tp.lineno}") if @exception_locations + end + + self.trace_calls = true +end diff --git a/lib/im-lost/version.rb b/lib/im-lost/version.rb new file mode 100644 index 0000000..6c49d6d --- /dev/null +++ b/lib/im-lost/version.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module ImLost + # The version number of the gem. + VERSION = '1.0.0' +end diff --git a/lib/im_lost.rb b/lib/im_lost.rb new file mode 100644 index 0000000..af21648 --- /dev/null +++ b/lib/im_lost.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require_relative 'im-lost' diff --git a/spec/helper.rb b/spec/helper.rb new file mode 100644 index 0000000..ce1d2fe --- /dev/null +++ b/spec/helper.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'stringio' +require_relative '../lib/im-lost' + +$stdout.sync = $stderr.sync = $VERBOSE = true +RSpec.configure(&:disable_monkey_patching!) diff --git a/spec/lib/im-lost/version_spec.rb b/spec/lib/im-lost/version_spec.rb new file mode 100644 index 0000000..b4a7e35 --- /dev/null +++ b/spec/lib/im-lost/version_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +RSpec.describe 'ImLost::VERSION' do + subject(:version) { ImLost::VERSION } + + it { is_expected.to be_frozen } + it do + is_expected.to match( + /\A[[:digit:]]{1,3}.[[:digit:]]{1,3}.[[:digit:]]{1,3}(alpha|beta)?\z/ + ) + end +end diff --git a/spec/lib/im-lost_spec.rb b/spec/lib/im-lost_spec.rb new file mode 100644 index 0000000..8132b3b --- /dev/null +++ b/spec/lib/im-lost_spec.rb @@ -0,0 +1,322 @@ +# frozen_string_literal: true + +class TestSample + def foo = :foo + def bar = :bar + def add(arg0, arg1) = arg0 + arg1 + def add_kw(arg0:, arg1:) = arg0 + arg1 + def add_block(arg0, &block) = add(arg0, block&.call || 42) + def map(*args) = args.map(&:to_s) + def insp(**kw_args) = kw_args.inspect + def fwd(...) = add(...) +end + +RSpec.describe ImLost do + let(:sample) { TestSample.new } + let(:output) { ImLost.output.string } + + before do + ImLost.output = StringIO.new + ImLost.untrace_all! + end + + it 'has defined default attributes' do + is_expected.to have_attributes( + caller_locations: true, + trace_calls: true, + trace_results: false + ) + end + + context 'trace method calls' do + before do + ImLost.trace_calls = true + ImLost.caller_locations = false + ImLost.trace_results = false + ImLost.trace(sample) + end + + it 'traces method calls' do + sample.foo + sample.bar + expect(output).to eq "> TestSample#foo()\n> TestSample#bar()\n" + end + + it 'includes arguments in call signatures' do + sample.add(21, 21) + expect(output).to eq "> 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" + 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 + end + + it 'includes splat arguments' do + sample.map(1, 2, 3, 4) + expect(output).to eq "> TestSample#map(*[1, 2, 3, 4])\n" + end + + it 'includes empty splat arguments' do + sample.map + expect(output).to eq "> 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" + end + + it 'includes empty keyword splat arguments' do + sample.insp + expect(output).to eq "> TestSample#insp(**{})\n" + end + + it 'handles argument forwarding' do + sample.fwd(40, 2) + expect(output).to eq <<~OUTPUT + > TestSample#fwd(*, **, &) + > TestSample#add(40, 2) + OUTPUT + 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 + expect(output).to eq "> TestSample#add(20, 22)\n" + end + + it 'can include caller locations' do + ImLost.caller_locations = true + sample.foo + expect(output).to eq <<~OUTPUT + > TestSample#foo() + #{__FILE__}:#{__LINE__ - 3} + OUTPUT + end + end + + context 'trace method call results' do + before do + ImLost.trace_calls = false + ImLost.caller_locations = false + ImLost.trace_results = true + ImLost.trace(sample) + end + + it 'traces method call results' do + sample.foo + sample.bar + expect(output).to eq <<~OUTPUT + < TestSample#foo() + = :foo + < TestSample#bar() + = :bar + OUTPUT + end + + it 'includes arguments in call signatures' do + sample.add(21, 21) + expect(output).to eq "< 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 + OUTPUT + end + + it 'includes splat arguments' do + sample.map(1, 2, 3, 4) + expect(output).to eq <<~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" + end + + it 'includes keyword splat arguments' do + sample.insp(a: 1, b: 2) + expect(output).to eq <<~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" + end + + it 'handles argument forwarding' do + sample.fwd(40, 2) + expect(output).to eq <<~OUTPUT + < TestSample#add(40, 2) + = 42 + < TestSample#fwd(*, **, &) + = 42 + OUTPUT + 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 + expect(output).to eq "< TestSample#add(20, 22)\n = 42\n" + end + end + + if RUBY_VERSION >= '3.3.0' + context '.trace_exceptions' do + it 'traces exceptions and rescue blocks' do + ImLost.trace_exceptions do + raise(ArgumentError, 'not the answer - 21') + rescue ArgumentError + # nop + end + expect(output).to eq <<~OUTPUT + x ArgumentError: not the answer - 21 + #{__FILE__}:#{__LINE__ - 6} + ! ArgumentError: not the answer - 21 + #{__FILE__}:#{__LINE__ - 7} + OUTPUT + 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 + expect(output).to eq <<~OUTPUT + x ArgumentError: not the answer - 21 + ! ArgumentError: not the answer - 21 + OUTPUT + end + + it 'allows to be stacked' 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 + begin + raise(NotImplementedError) + rescue NotImplementedError + # nop + end + expect(output).to eq <<~OUTPUT + x ArgumentError: not the answer - 42 + #{__FILE__}:#{__LINE__ - 15} + ! ArgumentError: not the answer - 42 + #{__FILE__}:#{__LINE__ - 16} + x ArgumentError: not the answer - 21 + ! ArgumentError: not the answer - 21 + OUTPUT + end + end + end + + if RUBY_VERSION < '3.3.0' + context '.trace_exceptions' do + it 'traces exceptions and rescue blocks' do + ImLost.trace_exceptions do + raise(ArgumentError, 'not the answer - 21') + rescue ArgumentError + # nop + end + expect(output).to eq <<~OUTPUT + x ArgumentError: not the answer - 21 + #{__FILE__}:#{__LINE__ - 6} + OUTPUT + 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 + expect(output).to eq "x ArgumentError: not the answer - 21\n" + end + + it 'allows to be stacked' 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 + begin + raise(NotImplementedError) + rescue NotImplementedError + # nop + end + expect(output).to eq <<~OUTPUT + x ArgumentError: not the answer - 42 + #{__FILE__}:#{__LINE__ - 15} + x ArgumentError: not the answer - 21 + OUTPUT + end + end + end + + context 'trace locations' do + it 'writes call location' do + ImLost.here + expect(output).to eq ": #{__FILE__}:#{__LINE__ - 1}\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__ - 2}\n" + end + + it 'returns given argument' do + expect(ImLost.here(:foo)).to be :foo + expect(output).to eq ": #{__FILE__}:#{__LINE__ - 1}\n" + end + + it 'writes only when given block result is truethy' do + ImLost.here { 1 < 2 } + ImLost.here { 1 > 2 } + expect(output).to eq ": #{__FILE__}:#{__LINE__ - 2}\n" + end + + it 'returns block result' do + expect(ImLost.here { :foo }).to be :foo + expect(output).to eq ": #{__FILE__}:#{__LINE__ - 1}\n" + end + end +end diff --git a/stats.md b/stats.md new file mode 100644 index 0000000..85ed8d4 --- /dev/null +++ b/stats.md @@ -0,0 +1,21 @@ +# Gem/Repo Statistics + +![version](https://img.shields.io/gem/v/im-lost) +![downloads](https://img.shields.io/gem/dt/im-lost) +![downloads](https://img.shields.io/gem/dtv/im-lost) + +![license](https://img.shields.io/github/license/mblumtritt/im-lost) +![stars](https://img.shields.io/github/stars/mblumtritt/im-lost) +![watchers](https://img.shields.io/github/watchers/mblumtritt/im-lost) +![forks](https://img.shields.io/github/forks/mblumtritt/im-lost) + +![issues](https://img.shields.io/github/issues/mblumtritt/im-lost) +![closed issues](https://img.shields.io/github/issues-closed/mblumtritt/im-lost) +![pull-requests](https://img.shields.io/github/issues-pr/mblumtritt/im-lost) +![closed pull-requests](https://img.shields.io/github/issues-pr-closed/mblumtritt/im-lost) + +![last commit](https://img.shields.io/github/last-commit/mblumtritt/im-lost/main) +![files](https://img.shields.io/github/directory-file-count/mblumtritt/im-lost) +![dependencies](https://img.shields.io/librariesio/github/mblumtritt/im-lost) + +![commit activity](https://img.shields.io/github/commit-activity/m/mblumtritt/im-lost)