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
@@ -1,190 +1,77 @@
1
- module Predicator
2
- module AST
3
- class Node
4
- include Enumerable
5
-
6
- attr_accessor :left
7
-
8
- def initialize left
9
- @left = left
10
- end
11
-
12
- def each &block
13
- Visitors::Each.new(block).accept self
14
- end
15
-
16
- def to_dot
17
- Visitors::Dot.new.accept self
18
- end
19
-
20
- def to_instructions
21
- Visitors::Instructions.new.accept self
22
- end
23
-
24
- def to_s
25
- Visitors::String.new.accept self
26
- end
27
-
28
- def type
29
- raise NotImplementedError
30
- end
31
-
32
- def variable?; false; end
33
- def literal?; false; end
34
- end
35
-
36
- class Terminal < Node
37
- alias :symbol :left
38
- end
39
-
40
- class Literal < Terminal
41
- def type; :LITERAL; end
42
- def literal?; true; end
43
- end
44
-
45
- class Variable < Terminal
46
- def type; :VARIABLE; end
47
- def variable?; true; end
48
- end
49
-
50
- %w[ True False Integer String Date Duration Blank Present ].each do |t|
51
- class_eval <<-eoruby, __FILE__, __LINE__ + 1
52
- class #{t} < Literal;
53
- def type; :#{t.upcase}; end
54
- end
55
- eoruby
56
- end
57
-
58
- class Unary < Node
59
- def children; [left] end
60
- end
61
-
62
- class IntegerArray < Unary
63
- def type; :INTARRAY; end
64
- end
65
-
66
- class StringArray < Unary
67
- def type; :STRARRAY; end
68
- end
69
-
70
- class Not < Unary
71
- def type; :NOT; end
72
- end
73
-
74
- class Group < Unary
75
- def type; :GROUP; end
76
- end
77
-
78
- class DateFromNow < Unary
79
- def type; :DATEFROMNOW; end
80
- end
81
-
82
- class DateAgo < Unary
83
- def type; :DATEAGO; end
84
- end
85
-
86
- class Binary < Node
87
- attr_accessor :right
88
-
89
- def initialize left, right
90
- super left
91
- @right = right
92
- end
93
-
94
- def children; [left, right] end
95
- end
96
-
97
- class IntegerEqual < Binary
98
- def type; :INTEQ; end
99
- end
100
-
101
- class StringEqual < Binary
102
- def type; :STREQ; end
103
- end
104
-
105
- class DateEqual < Binary
106
- def type; :DATEQ; end
107
- end
108
-
109
- class IntegerGreaterThan < Binary
110
- def type; :INTGT; end
111
- end
112
-
113
- class StringGreaterThan < Binary
114
- def type; :STRGT; end
115
- end
116
-
117
- class DateGreaterThan < Binary
118
- def type; :DATGT; end
119
- end
120
-
121
- class IntegerLessThan < Binary
122
- def type; :INTLT; end
123
- end
124
-
125
- class StringLessThan < Binary
126
- def type; :STRLT; end
127
- end
128
-
129
- class DateLessThan < Binary
130
- def type; :DATLT; end
131
- end
132
-
133
- class IntegerIn < Binary
134
- def type; :INTIN; end
135
- end
136
-
137
- class StringIn < Binary
138
- def type; :STRIN; end
139
- end
140
-
141
- class IntegerNotIn < Binary
142
- def type; :INTNOTIN; end
143
- end
144
-
145
- class StringNotIn < Binary
146
- def type; :STRNOTIN; end
147
- end
148
-
149
- class StringStartsWith < Binary
150
- def type; :STRSTARTSWITH; end
151
- end
152
-
153
- class StringEndsWith < Binary
154
- def type; :STRENDSWITH; end
155
- end
156
-
157
- class And < Binary
158
- def type; :AND; end
159
- end
160
-
161
- class Or < Binary
162
- def type; :OR; end
163
- end
164
-
165
- class Ternary < Node
166
- attr_accessor :middle, :right
167
-
168
- def initialize left, middle, right
169
- super left
170
- @middle = middle
171
- @right = right
172
- end
173
-
174
- def children; [left, middle, right] end
175
- end
176
-
177
- class IntegerBetween < Ternary
178
- def type; :INTBETWEEN; end
179
- end
180
-
181
- class DateBetween < Ternary
182
- def type; :DATBETWEEN; end
183
- end
184
-
185
- class BooleanVariable < Unary
186
- def type; :BOOL; end
187
- end
1
+ # frozen_string_literal: true
188
2
 
