strict 1.4.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/resume +4 -0
  3. data/.agents/setup +105 -0
  4. data/.rspec +2 -0
  5. data/.rubocop.yml +14 -4
  6. data/.tool-versions +1 -1
  7. data/API.md +335 -0
  8. data/CHANGELOG.md +110 -1
  9. data/Gemfile +13 -0
  10. data/Gemfile.lock +118 -42
  11. data/README.md +278 -4
  12. data/Rakefile +13 -7
  13. data/benchmark/baseline.rb +204 -0
  14. data/lib/strict/assignment_error.rb +5 -2
  15. data/lib/strict/attribute.rb +8 -47
  16. data/lib/strict/attributes/class.rb +0 -6
  17. data/lib/strict/attributes/coercer.rb +4 -3
  18. data/lib/strict/attributes/configuration.rb +15 -0
  19. data/lib/strict/attributes/dsl.rb +18 -10
  20. data/lib/strict/attributes/generated_methods.rb +113 -0
  21. data/lib/strict/attributes/instance.rb +32 -15
  22. data/lib/strict/configuration.rb +44 -0
  23. data/lib/strict/declaration.rb +109 -0
  24. data/lib/strict/detailed_validator.rb +9 -0
  25. data/lib/strict/error.rb +8 -1
  26. data/lib/strict/initialization_error.rb +11 -2
  27. data/lib/strict/interface.rb +39 -21
  28. data/lib/strict/interfaces/conformance.rb +125 -0
  29. data/lib/strict/interfaces/instance.rb +1 -49
  30. data/lib/strict/method.rb +72 -53
  31. data/lib/strict/method_call_error.rb +12 -2
  32. data/lib/strict/method_return_error.rb +2 -2
  33. data/lib/strict/methods/dsl.rb +16 -8
  34. data/lib/strict/methods/module.rb +27 -8
  35. data/lib/strict/methods/verifiable_method.rb +234 -70
  36. data/lib/strict/object.rb +1 -1
  37. data/lib/strict/parameter.rb +3 -52
  38. data/lib/strict/return.rb +14 -10
  39. data/lib/strict/rspec.rb +159 -0
  40. data/lib/strict/union.rb +180 -0
  41. data/lib/strict/unions/coercer.rb +55 -0
  42. data/lib/strict/validation.rb +40 -0
  43. data/lib/strict/validators/all_of.rb +11 -3
  44. data/lib/strict/validators/array_of.rb +18 -3
  45. data/lib/strict/validators/hash_of.rb +23 -3
  46. data/lib/strict/value.rb +18 -4
  47. data/lib/strict/version.rb +1 -1
  48. data/lib/strict/violation.rb +9 -0
  49. data/lib/strict.rb +42 -0
  50. data/sig/strict/rspec.rbs +10 -0
  51. data/sig/strict.rbs +213 -1
  52. data/strict.gemspec +1 -9
  53. metadata +20 -120
  54. data/lib/strict/accessor/attributes.rb +0 -15
  55. data/lib/strict/accessor/module.rb +0 -45
  56. data/lib/strict/reader/attributes.rb +0 -15
  57. data/lib/strict/reader/module.rb +0 -27
@@ -3,23 +3,42 @@
3
3
  module Strict
4
4
  module Methods
5
5
  class Module < ::Module
6
- attr_reader :verifiable_method
6
+ def wrap(verifiable_method, invocation_target: :super)
7
+ case invocation_target
8
+ when :super
9
+ wrap_super(verifiable_method)
10
+ when :implementation
11
+ wrap_implementation(verifiable_method)
12
+ else
13
+ raise ArgumentError, "Unknown invocation target: #{invocation_target.inspect}"
14
+ end
15
+ end
7
16
 
8
- def initialize(verifiable_method)
9
- super()
17
+ private
10
18
 
11
- @verifiable_method = verifiable_method
19
+ def wrap_super(verifiable_method)
12
20
  define_method verifiable_method.name do |*args, **kwargs, &block|
13
- args, kwargs = verifiable_method.verify_parameters!(*args, **kwargs)
21
+ configuration = Strict.configuration
22
+ verified_arguments = verifiable_method.verify_parameters!(args, kwargs, configuration)
23
+ args, kwargs = verified_arguments if verified_arguments
14
24
 
15
25
  super(*args, **kwargs, &block).tap do |value|
16
- verifiable_method.verify_returns!(value)
26
+ verifiable_method.verify_returns!(value, configuration)
17
27
  end
18
28
  end
19
29
  end
20
30
 
