clack 0.6.2 → 0.7.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.
@@ -12,6 +12,12 @@ module Clack
12
12
  # Clack.text(message: "Email?", validate: Clack::Validators.format(/@/, "Must be an email"))
13
13
  # Clack.password(message: "Password?", validate: Clack::Validators.min_length(8))
14
14
  #
15
+ # @example Shorthand shapes (resolved by {Validators.resolve})
16
+ # Clack.text(message: "Slug?", validate: /\A[a-z0-9-]+\z/) # Regexp, "Invalid format"
17
+ # Clack.text(message: "Email?", validate: :email) # zero-argument built-in
18
+ # Clack.text(message: "User?", validate: [:required, /\A\w+\z/]) # combined, first failure wins
19
+ # Clack.text(message: "User?", validate: {required: "Name needed", /\A\w+\z/ => "Word chars only"})
20
+ #
15
21
  # @example Database validation (blocking I/O)
16
22
  # Clack.text(
17
23
  # message: "Email?",
@@ -32,7 +38,64 @@ module Clack
32
38
  # )
33
39
  #
34
40
  module Validators
41
+ # Built-ins reachable by a bare Symbol (+validate: :email+) or as a Hash key with a
42
+ # custom message (+validate: {email: "Bad email"}+). Each takes only an optional message.
43
+ #
44
+ # Shortcuts are typed against the prompt's value: +:future_date+ and +:past_date+
45
+ # expect a Date (the +date+ prompt); +:path_exists+, +:directory_exists+, and
46
+ # +:file_exists_warning+ expect a path String (+text+ or +path+); +:required+,
47
+ # +:email+, +:url+, and +:integer+ work on any string-ish value.
48
+ SHORTCUTS = %i[
49
+ required email url integer path_exists directory_exists
50
+ future_date past_date file_exists_warning
51
+ ].freeze
52
+
53
+ # Built-ins that need arguments, so a bare Symbol can't name them. Consulted only to
54
+ # raise a pointed "needs arguments" error instead of "Unknown validator".
55
+ PARAMETERIZED = %i[min_length max_length format one_of in_range date_range combine as_warning].freeze
56
+ private_constant :PARAMETERIZED
57
+
35
58
  class << self
59
+ # Normalize the +validate:+ option into a callable, or nil.
60
+ #
61
+ # Accepted shapes:
62
+ # - +nil+ or +false+: no validation
63
+ # - +Regexp+: {format} with the default "Invalid format" message
64
+ # - +Symbol+: a zero-argument built-in listed in {SHORTCUTS}
65
+ # - +Array+: {combine} of each entry, resolved recursively; +nil+ or +false+ entries are dropped
66
+ # - +Hash+: +Regexp+ or +Symbol+ keys mapped to a custom message, checked in
67
+ # insertion order. A String message makes the entry an error; a {Clack::Warning}
68
+ # message makes it a soft check (wrapped with {as_warning})
69
+ # - anything responding to +#call+: returned unchanged
70
+ #
71
+ # {Core::Prompt#initialize} calls this, so a bad validator raises when the prompt
72
+ # is built rather than when the user presses Enter.
73
+ #
74
+ # @param validator [Regexp, Symbol, Array, Hash, #call, nil, false] the +validate:+ option value to normalize
75
+ # @return [#call, nil] the validator, or nil when no validation was requested
76
+ # @raise [ArgumentError] for unknown Symbols, built-ins that need arguments,
77
+ # malformed Hash entries, or any other unsupported type
78
+ #
79
+ # @example
80
+ # Clack::Validators.resolve(/\A\d+\z/).call("abc") # => "Invalid format"
81
+ # Clack::Validators.resolve(:email).call("nope") # => "Must be a valid email address"
82
+ # Clack::Validators.resolve({required: "Name needed"}).call("") # => "Name needed"
83
+ # Clack::Validators.resolve("oops") # raises ArgumentError
84
+ def resolve(validator)
85
+ case validator
86
+ when nil, false then nil
87
+ when Regexp then format(validator)
88
+ when Symbol then resolve_symbol(validator)
89
+ when Array then combine(*validator)
90
+ when Hash then combine(*validator.map { |shape, message| resolve_with_message(shape, message) })
91
+ else
92
+ return validator if validator.respond_to?(:call)
93
+
94
+ raise ArgumentError,
95
+ "Validate must be a Regexp, Symbol, Array, Hash, or respond to #call, got #{validator.class}"
96
+ end
97
+ end
98
+
36
99
  # Validates that the input is not empty.
37
100
  #
38
101
  # @param message [String] Custom error message
@@ -102,12 +165,19 @@ module Clack
102
165
  end
103
166
  end
104
167
 
105
- # Combines multiple validators. Returns the first error message, or nil if all pass.
168
+ # Combines multiple validators. Returns the first error or warning, or nil if all pass.
169
+ # Each argument is normalized with {resolve}, so Regexps, Symbols, nested Arrays,
170
+ # and Hashes are accepted alongside procs. +nil+ or +false+ entries are ignored.
106
171
  #