3
+ module Predicator
4
+ # The syntax tree the parser builds: the reference's node kinds, with the
5
+ # reference's fields, as immutable values.
6
+ #
7
+ # Every node carries both of the reference's two kinds of source metadata,
8
+ # where the reference's parser carries one or the other per parse. The
9
+ # position is the {Predicator::Position} of the token that defines the
10
+ # node, which is not always its leftmost token: an operator node points at
11
+ # its operator, an access node at the property or the key rather than the
12
+ # punctuation before it, a cast at its type name, a relative date written
13
+ # after its duration at the word `ago` or `from`. The span is the source
14
+ # the node covers, a {Lexer::Span} with an exclusive end. A parenthesized
15
+ # expression keeps its node's position and widens its span to the
16
+ # parentheses.
17
+ #
18
+ # The kinds and their fields follow the reference's syntax tree:
19
+ #
20
+ # - {Literal}: a number, a boolean, null, undefined, a date or a datetime;
21
+ # its value is the predicator value ({Predicator::UNDEFINED}, nil, an
22
+ # Integer, a Float, a {Predicator::Date}, a {Predicator::DateTime}). A
23
+ # negative number is a {Unary} minus over a literal, never a negative
24
+ # literal.
25
+ # - {StringLiteral}: a string and the quote it was written with
26
+ # (`:double` or `:single`).
27
+ # - {Identifier}, {Comparison}, {Arithmetic}, {Membership}, {Unary},
28
+ # {LogicalAnd}, {LogicalOr}, {LogicalNot}, {ListLiteral},
29
+ # {ObjectLiteral} (whose entries are `[ObjectKey, node]` pairs),
30
+ # {FunctionCall}, {BracketAccess}, {PropertyAccess}, {Cast},
31
+ # {Duration} (whose units are `[amount, suffix]` pairs in the order
32
+ # written, a fraction already expanded into whole units) and
33
+ # {RelativeDate}.
34
+ # - The statement kinds a program holds: {Assignment}, {If} (an `else if`
35
+ # is an else {Block} holding one {If}, which borrows that `if`'s
36
+ # position and span), {While}, {Block} and {Program}. Any expression
37
+ # node also stands as a statement.
38
+ #
39
+ # The operators are the reference's: comparison `:gt`, `:lt`, `:gte`,
40
+ # `:lte`, `:equal_equal`, `:ne`, `:strict_eq`, `:strict_ne`; arithmetic
41
+ # `:add`, `:subtract`, `:multiply`, `:divide`, `:modulo`; membership
42
+ # `:in`, `:contains`; unary `:minus`, `:bang`; relative-date directions
43
+ # `:ago`, `:future` (written `from now`), `:next`, `:last`.
44
+ #
45
+ # The module is private to the package.
46
+ module Ast
47
+ # A node class with the fields given, then a position and a span.
48
+ def self.node(*fields) = Data.define(*fields, :position, :span)
49
+ private_class_method :node
50
+
51
+ Literal = node(:value)
52
+ StringLiteral = node(:value, :quote)
53
+ Identifier = node(:name)
54
+ Comparison = node(:operator, :left, :right)
55
+ Arithmetic = node(:operator, :left, :right)
56
+ Membership = node(:operator, :left, :right)
57
+ Unary = node(:operator, :operand)
58
+ LogicalAnd = node(:left, :right)
59
+ LogicalOr = node(:left, :right)
60
+ LogicalNot = node(:operand)
61
+ ListLiteral = node(:elements)
62
+ ObjectLiteral = node(:entries)
63
+ ObjectKey = node(:name, :style)
64
+ FunctionCall = node(:name, :arguments)
65
+ BracketAccess = node(:object, :key)
66
+ PropertyAccess = node(:object, :property)
67
+ Cast = node(:expression, :type_name)
68
+ Duration = node(:units)
69
+ RelativeDate = node(:duration, :direction)
70
+ Assignment = node(:target, :value)
71
+ Block = node(:statements)
72
+ If = node(:condition, :then_block, :else_block)
73
+ While = node(:condition, :body)
74
+ Program = node(:statements)
189
75
  end
