naught 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +5 -5
  2. data/LICENSE.txt +1 -1
  3. data/lib/naught/basic_object.rb +4 -14
  4. data/lib/naught/call_location.rb +131 -0
  5. data/lib/naught/caller_info.rb +128 -0
  6. data/lib/naught/chain_proxy.rb +51 -0
  7. data/lib/naught/conversions.rb +108 -34
  8. data/lib/naught/null_class_builder/command.rb +42 -4
  9. data/lib/naught/null_class_builder/commands/callstack.rb +89 -0
  10. data/lib/naught/null_class_builder/commands/define_explicit_conversions.rb +21 -9
  11. data/lib/naught/null_class_builder/commands/define_implicit_conversions.rb +19 -21
  12. data/lib/naught/null_class_builder/commands/impersonate.rb +13 -1
  13. data/lib/naught/null_class_builder/commands/mimic.rb +75 -31
  14. data/lib/naught/null_class_builder/commands/null_safe_proxy.rb +92 -0
  15. data/lib/naught/null_class_builder/commands/pebble.rb +21 -17
  16. data/lib/naught/null_class_builder/commands/predicates_return.rb +42 -24
  17. data/lib/naught/null_class_builder/commands/singleton.rb +14 -17
  18. data/lib/naught/null_class_builder/commands/traceable.rb +16 -11
  19. data/lib/naught/null_class_builder/commands.rb +10 -8
  20. data/lib/naught/null_class_builder.rb +213 -119
  21. data/lib/naught/stub_strategy.rb +30 -0
  22. data/lib/naught/version.rb +3 -1
  23. data/lib/naught.rb +31 -7
  24. metadata +34 -66
  25. data/.gitignore +0 -23
  26. data/.rspec +0 -2
  27. data/.rubocop.yml +0 -65
  28. data/.travis.yml +0 -24
  29. data/Changelog.md +0 -18
  30. data/Gemfile +0 -25
  31. data/Guardfile +0 -15
  32. data/README.markdown +0 -474
  33. data/Rakefile +0 -15
  34. data/naught.gemspec +0 -22
  35. data/spec/base_object_spec.rb +0 -46
  36. data/spec/basic_null_object_spec.rb +0 -34
  37. data/spec/blackhole_spec.rb +0 -14
  38. data/spec/explicit_conversions_spec.rb +0 -21
  39. data/spec/functions/actual_spec.rb +0 -22
  40. data/spec/functions/just_spec.rb +0 -22
  41. data/spec/functions/maybe_spec.rb +0 -35
  42. data/spec/functions/null_spec.rb +0 -33
  43. data/spec/implicit_conversions_spec.rb +0 -28
  44. data/spec/mimic_spec.rb +0 -123
  45. data/spec/naught/null_object_builder/command_spec.rb +0 -10
  46. data/spec/naught/null_object_builder_spec.rb +0 -31
  47. data/spec/naught_spec.rb +0 -93
  48. data/spec/pebble_spec.rb +0 -77
  49. data/spec/predicate_spec.rb +0 -79
  50. data/spec/singleton_null_object_spec.rb +0 -33
  51. data/spec/spec_helper.rb +0 -15
  52. data/spec/support/convertable_null.rb +0 -4
  53. data/spec/support/jruby.rb +0 -3
  54. data/spec/support/rubinius.rb +0 -3
  55. data/spec/support/ruby_18.rb +0 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 429858c6dc05004904b5c95c8e536587996175fb
4
- data.tar.gz: 06ac8cf2ac0868e8b9d12d9cc15ee2c93c30428d
2
+ SHA256:
3
+ metadata.gz: 8e5ec51ba30d9f41f50f07369961f3a1442339734bb90be85b38a0bc839a6465
4
+ data.tar.gz: e481b3466ca2ec4c4f48e2cfd37d171f27988d27ec94bb576477ddc3fa6052c0
5
5
  SHA512:
