phonelib 0.10.25 → 0.10.26

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 84e2a8f47cd1fb8108efa26ab0ba6120bd6e28a420794acf7ee099135618ab1e
4
- data.tar.gz: a15e47d9b2a941639e4dc83226143c01b314506482d7fdee63ed95afa0ba78f4
3
+ metadata.gz: e167c9f5d91a48e5bd9b336f41f95da24b5d147b2a2c5fc22ce9c860e7c105e7
4
+ data.tar.gz: db640f3955e5c5d4fd907bb25d3c4fa1c0bbb783a5245a5515366e4721e01994
5
5
  SHA512:
6
- metadata.gz: 81a599c8844452b9d0124a8b75315905f85e79a31eedc3e14af66a830885fa0ebbe27aa9ebd65da57c7e710744d9147a1bf6304699049e0cd6452a8d698083bd
7
- data.tar.gz: baf0e046b6eea13ae466c97b6a065f2fe9bf6fc496992424ebe78fd51d177acf72cefe8b53bdcae07d0fc2b6a6e42323966558fe593a4f57f22780d7aecf3fd5
6
+ metadata.gz: 81acdf0374d5fb09daa882cb27ec95f79f39bab2ea8539c67f55aca156eb2516ddaa29d6757cc38ce8860feef5aa74654be96cbdc1c83cc000002495195250e8
7
+ data.tar.gz: 7080901f2d6d0a235f76fc9f1094775190f32e7036b336e1c719f5b2fc1b8412c66d9edea4fa357863a04f916d5c86b20489b2ab36cd3c9b1d2ea35677784fcb
data/README.md CHANGED
@@ -154,6 +154,59 @@ if mixed with <tt>possible</tt> will check if number is possible for specified c
154
154
 
155
155
  <tt>extensions: false</tt> - set to perform check for phone extension to be blank
156
156
 
157
+ <<<<<<< HEAD
158
+ <tt>require_international_prefix: true</tt> - requires the original value to use <tt>+</tt> or <tt>00</tt> as its explicit international marker. The marker may follow leading formatting characters recognized by the parser. This option does not change the parser or the formatted output.
159
+
160
+ <tt>format: :e164</tt> - requires the original value to match Phonelib's canonical <tt>+</tt>-prefixed E.164 representation: 2 to 15 total ASCII digits, beginning with 1-9, with no spaces, punctuation, or extension. Unsupported formats raise an <tt>ArgumentError</tt> when the validator is configured.
161
+
162
+ ||||||| ec5e850
163
+ =======
164
+ <tt>require_international_prefix: true</tt> - requires the original value to use <tt>+</tt> or <tt>00</tt> as its explicit international marker. The marker may follow leading formatting characters recognized by the parser. This option does not change the parser or the formatted output.
165
+
166
+ <tt>format: :e164</tt> - requires the original value to match Phonelib's canonical <tt>+</tt>-prefixed E.164 representation: 2 to 15 total ASCII digits, beginning with 1-9, with no spaces, punctuation, or extension. Unsupported formats raise an <tt>ArgumentError</tt> when the validator is configured.
167
+
168
+ <tt>detailed_errors: true</tt> - adds a specific, namespaced ActiveModel error
169
+ for each failed phone rule. It is opt-in; without it the validator keeps adding
170
+ the single <tt>:invalid</tt> error used by previous Phonelib versions. Enabling
171
+ it changes error reporting only, not which values the existing validator
172
+ accepts.
173
+
174
+ ``` ruby
175
+ validates :phone, phone: {
176
+ types: :mobile,
177
+ countries: :us,
178
+ extensions: false,
179
+ detailed_errors: true
180
+ }
181
+
182
+ record.errors.details[:phone] # ActiveModel 5+
183
+ # [{ error: :phone_type_not_allowed, allowed_types: [:mobile], ... }]
184
+
185
+ record.errors.messages[:phone]
186
+ # ["has a phone type that is not allowed"]
187
+
188
+ record.errors.full_messages
189
+ # ["Phone has a phone type that is not allowed"]
190
+ ```
191
+
192
+ On older ActiveModel versions, the detailed types still drive translated
193
+ <tt>errors.messages</tt>, but <tt>Errors#details</tt> is not provided by the
194
+ framework.
195
+
196
+ Detailed ActiveModel error types are prefixed with <tt>phone_</tt> so they do
197
+ not collide with generic Rails errors such as <tt>:too_short</tt>. Phonelib
198
+ ships English defaults for every detailed error. Applications can override
199
+ them through the normal I18n hierarchy, for example:
200
+
201
+ ``` yaml
202
+ es:
203
+ errors:
204
+ messages:
205
+ phone_too_short: "es demasiado corto"
206
+ phone_invalid: "no es un número telefónico válido"
207
+ ```
208
+
209
+ >>>>>>> salasebas-salasebas/structured-validation-errors
157
210
  ### Basic usage
158
211
 
159
212
  To check if phone number is valid simply run:
@@ -187,6 +240,73 @@ phone = Phonelib.parse('123456789')
187
240
  phone = Phonelib.parse('+1 (972) 123-4567', 'US')
