predicator 1.2.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -0
- data/{LICENSE.txt → LICENSE} +1 -1
- data/README.md +322 -46
- data/lib/predicator/ast.rb +74 -187
- data/lib/predicator/boundary.rb +237 -0
- data/lib/predicator/civil.rb +112 -0
- data/lib/predicator/duration_text.rb +92 -0
- data/lib/predicator/emitter/chains.rb +62 -0
- data/lib/predicator/emitter/statements.rb +86 -0
- data/lib/predicator/emitter.rb +187 -0
- data/lib/predicator/errors.rb +140 -0
- data/lib/predicator/evaluator/access.rb +108 -0
- data/lib/predicator/evaluator/arithmetic.rb +132 -0
- data/lib/predicator/evaluator/calls.rb +129 -0
- data/lib/predicator/evaluator/casts.rb +158 -0
- data/lib/predicator/evaluator/chronology.rb +88 -0
- data/lib/predicator/evaluator/compare.rb +158 -0
- data/lib/predicator/evaluator/json_functions.rb +167 -0
- data/lib/predicator/evaluator/json_scan.rb +303 -0
- data/lib/predicator/evaluator/library.rb +127 -0
- data/lib/predicator/evaluator/math_functions.rb +72 -0
- data/lib/predicator/evaluator/order.rb +120 -0
- data/lib/predicator/evaluator/rich_types.rb +146 -0
- data/lib/predicator/evaluator/statements.rb +151 -0
- data/lib/predicator/evaluator.rb +391 -117
- data/lib/predicator/float_text.rb +94 -0
- data/lib/predicator/iso_text.rb +99 -0
- data/lib/predicator/lexer/literals.rb +115 -0
- data/lib/predicator/lexer/numbers.rb +91 -0
- data/lib/predicator/lexer.rb +194 -0
- data/lib/predicator/parser/durations.rb +108 -0
- data/lib/predicator/parser/operators.rb +86 -0
- data/lib/predicator/parser/postfix.rb +57 -0
- data/lib/predicator/parser/primaries.rb +122 -0
- data/lib/predicator/parser/statements.rb +114 -0
- data/lib/predicator/parser/token_text.rb +52 -0
- data/lib/predicator/parser.rb +217 -438
- data/lib/predicator/result.rb +46 -0
- data/lib/predicator/tagged/reader.rb +139 -0
- data/lib/predicator/tagged/writer.rb +106 -0
- data/lib/predicator/tagged.rb +134 -0
- data/lib/predicator/values.rb +147 -0
- data/lib/predicator/version.rb +4 -1
- data/lib/predicator.rb +384 -14
- metadata +53 -147
- data/.gitignore +0 -11
- data/.rubocop.yml +0 -46
- data/.travis.yml +0 -5
- data/CODE_OF_CONDUCT.md +0 -49
- data/Gemfile +0 -4
- data/HISTORY.md +0 -51
- data/Rakefile +0 -24
- data/lib/predicator/context.rb +0 -18
- data/lib/predicator/lexer.rex +0 -69
- data/lib/predicator/lexer.rex.rb +0 -187
- data/lib/predicator/parser.y +0 -120
- data/lib/predicator/visitors/dot.rb +0 -113
- data/lib/predicator/visitors/each.rb +0 -16
- data/lib/predicator/visitors/instructions.rb +0 -183
- data/lib/predicator/visitors/string.rb +0 -76
- data/lib/predicator/visitors/visitor.rb +0 -100
- data/lib/predicator/visitors.rb +0 -5
- data/predicator.gemspec +0 -34
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Predicator
|
|
4
|
+
module Tagged
|
|
5
|
+
# Text to value: the decoding half of the codec.
|
|
6
|
+
# A private constant of Tagged: its functions are reached only through
|
|
7
|
+
# Tagged.decode and Tagged.encode, which document what they answer.
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
module Reader
|
|
11
|
+
DURATION_UNITS = %w[years months weeks days hours minutes seconds].freeze
|
|
12
|
+
DATE_TEXT = /\A(\d{4})-(\d{2})-(\d{2})\z/
|
|
13
|
+
DATETIME_TEXT = /\A(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:[.,](\d+))?(Z|[+-]\d{2}(?::?\d{2})?)\z/
|
|
14
|
+
OFFSET_TEXT = /\A([+-])(\d{2}):?(\d{2})?\z/
|
|
15
|
+
READERS = { "date" => :read_date, "datetime" => :read_datetime, "duration" => :read_duration }.freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def read(text)
|
|
20
|
+
utf8 = text.encoding == Encoding::UTF_8 ? text : text.dup.force_encoding(Encoding::UTF_8)
|
|
21
|
+
Refused.raise!("malformed_json", "the text is not valid UTF-8") unless utf8.valid_encoding?
|
|
22
|
+
value(parse(utf8), 1)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# JSON.parse keeps the written form of a number: `1` is an Integer and
|
|
26
|
+
# `1.0` and `1e2` are Floats. Only an exponent too large for a Float
|
|
27
|
+
# reads as an infinity, which value refuses. Its own nesting limit only
|
|
28
|
+
# bounds the parser's recursion (how it counts differs between json
|
|
29
|
+
# versions); value counts the levels exactly.
|
|
30
|
+
def parse(text)
|
|
31
|
+
JSON.parse(text, max_nesting: DEPTH_LIMIT + 2)
|
|
32
|
+
rescue JSON::NestingError => e
|
|
33
|
+
Refused.raise!("depth_limit_exceeded", e.message)
|
|
34
|
+
rescue JSON::ParserError => e
|
|
35
|
+
Refused.raise!("malformed_json", e.message)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def value(json, depth)
|
|
39
|
+
case json
|
|
40
|
+
when Hash then map(json, depth)
|
|
41
|
+
when Array then deep(depth) && json.map { |member| value(member, depth + 1) }
|
|
42
|
+
when Float then finite(json)
|
|
43
|
+
else json
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def map(json, depth)
|
|
48
|
+
deep(depth)
|
|
49
|
+
return tag(json, depth) if json.key?("$type")
|
|
50
|
+
|
|
51
|
+
json.transform_values { |member| value(member, depth + 1) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def finite(number)
|
|
55
|
+
return number if number.finite?
|
|
56
|
+
|
|
57
|
+
Refused.raise!("non_finite_number", "the number #{number} is not finite")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def deep(depth)
|
|
61
|
+
return true if depth <= DEPTH_LIMIT
|
|
62
|
+
|
|
63
|
+
Refused.raise!("depth_limit_exceeded", "the text nests past #{DEPTH_LIMIT} levels")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A duration's value is a map one level inside its tag's braces.
|
|
67
|
+
def tag(json, depth)
|
|
68
|
+
return UNDEFINED if json == { "$type" => "undefined" }
|
|
69
|
+
|
|
70
|
+
reader = READERS[json["$type"]]
|
|
71
|
+
deep(depth + 1) if reader == :read_duration
|
|
72
|
+
decoded = send(reader, json["value"]) if reader && json.keys.sort == ["$type", "value"]
|
|
73
|
+
decoded || Refused.raise!("invalid_tagged_value",
|
|
74
|
+
"a #{json["$type"].inspect} tag with the keys #{json.keys.sort.inspect} " \
|
|
75
|
+
"is not a tagged value")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def read_date(text)
|
|
79
|
+
match = text.is_a?(String) && DATE_TEXT.match(text)
|
|
80
|
+
return unless match
|
|
81
|
+
|
|
82
|
+
year, month, day = match.captures.map { |part| Integer(part, 10) }
|
|
83
|
+
Date.new(year:, month:, day:) if Civil.civil?(year, month, day)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Read as the reference's ISO parser reads one: a T or a space between
|
|
87
|
+
# the date and the time; a fraction after a full stop or a comma, of
|
|
88
|
+
# any count of digits (read to microseconds; the encoder writes none or
|
|
89
|
+
# six); and an offset of Z, +HH:MM, +HHMM or +HH (either sign) read as
|
|
90
|
+
# the instant it names. The one offset refused is the literal "-00:00";
|
|
91
|
+
# "-0000" and "-00" read as UTC, as the reference reads them. An offset
|
|
92
|
+
# that moves the instant outside the years 0 to 9999 is refused, since
|
|
93
|
+
# the encoder could not write it back.
|
|
94
|
+
def read_datetime(text)
|
|
95
|
+
match = text.is_a?(String) && DATETIME_TEXT.match(text)
|
|
96
|
+
return unless match
|
|
97
|
+
|
|
98
|
+
*parts, fraction, zone = match.captures
|
|
99
|
+
offset = read_offset(zone)
|
|
100
|
+
local = offset && local_instant(parts.map { |part| Integer(part, 10) }, fraction)
|
|
101
|
+
local && in_carried_years(local.with(epoch_seconds: local.epoch_seconds - offset))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def in_carried_years(instant)
|
|
105
|
+
instant if Civil.date_of(instant).year.between?(0, 9999)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def local_instant((year, month, day, hour, minute, second), fraction)
|
|
109
|
+
return unless Civil.civil?(year, month, day) && hour <= 23 && minute <= 59 && second <= 59
|
|
110
|
+
|
|
111
|
+
microsecond = Integer("#{fraction}000000"[0, 6], 10)
|
|
112
|
+
Civil.instant_at(Date.new(year:, month:, day:), hour, minute, second, microsecond:)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def read_offset(zone)
|
|
116
|
+
return 0 if zone == "Z"
|
|
117
|
+
return if zone == "-00:00"
|
|
118
|
+
|
|
119
|
+
sign, hours, minutes = OFFSET_TEXT.match(zone).captures
|
|
120
|
+
hours = hours.to_i # two digits, by OFFSET_TEXT
|
|
121
|
+
minutes = minutes.to_i # two digits, or absent and zero
|
|
122
|
+
seconds = (hours * 3600) + (minutes * 60)
|
|
123
|
+
return if hours > 23 || minutes > 59
|
|
124
|
+
|
|
125
|
+
sign == "-" ? -seconds : seconds
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The seven units are always written; milliseconds only when it is not
|
|
129
|
+
# zero, so an absent one reads as zero.
|
|
130
|
+
def read_duration(json)
|
|
131
|
+
return unless json.is_a?(Hash) && (DURATION_UNITS - json.keys).empty?
|
|
132
|
+
return unless (json.keys - DURATION_UNITS - ["milliseconds"]).empty? && json.values.all?(Integer)
|
|
133
|
+
|
|
134
|
+
Duration.new(**json.transform_keys(&:to_sym))
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
private_constant :Reader
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Predicator
|
|
4
|
+
module Tagged
|
|
5
|
+
# Value to text: the encoding half of the codec.
|
|
6
|
+
# A private constant of Tagged: its functions are reached only through
|
|
7
|
+
# Tagged.decode and Tagged.encode, which document what they answer.
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
module Writer
|
|
11
|
+
DURATION_UNITS = %w[years months weeks days hours minutes seconds].freeze
|
|
12
|
+
LITERALS = { nil => "null", true => "true", false => "false" }.freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def write(value) = text(value, 1, {}.compare_by_identity)
|
|
17
|
+
|
|
18
|
+
def text(value, depth, ancestors)
|
|
19
|
+
return LITERALS.fetch(value) if LITERALS.key?(value)
|
|
20
|
+
|
|
21
|
+
case value
|
|
22
|
+
when Integer then value.to_s
|
|
23
|
+
when Float then float(value)
|
|
24
|
+
when String then string(value)
|
|
25
|
+
when Array, Hash then container(value, depth, ancestors)
|
|
26
|
+
else tag(value, depth)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def float(value)
|
|
31
|
+
Refused.raise!("non_finite_number", "the float #{value} is not finite") unless value.finite?
|
|
32
|
+
value.to_s
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A String in another encoding is written as its UTF-8 transcoding; one
|
|
36
|
+
# that is not valid in its own encoding, or has no UTF-8 form, is not a
|
|
37
|
+
# predicator string.
|
|
38
|
+
def string(value)
|
|
39
|
+
utf8 = value.valid_encoding? ? value.encode(Encoding::UTF_8) : nil
|
|
40
|
+
JSON.generate(utf8 || Refused.raise!("unsupported_value", "#{value.inspect} has no UTF-8 form"))
|
|
41
|
+
rescue EncodingError
|
|
42
|
+
Refused.raise!("unsupported_value", "#{value.inspect} has no UTF-8 form")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def container(value, depth, ancestors)
|
|
46
|
+
deep(depth)
|
|
47
|
+
Refused.raise!("cyclic_value", "the value contains itself") if ancestors.key?(value)
|
|
48
|
+
|
|
49
|
+
ancestors[value] = true
|
|
50
|
+
written = value.is_a?(Array) ? list(value, depth, ancestors) : map(value, depth, ancestors)
|
|
51
|
+
ancestors.delete(value)
|
|
52
|
+
written
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def deep(depth)
|
|
56
|
+
Refused.raise!("depth_limit_exceeded", "the value nests past #{DEPTH_LIMIT} levels") if depth > DEPTH_LIMIT
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def list(list, depth, ancestors)
|
|
60
|
+
"[#{list.map { |member| text(member, depth + 1, ancestors) }.join(",")}]"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def map(map, depth, ancestors)
|
|
64
|
+
Refused.raise!("reserved_map_key", "a map with a \"$type\" key would read back as a tag") if map.key?("$type")
|
|
65
|
+
|
|
66
|
+
members = map.map do |key, member|
|
|
67
|
+
Refused.raise!("unsupported_value", "a map key is a String, not #{key.inspect}") unless key.is_a?(String)
|
|
68
|
+
"#{string(key)}:#{text(member, depth + 1, ancestors)}"
|
|
69
|
+
end
|
|
70
|
+
"{#{members.join(",")}}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A tag is one level of braces; a duration's value is a second.
|
|
74
|
+
def tag(value, depth)
|
|
75
|
+
written = tag_text(value)
|
|
76
|
+
deep(depth + (value.is_a?(Duration) ? 1 : 0))
|
|
77
|
+
written
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def tag_text(value)
|
|
81
|
+
case value
|
|
82
|
+
when Undefined then '{"$type":"undefined"}'
|
|
83
|
+
when Date then %({"$type":"date","value":"#{carried(value, value.year)}"})
|
|
84
|
+
when DateTime then %({"$type":"datetime","value":"#{carried(value, Civil.date_of(value).year)}"})
|
|
85
|
+
when Duration then duration(value)
|
|
86
|
+
else Refused.raise!("unsupported_value", "#{value.inspect} is not a predicator value")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The encoding carries four-digit years; a date or an instant outside
|
|
91
|
+
# them has no text that reads back as itself.
|
|
92
|
+
def carried(value, year)
|
|
93
|
+
return value.to_s if year.between?(0, 9999)
|
|
94
|
+
|
|
95
|
+
Refused.raise!("invalid_tagged_value", "#{value.inspect} is outside the years 0 to 9999 the encoding carries")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def duration(value)
|
|
99
|
+
units = DURATION_UNITS.map { |unit| %("#{unit}":#{value.public_send(unit)}) }
|
|
100
|
+
units << %("milliseconds":#{value.milliseconds}) unless value.milliseconds.zero?
|
|
101
|
+
%({"$type":"duration","value":{#{units.join(",")}}})
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
private_constant :Writer
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "values"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "result"
|
|
7
|
+
|
|
8
|
+
module Predicator
|
|
9
|
+
# A codec for the conformance corpus's tagged-value encoding, loaded with
|
|
10
|
+
# `require "predicator/tagged"` and by nothing else in the package.
|
|
11
|
+
#
|
|
12
|
+
# JSON carries most of predicator's values as themselves: integers, floats,
|
|
13
|
+
# strings, booleans, lists, maps (objects with String keys) and null (Ruby's
|
|
14
|
+
# nil). Four do not fit, and the encoding carries each as an object with a
|
|
15
|
+
# `$type` key:
|
|
16
|
+
#
|
|
17
|
+
# {"$type":"date","value":"2026-08-06"} a Predicator::Date
|
|
18
|
+
# {"$type":"datetime","value":"2026-08-06T12:00:00Z"} a Predicator::DateTime
|
|
19
|
+
# {"$type":"duration","value":{"years":0,...,"seconds":0}} a Predicator::Duration
|
|
20
|
+
# {"$type":"undefined"} Predicator::UNDEFINED
|
|
21
|
+
#
|
|
22
|
+
# The encoding is the corpus's apparatus, specified by predicator-ex's
|
|
23
|
+
# conformance/README.md at the tag conformance/SOURCE.json names. It is
|
|
24
|
+
# offered here for a host that persists instructions or values as JSON; it
|
|
25
|
+
# is not part of what conformance means.
|
|
26
|
+
#
|
|
27
|
+
# Both directions answer a {Predicator::Result}, a failed one carrying a
|
|
28
|
+
# {CodecError}, whatever the text or the value holds (decode raises
|
|
29
|
+
# ArgumentError only when it is handed something other than a String);
|
|
30
|
+
# {decode!} and {encode!} raise {Predicator::Error} instead. An
|
|
31
|
+
# integer stays an Integer and a float a Float across a round trip: `1`
|
|
32
|
+
# decodes to 1 and `1.0` to 1.0, and 1.0 encodes as `1.0`.
|
|
33
|
+
module Tagged
|
|
34
|
+
# The deepest a text may nest its brackets and braces, 100 levels, a
|
|
35
|
+
# tag's own braces counted. Both directions hold to it, so every text
|
|
36
|
+
# {encode} writes is one {decode} reads.
|
|
37
|
+
DEPTH_LIMIT = 100
|
|
38
|
+
private_constant :DEPTH_LIMIT
|
|
39
|
+
|
|
40
|
+
# Why a text could not be decoded or a value encoded. Its reason is one
|
|
41
|
+
# of these tokens:
|
|
42
|
+
#
|
|
43
|
+
# - "malformed_json": the text is not JSON (or not UTF-8)
|
|
44
|
+
# - "depth_limit_exceeded": the text or value nests past 100 levels
|
|
45
|
+
# - "non_finite_number": a number that is not finite
|
|
46
|
+
# - "invalid_tagged_value": an object with a `$type` key that is not one
|
|
47
|
+
# of the four tags, or a tag whose value is not what the tag carries;
|
|
48
|
+
# on encode, a date or a datetime outside the years the encoding carries
|
|
49
|
+
# - "reserved_map_key": on encode, a map with a `$type` key, which would
|
|
50
|
+
# read back as a tag
|
|
51
|
+
# - "cyclic_value": on encode, a list or map that contains itself
|
|
52
|
+
# - "unsupported_value": on encode, something that is not a predicator
|
|
53
|
+
# value (a Symbol, a map key that is not a String, a String with no
|
|
54
|
+
# UTF-8 form, any other object)
|
|
55
|
+
#
|
|
56
|
+
# @!attribute [r] reason
|
|
57
|
+
# @return [String] the token
|
|
58
|
+
# @!attribute [r] message
|
|
59
|
+
# @return [String] a sentence for a person
|
|
60
|
+
CodecError = Data.define(:reason, :message) do
|
|
61
|
+
# @return [String] "CodecError"
|
|
62
|
+
def type = "CodecError"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Signals a refusal from inside a walk; never leaves this module.
|
|
66
|
+
class Refused < StandardError
|
|
67
|
+
attr_reader :reason
|
|
68
|
+
|
|
69
|
+
def initialize(reason, message)
|
|
70
|
+
@reason = reason
|
|
71
|
+
super(message)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.raise!(reason, message) = raise(new(reason, message))
|
|
75
|
+
end
|
|
76
|
+
private_constant :Refused
|
|
77
|
+
|
|
78
|
+
# Decodes a text in the tagged encoding into a predicator value.
|
|
79
|
+
#
|
|
80
|
+
# @example
|
|
81
|
+
# Predicator::Tagged.decode('{"x":{"$type":"undefined"}}').value
|
|
82
|
+
# # => {"x" => Predicator::UNDEFINED}
|
|
83
|
+
#
|
|
84
|
+
# @param text [String]
|
|
85
|
+
# @return [Result] the value, or a {CodecError}
|
|
86
|
+
# @raise [ArgumentError] when text is not a String
|
|
87
|
+
def self.decode(text)
|
|
88
|
+
raise ArgumentError, "decode takes a String, not #{text.class}" unless text.is_a?(String)
|
|
89
|
+
|
|
90
|
+
refusing { Reader.read(text) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# As {decode}, answering the value itself.
|
|
94
|
+
#
|
|
95
|
+
# @param text [String]
|
|
96
|
+
# @return [Object] the value
|
|
97
|
+
# @raise [Predicator::Error] carrying the {CodecError}
|
|
98
|
+
def self.decode!(text) = unwrap(decode(text))
|
|
99
|
+
|
|
100
|
+
# Encodes a predicator value as text in the tagged encoding.
|
|
101
|
+
#
|
|
102
|
+
# @example
|
|
103
|
+
# Predicator::Tagged.encode([1, 1.0, Predicator::Date.new(2026, 8, 6)]).value
|
|
104
|
+
# # => '[1,1.0,{"$type":"date","value":"2026-08-06"}]'
|
|
105
|
+
#
|
|
106
|
+
# @param value [Object]
|
|
107
|
+
# @return [Result] the text, or a {CodecError}
|
|
108
|
+
def self.encode(value) = refusing { Writer.write(value) }
|
|
109
|
+
|
|
110
|
+
# As {encode}, answering the text itself.
|
|
111
|
+
#
|
|
112
|
+
# @param value [Object]
|
|
113
|
+
# @return [String] the text
|
|
114
|
+
# @raise [Predicator::Error] carrying the {CodecError}
|
|
115
|
+
def self.encode!(value) = unwrap(encode(value))
|
|
116
|
+
|
|
117
|
+
def self.refusing
|
|
118
|
+
Results.ok(yield)
|
|
119
|
+
rescue Refused => e
|
|
120
|
+
Results.failure(CodecError.new(reason: e.reason, message: e.message))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def self.unwrap(result)
|
|
124
|
+
raise Error, result.error unless result.ok?
|
|
125
|
+
|
|
126
|
+
result.value
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private_class_method :refusing, :unwrap
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
require_relative "tagged/reader"
|
|
134
|
+
require_relative "tagged/writer"
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "civil"
|
|
4
|
+
|
|
5
|
+
module Predicator
|
|
6
|
+
# predicator's undefined: an absence, where no value was ever supplied. It
|
|
7
|
+
# is a value of its own and never Ruby's nil, which is predicator's null (a
|
|
8
|
+
# value that is present and empty); the two are distinct in the language.
|
|
9
|
+
# There is exactly one instance, {Predicator::UNDEFINED}, compared by
|
|
10
|
+
# identity. The class is private to the package; the instance is the
|
|
11
|
+
# value a host holds.
|
|
12
|
+
class Undefined
|
|
13
|
+
# @return [String] how the one instance prints
|
|
14
|
+
def inspect = "Predicator::UNDEFINED"
|
|
15
|
+
alias to_s inspect
|
|
16
|
+
|
|
17
|
+
# The one instance is its own copy, so identity survives a dup or clone.
|
|
18
|
+
# @return [Undefined] self
|
|
19
|
+
def dup = self
|
|
20
|
+
|
|
21
|
+
# @return [Undefined] self
|
|
22
|
+
def clone(**) = self
|
|
23
|
+
|
|
24
|
+
private_class_method :new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The one {Undefined} instance.
|
|
28
|
+
UNDEFINED = Undefined.send(:new).freeze
|
|
29
|
+
private_constant :Undefined
|
|
30
|
+
|
|
31
|
+
# A civil date: a year, a month and a day on the proleptic Gregorian
|
|
32
|
+
# calendar, with no time of day and no zone. It is an immutable value,
|
|
33
|
+
# equal to and hashed like any other date with the same three parts.
|
|
34
|
+
#
|
|
35
|
+
# @!attribute [r] year
|
|
36
|
+
# @return [Integer] the year; any Integer, though the tagged encoding
|
|
37
|
+
# carries 0 to 9999 only
|
|
38
|
+
# @!attribute [r] month
|
|
39
|
+
# @return [Integer] the month, 1 to 12
|
|
40
|
+
# @!attribute [r] day
|
|
41
|
+
# @return [Integer] the day of the month, 1 to the month's length
|
|
42
|
+
Date = Data.define(:year, :month, :day) do
|
|
43
|
+
# @param year [Integer]
|
|
44
|
+
# @param month [Integer]
|
|
45
|
+
# @param day [Integer]
|
|
46
|
+
# @raise [ArgumentError] unless the three parts name a calendar date
|
|
47
|
+
def initialize(year:, month:, day:)
|
|
48
|
+
unless Civil.civil?(year, month, day)
|
|
49
|
+
raise ArgumentError, "#{[year, month, day].inspect} does not name a calendar date"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
super
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The date as ISO 8601 text, `YYYY-MM-DD`, for a year from 0 to 9999.
|
|
56
|
+
# Other years are written with as many digits as they take and a leading
|
|
57
|
+
# minus sign below zero, which ISO 8601 leaves to agreement.
|
|
58
|
+
#
|
|
59
|
+
# @return [String]
|
|
60
|
+
def to_s
|
|
61
|
+
digits = format("%04d", year.abs)
|
|
62
|
+
"#{"-" if year.negative?}#{digits}-#{format("%02d", month)}-#{format("%02d", day)}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [String]
|
|
66
|
+
def inspect = "#<Predicator::Date #{self}>"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# An instant in UTC: a whole number of seconds since 1970-01-01T00:00:00Z
|
|
70
|
+
# and a microsecond within that second, the sub-second precision the
|
|
71
|
+
# reference's ISA states for an instant. It is an immutable value, equal to
|
|
72
|
+
# and hashed like any other at the same instant.
|
|
73
|
+
#
|
|
74
|
+
# @!attribute [r] epoch_seconds
|
|
75
|
+
# @return [Integer] whole seconds since the epoch, negative before it
|
|
76
|
+
# @!attribute [r] microsecond
|
|
77
|
+
# @return [Integer] the microsecond of that second, 0 to 999999
|
|
78
|
+
DateTime = Data.define(:epoch_seconds, :microsecond) do
|
|
79
|
+
# @param epoch_seconds [Integer]
|
|
80
|
+
# @param microsecond [Integer] 0 to 999999
|
|
81
|
+
# @raise [ArgumentError] when a part is not an Integer in its range
|
|
82
|
+
def initialize(epoch_seconds:, microsecond: 0)
|
|
83
|
+
unless epoch_seconds.is_a?(Integer) && Civil.within?(microsecond, 999_999)
|
|
84
|
+
raise ArgumentError, "#{[epoch_seconds, microsecond].inspect} is not an instant"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
super
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The instant as canonical ISO 8601 text in UTC: the fraction left out
|
|
91
|
+
# when the microsecond is zero and exactly six digits when it is not
|
|
92
|
+
# (the form the tagged encoding and the reference's string cast both
|
|
93
|
+
# write).
|
|
94
|
+
#
|
|
95
|
+
# @return [String]
|
|
96
|
+
def to_s
|
|
97
|
+
fraction = microsecond.zero? ? "" : format(".%06d", microsecond)
|
|
98
|
+
format("%<date>sT%<hour>02d:%<minute>02d:%<second>02d%<fraction>sZ",
|
|
99
|
+
date: Civil.date_of(self), hour:, minute:, second:, fraction:)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @return [String]
|
|
103
|
+
def inspect = "#<Predicator::DateTime #{self}>"
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# The hour of the day, 0 to 23, the minute and the second, in UTC.
|
|
108
|
+
def hour = seconds_of_day / 3600
|
|
109
|
+
def minute = (seconds_of_day % 3600) / 60
|
|
110
|
+
def second = seconds_of_day % 60
|
|
111
|
+
|
|
112
|
+
def seconds_of_day = epoch_seconds % Civil::SECONDS_PER_DAY
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A duration: the eight units the reference's duration carries, every one
|
|
116
|
+
# present and defaulting to zero. The set never varies with the units an
|
|
117
|
+
# expression named. It is an immutable value.
|
|
118
|
+
#
|
|
119
|
+
# @!attribute [r] years
|
|
120
|
+
# @return [Integer]
|
|
121
|
+
# @!attribute [r] months
|
|
122
|
+
# @return [Integer]
|
|
123
|
+
# @!attribute [r] weeks
|
|
124
|
+
# @return [Integer]
|
|
125
|
+
# @!attribute [r] days
|
|
126
|
+
# @return [Integer]
|
|
127
|
+
# @!attribute [r] hours
|
|
128
|
+
# @return [Integer]
|
|
129
|
+
# @!attribute [r] minutes
|
|
130
|
+
# @return [Integer]
|
|
131
|
+
# @!attribute [r] seconds
|
|
132
|
+
# @return [Integer]
|
|
133
|
+
# @!attribute [r] milliseconds
|
|
134
|
+
# @return [Integer]
|
|
135
|
+
Duration = Data.define(:years, :months, :weeks, :days, :hours, :minutes, :seconds, :milliseconds) do
|
|
136
|
+
# Every unit given defaults to zero.
|
|
137
|
+
#
|
|
138
|
+
# @raise [ArgumentError] when a unit is not an Integer
|
|
139
|
+
def initialize(years: 0, months: 0, weeks: 0, days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 0)
|
|
140
|
+
units = { years:, months:, weeks:, days:, hours:, minutes:, seconds:, milliseconds: }
|
|
141
|
+
wrong = units.reject { |_, amount| amount.is_a?(Integer) }
|
|
142
|
+
raise ArgumentError, "a duration's units are Integers: #{wrong.inspect}" unless wrong.empty?
|
|
143
|
+
|
|
144
|
+
super(**units)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|