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.
- checksums.yaml +7 -0
- data/LICENSE +17 -0
- data/data/assumptions.yml +124 -0
- data/data/words.yml +699 -0
- data/exe/acute +4 -0
- data/lib/acute/assumption.rb +13 -0
- data/lib/acute/calendar.rb +91 -0
- data/lib/acute/chronic.rb +176 -0
- data/lib/acute/chronic_duration.rb +321 -0
- data/lib/acute/cli.rb +168 -0
- data/lib/acute/context.rb +205 -0
- data/lib/acute/describe.rb +316 -0
- data/lib/acute/duration.rb +120 -0
- data/lib/acute/errors.rb +47 -0
- data/lib/acute/explain.rb +165 -0
- data/lib/acute/extract.rb +493 -0
- data/lib/acute/grammar/clock_words.rb +109 -0
- data/lib/acute/grammar/compact.rb +222 -0
- data/lib/acute/grammar/duration.rb +93 -0
- data/lib/acute/grammar/nesting.rb +104 -0
- data/lib/acute/grammar/numbers.rb +378 -0
- data/lib/acute/grammar/ordinals.rb +101 -0
- data/lib/acute/grammar/ranges.rb +112 -0
- data/lib/acute/grammar/rule.rb +277 -0
- data/lib/acute/grammar/shifts.rb +226 -0
- data/lib/acute/grammar/words.rb +307 -0
- data/lib/acute/grammar.rb +378 -0
- data/lib/acute/holidays.rb +97 -0
- data/lib/acute/lexer.rb +487 -0
- data/lib/acute/locale.rb +53 -0
- data/lib/acute/mcp.rb +224 -0
- data/lib/acute/parser.rb +66 -0
- data/lib/acute/read.rb +21 -0
- data/lib/acute/resolve/clock.rb +75 -0
- data/lib/acute/resolve/dates.rb +75 -0
- data/lib/acute/resolve/day.rb +24 -0
- data/lib/acute/resolve/fuzzy.rb +91 -0
- data/lib/acute/resolve/named.rb +116 -0
- data/lib/acute/resolve/nth.rb +47 -0
- data/lib/acute/resolve/period.rb +160 -0
- data/lib/acute/resolve/periodic.rb +274 -0
- data/lib/acute/resolve/ranges.rb +165 -0
- data/lib/acute/resolve/recur.rb +187 -0
- data/lib/acute/resolve/rel.rb +146 -0
- data/lib/acute/resolve/shift.rb +81 -0
- data/lib/acute/resolve.rb +245 -0
- data/lib/acute/result.rb +128 -0
- data/lib/acute/rule/cron.rb +222 -0
- data/lib/acute/rule/expand.rb +401 -0
- data/lib/acute/rule.rb +232 -0
- data/lib/acute/tables/assumptions.rb +39 -0
- data/lib/acute/tables/words.rb +656 -0
- data/lib/acute/tables/zones.rb +930 -0
- data/lib/acute/tool/compact.rb +82 -0
- data/lib/acute/tool/ruby_llm.rb +29 -0
- data/lib/acute/tool.rb +192 -0
- data/lib/acute/verify.rb +216 -0
- data/lib/acute/version.rb +3 -0
- data/lib/acute/zone.rb +252 -0
- data/lib/acute.rb +160 -0
- data/schema/description.json +12 -0
- data/schema/duration.json +26 -0
- data/schema/extraction.json +30 -0
- data/schema/failure.json +29 -0
- data/schema/now.json +17 -0
- data/schema/result.json +45 -0
- data/schema/rule.json +19 -0
- data/schema/tools/describe.json +13 -0
- data/schema/tools/duration.json +12 -0
- data/schema/tools/extract.json +13 -0
- data/schema/tools/now.json +9 -0
- data/schema/tools/parse.json +13 -0
- data/schema/tools/recurrence.json +12 -0
- data/schema/tools/verify.json +14 -0
- data/schema/verdict.json +25 -0
- 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
|
data/lib/acute/lexer.rb
ADDED
|
@@ -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
|
data/lib/acute/locale.rb
ADDED
|
@@ -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
|