jscontact 0.1.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.
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jscontact
4
+ Violation = Struct.new(:pointer, :message, keyword_init: true)
5
+
6
+ class Error < StandardError
7
+ attr_reader :pointer
8
+
9
+ def initialize(message = nil, pointer: nil)
10
+ @pointer = pointer
11
+ super(message)
12
+ end
13
+ end
14
+
15
+ class ParseError < Error; end
16
+ class ConversionError < Error; end
17
+ class LimitError < Error; end
18
+
19
+ class ValidationError < Error
20
+ attr_reader :errors
21
+
22
+ def initialize(errors, pointer: nil)
23
+ @errors = errors.is_a?(Array) ? errors : [Violation.new(pointer: pointer || "", message: errors)]
24
+ super(@errors.map { |error| "#{error.pointer}: #{error.message}" }.join("; "), pointer: pointer || @errors.first&.pointer)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jscontact
4
+ module Localization
5
+ module_function
6
+
7
+ def validate(data, **limits)
8
+ localizations = data["localizations"]
9
+ return [] unless localizations.is_a?(Hash)
10
+
11
+ count = localizations.values.sum { |patch| patch.is_a?(Hash) ? patch.size : 0 }
12
+ raise LimitError.new("Too many localization patches", pointer: "/localizations") if count > limits.fetch(:max_patches, DEFAULT_LIMITS[:max_patches])
13
+
14
+ source = data.except("localizations")
15
+ localizations.flat_map do |language, patch|
16
+ prefix = Jscontact.pointer("/localizations", language)
17
+ next [] unless patch.is_a?(Hash)
18
+
19
+ forbidden = patch.keys.select { |path| path.is_a?(String) && (path == "localizations" || path.start_with?("localizations/")) }
20
+ unless forbidden.empty?
21
+ next forbidden.map { |path| Violation.new(pointer: Jscontact.pointer(prefix, path), message: "A localization cannot target localizations") }
22
+ end
23
+
24
+ begin
25
+ localized = PatchObject.new(patch, **limits).apply(source, validate: false)
26
+ Validator.new(**limits, validate_localizations: false).validate(localized).map do |error|
27
+ owner = patch.keys.find { |key| error.pointer == "/#{key}" || error.pointer.start_with?("/#{key}/") }
28
+ pointer = owner ? Jscontact.pointer(prefix, owner) + error.pointer.delete_prefix("/#{owner}") : prefix
29
+ Violation.new(pointer: pointer, message: "#{error.pointer}: #{error.message}")
30
+ end
31
+ rescue ValidationError => e
32
+ e.errors.map { |error| Violation.new(pointer: prefix + error.pointer.to_s, message: error.message) }
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ class Card
39
+ def localized(language)
40
+ raise ValidationError.new("Invalid language tag", pointer: "/language") unless language.is_a?(String) && Types.language_tag?(language)
41
+
42
+ validate!
43
+ source = to_h
44
+ localizations = source.fetch("localizations", {})
45
+ key = localizations.keys.find { |tag| tag.casecmp?(language) }
46
+ return self.class.new(source, **limits) unless key
47
+
48
+ patch = localizations.fetch(key)
49
+ source.delete("localizations")
50
+ result = PatchObject.new(patch, **limits).apply(source)
51
+ result["language"] = language
52
+ self.class.new(result, **limits).validate!
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jscontact
4
+ # A typed view over a JSON object; nested views write into their owning model.
5
+ class Model
6
+ attr_reader :limits
7
+
8
+ def initialize(data = nil, strict: false, explicit_types: false, **limits)
9
+ @limits = DEFAULT_LIMITS.merge(limits).freeze
10
+ validator = Validator.new(**limits)
11
+ data = { "@type" => "Card", "version" => "2.0" } if data.nil? && instance_of?(Card)
12
+ data ||= {}
13
+ raise ValidationError, "expected a JSON object" unless data.is_a?(Hash)
14
+
15
+ @data = Jscontact.deep_copy(data, max_depth: @limits[:max_depth])
16
+ @explicit_types = explicit_types
17
+ violations = validator.validate(@data, type: type_name, root: instance_of?(Card))
18
+ raise ValidationError, violations if strict && !violations.empty?
19
+ end
20
+
21
+ def self.wrap(data, limits, explicit_types)
22
+ value = allocate
23
+ value.instance_variable_set(:@data, data)
24
+ value.instance_variable_set(:@limits, limits)
25
+ value.instance_variable_set(:@explicit_types, explicit_types)
26
+ value
27
+ end
28
+
29
+ def [](name)
30
+ name = name.to_s
31
+ rule = SCHEMA.fetch(type_name).fetch("properties")[name]
32
+ return @data[name] unless rule
33
+ return Jscontact.deep_copy(rule["default"]) if !@data.key?(name) && rule.key?("default")
34
+
35
+ wrap_value(@data[name], rule.fetch("type"))
36
+ end
37
+
38
+ def []=(name, value)
39
+ @data[name.to_s] = Jscontact.deep_copy(value, max_depth: @limits[:max_depth])
40
+ end
41
+
42
+ def key?(name)
43
+ @data.key?(name.to_s)
44
+ end
45
+
46
+ def delete(name)
47
+ @data.delete(name.to_s)
48
+ end
49
+
50
+ def to_h
51
+ output = Jscontact.deep_copy(@data, max_depth: @limits[:max_depth])
52
+ add_types(output, type_name) if @explicit_types
53
+ output
54
+ end
55
+
56
+ def to_json(options = nil)
57
+ validate!
58
+ data = to_h
59
+ data["@type"] ||= type_name
60
+ Jscontact.generate_json(data, options || { max_nesting: @limits[:max_depth] })
61
+ end
62
+
63
+ def errors
64
+ Validator.new(**@limits).validate(@data, type: type_name, root: instance_of?(Card))
65
+ end
66
+ alias warnings errors
67
+
68
+ def valid?
69
+ errors.empty?
70
+ end
71
+
72
+ def validate!
73
+ violations = errors
74
+ raise ValidationError, violations unless violations.empty?
75
+
76
+ self
77
+ end
78
+
79
+ def ==(other)
80
+ other.is_a?(Model) && to_h == other.to_h
81
+ end
82
+
83
+ protected
84
+
85
+ def type_name
86
+ self.class.name.split("::").last
87
+ end
88
+
89
+ private
90
+
91
+ def wrap_value(value, signature)
92
+ return value unless value.is_a?(Hash) || value.is_a?(Array)
93
+
94
+ if SCHEMA.key?(signature) && value.is_a?(Hash)
95
+ Jscontact.const_get(signature).wrap(value, @limits, @explicit_types)
96
+ elsif (match = /\A(?:Id|String)\[(\w+)\]\z/.match(signature)) && SCHEMA.key?(match[1]) && value.is_a?(Hash)
97
+ IdMap.new(value, type: match[1], limits: @limits, explicit_types: @explicit_types,
98
+ validate_ids: signature.start_with?("Id["))
99
+ elsif signature.end_with?("[]") && value.is_a?(Array)
100
+ value.map { |item| wrap_value(item, signature.delete_suffix("[]")) }
101
+ elsif signature.include?("|") && value.is_a?(Hash)
102
+ type = value.fetch("@type", signature.split("|").first)
103
+ SCHEMA.key?(type) ? Jscontact.const_get(type).wrap(value, @limits, @explicit_types) : value
104
+ else
105
+ value
106
+ end
107
+ end
108
+
109
+ def add_types(value, type)
110
+ return unless value.is_a?(Hash)
111
+
112
+ value["@type"] ||= type
113
+ SCHEMA.fetch(type).fetch("properties").each do |name, definition|
114
+ next unless value.key?(name)
115
+
116
+ item = value[name]
117
+ signature = definition.fetch("type")
118
+ if SCHEMA.key?(signature)
119
+ add_types(item, signature)
120
+ elsif (match = /\A(?:Id|String)\[(\w+)\]\z/.match(signature)) && SCHEMA.key?(match[1]) && item.is_a?(Hash)
121
+ item.each_value { |entry| add_types(entry, match[1]) }
122
+ elsif signature.end_with?("[]") && SCHEMA.key?(signature.delete_suffix("[]")) && item.is_a?(Array)
123
+ item.each { |entry| add_types(entry, signature.delete_suffix("[]")) }
124
+ elsif signature.include?("|") && item.is_a?(Hash)
125
+ nested_type = item.fetch("@type", signature.split("|").first)
126
+ add_types(item, nested_type) if SCHEMA.key?(nested_type)
127
+ end
128
+ end
129
+ end
130
+ end
131
+
132
+ # A map preserving source identifiers and exposing typed member values.
133
+ class IdMap
134
+ include Enumerable
135
+
136
+ def initialize(data = {}, type:, limits: DEFAULT_LIMITS, explicit_types: false, validate_ids: true)
137
+ @data = data
138
+ @type = type
139
+ @limits = limits
140
+ @explicit_types = explicit_types
141
+ @validate_ids = validate_ids
142
+ end
143
+
144
+ def [](id)
145
+ value = @data[id.to_s]
146
+ value.is_a?(Hash) ? Jscontact.const_get(@type).wrap(value, @limits, @explicit_types) : value
147
+ end
148
+
149
+ def []=(id, value)
150
+ id = id.to_s
151
+ raise ValidationError.new("invalid map Id", pointer: Jscontact.pointer("", id)) if @validate_ids && !Types.id?(id)
152
+ raise LimitError, "map exceeds max_map_size" if !@data.key?(id) && @data.size >= @limits[:max_map_size]
153
+
154
+ @data[id] = Jscontact.deep_copy(value, max_depth: @limits[:max_depth])
155
+ end
156
+
157
+ def each
158
+ return enum_for(:each) unless block_given?
159
+
160
+ @data.each_key { |id| yield id, self[id] }
161
+ self
162
+ end
163
+
164
+ def keys
165
+ @data.keys
166
+ end
167
+
168
+ def values
169
+ map { |_id, value| value }
170
+ end
171
+
172
+ def size
173
+ @data.size
174
+ end
175
+ alias length size
176
+
177
+ def empty?
178
+ @data.empty?
179
+ end
180
+
181
+ def key?(id)
182
+ @data.key?(id.to_s)
183
+ end
184
+
185
+ def delete(id)
186
+ value = self[id]
187
+ @data.delete(id.to_s)
188
+ value
189
+ end
190
+
191
+ def add(value, prefix: "id")
192
+ counter = 1
193
+ counter += 1 while @data.key?("#{prefix}#{counter}")
194
+ id = "#{prefix}#{counter}"
195
+ self[id] = value
196
+ id
197
+ end
198
+
199
+ def to_h
200
+ Jscontact.deep_copy(@data, max_depth: @limits[:max_depth])
201
+ end
202
+ end
203
+
204
+ SCHEMA.each do |name, definition|
205
+ model = const_set(name, Class.new(Model))
206
+ definition.fetch("properties").each_key do |property|
207
+ next if property == "@type"
208
+
209
+ model.class_eval(<<~RUBY, __FILE__, __LINE__ + 1)
210
+ # def full; self["full"]; end
211
+ def #{property}; self[#{property.inspect}]; end
212
+ # def full=(value); self["full"] = value; end
213
+ def #{property}=(value); self[#{property.inspect}] = value; end
214
+ RUBY
215
+ snake = property.gsub(/([A-Z])/, '_\\1').downcase
216
+ next if snake == property
217
+
218
+ model.alias_method(snake, property)
219
+ model.alias_method("#{snake}=", "#{property}=")
220
+ end
221
+ end
222
+
223
+ class Card
224
+ def self.build(version: "2.0", explicit_types: false, **properties)
225
+ card = new({ "@type" => "Card", "version" => version }.merge(properties), explicit_types: explicit_types)
226
+ yield card if block_given?
227
+ card.validate!
228
+ end
229
+
230
+ # Returns the stable Id allocated for the new email address.
231
+ def add_email(**properties)
232
+ self.emails = {} unless key?("emails")
233
+ emails.add(properties, prefix: "e")
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jscontact
4
+ class PatchObject
5
+ def initialize(patches, max_patches: 1_000, **limits)
6
+ raise ArgumentError, "max_patches must be a nonnegative integer" unless max_patches.is_a?(Integer) && max_patches >= 0
7
+ raise ValidationError, "PatchObject must be an object" unless patches.is_a?(Hash)
8
+ raise LimitError.new("Too many patches", pointer: "") if patches.size > max_patches
9
+
10
+ @limits = limits.merge(max_patches: max_patches)
11
+ @patches = Jscontact.deep_copy(patches, max_depth: @limits.fetch(:max_depth, 32))
12
+ end
13
+
14
+ def to_h
15
+ Jscontact.deep_copy(@patches, max_depth: @limits.fetch(:max_depth, 32))
16
+ end
17
+
18
+ # RFC 9553 section 1.4.3: resolve every path before modifying the copy.
19
+ def apply(data, validate: true)
20
+ raise ValidationError, "Patch target must be an object" unless data.is_a?(Hash)
21
+
22
+ result = Jscontact.deep_copy(data, max_depth: @limits.fetch(:max_depth, 32))
23
+ type = result["@type"]
24
+ validator = Validator.new(**@limits, validate_localizations: false)
25
+ errors = validator.validate_json(result) + validator.validate_json(@patches)
26
+ raise ValidationError, errors unless errors.empty?
27
+
28
+ paths = @patches.keys.map { |path| [tokens(path), path] }.sort_by(&:first)
29
+ paths.each_cons(2) do |(first, _), (second, path)|
30
+ invalid(path, "Patch paths must not overlap") if second.first(first.size) == first
31
+ end
32
+ operations = paths.map do |parts, path|
33
+ parent = parts[0...-1].reduce(result) { |value, token| member(value, token, path) }
34
+ key = parts.last
35
+ if parent.is_a?(Array)
36
+ key = array_index(parent, key, path)
37
+ invalid(path, "Array members cannot be removed") if @patches[path].nil?
38
+ elsif !parent.is_a?(Hash)
39
+ invalid(path, "Patch parent must be an object or array")
40
+ end
41
+ [parent, key, @patches[path]]
42
+ end
43
+ operations.each do |parent, key, value|
44
+ if value.nil?
45
+ parent.delete(key)
46
+ else
47
+ parent[key] = Jscontact.deep_copy(value, max_depth: @limits.fetch(:max_depth, 32))
48
+ end
49
+ end
50
+ errors = if validate && (type || result["@type"])
51
+ raise ValidationError.new("Unknown patch target type", pointer: "/@type") unless SCHEMA.key?(type || result["@type"])
52
+
53
+ validator.validate(result, type: type || result["@type"], root: true)
54
+ else
55
+ validator.validate_json(result)
56
+ end
57
+ raise ValidationError, errors unless errors.empty?
58
+
59
+ result
60
+ end
61
+
62
+ private
63
+
64
+ def tokens(path)
65
+ invalid(path, "Invalid JSON Pointer escape") if path.match?(/~(?:[^01]|\z)/)
66
+ (path.empty? ? [""] : path.split("/", -1)).map { |token| token.gsub(/~[01]/) { |escape| escape == "~0" ? "~" : "/" } }
67
+ end
68
+
69
+ def array_index(array, token, path)
70
+ valid = token.match?(/\A(?:0|[1-9][0-9]*)\z/) && token.bytesize <= array.size.to_s.size && token.to_i < array.size
71
+ invalid(path, "Array index must refer to an existing member") unless valid
72
+ token.to_i
73
+ end
74
+
75
+ def member(parent, key, path)
76
+ if parent.is_a?(Array)
77
+ parent[array_index(parent, key, path)]
78
+ elsif parent.is_a?(Hash) && parent.key?(key)
79
+ parent[key]
80
+ else
81
+ invalid(path, "Intermediate reference token does not exist")
82
+ end
83
+ end
84
+
85
+ def invalid(path, message)
86
+ raise ValidationError.new(message, pointer: "/#{path.gsub("~", "~0").gsub("/", "~1")}")
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jscontact
4
+ DEFAULT_LIMITS = { max_input_size: 16 * 1024 * 1024, max_depth: 32, max_map_size: 10_000,
5
+ max_data_uri_size: 1024 * 1024, max_patches: 1000 }.freeze
6
+ MAX_INTEGER = (2**53) - 1
7
+
8
+ class << self
9
+ def generate_json(value, options = nil)
10
+ value = precise_json_floats(value) if RUBY_ENGINE == "truffleruby"
11
+ JSON.generate(value, options)
12
+ end
13
+
14
+ # TruffleRuby Float#to_s can round away significant digits used by json's generator.
15
+ def precise_json_floats(value)
16
+ case value
17
+ when Hash then value.transform_values { |item| precise_json_floats(item) }
18
+ when Array then value.map { |item| precise_json_floats(item) }
19
+ when Float
20
+ raise JSON::GeneratorError, "#{value} not allowed in JSON" unless value.finite?
21
+
22
+ number = format("%.17g", value)
23
+ number += ".0" unless number.match?(/[.eE]/)
24
+ JSON::Fragment.new(number)
25
+ else value
26
+ end
27
+ end
28
+ private :precise_json_floats
29
+
30
+ # Copies JSON-compatible values without sharing mutable caller-owned containers.
31
+ def deep_copy(value, max_depth: DEFAULT_LIMITS[:max_depth], depth: 0, ancestors: [], pointer: "")
32
+ case value
33
+ when Hash, Array
34
+ raise LimitError.new("JSON nesting exceeds max_depth", pointer: pointer) if depth >= max_depth
35
+ raise ValidationError.new("cyclic JSON data", pointer: pointer) if ancestors.include?(value.object_id)
36
+
37
+ nested = ancestors + [value.object_id]
38
+ if value.is_a?(Array)
39
+ value.each_with_index.map do |item, index|
40
+ deep_copy(item, max_depth: max_depth, depth: depth + 1, ancestors: nested, pointer: self.pointer(pointer, index))
41
+ end
42
+ else
43
+ value.each_with_object({}) do |(key, item), result|
44
+ raise ValidationError.new("JSON object keys must be strings", pointer: pointer) unless key.is_a?(String) || key.is_a?(Symbol)
45
+
46
+ key = key.to_s
47
+ unless key.valid_encoding? && (key.encoding == Encoding::UTF_8 || key.ascii_only?)
48
+ raise ValidationError.new("invalid JSON object key encoding", pointer: pointer)
49
+ end
50
+ raise ValidationError.new("duplicate JSON object key #{key}", pointer: self.pointer(pointer, key)) if result.key?(key)
51
+
52
+ result[key.dup] = deep_copy(item, max_depth: max_depth, depth: depth + 1, ancestors: nested,
53
+ pointer: self.pointer(pointer, key))
54
+ end
55
+ end
56
+ when String then value.dup
57
+ when Integer, Float, TrueClass, FalseClass, NilClass then value
58
+ when Model, IdMap then value.to_h
59
+ else raise ValidationError.new("value is not JSON-compatible", pointer: pointer)
60
+ end
61
+ end
62
+
63
+ def deep_freeze(value)
64
+ if value.is_a?(Hash)
65
+ value.each do |key, item|
66
+ deep_freeze(key)
67
+ deep_freeze(item)
68
+ end
69
+ end
70
+ value.each { |item| deep_freeze(item) } if value.is_a?(Array)
71
+ value.freeze
72
+ end
73
+
74
+ def pointer(parent, key)
75
+ "#{parent}/#{key.to_s.gsub("~", "~0").gsub("/", "~1")}"
76
+ end
77
+ end
78
+
79
+ SCHEMA = deep_freeze(YAML.safe_load_file(File.expand_path("../../data/types.yml", __dir__)))
80
+ ENUMS = deep_freeze(YAML.safe_load_file(File.expand_path("../../data/enums.yml", __dir__)))
81
+
82
+ module Types
83
+ module_function
84
+
85
+ def id?(value)
86
+ value.is_a?(String) && value.bytesize.between?(1, 255) && value.match?(/\A[A-Za-z0-9_-]+\z/)
87
+ end
88
+
89
+ def integer?(value, minimum: -MAX_INTEGER, maximum: MAX_INTEGER)
90
+ value.is_a?(Integer) && value.between?(minimum, maximum)
91
+ end
92
+
93
+ def script?(value)
94
+ value.is_a?(String) && value.match?(/\A[A-Za-z]{4}\z/) &&
95
+ (ENUMS.fetch("script").include?(value.capitalize) || value.downcase.between?("qaaa", "qabx"))
96
+ end
97
+
98
+ def vendor?(value)
99
+ return false unless value.is_a?(String) && value.valid_encoding?
100
+
101
+ prefix, name = value.split(":", 2)
102
+ return false unless name && !prefix.empty? && !name.empty? && !name.match?(%r{[\x00-\x08\x0A-\x1F\x7F"/~]})
103
+
104
+ prefix.split(".", -1).all? do |label|
105
+ label.match?(/\A[A-Za-z0-9\u0080-\u{10ffff}](?:[A-Za-z0-9\u0080-\u{10ffff}-]*[A-Za-z0-9\u0080-\u{10ffff}])?\z/)
106
+ end
107
+ rescue ArgumentError, Encoding::CompatibilityError
108
+ false
109
+ end
110
+
111
+ def uri?(value)
112
+ value.is_a?(String) && value.ascii_only? && !value.match?(/%(?![0-9a-fA-F]{2})/) && !URI.parse(value).scheme.nil?
113
+ rescue URI::InvalidURIError
114
+ false
115
+ end
116
+
117
+ def utc_date_time?(value)
118
+ return false unless value.is_a?(String)
119
+
120
+ match = /\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d*[1-9])?Z\z/.match(value)
121
+ return false unless match
122
+
123
+ year, month, day, hour, minute, second = match.captures.map(&:to_i)
124
+ Date.valid_date?(year, month, day, Date::GREGORIAN) && hour < 24 && minute < 60 && second <= 60 &&
125
+ (second < 60 || (hour == 23 && minute == 59 && [6, 12].include?(month) && day == Date.new(year, month, -1).day))
126
+ end
127
+
128
+ def jcard_temporal?(value, type)
129
+ return false unless value.is_a?(String)
130
+
131
+ case type
132
+ when "date" then jcard_date?(value)
133
+ when "time" then jcard_time?(value)
134
+ when "utc-offset"
135
+ match = /\A[+-](\d{2})(?::(\d{2}))?\z/.match(value)
136
+ match && match[1].to_i <= 23 && match[2].to_i <= 59
137
+ when "date-and-or-time"
138
+ if value.start_with?("T")
139
+ jcard_time?(value[1..])
140
+ else
141
+ jcard_temporal?(value, value.include?("T") ? "date-time" : "date")
142
+ end
143
+ else
144
+ day, clock, extra = value.split("T", -1)
145
+ return false if extra || !clock || !jcard_date?(day) || !jcard_time?(clock)
146
+ return false unless day.match?(/\A(?:\d{4}-\d{2}-\d{2}|--\d{2}-\d{2}|---\d{2})\z/) && !clock.start_with?("-")
147
+
148
+ type != "timestamp" || (day.match?(/\A\d{4}-\d{2}-\d{2}\z/) && clock.match?(/\A\d{2}:\d{2}:\d{2}(?:Z|[+-]\d{2}(?::\d{2})?)?\z/))
149
+ end
150
+ end
151
+
152
+ def jcard_date?(value)
153
+ if (match = /\A(?:(\d{4})-|--)(\d{2})-(\d{2})\z/.match(value))
154
+ return Date.valid_date?((match[1] || "2000").to_i, match[2].to_i, match[3].to_i, Date::GREGORIAN)
155
+ end
156
+ return true if value.match?(/\A\d{4}\z/)
157
+ return (1..31).cover?(match[1].to_i) if (match = /\A---(\d{2})\z/.match(value))
158
+
159
+ match = /\A(?:\d{4}-|--)(\d{2})\z/.match(value)
160
+ match && (1..12).cover?(match[1].to_i)
161
+ end
162
+
163
+ def jcard_time?(value)
164
+ match = /\A(\d{2}(?::\d{2}){0,2}|-\d{2}(?::\d{2})?|--\d{2})(Z|[+-]\d{2}(?::\d{2})?)?\z/.match(value)
165
+ return false unless match
166
+
167
+ clock, zone = match.captures
168
+ return false if zone && zone != "Z" && !jcard_temporal?(zone, "utc-offset")
169
+
170
+ offset = clock[/\A-*/].length
171
+ units = clock.delete_prefix("-" * offset).split(":").map(&:to_i)
172
+ units.each_with_index.all? { |unit, index| unit <= [23, 59, 60][index + offset] }
173
+ end
174
+
175
+ GRANDFATHERED_LANGUAGES = %w[art-lojban cel-gaulish en-gb-oed i-ami i-bnn i-default i-enochian i-hak i-klingon
176
+ i-lux i-mingo i-navajo i-pwn i-tao i-tay i-tsu no-bok no-nyn sgn-be-fr sgn-be-nl
177
+ sgn-ch-de zh-guoyu zh-hakka zh-min zh-min-nan zh-xiang].freeze
178
+
179
+ def language_tag?(value)
180
+ return false unless value.is_a?(String)
181
+ return true if GRANDFATHERED_LANGUAGES.include?(value.downcase)
182
+ return true if value.match?(/\Ax(?:-[A-Za-z0-9]{1,8})+\z/i)
183
+
184
+ parts = value.split("-", -1)
185
+ language = parts.shift
186
+ return false unless language&.match?(/\A[A-Za-z]{2,8}\z/)
187
+
188
+ 3.times { parts.shift if parts.first&.match?(/\A[A-Za-z]{3}\z/) } if language.size <= 3
189
+ parts.shift if parts.first&.match?(/\A[A-Za-z]{4}\z/)
190
+ parts.shift if parts.first&.match?(/\A(?:[A-Za-z]{2}|[0-9]{3})\z/)
191
+ variants = []
192
+ while parts.first&.match?(/\A(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3})\z/)
193
+ variant = parts.shift.downcase
194
+ return false if variants.include?(variant)
195
+
196
+ variants << variant
197
+ end
198
+ singletons = []
199
+ until parts.empty?
200
+ singleton = parts.shift.downcase
201
+ return false unless singleton.match?(/\A[A-Za-z0-9]\z/) && !singletons.include?(singleton)
202
+ return !parts.empty? && parts.all? { |part| part.match?(/\A[A-Za-z0-9]{1,8}\z/) } if singleton == "x"
203
+
204
+ singletons << singleton
205
+ count = 0
206
+ while parts.first&.match?(/\A[A-Za-z0-9]{2,8}\z/)
207
+ parts.shift
208
+ count += 1
209
+ end
210
+ return false if count.zero?
211
+ end
212
+ true
213
+ end
214
+
215
+ def email?(value)
216
+ return false unless value.is_a?(String) && value.ascii_only?
217
+
218
+ value = value.gsub(/\r\n(?=[ \t])/, "")
219
+ return false if value.match?(/[^\x09\x20-\x7E]/)
220
+
221
+ value = without_email_comments(value)
222
+ return false unless value
223
+
224
+ atom = %q([A-Za-z0-9!#$%&'*+\-/=?^_`{|}~]+)
225
+ quoted = '"(?:[^"\\\\\r\n]|\\\\[\\x20-\\x7E])*"'
226
+ word = "(?:#{atom}|#{quoted})"
227
+ local = "#{word}(?:[ \\t]*\\.[ \\t]*#{word})*"
228
+ domain = "(?:#{atom}(?:[ \\t]*\\.[ \\t]*#{atom})*|\\[(?:[^\\[\\]\\\\\\r\\n]|\\\\[\\x20-\\x7E])*\\])"
229
+ value.match?(/\A[ \t]*#{local}[ \t]*@[ \t]*#{domain}[ \t]*\z/)
230
+ end
231
+
232
+ def without_email_comments(value)
233
+ result = +""
234
+ depth = 0
235
+ quoted = nil
236
+ escaped = false
237
+ value.each_char do |character|
238
+ if escaped
239
+ result << character if depth.zero?
240
+ escaped = false
241
+ elsif character == "\\" && (depth.positive? || quoted)
242
+ escaped = true
243
+ result << character if depth.zero?
244
+ elsif depth.positive?
245
+ depth += 1 if character == "("
246
+ depth -= 1 if character == ")"
247
+ result << " " if depth.zero?
248
+ elsif quoted
249
+ result << character
250
+ quoted = nil if character == quoted
251
+ elsif character == "("
252
+ depth = 1
253
+ else
254
+ result << character
255
+ quoted = character == "[" ? "]" : '"' if ['"', "["].include?(character)
256
+ end
257
+ end
258
+ result if depth.zero? && !quoted && !escaped
259
+ end
260
+ end
261
+ end