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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/{LICENSE.txt → LICENSE} +1 -1
  4. data/README.md +322 -46
  5. data/lib/predicator/ast.rb +74 -187
  6. data/lib/predicator/boundary.rb +237 -0
  7. data/lib/predicator/civil.rb +112 -0
  8. data/lib/predicator/duration_text.rb +92 -0
  9. data/lib/predicator/emitter/chains.rb +62 -0
  10. data/lib/predicator/emitter/statements.rb +86 -0
  11. data/lib/predicator/emitter.rb +187 -0
  12. data/lib/predicator/errors.rb +140 -0
  13. data/lib/predicator/evaluator/access.rb +108 -0
  14. data/lib/predicator/evaluator/arithmetic.rb +132 -0
  15. data/lib/predicator/evaluator/calls.rb +129 -0
  16. data/lib/predicator/evaluator/casts.rb +158 -0
  17. data/lib/predicator/evaluator/chronology.rb +88 -0
  18. data/lib/predicator/evaluator/compare.rb +158 -0
  19. data/lib/predicator/evaluator/json_functions.rb +167 -0
  20. data/lib/predicator/evaluator/json_scan.rb +303 -0
  21. data/lib/predicator/evaluator/library.rb +127 -0
  22. data/lib/predicator/evaluator/math_functions.rb +72 -0
  23. data/lib/predicator/evaluator/order.rb +120 -0
  24. data/lib/predicator/evaluator/rich_types.rb +146 -0
  25. data/lib/predicator/evaluator/statements.rb +151 -0
  26. data/lib/predicator/evaluator.rb +391 -117
  27. data/lib/predicator/float_text.rb +94 -0
  28. data/lib/predicator/iso_text.rb +99 -0
  29. data/lib/predicator/lexer/literals.rb +115 -0
  30. data/lib/predicator/lexer/numbers.rb +91 -0
  31. data/lib/predicator/lexer.rb +194 -0
  32. data/lib/predicator/parser/durations.rb +108 -0
  33. data/lib/predicator/parser/operators.rb +86 -0
  34. data/lib/predicator/parser/postfix.rb +57 -0
  35. data/lib/predicator/parser/primaries.rb +122 -0
  36. data/lib/predicator/parser/statements.rb +114 -0
  37. data/lib/predicator/parser/token_text.rb +52 -0
  38. data/lib/predicator/parser.rb +217 -438
  39. data/lib/predicator/result.rb +46 -0
  40. data/lib/predicator/tagged/reader.rb +139 -0
  41. data/lib/predicator/tagged/writer.rb +106 -0
  42. data/lib/predicator/tagged.rb +134 -0
  43. data/lib/predicator/values.rb +147 -0
  44. data/lib/predicator/version.rb +4 -1
  45. data/lib/predicator.rb +384 -14
  46. metadata +53 -147
  47. data/.gitignore +0 -11
  48. data/.rubocop.yml +0 -46
  49. data/.travis.yml +0 -5
  50. data/CODE_OF_CONDUCT.md +0 -49
  51. data/Gemfile +0 -4
  52. data/HISTORY.md +0 -51
  53. data/Rakefile +0 -24
  54. data/lib/predicator/context.rb +0 -18
  55. data/lib/predicator/lexer.rex +0 -69
  56. data/lib/predicator/lexer.rex.rb +0 -187
  57. data/lib/predicator/parser.y +0 -120
  58. data/lib/predicator/visitors/dot.rb +0 -113
  59. data/lib/predicator/visitors/each.rb +0 -16
  60. data/lib/predicator/visitors/instructions.rb +0 -183
  61. data/lib/predicator/visitors/string.rb +0 -76
  62. data/lib/predicator/visitors/visitor.rb +0 -100
  63. data/lib/predicator/visitors.rb +0 -5
  64. 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
@@ -1,3 +1,6 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Predicator
2
- VERSION = "1.2.1"
4
+ # The gem's version.
5
+ VERSION = "2.0.0"
3
6
  end