21
- def inspect
22
- "#<#{self.class} (#{verifiable_method.name})>"
31
+ def wrap_implementation(verifiable_method)
32
+ method_name = verifiable_method.name
33
+ define_method method_name do |*args, **kwargs, &block|
34
+ configuration = Strict.configuration
35
+ verified_arguments = verifiable_method.verify_parameters!(args, kwargs, configuration)
36
+ args, kwargs = verified_arguments if verified_arguments
37
+
38
+ implementation.public_send(method_name, *args, **kwargs, &block).tap do |value|
39
+ verifiable_method.verify_returns!(value, configuration)
40
+ end
41
+ end
23
42
  end
24
43
  end
25
44
  end
@@ -1,11 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "forwardable"
4
-
5
3
  module Strict
6
4
  module Methods
7
5
  class VerifiableMethod # rubocop:disable Metrics/ClassLength
8
- extend Forwardable
6
+ class << self
7
+ def from_method(method:, configuration:, instance:)
8
+ new(
9
+ method: method,
10
+ configuration: configuration,
11
+ instance: instance
12
+ )
13
+ end
14
+
15
+ def for_interface(owner:, name:, configuration:)
16
+ new(
17
+ method: nil,
18
+ owner: owner,
19
+ name: name,
20
+ configuration: configuration,
21
+ instance: true
22
+ )
23
+ end
24
+ end
9
25
 
10
26
  class UnknownParameterError < Error
11
27
  attr_reader :parameter_name
@@ -24,25 +40,41 @@ module Strict
24
40
  end
25
41
  end
26
42
 
27
- def_delegator :method, :name
28
-
29
43
  attr_reader :parameters, :returns
30
44
 
31
- def initialize(method:, parameters:, returns:, instance:)
45
+ def initialize(method:, configuration:, instance:, owner: nil, name: nil)
32
46
  @method = method
33
- @parameters = parameters
34
- @parameters_index = parameters.to_h { |p| [p.name, p] }
35
- @returns = returns
47
+ @parameters = configuration.parameters
48
+ @parameters_index = parameters.to_h { |parameter| [parameter.name, parameter] }
49
+ @returns = configuration.returns
36
50
  @instance = instance
51
+ compile_invocation(invocation_parameters_for(method))
52
+ return if method
53
+
54
+ @owner = owner
55
+ @name = name
56
+ end
57
+
58
+ def compile_invocation(invocation_parameters)
59
+ @parameter_bindings = compile_parameter_bindings(invocation_parameters)
60
+ @keyword_parameter_names = parameter_bindings.filter_map do |binding|
61
+ binding.name if binding.kind == :keyword
62
+ end.freeze
63
+ @accepts_keyrest = parameter_bindings.any? { |binding| binding.kind == :keyrest }
64
+ end
65
+ private :compile_invocation
66
+
67
+ def name
68
+ method ? method.name : @name
37
69
  end
38
70
 
39
71
  def to_s
40
- "#{method.owner}#{separator}#{name}"
72
+ "#{owner}#{separator}#{name}"
41
73
  end
42
74
 
43
75
  def verify_definition!
44
76
  expected_parameters = Set.new(parameters.map(&:name))
45
- defined_parameters = Set.new(method.parameters.filter_map { |kind, name| name unless kind == :block })
77
+ defined_parameters = Set.new(parameter_bindings.map(&:name))
46
78
  return if expected_parameters == defined_parameters
47
79
 
48
80
  missing_parameters = expected_parameters - defined_parameters
@@ -54,97 +86,229 @@ module Strict
54
86
  )
55
87
  end
56
88
 
57
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
58
- # TODO(kkt): clean this up- it's late, though, and the tests are passing
59
- def verify_parameters!(*args, **kwargs)
89
+ # rubocop:disable Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
90
+ def verify_parameters!(args, kwargs, configuration)
60
91
  invalid_parameters = nil
61
92
  missing_parameters = nil
93
+ violations = nil
94
+ verified_args = nil
95
+ verified_kwargs = nil
96
+ positional_argument_count = args.length
97
+ positional_index = 0
62
98
 