76
+ private_constant :Ast
190
77
  end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "values"
4
+ require_relative "errors"
5
+
6
+ module Predicator
7
+ # The value boundary: where a value a host hands in (a context, a literal
8
+ # operand of an instruction list it built, the answer of one of its
9
+ # functions) is checked against the value domain before an evaluation
10
+ # holds it, and where an argument handed to a host function is copied.
11
+ #
12
+ # The domain is nil, true and false, an Integer, a finite Float, a String
13
+ # in UTF-8 (or US-ASCII) that is valid in its encoding, an Array of
14
+ # values, a Hash of keys to values, {Predicator::UNDEFINED}, a
15
+ # {Predicator::Date}, a {Predicator::DateTime} and a {Predicator::Duration}.
16
+ # A map key is a String, an Integer, true, false, nil or
17
+ # {Predicator::UNDEFINED}, the keys a bracket access reads; a key of the
18
+ # context itself, the name a load reads, is a String.
19
+ # A value outside it is refused with an {EvaluationError}, never raised,
20
+ # under one of these reasons:
21
+ #
22
+ # - "unsupported_host_value": a value the domain has no member for (a
23
+ # Symbol, a Time, any other object), a map key outside the keys above,
24
+ # or a String that is not valid UTF-8 text;
25
+ # - "non_finite_number": a Float that is not finite;
26
+ # - "cyclic_value": a list or a map that contains itself;
27
+ # - "depth_limit_exceeded": lists and maps nested past {DEPTH_LIMIT}
28
+ # levels along any path, the outermost counting as the first, a list or
29
+ # map shared by several paths measured along each.
30
+ #
31
+ # The walks are iterative, so a value deeper than the stack is refused
32
+ # rather than exhausting it, and each list or map is walked once however
33
+ # many paths reach it. The module holds no state. It is private to the
34
+ # package.
35
+ module Boundary
36
+ # How deep lists and maps may nest in a value that crosses the boundary:
37
+ # 100 levels, the bound the tagged codec holds a text to.
38
+ DEPTH_LIMIT = 100
39
+
40
+ # The String encodings a String in the domain may carry.
41
+ TEXT_ENCODINGS = [Encoding::UTF_8, Encoding::US_ASCII].freeze
42
+
43
+ # The keys a map may have besides a String and an Integer.
44
+ MAP_KEY_ATOMS = [true, false, nil, UNDEFINED].freeze
45
+
46
+ module_function
47
+
48
+ # The refusal of what an evaluation is handed, or nil when all of it is
49
+ # in the domain: the context's keys, then its values, then every `lit`
50
+ # operand of the instruction list, in order.
51
+ #
52
+ # @param instructions [Array] the instruction list
53
+ # @param context [Hash] the context
54
+ # @return [EvaluationError, nil]
55
+ def input_refusal(instructions, context)
56
+ root_refusal(context) || refusal(context, "the context") ||
57
+ instructions.each_with_index.lazy.filter_map do |instruction, at|
58
+ next unless instruction.is_a?(Array) && instruction.first == "lit" && instruction.size == 2
59
+
60
+ refusal(instruction.last, "the literal at index #{at}")
61
+ end.first
62
+ end
63
+
64
+ # The refusal of a context key that is not a String, or nil.
65
+ def root_refusal(context)
66
+ key = context.each_key.find { |name| !name.is_a?(String) }
67
+ return unless key || context.key?(key)
68
+
69
+ EvaluationError.new(reason: "unsupported_host_value",
70
+ message: "the context has the key #{key.inspect}, which is not a String")
71
+ end
72
+
73
+ # The refusal of a value, or nil when it is in the domain.
74
+ #
75
+ # @param value [Object]
76
+ # @param subject [String] what the value is, for the message
77
+ # @return [EvaluationError, nil]
78
+ def refusal(value, subject)
79
+ reason, detail = fault(value)
80
+ EvaluationError.new(reason:, message: "#{subject} #{detail}") if reason
81
+ end
82
+
83
+ # The first fault of a value, as a reason and a phrase, or nil.
84
+ def fault(value) = Check.new.fault(value)
85
+
86
+ # The fault of a value that is not a list or a map, or nil.
87
+ def leaf_fault(value)
88
+ case value
89
+ when nil, true, false, Integer, Predicator::Date, Predicator::DateTime, Duration, Array, Hash then nil
90
+ when Float then ["non_finite_number", "holds the float #{value}, which is not finite"] unless value.finite?
91
+ when String then text_fault(value)
92
+ else ["unsupported_host_value", "holds #{value.inspect}, which is not a predicator value"] \
93
+ unless UNDEFINED.equal?(value)
94
+ end
95
+ end
96
+
97
+ def text_fault(text)
98
+ return if TEXT_ENCODINGS.include?(text.encoding) && text.valid_encoding?
99
+
100
+ ["unsupported_host_value", "holds a String that is not valid UTF-8 text"]
101
+ end
102
+
103
+ # The fault of a map's first key outside the keys a map may have, or
104
+ # nil.
105
+ def key_fault(map)
106
+ map.each_key do |key|
107
+ next if key.is_a?(Integer) || MAP_KEY_ATOMS.any? { |atom| atom.equal?(key) }
108
+ return ["unsupported_host_value", "has the key #{key.inspect}, which is not a map key"] unless key.is_a?(String)
109
+
110
+ found = text_fault(key)
111
+ return found if found
112
+ end
113
+ nil
114
+ end
115
+
116
+ def container?(value) = value.is_a?(Array) || value.is_a?(Hash)
117
+ def members(container) = container.is_a?(Hash) ? container.values : container
118
+
119
+ # A copy of a value in the domain that shares no list, map or String
120
+ # with it, so that whatever is done to the copy leaves the value as it
121
+ # was. Every other member of the domain is immutable and is shared. A
122
+ # list or a map reached by two paths is copied once, and the walk is
123
+ # iterative.
124
+ #
125
+ # @param value [Object] a value in the domain, with no cycle
126
+ # @return [Object] the copy
127
+ def copy(value) = container?(value) ? Copy.new.copy(value) : copy_text(value)
128
+
129
+ def copy_text(value) = value.is_a?(String) ? value.dup : value
130
+
131
+ # One check of one value: a depth-first walk over an explicit stack. A
132
+ # list or a map is :open, on the path, from when its members are queued
133
+ # until the marker queued beneath them is reached; meeting an open one
134
+ # again is a cycle. From then on it holds its height, the levels it
135
+ # nests including itself, and is not walked again: a path that reaches
136
+ # it at a depth where depth plus height passes the limit by one is
137
+ # refused, so the limit holds along every path however much of the value
138
+ # is shared.
139
+ class Check
140
+ def initialize
141
+ @state = {}.compare_by_identity
142
+ @work = []
143
+ end
144
+
145
+ def fault(root)
146
+ @work.push([root, 1])
147
+ until @work.empty?
148
+ value, depth = @work.pop
149
+ next leave(value) if depth == :leave
150
+
151
+ found = Boundary.leaf_fault(value) || container_fault(value, depth)
152
+ return found if found
153
+ end
154
+ nil
155
+ end
156
+
157
+ private
158
+
159
+ # Marks a container walked, with its height: one more than its
160
+ # tallest member's, a member that is not a list or a map counting 0.
161
+ def leave(container)
162
+ tallest = Boundary.members(container).map { |member| Boundary.container?(member) ? @state[member] : 0 }.max
163
+ @state[container] = 1 + (tallest || 0)
164
+ end
165
+
166
+ # The fault of a list or a map, or nil having queued its members; nil
167
+ # for any other value and for one already walked whose height fits.
168
+ def container_fault(value, depth)
169
+ return unless Boundary.container?(value)
170
+
171
+ state = @state[value]
172
+ return state_fault(state, depth) if state
173
+
174
+ return too_deep if depth > DEPTH_LIMIT
175
+
176
+ (value.is_a?(Hash) && Boundary.key_fault(value)) || enter(value, depth)
177
+ end
178
+
179
+ # The fault of meeting a container again: a cycle while it is open,
180
+ # too deep when its height from this depth passes the limit.
181
+ def state_fault(state, depth)
182
+ return ["cyclic_value", "holds a list or a map that contains itself"] if state == :open
183
+
184
+ too_deep if depth + state - 1 > DEPTH_LIMIT
185
+ end
186
+
187
+ def too_deep = ["depth_limit_exceeded", "nests past #{DEPTH_LIMIT} levels"]
188
+
189
+ def enter(container, depth)
190
+ @state[container] = :open
191
+ @work.push([container, :leave])
192
+ Boundary.members(container).each { |member| @work.push([member, depth + 1]) }
193
+ nil
194
+ end
195
+ end
196
+ private_constant :Check
197
+
198
+ # One copy of one list or map: a depth-first walk over an explicit
199
+ # stack that builds each container after its members.
200
+ class Copy
201
+ def initialize
202
+ @copies = {}.compare_by_identity
203
+ @work = []
204
+ end
205
+
206
+ def copy(root)
207
+ @work.push([root, false])
208
+ until @work.empty?
209
+ held, members_built = @work.pop
210
+ next @copies[held] = build(held) if members_built
211
+
212
+ enter(held) unless @copies.key?(held)
213
+ end
214
+ @copies[root]
215
+ end
216
+
217
+ private
218
+
219
+ def enter(container)
220
+ @copies[container] = nil
221
+ @work.push([container, true])
222
+ Boundary.members(container).each { |member| @work.push([member, false]) if Boundary.container?(member) }
223
+ end
224
+
225
+ # The copy of one list or map, its members' copies already built.
226
+ def build(container)
227
+ return container.map { |member| take(member) } if container.is_a?(Array)
228
+
229
+ container.to_h { |key, member| [key.dup, take(member)] }
230
+ end
231
+
232
+ def take(member) = @copies.key?(member) ? @copies[member] : Boundary.copy_text(member)
233
+ end
234
+ private_constant :Copy
235
+ end
236
+ private_constant :Boundary
237
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Predicator
4
+ # Calendar arithmetic on the proleptic Gregorian calendar, written by hand so
5
+ # that the package needs no date library: a civil date (year, month, day) to
6
+ # a count of days since 1970-01-01 and back, and the tests built on them.
7
+ #
8
+ # Every function but the four that convert a {Predicator::Date} or a
9
+ # {Predicator::DateTime} takes and answers Integers. The algorithms are the era-based ones published by
10
+ # Howard Hinnant ("chrono-Compatible Low-Level Date Algorithms"), exact for
11
+ # every Integer year, so a year before 1 or after 9999 converts as well as
12
+ # any other.
13
+ #
14
+ # The module holds no state. It is private to the package: the value classes
15
+ # in values.rb, the evaluator and the codec in tagged.rb are its callers.
16
+ module Civil
17
+ # Seconds in a civil day; an instant's day is its epoch second divided by
18
+ # this, rounded toward the past.
19
+ SECONDS_PER_DAY = 86_400
20
+
21
+ # The days in each month of a year that is not a leap year.
22
+ MONTH_LENGTHS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze
23
+
24
+ module_function
25
+
26
+ # The count of days from 1970-01-01 to the civil date year-month-day,
27
+ # negative before it. month and day are taken as given; a caller wanting
28
+ # to know they name a date asks civil?.
29
+ def days_from_civil(year, month, day)
30
+ shifted = month <= 2 ? year - 1 : year
31
+ era = shifted.div(400)
32
+ (era * 146_097) + day_of_era(shifted - (era * 400), day_of_year(month, day)) - 719_468
33
+ end
34
+
35
+ # The civil date days after 1970-01-01, as [year, month, day].
36
+ def civil_from_days(days)
37
+ shifted = days + 719_468
38
+ era = shifted.div(146_097)
39
+ era_day = shifted - (era * 146_097)
40
+ era_year = year_of_era(era_day)
41
+ month, day = month_and_day(era_day - day_of_era(era_year, 0))
42
+ [era_year + (era * 400) + (month <= 2 ? 1 : 0), month, day]
43
+ end
44
+
45
+ # The day of a year that starts on March 1st.
46
+ def day_of_year(month, day) = (((153 * (month + (month > 2 ? -3 : 9))) + 2) / 5) + day - 1
47
+
48
+ # The day of a 400-year era, from the year of the era and the day of
49
+ # that (March-based) year.
50
+ def day_of_era(year_of_era, day_of_year)
51
+ (year_of_era * 365) + (year_of_era / 4) - (year_of_era / 100) + day_of_year
52
+ end
53
+
54
+ # The year of a 400-year era a day of the era falls in.
55
+ def year_of_era(day_of_era)
56
+ (day_of_era - (day_of_era / 1460) + (day_of_era / 36_524) - (day_of_era / 146_096)) / 365
57
+ end
58
+
59
+ # The civil month and day of a day of a March-based year.
60
+ def month_and_day(day_of_year)
61
+ month_from_march = ((5 * day_of_year) + 2) / 153
62
+ day = day_of_year - (((153 * month_from_march) + 2) / 5) + 1
63
+ [month_from_march + (month_from_march < 10 ? 3 : -9), day]
64
+ end
65
+
66
+ # Whether part is an Integer from 0 to top.
67
+ def within?(part, top) = part.is_a?(Integer) && part.between?(0, top)
68
+
69
+ # Whether year, month and day are Integers naming a date on the calendar:
70
+ # a month from 1 to 12 and a day that month has in that year. It reads
71
+ # the month lengths and the leap-year rule, not the day count, so the two
72
+ # conversions above are checked against it rather than by it.
73
+ def civil?(year, month, day)
74
+ return false unless [year, month, day].all?(Integer) && month.between?(1, 12)
75
+
76
+ day.between?(1, days_in_month(year, month))
77
+ end
78
+
79
+ # The {Predicator::Date} days after 1970-01-01.
80
+ def date_of_days(days) = Predicator::Date.new(*civil_from_days(days))
81
+
82
+ # The count of days from 1970-01-01 to a {Predicator::Date}, negative
83
+ # before it.
84
+ def days_of(date) = days_from_civil(date.year, date.month, date.day)
85
+
86
+ # The {Predicator::DateTime} at a {Predicator::Date} and a time of day,
87
+ # in UTC.
88
+ def instant_at(date, hour = 0, minute = 0, second = 0, microsecond: 0)
89
+ clock = [hour, minute, second]
90
+ unless date.is_a?(Predicator::Date) && clock.zip([23, 59, 59]).all? { |part, top| within?(part, top) }
91
+ raise ArgumentError, "#{date.inspect} at #{clock.inspect} does not name a time of day on a date"
92
+ end
93
+
94
+ seconds = (days_of(date) * SECONDS_PER_DAY) + (hour * 3600) + (minute * 60) + second
95
+ Predicator::DateTime.new(epoch_seconds: seconds, microsecond:)
96
+ end
97
+
98
+ # The civil {Predicator::Date} of a {Predicator::DateTime}, in UTC.
99
+ def date_of(instant) = date_of_days(instant.epoch_seconds.div(SECONDS_PER_DAY))
100
+
101
+ # The days in a month of a year.
102
+ def days_in_month(year, month)
103
+ return 29 if month == 2 && leap?(year)
104
+
105
+ MONTH_LENGTHS[month - 1]
106
+ end
107
+
108
+ # Whether a year is a leap year on the proleptic Gregorian calendar.
109
+ def leap?(year) = (year % 4).zero? && (!(year % 100).zero? || (year % 400).zero?)
110
+ end
111
+ private_constant :Civil
112
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Predicator
4
+ # A duration as text in the language's duration-literal grammar, both
5
+ # ways, as the reference writes and reads it.
6
+ #
7
+ # Written, the units come largest first, each as its amount and its
8
+ # suffix (`y`, `mo`, `w`, `d`, `h`, `m`, `s`, `ms`); a unit that is not
9
+ # positive is left out, and a duration with nothing left to write is
10
+ # `0s`.
11
+ #
12
+ # Read, the whole text is one or more components, each digits, an
13
+ # optional fraction (a point and digits) and a suffix, with no sign, no
14
+ # space and no empty text; `mo` and `ms` are read before `m`. A repeated
15
+ # unit adds to what came before. A fraction is exact or refused: it is
16
+ # worth a whole number of milliseconds (a month 30 days, a year 365) or
17
+ # the text is not a duration, and that remainder is spent largest first
18
+ # through days, hours, minutes, seconds and milliseconds only, the whole
19
+ # part keeping its own unit.
20
+ #
21
+ # The module holds no state. It is private to the package.
22
+ module DurationText
23
+ # Each unit's suffix and its worth in milliseconds, largest unit first.
24
+ UNITS = {
25
+ years: ["y", 31_536_000_000], months: ["mo", 2_592_000_000], weeks: ["w", 604_800_000],
26
+ days: ["d", 86_400_000], hours: ["h", 3_600_000], minutes: ["m", 60_000], seconds: ["s", 1000],
27
+ milliseconds: ["ms", 1]
28
+ }.freeze
29
+
30
+ # The unit each suffix names.
31
+ SUFFIXES = UNITS.to_h { |unit, (suffix, _)| [suffix, unit] }.freeze
32
+
33
+ # The units a fraction's remainder is spent into.
34
+ REMAINDER_UNITS = %i[days hours minutes seconds milliseconds].freeze
35
+
36
+ # One component, and the whole text as a run of them.
37
+ COMPONENT = /([0-9]+)(?:\.([0-9]+))?(mo|ms|y|w|d|h|m|s)/
38
+ TEXT = /\A(?:[0-9]+(?:\.[0-9]+)?(?:mo|ms|y|w|d|h|m|s))+\z/
39
+
40
+ module_function
41
+
42
+ # @param duration [Duration]
43
+ # @return [String]
44
+ def write(duration)
45
+ parts = UNITS.filter_map do |unit, (suffix, _)|
46
+ amount = duration.public_send(unit)
47
+ "#{amount}#{suffix}" if amount.positive?
48
+ end
49
+ parts.empty? ? "0s" : parts.join
50
+ end
51
+
52
+ # @param text [String]
53
+ # @return [Duration, nil] the duration the text names, or nil when it
54
+ # names none
55
+ def read(text)
56
+ return unless TEXT.match?(text)
57
+
58
+ pairs = text.scan(COMPONENT).map { |whole, fraction, suffix| component(whole, fraction, suffix) }
59
+ return if pairs.include?(nil)
60
+
61
+ units = Hash.new(0)
62
+ pairs.flatten(1).each { |unit, amount| units[unit] += amount }
63
+ Duration.new(**units)
64
+ end
65
+
66
+ # The unit pairs one component is worth, or nil when its fraction is not
67
+ # a whole number of milliseconds.
68
+ def component(whole, fraction, suffix)
69
+ whole = Integer(whole, 10)
70
+ unit = SUFFIXES.fetch(suffix)
71
+ fraction ? expand(whole, fraction, unit) : [[unit, whole]]
72
+ end
73
+
74
+ # The unit pairs a component with a fraction is worth, or nil when the
75
+ # fraction is not a whole number of milliseconds.
76
+ def expand(whole, fraction, unit)
77
+ worth = Integer(fraction, 10) * UNITS.fetch(unit).last
78
+ remainder, inexact = worth.divmod(10**fraction.size)
79
+ return unless inexact.zero?
80
+
81
+ [[unit, whole], *spend(remainder)]
82
+ end
83
+
84
+ def spend(milliseconds)
85
+ REMAINDER_UNITS.filter_map do |unit|
86
+ amount, milliseconds = milliseconds.divmod(UNITS.fetch(unit).last)
87
+ [unit, amount] if amount.positive?
88
+ end
89
+ end
90
+ end
91
+ private_constant :DurationText
92
+ end