naught 1.0.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.
- checksums.yaml +5 -5
- data/LICENSE.txt +1 -1
- data/lib/naught/basic_object.rb +4 -14
- data/lib/naught/call_location.rb +131 -0
- data/lib/naught/caller_info.rb +128 -0
- data/lib/naught/chain_proxy.rb +51 -0
- data/lib/naught/conversions.rb +108 -34
- data/lib/naught/null_class_builder/command.rb +42 -5
- data/lib/naught/null_class_builder/commands/callstack.rb +89 -0
- data/lib/naught/null_class_builder/commands/define_explicit_conversions.rb +25 -9
- data/lib/naught/null_class_builder/commands/define_implicit_conversions.rb +22 -12
- data/lib/naught/null_class_builder/commands/impersonate.rb +21 -5
- data/lib/naught/null_class_builder/commands/mimic.rb +87 -25
- data/lib/naught/null_class_builder/commands/null_safe_proxy.rb +92 -0
- data/lib/naught/null_class_builder/commands/pebble.rb +21 -18
- data/lib/naught/null_class_builder/commands/predicates_return.rb +51 -31
- data/lib/naught/null_class_builder/commands/singleton.rb +18 -17
- data/lib/naught/null_class_builder/commands/traceable.rb +21 -12
- data/lib/naught/null_class_builder/commands.rb +10 -8
- data/lib/naught/null_class_builder.rb +217 -120
- data/lib/naught/stub_strategy.rb +30 -0
- data/lib/naught/version.rb +3 -1
- data/lib/naught.rb +31 -7
- metadata +34 -66
- data/.gitignore +0 -23
- data/.rspec +0 -2
- data/.rubocop.yml +0 -74
- data/.travis.yml +0 -20
- data/Changelog.md +0 -12
- data/Gemfile +0 -31
- data/Guardfile +0 -15
- data/README.markdown +0 -436
- data/Rakefile +0 -15
- data/naught.gemspec +0 -22
- data/spec/base_object_spec.rb +0 -47
- data/spec/basic_null_object_spec.rb +0 -35
- data/spec/blackhole_spec.rb +0 -16
- data/spec/explicit_conversions_spec.rb +0 -23
- data/spec/functions/actual_spec.rb +0 -22
- data/spec/functions/just_spec.rb +0 -22
- data/spec/functions/maybe_spec.rb +0 -35
- data/spec/functions/null_spec.rb +0 -34
- data/spec/implicit_conversions_spec.rb +0 -25
- data/spec/mimic_spec.rb +0 -117
- data/spec/naught/null_object_builder/command_spec.rb +0 -10
- data/spec/naught/null_object_builder_spec.rb +0 -31
- data/spec/naught_spec.rb +0 -101
- data/spec/pebble_spec.rb +0 -77
- data/spec/predicate_spec.rb +0 -84
- data/spec/singleton_null_object_spec.rb +0 -35
- data/spec/spec_helper.rb +0 -13
- data/spec/support/convertable_null.rb +0 -4
- data/spec/support/jruby.rb +0 -3
- data/spec/support/rubinius.rb +0 -3
- data/spec/support/ruby_18.rb +0 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8e5ec51ba30d9f41f50f07369961f3a1442339734bb90be85b38a0bc839a6465
|
|
4
|
+
data.tar.gz: e481b3466ca2ec4c4f48e2cfd37d171f27988d27ec94bb576477ddc3fa6052c0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 750f5fcec8d8e344cb17023544d16da82b6358fcf9b557766edb403657ad60cd2cdb015f704d3acc4f7d2a4ed44625b2487e9fab83840f1d0e6e4ab57b340d49
|
|
7
|
+
data.tar.gz: 3665ffd61394d8ff066aea5702db37eff96d18d49c66783b35d9efd0842860fd6417c6129589c8b349166ebdaa7c144f32a819c0ba7c389a0696049d8a39e4e4
|
data/LICENSE.txt
CHANGED
data/lib/naught/basic_object.rb
CHANGED
|
@@ -1,17 +1,7 @@
|
|
|
1
1
|
module Naught
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
data/lib/naught/conversions.rb
CHANGED
|
@@ -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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
fail ArgumentError, "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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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,20 +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
|
-
|
|
12
|
-
'Method #call should be overriden in child classes'
|
|
27
|
+
raise NotImplementedError, "Method #call should be overridden in child classes"
|
|
13
28
|
end
|
|
14
29
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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(&)
|
|
18
55
|
end
|
|
19
56
|
end
|
|
20
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
|