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
data/lib/acute/mcp.rb ADDED
@@ -0,0 +1,224 @@
1
+ require "json"
2
+ require_relative "tool"
3
+
4
+ module Acute
5
+ # acute mcp: Acute::Tool as an MCP server, one JSON-RPC message per line of stdin and stdout. Dual-era: a request
6
+ # whose _meta names a modern version is served statelessly as that version; an initialize opens a legacy session.
7
+ # T5: no error message quotes the input. The tool's own failures put it in unread.
8
+ # @api private
9
+ class MCP
10
+ MODERN = %w[2026-07-28].freeze
11
+ LEGACY = %w[2025-11-25 2025-06-18].freeze
12
+ VERSION_KEY = "io.modelcontextprotocol/protocolVersion".freeze
13
+ LINE_LIMIT = 64 * 1024
14
+ PARSE_ERROR = -32_700
15
+ INVALID_REQUEST = -32_600
16
+ METHOD_NOT_FOUND = -32_601
17
+ INVALID_PARAMS = -32_602
18
+ INTERNAL_ERROR = -32_603
19
+ UNSUPPORTED_VERSION = -32_022
20
+ ZONED = %w[parse recurrence extract verify describe now].freeze
21
+ ZONE_LENGTH = 64
22
+ ZONE = {
23
+ "type" => "string", "maxLength" => ZONE_LENGTH,
24
+ "description" => "Only if the user names a zone not their own (\"3pm London time\"): IANA or UTC offset"
25
+ }.freeze
26
+ ZONE_ERROR = "zone must be an IANA time zone or a UTC offset such as +05:30".freeze
27
+ OUTPUTS = { "parse" => "result", "duration" => "duration", "recurrence" => "rule", "extract" => "extraction",
28
+ "verify" => "verdict", "describe" => "description", "now" => "now" }.freeze
29
+ ANNOTATIONS = %w[title description examples $comment].freeze
30
+ NAMED = %w[properties $defs].freeze
31
+ SCHEMAS = File.expand_path("../../schema", __dir__)
32
+
33
+ Refusal = Class.new(StandardError) do
34
+ attr_reader :code, :data
35
+
36
+ def initialize(code, message, data = nil)
37
+ super(message)
38
+ @code = code
39
+ @data = data
40
+ end
41
+ end
42
+
43
+ # A tool's own schema or a failure, in one self-contained document: the schemas it refers to are inlined, so a
44
+ # client resolves everything without fetching
45
+ def self.output_schema(name)
46
+ defs = {}
47
+ hoist = lambda do |file|
48
+ return if defs.key?(file)
49
+ defs[file] = nil
50
+ schema = JSON.parse(File.read(File.join(SCHEMAS, "#{file}.json")))
51
+ defs.merge!(schema.delete("$defs") || {})
52
+ defs[file] = localise(schema.except("$schema", "$id"), hoist)
53
+ end
54
+ own = OUTPUTS.fetch(name)
55
+ [own, "failure"].each(&hoist)
56
+ defs = bare(Tool::Compact.schema(defs, name), names: true)
57
+ root = [own, "failure"].map { { "$ref" => "#/$defs/#{_1}" } }
58
+ uses = JSON.generate([root, defs]).scan(%r{"#/\$defs/(\w+)"}).flatten.tally
59
+ schema = { "type" => "object", "oneOf" => inline(root, defs, uses) }
60
+ shared = defs.select { uses.fetch(_1, 0) > 1 }.transform_values { inline(_1, defs, uses) }
61
+ shared.empty? ? schema : schema.merge("$defs" => shared)
62
+ end
63
+
64
+ # The model's host pays for every char of a listing, and the annotations are for people: validation needs only
65
+ # the structure. Under properties and $defs the keys are names, never annotations.
66
+ def self.bare(node, names: false)
67
+ case node
68
+ when Hash
69
+ node.each_with_object({}) do |(key, value), out|
70
+ out[key] = bare(value, names: !names && NAMED.include?(key)) if names || !ANNOTATIONS.include?(key)
71
+ end
72
+ when Array then node.map { bare(_1) }
73
+ else node
74
+ end
75
+ end
76
+
77
+ # A schema referred to once is written in place; only those referred to more than once stay in $defs
78
+ def self.inline(node, defs, uses)
79
+ case node
80
+ when Hash
81
+ ref = node["$ref"]&.delete_prefix("#/$defs/")
82
+ return inline(defs.fetch(ref), defs, uses).merge(node.except("$ref")) if ref && uses[ref] == 1
83
+ node.transform_values { inline(_1, defs, uses) }
84
+ when Array then node.map { inline(_1, defs, uses) }
85
+ else node
86
+ end
87
+ end
88
+
89
+ def self.localise(node, hoist)
90
+ case node
91
+ when Hash
92
+ node.to_h do |key, value|
93
+ next [key, localise(value, hoist)] unless key == "$ref" && value.end_with?(".json")
94
+ file = value.delete_suffix(".json")
95
+ hoist.(file)
96
+ [key, "#/$defs/#{file}"]
97
+ end
98
+ when Array then node.map { localise(_1, hoist) }
99
+ else node
100
+ end
101
+ end
102
+
103
+ def initialize(tool = Tool.new, log: nil)
104
+ @tool = tool
105
+ @log = log
106
+ @tools = tool.definitions.map { listing(_1) }
107
+ @names = @tools.to_h { [_1["name"], _1["name"].delete_prefix(tool.prefix)] }
108
+ end
109
+
110
+ def serve(input, output)
111
+ while (line = read(input))
112
+ next if String === line && line.strip.empty?
113
+ reply = line == :oversize ? error(nil, INVALID_REQUEST, "Invalid request: the line is too long") : handle(line)
114
+ next unless reply
115
+ output.write("#{JSON.generate(reply)}\n")
116
+ output.flush
117
+ end
118
+ end
119
+
120
+ private
121
+
122
+ def handle(line)
123
+ line = line.dup.force_encoding(Encoding::UTF_8)
124
+ return error(nil, PARSE_ERROR, "Parse error") unless line.valid_encoding?
125
+ respond(JSON.parse(line))
126
+ rescue JSON::ParserError
127
+ error(nil, PARSE_ERROR, "Parse error")
128
+ end
129
+
130
+ def read(input)
131
+ line = input.gets("\n", LINE_LIMIT + 1) or return
132
+ return line if line.end_with?("\n") || line.bytesize <= LINE_LIMIT
133
+ while (rest = input.gets("\n", LINE_LIMIT))
134
+ break if rest.end_with?("\n")
135
+ end
136
+ :oversize
137
+ end
138
+
139
+ def respond(message)
140
+ return error(nil, INVALID_REQUEST, "Invalid request") unless Hash === message
141
+ # Notifications get no reply, whatever they are
142
+ return if !message.key?("id") && String === message["method"]
143
+ id = message["id"]
144
+ return error(nil, INVALID_REQUEST, "Invalid request") unless String === id || Integer === id
145
+ params = message.fetch("params", {})
146
+ unless message["jsonrpc"] == "2.0" && String === message["method"] && Hash === params
147
+ return error(id, INVALID_REQUEST, "Invalid request")
148
+ end
149
+ { "jsonrpc" => "2.0", "id" => id, "result" => dispatch(message["method"], params) }
150
+ rescue Refusal => e
151
+ error(id, e.code, e.message, e.data)
152
+ rescue StandardError => e
153
+ @log&.puts "acute mcp: #{e.class} at #{e.backtrace&.first}"
154
+ error(id, INTERNAL_ERROR, "Internal error")
155
+ end
156
+
157
+ def dispatch(method, params)
158
+ meta = params["_meta"]
159
+ version = meta[VERSION_KEY] if Hash === meta && meta.key?(VERSION_KEY)
160
+ if version && !MODERN.include?(version)
161
+ raise Refusal.new(UNSUPPORTED_VERSION, "Unsupported protocol version", { "supported" => MODERN })
162
+ end
163
+ result =
164
+ case method
165
+ when "initialize" then opening(params["protocolVersion"])
166
+ when "server/discover" then discovery
167
+ when "ping" then {}
168
+ when "tools/list" then { "tools" => @tools }
169
+ when "tools/call" then call(params["name"], params.fetch("arguments", {}))
170
+ else raise Refusal.new(METHOD_NOT_FOUND, "Method not found")
171
+ end
172
+ version ? result.merge("resultType" => "complete") : result
173
+ end
174
+
175
+ def server_info = { "name" => "acute", "version" => VERSION }
176
+
177
+ # A legacy client asking for a version this server doesn't speak gets the latest legacy one: the modern
178
+ # versions have no initialize
179
+ def opening(asked)
180
+ { "protocolVersion" => LEGACY.include?(asked) ? asked : LEGACY.first, "capabilities" => { "tools" => {} },
181
+ "serverInfo" => server_info }
182
+ end
183
+
184
+ def discovery
185
+ { "supportedVersions" => MODERN, "capabilities" => { "tools" => {} },
186
+ "_meta" => { "io.modelcontextprotocol/serverInfo" => server_info } }
187
+ end
188
+
189
+ def call(name, args)
190
+ # A Tool with a prefix: lists and answers to the prefixed names only, as Tool#call does
191
+ base = @names[name] or raise Refusal.new(INVALID_PARAMS, "Unknown tool")
192
+ raise Refusal.new(INVALID_PARAMS, "Invalid params: arguments must be an object") unless Hash === args
193
+ zone = args["zone"] if ZONED.include?(base)
194
+ value = zone.nil? ? @tool.call(name, args) : zoned(zone)&.call(name, args.except("zone")) || zone_failure(zone)
195
+ { "content" => [{ "type" => "text", "text" => JSON.generate(value) }], "structuredContent" => value,
196
+ "isError" => value["kind"] == "failure" }
197
+ end
198
+
199
+ def zoned(zone)
200
+ return unless String === zone && zone.length <= ZONE_LENGTH
201
+ Tool.new(@tool.parser, prefix: @tool.prefix, zone: zone)
202
+ rescue OptionError
203
+ nil
204
+ end
205
+
206
+ def zone_failure(zone)
207
+ unread = String === zone ? [zone[0, Tool::UNREAD_LENGTH]] : []
208
+ Tool::Compact.({ "kind" => "failure", "code" => "invalid_argument", "error" => ZONE_ERROR, "unread" => unread })
209
+ end
210
+
211
+ def listing(definition)
212
+ name = definition[:name]
213
+ base = name.delete_prefix(@tool.prefix)
214
+ input = definition[:input_schema]
215
+ input = input.merge("properties" => input["properties"].merge("zone" => ZONE)) if ZONED.include?(base)
216
+ { "name" => name, "description" => definition[:description], "inputSchema" => input,
217
+ "outputSchema" => self.class.output_schema(base) }
218
+ end
219
+
220
+ def error(id, code, message, data = nil)
221
+ { "jsonrpc" => "2.0", "id" => id, "error" => { "code" => code, "message" => message, "data" => data }.compact }
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,66 @@
1
+ module Acute
2
+ def self.with(now: nil, zone: nil, context: :future, locale: nil, week_start: nil, hemisphere: nil,
3
+ fiscal_year_start: nil, strict: true, dst: nil, holidays: nil, business_days: nil, year_bias: nil)
4
+ Parser.new(now: now, zone: zone, context: context, locale: locale, week_start: week_start,
5
+ hemisphere: hemisphere, fiscal_year_start: fiscal_year_start, strict: strict, dst: dst,
6
+ holidays: holidays, business_days: business_days, year_bias: year_bias)
7
+ end
8
+
9
+ # Every entry point with the same options. Frozen and Ractor-shareable; options given to a call override
10
+ # the parser's (D45). D47: a holidays: or business_days: object is kept as given, not frozen, so a parser
11
+ # holding one is shareable only if the object already is.
12
+ class Parser
13
+ attr_reader :options
14
+
15
+ def initialize(now: nil, zone: nil, context: :future, locale: nil, week_start: nil, hemisphere: nil,
16
+ fiscal_year_start: nil, strict: true, dst: nil, holidays: nil, business_days: nil, year_bias: nil)
17
+ ctx = Context.new(now, zone, context, locale, week_start, hemisphere, fiscal_year_start, strict, dst, holidays,
18
+ business_days, year_bias)
19
+ # A now that carries a zone (a TimeWithZone) gives the parser that zone, as each call would infer it
20
+ zone ||= ctx.zone.name if Context.carried_zone(now)
21
+ # Copies, so making the parser shareable freezes nothing of the caller's
22
+ opts = { now: now && Time.at(ctx.now).utc, zone: zone && -ctx.zone.name, context: context,
23
+ locale: locale && -locale.to_s, week_start: week_start&.to_sym, hemisphere: hemisphere&.to_sym,
24
+ fiscal_year_start: fiscal_year_start && Integer(fiscal_year_start), strict: strict == false ? false : nil,
25
+ dst: dst&.to_sym, year_bias: year_bias && Integer(year_bias) }
26
+ # A region is copied; an object answering holiday? or business_day? is the caller's, so it is kept as is
27
+ holidays = Array(holidays).map(&:to_sym) if Symbol === holidays || String === holidays || Array === holidays
28
+ @options = opts.merge(holidays: holidays, business_days: business_days).compact
29
+ # extract takes no strict:, as it always skips unknown words
30
+ @extract_options = @options.except(:strict)
31
+ objects = [holidays, business_days].compact.reject { Array === _1 }
32
+ if objects.all? { Ractor.shareable?(_1) }
33
+ Ractor.make_shareable(self)
34
+ else
35
+ @options.each_value { Ractor.make_shareable(_1) unless objects.include?(_1) }
36
+ @options.freeze
37
+ @extract_options.freeze
38
+ freeze
39
+ end
40
+ end
41
+
42
+ def parse(text, **ctx) = Acute.parse(text, **@options, **ctx)
43
+
44
+ def parse!(text, **ctx) = Acute.parse!(text, **@options, **ctx)
45
+
46
+ def read(text, **ctx) = Acute.read(text, **@options, **ctx)
47
+
48
+ def extract(text, **ctx) = Acute.extract(text, **@extract_options, **ctx)
49
+
50
+ def describe(value, **ctx) = Acute.describe(value, **@options, **ctx)
51
+
52
+ def verify(text, value, **ctx) = Acute.verify(text, value, **@options, **ctx)
53
+
54
+ def duration(text, **ctx) = Acute.duration(text, **@options, **ctx)
55
+
56
+ def duration!(text, **ctx) = Acute.duration!(text, **@options, **ctx)
57
+
58
+ def recurrence(text, **ctx) = Acute.recurrence(text, **@options, **ctx)
59
+
60
+ def recurrence!(text, **ctx) = Acute.recurrence!(text, **@options, **ctx)
61
+
62
+ def with(**more) = Parser.new(**@options, **more)
63
+
64
+ def inspect = "#<Acute::Parser #{@options.map { "#{_1}: #{_2.inspect}" }.join(", ")}>"
65
+ end
66
+ end
data/lib/acute/read.rb ADDED
@@ -0,0 +1,21 @@
1
+ module Acute
2
+ ALSO_DURATION = Assumption.for(:also_duration)
3
+ private_constant :ALSO_DURATION
4
+
5
+ # Whatever the text is: a Rule, a Result, a Duration, or nil. A rule comes first, then a time, which wins over
6
+ # a duration (D45). The rule is tried only when a word that makes one is there.
7
+ def self.read(text, now: nil, zone: nil, context: :future, locale: nil, week_start: nil, hemisphere: nil,
8
+ fiscal_year_start: nil, strict: true, dst: nil, holidays: nil, business_days: nil, year_bias: nil)
9
+ ctx = Context.new(now, zone, context, locale, week_start, hemisphere, fiscal_year_start, strict, dst, holidays,
10
+ business_days, year_bias)
11
+ if String === text && (t = utf8(text)) && t.bytesize <= MAX_LENGTH && Array === (tokens = Lexer.lex(t)) && Grammar.rule?(tokens)
12
+ out = rule(t, Context.new(now, zone, context, locale, week_start, hemisphere, fiscal_year_start, strict, dst,
13
+ holidays, business_days, year_bias), tokens)
14
+ return out if Rule === out
15
+ end
16
+ length = duration(text)
17
+ ctx.note(ALSO_DURATION) if length
18
+ out = run(text, ctx)
19
+ Result === out ? out : length
20
+ end
21
+ end
@@ -0,0 +1,75 @@
1
+ module Acute
2
+ module Resolve
3
+ # Times of day: which seconds-of-day a clock can mean, and which day a bare one falls on
4
+ module Clock
5
+ BARE_HOUR = Assumption.for(:bare_hour_window)
6
+ TWELVE = Assumption.for(:twelve_oclock)
7
+ MACHINE = Assumption.for(:machine_time)
8
+ MIDNIGHT_END = Assumption.for(:midnight_end_of_day)
9
+ NEXT = Assumption.for(:next_occurrence)
10
+ PREVIOUS = Assumption.for(:previous_occurrence)
11
+ EQUAL_NOW = Assumption.for(:equal_to_now)
12
+
13
+ NOON = [43_200].freeze
14
+ MIDNIGHT = [0].freeze
15
+ # Midnight attached to a day: its end (86400 = the next 00:00) first, then its start
16
+ DAY_MIDNIGHT = [86_400, 0].freeze
17
+
18
+ # Seconds of the day, most likely first
19
+ def self.candidates(c, ctx, attached)
20
+ ctx.frac = c.frac if c.frac
21
+ secs = c.min * 60 + c.sec
22
+ if c.named == :noon then NOON
23
+ elsif c.named == :midnight
24
+ return MIDNIGHT unless attached
25
+ ctx.note(MIDNIGHT_END) # D6
26
+ DAY_MIDNIGHT
27
+ elsif c.meridiem
28
+ [((c.hour % 12) + (c.meridiem == :pm ? 12 : 0)) * 3600 + secs]
29
+ elsif c.hour == 0 || c.hour >= 13 || c.h24
30
+ [c.hour * 3600 + secs]
31
+ elsif c.seconds || ctx.machine
32
+ ctx.note(MACHINE) # D29
33
+ [c.hour * 3600 + secs]
34
+ elsif c.hour == 12
35
+ ctx.note(TWELVE) # D30
36
+ [43_200 + secs]
37
+ else
38
+ ctx.note(BARE_HOUR) # D4: 6-11 am, 12 and 1-5 pm, then the other half
39
+ am = c.hour * 3600 + secs
40
+ c.hour >= 6 ? [am, am + 43_200] : [am + 43_200, am]
41
+ end
42
+ end
43
+
44
+ # A clock's grain and length in seconds
45
+ SECOND = [:second, 1].freeze
46
+ MINUTE = [:minute, 60].freeze
47
+ HOUR = [:hour, 3600].freeze
48
+
49
+ def self.grain(c) = c.seconds ? (c.seconds == :hour ? HOUR : SECOND) : MINUTE
50
+
51
+ # D23: the next occurrence of each candidate (the previous one in past context); D36: now counts
52
+ def self.bare(c, ctx)
53
+ cands = candidates(c, ctx, false)
54
+ grain, len = grain(c)
55
+ now_secs = ctx.now_secs
56
+ out = []
57
+ i = 0
58
+ while i < cands.size
59
+ secs = cands[i]
60
+ day = ctx.today
61
+ if secs == now_secs
62
+ ctx.note(EQUAL_NOW) if i == 0
63
+ elsif ctx.past? ? secs > now_secs : secs < now_secs
64
+ day += ctx.past? ? -1 : 1
65
+ ctx.note(ctx.past? ? PREVIOUS : NEXT) if i == 0
66
+ end
67
+ b = day * 86_400 + secs
68
+ out << Span.new(b, b + len, grain, nil)
69
+ i += 1
70
+ end
71
+ out
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,75 @@
1
+ module Acute
2
+ module Resolve
3
+ # Calendar dates: "2026-10-23", "oct 23", "10/23", "23.10.2026", "2015"
4
+ module Dates
5
+ ORDER = Assumption.for(:numeric_date_order)
6
+ CENTURY = Assumption.for(:century_from_00)
7
+ DECADE_CENTURY = Assumption.for(:decade_or_century)
8
+ DAY = Period::DAY
9
+ LONGEST = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze
10
+
11
+ def self.spans(d, ctx)
12
+ if d.sep
13
+ out = []
14
+ orders(d, ctx).each { |x| out.concat(spans(x, ctx)) }
15
+ return out
16
+ end
17
+ return Periodic.bare(d, ctx) unless d.year
18
+ y = d.short ? two_digit_year(d.year, ctx) : d.year
19
+ return [] if y < 1 # D18
20
+ m = d.month
21
+ if (day = d.day)
22
+ return [] unless m >= 1 && m <= 12 && day >= 1 && (day <= 28 || day <= Calendar.days_in_month(y, m))
23
+ b = Calendar.days_from_civil(y, m, day) * DAY
24
+ [Span.new(b, b + DAY, :day, nil)]
25
+ elsif m
26
+ return [] unless m >= 1 && m <= 12
27
+ b = Calendar.days_from_civil(y, m, 1) * DAY
28
+ [Span.new(b, b + (m == 2 ? Calendar.days_in_month(y, m) : Calendar::MONTH_DAYS[m]) * DAY, :month, nil)]
29
+ else
30
+ [Span.new(Calendar.year_start(y) * DAY, Calendar.year_start(y + 1) * DAY, :year, nil)]
31
+ end
32
+ end
33
+
34
+ # D12: "the 15th century" from 1400, then from 1401; "the 90s" in the two-digit window; "the 2000s" the
35
+ # decade, then the century
36
+ def self.years(node, ctx)
37
+ y = node.short ? two_digit_year(node.year, ctx) : node.year
38
+ b = Calendar.year_start(y) * DAY
39
+ if node.unit == :century
40
+ ctx.note(CENTURY)
41
+ return [Span.new(b, Calendar.year_start(y + 100) * DAY, :century, nil),
42
+ Span.new(Calendar.year_start(y + 1) * DAY, Calendar.year_start(y + 101) * DAY, :century, nil)]
43
+ end
44
+ decade = Span.new(b, Calendar.year_start(y + 10) * DAY, :decade, nil)
45
+ return [decade] unless y % 100 == 0 && !node.short
46
+ ctx.note(DECADE_CENTURY)
47
+ [decade, Span.new(b, Calendar.year_start(y + 100) * DAY, :century, nil)]
48
+ end
49
+
50
+ # D10, D37: the locale's order first (dots are always day first), then the other order when it is also
51
+ # a date. The result is plain dates, month and day in place.
52
+ def self.orders(d, ctx)
53
+ out = []
54
+ [false, true].each { |swap| (x = order(d, ctx, swap)) && !out.include?(x) && out << x }
55
+ ctx.note(ORDER) if out.size > 1
56
+ out
57
+ end
58
+
59
+ # The date in the locale's order, or with swap in the other, or nil when that is no date
60
+ def self.order(d, ctx, swap)
61
+ y = d.year && d.short ? two_digit_year(d.year, ctx) : d.year
62
+ m, day = (d.sep == :dot || ctx.locale.day_first) == swap ? [d.month, d.day] : [d.day, d.month]
63
+ return unless m.between?(1, 12) && day.between?(1, y ? Calendar.days_in_month(y, m) : LONGEST[m])
64
+ G::Date.new(y, m, day, nil, false)
65
+ end
66
+
67
+ # D23: chronic's window, the 100 years from year_bias: years before now (50 by default)
68
+ def self.two_digit_year(yy, ctx)
69
+ first = Period.year(ctx.now_local) - ctx.year_bias
70
+ y = first - first % 100 + yy
71
+ y < first ? y + 100 : y
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,24 @@
1
+ module Acute
2
+ module Resolve
3
+ # Day words: today, tomorrow, yesterday
4
+ module Day
5
+ TOMORROW_EARLY = Assumption.for(:tomorrow_after_midnight)
6
+ NIGHT_ENDS = 4 * 3600
7
+
8
+ # The day's spans, most likely first
9
+ def self.whole(d, ctx)
10
+ today = ctx.today
11
+ # D40: before 04:00 the speaker's "tomorrow" is usually the coming day. Exactly 00:00 is excluded:
12
+ # it is what a Date or a time-less fixture gives as now, which means the day itself (D41)
13
+ if d.offset == 1 && ctx.now_secs > 0 && ctx.now_secs < NIGHT_ENDS
14
+ ctx.note(TOMORROW_EARLY)
15
+ [day(today), day(today + 1)]
16
+ else
17
+ [day(today + d.offset)]
18
+ end
19
+ end
20
+
21
+ def self.day(d) = Span.new(d * 86_400, d * 86_400 + 86_400, :day, nil)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,91 @@
1
+ module Acute
2
+ module Resolve
3
+ # early / mid / late / start / end of a period (D2, D35)
4
+ module Fuzzy
5
+ DAY = Period::DAY
6
+ HOUR = 3600
7
+
8
+ # D42: a repeating one is picked by the fuzzy part, so "early morning" said at 10:30 is tomorrow's
9
+ def self.spans(node, ctx)
10
+ return Periodic.bare(node, ctx) if Periodic.cycle(node.node)
11
+ Resolve.spans(node.node, ctx).flat_map { |s| apply(node.pos, s, node.node, ctx) }
12
+ end
13
+
14
+ def self.apply(pos, s, x, ctx)
15
+ part = Periodic.part_name(x)
16
+ return [part(pos, s, part)] if part
17
+ case s.grain
18
+ when :month then [days(pos, s, 10)]
19
+ when :year, :quarter, :season then [months(pos, s, ctx)]
20
+ when :week then [week(pos, s, ctx)]
21
+ when :day then [day(pos, s)]
22
+ when :decade
23
+ y = Period.year(s.b)
24
+ from, to = { early: [0, 4], mid: [4, 7], late: [7, 10], start: [0, 1], end: [9, 10] }.fetch(pos)
25
+ [Span.new(Period.civil(y + from, 1, 1), Period.civil(y + to, 1, 1), :year, nil)]
26
+ else [s]
27
+ end
28
+ end
29
+
30
+ # D2: thirds of a month (1-10, 11-20, 21-end); start and end are the first and last day
31
+ def self.days(pos, s, third)
32
+ case pos
33
+ when :start then Span.new(s.b, s.b + DAY, :day, nil)
34
+ when :end then Span.new(s.e - DAY, s.e, :day, nil)
35
+ when :early then Span.new(s.b, s.b + third * DAY, :day, nil)
36
+ when :mid then Span.new(s.b + third * DAY, s.b + 2 * third * DAY, :day, s.b + 14 * DAY)
37
+ else Span.new(s.b + 2 * third * DAY, s.e, :day, nil)
38
+ end
39
+ end
40
+
41
+ # D2: a year in thirds of four months; a quarter or season in months
42
+ def self.months(pos, s, _ctx)
43
+ return days(pos, s, 0) if pos == :start || pos == :end
44
+ n = s.grain == :year ? 4 : 1
45
+ k = { early: 0, mid: 1, late: 2 }.fetch(pos)
46
+ b = Period.add(s.b, k * n, :month)
47
+ Span.new(b, k == 2 ? s.e : Period.add(b, n, :month), :month, nil)
48
+ end
49
+
50
+ # D2: early Mon-Tue, mid Wed-Thu, late Fri-Sat; end of week is Friday; D38: start is the locale's
51
+ # first day
52
+ def self.week(pos, s, ctx)
53
+ mon = s.b + ((1 - ctx.week_start) % 7) * DAY
54
+ case pos
55
+ when :start then Span.new(s.b, s.b + DAY, :day, nil)
56
+ when :end then Span.new(mon + 4 * DAY, mon + 5 * DAY, :day, nil)
57
+ else
58
+ b = mon + { early: 0, mid: 2, late: 4 }.fetch(pos) * DAY
59
+ Span.new(b, b + 2 * DAY, :day, nil)
60
+ end
61
+ end
62
+
63
+ # D2: end of day is 23:59:59
64
+ def self.day(pos, s)
65
+ case pos
66
+ when :start then Span.new(s.b, s.b + 1, :second, nil)
67
+ when :end then Span.new(s.e - 1, s.e, :second, nil)
68
+ else
69
+ b = s.b + { early: 0, mid: 8, late: 16 }.fetch(pos) * HOUR
70
+ Span.new(b, b + 8 * HOUR, :hour, nil)
71
+ end
72
+ end
73
+
74
+ # D1: early morning 04-08 and late night 22-04; D35: otherwise halves; D40: mid is the middle third
75
+ def self.part(pos, s, name)
76
+ day = Period.day(s.b) * DAY
77
+ if pos == :early && name == :morning then Span.new(day + 4 * HOUR, day + 8 * HOUR, :hour, day + 6 * HOUR)
78
+ elsif pos == :late && (name == :night || name == :tonight)
79
+ Span.new(day + 22 * HOUR, day + 28 * HOUR, :hour, day + 23 * HOUR)
80
+ else
81
+ len = s.e - s.b
82
+ case pos
83
+ when :early, :start then Span.new(s.b, s.b + len / 2, :hour, nil)
84
+ when :mid then Span.new(s.b + len / 3, s.b + 2 * len / 3, :hour, nil)
85
+ else Span.new(s.b + len / 2, s.e, :hour, nil)
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end