6
- metadata.gz: 53cc9f5fc1b17d678163f02c7d61e967895a21afb01a93bcddc68963654e036051cfd303eb492f11da7d1b6e67508ccaf8ebe40ee2b45ec0d3884cec11ce123f
7
- data.tar.gz: 1bb2ba430a1be17b50394c2ea2f3e035fec669d4b014c61726cfad653f55c1f42217534d1f920bd9d9cb62096ebb574abe8da0f8017e86d6739a5accb1579277
6
+ metadata.gz: 750f5fcec8d8e344cb17023544d16da82b6358fcf9b557766edb403657ad60cd2cdb015f704d3acc4f7d2a4ed44625b2487e9fab83840f1d0e6e4ab57b340d49
7
+ data.tar.gz: 3665ffd61394d8ff066aea5702db37eff96d18d49c66783b35d9efd0842860fd6417c6129589c8b349166ebdaa7c144f32a819c0ba7c389a0696049d8a39e4e4
data/LICENSE.txt CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2013 Avdi Grimm
1
+ Copyright (c) 2013-2026 Avdi Grimm, Erik Berlin
2
2
 
3
3
  MIT License
4
4
 
@@ -1,17 +1,7 @@
1
1
  module Naught
2
- if defined? ::BasicObject
3
- class BasicObject < ::BasicObject
4
- end
5
- else
6
- class BasicObject #:nodoc:
7
- keep = %w(
8
- ! != == __id__ __send__ equal? instance_eval instance_exec
9
- method_missing singleton_method_added singleton_method_removed
10
- singleton_method_undefined
11
- )
12
- instance_methods.each do |method_name|
13
- undef_method(method_name) unless keep.include?(method_name)
14
- end
15
- end
2
+ # BasicObject subclass used as a minimal base for null objects
3
+ #
4
+ # @api private
5
+ class BasicObject < ::BasicObject
16
6
  end
17
7
  end
