acute 1.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 (76) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +17 -0
  3. data/data/assumptions.yml +124 -0
  4. data/data/words.yml +699 -0
  5. data/exe/acute +4 -0
  6. data/lib/acute/assumption.rb +13 -0
  7. data/lib/acute/calendar.rb +91 -0
  8. data/lib/acute/chronic.rb +176 -0
  9. data/lib/acute/chronic_duration.rb +321 -0
  10. data/lib/acute/cli.rb +168 -0
  11. data/lib/acute/context.rb +205 -0
  12. data/lib/acute/describe.rb +316 -0
  13. data/lib/acute/duration.rb +120 -0
  14. data/lib/acute/errors.rb +47 -0
  15. data/lib/acute/explain.rb +165 -0
  16. data/lib/acute/extract.rb +493 -0
  17. data/lib/acute/grammar/clock_words.rb +109 -0
  18. data/lib/acute/grammar/compact.rb +222 -0
  19. data/lib/acute/grammar/duration.rb +93 -0
  20. data/lib/acute/grammar/nesting.rb +104 -0
  21. data/lib/acute/grammar/numbers.rb +378 -0
  22. data/lib/acute/grammar/ordinals.rb +101 -0
  23. data/lib/acute/grammar/ranges.rb +112 -0
  24. data/lib/acute/grammar/rule.rb +277 -0
  25. data/lib/acute/grammar/shifts.rb +226 -0
  26. data/lib/acute/grammar/words.rb +307 -0
  27. data/lib/acute/grammar.rb +378 -0
  28. data/lib/acute/holidays.rb +97 -0
  29. data/lib/acute/lexer.rb +487 -0
  30. data/lib/acute/locale.rb +53 -0
  31. data/lib/acute/mcp.rb +224 -0
  32. data/lib/acute/parser.rb +66 -0
  33. data/lib/acute/read.rb +21 -0
  34. data/lib/acute/resolve/clock.rb +75 -0
  35. data/lib/acute/resolve/dates.rb +75 -0
  36. data/lib/acute/resolve/day.rb +24 -0
  37. data/lib/acute/resolve/fuzzy.rb +91 -0
  38. data/lib/acute/resolve/named.rb +116 -0
  39. data/lib/acute/resolve/nth.rb +47 -0
  40. data/lib/acute/resolve/period.rb +160 -0
  41. data/lib/acute/resolve/periodic.rb +274 -0
  42. data/lib/acute/resolve/ranges.rb +165 -0
  43. data/lib/acute/resolve/recur.rb +187 -0
  44. data/lib/acute/resolve/rel.rb +146 -0
  45. data/lib/acute/resolve/shift.rb +81 -0
  46. data/lib/acute/resolve.rb +245 -0
  47. data/lib/acute/result.rb +128 -0
  48. data/lib/acute/rule/cron.rb +222 -0
  49. data/lib/acute/rule/expand.rb +401 -0
  50. data/lib/acute/rule.rb +232 -0
  51. data/lib/acute/tables/assumptions.rb +39 -0
  52. data/lib/acute/tables/words.rb +656 -0
  53. data/lib/acute/tables/zones.rb +930 -0
  54. data/lib/acute/tool/compact.rb +82 -0
  55. data/lib/acute/tool/ruby_llm.rb +29 -0
  56. data/lib/acute/tool.rb +192 -0
  57. data/lib/acute/verify.rb +216 -0
  58. data/lib/acute/version.rb +3 -0
  59. data/lib/acute/zone.rb +252 -0
  60. data/lib/acute.rb +160 -0
  61. data/schema/description.json +12 -0
  62. data/schema/duration.json +26 -0
  63. data/schema/extraction.json +30 -0
  64. data/schema/failure.json +29 -0
  65. data/schema/now.json +17 -0
  66. data/schema/result.json +45 -0
  67. data/schema/rule.json +19 -0
  68. data/schema/tools/describe.json +13 -0
  69. data/schema/tools/duration.json +12 -0
  70. data/schema/tools/extract.json +13 -0
  71. data/schema/tools/now.json +9 -0
  72. data/schema/tools/parse.json +13 -0
  73. data/schema/tools/recurrence.json +12 -0
  74. data/schema/tools/verify.json +14 -0
  75. data/schema/verdict.json +25 -0
  76. metadata +117 -0