107
- # @param validators [Array<Proc>] Validators to combine
108
- # @return [Proc] Combined validator proc
172
+ # @param validators [Array<Proc, Regexp, Symbol, Array, Hash, nil, false>] validators to combine
173
+ # @return [Proc] combined validator proc
174
+ # @raise [ArgumentError] if any entry can't be resolved
175
+ #
176
+ # @example
177
+ # Clack::Validators.combine(:required, /\A\d+\z/, Clack::Validators.in_range(1..65535))
109
178
  def combine(*validators)
110
- ->(value) { first_failing_validation(validators, value) }
179
+ resolved = validators.map { |validator| resolve(validator) }.compact
180
+ ->(value) { first_failing_validation(resolved, value) }
111
181
  end
112
182
 
113
183
  # Common email format validator.
@@ -184,9 +254,12 @@ module Clack
184
254
 
185
255
  # Convert any validator to return a warning instead of an error.
186
256
  # Warnings allow the user to proceed with confirmation.
257
+ # The argument is normalized with {resolve}, so +as_warning(:email)+ and
258
+ # +as_warning(/\A[a-z]+\z/)+ work.
187
259
  #
188
- # @param validator [Proc] Original validator
189
- # @return [Proc] Validator that returns Warning instead of String
260
+ # @param validator [Proc, Regexp, Symbol, Array, Hash] original validator
261
+ # @return [Proc] validator that returns Warning instead of String
262
+ # @raise [ArgumentError] if validator is nil or false, or can't be resolved
190
263
  #
191
264
  # @example
192
265
  # # Make max_length a warning instead of error
@@ -196,9 +269,16 @@ module Clack
196
269
  # Clack::Validators.max_length(100, "Bio is quite long")
197
270
  # )
198
271
  # )
272
+ #
273
+ # @example Shorthand shapes
274
+ # Clack::Validators.as_warning(:email)
275
+ # Clack::Validators.as_warning(/\A[a-z]+\z/)
199
276
  def as_warning(validator)
277
+ raise ArgumentError, "as_warning needs a validator, got #{validator.inspect}" unless validator
278
+
279
+ resolved = resolve(validator)
200
280
  lambda do |value|
201
- result = validator.call(value)
281
+ result = resolved.call(value)
202
282
  next if result.nil?
203
283
 
204
284
  result.is_a?(Clack::Warning) ? result : Clack::Warning.new(result)
@@ -214,6 +294,55 @@ module Clack
214
294
  end
215
295
  nil
216
296
  end
297
+
298
+ # Only names on the SHORTCUTS allowlist reach public_send (never respond_to?),
299
+ # so :resolve or :to_s can't be smuggled in as a validator.
300
+ def resolve_symbol(name, *args)
301
+ return public_send(name, *args) if SHORTCUTS.include?(name)
302
+
303
+ if PARAMETERIZED.include?(name)
304
+ raise ArgumentError,
305
+ "Validator #{name.inspect} needs arguments; pass Clack::Validators.#{name}(...) instead"
306
+ end
307
+
308
+ raise ArgumentError,
309
+ "Unknown validator: #{name.inspect}. Available: #{SHORTCUTS.map(&:inspect).join(", ")}"
310
+ end
311
+
312
+ # A Hash entry. A Warning message is unwrapped to its String, the built-in is
313
+ # constructed with that String, and the result is wrapped with as_warning. Passing
314
+ # the Warning object itself as the message would nest a Warning inside a Warning
315
+ # for built-ins that already return one (file_exists_warning).
316
+ def resolve_with_message(shape, message)
317
+ case message
318
+ when String then build_with_message(shape, message)
319
+ when Clack::Warning then as_warning(build_with_message(shape, warning_text(message)))
320
+ else
321
+ raise ArgumentError,
322
+ "Validate Hash values must be a String or Clack::Warning message, got #{message.class}"
323
+ end
324
+ end
325
+
326
+ # The String carried by a Warning Hash value. Clack.warning always takes a String,
327
+ # but Clack::Warning.new(nil) is constructible and would otherwise hand a nil message
328
+ # to the built-in, silently producing a check that always passes.
329
+ def warning_text(warning)
330
+ text = warning.message
331
+ return text if text.is_a?(String)
332
+
333
+ raise ArgumentError,
334
+ "Validate Hash values must be a String or Clack::Warning message, got Clack::Warning(#{text.class})"
335
+ end
336
+
337
+ def build_with_message(shape, message)
338
+ case shape
339
+ when Regexp then format(shape, message)
340
+ when Symbol then resolve_symbol(shape, message)
341
+ else
342
+ raise ArgumentError,
343
+ "Validate Hash keys must be a Regexp or a validator Symbol, got #{shape.class}"
344
+ end
345
+ end
217
346
  end
218
347
  end
219
348
  end
data/lib/clack/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Clack
4
4
  # Current gem version.
5
- VERSION = "0.6.2"
5
+ VERSION = "0.7.0"
6
6
  end