63
- positional_arguments = []
64
- keyword_arguments = {}
65
-
66
- # TODO(kkt): doesn't handle oddly sorted optional positional parameters like def foo(opt = nil, req)
67
- method.parameters.each do |kind, name|
68
- case kind
69
- when POSITIONAL
70
- parameter_kind = :positional
71
- value = args.any? ? args.shift : NOT_PROVIDED
72
- when REST
73
- parameter_kind = :rest
74
- value = [*args]
75
- args.clear
76
- when KEYWORD
77
- parameter_kind = :keyword
78
- value = kwargs.key?(name) ? kwargs.delete(name) : NOT_PROVIDED
79
- when KEYREST
80
- parameter_kind = :keyrest
81
- value = { **kwargs }
82
- kwargs.clear
83
- end
84
- next unless parameter_kind
99
+ parameter_bindings.each do |binding|
100
+ parameter = binding.parameter || parameter_named!(binding.name)
101
+ positional_start = positional_index
102
+ original_value = case binding.kind
103
+ when :positional
104
+ if positional_index < positional_argument_count
105
+ positional_index += 1
106
+ args.fetch(positional_start)
107
+ else
108
+ NOT_PROVIDED
109
+ end
110
+ when :optional_positional
111
+ if positional_argument_count - positional_index > binding.required_after
112
+ positional_index += 1
113
+ args.fetch(positional_start)
114
+ else
115
+ NOT_PROVIDED
116
+ end
117
+ when :rest
118
+ count = positional_argument_count - positional_index - binding.required_after
119
+ count = 0 if count.negative?
120
+ positional_index += count
121
+ if positional_start.zero? && count == positional_argument_count
122
+ args
123
+ else
124
+ args.slice(positional_start, count)
125
+ end
126
+ when :keyword
127
+ kwargs.key?(binding.name) ? kwargs.fetch(binding.name) : NOT_PROVIDED
128
+ when :keyrest
129
+ keyrest_value(kwargs)
130
+ end
85
131
 
86
- parameter = parameter_named!(name)
87
- if value.equal?(NOT_PROVIDED) && parameter.optional?
132
+ if original_value.equal?(NOT_PROVIDED) && parameter.optional?
88
133
  value = parameter.default_generator.call
89
- elsif value.equal?(NOT_PROVIDED)
134
+ changed = true
135
+ elsif original_value.equal?(NOT_PROVIDED)
90
136
  missing_parameters ||= []
91
137
  missing_parameters << parameter.name
138
+ violations ||= []
139
+ violations << Validation.missing(parameter.validator, path: [parameter.name])
92
140
  next
141
+ else
142
+ value = original_value
143
+ changed = false
93
144
  end
94
145
 
95
146
  value = parameter.coerce(value)
96
- if parameter.valid?(value)
97
- case parameter_kind
98
- when :positional
99
- positional_arguments << value
147
+ changed ||= !value.equal?(original_value)
148
+ parameter_violations = parameter.violations(value, configuration)
149
+ if parameter_violations.empty?
150
+ case binding.kind
151
+ when :positional, :optional_positional
152
+ if verified_args
153
+ verified_args << value
154
+ elsif changed
155
+ verified_args = args.take(positional_start)
156
+ verified_args << value
157
+ end
100
158
  when :rest
101
- positional_arguments.concat(value)
159
+ if verified_args
160
+ verified_args.concat(value)
161
+ elsif rest_changed?(args, positional_start, positional_index, value, parameter.coercer)
162
+ verified_args = args.take(positional_start)
163
+ verified_args.concat(value)
164
+ end
102
165
  when :keyword
103
- keyword_arguments[name] = value
166
+ if changed
167
+ verified_kwargs ||= kwargs.dup
168
+ verified_kwargs[binding.name] = value
169
+ end
104
170
  when :keyrest
105
- keyword_arguments.merge!(value)
171
+ if keyrest_changed?(kwargs, value, parameter.coercer)
172
+ verified_kwargs = merge_keyrest(verified_kwargs, kwargs, value)
173
+ end
106
174
  end
107
175
  else
108
176
  invalid_parameters ||= {}
109
177
  invalid_parameters[parameter] = value
178
+ violations ||= []
179
+ violations.concat(Validation.prepend_path(parameter_violations, parameter.name))
110
180
  end
111
181
  end
112
182
 
113
- if args.empty? && kwargs.empty? && invalid_parameters.nil? && missing_parameters.nil?
114
- [positional_arguments, keyword_arguments]
115
- else
116
- raise Strict::MethodCallError.new(
117
- verifiable_method: self,
118
- remaining_args: args,
119
- remaining_kwargs: kwargs,
120
- invalid_parameters: invalid_parameters,
121
- missing_parameters: missing_parameters
122
- )
183
+ if positional_index == positional_argument_count && no_additional_keywords?(kwargs) &&
184
+ invalid_parameters.nil? && missing_parameters.nil?
185
+ return if verified_args.nil? && verified_kwargs.nil?
186
+
187
+ return [verified_args || args, verified_kwargs || kwargs]
188
+ end
189
+
190
+ remaining_args = args.slice(positional_index, positional_argument_count - positional_index) || []
191
+ remaining_kwargs = remaining_kwargs_from(kwargs)
192
+ violations ||= []
193
+ remaining_args.each_with_index do |argument, offset|
194
+ violations << Validation.unexpected(argument, path: [positional_index + offset])
195
+ end
196
+ remaining_kwargs.each do |name, value|
197
+ violations << Validation.unexpected(value, path: [name])
123
198
  end