@@ -0,0 +1,97 @@
1
+ require "date"
2
+
3
+ module Acute
4
+ # The holidays: option (D47): an object that answers holiday?(date) and named(name, year), or a region of the
5
+ # holidays gem, which is loaded only then
6
+ # @api private
7
+ module Holidays
8
+ def self.for(given)
9
+ case given
10
+ when Symbol, String, Array then Region.new(Array(given).dup, given)
11
+ else
12
+ unless given.respond_to?(:holiday?) && given.respond_to?(:named)
13
+ raise OptionError.new("holidays: must be a holidays-gem region, or answer holiday?(date) and named(name, year)",
14
+ option: :holidays, value: given)
15
+ end
16
+ given
17
+ end
18
+ end
19
+
20
+ class Region
21
+ NAME = /\A[a-z][a-z0-9_]*\z/i
22
+
23
+ def initialize(regions, given)
24
+ unless regions.any? && regions.all? { (Symbol === _1 || (String === _1 && _1.valid_encoding?)) && NAME.match?(_1) }
25
+ raise OptionError.new("holidays: a region is a name like :us or :gb_eng", option: :holidays, value: given)
26
+ end
27
+ begin
28
+ require "holidays" unless defined?(::Holidays)
29
+ rescue LoadError
30
+ raise OptionError.new("holidays: #{Acute.quote(regions.join(", "))} is a region of the holidays gem, " \
31
+ "which is not installed", option: :holidays, value: given)
32
+ end
33
+ regions.map!(&:to_sym)
34
+ if ::Holidays.respond_to?(:available_regions) && (unknown = regions - ::Holidays.available_regions).any?
35
+ raise OptionError.new("holidays: unknown region #{Acute.quote(unknown.join(", "))}", option: :holidays, value: given)
36
+ end
37
+ @regions = regions
38
+ end
39
+
40
+ def holiday?(date) = !::Holidays.on(date, *@regions).empty?
41
+
42
+ # Its days in year y, as days since 1970
43
+ def days_in(year) = ::Holidays.between(Date.new(year, 1, 1), Date.new(year, 12, 31), *@regions).map { _1[:date].jd - 2_440_588 }
44
+
45
+ # The gem's names are English titles: "Christmas Day" is :christmas, "Thanksgiving" :thanksgiving
46
+ def named(name, year)
47
+ name = name.to_s
48
+ found = ::Holidays.between(Date.new(year, 1, 1), Date.new(year, 12, 31), *@regions).find do |h|
49
+ key = h[:name].downcase.delete("'.,").gsub(/[^a-z0-9]+/, "_")
50
+ key == name || key == "#{name}_day"
51
+ end
52
+ found && found[:date]
53
+ end
54
+ end
55
+
56
+ def self.workdays(holidays) = Region === holidays ? RegionDays.new(holidays) : Workdays.new(holidays)
57
+
58
+ # Monday to Friday, less a holidays-gem region's days, counted rather than stepped through, with the region
59
+ # asked once a year (T17). It is made for one call, so its cache is never shared.
60
+ class RegionDays
61
+ def initialize(region)
62
+ @region = region
63
+ @years = {}
64
+ end
65
+
66
+ # The day n business days from day d: n weekdays on, then one more for each holiday passed, until none is
67
+ def add(d, n)
68
+ to = Calendar.add_weekdays(d, n)
69
+ k = n > 0 ? off(d + 1, to + 1) : off(to, d)
70
+ while k > 0
71
+ from = to
72
+ to = Calendar.add_weekdays(from, n > 0 ? k : -k)
73
+ k = n > 0 ? off(from + 1, to + 1) : off(to, from)
74
+ end
75
+ to
76
+ end
77
+
78
+ private
79
+
80
+ # The holidays from Monday to Friday, from day lo up to but not including hi
81
+ def off(lo, hi)
82
+ (Calendar.year_of(lo)..Calendar.year_of(hi - 1)).sum do |y|
83
+ (@years[y] ||= @region.days_in(y).reject { (_1 + 4) % 7 % 6 == 0 }.uniq).count { lo <= _1 && _1 < hi }
84
+ end
85
+ end
86
+ end
87
+
88
+ # Monday to Friday, less the holidays
89
+ class Workdays
90
+ def initialize(holidays)
91
+ @holidays = holidays
92
+ end
93
+
94
+ def business_day?(date) = !date.saturday? && !date.sunday? && !@holidays.holiday?(date)
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,487 @@
1
+ require_relative "errors"
2
+ require_relative "tables/words"
3
+
4
+ module Acute
5
+ # Bytes to tokens. The result is one flat Array, STRIDE entries per token:
6
+ # kind, value, flags, start, stop (byte offsets, stop exclusive). Failures come back as a Failure value.
7
+ # @api private
8
+ module Lexer
9
+ STRIDE = 5
10
+
11
+ NUM = 0 # value: the number; flags: digit count, ZERO, WORDY
12
+ CLOCK = 1 # value: hhmmss as decimal digits; flags: hour digit count, ZERO, SECONDS
13
+ MER = 2 # value: 0 for am, 12 for pm
14
+ DAY = 3 # value: days from today
15
+ NAMED = 4 # value: the hour named (noon 12, midnight 0)
16
+ NOW = 5
17
+ AT = 6
18
+ WDAY = 7 # value: 0 = Sunday
19
+ MONTH = 8 # value: 1-12
20
+ UNIT = 9 # value: a Symbol (:day, :week, :business_day...)
21
+ REL = 10 # value: :next, :this, :last or :past
22
+ ORD = 11 # value: the number; "3rd" or "third"
23
+ PART = 12 # value: :morning, :tonight...
24
+ SEASON = 13
25
+ HOLIDAY = 14
26
+ WEEKEND = 15
27
+ ZONE = 16 # value: UTC offset in seconds, or an IANA name as a Symbol
28
+ FUZZ = 17 # value: :early, :mid, :late, :start or :end
29
+ ART = 18 # "a", "an"
30
+ WORD = 19 # value: a Symbol for a function word (:in, :ago, :of...)
31
+ SLASH = 20
32
+ DASH = 21
33
+ DOT = 22 # only between digits
34
+ PLUS = 23
35
+ DECADE = 24 # value: the number as written, 90 or 1990; flags: digit count, WORDY
36
+ KIND_NAMES = Ractor.make_shareable(%w[NUM CLOCK MER DAY NAMED NOW AT WDAY MONTH UNIT REL ORD PART SEASON HOLIDAY WEEKEND ZONE FUZZ ART
37
+ WORD SLASH DASH DOT PLUS DECADE])
38
+
39
+ DIGITS = 15
40
+ ZERO = 16
41
+ SECONDS = 32
42
+ WORDY = 64 # a number or ordinal written as a word
43
+ ENGLISH = 128 # also an ordinary English word ("may", "sat", "second"), for extraction
44
+ CAP = 256 # written with a capital
45
+ SHORT = 512 # two digits after an apostrophe: a year with its century left out, "'26"
46
+ PLURAL = 1024 # "fridays": only after a count
47
+ QUOTED = 2048 # a decade written with an apostrophe, "90's": never seconds
48
+ # A CLOCK keeps its written fraction of a second in the flags above this bit: the digits after the point as
49
+ # a number, times 32, plus how many there are (D47)
50
+ FRACTION = 12
51
+
52
+ KINDS = {
53
+ day: DAY, meridiem: MER, named: NAMED, now: NOW, at: AT, weekday: WDAY, month: MONTH, unit: UNIT,
54
+ relative: REL, number: NUM, ordinal: ORD, part: PART, season: SEASON, holiday: HOLIDAY, weekend: WEEKEND,
55
+ zone: ZONE, fuzzy: FUZZ, article: ART, word: WORD, decade: DECADE
56
+ }.freeze
57
+
58
+ # A word packs into an Integer, 5 bits a character, so lookup needs no substring
59
+ def self.pack(word) = word.each_byte.reduce(0) { |k, c| k * 32 + code(c) }
60
+
61
+ def self.code(c)
62
+ if c >= 97 then c - 96
63
+ elsif c == 46 then 27
64
+ else 28
65
+ end
66
+ end
67
+
68
+ # [kind, value, flags, whether it is "one" to "nine" or "first" to "ninth", which join a "twenty" before them]
69
+ def self.entry(word, kind, value)
70
+ kind = KINDS.fetch(kind)
71
+ flags = kind == NUM || kind == ORD || kind == DECADE ? WORDY : 0
72
+ flags |= ENGLISH if Tables::ENGLISH.include?(word)
73
+ flags |= PLURAL if Tables::PLURAL.include?(word)
74
+ [kind, value, flags, flags & WORDY != 0 && value < 10].freeze
75
+ end
76
+
77
+ # A phrase's first word => its second word => [[keys of the words after], token entry], longest first
78
+ phrases = Tables::PHRASES.each_with_object({}) do |(text, (kind, value)), h|
79
+ first, second, *rest = text.split
80
+ ((h[pack(first)] ||= {})[pack(second)] ||= []) << [rest.map { pack(_1) }.freeze, entry(text, kind, value)].freeze
81
+ end
82
+
83
+ # Key => an entry and then the phrases it begins or nil, so a word needs one lookup; a phrase's first word
84
+ # that is no word alone has a nil kind
85
+ WORDS = Tables::WORDS.to_h { |word, (kind, value)| [pack(word), [*entry(word, kind, value), nil]] }
86
+ phrases.each { |k, by_second| WORDS[k] = [*(WORDS[k] || [nil, nil, 0, false]).first(4), by_second.each_value(&:freeze).freeze] }
87
+ WORDS.each_value(&:freeze).freeze
88
+ MID = pack("mid")
89
+ ISH = pack("ish")
90
+
91
+ # Each byte's class, so the main loop tests one value a byte; the punctuation it keeps is its own byte
92
+ SPACE = 1
93
+ DIGIT = 2
94
+ LETTER = 3
95
+ PUNCT = 4
96
+ CLASS = Array.new(256) do |c|
97
+ if [32, 44, 9, 10, 13].include?(c) then SPACE
98
+ elsif c >= 48 && c <= 57 then DIGIT
99
+ elsif (c >= 97 && c <= 122) || (c >= 65 && c <= 90) || c == 39 || c >= 128 then LETTER
100
+ elsif [46, 45, 47, 43, 64].include?(c) then c
101
+ else PUNCT
102
+ end
103
+ end.freeze
104
+ # A byte's 5-bit code inside a word: either case of a letter alike, the apostrophe 28, a byte of a UTF-8
105
+ # letter 31, which is in no word, other bytes 0. Keys are unique to their text, so a word too long or
106
+ # with such a letter simply matches nothing. The lead bytes of WIDE are 0 too, so the word loop looks at
107
+ # them only when it stops.
108
+ CODE = Array.new(256) do |c|
109
+ if c >= 97 && c <= 122 then c - 96
110
+ elsif c >= 65 && c <= 90 then c - 64
111
+ elsif c == 39 then 28
112
+ elsif c == 0xC2 || c == 0xE2 || c == 0xE3 then 0
113
+ elsif c >= 128 then 31
114
+ else 0
115
+ end
116
+ end.freeze
117
+ # Unicode spaces and dashes by their UTF-8 bytes as one Integer: no-break, thin and ideographic spaces;
118
+ # hyphens, en and em dashes and the minus sign; ½; and the curly apostrophe
119
+ WIDE = [0xC2A0, *0xE28080..0xE2808A, 0xE280AF, 0xE2819F, 0xE38080].to_h { [_1, SPACE] }
120
+ .merge([*0xE28090..0xE28095, 0xE28892].to_h { [_1, DASH] })
121
+ .merge(0xC2BD => WORD, 0xE28099 => 39).freeze
122
+ # Whether a byte is an ASCII letter, a lookup rather than a call
123
+ ALPHA = Array.new(256) { |c| (c >= 97 && c <= 122) || (c >= 65 && c <= 90) }.freeze
124
+ # An ordinal's suffix ("3rd"), its two bytes lower-cased and packed
125
+ SUFFIX = %w[st nd rd th].to_h { |x| [x.getbyte(0) << 8 | x.getbyte(1), true] }.freeze
126
+
127
+ # With an Array for unknown, unknown words and stray punctuation are appended to it as start, stop pairs
128
+ # and lexing carries on; that is how the error path finds them all, and how extraction skips them.
129
+ def self.lex(s, unknown = nil)
130
+ n = s.bytesize
131
+ t = []
132
+ i = 0
133
+ while i < n
134
+ c = s.getbyte(i)
135
+ cl = CLASS[c]
136
+ if cl == SPACE
137
+ i += 1
138
+ elsif c >= 194 && (w = wide(s, i, c))
139
+ # "8\u202Fpm" as macOS writes it, "tue – thu"
140
+ if w == DASH
141
+ t.push(DASH, 0, 0, i, i + 3)
142
+ elsif w == WORD
143
+ t.push(WORD, :half, 0, i, i + 2) # D48: "½ hour"
144
+ end
145
+ i += c < 224 ? 2 : 3
146
+ elsif cl == LETTER
147
+ # the "T" between an ISO date and its time, or before a time alone: "T13:23:42"
148
+ if c | 32 == 116 && i + 1 < n && CLASS[s.getbyte(i + 1)] == DIGIT &&
149
+ (i > 0 && CLASS[p = s.getbyte(i - 1)] == DIGIT || ((i == 0 || CLASS[p] == SPACE) && i + 3 < n && s.getbyte(i + 3) == 58))
150
+ i += 1
151
+ next
152
+ end
153
+ st = i
154
+ k = 0
155
+ while i < n
156
+ x = CODE[b = s.getbyte(i)]
157
+ if x == 0
158
+ break if b < 46 # a space, comma or hyphen; only a dot or a UTF-8 byte may carry a word on
159
+ if b == 46 && i + 1 < n && (x = CODE[s.getbyte(i + 1)]) >= 1 && x <= 26
160
+ x = 27
161
+ elsif b >= 194 && (w = wide(s, i, b)) == 39
162
+ # D51: "fortnight’s": a curly apostrophe is an apostrophe
163
+ k = k * 32 + 28
164
+ i += 3
165
+ next
166
+ elsif b >= 194 && w != SPACE && w != DASH
167
+ x = 31 # a UTF-8 letter, or "½"
168
+ else
169
+ break # "10am–2pm": a Unicode dash or space ends the word
170
+ end
171
+ end
172
+ k = k * 32 + x
173
+ i += 1
174
+ end
175
+ if i < n && s.getbyte(i) == 47
176
+ if (zone = iana(s, st, i, n))
177
+ t.push(ZONE, zone, 0, st, i = st + zone.size)
178
+ next
179
+ end
180
+ # D48: "w/c feb 4": "week commencing"
181
+ if k == 23 && i + 1 < n && s.getbyte(i + 1) | 32 == 99 && (i + 2 == n || !ALPHA[s.getbyte(i + 2)])
182
+ t.push(UNIT, :week, 0, st, i, WORD, :starting, 0, i, i += 2)
183
+ next
184
+ end
185
+ end
186
+ word = WORDS[k]
187
+ if word && word[4] && (match = phrase(s, i, n, word[4]))
188
+ i, word = match
189
+ end
190
+ if word && (kind = word[0])
191
+ flags = word[2]
192
+ flags |= CAP if c <= 90 && c >= 65
193
+ value = word[1]
194
+ # "twenty four", "twenty-fourth": one number
195
+ if word[3] && (m = t.size - STRIDE) >= 0 && t[m] == NUM && t[m + 2] & WORDY != 0 && t[m + 1] >= 20 &&
196
+ t[m + 1] % 10 == 0 && only_gap?(s, t[m + 4], st)
197
+ t[m] = kind
198
+ t[m + 1] += value
199
+ t[m + 4] = i
200
+ else
201
+ t.push(kind, value, flags, st, i)
202
+ end
203
+ elsif k == 28 && i < n && CLASS[s.getbyte(i)] == DIGIT
204
+ # an apostrophe for the century left out: "'90s", "'00"
205
+ elsif i - st > 3 && i - st < 13 && k >> 5 * (i - st - 3) == MID && (rest = WORDS[k & (1 << 5 * (i - st - 3)) - 1]) && rest[0]
206
+ # "midweek", "midmorning": "mid" and a word
207
+ t.push(FUZZ, :mid, 0, st, st + 3, rest[0], rest[1], rest[2], st + 3, i)
208
+ elsif i - st > 5 && k & 32_767 == ISH && (stem = WORDS[k >> 15]) && stem[0]
209
+ # "noonish", "tenish": a word of three letters or more, then "ish" as "around" (D14), not "dish"
210
+ t.push(stem[0], stem[1], stem[2], st, i - 3, WORD, :around, 0, i - 3, i)
211
+ else
212
+ return Failure.new(:unknown_word, st, i) unless unknown
213
+ unknown << st << i
214
+ end
215
+ elsif cl == DIGIT
216
+ st = i
217
+ v = 0
218
+ while i < n && CLASS[c = s.getbyte(i)] == DIGIT
219
+ v = v * 10 + c - 48 if i - st < 9 # the first nine digits
220
+ i += 1
221
+ end
222
+ nd = i - st
223
+ flags = nd > DIGITS ? DIGITS : nd
224
+ flags |= ZERO if nd > 1 && s.getbyte(st) == 48
225
+ if (i == n || CLASS[c] == SPACE) && nd < 12
226
+ # the commonest number, a plain one: none of the forms below
227
+ flags |= SHORT if nd == 2 && st > 0 && s.getbyte(st - 1) == 39
228
+ t.push(NUM, v, flags, st, i)
229
+ elsif i < n && c == 58
230
+ # the minutes, and below the seconds: two digits, each byte less 48
231
+ unless nd <= 2 && i + 2 < n && CLASS[a = s.getbyte(i + 1)] == DIGIT && CLASS[b = s.getbyte(i + 2)] == DIGIT
232
+ # D46: "2012:05:25", EXIF's date, is the ISO date with colons for dashes
233
+ if nd == 4 && i + 5 < n && CLASS[a = s.getbyte(i + 1)] == DIGIT && CLASS[b = s.getbyte(i + 2)] == DIGIT &&
234
+ s.getbyte(i + 3) == 58 && CLASS[d = s.getbyte(i + 4)] == DIGIT && CLASS[e = s.getbyte(i + 5)] == DIGIT &&
235
+ (i + 6 == n || (s.getbyte(i + 6) != 58 && CLASS[s.getbyte(i + 6)] != DIGIT))
236
+ t.push(NUM, v, flags, st, i, DASH, 0, 0, i, i + 1, NUM, a * 10 + b - 528, a == 48 ? 2 | ZERO : 2, i + 1, i + 3,
237
+ DASH, 0, 0, i + 3, i + 4, NUM, d * 10 + e - 528, d == 48 ? 2 | ZERO : 2, i + 4, i + 6)
238
+ i += 6
239
+ next
240
+ end
241
+ if nd == 4 && i + 6 < n && s.getbyte(i + 3) == 58 && s.getbyte(i + 6) == 58
242
+ # "06/Nov/2023:06:36:02", a log's timestamp: the year, then its time
243
+ t.push(NUM, v, flags, st, i)
244
+ i += 1
245
+ next
246
+ end
247
+ return Failure.new(:invalid_date, st, i + 3 < n ? i + 3 : n) unless unknown
248
+ i = malformed(s, st, i, n, unknown)
249
+ next
250
+ end
251
+ mi = a * 10 + b - 528
252
+ i += 3
253
+ sec = 0
254
+ if i < n && s.getbyte(i) == 58
255
+ unless i + 2 < n && CLASS[a = s.getbyte(i + 1)] == DIGIT && CLASS[b = s.getbyte(i + 2)] == DIGIT
256
+ return Failure.new(:invalid_date, st, i + 3 < n ? i + 3 : n) unless unknown
257
+ i = malformed(s, st, i, n, unknown)
258
+ next
259
+ end
260
+ sec = a * 10 + b - 528
261
+ i += 3
262
+ flags |= SECONDS
263
+ # D47: ".345", or ":000536" (three digits or more, so not a fourth field); D48: ",502", the decimal comma
264
+ fc = i + 1 < n ? s.getbyte(i) : 0
265
+ if (fc == 46 && CLASS[s.getbyte(i + 1)] == DIGIT) ||
266
+ ((fc == 58 || fc == 44) && i + 3 < n && CLASS[s.getbyte(i + 1)] == DIGIT && CLASS[s.getbyte(i + 2)] == DIGIT &&
267
+ CLASS[s.getbyte(i + 3)] == DIGIT)
268
+ i += 1
269
+ f = 0
270
+ nf = 0
271
+ while i < n && CLASS[fc = s.getbyte(i)] == DIGIT
272
+ if nf < 31
273
+ f = f * 10 + fc - 48
274
+ nf += 1
275
+ end
276
+ i += 1
277
+ end
278
+ flags |= (f << 5 | nf) << FRACTION if f > 0
279
+ end
280
+ end
281
+ t.push(CLOCK, v * 10_000 + mi * 100 + sec, flags, st, i)
282
+ elsif i + 1 < n && SUFFIX[(c | 32) << 8 | (s.getbyte(i + 1) | 32)] && (i + 2 >= n || !ALPHA[s.getbyte(i + 2)])
283
+ i += 2
284
+ t.push(ORD, v, flags, st, i)
285
+ elsif (c | 32 == 115 || c == 39) && (nd == 2 || nd == 4) && v % 10 == 0 && (j = decade_end(s, i, n))
286
+ # "1990s", "90's"
287
+ flags |= QUOTED if c == 39
288
+ i = j
289
+ t.push(DECADE, v, flags, st, i)
290
+ elsif nd == 12 || nd == 14
291
+ # D44: "19970902090807", a compact date and its time, lexed as "19970902T090807" is
292
+ f = nd - 8
293
+ f |= ZERO if s.getbyte(st + 8) == 48
294
+ t.push(NUM, s.byteslice(st, 8).to_i, 8, st, st + 8, NUM, s.byteslice(st + 8, nd - 8).to_i, f, st + 8, i)
295
+ elsif (c == 39 || c == 34) && v < 60 && i < n && (i + 1 == n || CLASS[s.getbyte(i + 1)] == SPACE)
296
+ # D48: "2'", "1\"": prime notation, minutes and seconds; under 60, so "'TUNNEL 64'" is a quote
297
+ t.push(NUM, v, flags, st, i, UNIT, c == 39 ? :minute : :second, 0, i, i + 1)
298
+ i += 1
299
+ else
300
+ flags |= SHORT if nd == 2 && st > 0 && s.getbyte(st - 1) == 39
301
+ t.push(NUM, v, flags, st, i)
302
+ # "2026-10-23": the hyphens and month of an ISO date at once, as the loop would lex them
303
+ if nd == 4 && c == 45 && i + 4 < n && CLASS[a = s.getbyte(i + 1)] == DIGIT &&
304
+ CLASS[b = s.getbyte(i + 2)] == DIGIT && s.getbyte(i + 3) == 45 && CLASS[s.getbyte(i + 4)] == DIGIT
305
+ t.push(DASH, 0, 0, i, i + 1, NUM, a * 10 + b - 528, a == 48 ? 2 | ZERO : 2, i + 1, i + 3, DASH, 0, 0, i + 3, i + 4)
306
+ i += 4
307
+ end
308
+ end
309
+ else
310
+ case c
311
+ when 46
312
+ t.push(DOT, 0, 0, i, i + 1) if (i > 0 && i + 1 < n && CLASS[s.getbyte(i - 1)] == DIGIT && CLASS[s.getbyte(i + 1)] == DIGIT) ||
313
+ spaced_dot?(s, i, n, t)
314
+ i += 1
315
+ when 45
316
+ # A hyphen inside a word ("twenty-four", "mid-october") is a space; after a month or weekday it
317
+ # is a range ("jun-aug", "mon-fri")
318
+ if !(i > 0 && i + 1 < n && ALPHA[s.getbyte(i - 1)] && ALPHA[s.getbyte(i + 1)]) ||
319
+ ((k = t[-5]) == MONTH || k == WDAY) && t[-1] == i
320
+ t.push(DASH, 0, 0, i, i + 1)
321
+ end
322
+ i += 1
323
+ when 47
324
+ t.push(SLASH, 0, 0, i, i + 1)
325
+ i += 1
326
+ when 126
327
+ # "2014~2018"
328
+ if t[-5] == NUM && t[-1] == i && i + 1 < n && CLASS[s.getbyte(i + 1)] == DIGIT
329
+ t.push(DASH, 0, 0, i, i + 1)
330
+ else
331
+ return Failure.new(:unknown_word, i, i + 1) unless unknown
332
+ unknown << i << i + 1
333
+ end
334
+ i += 1
335
+ when 43
336
+ t.push(PLUS, 0, 0, i, i + 1)
337
+ i += 1
338
+ when 64
339
+ t.push(AT, 0, 0, i, i + 1)
340
+ i += 1
341
+ when 91, 93
342
+ # D48: "[Sept] 04": brackets around a word, as in a citation, are dropped
343
+ if c == 91 ? i + 1 < n && ALPHA[s.getbyte(i + 1)] : i > 0 && ALPHA[s.getbyte(i - 1)]
344
+ i += 1
345
+ next
346
+ end
347
+ return Failure.new(:unknown_word, i, i + 1) unless unknown
348
+ unknown << i << i + 1
349
+ i += 1
350
+ when 40, 41
351
+ # "9 am (BST)", "-0500 (CDT)": a zone in brackets is the zone
352
+ if c == 40 ? zone_in_brackets?(s, i + 1, n) : t[-5] == ZONE && t[-1] == i && s.getbyte(t[-2] - 1) == 40
353
+ i += 1
354
+ next
355
+ end
356
+ return Failure.new(:unknown_word, i, i + 1) unless unknown
357
+ unknown << i << i + 1
358
+ i += 1
359
+ else
360
+ st = i
361
+ i += 1
362
+ i += 1 while i < n && CLASS[s.getbyte(i)] == PUNCT
363
+ # D44: "tomorrow!", "at 5?": a sentence's end mark after the phrase says nothing of the time
364
+ if c == 33 || c == 63
365
+ j = i
366
+ j += 1 while j < n && ((b = s.getbyte(j)) == 32 || (b >= 9 && b <= 13) || b == 0)
367
+ next if j == n
368
+ end
369
+ return Failure.new(:unknown_word, st, i) unless unknown
370
+ unknown << st << i
371
+ end
372
+ end
373
+ end
374
+ t
375
+ end
376
+
377
+ # Tries each phrase starting with the word just read and the next: [stop, token entry] on a match. Phrases
378
+ # are rare, so this path may rescan and allocate.
379
+ def self.phrase(s, i, n, cands)
380
+ j = i
381
+ j += 1 while j < n && ((c = s.getbyte(j)) == 32 || c == 45 || c == 46)
382
+ return if j == i
383
+ first = 0
384
+ while j < n && (ALPHA[c = s.getbyte(j)] || c == 39)
385
+ first = first * 32 + CODE[c]
386
+ j += 1
387
+ end
388
+ return unless (cands = cands[first])
389
+ after = j
390
+ # while, not each and all?: a block call here is a measurable share of a phrase's time
391
+ x = 0
392
+ while x < cands.size
393
+ rest, entry = cands[x]
394
+ x += 1
395
+ j = after
396
+ r = 0
397
+ while r < rest.size
398
+ gap = j
399
+ j += 1 while j < n && ((c = s.getbyte(j)) == 32 || c == 45 || c == 46) # "st. patrick's"
400
+ break if j == gap
401
+ k = 0
402
+ while j < n && (ALPHA[c = s.getbyte(j)] || c == 39)
403
+ k = k * 32 + CODE[c]
404
+ j += 1
405
+ end
406
+ break unless k == rest[r]
407
+ r += 1
408
+ end
409
+ next unless r == rest.size && (j >= n || !ALPHA[s.getbyte(j)])
410
+ # D51: "2018 or later than 2020": the "later" goes with its "than"
411
+ next if (entry[1] == :orlater || entry[1] == :orearlier) && s.byteslice(j, 8).match?(/\A\s+than\b/i)
412
+ return [j, entry]
413
+ end
414
+ nil
415
+ end
416
+
417
+ def self.wide(s, i, c)
418
+ b = s.getbyte(i + 1).to_i
419
+ WIDE[c < 224 ? c << 8 | b : c << 16 | b << 8 | s.getbyte(i + 2).to_i]
420
+ end
421
+
422
+ # Where a decade's "s" or "'s" ends, or nil
423
+ def self.decade_end(s, i, n)
424
+ i += 1 if i + 1 < n && s.getbyte(i) == 39
425
+ return unless i < n && s.getbyte(i) | 32 == 115
426
+ i + 1 >= n || !ALPHA[s.getbyte(i + 1)] ? i + 1 : nil
427
+ end
428
+
429
+ # D44: "26. 10.21", "30 . 09 . 22": a dot after a day or month number, with a space either side at most
430
+ def self.spaced_dot?(s, i, n, t)
431
+ return false unless t[-5] == NUM && t[-3] & DIGITS <= 2 && i - t[-1] <= 1 && t[-10] != MONTH
432
+ j = i + 1
433
+ j += 1 if j < n && s.getbyte(j) == 32
434
+ j < n && CLASS[s.getbyte(j)] == DIGIT
435
+ end
436
+
437
+ # D44: "America/New_York", "europe/amsterdam": an IANA name, as tzdata spells it
438
+ def self.iana(s, st, i, n)
439
+ i += 1 while i < n && (ALPHA[c = s.getbyte(i)] || c == 47 || c == 95 || c == 45)
440
+ name = s.byteslice(st, i - st)
441
+ Zone::NAMES[name.downcase] if name.ascii_only?
442
+ end
443
+
444
+ def self.zone_in_brackets?(s, i, n)
445
+ st = i
446
+ k = 0
447
+ while i < n && (x = CODE[s.getbyte(i)]) != 0
448
+ k = k * 32 + x
449
+ i += 1
450
+ end
451
+ return false unless i > st && (word = WORDS[k])
452
+ if word[4] && (match = phrase(s, i, n, word[4]))
453
+ i, word = match
454
+ end
455
+ i < n && s.getbyte(i) == 41 && word[0] == ZONE
456
+ end
457
+
458
+ # A malformed clock ("4:3", "123:00") is one unknown word, to the end of its digits, colons and letters
459
+ def self.malformed(s, st, i, n, unknown)
460
+ i += 1 while i < n && ((c = CLASS[s.getbyte(i)]) == DIGIT || c == LETTER || s.getbyte(i) == 58)
461
+ unknown << st << i
462
+ i
463
+ end
464
+
465
+ def self.only_gap?(s, from, to)
466
+ return false if from == to
467
+ while from < to
468
+ c = s.getbyte(from)
469
+ return false unless c == 32 || c == 45
470
+ from += 1
471
+ end
472
+ true
473
+ end
474
+
475
+ def self.unknown_ranges(s)
476
+ ranges = []
477
+ lex(s, ranges)
478
+ ranges.each_slice(2).map { |st, stop| st...stop }
479
+ end
480
+
481
+ def self.inspect_tokens(t, s)
482
+ t.each_slice(STRIDE).map do |kind, value, flags, st, stop|
483
+ "#{KIND_NAMES[kind]}(#{value}#{format(", flags=%#x", flags) unless flags == 0}) #{s.byteslice(st, stop - st).inspect}"
484
+ end
485
+ end
486
+ end
487
+ end
@@ -0,0 +1,53 @@
1
+ require_relative "tables/zones"
2
+
3
+ module Acute
4
+ # The regional conventions one locale sets (D27): numeric date order, week start, hemisphere and which
5
+ # country's formula holidays apply. A hand table of the countries the corpus uses; D39 wants it
6
+ # generated from CLDR.
7
+ Locale = Struct.new(:country, :day_first, :week_start, :south)
8
+
9
+ # @api private
10
+ class Locale
11
+ SUNDAY_WEEKS = Ractor.make_shareable(%w[US CA JP IN PK BR NP ZA PH MX IL KR TW HK]) # D19, D39
12
+ MONTH_FIRST = Ractor.make_shareable(%w[US CA JP CN PH NP]) # D10, D37
13
+ SOUTH = Ractor.make_shareable(%w[AU NZ ZA BR AR CL UY PY BO PE]) # D3
14
+
15
+ def self.build(country) = new(country, !MONTH_FIRST.include?(country), SUNDAY_WEEKS.include?(country) ? 0 : 1,
16
+ SOUTH.include?(country)).freeze
17
+
18
+ # Built once at load, frozen and shareable, so any Ractor can read them
19
+ ALL = Ractor.make_shareable(Tables::COUNTRIES.to_h { [_1, build(_1)] })
20
+ US = ALL.fetch("US")
21
+
22
+ TAG = /\A[a-z]{2,3}(?:[-_][a-z0-9]{2,8})*\z/i
23
+
24
+ def self.for(code)
25
+ unless (Symbol === code || (String === code && code.valid_encoding?)) && TAG.match?(code)
26
+ raise OptionError.new("locale: must be a tag like en-US, en-GB or fr", option: :locale, value: code)
27
+ end
28
+ country = code.to_s.split(/[-_]/).last.upcase
29
+ ALL[country] || build(country)
30
+ end
31
+
32
+ # tzdata lists a zone under every country that uses it (America/Toronto under BS and CA), and lists none
33
+ # for a zone tzdata made a link (Europe/Oslo to Europe/Berlin); these are the zone's own country, from zone.tab
34
+ SHARED = {
35
+ "Africa/Abidjan" => "CI", "Africa/Johannesburg" => "ZA", "Africa/Lagos" => "NG", "Africa/Maputo" => "MZ",
36
+ "Africa/Nairobi" => "KE", "America/Panama" => "PA", "America/Phoenix" => "US", "America/Puerto_Rico" => "PR",
37
+ "America/Toronto" => "CA", "Asia/Bangkok" => "TH", "Asia/Dubai" => "AE", "Asia/Kuching" => "MY",
38
+ "Asia/Qatar" => "QA", "Asia/Riyadh" => "SA", "Asia/Singapore" => "SG", "Asia/Tokyo" => "JP",
39
+ "Asia/Yangon" => "MM", "Europe/Belgrade" => "RS", "Europe/Berlin" => "DE", "Europe/Brussels" => "BE",
40
+ "Europe/Helsinki" => "FI", "Europe/Amsterdam" => "NL", "Europe/Copenhagen" => "DK", "Europe/Luxembourg" => "LU",
41
+ "Europe/Monaco" => "MC", "Europe/Oslo" => "NO", "Europe/Stockholm" => "SE", "Europe/London" => "GB", "Europe/Paris" => "FR", "Europe/Prague" => "CZ",
42
+ "Europe/Rome" => "IT", "Europe/Simferopol" => "UA", "Europe/Zurich" => "CH", "Indian/Maldives" => "MV",
43
+ "Pacific/Auckland" => "NZ", "Pacific/Guadalcanal" => "SB", "Pacific/Guam" => "GU",
44
+ "Pacific/Port_Moresby" => "PG", "Pacific/Tarawa" => "KI", "Pacific/Pago_Pago" => "AS"
45
+ }.freeze
46
+
47
+ # Zone name => its country's code
48
+ ZONE_COUNTRIES = Ractor.make_shareable(Tables::ZONE_COUNTRIES.merge(SHARED))
49
+
50
+ # D27: from the zone's country; UTC and zones with no country fall back to en-US
51
+ def self.for_zone(name) = ALL[ZONE_COUNTRIES[name]] || US
52
+ end
53
+ end