188
241
  ```
189
242
 
243
+ Parsed phones expose immutable validation diagnostics without requiring Rails:
244
+
245
+ ``` ruby
246
+ phone = Phonelib.parse('+1 253000')
247
+
248
+ phone.errors.details
249
+ # [{ error: :too_short, actual_length: 6, expected_lengths: [10] }]
250
+
251
+ phone.errors.messages
252
+ # ["is too short"]
253
+
254
+ phone.errors.full_messages
255
+ # ["is too short"] # no model attribute exists in the core API
256
+ ```
257
+
258
+ Use <tt>validation</tt> when the result or a validation policy is also needed:
259
+
260
+ ``` ruby
261
+ result = phone.validation(
262
+ possible: true,
263
+ types: :mobile,
264
+ countries: [:us, :ca],
265
+ extensions: false
266
+ )
267
+
268
+ result.valid? # whether all requested rules passed
269
+ result.invalid?
270
+ result.phone # the same parsed Phone instance
271
+ result.errors # Phonelib::Errors
272
+ result.error # first ValidationError, or nil
273
+ result.possibility # :possible, :possible_local_only, or :impossible
274
+ ```
275
+
276
+ <tt>possible_local_only</tt> means the number has a length that can be dialed
277
+ within its local area but lacks the information required for general dialing.
278
+ It is exposed as diagnostic context; <tt>validation(possible: true)</tt> still
279
+ follows the existing <tt>Phone#possible?</tt> result and therefore reports
280
+ <tt>:not_possible</tt> for a local-only number.
281
+
282
+ Core diagnostic codes are deliberately short because they already live in
283
+ <tt>Phonelib::ValidationError</tt>:
284
+
285
+ * <tt>:not_a_number</tt>
286
+ * <tt>:invalid_country_code</tt>
287
+ * <tt>:too_short</tt>
288
+ * <tt>:too_long</tt>
289
+ * <tt>:invalid_length</tt>
290
+ * <tt>:not_possible</tt>
291
+ * <tt>:invalid</tt>
292
+ * <tt>:type_not_allowed</tt>
293
+ * <tt>:country_not_allowed</tt>
294
+ * <tt>:extension_not_allowed</tt>
295
+
296
+ Successful validity and possibility are represented by the result, not by
297
+ error codes.
298
+
299
+ Messages can be translated in plain Ruby without adding an I18n dependency to
300
+ Phonelib. Pass any callable that accepts an I18n-style key and keyword values:
301
+
302
+ ``` ruby
303
+ translator = -> key, default:, **values do
304
+ I18n.t(key, default: default, **values)
305
+ end
306
+
307
+ phone.errors.messages(translator: translator)
308
+ ```
309
+
190
310
  You can pass phone number with extension, it should be separated with <tt>;</tt> or <tt>#</tt> signs from the phone number.
191
311
 
192
312
  Returned value is object of <tt>Phonelib::Phone</tt> class which have following methods:
data/data/phone_data.dat CHANGED
Binary file
data/lib/phonelib/core.rb CHANGED
@@ -184,6 +184,8 @@ module Phonelib
184
184
  # setter for data file to use
185
185
  def override_phone_data=(file_path)
186
186
  @@override_phone_data = file_path
187
+ @@phone_data = @@data_by_country_codes = @@all_int_prefixes = nil
188
+ @@phone_regexp_cache.clear
187
189
  end
188
190
 
189
191
  def override_phone_data
@@ -308,6 +310,10 @@ module Phonelib
308
310
  VALID_PATTERN = :national_number_pattern
309
311
  # @private Possible regex pattern key
310
312
  POSSIBLE_PATTERN = :possible_number_pattern
313
+ # @private Possible national number lengths imported from libphonenumber
314
+ POSSIBLE_LENGTHS = :possible_lengths
315
+ # @private Possible local-only number lengths imported from libphonenumber
316
+ POSSIBLE_LOCAL_ONLY_LENGTHS = :possible_local_only_lengths
311
317
  # @private National prefix key
312
318
  NATIONAL_PREFIX = :national_prefix
313
319
  # @private National prefix for parsing key
@@ -243,19 +243,29 @@ module Phonelib
243
243
  @data[country_id][:types][type][Core::SHORT] ||= {}
244
244
  data.each do |k, v|
245
245
  if @data[country_id][:types][type][Core::SHORT][k]
246
- @data[country_id][:types][type][Core::SHORT][k] += "|#{v}"
246
+ if possible_lengths_key?(k)
247
+ @data[country_id][:types][type][Core::SHORT][k] =
248
+ (@data[country_id][:types][type][Core::SHORT][k] + v).uniq.sort
249
+ else
250
+ @data[country_id][:types][type][Core::SHORT][k] += "|#{v}"
251
+ end
247
252
  else
248
253
  @data[country_id][:types][type][Core::SHORT][k] = v
249
254
  end
250
255
  end
251
256
  end
252
257
 
258
+ def possible_lengths_key?(key)
259
+ [Core::POSSIBLE_LENGTHS, Core::POSSIBLE_LOCAL_ONLY_LENGTHS].include?(key)
260
+ end
261
+
253
262
  # adds possible pattern in case it doesn't exists
254
263
  def fill_possible_to_types_if_nil(result)
255
264
  result[:types].each do |type, data|
256
265
  if data[Core::VALID_PATTERN] && !data[Core::POSSIBLE_PATTERN]
257
266
  result[:types][type][Core::POSSIBLE_PATTERN] = case type
258
267
  when Core::GENERAL
268
+ fill_general_possible_lengths(result[:types], data)
259
269
  national_possible result[:types]
260
270
  else
261
271
  data[Core::VALID_PATTERN]
@@ -265,10 +275,33 @@ module Phonelib
265
275
  result
266
276
  end
267
277
 
278
+ def fill_general_possible_lengths(types, general)
279
+ national = concrete_possible_lengths(types, Core::POSSIBLE_LENGTHS)
280
+ local_only = concrete_possible_lengths(
281
+ types,
282
+ Core::POSSIBLE_LOCAL_ONLY_LENGTHS
283
+ ) - national
284
+ general[Core::POSSIBLE_LENGTHS] = national unless national.empty?
285
+ unless local_only.empty?
286
+ general[Core::POSSIBLE_LOCAL_ONLY_LENGTHS] = local_only
287
+ end
288
+ end
289
+
290
+ def concrete_possible_lengths(types, key)
291
+ lengths = []
292
+ types.each do |type, data|
293
+ next if type == Core::GENERAL || !data[key]
294
+
295
+ lengths.concat(data[key].select { |length| length > 0 })
296
+ end
297
+ lengths.uniq.sort
298
+ end
299
+
268
300
  # take all possible patters from all types
269
301
  def national_possible(types)
270
302
  types.map { |k, v| v[:possible_number_pattern] }.
271
- compact.map { |e| e.split('|') }.flatten.uniq.join('|')
303
+ compact.reject { |pattern| pattern == '(?!)' }.
304
+ map { |e| e.split('|') }.flatten.uniq.join('|')
272
305
  end
273
306
 
274
307
  # method parses xml for formats data
@@ -7,6 +7,8 @@ module Phonelib
7
7
  XML_COMMENT_ATTRIBUTES = %w(text comment)
8
8
  # xml format attributes names
9
9
  XML_FORMAT_NAMES = %w(intlFormat format)
10
+ # libphonenumber's maximum supported national significant number length
11
+ MAX_POSSIBLE_LENGTH = 17
10
12
 
11
13
  def file_path(file)
12
14
  "#{File.dirname(__FILE__)}/../../#{file}"
@@ -83,8 +85,16 @@ module Phonelib
83
85
  when :element
84
86
  data.elements.each do |child|
85
87
  if child.name == 'possibleLengths'
86
- hash[Core::POSSIBLE_PATTERN] =
87
- possible_length_regex(hash_from_xml(child, :attributes))
88
+ attributes = hash_from_xml(child, :attributes)
89
+ hash[Core::POSSIBLE_PATTERN] = possible_length_regex(attributes)
90
+
91
+ national_lengths = possible_lengths(attributes[:national])
92
+ hash[Core::POSSIBLE_LENGTHS] = national_lengths unless national_lengths.empty?
93
+
94
+ local_only_lengths = possible_lengths(attributes[:local_only])
95
+ unless local_only_lengths.empty?
96
+ hash[Core::POSSIBLE_LOCAL_ONLY_LENGTHS] = local_only_lengths
97
+ end
88
98
  else
89
99
  hash[name2sym(child.name)] = str_clean(child.children.first)
90
100
  end
@@ -95,15 +105,58 @@ module Phonelib
95
105
 
96
106
  def possible_length_regex(attributes)
97
107
  return '' unless attributes[:national]
98
- attributes[:national].split(',').map do |m|
99
- if m.include? '-'
100
- "\\d{#{m.gsub(/[\[\]]/, '').gsub('-', ',')}}"
108
+
109
+ possible_length_ranges(attributes[:national]).map do |first, last|
110
+ if first == -1
111
+ '(?!)'
112
+ elsif first == last
113
+ "\\d{#{first}}"
101
114
  else
102
- "\\d{#{m}}"
115
+ "\\d{#{first},#{last}}"
103
116
  end
104
117
  end.join('|')
105
118
  end
106
119
 
120
+ def possible_lengths(lengths)
121
+ return [] unless lengths
122
+
123
+ possible_length_ranges(lengths).flat_map do |first, last|
124
+ first == -1 ? [-1] : (first..last).to_a
125
+ end.uniq.sort
126
+ end
127
+
128
+ def possible_length_ranges(lengths)
129
+ lengths.split(',').map do |length|
130
+ raw_token = length
131
+ token = if raw_token.include?('[') || raw_token.include?(']')
132
+ unless raw_token =~ /\A\[\d+(?:-\d+)?\]\z/
133
+ raise ArgumentError, "invalid possible length: #{raw_token}"
134
+ end
135
+ raw_token[1..-2]
136
+ else
137
+ raw_token
138
+ end
139
+ unless token =~ /\A(?:-1|\d+(?:-\d+)?)\z/
140
+ raise ArgumentError, "invalid possible length: #{raw_token}"
141
+ end
142
+
143
+ next [-1, -1] if token == '-1'
144
+
145
+ first, last = token.split('-').map(&:to_i)
146
+ last ||= first
147
+ [first, last].each do |value|
148
+ unless (1..MAX_POSSIBLE_LENGTH).include?(value)
149
+ raise ArgumentError, "possible length out of range: #{value}"
150
+ end
151
+ end
152
+ if first > last
153
+ raise ArgumentError, "invalid possible length range: #{token}"
154
+ end
155
+
156
+ [first, last]
157
+ end
158
+ end
159
+
107
160
  # method parses raw data file
108
161
  def parse_raw_file(file)
109
162
  data = {}
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phonelib
4
+ # Read-only collection of phone validation errors.
5
+ class Errors
6
+ include Enumerable
7
+
8
+ def initialize(errors = [])
9
+ @errors = errors.dup.freeze
10
+ freeze
11
+ end
12
+
13
+ def each(&block)
14
+ @errors.each(&block)
15
+ end
16
+
17
+ def empty?
18
+ @errors.empty?
19
+ end
20
+
21
+ def size
22
+ @errors.size
23
+ end
24
+
25
+ def first
26
+ @errors.first
27
+ end
28
+
29
+ def details
30
+ @errors.map { |error| { error: error.code }.merge(error.details).freeze }.freeze
31
+ end
32
+
33
+ def messages(options = {})
34
+ translator = options.is_a?(Hash) ? options[:translator] : options
35
+ @errors.map { |error| error.message(translator) }.freeze
36
+ end
37
+
38
+ def full_messages(options = {})
39
+ messages(options)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,13 @@
1
+ en:
2
+ errors:
3
+ messages:
4
+ phone_not_a_number: "is not a phone number"
5
+ phone_invalid_country_code: "has an invalid country calling code"
6
+ phone_too_short: "is too short"
7
+ phone_too_long: "is too long"
8
+ phone_invalid_length: "has an invalid length"
9
+ phone_not_possible: "is not a possible phone number"
10
+ phone_invalid: "is not a valid phone number"
11
+ phone_type_not_allowed: "has a phone type that is not allowed"
12
+ phone_country_not_allowed: "is from a country that is not allowed"
13
+ phone_extension_not_allowed: "must not include an extension"
@@ -17,6 +17,7 @@ module Phonelib
17
17
  include Phonelib::PhoneAnalyzer
18
18
  include Phonelib::PhoneExtendedData
19
19
  include Phonelib::PhoneFormatter
20
+ include Phonelib::PhoneValidation
20
21
 
21
22
  # class initialization method
22
23
  # @param phone [String] Phone number for parsing
@@ -30,12 +31,15 @@ module Phonelib
30
31
 
31
32
  if sanitized.empty?
32
33
  @data = {}
34
+ @diagnostic_possibility = :impossible
33
35
  else
34
- @data = analyze(sanitized, passed_country(country))
36
+ @passed_country = passed_country(country)
37
+ @data = analyze(sanitized, @passed_country)
35
38
  # the national number must come from the country #country reports:
36
39
  # another entry can be a possible-only match, and mixing entries makes
37
40
  # e164 splice one country's code onto another's number (issue #355)
38
41
  @national_number = country_data ? country_data[:national] : sanitized
42
+ @diagnostic_possibility = diagnostic_possibility
39
43
  end
40
44
  end
41
45
 
@@ -62,6 +66,13 @@ module Phonelib
62
66
  '')
63
67
  end
64
68
 
69
+ # Returns whether the original input begins with an explicit + or 00
70
+ # international prefix after parser-recognized leading formatting.
71
+ # @return [Boolean] original input has an explicit international prefix
72
+ def explicit_international_prefix?
73
+ !!original_starts_with_plus_or_double_zero?
74
+ end
75
+
65
76
  # Returns all phone types that matched valid patterns
66
77
  # @return [Array] all valid phone types
67
78
  def types
@@ -0,0 +1,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phonelib
4
+ # Framework-independent diagnostics and policy validation for a parsed phone.
5
+ module PhoneValidation
6
+ VALIDATION_OPTIONS = [:possible, :types, :countries, :extensions].freeze
7
+ VALID_TYPE_NAMES = Core::TYPES_DESC_KEYS.map(&:to_s).freeze
8
+ MAX_VALIDATION_VALUES = 300
9
+ private_constant :VALIDATION_OPTIONS, :VALID_TYPE_NAMES,
10
+ :MAX_VALIDATION_VALUES
11
+
12
+ # Returns an immutable result explaining whether this phone satisfies the
13
+ # requested rules and, when it does not, why.
14
+ # @param options [Hash] phone validation rules
15
+ # @return [Phonelib::ValidationResult]
16
+ def validation(options = {})
17
+ validate_options!(options)
18
+ possible = options.fetch(:possible, false)
19
+ possibility = diagnostic_possibility
20
+ validation_errors = intrinsic_validation_errors(possible, possibility)
21
+ if validation_errors.empty?
22
+ validation_errors.concat(policy_validation_errors(options, possible))
23
+ end
24
+
25
+ ValidationResult.new(self, validation_errors, possibility)
26
+ end
27
+
28
+ # Convenience accessor for validation diagnostics.
29
+ # @param options [Hash] phone validation rules
30
+ # @return [Phonelib::Errors]
31
+ def errors(options = {})
32
+ validation(options).errors
33
+ end
34
+
35
+ private
36
+
37
+ def validate_options!(options)
38
+ unknown_options = options.keys - VALIDATION_OPTIONS
39
+ if unknown_options.any?
40
+ raise ArgumentError, "unknown validation option: #{unknown_options.first}"
41
+ end
42
+
43
+ validate_boolean_option!(:possible, options.fetch(:possible, false))
44
+ validate_boolean_option!(:extensions, options.fetch(:extensions, true))
45
+ validate_collection_option!(:types, options[:types])
46
+ validate_collection_option!(:countries, options[:countries])
47
+ end
48
+
49
+ def validate_boolean_option!(name, value)
50
+ return if value == true || value == false
51
+
52
+ raise ArgumentError, "#{name} must be true or false"
53
+ end
54
+
55
+ def validate_collection_option!(name, value)
56
+ return if value.nil?
57
+
58
+ values = value.is_a?(Array) ? value : [value]
59
+ if values.size > MAX_VALIDATION_VALUES
60
+ raise ArgumentError,
61
+ "#{name} must contain at most #{MAX_VALIDATION_VALUES} values"
62
+ end
63
+ unless values.all? { |item| item.is_a?(String) || item.is_a?(Symbol) }
64
+ raise ArgumentError, "#{name} must be a symbol, string, or array of them"
65
+ end
66
+ validate_type_names!(values) if name == :types
67
+ end
68
+
69
+ def validate_type_names!(values)
70
+ unknown = values.find { |item| !VALID_TYPE_NAMES.include?(item.to_s) }
71
+ raise ArgumentError, "unknown phone type: #{unknown}" if unknown
72
+ end
73
+
74
+ def intrinsic_validation_errors(possible, possibility)
75
+ if possible
76
+ return [] if possible?
77
+ if possibility != :impossible
78
+ return [ValidationError.new(:not_possible, possibility: possibility)]
79
+ end
80
+ end
81
+ return [] if !possible && valid?
82
+ return [ValidationError.new(:invalid)] if possibility != :impossible
83
+
84
+ diagnostic_error = intrinsic_diagnostic_error
85
+ return [diagnostic_error] if diagnostic_error
86
+
87
+ [ValidationError.new(:not_possible)]
88
+ end
89
+
90
+ def intrinsic_diagnostic_error
91
+ return ValidationError.new(:not_a_number) unless viable_input?
92
+ parser_length = parser_national_length
93
+ if parser_length && parser_length > 17
94
+ return ValidationError.new(
95
+ :too_long,
96
+ actual_length: parser_length
97
+ )
98
+ end
99
+ return ValidationError.new(:invalid_country_code) if passed_country_invalid?
100
+
101
+ idd_remainder = digits_after_international_dialing_prefix
102
+ if idd_remainder && idd_remainder.length <= 2
103
+ return ValidationError.new(
104
+ :too_short,
105
+ actual_length: idd_remainder.length
106
+ )
107
+ end
108
+
109
+ international_digits = international_digits_for_diagnostics
110
+ if international_digits && country_calling_code_for(international_digits).nil?
111
+ return ValidationError.new(:invalid_country_code)
112
+ end
113
+ national_length_error = short_national_number_error(international_digits)
114
+ return national_length_error if national_length_error
115
+
116
+ possibility_length_error
117
+ end
118
+
119
+ def viable_input?
120
+ candidate = significant_original_s.sub(cr('[^0-9A-Za-z]+\z'), '')
121
+ return false if Phonelib.strict_check && !sanitized.match?(cr('\A[0-9]+\z'))
122
+
123
+ minimum_digits = candidate.match?(cr('\A[0-9]+\z')) ? 2 : 3
124
+ sanitized.length >= minimum_digits
125
+ end
126
+
127
+ def parser_national_length
128
+ international_digits = international_digits_for_diagnostics
129
+ return sanitized.length unless international_digits
130
+
131
+ country_code = country_calling_code_for(international_digits)
132
+ international_digits.delete_prefix(country_code).length if country_code
133
+ end
134
+
135
+ def passed_country_invalid?
136
+ return false if significant_original_s.start_with?(Core::PLUS_SIGN)
137
+
138
+ passed_countries = Array(@passed_country).compact
139
+ passed_countries.any? && passed_countries.none? do |country|
140
+ Phonelib.phone_data.key?(normalize_passed_country(country))
141
+ end
142
+ end
143
+
144
+ def short_national_number_error(international_digits)
145
+ return unless international_digits
146
+
147
+ country_code = country_calling_code_for(international_digits)
148
+ national_digits = international_digits.delete_prefix(country_code)
149
+ return unless national_digits.length < 2
150
+
151
+ ValidationError.new(
152
+ :too_short,
153
+ actual_length: national_digits.length
154
+ )
155
+ end
156
+
157
+ def possibility_length_error
158
+ context = diagnostic_number_context
159
+ return unless context
160
+
161
+ national_digits, data = context
162
+ lengths = possible_lengths_for(data)
163
+ local_only_lengths = possible_local_only_lengths_for(data)
164
+ return if lengths.empty? || lengths.include?(national_digits.length) ||
165
+ local_only_lengths.include?(national_digits.length)
166
+
167
+ code = if national_digits.length < lengths.min
168
+ :too_short
169
+ elsif national_digits.length > lengths.max
170
+ :too_long
171
+ else
172
+ :invalid_length
173
+ end
174
+ ValidationError.new(
175
+ code,
176
+ actual_length: national_digits.length,
177
+ expected_lengths: lengths
178
+ )
179
+ end
180
+
181
+ def diagnostic_possibility
182
+ return @diagnostic_possibility if defined?(@diagnostic_possibility)
183
+
184
+ return :impossible if passed_country_invalid?
185
+ return :impossible unless viable_input?
186
+ return :possible if possible_special_number?
187
+
188
+ context = diagnostic_number_context
189
+ return possible? ? :possible : :impossible unless context
190
+
191
+ national_digits, data = context
192
+ length = national_digits.length
193
+ return :possible if possible_lengths_for(data).include?(length)
194
+ return :possible if additional_regex_possible?(national_digits, data)
195
+ return :possible_local_only if possible_local_only_lengths_for(data).include?(length)
196
+
197
+ :impossible
198
+ end
199
+
200
+ def possible_special_number?
201
+ Phonelib.parse_special && (possible_types & Core::SHORT_CODES).any?
202
+ end
203
+
204
+ def additional_regex_possible?(national_digits, data)
205
+ candidate_countries = countries.any? ? countries : [data[:id]]
206
+ candidate_countries.any? do |country|
207
+ regexes = Phonelib.additional_regexes[country]
208
+ regexes && regexes.values.flatten.any? do |regex|
209
+ national_digits.match?(cr("^(?:#{regex})$"))
210
+ end
211
+ end
212
+ end
213
+
214
+ def diagnostic_number_context
215
+ international_digits = international_digits_for_diagnostics
216
+ if international_digits
217
+ country_code = country_calling_code_for(international_digits)
218
+ return unless country_code
219
+
220
+ countries_data = Phonelib.data_by_country_codes[country_code]
221
+ data = countries_data.find do |candidate|
222
+ candidate[Core::MAIN_COUNTRY_FOR_CODE] == 'true'
223
+ end || countries_data.first
224
+ national_digits = if valid? || possible?
225
+ @national_number
226
+ else
227
+ international_digits.delete_prefix(country_code)
228
+ end
229
+ return [national_digits, data]
230
+ end
231
+
232
+ resolved_country = self.country
233
+ data = resolved_country && Phonelib.phone_data[resolved_country]
234
+ unless data
235
+ supplied_country = Array(
236
+ @passed_country || Phonelib.default_country
237
+ ).compact.first
238
+ data = supplied_country && Phonelib.phone_data[
239
+ normalize_passed_country(supplied_country)
240
+ ]
241
+ end
242
+ [@national_number || sanitized, data] if data
243
+ end
244
+
245
+ def international_digits_for_diagnostics
246
+ if !Phonelib.ignore_plus &&
247
+ significant_original_s.start_with?(Core::PLUS_SIGN)
248
+ return sanitized
249
+ end
250
+
251
+ digits_after_international_dialing_prefix
252
+ end
253
+
254
+ def possible_lengths_for(data)
255
+ general = data[Core::TYPES][Core::GENERAL]
256
+ lengths = general[Core::POSSIBLE_LENGTHS]
257
+ return lengths if lengths.is_a?(Array)
258
+
259
+ regex = general[Core::POSSIBLE_PATTERN]
260
+ return [] unless regex
261
+
262
+ (1..17).select { |length| ('0' * length).match?(cr("^(?:#{regex})$")) }
263
+ end
264
+
265
+ def possible_local_only_lengths_for(data)
266
+ lengths = data[Core::TYPES][Core::GENERAL][Core::POSSIBLE_LOCAL_ONLY_LENGTHS]
267
+ lengths.is_a?(Array) ? lengths : []
268
+ end
269
+
270
+ def digits_after_international_dialing_prefix
271
+ diagnostic_countries.each do |country|
272
+ data = Phonelib.phone_data[country]
273
+ next unless data && data[Core::INTERNATIONAL_PREFIX]
274
+
275
+ prefix = sanitized.match(cr("^(?:#{data[Core::INTERNATIONAL_PREFIX]})"))
276
+ return sanitized[prefix[0].length..-1] if prefix
277
+ end
278
+ nil
279
+ end
280
+
281
+ def diagnostic_countries
282
+ Array(@passed_country || Phonelib.default_country).compact.map do |country|
283
+ normalize_passed_country(country)
284
+ end
285
+ end
286
+
287
+ def country_calling_code_for(digits)
288
+ (1..3).map { |length| digits[0, length] }.find do |code|
289
+ Phonelib.data_by_country_codes.key?(code)
290
+ end
291
+ end
292
+
293
+ def policy_validation_errors(options, possible)
294
+ errors = []
295
+ allowed_types = normalize_validation_values(options[:types]) { |type| type.to_sym }
296
+ matched_types = possible ? possible_types : types
297
+ expanded_types = matched_types.dup
298
+ if expanded_types.include?(Core::FIXED_OR_MOBILE)
299
+ expanded_types += Core::FIXED_LINE_OR_MOBILE_ARRAY
300
+ end
301
+ if !options[:types].nil? && (expanded_types & allowed_types).empty?
302
+ errors << ValidationError.new(
303
+ :type_not_allowed,
304
+ allowed_types: allowed_types,
305
+ matched_types: matched_types
306
+ )
307
+ end
308
+
309
+ allowed_countries = normalize_validation_values(options[:countries]) do |country|
310
+ country.to_s.upcase
311
+ end
312
+ matched_countries = possible ? possible_policy_countries : valid_countries
313
+ if !options[:countries].nil? && (matched_countries & allowed_countries).empty?
314
+ errors << ValidationError.new(
315
+ :country_not_allowed,
316
+ allowed_countries: allowed_countries,
317
+ matched_countries: matched_countries
318
+ )
319
+ end
320
+
321
+ if options.fetch(:extensions, true) == false && !extension.to_s.empty?
322
+ errors << ValidationError.new(:extension_not_allowed, extension_present: true)
323
+ end
324
+ errors
325
+ end
326
+
327
+ def possible_policy_countries
328
+ return countries if countries.any?
329
+
330
+ context = diagnostic_number_context
331
+ context ? [context.last[:id]].compact : []
332
+ end
333
+
334
+ def normalize_validation_values(value)
335
+ Array(value).map { |item| yield(item) }.uniq.freeze
336
+ end
337
+ end
338
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phonelib
4
+ class Railtie < Rails::Railtie
5
+ initializer 'phonelib' do |app|
6
+ app.config.eager_load_namespaces << Phonelib
7
+
8
+ locale_files = Dir[File.expand_path('phonelib/locale/*.yml', __dir__)]
9
+ app.config.i18n.load_path = locale_files | app.config.i18n.load_path if defined?(I18n)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phonelib
4
+ # A framework-independent, immutable phone validation diagnostic.
5
+ class ValidationError
6
+ DEFAULT_MESSAGES = {
7
+ not_a_number: 'is not a phone number',
8
+ invalid_country_code: 'has an invalid country calling code',
9
+ too_short: 'is too short',
10
+ too_long: 'is too long',
11
+ invalid_length: 'has an invalid length',
12
+ not_possible: 'is not a possible phone number',
13
+ invalid: 'is not a valid phone number',
14
+ type_not_allowed: 'has a phone type that is not allowed',
15
+ country_not_allowed: 'is from a country that is not allowed',
16
+ extension_not_allowed: 'must not include an extension'
17
+ }.freeze
18
+
19
+ attr_reader :code, :details
20
+
21
+ def initialize(code, details = {})
22
+ @code = DEFAULT_MESSAGES.keys.find { |key| key.to_s == code.to_s }
23
+ unless @code
24
+ raise ArgumentError, "unknown validation error code: #{code}"
25
+ end
26
+ @details = immutable_copy(details)
27
+ freeze
28
+ end
29
+
30
+ def i18n_key
31
+ "phonelib.errors.#{code}".to_sym
32
+ end
33
+
34
+ def message(translator = nil)
35
+ default = DEFAULT_MESSAGES.fetch(code, code.to_s.tr('_', ' '))
36
+ return default unless translator
37
+
38
+ translator.call(i18n_key, **{ default: default }.merge(details))
39
+ end
40
+
41
+ private
42
+
43
+ def immutable_copy(value)
44
+ case value
45
+ when Hash
46
+ value.each_with_object({}) do |(key, item), copy|
47
+ copy[key] = immutable_copy(item)
48
+ end.freeze
49
+ when Array
50
+ value.map { |item| immutable_copy(item) }.freeze
51
+ when String
52
+ value.dup.freeze
53
+ else
54
+ value
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phonelib
4
+ # Immutable outcome of validating a parsed phone against a rule set.
5
+ class ValidationResult
6
+ attr_reader :phone, :errors, :possibility
7
+
8
+ def initialize(phone, errors, possibility)
9
+ @phone = phone
10
+ @errors = Errors.new(errors)
11
+ @possibility = possibility
12
+ freeze
13
+ end
14
+
15
+ def valid?
16
+ errors.empty?
17
+ end
18
+
19
+ def invalid?
20
+ !valid?
21
+ end
22
+
23
+ def error
24
+ errors.first
25
+ end
26
+ end
27
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Phonelib
4
4
  # @private
5
- VERSION = '0.10.25'
5
+ VERSION = '0.10.26'
6
6
  end
data/lib/phonelib.rb CHANGED
@@ -8,19 +8,18 @@ module Phonelib
8
8
  require 'phonelib/phone_analyzer_helper'
9
9
  require 'phonelib/phone_analyzer'
10
10
  require 'phonelib/phone_extended_data'
11
+ require 'phonelib/validation_error'
12
+ require 'phonelib/errors'
13
+ require 'phonelib/validation_result'
14
+ require 'phonelib/phone_validation'
11
15
  require 'phonelib/phone'
12
16
 
13
17
  extend Core
14
18
  end
15
19
 
16
- if defined?(ActiveModel) || defined?(Rails)
17
- autoload :PhoneValidator, 'validators/phone_validator'
20
+ autoload :PhoneValidator, 'validators/phone_validator'
18
21
 
19
- if defined?(Rails)
20
- class Phonelib::Railtie < Rails::Railtie
21
- initializer 'phonelib' do |app|
22
- app.config.eager_load_namespaces << Phonelib
23
- end
24
- end
25
- end
26
- end
22
+ locale_files = Dir[File.expand_path('phonelib/locale/*.yml', __dir__)]
23
+ I18n.load_path = locale_files | I18n.load_path if defined?(I18n)
24
+
25
+ require 'phonelib/railtie' if defined?(Rails::Railtie)
@@ -53,18 +53,51 @@
53
53
  # validates :number, phone: { extensions: false }
54
54
  # end
55
55
  #
56
+ # Validates that the original value includes an explicit international prefix.
57
+ # Both + and 00 prefixes are accepted.
58
+ #
59
+ # class Phone < ActiveRecord::Base
60
+ # validates :number, phone: { require_international_prefix: true }
61
+ # end
62
+ #
63
+ # Validates that the original value uses canonical E.164 input format.
64
+ #
65
+ # class Phone < ActiveRecord::Base
66
+ # validates :number, phone: { format: :e164 }
67
+ # end
68
+ #
69
+
70
+ if defined?(I18n)
71
+ locale_files = Dir[File.expand_path('../phonelib/locale/*.yml', __dir__)]
72
+ I18n.load_path = locale_files | I18n.load_path
73
+ end
74
+
56
75
  class PhoneValidator < ActiveModel::EachValidator
76
+ E164_PATTERN = /\A\+[1-9][0-9]{1,14}\z/
77
+ SUPPORTED_FORMATS = [:e164, 'e164'].freeze
78
+
57
79
  # Include all core methods
58
80
  include Phonelib::Core
59
81
 
82
+ def check_validity!
83
+ return unless options.has_key?(:format)
84
+ return if SUPPORTED_FORMATS.include?(options[:format])
85
+
86
+ raise ArgumentError, "Unsupported phone format: #{options[:format]}"
87
+ end
88
+
60
89
  # Validation method
61
90
  def validate_each(record, attribute, value)
62
91
  return if options[:allow_blank] && value.blank?
63
92
 
64
- @phone = parse(value, specified_country(record))
65
- valid = phone_valid? && valid_types? && valid_country? && valid_extensions?
93
+ phone = parse(value, specified_country(record))
94
+ if detailed_errors?
95
+ return validate_with_details(record, attribute, phone)
96
+ end
66
97
 
67
- record.errors.add(attribute, message, **options) unless valid
98
+ valid = phone_valid?(phone) && valid_types?(phone) && valid_country?(phone) &&
99
+ valid_extensions?(phone) && valid_input_format?(value)
100
+ record.errors.add(attribute, message, **legacy_error_options) unless valid
68
101
  end
69
102
 
70
103
  private
@@ -73,23 +106,84 @@ class PhoneValidator < ActiveModel::EachValidator
73
106
  options[:message] || :invalid
74
107
  end
75
108
 
76
- def phone_valid?
77
- @phone.send(options[:possible] ? :possible? : :valid?)
109
+ def detailed_errors?
110
+ options[:detailed_errors]
111
+ end
112
+
113
+ def legacy_error_options
114
+ options.reject { |key, _value| key == :detailed_errors }
115
+ end
116
+
117
+ def validate_with_details(record, attribute, phone)
118
+ result = phone.validation(
119
+ possible: !!options[:possible],
120
+ types: detailed_types,
121
+ countries: detailed_countries,
122
+ extensions: extensions_allowed?
123
+ )
124
+ return if result.valid?
125
+
126
+ result.errors.each do |error|
127
+ record.errors.add(
128
+ attribute,
129
+ "phone_#{error.code}".to_sym,
130
+ **detailed_error_options(error)
131
+ )
132
+ end
133
+ end
134
+
135
+ def detailed_error_options(error)
136
+ details = error.details.dup
137
+ details.delete(:error)
138
+ details.delete('error')
139
+ details[:message] = options[:message] if options.key?(:message)
140
+ details[:strict] = options[:strict] if options.key?(:strict)
141
+ details
142
+ end
143
+
144
+ def detailed_types
145
+ types.uniq if options[:types]
78
146
  end
79
147
 
80
- def valid_types?
148
+ def detailed_countries
149
+ return unless options[:countries]
150
+
151
+ countries.uniq.select { |country| Phonelib.phone_data.key?(country) }
152
+ end
153
+
154
+ def phone_valid?(phone)
155
+ phone.send(options[:possible] ? :possible? : :valid?)
156
+ end
157
+
158
+ def valid_types?(phone)
81
159
  return true unless options[:types]
82
- (phone_types & types).size > 0
160
+ (phone_types(phone) & types).size > 0
83
161
  end
84
162
 
85
- def valid_country?
163
+ def valid_country?(phone)
86
164
  return true unless options[:countries]
87
- (phone_countries & countries).size > 0
165
+ (phone_countries(phone) & countries).size > 0
166
+ end
167
+
168
+ def valid_extensions?(phone)
169
+ return true if extensions_allowed?
170
+ phone.extension.empty?
171
+ end
172
+
173
+ def extensions_allowed?
174
+ !options.has_key?(:extensions) || !!options[:extensions]
88
175
  end
89
176
 
90
- def valid_extensions?
91
- return true if !options.has_key?(:extensions) || options[:extensions]
92
- @phone.extension.empty?
177
+ def valid_international_prefix?
178
+ return true unless options[:require_international_prefix]
179
+
180
+ @phone.explicit_international_prefix?
181
+ end
182
+
183
+ def valid_input_format?(value)
184
+ return true unless options[:format]
185
+
186
+ SUPPORTED_FORMATS.include?(options[:format]) && value.is_a?(String) && value.match?(E164_PATTERN)
93
187
  end
94
188
 
95
189
  def specified_country(record)
@@ -103,9 +197,9 @@ class PhoneValidator < ActiveModel::EachValidator
103
197
  end
104
198
 
105
199
  # @private
106
- def phone_types
200
+ def phone_types(phone)
107
201
  method = options[:possible] ? :possible_types : :types
108
- phone_types = @phone.send(method)
202
+ phone_types = phone.send(method)
109
203
  if (phone_types & [Phonelib::Core::FIXED_OR_MOBILE]).size > 0
110
204
  phone_types += [Phonelib::Core::FIXED_LINE, Phonelib::Core::MOBILE]
111
205
  end
@@ -113,15 +207,17 @@ class PhoneValidator < ActiveModel::EachValidator
113
207
  end
114
208
 
115
209
  # @private
116
- def phone_countries
210
+ def phone_countries(phone)
117
211
  method = options[:possible] ? :countries : :valid_countries
118
- @phone.send(method)
212
+ phone.send(method)
119
213
  end
120
214
 
121
215
  # @private
122
216
  def types
123
217
  types = options[:types].is_a?(Array) ? options[:types] : [options[:types]]
124
- types.map(&:to_sym)
218
+ types.map do |type|
219
+ Phonelib::Core::TYPES_DESC_KEYS.find { |known| known.to_s == type.to_s }
220
+ end.compact
125
221
  end
126
222
 
127
223
  # @private
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phonelib
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.25
4
+ version: 0.10.26
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vadim Senderovich
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-18 00:00:00.000000000 Z
11
+ date: 2026-08-26 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |2
14
14
  Google libphonenumber library was taken as a basis for
@@ -28,11 +28,17 @@ files:
28
28
  - lib/phonelib/core.rb
29
29
  - lib/phonelib/data_importer.rb
30
30
  - lib/phonelib/data_importer_helper.rb
31
+ - lib/phonelib/errors.rb
32
+ - lib/phonelib/locale/en.yml
31
33
  - lib/phonelib/phone.rb
32
34
  - lib/phonelib/phone_analyzer.rb
33
35
  - lib/phonelib/phone_analyzer_helper.rb
34
36
  - lib/phonelib/phone_extended_data.rb
35
37
  - lib/phonelib/phone_formatter.rb
38
+ - lib/phonelib/phone_validation.rb
39
+ - lib/phonelib/railtie.rb
40
+ - lib/phonelib/validation_error.rb
41
+ - lib/phonelib/validation_result.rb
36
42
  - lib/phonelib/version.rb
37
43
  - lib/tasks/phonelib_tasks.rake
38
44
  - lib/validators/phone_validator.rb
@@ -40,7 +46,7 @@ homepage: https://github.com/daddyz/phonelib
40
46
  licenses:
41
47
  - MIT
42
48
  metadata:
43
- changelog_uri: https://github.com/daddyz/phonelib/releases/tag/v0.10.25
49
+ changelog_uri: https://github.com/daddyz/phonelib/releases/tag/v0.10.26
44
50
  post_install_message:
45
51
  rdoc_options:
46
52
  - " --no-private - CHANGELOG.md --readme README.md"