├── .gitignore ├── .rspec ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.lock ├── Guardfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── lib ├── trace_spy.rb └── trace_spy │ ├── method.rb │ └── version.rb ├── spec ├── spec_helper.rb ├── trace_spy │ └── method_spec.rb └── trace_spy_spec.rb └── trace_spy.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | 13 | *.gem 14 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | sudo: false 3 | language: ruby 4 | cache: bundler 5 | rvm: 6 | - 2.6.0 7 | before_install: gem install bundler -v 1.17.2 8 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at keystonelemur@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } 4 | 5 | # Specify your gem's dependencies in trace_spy.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | trace_spy (0.0.3) 5 | qo (~> 0.5) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | any (0.1.0) 11 | coderay (1.1.2) 12 | diff-lcs (1.3) 13 | ffi (1.10.0) 14 | formatador (0.2.5) 15 | guard (2.15.0) 16 | formatador (>= 0.2.4) 17 | listen (>= 2.7, < 4.0) 18 | lumberjack (>= 1.0.12, < 2.0) 19 | nenv (~> 0.1) 20 | notiffany (~> 0.0) 21 | pry (>= 0.9.12) 22 | shellany (~> 0.0) 23 | thor (>= 0.18.1) 24 | guard-compat (1.2.1) 25 | guard-rspec (4.7.3) 26 | guard (~> 2.1) 27 | guard-compat (~> 1.1) 28 | rspec (>= 2.99.0, < 4.0) 29 | listen (3.1.5) 30 | rb-fsevent (~> 0.9, >= 0.9.4) 31 | rb-inotify (~> 0.9, >= 0.9.7) 32 | ruby_dep (~> 1.2) 33 | lumberjack (1.0.13) 34 | method_source (0.9.2) 35 | nenv (0.3.0) 36 | notiffany (0.1.1) 37 | nenv (~> 0.1) 38 | shellany (~> 0.0) 39 | pry (0.12.2) 40 | coderay (~> 1.1.0) 41 | method_source (~> 0.9.0) 42 | qo (0.5.0) 43 | any (= 0.1.0) 44 | rake (10.5.0) 45 | rb-fsevent (0.10.3) 46 | rb-inotify (0.10.0) 47 | ffi (~> 1.0) 48 | rspec (3.8.0) 49 | rspec-core (~> 3.8.0) 50 | rspec-expectations (~> 3.8.0) 51 | rspec-mocks (~> 3.8.0) 52 | rspec-core (3.8.0) 53 | rspec-support (~> 3.8.0) 54 | rspec-expectations (3.8.2) 55 | diff-lcs (>= 1.2.0, < 2.0) 56 | rspec-support (~> 3.8.0) 57 | rspec-mocks (3.8.0) 58 | diff-lcs (>= 1.2.0, < 2.0) 59 | rspec-support (~> 3.8.0) 60 | rspec-support (3.8.0) 61 | ruby_dep (1.5.0) 62 | shellany (0.0.1) 63 | thor (0.20.3) 64 | 65 | PLATFORMS 66 | ruby 67 | 68 | DEPENDENCIES 69 | bundler (~> 1.17) 70 | guard-rspec (~> 4.0) 71 | rake (~> 10.0) 72 | rspec (~> 3.0) 73 | trace_spy! 74 | 75 | BUNDLED WITH 76 | 1.17.2 77 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard :rspec, cmd: "bundle exec rspec" do 2 | require "guard/rspec/dsl" 3 | 4 | dsl = Guard::RSpec::Dsl.new(self) 5 | 6 | # RSpec files 7 | rspec = dsl.rspec 8 | watch(rspec.spec_helper) { rspec.spec_dir } 9 | watch(rspec.spec_support) { rspec.spec_dir } 10 | watch(rspec.spec_files) 11 | 12 | # Ruby files 13 | ruby = dsl.ruby 14 | dsl.watch_spec_files_for(ruby.lib_files) 15 | end 16 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Brandon Weaver 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TraceSpy 2 | 3 | TraceSpy is a wrapper around TracePoint to expose more power in matching against 4 | various cases of Ruby and getting value from composable traces. 5 | 6 | I would suggest reading into [Qo](https://github.com/baweaver/qo) to get an idea of how the matchers 7 | work. 8 | 9 | ## Alpha Version - What that means 10 | 11 | When I say alpha I mean the API is going to be in flux as I experiment with things. This is a proof-of-concept to demonstrate that the idea works. As I learn more about TracePoint and potential uses you may see things shifting. 12 | 13 | These will always be done with "minor" version bumps, i.e. `0.0.3 -> 0.1.0` for breaking APIs. For now, locking to static versions would be best if you intend to leverage it for more than just testing. 14 | 15 | `1.0.0` will be considered a stable API, and will come after the gem has had some time to settle. 16 | 17 | ## Usage 18 | 19 | The methods themselves are documented, and I'll work on expanding this section later with more examples and ideas 20 | as I can. 21 | 22 | ```ruby 23 | def testing(a, b, c) 24 | raise 'heck' if a.is_a?(Numeric) && a > 20 25 | 26 | d = 5 if c.is_a?(Numeric) && c > 3 27 | 28 | a + b + c 29 | end 30 | 31 | testing_spy = TraceSpy::Method.new(:testing) do |spy| 32 | # On the arguments, given as keywords, will yield arguments to the block 33 | spy.on_arguments do |m| 34 | m.when(a: String, b: String, c: String) do |v| 35 | puts "Oh hey! You called me with strings: #{v}" 36 | end 37 | 38 | m.when(a: 1, b: 2, c: 3) do |v| 39 | puts "My args were 1, 2, 3: #{v}" 40 | end 41 | end 42 | 43 | # On an exception, will yield exception to the block 44 | spy.on_exception do |m| 45 | m.when(RuntimeError) do |e| 46 | puts "I encountered an error: #{e}" 47 | end 48 | end 49 | 50 | # On a return value, will yield the return to the block 51 | spy.on_return do |m| 52 | m.when(String) do |v| 53 | puts "Strings in, Strings out no?: #{v}. I got this in though: #{spy.current_arguments}" 54 | end 55 | 56 | m.when(:even?) do |v| 57 | puts "I got an even return: #{v}" 58 | end 59 | end 60 | 61 | # On a local variable being present: 62 | spy.on_locals do |m| 63 | m.when(d: 5) do |v| 64 | puts "I saw d was a local in here!: #{v}. I could also ask this: #{spy.current_local_variables}" 65 | end 66 | end 67 | end 68 | 69 | # If you want to manually enable/disable the trace, use: 70 | # 71 | # testing_spy.enable 72 | # testing_spy.disable 73 | # 74 | # Otherwise, use: 75 | 76 | # Outside of this block, the trace is inactive: 77 | testing_spy.with_tracing do 78 | p testing(1, 2, 3) 79 | # My args were 1, 2, 3: {:a=>1, :b=>2, :c=>3} 80 | # I got an even return: 6 81 | # => 6 82 | 83 | p testing(21, 2, 3) rescue 'nope' 84 | # I encountered an error: heck 85 | # => 'nope' 86 | 87 | p testing(*%w(foo bar baz)) 88 | # Oh hey! You called me with strings: {:a=>"foo", :b=>"bar", :c=>"baz"} 89 | # Strings in, Strings out no?: foobarbaz 90 | # => 'foobarbaz' 91 | 92 | p testing(1, 2, 4) 93 | # I saw d was a local in here!: {:a=>1, :b=>2, :c=>4, :d=>5} 94 | # => 7 95 | end 96 | 97 | # Back to normal 98 | testing(1, 2, 3) 99 | # => 6 100 | ``` 101 | 102 | ## Installation 103 | 104 | Add this line to your application's Gemfile: 105 | 106 | ```ruby 107 | gem 'trace_spy' 108 | ``` 109 | 110 | And then execute: 111 | 112 | $ bundle 113 | 114 | Or install it yourself as: 115 | 116 | $ gem install trace_spy 117 | 118 | ## Development 119 | 120 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 121 | 122 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 123 | 124 | ## Contributing 125 | 126 | Bug reports and pull requests are welcome on GitHub at https://github.com/baweaver/trace_spy. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 127 | 128 | ## License 129 | 130 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 131 | 132 | ## Code of Conduct 133 | 134 | Everyone interacting in the TraceSpy project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/baweaver/trace_spy/blob/master/CODE_OF_CONDUCT.md). 135 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "trace_spy" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/trace_spy.rb: -------------------------------------------------------------------------------- 1 | require "trace_spy/version" 2 | 3 | require 'qo' 4 | 5 | # A Wrapper around TracePoint to provide a more flexible API 6 | # 7 | # @author baweaver 8 | # @since 0.0.1 9 | # 10 | module TraceSpy 11 | # Method call events 12 | CALL_EVENT = Set.new([:call, :c_call]) 13 | 14 | # Method return events 15 | RETURN_EVENT = Set.new([:return, :c_return]) 16 | 17 | # Exception events 18 | RAISE_EVENT = Set.new([:raise]) 19 | 20 | # Line execution events 21 | LINE_EVENT = Set.new([:line]) 22 | 23 | # TODO: Implement other event types 24 | end 25 | 26 | require 'trace_spy/method' 27 | -------------------------------------------------------------------------------- /lib/trace_spy/method.rb: -------------------------------------------------------------------------------- 1 | module TraceSpy 2 | # Implements a TraceSpy on a Method 3 | # 4 | # @author baweaver 5 | # @since 0.0.1 6 | # 7 | # @note 8 | # Tracer spies all rely on Qo for pattern-matching syntax. In order to more 9 | # effectively leverage this gem it would be a good idea to look through 10 | # the Qo documentation present here: https://github.com/baweaver/qo 11 | # 12 | # @example 13 | # A simple use-case would be monitoring for a line in which c happens to be 14 | # equal to 5. Now this value could be a range or other `===` respondant type 15 | # if desired, which gives quite a bit of flexibility in querying. 16 | # 17 | # ```ruby 18 | # def testing(a, b) 19 | # c = 5 20 | # 21 | # a + b + c 22 | # end 23 | # 24 | # trace_spy = TraceSpy::Method.new(:testing) do |spy| 25 | # spy.on_locals do |m| 26 | # m.when(c: 5) { |locals| p locals } 27 | # end 28 | # end 29 | # 30 | # trace_spy.enable 31 | # # => false 32 | # 33 | # testing(1, 2) 34 | # # {:a=>1, :b=>2, :c=>5} 35 | # # => 8 36 | # ``` 37 | class Method 38 | # The current trace being executed upon, can be used in matcher 39 | # blocks to get the entire trace context instead of just a part. 40 | attr_reader :current_trace 41 | 42 | # Creates a new method trace 43 | # 44 | # @param method_name [Symbol, String] 45 | # Name of the method to watch, will be compared with `===` for flexibility 46 | # which enables the use of regex and other more powerful matching 47 | # techniques. 48 | # 49 | # @param from_class: Any [Any] 50 | # Either a Class for type-matching, or other `===` respondant type for flexibility 51 | # 52 | # @param &fn [Proc] 53 | # Self-yielding proc used to initialize a spy in one block function 54 | # 55 | # @yields self 56 | # 57 | # @return [TraceSpy::Method] 58 | def initialize(method_name, from_class: Any, &fn) 59 | @method_name = method_name 60 | @from_class = from_class 61 | @spies = Hash.new { |h,k| h[k] = [] } 62 | @tracepoint = nil 63 | @current_trace = nil 64 | 65 | yield(self) if block_given? 66 | end 67 | 68 | # Allows to run a block of code in the context of a tracer with the 69 | # convenient side-effect of not having to remember to turn it off 70 | # afterwards. 71 | # 72 | # Tracer will only be active within the block, and will be disabled 73 | # afterwards 74 | # 75 | # @since 0.0.3 76 | # 77 | # @example 78 | # The tracer will only be active within the block: 79 | # 80 | # ```ruby 81 | # tracer.with_tracing do 82 | # # tasks 83 | # end 84 | # ``` 85 | # 86 | # @param &traced_function [Proc] 87 | # Function to execute with tracing enabled 88 | # 89 | # @return [TrueClass] 90 | # Result of disabling the tracer 91 | def with_tracing(&traced_function) 92 | self.enable 93 | 94 | yield 95 | 96 | self.disable 97 | end 98 | 99 | # Creates a Spy on function arguments 100 | # 101 | # @since 0.0.1 102 | # 103 | # @example 104 | # Consider, you'd like to monitor if a particular argument is nil: 105 | # 106 | # ```ruby 107 | # def testing(a) a + 2 end 108 | # 109 | # trace_spy = TraceSpy::Method.new(:testing) do |spy| 110 | # spy.on_arguments do |m| 111 | # m.when(a: nil) { |args| binding.pry } 112 | # end 113 | # end 114 | # ``` 115 | # 116 | # You could use this to find out if there's a type-mismatch, or what 117 | # the context is around a particular error due to an argument being 118 | # a particular value. 119 | # 120 | # @param &matcher_fn [Proc] 121 | # Qo Matcher 122 | # 123 | # @return [Array[Qo::Matcher]] 124 | # Currently added Qo matchers 125 | def on_arguments(&matcher_fn) 126 | @spies[:arguments] << Qo.match(&matcher_fn) 127 | end 128 | 129 | # Creates a Spy on local method variables 130 | # 131 | # @since 0.0.2 132 | # 133 | # @example 134 | # Consider, a local variable is inexplicably getting set equal to nil, 135 | # and you don't know where it's happening: 136 | # 137 | # ```ruby 138 | # def testing(a) 139 | # b = nil 140 | # a + 2 141 | # end 142 | # 143 | # trace_spy = TraceSpy::Method.new(:testing) do |spy| 144 | # spy.on_locals do |m| 145 | # m.when(b: nil) { |args| binding.pry } 146 | # end 147 | # end 148 | # ``` 149 | # 150 | # You can use this to stop your program precisely where the offending code 151 | # is located without needing to know where it is beforehand. 152 | # 153 | # @param &matcher_fn [Proc] 154 | # Qo Matcher 155 | # 156 | # @return [Array[Qo::Matcher]] 157 | # Currently added Qo matchers 158 | def on_locals(&matcher_fn) 159 | @spies[:locals] << Qo.match(&matcher_fn) 160 | end 161 | 162 | # Creates a Spy on function returns 163 | # 164 | # @since 0.0.1 165 | # 166 | # @example 167 | # Consider, you'd like to know when your logging method is returning 168 | # an empty string: 169 | # 170 | # ```ruby 171 | # def logger(msg) 172 | # rand(10) < 5 ? msg : "" 173 | # end 174 | # 175 | # trace_spy = TraceSpy::Method.new(:logger) do |spy| 176 | # spy.on_return do |m| 177 | # m.when("") { |v| binding.pry } 178 | # end 179 | # end 180 | # ``` 181 | # 182 | # This could be used to find out the remaining context around what caused 183 | # the blank message, like getting arguments from the `spy.current_trace`. 184 | # 185 | # @param &matcher_fn [Proc] 186 | # Qo Matcher 187 | # 188 | # @return [Array[Qo::Matcher]] 189 | # Currently added Qo matchers 190 | def on_return(&matcher_fn) 191 | @spies[:return] << Qo.match(&matcher_fn) 192 | end 193 | 194 | # Creates a Spy on a certain type of exception 195 | # 196 | # @since 0.0.1 197 | # 198 | # @example 199 | # Consider, you'd like to find out where that error is coming from in 200 | # your function: 201 | # 202 | # ```ruby 203 | # def testing(a) 204 | # raise 'heck' 205 | # a + 2 206 | # end 207 | # 208 | # trace_spy = TraceSpy::Method.new(:testing) do |spy| 209 | # spy.on_exception do |m| 210 | # m.when(RuntimeError) { |args| binding.pry } 211 | # end 212 | # end 213 | # ``` 214 | # 215 | # Like return, you can use this to find out the context around why this 216 | # particular error occurred. 217 | # 218 | # @param &matcher_fn [Proc] 219 | # Qo Matcher 220 | # 221 | # @return [Array[Qo::Matcher]] 222 | # Currently added Qo matchers 223 | def on_exception(&matcher_fn) 224 | @spies[:exception] << Qo.match(&matcher_fn) 225 | end 226 | 227 | # "Enables" the current tracepoint by defining it, caching it, and enabling it 228 | # 229 | # @since 0.0.1 230 | # 231 | # @return [FalseClass] 232 | # Still not sure why TracePoint#enable returns `false`, but here we are 233 | def enable 234 | @tracepoint = TracePoint.new do |trace| 235 | begin 236 | next unless matches?(trace) 237 | 238 | @current_trace = trace 239 | 240 | call_with = -> with { -> spy { spy.call(with) } } 241 | 242 | 243 | @spies[:arguments].each(&call_with[extract_args(trace)]) if CALL_EVENT.include?(trace.event) 244 | @spies[:locals].each(&call_with[extract_locals(trace)]) if LINE_EVENT.include?(trace.event) 245 | @spies[:return].each(&call_with[trace.return_value]) if RETURN_EVENT.include?(trace.event) 246 | @spies[:exception].each(&call_with[trace.raised_exception]) if RAISE_EVENT.include?(trace.event) 247 | 248 | @current_trace = nil 249 | rescue RuntimeError => e 250 | # Stupid hack for now 251 | p e 252 | end 253 | end 254 | 255 | @tracepoint.enable 256 | end 257 | 258 | # Disables the TracePoint, or pretends it did if one isn't enabled yet 259 | # 260 | # @since 0.0.1 261 | # 262 | # @return [Boolean] 263 | def disable 264 | !!@tracepoint&.disable 265 | end 266 | 267 | # Returns the local variables of the currently active trace 268 | # 269 | # @since 0.0.2 270 | # 271 | # @example 272 | # This is a utility function for use with `spy` inside the matcher 273 | # block. 274 | # 275 | # ```ruby 276 | # trace_spy = TraceSpy::Method.new(:testing) do |spy| 277 | # spy.on_exception do |m| 278 | # m.when(RuntimeError) do |v| 279 | # p spy.current_local_variables 280 | # end 281 | # end 282 | # end 283 | # ``` 284 | # 285 | # It's meant to be used to expose the current local variables 286 | # within a trace's scope in any type of matcher. 287 | # 288 | # @return [Hash[Symbol, Any]] 289 | def current_local_variables 290 | return {} unless @current_trace 291 | 292 | extract_locals(@current_trace) 293 | end 294 | 295 | # Returns the arguments of the currently active trace 296 | # 297 | # @since 0.0.2 298 | # 299 | # @note 300 | # This method will attempt to avoid running in contexts where 301 | # argument retrieval will give a runtime error. 302 | # 303 | # @example 304 | # This is a utility function for use with `spy` inside the matcher 305 | # block. 306 | # 307 | # ```ruby 308 | # trace_spy = TraceSpy::Method.new(:testing) do |spy| 309 | # spy.on_return do |m| 310 | # m.when(String) do |v| 311 | # binding.pry if spy.current_arguments[:a] == 'foo' 312 | # end 313 | # end 314 | # end 315 | # ``` 316 | # 317 | # It's meant to expose the current arguments present in a trace's 318 | # scope. 319 | # 320 | # @return [Hash[Symbol, Any]] 321 | def current_arguments 322 | return {} unless @current_trace 323 | return {} if RAISE_EVENT.include?(@current_trace.event) 324 | 325 | extract_args(@current_trace) 326 | end 327 | 328 | # Whether the current trace matches our current preconditions 329 | # 330 | # @since 0.0.1 331 | # 332 | # @param trace [Trace] 333 | # Currently active Trace 334 | # 335 | # @return [Boolean] 336 | # Whether or not the trace matches 337 | private def matches?(trace) 338 | method_matches?(trace) && class_matches?(trace) 339 | end 340 | 341 | # Whether the current trace fits the class constraints 342 | # 343 | # @since 0.0.1 344 | # 345 | # @param trace [Trace] 346 | # Currently active Trace 347 | # 348 | # @return [Boolean] 349 | # Whether or not the trace matches 350 | private def class_matches?(trace) 351 | return true if @from_class == Any 352 | 353 | @from_class == trace.defined_class || @from_class === trace.defined_class 354 | end 355 | 356 | # Whether the current trace fits the method constraints 357 | # 358 | # @since 0.0.1 359 | # 360 | # @param trace [Trace] 361 | # Currently active Trace 362 | # 363 | # @return [Boolean] 364 | # Whether or not the trace matches 365 | private def method_matches?(trace) 366 | @method_name === trace.method_id 367 | end 368 | 369 | # Extracts the arguments from a given trace 370 | # 371 | # @since 0.0.1 372 | # 373 | # @param trace [Trace] 374 | # 375 | # @return [Hash[Symbol, Any]] 376 | # Hash mapping argument names to their respective values 377 | private def extract_args(trace) 378 | param_names = trace.parameters.map(&:last) 379 | 380 | param_names.map { |n| [n, trace.binding.eval(n.to_s)] }.to_h 381 | end 382 | 383 | # Extracts the local variables from a given trace 384 | # 385 | # @since 0.0.1 386 | # 387 | # @param trace [Trace] 388 | # 389 | # @return [Hash[Symbol, Any]] 390 | # Hash mapping local variable names to their respective values 391 | private def extract_locals(trace) 392 | local_names = trace.binding.eval('local_variables') 393 | local_names.map { |n| [n, trace.binding.eval(n.to_s)] }.to_h 394 | end 395 | end 396 | end 397 | -------------------------------------------------------------------------------- /lib/trace_spy/version.rb: -------------------------------------------------------------------------------- 1 | module TraceSpy 2 | VERSION = "0.0.3" 3 | end 4 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "trace_spy" 3 | 4 | RSpec.configure do |config| 5 | # Enable flags like --only-failures and --next-failure 6 | config.example_status_persistence_file_path = ".rspec_status" 7 | 8 | # Disable RSpec exposing methods globally on `Module` and `main` 9 | config.disable_monkey_patching! 10 | 11 | config.expect_with :rspec do |c| 12 | c.syntax = :expect 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/trace_spy/method_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe TraceSpy::Method do 2 | def standard_method(a, b, c) 3 | a + b + c 4 | end 5 | 6 | def local_method(a, b, c) 7 | d = 10 8 | 9 | a + b + c + d 10 | end 11 | 12 | def exception_method(a) 13 | raise 'heck' unless a > 5 14 | 15 | a + 5 16 | end 17 | 18 | let(:method_name) { :standard_method } 19 | let(:klass) { Any } 20 | let(:spy_function) { proc {} } 21 | 22 | let(:target) { double('target') } 23 | 24 | let(:subject) { 25 | TraceSpy::Method.new(method_name, from_class: klass, &spy_function) 26 | } 27 | 28 | describe '.initialize' do 29 | it 'creates a new Method spy' do 30 | expect(subject).to be_a(TraceSpy::Method) 31 | end 32 | end 33 | 34 | describe 'Argument Spies' do 35 | let(:spy_function) { 36 | -> spy { 37 | spy.on_arguments do |m| 38 | m.when(a: 5) { |args| target.call(args) } 39 | end 40 | } 41 | } 42 | 43 | it 'can spy on an arguments value' do 44 | subject.with_tracing do 45 | expect(target).to receive(:call).with( 46 | a: 5, 47 | b: 2, 48 | c: 3 49 | ) 50 | 51 | expect(standard_method(5, 2, 3)).to eq(10) 52 | end 53 | end 54 | 55 | it 'will not be called if the argument predicate is not matched' do 56 | subject.with_tracing do 57 | expect(target).not_to receive(:call) 58 | 59 | expect(standard_method(1, 2, 3)).to eq(6) 60 | end 61 | end 62 | end 63 | 64 | describe 'Local Spies' do 65 | let(:method_name) { :local_method } 66 | 67 | let(:spy_function) { 68 | -> spy { 69 | spy.on_locals do |m| 70 | m.when(d: 10) { |locals| target.call(locals) } 71 | end 72 | } 73 | } 74 | 75 | it 'can spy on an arguments value' do 76 | subject.with_tracing do 77 | expect(target).to receive(:call).with( 78 | a: 5, 79 | b: 2, 80 | c: 3, 81 | d: 10 82 | ) 83 | 84 | local_method(5, 2, 3) 85 | end 86 | end 87 | 88 | context 'When the local variable is not defined' do 89 | let(:method_name) { :standard_method } 90 | 91 | it 'will not be called if the argument predicate is not matched' do 92 | subject.with_tracing do 93 | expect(target).not_to receive(:call) 94 | 95 | standard_method(1, 2, 3) 96 | end 97 | end 98 | end 99 | end 100 | 101 | describe 'Return Spies' do 102 | let(:spy_function) { 103 | -> spy { 104 | spy.on_return do |m| 105 | m.when(:even?) { |return_value| target.call(return_value) } 106 | end 107 | } 108 | } 109 | 110 | it 'can spy on an arguments value' do 111 | subject.with_tracing do 112 | expect(target).to receive(:call).with(6) 113 | expect(standard_method(1, 2, 3)).to eq(6) 114 | end 115 | end 116 | 117 | it 'will not be called if the return predicate is not matched' do 118 | subject.with_tracing do 119 | expect(target).not_to receive(:call) 120 | expect(standard_method(2, 2, 3)).to eq(7) 121 | end 122 | end 123 | end 124 | 125 | describe 'Exception Spies' do 126 | let(:method_name) { :exception_method } 127 | 128 | let(:spy_function) { 129 | -> spy { 130 | spy.on_exception do |m| 131 | m.when(RuntimeError) { |e| target.call(e) } 132 | end 133 | } 134 | } 135 | 136 | it 'can spy on an arguments value' do 137 | subject.with_tracing do 138 | expect(target).to receive(:call).with(instance_of(RuntimeError)) 139 | expect { exception_method(1) }.to raise_error(RuntimeError, 'heck') 140 | end 141 | end 142 | 143 | it 'will not be called if the return predicate is not matched' do 144 | subject.with_tracing do 145 | expect(target).not_to receive(:call) 146 | expect(exception_method(6)).to eq(11) 147 | end 148 | end 149 | end 150 | 151 | describe '#current_local_variables' do 152 | let(:method_name) { :local_method } 153 | 154 | let(:spy_function) { 155 | -> spy { 156 | spy.on_return do |m| 157 | m.when(:even?) { |_| target.call(spy.current_local_variables) } 158 | end 159 | } 160 | } 161 | 162 | it 'can reference the current local variables without needing on_locals' do 163 | subject.with_tracing do 164 | expect(target).to receive(:call).with( 165 | a: 2, 166 | b: 2, 167 | c: 2, 168 | d: 10 169 | ) 170 | 171 | local_method(2, 2, 2) 172 | end 173 | end 174 | end 175 | 176 | describe '#current_arguments' do 177 | let(:method_name) { :local_method } 178 | 179 | let(:spy_function) { 180 | -> spy { 181 | spy.on_return do |m| 182 | m.when(:even?) { |_| target.call(spy.current_arguments) } 183 | end 184 | } 185 | } 186 | 187 | it 'can reference the current arguments without needing on_arguments' do 188 | subject.with_tracing do 189 | expect(target).to receive(:call).with( 190 | a: 2, 191 | b: 2, 192 | c: 2 193 | ) 194 | 195 | local_method(2, 2, 2) 196 | end 197 | end 198 | end 199 | end 200 | -------------------------------------------------------------------------------- /spec/trace_spy_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe TraceSpy do 2 | it "has a version number" do 3 | expect(TraceSpy::VERSION).not_to be nil 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /trace_spy.gemspec: -------------------------------------------------------------------------------- 1 | 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "trace_spy/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "trace_spy" 8 | spec.version = TraceSpy::VERSION 9 | spec.authors = ["Brandon Weaver"] 10 | spec.email = ["keystonelemur@gmail.com"] 11 | 12 | spec.summary = %q{TraceSpy is a wrapper around TracePoint} 13 | spec.homepage = "https://www.github.com/baweaver/trace_spy" 14 | spec.license = "MIT" 15 | 16 | # Specify which files should be added to the gem when it is released. 17 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 18 | spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do 19 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 20 | end 21 | spec.bindir = "exe" 22 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 23 | spec.require_paths = ["lib"] 24 | 25 | spec.add_runtime_dependency "qo", "~> 0.5" 26 | 27 | spec.add_development_dependency "bundler", "~> 1.17" 28 | spec.add_development_dependency "rake", "~> 10.0" 29 | spec.add_development_dependency "rspec", "~> 3.0" 30 | spec.add_development_dependency "guard-rspec", "~> 4.0" 31 | end 32 | --------------------------------------------------------------------------------