199
+ raise Strict::MethodCallError.new(
200
+ verifiable_method: self,
201
+ remaining_args: remaining_args,
202
+ remaining_kwargs: remaining_kwargs,
203
+ invalid_parameters: invalid_parameters,
204
+ missing_parameters: missing_parameters,
205
+ violations: violations
206
+ )
124
207
  end
125
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
208
+ # rubocop:enable Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
126
209
 
127
- def verify_returns!(value)
128
- value = returns.coerce(value)
129
- return if returns.valid?(value)
210
+ def verify_returns!(value, configuration)
211
+ violations = returns.violations(value, configuration)
212
+ return if violations.empty?
130
213
 
131
- raise Strict::MethodReturnError.new(verifiable_method: self, value: value)
214
+ raise Strict::MethodReturnError.new(verifiable_method: self, value: value, violations: violations)
132
215
  end
133
216
 
134
217
  private
135
218
 
136
- POSITIONAL = Set.new(%i[req opt])
137
- private_constant :POSITIONAL
138
- REST = :rest
139
- private_constant :REST
140
- KEYWORD = Set.new(%i[keyreq key])
141
- private_constant :KEYWORD
142
- KEYREST = :keyrest
143
- private_constant :KEYREST
219
+ ParameterBinding = Data.define(:kind, :name, :parameter, :required_after)
220
+ private_constant :ParameterBinding
221
+ PARAMETER_KINDS = {
222
+ req: :positional,
223
+ opt: :optional_positional,
224
+ rest: :rest,
225
+ keyreq: :keyword,
226
+ key: :keyword,
227
+ keyrest: :keyrest
228
+ }.freeze
229
+ private_constant :PARAMETER_KINDS
144
230
  NOT_PROVIDED = ::Object.new.freeze
145
231
  private_constant :NOT_PROVIDED
146
232
 
147
- attr_reader :method, :parameters_index
233
+ attr_reader :method, :parameters_index, :parameter_bindings, :keyword_parameter_names
234
+
235
+ def owner
236
+ method ? method.owner : @owner
237
+ end
238
+
239
+ def invocation_parameters_for(method)
240
+ return method.parameters if method
241
+
242
+ parameters.map { |parameter| [:keyreq, parameter.name] }
243
+ end
244
+
245
+ # rubocop:disable Metrics/MethodLength
246
+ def compile_parameter_bindings(invocation_parameters)
247
+ required_after = 0
248
+ invocation_parameters.reverse_each.filter_map do |kind, name|
249
+ compiled_kind = PARAMETER_KINDS[kind]
250
+ next unless compiled_kind
251
+
252
+ binding = ParameterBinding.new(
253
+ kind: compiled_kind,
254
+ name: name,
255
+ parameter: parameters_index[name],
256
+ required_after: required_after
257
+ )
258
+ required_after += 1 if kind == :req
259
+ binding
260
+ end.reverse.freeze
261
+ end
262
+ # rubocop:enable Metrics/MethodLength
263
+
264
+ def keyrest_value(kwargs)
265
+ return kwargs if keyword_parameter_names.empty?
266
+
267
+ kwargs.except(*keyword_parameter_names)
268
+ end
269
+
270
+ def rest_changed?(args, start, finish, value, coercer)
271
+ return true if coercer
272
+ return false if value.equal?(args)
273
+ return true unless value.length == finish - start
274
+
275
+ value.each_index.any? { |index| !value.fetch(index).equal?(args.fetch(start + index)) }
276
+ end
277
+
278
+ def keyrest_changed?(kwargs, value, coercer)
279
+ return true if coercer
280
+ return false if value.equal?(kwargs)
281
+
282
+ original_size = 0
283
+ kwargs.each do |name, original_value|
284
+ next if keyword_parameter_names.include?(name)
285
+
286
+ original_size += 1
287
+ return true unless value.key?(name) && value.fetch(name).equal?(original_value)
288
+ end
289
+ value.size != original_size
290
+ end
291
+
292
+ def merge_keyrest(verified_kwargs, kwargs, value)
293
+ return value if keyword_parameter_names.empty? && !value.equal?(kwargs)
294
+ return verified_kwargs if keyword_parameter_names.empty?
295
+
296
+ merged_kwargs = verified_kwargs || kwargs.dup
297
+ merged_kwargs.delete_if do |name, _value|
298
+ !keyword_parameter_names.include?(name)
299
+ end
300
+ merged_kwargs.merge!(value) { |_name, explicit_value, _keyrest_value| explicit_value }
301
+ end
302
+
303
+ def no_additional_keywords?(kwargs)
304
+ @accepts_keyrest || kwargs.none? { |name, _value| !keyword_parameter_names.include?(name) }
305
+ end
306
+
307
+ def remaining_kwargs_from(kwargs)
308
+ return {} if @accepts_keyrest
309
+
310
+ kwargs.except(*keyword_parameter_names)
311
+ end
148
312
 
