matchers 0.1.0.pre.1

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 (127) hide show
  1. checksums.yaml +7 -0
  2. data/lib/matcher/assertions.rb +19 -0
  3. data/lib/matcher/autoload.rb +5 -0
  4. data/lib/matcher/base.rb +183 -0
  5. data/lib/matcher/compatibility.rb +34 -0
  6. data/lib/matcher/debug.rb +62 -0
  7. data/lib/matcher/dsl/builder.rb +99 -0
  8. data/lib/matcher/dsl/chain.rb +84 -0
  9. data/lib/matcher/dsl/expression_dsl.rb +306 -0
  10. data/lib/matcher/dsl/matcher_dsl.rb +5 -0
  11. data/lib/matcher/dsl/optional.rb +82 -0
  12. data/lib/matcher/dsl/optional_chain.rb +24 -0
  13. data/lib/matcher/dsl/others.rb +28 -0
  14. data/lib/matcher/errors/and_error.rb +88 -0
  15. data/lib/matcher/errors/boolean_collector.rb +51 -0
  16. data/lib/matcher/errors/element_error.rb +24 -0
  17. data/lib/matcher/errors/empty_error.rb +23 -0
  18. data/lib/matcher/errors/error.rb +39 -0
  19. data/lib/matcher/errors/error_collector.rb +100 -0
  20. data/lib/matcher/errors/nested_error.rb +98 -0
  21. data/lib/matcher/errors/or_error.rb +88 -0
  22. data/lib/matcher/expression_cache.rb +57 -0
  23. data/lib/matcher/expression_labeler.rb +96 -0
  24. data/lib/matcher/expressions/array_expression.rb +45 -0
  25. data/lib/matcher/expressions/block.rb +189 -0
  26. data/lib/matcher/expressions/call.rb +307 -0
  27. data/lib/matcher/expressions/call_error.rb +45 -0
  28. data/lib/matcher/expressions/constant.rb +53 -0
  29. data/lib/matcher/expressions/expression.rb +237 -0
  30. data/lib/matcher/expressions/expression_walker.rb +77 -0
  31. data/lib/matcher/expressions/hash_expression.rb +59 -0
  32. data/lib/matcher/expressions/proc_expression.rb +96 -0
  33. data/lib/matcher/expressions/range_expression.rb +65 -0
  34. data/lib/matcher/expressions/recorder.rb +136 -0
  35. data/lib/matcher/expressions/rescue_last_error_expression.rb +49 -0
  36. data/lib/matcher/expressions/set_expression.rb +45 -0
  37. data/lib/matcher/expressions/string_expression.rb +53 -0
  38. data/lib/matcher/expressions/symbol_proc.rb +53 -0
  39. data/lib/matcher/expressions/variable.rb +87 -0
  40. data/lib/matcher/hash_stack.rb +52 -0
  41. data/lib/matcher/list.rb +102 -0
  42. data/lib/matcher/markers.rb +7 -0
  43. data/lib/matcher/matcher_cache.rb +18 -0
  44. data/lib/matcher/matchers/all_matcher.rb +60 -0
  45. data/lib/matcher/matchers/always_matcher.rb +34 -0
  46. data/lib/matcher/matchers/any_matcher.rb +70 -0
  47. data/lib/matcher/matchers/array_matcher.rb +72 -0
  48. data/lib/matcher/matchers/block_matcher.rb +61 -0
  49. data/lib/matcher/matchers/boolean_matcher.rb +37 -0
  50. data/lib/matcher/matchers/dig_matcher.rb +149 -0
  51. data/lib/matcher/matchers/each_matcher.rb +85 -0
  52. data/lib/matcher/matchers/each_pair_matcher.rb +119 -0
  53. data/lib/matcher/matchers/equal_matcher.rb +198 -0
  54. data/lib/matcher/matchers/equal_set_matcher.rb +112 -0
  55. data/lib/matcher/matchers/expression_matcher.rb +69 -0
  56. data/lib/matcher/matchers/filter_matcher.rb +115 -0
  57. data/lib/matcher/matchers/hash_matcher.rb +315 -0
  58. data/lib/matcher/matchers/imply_matcher.rb +83 -0
  59. data/lib/matcher/matchers/imply_some_matcher.rb +116 -0
  60. data/lib/matcher/matchers/index_by_matcher.rb +177 -0
  61. data/lib/matcher/matchers/inline_matcher.rb +101 -0
  62. data/lib/matcher/matchers/keys_matcher.rb +131 -0
  63. data/lib/matcher/matchers/kind_of_matcher.rb +35 -0
  64. data/lib/matcher/matchers/lazy_all_matcher.rb +69 -0
  65. data/lib/matcher/matchers/lazy_any_matcher.rb +69 -0
  66. data/lib/matcher/matchers/let_matcher.rb +73 -0
  67. data/lib/matcher/matchers/map_matcher.rb +148 -0
  68. data/lib/matcher/matchers/negated_array_matcher.rb +38 -0
  69. data/lib/matcher/matchers/negated_each_matcher.rb +36 -0
  70. data/lib/matcher/matchers/negated_each_pair_matcher.rb +38 -0
  71. data/lib/matcher/matchers/negated_imply_some_matcher.rb +46 -0
  72. data/lib/matcher/matchers/negated_matcher.rb +25 -0
  73. data/lib/matcher/matchers/negated_project_matcher.rb +31 -0
  74. data/lib/matcher/matchers/never_matcher.rb +35 -0
  75. data/lib/matcher/matchers/one_matcher.rb +68 -0
  76. data/lib/matcher/matchers/optional_matcher.rb +38 -0
  77. data/lib/matcher/matchers/parse_float_matcher.rb +86 -0
  78. data/lib/matcher/matchers/parse_integer_matcher.rb +101 -0
  79. data/lib/matcher/matchers/parse_iso8601_helper.rb +41 -0
  80. data/lib/matcher/matchers/parse_iso8601_matcher.rb +52 -0
  81. data/lib/matcher/matchers/parse_json_helper.rb +43 -0
  82. data/lib/matcher/matchers/parse_json_matcher.rb +59 -0
  83. data/lib/matcher/matchers/project_matcher.rb +72 -0
  84. data/lib/matcher/matchers/raises_matcher.rb +131 -0
  85. data/lib/matcher/matchers/range_matcher.rb +50 -0
  86. data/lib/matcher/matchers/reference_matcher.rb +213 -0
  87. data/lib/matcher/matchers/reference_matcher_collection.rb +57 -0
  88. data/lib/matcher/matchers/regexp_matcher.rb +86 -0
  89. data/lib/matcher/messages/expected_phrasing.rb +355 -0
  90. data/lib/matcher/messages/message.rb +104 -0
  91. data/lib/matcher/messages/message_builder.rb +35 -0
  92. data/lib/matcher/messages/message_rules.rb +240 -0
  93. data/lib/matcher/messages/namespaced_message_builder.rb +19 -0
  94. data/lib/matcher/messages/phrasing.rb +59 -0
  95. data/lib/matcher/messages/standard_message_builder.rb +105 -0
  96. data/lib/matcher/patterns/ast_mapping.rb +42 -0
  97. data/lib/matcher/patterns/capture_hole.rb +33 -0
  98. data/lib/matcher/patterns/constant_hole.rb +14 -0
  99. data/lib/matcher/patterns/hole.rb +30 -0
  100. data/lib/matcher/patterns/method_hole.rb +62 -0
  101. data/lib/matcher/patterns/pattern.rb +104 -0
  102. data/lib/matcher/patterns/pattern_building.rb +39 -0
  103. data/lib/matcher/patterns/pattern_capture.rb +11 -0
  104. data/lib/matcher/patterns/pattern_match.rb +29 -0
  105. data/lib/matcher/patterns/variable_hole.rb +14 -0
  106. data/lib/matcher/reporter.rb +103 -0
  107. data/lib/matcher/rules/message_factory.rb +26 -0
  108. data/lib/matcher/rules/message_rule.rb +18 -0
  109. data/lib/matcher/rules/message_rule_context.rb +26 -0
  110. data/lib/matcher/rules/rule_builder.rb +29 -0
  111. data/lib/matcher/rules/rule_set.rb +57 -0
  112. data/lib/matcher/rules/transform_builder.rb +24 -0
  113. data/lib/matcher/rules/transform_mapping.rb +5 -0
  114. data/lib/matcher/rules/transform_rule.rb +21 -0
  115. data/lib/matcher/state.rb +40 -0
  116. data/lib/matcher/testing/error_builder.rb +62 -0
  117. data/lib/matcher/testing/error_checker.rb +514 -0
  118. data/lib/matcher/testing/error_testing.rb +37 -0
  119. data/lib/matcher/testing/pattern_testing.rb +11 -0
  120. data/lib/matcher/testing/pattern_testing_scope.rb +34 -0
  121. data/lib/matcher/testing.rb +107 -0
  122. data/lib/matcher/undefined.rb +10 -0
  123. data/lib/matcher/utils/mapping_utils.rb +61 -0
  124. data/lib/matcher/utils.rb +72 -0
  125. data/lib/matcher/version.rb +5 -0
  126. data/lib/matcher.rb +346 -0
  127. metadata +174 -0
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matcher
4
+ class ProjectMatcher < Base
5
+ def initialize(expression, matcher)
6
+ super()
7
+
8
+ @expression = expression
9
+ @matcher = matcher
10
+ end
11
+
12
+ def negate
13
+ NegatedProjectMatcher.new(@expression, @matcher)
14
+ end
15
+
16
+ def validate(state)
17
+ begin
18
+ result = @expression.evaluate(state.values)
19
+ rescue CallError => e
20
+ # rescuing here instead of method so we won't catch from yield
21
+ state.errors << e.message_for_errors(state.actual)
22
+ return
23
+ end
24
+
25
+ state.errors[@expression] << yield(@matcher, result)
26
+ end
27
+
28
+ def to_s
29
+ "project(#{@expression} => #{@matcher})"
30
+ end
31
+ end
32
+
33
+ module MatcherDsl
34
+ ##
35
+ # Matches the value of an expression
36
+ # @example
37
+ # # matches "5"
38
+ # project(_.to_i => _ < 10)
39
+ # # alternatively:
40
+ # project(_.to_i) ^ (_ < 10)
41
+ # # project multiple expressions
42
+ # project(
43
+ # _.foo => 1,
44
+ # _.bar => 2,
45
+ # )
46
+ # @overload project(expression => matcher)
47
+ # @return [ProjectMatcher]
48
+ # @overload project(expression)
49
+ # @return [Chain<ProjectMatcher>]
50
+ # @overload project(**projections)
51
+ # @return [AllMatcher<ProjectMatcher>]
52
+ def project(expression = UNDEFINED, **projections)
53
+ if !Matcher.undefined?(expression) && !projections.empty?
54
+ raise "cannot mix project(expression) ^ matcher and " \
55
+ "project(expression => matcher)"
56
+ end
57
+
58
+ unless Matcher.undefined?(expression)
59
+ return Chain.new { project(expression => _1) }
60
+ end
61
+
62
+ project_matchers = projections.map do |e, m|
63
+ e = expression_of(e)
64
+ m = matcher_of(m)
65
+
66
+ ProjectMatcher.new(e, m)
67
+ end
68
+
69
+ all(*project_matchers)
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matcher
4
+ class RaisesMatcher < Base
5
+ def initialize(
6
+ expression,
7
+ matcher,
8
+ negated: false,
9
+ rescue_exception: StandardError
10
+ )
11
+ super()
12
+
13
+ @expression = expression
14
+ @matcher = negated ? ~matcher : matcher
15
+ @original_matcher = matcher
16
+ @negated = negated
17
+ @rescue_exception = rescue_exception
18
+ end
19
+
20
+ def negate
21
+ RaisesMatcher.new(
22
+ @expression,
23
+ @original_matcher,
24
+ negated: !@negated,
25
+ rescue_exception: @rescue_exception,
26
+ )
27
+ end
28
+
29
+ def validate(state)
30
+ @expression.evaluate(state.values)
31
+
32
+ return if @negated
33
+
34
+ given = @expression.given_for(state.values)
35
+ state.errors << state.expected.namespace(:expression)
36
+ .raising(@expression, @rescue_exception, given)
37
+ rescue @rescue_exception => e
38
+ state.errors[rescue_last_error] << yield(@matcher, unwrap_exception(e))
39
+ end
40
+
41
+ def to_s
42
+ "#{'~' if @negated}raises(#{@expression}, #{@original_matcher})"
43
+ end
44
+
45
+ private
46
+
47
+ def rescue_last_error
48
+ @rescue_last_error ||= RescueLastErrorExpression.new(@expression)
49
+ end
50
+
51
+ def unwrap_exception(error)
52
+ case error
53
+ when CallError
54
+ error.cause
55
+ else
56
+ error
57
+ end
58
+ end
59
+ end
60
+
61
+ module MatcherDsl
62
+ ##
63
+ # Matches raised error
64
+ # @example
65
+ # # matches {} (because fetch raises KeyError)
66
+ # raises(_.fetch(:foo), KeyError)
67
+ # # alternatively:
68
+ # raises(_.fetch(:foo)) ^ KeyError
69
+ # # match error message
70
+ # raises(_.call, message: /something went wrong/)
71
+ # # pass block instead of expression
72
+ # raises(NoMethodError) { |x| x.fetch(:foo) }
73
+ # # rescue non-standard exceptions
74
+ # raises(_.call, rescue: Exception)
75
+ # @overload raises(expression, matcher, message: UNDEFINED, rescue: StandardError)
76
+ # Matches error raised from expression.
77
+ # @param expression [Expression]
78
+ # @param matcher [Base] matcher for error
79
+ # @param message [Base] matcher for message
80
+ # @param rescue [Class] exception class to rescue
81
+ # @return [RaisesMatcher]
82
+ # @overload raises(matcher, message: UNDEFINED, rescue: StandardError, &)
83
+ # Matches error raised from block.
84
+ # @param matcher [Base] matcher for error
85
+ # @param message [Base] matcher for message
86
+ # @param rescue [Class] exception class to rescue
87
+ # @yield actual
88
+ # @return [OptionalChain<RaisesMatcher>]
89
+ def raises(
90
+ expression_or_matcher = UNDEFINED,
91
+ matcher = UNDEFINED,
92
+ message: UNDEFINED,
93
+ rescue: StandardError,
94
+ &block
95
+ )
96
+ no_arg1 = Matcher.undefined?(expression_or_matcher)
97
+ no_arg2 = Matcher.undefined?(matcher)
98
+
99
+ if no_arg1 == no_arg2 && no_arg1 ^ block_given?
100
+ raise ArgumentError, "both expression and block given" unless no_arg1
101
+
102
+ raise ArgumentError, "neither expression nor block given"
103
+ end
104
+
105
+ if block_given?
106
+ expression = ProcExpression.new(block)
107
+ matcher = expression_or_matcher
108
+ else
109
+ expression = expression_of(expression_or_matcher)
110
+ end
111
+
112
+ return Chain.new { raises(expression, _1, message:, rescue:) }.optional if
113
+ Matcher.undefined?(matcher)
114
+
115
+ matcher = matcher_of(matcher)
116
+
117
+ unless Matcher.undefined?(message)
118
+ @raises_message_call ||=
119
+ expression_of(Call.new(Variable.actual, :message))
120
+
121
+ message_matcher = matcher_of(message)
122
+
123
+ matcher &= ProjectMatcher.new(@raises_message_call, message_matcher)
124
+ end
125
+
126
+ RaisesMatcher.new(
127
+ expression, matcher, rescue_exception: { rescue: }[:rescue]
128
+ )
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matcher
4
+ class RangeMatcher < Base
5
+ def self.cache(range, matcher_cache = MatcherCache.current)
6
+ return new(range) unless matcher_cache
7
+
8
+ (matcher_cache.range_matchers ||= {})[range] ||= new(range)
9
+ end
10
+
11
+ def initialize(range, negated: false)
12
+ super()
13
+
14
+ @range = range
15
+ @negated = negated
16
+ end
17
+
18
+ def negate
19
+ RangeMatcher.new(@range, negated: !@negated)
20
+ end
21
+
22
+ def validate(state)
23
+ limit = @range.begin || @range.end
24
+
25
+ if (limit <=> state.actual).nil?
26
+ unless @negated
27
+ state.errors << state.expected.comparable_to(@range.begin)
28
+ end
29
+
30
+ return
31
+ end
32
+
33
+ return if @range.include?(state.actual) ^ @negated
34
+
35
+ state.errors << state.expected.not_if(@negated).between(
36
+ @range.begin,
37
+ @range.end,
38
+ exclude_end: @range.exclude_end?,
39
+ )
40
+ end
41
+
42
+ def to_s
43
+ if @negated
44
+ "neg(#{@range})"
45
+ else
46
+ @range.to_s
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matcher
4
+ ##
5
+ # Build recursive matchers.
6
+ # m = Matcher.build do
7
+ # refs[:list] = {
8
+ # head: Integer,
9
+ # tail: optional(refs[:list]),
10
+ # }
11
+ # end
12
+ #
13
+ # m.match?({ head: 1, tail: { head: 2, tail: nil } })
14
+ # # => true
15
+ # m.match({ head: 1, tail: { head: "two", tail: nil } })
16
+ # # > root[:tail][:head]: expected a kind of Integer but got "two"
17
+ #
18
+ # == Allow cyclic references
19
+ #
20
+ # By default, reference matchers won't allow visiting the same object twice.
21
+ # However, for structures like graphs we can enable cyclic mode. When the
22
+ # reference matcher revisits an object it will assume a new match would be
23
+ # the same as the first match result and skip traversal.
24
+ #
25
+ # Also note, that when caching is enabled values won't be passed to the target
26
+ # matcher to ensure results are reproducible for the same object.
27
+ #
28
+ # m = Matcher.build do
29
+ # refs[:vertex] = {
30
+ # name: String,
31
+ # edges: each(refs[:edge, cyclic: true]),
32
+ # }
33
+ #
34
+ # refs[:edge] = {
35
+ # weight: Integer,
36
+ # destination: refs[:vertex, cyclic: true],
37
+ # }
38
+ #
39
+ # { vertices: each(refs[:vertex]) }
40
+ # end
41
+ #
42
+ # a = { name: 'a', edges: [] }
43
+ # b = { name: 'b', edges: [] }
44
+ # c = { name: 'c', edges: [] }
45
+ #
46
+ # a[:edges] << { weight: 1, destination: b }
47
+ # b[:edges] << { weight: 2, destination: c }
48
+ # c[:edges] << { weight: "3", destination: a }
49
+ #
50
+ # graph = { vertices: [a, b, c] }
51
+ #
52
+ # m.match(graph)
53
+ # # > root[:vertices][0][:edges][0][:destination][:edges][0][:destination]~
54
+ # # [:edges][0][:weight]: expected a kind of Integer but got "3"
55
+ # # > root[:vertices][1]: actual has already failed before
56
+ # # > root[:vertices][2]: actual has already failed before
57
+ #
58
+ # == Disable cache
59
+ #
60
+ # By default, results are cached for each object during a match session.
61
+ # However, if a result depends not only on the actual value but also on other
62
+ # passed values then the cache may return an incorrect result. See the example
63
+ # below:
64
+ #
65
+ # m = Matcher.build do
66
+ # refs[:foo] = _ == vars[:a]
67
+ #
68
+ # [
69
+ # let(a: 1) ^ refs[:foo],
70
+ # let(a: 2) ^ refs[:foo],
71
+ # ]
72
+ # end
73
+ #
74
+ # # should only match [1, 2] but refs[:foo] caches result for 1 as valid:
75
+ #
76
+ # m.match?([1, 1])
77
+ # # => true, but shouldn't
78
+ #
79
+ # # disable cache when matching result depends on other values than actual:
80
+ #
81
+ # m = Matcher.build do
82
+ # refs[:foo, { cache: false }] = _ == vars[:a]
83
+ #
84
+ # [
85
+ # let(a: 1) ^ refs[:foo],
86
+ # let(a: 2) ^ refs[:foo],
87
+ # ]
88
+ # end
89
+ #
90
+ # m.match([1, 1])
91
+ # # > root[1]: expected actual == a but got 1 == 2
92
+ class ReferenceMatcher < Base
93
+ Settings = Struct.new(:target, :cache)
94
+
95
+ def initialize(
96
+ key,
97
+ settings,
98
+ cyclic: nil,
99
+ negated: false,
100
+ session_key: object_id
101
+ )
102
+ super()
103
+
104
+ @key = key
105
+ @settings = settings
106
+ @cyclic = cyclic
107
+ @negated = negated
108
+ @session_key = session_key
109
+ end
110
+
111
+ def negate
112
+ ReferenceMatcher.new(
113
+ @key,
114
+ @settings,
115
+ cyclic: @cyclic,
116
+ negated: !@negated,
117
+ session_key: @session_key,
118
+ )
119
+ end
120
+
121
+ def validate(state)
122
+ actual = state.actual
123
+ sess = class_session
124
+ depth = sess[:depth]
125
+ depth = depth ? depth + 1 : 1
126
+ sess[:depth] = depth
127
+
128
+ if depth > Matcher.max_reference_depth
129
+ state.errors << "match level too deep: #{depth}"
130
+ return
131
+ end
132
+
133
+ unless visited.add?(actual.object_id)
134
+ if @negated == @cyclic
135
+ state.errors << state.report.namespace(:reference).cyclic
136
+ end
137
+
138
+ return
139
+ end
140
+
141
+ unless cache?
142
+ state.errors << yield(target)
143
+ return
144
+ end
145
+
146
+ cache = (sess[:cache] ||= {})
147
+ cache_key = [@key, actual.object_id]
148
+ cached_result = cache[cache_key]
149
+
150
+ if cached_result.nil?
151
+ # If @cyclic then call #match instead of yield. We disallow passing
152
+ # previous values for cyclic reference matchers. #match will create a
153
+ # new values stack.
154
+ target_errors = @cyclic ? target.match(actual) : yield(target)
155
+ cache[cache_key] = @negated ^ target_errors.valid?
156
+
157
+ state.errors << target_errors
158
+ elsif @negated == cached_result
159
+ state.errors << state.report.namespace(:reference).failed_from_cache
160
+ end
161
+ ensure
162
+ sess[:depth] = depth - 1
163
+ end
164
+
165
+ def to_s
166
+ "#{'~' if @negated}refs[#{@key.inspect}]"
167
+ end
168
+
169
+ private
170
+
171
+ def cache?
172
+ return @cache if defined? @cache
173
+
174
+ @cache = @settings[@key].cache
175
+ end
176
+
177
+ def visited
178
+ session(@session_key)[:visited] ||= Set.new
179
+ end
180
+
181
+ def target
182
+ return @target if defined? @target
183
+
184
+ pair = @settings[@key].target
185
+
186
+ raise "No target for #{@key.inspect}" unless pair
187
+
188
+ @target = if @negated
189
+ pair[1] ||= ~pair[0]
190
+ else
191
+ pair[0]
192
+ end
193
+ end
194
+ end
195
+
196
+ module MatcherDsl
197
+ def refs?
198
+ !@refs.nil?
199
+ end
200
+
201
+ ##
202
+ # Returns the reference matcher collection for recursive matchers
203
+ # @example
204
+ # refs[:list] = {
205
+ # head: Integer,
206
+ # tail: optional(refs[:list]),
207
+ # }
208
+ # @return [ReferenceMatcherCollection]
209
+ def refs
210
+ @refs ||= ReferenceMatcherCollection.new(self)
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matcher
4
+ class ReferenceMatcherCollection
5
+ include NoMatcher
6
+ include NoExpression
7
+ include NoKey
8
+
9
+ attr_reader :last_object_id, :last_matcher
10
+
11
+ def initialize(builder)
12
+ @settings = {}
13
+ @last_object_id = nil
14
+ @last_matcher = nil
15
+ @used = Set.new
16
+ @builder = builder
17
+ end
18
+
19
+ def finalize
20
+ @settings.freeze
21
+
22
+ assigned = @settings.each_key.to_set
23
+ missing = @used - assigned
24
+ unused = assigned - @used
25
+
26
+ raise "undefined ref: #{missing.join(', ')}" unless missing.empty?
27
+ raise "unused ref: #{unused.join(', ')}" unless unused.empty?
28
+ end
29
+
30
+ def [](key, cyclic: false)
31
+ @used << key
32
+
33
+ ReferenceMatcher.new(key, @settings, cyclic:)
34
+ end
35
+
36
+ DEFAULT_OPTIONS = { cache: true }.freeze
37
+
38
+ def []=(key, matcher_or_options, matcher = UNDEFINED)
39
+ raise "Cannot reassign reference: #{key.inspect}" if @settings.key?(key)
40
+
41
+ if Matcher.undefined?(matcher)
42
+ options = DEFAULT_OPTIONS
43
+ matcher = matcher_or_options
44
+ else
45
+ options = matcher_or_options.merge(DEFAULT_OPTIONS) { |_k, l, _r| l }
46
+ end
47
+
48
+ @last_object_id = matcher.__id__
49
+ matcher = @builder.matcher_of(matcher)
50
+ @last_matcher = matcher
51
+
52
+ settings = (@settings[key] ||= ReferenceMatcher::Settings.new)
53
+ settings.target = [matcher, nil]
54
+ settings.cache = options[:cache]
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matcher
4
+ class RegexpMatcher < Base
5
+ def self.cache(pattern, matcher_cache = MatcherCache.current)
6
+ return new(pattern) unless matcher_cache
7
+
8
+ (matcher_cache.regexp_matchers ||= {})[pattern] ||= new(pattern)
9
+ end
10
+
11
+ def initialize(pattern, matcher = AlwaysMatcher.instance, negated: false)
12
+ super()
13
+
14
+ @pattern = pattern
15
+ @matcher = negated ? ~matcher : matcher
16
+ @original_matcher = matcher
17
+ @negated = negated
18
+ end
19
+
20
+ def negate
21
+ RegexpMatcher.new(@pattern, @original_matcher, negated: !@negated)
22
+ end
23
+
24
+ def validate(state)
25
+ unless state.actual.is_a?(String)
26
+ state.errors << state.expected.kind_of(String) unless @negated
27
+ return
28
+ end
29
+
30
+ if @original_matcher == AlwaysMatcher.instance
31
+ state.errors << state.expected.not_if(@negated).matching(@pattern) if
32
+ @pattern.match?(state.actual) == @negated
33
+ else
34
+ match = @pattern.match(state.actual)
35
+
36
+ if match
37
+ state.errors[match_pattern_call] << yield(@matcher, match)
38
+ else
39
+ state.errors << state.expected.matching(@pattern) unless @negated
40
+ end
41
+ end
42
+ end
43
+
44
+ def match_pattern_call
45
+ @match_pattern_call ||=
46
+ Call.new(Variable.actual, :match, [Constant.new(@pattern)])
47
+ end
48
+
49
+ def to_s
50
+ if @original_matcher == AlwaysMatcher.instance
51
+ if @negated
52
+ "neg(#{@pattern.inspect})"
53
+ else
54
+ @pattern.inspect
55
+ end
56
+ else
57
+ "#{'~' if @negated}regexp(#{@pattern.inspect}, #{@original_matcher})"
58
+ end
59
+ end
60
+ end
61
+
62
+ module MatcherDsl
63
+ ##
64
+ # Matches regular expression and passes MatchData to matcher
65
+ # @example
66
+ # # matches "x=5" but not "y=5" or "x=20"
67
+ # regexp(/x=(\d+)/, project(_[1].to_i => 0..10))
68
+ # # alternatively:
69
+ # regexp(/x=(\d+)/) ^ project(_[1].to_i => 0..10)
70
+ # # without matcher passes if regular expression matches
71
+ # regexp(/x=\d+/)
72
+ # @overload regexp(pattern, matcher)
73
+ # @param pattern [Regexp]
74
+ # @param matcher [Base]
75
+ # @return [RegexpMatcher]
76
+ # @overload regexp(pattern)
77
+ # @param pattern [Regexp]
78
+ # @return [OptionalChain<RegexpMatcher>]
79
+ def regexp(pattern, matcher = UNDEFINED)
80
+ return Chain.new { regexp(pattern, _1) }.optional if
81
+ Matcher.undefined?(matcher)
82
+
83
+ RegexpMatcher.new(pattern, matcher_of(matcher))
84
+ end
85
+ end
86
+ end