matchers 0.1.0.pre.test

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.

Potentially problematic release.


This version of matchers might be problematic. Click here for more details.

Files changed (123) hide show
  1. checksums.yaml +7 -0
  2. data/lib/matcher/assertions.rb +21 -0
  3. data/lib/matcher/base.rb +189 -0
  4. data/lib/matcher/builder.rb +74 -0
  5. data/lib/matcher/chain.rb +60 -0
  6. data/lib/matcher/debug.rb +48 -0
  7. data/lib/matcher/errors/and_error.rb +86 -0
  8. data/lib/matcher/errors/boolean_collector.rb +51 -0
  9. data/lib/matcher/errors/element_error.rb +24 -0
  10. data/lib/matcher/errors/empty_error.rb +23 -0
  11. data/lib/matcher/errors/error.rb +39 -0
  12. data/lib/matcher/errors/error_collector.rb +99 -0
  13. data/lib/matcher/errors/nested_error.rb +96 -0
  14. data/lib/matcher/errors/or_error.rb +86 -0
  15. data/lib/matcher/expression_cache.rb +57 -0
  16. data/lib/matcher/expression_labeler.rb +91 -0
  17. data/lib/matcher/expressions/array_expression.rb +45 -0
  18. data/lib/matcher/expressions/block.rb +153 -0
  19. data/lib/matcher/expressions/call.rb +338 -0
  20. data/lib/matcher/expressions/call_error.rb +45 -0
  21. data/lib/matcher/expressions/constant.rb +53 -0
  22. data/lib/matcher/expressions/expression.rb +147 -0
  23. data/lib/matcher/expressions/expression_building.rb +258 -0
  24. data/lib/matcher/expressions/expression_walker.rb +73 -0
  25. data/lib/matcher/expressions/hash_expression.rb +59 -0
  26. data/lib/matcher/expressions/proc_expression.rb +92 -0
  27. data/lib/matcher/expressions/range_expression.rb +58 -0
  28. data/lib/matcher/expressions/recorder.rb +86 -0
  29. data/lib/matcher/expressions/rescue_last_error_expression.rb +44 -0
  30. data/lib/matcher/expressions/set_expression.rb +45 -0
  31. data/lib/matcher/expressions/string_expression.rb +53 -0
  32. data/lib/matcher/expressions/symbol_proc.rb +53 -0
  33. data/lib/matcher/expressions/variable.rb +85 -0
  34. data/lib/matcher/hash_stack.rb +53 -0
  35. data/lib/matcher/list.rb +102 -0
  36. data/lib/matcher/markers/optional.rb +80 -0
  37. data/lib/matcher/markers/others.rb +28 -0
  38. data/lib/matcher/matcher_cache.rb +18 -0
  39. data/lib/matcher/matchers/all_matcher.rb +60 -0
  40. data/lib/matcher/matchers/always_matcher.rb +28 -0
  41. data/lib/matcher/matchers/any_matcher.rb +70 -0
  42. data/lib/matcher/matchers/array_matcher.rb +35 -0
  43. data/lib/matcher/matchers/block_matcher.rb +59 -0
  44. data/lib/matcher/matchers/boolean_matcher.rb +35 -0
  45. data/lib/matcher/matchers/dig_matcher.rb +146 -0
  46. data/lib/matcher/matchers/each_matcher.rb +52 -0
  47. data/lib/matcher/matchers/each_pair_matcher.rb +119 -0
  48. data/lib/matcher/matchers/equal_matcher.rb +197 -0
  49. data/lib/matcher/matchers/equal_set_matcher.rb +99 -0
  50. data/lib/matcher/matchers/expression_matcher.rb +73 -0
  51. data/lib/matcher/matchers/filter_matcher.rb +111 -0
  52. data/lib/matcher/matchers/hash_matcher.rb +223 -0
  53. data/lib/matcher/matchers/imply_matcher.rb +81 -0
  54. data/lib/matcher/matchers/imply_some_matcher.rb +112 -0
  55. data/lib/matcher/matchers/index_by_matcher.rb +175 -0
  56. data/lib/matcher/matchers/inline_matcher.rb +99 -0
  57. data/lib/matcher/matchers/keys_matcher.rb +121 -0
  58. data/lib/matcher/matchers/kind_of_matcher.rb +35 -0
  59. data/lib/matcher/matchers/lazy_all_matcher.rb +68 -0
  60. data/lib/matcher/matchers/lazy_any_matcher.rb +68 -0
  61. data/lib/matcher/matchers/let_matcher.rb +73 -0
  62. data/lib/matcher/matchers/map_matcher.rb +129 -0
  63. data/lib/matcher/matchers/matcher_building.rb +5 -0
  64. data/lib/matcher/matchers/negated_array_matcher.rb +38 -0
  65. data/lib/matcher/matchers/negated_each_matcher.rb +36 -0
  66. data/lib/matcher/matchers/negated_each_pair_matcher.rb +38 -0
  67. data/lib/matcher/matchers/negated_imply_some_matcher.rb +46 -0
  68. data/lib/matcher/matchers/negated_matcher.rb +23 -0
  69. data/lib/matcher/matchers/negated_project_matcher.rb +31 -0
  70. data/lib/matcher/matchers/never_matcher.rb +29 -0
  71. data/lib/matcher/matchers/one_matcher.rb +70 -0
  72. data/lib/matcher/matchers/optional_matcher.rb +38 -0
  73. data/lib/matcher/matchers/parse_float_matcher.rb +86 -0
  74. data/lib/matcher/matchers/parse_integer_matcher.rb +98 -0
  75. data/lib/matcher/matchers/parse_iso8601_matcher.rb +92 -0
  76. data/lib/matcher/matchers/parse_json_matcher.rb +95 -0
  77. data/lib/matcher/matchers/project_matcher.rb +68 -0
  78. data/lib/matcher/matchers/raises_matcher.rb +124 -0
  79. data/lib/matcher/matchers/range_matcher.rb +47 -0
  80. data/lib/matcher/matchers/reference_matcher.rb +111 -0
  81. data/lib/matcher/matchers/reference_matcher_collection.rb +57 -0
  82. data/lib/matcher/matchers/regexp_matcher.rb +84 -0
  83. data/lib/matcher/messages/expected_phrasing.rb +342 -0
  84. data/lib/matcher/messages/message.rb +102 -0
  85. data/lib/matcher/messages/message_builder.rb +35 -0
  86. data/lib/matcher/messages/message_rules.rb +223 -0
  87. data/lib/matcher/messages/namespaced_message_builder.rb +19 -0
  88. data/lib/matcher/messages/phrasing.rb +57 -0
  89. data/lib/matcher/messages/standard_message_builder.rb +105 -0
  90. data/lib/matcher/once_before.rb +18 -0
  91. data/lib/matcher/optional_chain.rb +24 -0
  92. data/lib/matcher/patterns/ast_mapping.rb +42 -0
  93. data/lib/matcher/patterns/capture_hole.rb +33 -0
  94. data/lib/matcher/patterns/constant_hole.rb +14 -0
  95. data/lib/matcher/patterns/hole.rb +30 -0
  96. data/lib/matcher/patterns/method_hole.rb +58 -0
  97. data/lib/matcher/patterns/pattern.rb +92 -0
  98. data/lib/matcher/patterns/pattern_building.rb +39 -0
  99. data/lib/matcher/patterns/pattern_capture.rb +11 -0
  100. data/lib/matcher/patterns/pattern_match.rb +29 -0
  101. data/lib/matcher/patterns/variable_hole.rb +14 -0
  102. data/lib/matcher/reporter.rb +98 -0
  103. data/lib/matcher/rules/message_factory.rb +25 -0
  104. data/lib/matcher/rules/message_rule.rb +18 -0
  105. data/lib/matcher/rules/message_rule_context.rb +24 -0
  106. data/lib/matcher/rules/rule_builder.rb +29 -0
  107. data/lib/matcher/rules/rule_set.rb +57 -0
  108. data/lib/matcher/rules/transform_builder.rb +24 -0
  109. data/lib/matcher/rules/transform_mapping.rb +5 -0
  110. data/lib/matcher/rules/transform_rule.rb +21 -0
  111. data/lib/matcher/state.rb +40 -0
  112. data/lib/matcher/testing/error_builder.rb +62 -0
  113. data/lib/matcher/testing/error_checker.rb +496 -0
  114. data/lib/matcher/testing/error_testing.rb +37 -0
  115. data/lib/matcher/testing/pattern_testing.rb +11 -0
  116. data/lib/matcher/testing/pattern_testing_scope.rb +34 -0
  117. data/lib/matcher/testing.rb +102 -0
  118. data/lib/matcher/undefined.rb +10 -0
  119. data/lib/matcher/utils/mapping_utils.rb +61 -0
  120. data/lib/matcher/utils.rb +72 -0
  121. data/lib/matcher/version.rb +5 -0
  122. data/lib/matcher.rb +337 -0
  123. metadata +167 -0
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matcher
4
+ class ParseIso8601Matcher < Base
5
+ extend OnceBefore
6
+
7
+ def initialize(matcher, negated: false)
8
+ super()
9
+
10
+ @matcher = negated ? ~matcher : matcher
11
+ @original_matcher = matcher
12
+ @negated = negated
13
+ end
14
+
15
+ once_before :initialize do
16
+ require 'time'
17
+ end
18
+
19
+ def negate
20
+ ParseIso8601Matcher.new(@original_matcher, negated: !@negated)
21
+ end
22
+
23
+ def validate(state, &)
24
+ actual = state.actual
25
+
26
+ unless actual.is_a?(String)
27
+ state.errors << state.expected.kind_of(String) unless @negated
28
+ return
29
+ end
30
+
31
+ value = Time.iso8601(actual)
32
+
33
+ if @matcher.is_a?(NeverMatcher)
34
+ state.errors << state.expected.not.valid_format(:iso8601)
35
+ return
36
+ end
37
+
38
+ result = yield(@matcher, value)
39
+
40
+ return if result.valid?
41
+
42
+ time_of = Call.new(Constant.new(Time), :iso8601, [Variable.actual])
43
+ state.errors[time_of] << result
44
+ rescue ArgumentError
45
+ state.errors << state.expected.valid_format(:iso8601) unless @negated
46
+ end
47
+
48
+ def to_s
49
+ prefix = @negated ? '~' : ''
50
+
51
+ return "#{prefix}iso8601_format" if @original_matcher.is_a?(AlwaysMatcher)
52
+
53
+ "#{prefix}parse_iso8601(#{@original_matcher})"
54
+ end
55
+ end
56
+
57
+ module MatcherBuilding
58
+ ##
59
+ # Parses ISO 8601 time and matches with given matcher
60
+ # @example
61
+ # # matches "1999-12-31T23:59:00+01:00"
62
+ # parse_iso8601(_ < expr { Time.now })
63
+ # # alternatively:
64
+ # parse_iso8601 ^ (_ < expr { Time.now })
65
+ # # without matcher matches any valid ISO 8601 string
66
+ # parse_iso8601
67
+ # @overload parse_iso8601(matcher)
68
+ # @param matcher [Base]
69
+ # @return [ParseIso8601Matcher]
70
+ # @overload parse_iso8601
71
+ # @return [OptionalChain<ParseIso8601Matcher>]
72
+ # @see #iso8601_format
73
+ def parse_iso8601(matcher = UNDEFINED)
74
+ return Chain.new { parse_iso8601(_1) }.optional if
75
+ Matcher.undefined?(matcher)
76
+
77
+ matcher = matcher_of(matcher)
78
+
79
+ ParseIso8601Matcher.new(matcher)
80
+ end
81
+
82
+ ##
83
+ # Matches valid ISO 8601 strings
84
+ # @example
85
+ # # matches { timestamp: "2025-11-16T21:13:33+01:00" }
86
+ # { timestamp: iso8601_format }
87
+ # @return [ParseIso8601Matcher]
88
+ def iso8601_format
89
+ @iso8601_format ||= ParseIso8601Matcher.new(AlwaysMatcher.instance)
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matcher
4
+ class ParseJsonMatcher < Base
5
+ extend OnceBefore
6
+
7
+ def initialize(matcher, json_options: nil, negated: false)
8
+ super()
9
+
10
+ @matcher = negated ? ~matcher : matcher
11
+ @original_matcher = matcher
12
+ @json_options = json_options
13
+ @negated = negated
14
+ end
15
+
16
+ once_before :initialize do
17
+ require 'json'
18
+ end
19
+
20
+ def negate
21
+ ParseJsonMatcher.new(@original_matcher, json_options: @json_options, negated: !@negated)
22
+ end
23
+
24
+ def validate(state)
25
+ actual = state.actual
26
+
27
+ unless actual.is_a?(String)
28
+ state.errors << state.expected.kind_of(String) unless @negated
29
+ return
30
+ end
31
+
32
+ value = JSON.parse(actual, **@json_options)
33
+
34
+ if @matcher.is_a?(NeverMatcher)
35
+ state.errors << state.expected.not.valid_format(:json)
36
+ return
37
+ end
38
+
39
+ result = yield(@matcher, value)
40
+
41
+ return if result.valid?
42
+
43
+ parse_json = Call.new(Constant.new(JSON), :parse, [Variable.actual])
44
+ state.errors[parse_json] << result
45
+ rescue JSON::ParserError
46
+ state.errors << state.expected.valid_format(:json) unless @negated
47
+ end
48
+
49
+ def to_s
50
+ prefix = @negated ? '~' : ''
51
+
52
+ return "#{prefix}json_format" if @original_matcher.is_a?(AlwaysMatcher)
53
+
54
+ "#{prefix}parse_json(#{@original_matcher})"
55
+ end
56
+ end
57
+
58
+ module MatcherBuilding
59
+ ##
60
+ # Parses JSON and matches with given matcher
61
+ # @example
62
+ # # matches '{"foo":42}'
63
+ # parse_json({ "foo" => Integer })
64
+ # # alternatively:
65
+ # parse_json ^ { "foo" => Integer }
66
+ # # without matcher matches any valid JSON string
67
+ # parse_json
68
+ # @overload parse_json(matcher, **json_options)
69
+ # @param matcher [Base]
70
+ # @return [ParseJsonMatcher]
71
+ # @overload parse_json(**json_options)
72
+ # @return [OptionalChain<ParseJsonMatcher>]
73
+ # @see #json_format
74
+ def parse_json(matcher = UNDEFINED, **)
75
+ return Chain.new { parse_json(_1, **) }.optional if
76
+ Matcher.undefined?(matcher)
77
+
78
+ matcher = matcher_of(matcher)
79
+ json_options = {}.merge(**)
80
+ json_options = nil if json_options.empty?
81
+
82
+ ParseJsonMatcher.new(matcher, json_options:)
83
+ end
84
+
85
+ ##
86
+ # Matches valid JSON strings
87
+ # @example
88
+ # # matches { payload: '{"foo":42}' }
89
+ # { payload: json_format }
90
+ # @return [ParseJsonMatcher]
91
+ def json_format
92
+ @json_format ||= ParseJsonMatcher.new(AlwaysMatcher.instance)
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,68 @@
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 MatcherBuilding
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
+ raise 'cannot mix project(expression) ^ matcher and project(expression => matcher)' if
54
+ !Matcher.undefined?(expression) && !projections.empty?
55
+
56
+ return Chain.new { project(expression => _1) } unless Matcher.undefined?(expression)
57
+
58
+ project_matchers = projections.map do |e, m|
59
+ e = expression_of(e)
60
+ m = matcher_of(m)
61
+
62
+ ProjectMatcher.new(e, m)
63
+ end
64
+
65
+ all(*project_matchers)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,124 @@
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).raising(@expression, @rescue_exception, given)
36
+ rescue @rescue_exception => e
37
+ state.errors[rescue_last_error] << yield(@matcher, unwrap_exception(e))
38
+ end
39
+
40
+ def to_s
41
+ "#{'~' if @negated}raises(#{@expression}, #{@original_matcher})"
42
+ end
43
+
44
+ private
45
+
46
+ def rescue_last_error
47
+ @rescue_last_error ||= RescueLastErrorExpression.new(@expression)
48
+ end
49
+
50
+ def unwrap_exception(e)
51
+ case e
52
+ when CallError
53
+ e.cause
54
+ else
55
+ e
56
+ end
57
+ end
58
+ end
59
+
60
+ module MatcherBuilding
61
+ ##
62
+ # Matches raised error
63
+ # @example
64
+ # # matches {} (because fetch raises KeyError)
65
+ # raises(_.fetch(:foo), KeyError)
66
+ # # alternatively:
67
+ # raises(_.fetch(:foo)) ^ KeyError
68
+ # # match error message
69
+ # raises(_.call, message: /something went wrong/)
70
+ # # pass block instead of expression
71
+ # raises(NoMethodError) { |x| x.foo }
72
+ # # rescue non-standard exceptions
73
+ # raises(_.call, rescue: Exception)
74
+ # @overload raises(expression, matcher, message: UNDEFINED, rescue: StandardError)
75
+ # @param expression [Expression]
76
+ # @param matcher [Base] matcher for error
77
+ # @param message [Base] matcher for message
78
+ # @param rescue [Class] exception class to rescue
79
+ # @return [RaisesMatcher]
80
+ # @overload raises(matcher, message: UNDEFINED, rescue: StandardError)
81
+ # @param matcher [Base] matcher for error
82
+ # @param message [Base] matcher for message
83
+ # @param rescue [Class] exception class to rescue
84
+ # @yield actual
85
+ # @return [OptionalChain<RaisesMatcher>]
86
+ def raises(
87
+ expression_or_matcher = UNDEFINED,
88
+ matcher = UNDEFINED,
89
+ message: UNDEFINED,
90
+ rescue: StandardError,
91
+ &block
92
+ )
93
+ no_arg1 = Matcher.undefined?(expression_or_matcher)
94
+ no_arg2 = Matcher.undefined?(matcher)
95
+
96
+ if no_arg1 == no_arg2 && no_arg1 ^ block_given?
97
+ raise ArgumentError, 'both expression and block given' unless no_arg1
98
+
99
+ raise ArgumentError, 'neither expression nor block given'
100
+ end
101
+
102
+ if block_given?
103
+ expression = ProcExpression.new(block)
104
+ matcher = expression_or_matcher
105
+ else
106
+ expression = expression_of(expression_or_matcher)
107
+ end
108
+
109
+ return Chain.new { raises(expression, _1, message:, rescue:) }.optional if
110
+ Matcher.undefined?(matcher)
111
+
112
+ matcher = matcher_of(matcher)
113
+
114
+ unless Matcher.undefined?(message)
115
+ @raises_message_call ||= expression_of(Call.new(Variable.actual, :message))
116
+ message_matcher = matcher_of(message)
117
+
118
+ matcher &= ProjectMatcher.new(@raises_message_call, message_matcher)
119
+ end
120
+
121
+ RaisesMatcher.new(expression, matcher, rescue_exception: { rescue: }[:rescue])
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,47 @@
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
+ state.errors << state.expected.comparable_to(@range.begin) unless @negated
27
+ return
28
+ end
29
+
30
+ return if @range.include?(state.actual) ^ @negated
31
+
32
+ state.errors << state.expected.not_if(@negated).between(
33
+ @range.begin,
34
+ @range.end,
35
+ exclude_end: @range.exclude_end?,
36
+ )
37
+ end
38
+
39
+ def to_s
40
+ if @negated
41
+ "neg(#{@range})"
42
+ else
43
+ @range.to_s
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matcher
4
+ class ReferenceMatcher < Base
5
+ Settings = Struct.new(:target, :cache)
6
+
7
+ def initialize(key, settings, cyclic: nil, negated: false, session_key: object_id)
8
+ super()
9
+
10
+ @key = key
11
+ @settings = settings
12
+ @cyclic = cyclic
13
+ @negated = negated
14
+ @session_key = session_key
15
+ end
16
+
17
+ def negate
18
+ ReferenceMatcher.new(@key, @settings, cyclic: @cyclic, negated: !@negated, session_key: @session_key)
19
+ end
20
+
21
+ def validate(state)
22
+ actual = state.actual
23
+ sess = class_session
24
+ depth = sess[:depth]
25
+ depth = depth ? depth + 1 : 1
26
+ sess[:depth] = depth
27
+
28
+ if depth > Matcher.max_reference_depth
29
+ state.errors << "match level too deep: #{depth}"
30
+ return
31
+ end
32
+
33
+ unless visited.add?(actual.object_id)
34
+ state.errors << state.report.namespace(:reference).cyclic if @negated == @cyclic
35
+
36
+ return
37
+ end
38
+
39
+ unless cache?
40
+ state.errors << yield(target)
41
+ return
42
+ end
43
+
44
+ cache = (sess[:cache] ||= {})
45
+ cache_key = [@key, actual.object_id]
46
+ cached_result = cache[cache_key]
47
+
48
+ if cached_result.nil?
49
+ # If @cyclic then call #match instead of yield. We disallow passing
50
+ # previous values for cyclic reference matchers. #match will create a
51
+ # new values stack.
52
+ target_errors = @cyclic ? target.match(actual) : yield(target)
53
+ cache[cache_key] = @negated ^ target_errors.valid?
54
+
55
+ state.errors << target_errors
56
+ elsif @negated == cached_result
57
+ state.errors << state.report.namespace(:reference).failed_from_cache
58
+ end
59
+ ensure
60
+ sess[:depth] = depth - 1
61
+ end
62
+
63
+ def to_s
64
+ "#{'~' if @negated}refs[#{@key.inspect}]"
65
+ end
66
+
67
+ private
68
+
69
+ def cache?
70
+ return @cache if defined? @cache
71
+
72
+ @cache = @settings[@key].cache
73
+ end
74
+
75
+ def visited
76
+ session(@session_key)[:visited] ||= Set.new
77
+ end
78
+
79
+ def target
80
+ return @target if defined? @target
81
+
82
+ pair = @settings[@key].target
83
+
84
+ raise "No target for #{@key.inspect}" unless pair
85
+
86
+ @target = if @negated
87
+ pair[1] ||= ~pair[0]
88
+ else
89
+ pair[0]
90
+ end
91
+ end
92
+ end
93
+
94
+ module MatcherBuilding
95
+ def refs?
96
+ !@refs.nil?
97
+ end
98
+
99
+ ##
100
+ # Returns the reference matcher collection for recursive matchers
101
+ # @example
102
+ # refs[:list] = {
103
+ # head: Integer,
104
+ # tail: optional(refs[:list]),
105
+ # }
106
+ # @return [ReferenceMatcherCollection]
107
+ def refs
108
+ @refs ||= ReferenceMatcherCollection.new(self)
109
+ end
110
+ end
111
+ 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(cache: true) { |_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,84 @@
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 MatcherBuilding
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
+ # @overload regexp(pattern, matcher)
71
+ # @param pattern [Regexp]
72
+ # @param matcher [Base]
73
+ # @return [RegexpMatcher]
74
+ # @overload regexp(pattern)
75
+ # @param pattern [Regexp]
76
+ # @return [OptionalChain<RegexpMatcher>]
77
+ def regexp(pattern, matcher = UNDEFINED)
78
+ return Chain.new { regexp(pattern, _1) }.optional if
79
+ Matcher.undefined?(matcher)
80
+
81
+ RegexpMatcher.new(pattern, matcher_of(matcher))
82
+ end
83
+ end
84
+ end