@@ -0,0 +1,131 @@
1
+ module Naught
2
+ # Represents a single method call in a null object's call trace
3
+ #
4
+ # This class provides an interface similar to Thread::Backtrace::Location,
5
+ # capturing information about where a method was called on a null object.
6
+ #
7
+ # @api public
8
+ class CallLocation
9
+ # Create a CallLocation from a caller string
10
+ #
11
+ # @param method_name [Symbol, String] the method that was called
12
+ # @param args [Array<Object>] arguments passed to the method
13
+ # @param caller_string [String, nil] a single entry from Kernel.caller
14
+ # @return [CallLocation]
15
+ # @api private
16
+ def self.from_caller(method_name, args, caller_string)
17
+ data = CallerInfo.parse(caller_string || "")
18
+ new(
19
+ label: method_name,
20
+ args: args,
21
+ path: data[:path] || "",
22
+ lineno: data[:lineno],
23
+ base_label: data[:base_label]
24
+ )
25
+ end
26
+
27
+ # The name of the method that was called
28
+ #
29
+ # @return [String] the name of the method that was called
30
+ # @example
31
+ # location.label #=> "foo"
32
+ attr_reader :label
33
+
34
+ # Arguments passed to the method call
35
+ #
36
+ # @return [Array<Object>] arguments passed to the method call
37
+ # @example
38
+ # location.args #=> [1, 2, 3]
39
+ attr_reader :args
40
+
41
+ # The absolute path to the file where the call originated
42
+ #
43
+ # @return [String] the absolute path to the file where the call originated
44
+ # @example
45
+ # location.path #=> "/path/to/file.rb"
46
+ attr_reader :path
47
+
48
+ # @!method absolute_path
49
+ # Returns the absolute path (alias for {#path})
50
+ # @return [String] the absolute path to the file
51
+ # @example
52
+ # location.absolute_path #=> "/path/to/file.rb"
53
+ alias_method :absolute_path, :path
54
+
55
+ # The line number where the call originated
56
+ #
57
+ # @return [Integer] the line number where the call originated
58
+ # @example
59
+ # location.lineno #=> 42
60
+ attr_reader :lineno
61
+
62
+ # The name of the method that made the call
63
+ #
64
+ # @return [String, nil] the name of the method that made the call
65
+ # @example
66
+ # location.base_label #=> "some_method"
67
+ attr_reader :base_label
68
+
69
+ # Initialize a new CallLocation
70
+ #
71
+ # @param label [Symbol, String] the method that was called
72
+ # @param args [Array<Object>] arguments passed to the method
73
+ # @param path [String] path to the file where the call originated
74
+ # @param lineno [Integer] line number where the call originated
75
+ # @param base_label [String, nil] name of the method that made the call
76
+ # @api private
77
+ def initialize(label:, args:, path:, lineno:, base_label: nil)
78
+ @label = label.to_s
79
+ @args = args.dup.freeze
80
+ @path = path
81
+ @lineno = lineno
82
+ @base_label = base_label
83
+ end
84
+
85
+ # Returns a human-readable string representation of the call
86
+ #
87
+ # @return [String] string representation
88
+ # @example
89
+ # location.to_s #=> "/path/to/file.rb:42:in `method' -> foo(1, 2)"
90
+ def to_s
91
+ pretty_args = args.map(&:inspect).join(", ")
92
+ location = base_label ? "#{path}:#{lineno}:in `#{base_label}'" : "#{path}:#{lineno}"
93
+ "#{location} -> #{label}(#{pretty_args})"
94
+ end
95
+
96
+ # Returns a detailed inspect representation
97
+ #
98
+ # @return [String] inspect representation
99
+ # @example
100
+ # location.inspect #=> "#<Naught::CallLocation /path/to/file.rb:42 -> foo(1)>"
101
+ def inspect = "#<#{self.class} #{self}>"
102
+
103
+ # Compare this CallLocation with another for equality
104
+ #
105
+ # @param other [CallLocation] the object to compare with
106
+ # @return [Boolean] true if all attributes match
107
+ # @example
108
+ # location1 == location2 #=> true
109
+ def ==(other)
110
+ other.is_a?(CallLocation) &&
111
+ label == other.label &&
112
+ args == other.args &&
113
+ path == other.path &&
114
+ lineno == other.lineno &&
115
+ base_label == other.base_label
116
+ end
117
+ # @!method eql?
118
+ # Compare for equality (alias for {#==})
119
+ # @return [Boolean] true if all attributes match
120
+ # @example
121
+ # location1.eql?(location2) #=> true
122
+ alias_method :eql?, :==
123
+
124
+ # Compute a hash value for this CallLocation
125
+ #
126
+ # @return [Integer] hash value based on all attributes
127
+ # @example
128
+ # location.hash #=> 123456789
129
+ def hash = [label, args, path, lineno, base_label].hash
130
+ end
131
+ end
@@ -0,0 +1,128 @@
1
+ module Naught
2
+ # Utility for parsing Ruby caller/backtrace information
3
+ #
4
+ # Extracts structured information from caller strings like:
5
+ # "/path/to/file.rb:42:in `method_name'"
6
+ # "/path/to/file.rb:42:in `block in method_name'"
7
+ # "/path/to/file.rb:42:in `block (2 levels) in method_name'"
8
+ #
9
+ # @api private
10
+ module CallerInfo
11
+ # Pattern matching quoted method signature in caller strings
12
+ # Matches both backticks and single quotes for cross-Ruby compatibility
13
+ SIGNATURE_PATTERN = /['`](?<signature>[^'`]+)['`]$/
14
+ private_constant :SIGNATURE_PATTERN
15
+
16
+ module_function
17
+
18
+ # Parse a caller string into structured components
19
+ #
20
+ # @param caller_string [String] a single entry from Kernel.caller
21
+ # @return [Hash] parsed components with keys :path, :lineno, :base_label
22
+ def parse(caller_string)
23
+ path, lineno, method_part = caller_string.to_s.split(":", 3)
24
+ {
25
+ path: path,
26
+ lineno: lineno.to_i,
27
+ base_label: extract_base_label(method_part)
28
+ }
29
+ end
30
+
31
+ # Format caller information for display in pebble output
32
+ #
33
+ # Handles nested block detection by examining the call stack.
34
+ #
35
+ # @param stack [Array<String>] the call stack from Kernel.caller
36
+ # @return [String] formatted caller description
37
+ def format_caller_for_pebble(stack)
38
+ caller_line = stack.first
39
+ signature = extract_signature(caller_line.split(":", 3)[2])
40
+ return caller_line unless signature
41
+
42
+ block_info, method_name = parse_signature(signature)
43
+ block_info = adjusted_block_info(block_info, stack, method_name)
44
+
45
+ block_info ? "#{block_info} #{method_name}" : method_name
46
+ end
47
+
48
+ # Extract the base method name from the method part of a caller string
49
+ #
50
+ # @param method_part [String, nil] the third component after splitting on ":"
51
+ # @return [String, nil] the extracted method name
52
+ def extract_base_label(method_part)
53
+ signature = extract_signature(method_part)
54
+ return nil unless signature
55
+
56
+ _block_info, method_name = parse_signature(signature)
57
+ method_name
58
+ end
59
+
60
+ # Extract the full method signature including block info
61
+ #
62
+ # @param method_part [String, nil] the third component after splitting on ":"
63
+ # @return [String, nil] the full signature
64
+ def extract_signature(method_part)
65
+ method_part&.match(SIGNATURE_PATTERN)&.[](:signature)
66
+ end
67
+
68
+ # Split a signature into block info and base method name
69
+ #
70
+ # @param signature [String] the method signature
71
+ # @return [Array(String, String), Array(nil, String)] [block_info, method_name]
72
+ def split_signature(signature)
73
+ signature.include?(" in ") ? signature.split(" in ", 2) : [nil, signature]
74
+ end
75
+
76
+ # Count nested block levels in the call stack
77
+ #
78
+ # @param stack [Array<String>] the call stack
79
+ # @param target_method [String] the method name to look for
80
+ # @return [Integer] the number of nested block levels
81
+ def count_block_levels(stack, target_method)
82
+ stack.reduce(0) do |levels, entry|
83
+ signature = extract_signature(entry.split(":", 3)[2])
84
+ break levels unless signature
85
+
86
+ block_info, method_name = parse_signature(signature)
87
+
88
+ if method_name == target_method
89
+ block_info&.start_with?("block") ? levels + 1 : (break levels)
90
+ else
91
+ levels
92
+ end
93
+ end
94
+ end
95
+
96
+ # Parse a signature into block info and clean method name
97
+ #
98
+ # @param signature [String] the method signature
99
+ # @return [Array(String, String), Array(nil, String)] [block_info, method_name]
100
+ def parse_signature(signature)
101
+ block_info, method_part = split_signature(signature)
102
+ method_name = method_part.split(/[#.]/).last
103
+ [block_info, method_name]
104
+ end
105
+
106
+ # Adjust block info to show nested levels if applicable
107
+ #
108
+ # @param block_info [String, nil] current block info
109
+ # @param stack [Array<String>] the call stack
110
+ # @param method_name [String] the method name to look for
111
+ # @return [String, nil] adjusted block info
112
+ def adjusted_block_info(block_info, stack, method_name)
113
+ return block_info unless simple_block?(block_info)
114
+
115
+ levels = count_block_levels(stack, method_name)
116
+ (levels > 1) ? "block (#{levels} levels)" : block_info
117
+ end
118
+
119
+ # Check if block_info is a simple "block" without level info
120
+ #
121
+ # @param block_info [String, nil]
122
+ # @return [Boolean]
123
+ def simple_block?(block_info)
124
+ block_info&.start_with?("block") && !block_info.include?("levels")
125
+ end
126
+ private :parse_signature, :adjusted_block_info, :simple_block?
127
+ end
128
+ end
@@ -0,0 +1,51 @@
1
+ require "naught/basic_object"
2
+
3
+ module Naught
4
+ # Lightweight proxy for tracking chained method calls
5
+ #
6
+ # Used by the callstack feature to group chained method calls
7
+ # (e.g., `null.foo.bar.baz`) into a single trace while keeping
8
+ # separate calls (e.g., `null.foo; null.bar`) in separate traces.
9
+ #
10
+ # @api private
11
+ class ChainProxy < BasicObject
12
+ # Create a new ChainProxy
13
+ #
14
+ # @param root [Object] the original null object being tracked
15
+ # @param current_trace [Array<CallLocation>] the trace to append calls to
16
+ def initialize(root, current_trace)
17
+ @root = root
18
+ @current_trace = current_trace
19
+ end
20
+
21
+ # Handle method calls by recording them and returning self for chaining
22
+ #
23
+ # @param method_name [Symbol] the method being called
24
+ # @param args [Array] arguments passed to the method
25
+ # @return [ChainProxy] self for method chaining
26
+ # rubocop:disable Style/MissingRespondToMissing -- BasicObject doesn't use respond_to_missing?
27
+ def method_missing(method_name, *args)
28
+ location = ::Naught::CallLocation.from_caller(
29
+ method_name, args, ::Kernel.caller(1, 1).first
30
+ )
31
+ @current_trace << location
32
+ self
33
+ end
34
+ # rubocop:enable Style/MissingRespondToMissing
35
+
36
+ # Check if the proxy responds to a method
37
+ #
38
+ # @return [true] chain proxies respond to any method
39
+ def respond_to?(*, **) = true
40
+
41
+ # Return a string representation of the proxy
42
+ #
43
+ # @return [String] a simple representation of the proxy
44
+ def inspect = "<null:chain>"
45
+
46
+ # Return the class of the root null object
47
+ #
48
+ # @return [Class] the class of the root null object
49
+ def class = @root.class
50
+ end
51
+ end
@@ -1,55 +1,129 @@
1
1
  module Naught
2
+ # Helper conversion API available on generated null classes
3
+ #
4
+ # This module is designed to be configured per null class via
5
+ # {Conversions.configure}. Each generated null class gets its
6
+ # own configured version of these conversion functions.
7
+ #
8
+ # @api public
2
9
  module Conversions
3
- def self.included(null_class)
4
- unless class_variable_defined?(:@@included) && @@included
5
- @@null_class = null_class
6
- @@null_equivs = null_class::NULL_EQUIVS
7
- @@included = true
10
+ # Sentinel value for no argument passed
11
+ NOTHING_PASSED = Object.new.freeze
12
+ private_constant :NOTHING_PASSED
13
+
14
+ class << self
15
+ # Configure a Conversions module for a specific null class
16
+ #
17
+ # @param mod [Module] module to configure
18
+ # @param null_class [Class] the generated null class
19
+ # @param null_equivs [Array] values treated as null-equivalent
20
+ # @return [void]
21
+ # @api private
22
+ def configure(mod, null_class:, null_equivs:)
23
+ mod.define_method(:__null_class__) { null_class }
24
+ mod.define_method(:__null_equivs__) { null_equivs }
25
+ mod.send(:private, :__null_class__, :__null_equivs__)
8
26
  end
9
- super
10
27
  end
11
28
 
12
- def Null(object = :nothing_passed)
13
- case object
14
- when NullObjectTag
15
- object
16
- when :nothing_passed, *@@null_equivs
17
- @@null_class.get(:caller => caller(1))
18
- else
19
- fail(ArgumentError.new("#{object.inspect} is not null!"))
20
- end
29
+ # Return a null object for +object+ if it is null-equivalent
30
+ #
31
+ # @example
32
+ # include MyNullObject::Conversions
33
+ # Null() #=> <null>
34
+ # Null(nil) #=> <null>
35
+ #
36
+ # @param object [Object] candidate object
37
+ # @return [Object] a null object
38
+ # @raise [ArgumentError] if +object+ is not null-equivalent
39
+ def Null(object = NOTHING_PASSED)
40
+ return object if null_object?(object)
41
+ return make_null(1) if null_equivalent?(object, include_nothing: true)
42
+
43
+ raise ArgumentError, "Null() requires a null-equivalent value, " \
44
+ "got #{object.class}: #{object.inspect}"
21
45
  end
22
46
 
47
+ # Return a null object for null-equivalent values, otherwise the value
48
+ #
49
+ # @example
50
+ # Maybe(nil) #=> <null>
51
+ # Maybe("hello") #=> "hello"
52
+ #
53
+ # @param object [Object] candidate object
54
+ # @yieldreturn [Object] optional lazy value
55
+ # @return [Object] null object or original value
23
56
  def Maybe(object = nil)
24
57
  object = yield if block_given?
25
- case object
26
- when NullObjectTag
27
- object
28
- when *@@null_equivs
29
- @@null_class.get(:caller => caller(1))
30
- else
31
- object
32
- end
58
+ return object if null_object?(object)
59
+ return make_null(1) if null_equivalent?(object)
60
+
61
+ object
33
62
  end
34
63
 
64
+ # Return the value if not null-equivalent, otherwise raise
65
+ #
66
+ # @example
67
+ # Just("hello") #=> "hello"
68
+ # Just(nil) # raises ArgumentError
69
+ #
70
+ # @param object [Object] candidate object
71
+ # @yieldreturn [Object] optional lazy value
72
+ # @return [Object] original value
73
+ # @raise [ArgumentError] if value is null-equivalent
35
74
  def Just(object = nil)
36
75
  object = yield if block_given?
37
- case object
38
- when NullObjectTag, *@@null_equivs
39
- fail(ArgumentError.new("Null value: #{object.inspect}"))
40
- else
41
- object
76
+ if null_object?(object) || null_equivalent?(object)
77
+ raise ArgumentError, "Just() requires a non-null value, got: #{object.inspect}"
42
78
  end
79
+
80
+ object
43
81
  end
44
82
 
83
+ # Return +nil+ for null objects, otherwise return the value
84
+ #
85
+ # @example
86
+ # Actual(null) #=> nil
87
+ # Actual("hello") #=> "hello"
88
+ #
89
+ # @param object [Object] candidate object
90
+ # @yieldreturn [Object] optional lazy value
91
+ # @return [Object, nil] actual value or nil
45
92
  def Actual(object = nil)
46
93
  object = yield if block_given?
47
- case object
48
- when NullObjectTag
49
- nil
50
- else
51
- object
52
- end
94
+ null_object?(object) ? nil : object
95
+ end
96
+
97
+ private
98
+
99
+ # Check if an object is a null object
100
+ #
101
+ # @param object [Object] the object to check
102
+ # @return [Boolean] true if the object is a null object
103
+ # @api private
104
+ def null_object?(object)
105
+ NullObjectTag === object
106
+ end
107
+
108
+ # Check if an object is null-equivalent (nil or custom null equivalents)
109
+ #
110
+ # @param object [Object] the object to check
111
+ # @param include_nothing [Boolean] whether to treat NOTHING_PASSED as null-equivalent
112
+ # @return [Boolean] true if the object is null-equivalent
113
+ # @api private
114
+ def null_equivalent?(object, include_nothing: false)
115
+ return true if include_nothing && object == NOTHING_PASSED
116
+
117
+ __null_equivs__.any? { |equiv| equiv === object }
118
+ end
119
+
120
+ # Create a new null object instance
121
+ #
122
+ # @param caller_offset [Integer] additional stack frames to skip
123
+ # @return [Object] a new null object
124
+ # @api private
125
+ def make_null(caller_offset)
126
+ __null_class__.get(caller: caller(caller_offset + 1))
53
127
  end
54
128
  end
55
129
  end
@@ -1,19 +1,57 @@
1
1
  module Naught
2
2
  class NullClassBuilder
3
+ # Base class for builder command implementations
4
+ #
5
+ # @api private
3
6
  class Command
7
+ # Builder instance for this command
8
+ # @return [NullClassBuilder]
9
+ # @api private
4
10
  attr_reader :builder
5
11
 
12
+ # Create a command bound to a builder
13
+ #
14
+ # @param builder [NullClassBuilder]
15
+ # @return [void]
16
+ # @api private
6
17
  def initialize(builder)
7
18
  @builder = builder
8
19
  end
9
20
 
21
+ # Execute the command
22
+ #
23
+ # @raise [NotImplementedError] when not overridden
24
+ # @return [void]
25
+ # @api private
10
26
  def call
11
- fail(NotImplementedError.new('Method #call should be overriden in child classes'))
27
+ raise NotImplementedError, "Method #call should be overridden in child classes"
12
28
  end
13
29
 
14
- def defer(options = {}, &block)
15
- @builder.defer(options, &block)
16
- end
30
+ private
31
+
32
+ # Delegate a deferred operation to the builder
33
+ #
34
+ # @param options [Hash] operation options
35
+ # @yieldparam subject [Module, Class]
36
+ # @yieldreturn [void]
37
+ # @return [void]
38
+ # @api private
39
+ def defer(options = {}, &) = builder.defer(options, &)
40
+
41
+ # Delegate a deferred class operation to the builder
42
+ #
43
+ # @yieldparam subject [Class]
44
+ # @yieldreturn [void]
45
+ # @return [void]
46
+ # @api private
47
+ def defer_class(&) = builder.defer(class: true, &)
48
+
49
+ # Delegate a prepend module operation to the builder
50
+ #
51
+ # @yieldreturn [void]
52
+ # @return [void]
53
+ # @api private
54
+ def defer_prepend_module(&) = builder.defer_prepend_module(&)
17
55
  end
18
56
  end
19
57
  end
@@ -0,0 +1,89 @@
1
+ require "naught/null_class_builder/command"
2
+ require "naught/call_location"
3
+ require "naught/chain_proxy"
4
+
5
+ module Naught
6
+ class NullClassBuilder
7
+ module Commands
8
+ # Records method calls made on null objects for debugging
9
+ #
10
+ # When enabled, each null object instance tracks all method calls made to it,
11
+ # including the method name, arguments, and source location. Calls are grouped
12
+ # into "traces" - each time a method is called directly on the original null
13
+ # object (rather than on a chained result), a new trace begins.
14
+ #
15
+ # This uses lightweight proxy objects for chaining so that we can distinguish
16
+ # between `null.foo.bar` (one trace with two calls) and `null.foo; null.bar`
17
+ # (two traces with one call each).
18
+ #
19
+ # @example Basic usage
20
+ # NullObject = Naught.build do |config|
21
+ # config.callstack
22
+ # end
23
+ #
24
+ # null = NullObject.new
25
+ # null.foo(1, 2).bar
26
+ # null.baz
27
+ #
28
+ # null.__call_trace__
29
+ # # => [
30
+ # # [#<Naught::CallLocation foo(1, 2) at example.rb:8>,
31
+ # # #<Naught::CallLocation bar() at example.rb:8>],
32
+ # # [#<Naught::CallLocation baz() at example.rb:9>]
33
+ # # ]
34
+ #
35
+ # @api private
36
+ class Callstack < Command
37
+ # Install the callstack tracking mechanism
38
+ # @return [void]
39
+ # @api private
40
+ def call
41
+ install_call_trace_accessor
42
+ install_method_missing_tracking
43
+ install_chain_proxy_class
44
+ end
45
+
46
+ private
47
+
48
+ # Install the __call_trace__ accessor on null objects
49
+ # @return [void]
50
+ # @api private
51
+ def install_call_trace_accessor
52
+ defer_prepend_module do
53
+ attr_reader :__call_trace__
54
+
55
+ define_method(:initialize) do |*args, **kwargs|
56
+ super(*args, **kwargs)
57
+ @__call_trace__ = [] #: Array[Array[Naught::CallLocation]]
58
+ end
59
+ end
60
+ end
61
+
62
+ # Install method_missing override that records calls
63
+ # @return [void]
64
+ # @api private
65
+ def install_method_missing_tracking
66
+ defer_prepend_module do
67
+ define_method(:respond_to?) do |method_name, include_private = false|
68
+ method_name == :__call_trace__ || super(method_name, include_private)
69
+ end
70
+
71
+ define_method(:method_missing) do |method_name, *args, &block|
72
+ location = Naught::CallLocation.from_caller(method_name, args, Kernel.caller(1, 1).first)
73
+ @__call_trace__ ||= [] #: Array[Array[Naught::CallLocation]]
74
+ @__call_trace__ << [location]
75
+ Naught::ChainProxy.new(self, @__call_trace__.last)
76
+ end
77
+ end
78
+ end
79
+
80
+ # Install the ChainProxy class constant for backwards compatibility
81
+ # @return [void]
82
+ # @api private
83
+ def install_chain_proxy_class
84
+ defer_class { |null_class| null_class.const_set(:ChainProxy, Naught::ChainProxy) }
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end