149
313
  def instance?
150
314
  @instance
data/lib/strict/object.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  module Strict
4
4
  module Object
5
5
  def self.included(mod)
6
- mod.extend(Accessor::Attributes)
6
+ Attributes::GeneratedMethods.install_on(mod, writable: true)
7
7
  end
8
8
  end
9
9
  end
@@ -1,64 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Strict
4
- class Parameter
5
- NOT_PROVIDED = ::Object.new.freeze
6
-
4
+ class Parameter < Declaration
7
5
  class << self
8
- def make(name, validator = Validators::Anything.instance, coerce: false, **defaults)
9
- unless valid_defaults?(**defaults)
10
- raise ArgumentError, "Only one of 'default', 'default_value', or 'default_generator' can be provided"
11
- end
12
-
13
- new(
14
- name: name.to_sym,
15
- validator: validator,
16
- default_generator: make_default_generator(**defaults),
17
- coercer: coerce
18
- )
19
- end
20
-
21
6
  private
22
7
 
23
- def valid_defaults?(default: NOT_PROVIDED, default_value: NOT_PROVIDED, default_generator: NOT_PROVIDED)
24
- defaults_provided = [default, default_value, default_generator].count do |default_option|
25
- !default_option.equal?(NOT_PROVIDED)
26
- end
27
-
28
- defaults_provided <= 1
29
- end
30
-
31
- def make_default_generator(default: NOT_PROVIDED, default_value: NOT_PROVIDED, default_generator: NOT_PROVIDED)
32
- if !default.equal?(NOT_PROVIDED)
33
- default.respond_to?(:call) ? default : -> { default }
34
- elsif !default_value.equal?(NOT_PROVIDED)
35
- -> { default_value }
36
- elsif !default_generator.equal?(NOT_PROVIDED)
37
- default_generator
38
- else
39
- NOT_PROVIDED
40
- end
8
+ def coercer_supported?(coercer)
9
+ super || coercer.respond_to?(:call)
41
10
  end
42
11
  end
43
12
 
44
- attr_reader :name, :validator, :default_generator, :coercer
45
-
46
- def initialize(name:, validator:, default_generator:, coercer:)
47
- @name = name.to_sym
48
- @validator = validator
49
- @default_generator = default_generator
50
- @coercer = coercer
51
- @optional = !default_generator.equal?(NOT_PROVIDED)
52
- end
53
-
54
- def optional?
55
- @optional
56
- end
57
-
58
- def valid?(value)
59
- validator === value
60
- end
61
-
62
13
  def coerce(value)
63
14
  return value unless coercer
64
15
 
data/lib/strict/return.rb CHANGED
@@ -3,26 +3,30 @@
3
3
  module Strict
4
4
  class Return
5
5
  class << self
6
- def make(validator = Validators::Anything.instance, coerce: false)
7
- new(validator: validator, coercer: coerce)
6
+ def make(validator = Validators::Anything.instance, **unsupported)
7
+ raise ArgumentError, "Unsupported return options: #{unsupported.keys.join(', ')}" if unsupported.any?
8
+
9
+ new(validator: validator)
8
10
  end
9
11
  end
10
12
 
11
- attr_reader :validator, :coercer
13
+ attr_reader :validator
12
14
 
13
- def initialize(validator:, coercer:)
15
+ def initialize(validator:)
14
16
  @validator = validator
15
- @coercer = coercer
17
+ @detailed_validator = DetailedValidator === validator
16
18
  end
17
19
 
18
- def valid?(value)
19
- validator === value
20
+ def valid?(value, configuration = Strict.configuration)
21
+ violations(value, configuration).empty?
20
22
  end
21
23
 
22
- def coerce(value)
23
- return value unless coercer
24
+ def violations(value, configuration = Strict.configuration)
25
+ return Validation::NONE unless configuration.validate?
26
+ return validator.violations(value) if @detailed_validator
27
+ return Validation::NONE if validator === value
24
28
 
25
- coercer.call(value)
29
+ Validation.invalid(validator, value)
26
30
  end
27
31
  end
28
32
  end