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/cli.rb ADDED
@@ -0,0 +1,168 @@
1
+ require "optparse"
2
+ require_relative "../acute"
3
+
4
+ module Acute
5
+ # exe/acute: one phrase from the arguments, or one per line from stdin (D45)
6
+ # @api private
7
+ class CLI
8
+ CLOCK_GRAINS = %i[second minute hour].freeze
9
+ # the library's DST hints name its Ruby option; the command's name its flag
10
+ DST_HINTS = { dst_skipped_time: "--dst earlier, later or compatible picks an instant for it",
11
+ dst_repeated_time: "--dst earlier or later picks one" }.freeze
12
+
13
+ def self.run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr) = new(stdin, stdout, stderr).run(argv)
14
+
15
+ def initialize(stdin, stdout, stderr)
16
+ @stdin = stdin
17
+ @out = stdout
18
+ @err = stderr
19
+ @json = false
20
+ @next = nil
21
+ @options = {}
22
+ end
23
+
24
+ def run(argv)
25
+ return mcp(argv.drop(1)) if argv.first == "mcp"
26
+ args = options.parse(argv)
27
+ return say(@say) if @say
28
+ @parser = Acute.with(**@options)
29
+ return phrase(args.join(" ")) if args.any?
30
+ return usage if @stdin.tty?
31
+ @out.sync = true
32
+ @stdin.each_line.map { _1.b.strip.empty? || line(_1.chomp) }.all? ? 0 : 1
33
+ rescue OptionParser::ParseError, ArgumentError => e
34
+ @err.puts "acute: #{Acute.visible(e.message.scrub)}"
35
+ 2
36
+ end
37
+
38
+ private
39
+
40
+ def options
41
+ OptionParser.new do |o|
42
+ o.banner = "Usage: acute [options] PHRASE\n acute [options] < phrases.txt (one per line)\n " \
43
+ "acute mcp [options] (an MCP server: acute mcp --help)"
44
+ o.on("--json", "Print JSON (JSON Lines for stdin)") { @json = true }
45
+ o.on("--next N", Integer, "A rule's first N occurrences, 1–#{Rule::MAX_OCCURRENCES}") do |n|
46
+ raise OptionParser::InvalidArgument, n.to_s unless (1..Rule::MAX_OCCURRENCES).cover?(n)
47
+ @next = n
48
+ end
49
+ o.on("--now TIME", "Now, in ISO 8601 (default: the clock)") { @options[:now] = _1 }
50
+ o.on("--zone ZONE", "IANA zone or offset (default: the system's)") { @options[:zone] = _1 }
51
+ o.on("--locale LOCALE", "en-US, en-GB, en-AU… (default: from the zone)") { @options[:locale] = _1 }
52
+ o.on("--context CONTEXT", %w[future past], "future or past") { @options[:context] = _1.to_sym }
53
+ o.on("--week-start DAY", %w[sunday monday], "sunday or monday") { @options[:week_start] = _1.to_sym }
54
+ o.on("--hemisphere SIDE", %w[north south], "north or south, for seasons") { @options[:hemisphere] = _1.to_sym }
55
+ o.on("--fiscal-year-start MONTH", Integer, "1–12") { @options[:fiscal_year_start] = _1 }
56
+ o.on("--lenient", "Skip unknown words (strict: false)") { @options[:strict] = false }
57
+ o.on("--dst POLICY", %w[compatible earlier later reject], "compatible, earlier, later or reject") { @options[:dst] = _1.to_sym }
58
+ o.on("--holidays REGION", "A holidays-gem region, such as us (needs the gem)") { @options[:holidays] = _1 }
59
+ o.on("--year-bias YEARS", Integer, "Two-digit years: the 100 from this many years ago (default 50)") { @options[:year_bias] = _1 }
60
+ o.on("-v", "--version") { @say = Acute::VERSION }
61
+ o.on("-h", "--help") { @say = o.to_s }
62
+ end
63
+ end
64
+
65
+ # The options an operator fixes for every call. now is always the clock; the model may pass zone per call.
66
+ def mcp(argv)
67
+ options = {}
68
+ parser = OptionParser.new do |o|
69
+ o.banner = "Usage: acute mcp [options]\n" \
70
+ "Serves acute's tools to an MCP client over stdio: one JSON-RPC message per line."
71
+ o.on("--zone ZONE", "IANA zone or offset when a call names none (default: the system's)") { options[:zone] = _1 }
72
+ o.on("--locale LOCALE", "en-US, en-GB, en-AU… (default: from the zone)") { options[:locale] = _1 }
73
+ o.on("--context CONTEXT", %w[future past], "Default for calls that give none: future or past") { options[:context] = _1.to_sym }
74
+ o.on("--week-start DAY", %w[sunday monday], "sunday or monday") { options[:week_start] = _1.to_sym }
75
+ o.on("-h", "--help") { return say(o.to_s) }
76
+ end
77
+ extra = parser.parse(argv)
78
+ raise OptionParser::NeedlessArgument, extra.join(" ") if extra.any?
79
+ require_relative "mcp"
80
+ server = MCP.new(Tool.new(**options), log: @err)
81
+ @stdin.binmode
82
+ server.serve(@stdin, @out)
83
+ 0
84
+ end
85
+
86
+ def say(text)
87
+ @out.puts text
88
+ 0
89
+ end
90
+
91
+ def usage
92
+ @err.puts options
93
+ 2
94
+ end
95
+
96
+ # A single phrase that fails exits 1 with parse!'s reason
97
+ def phrase(text)
98
+ value = reading(text)
99
+ return show(value) || 0 if value
100
+ @parser.parse!(text)
101
+ rescue ParseError => e
102
+ @json ? @out.puts(failure(e, text)) : @err.puts("acute: #{e.message}")
103
+ 1
104
+ end
105
+
106
+ def line(text)
107
+ value = reading(text)
108
+ return show(value, batch: true) || true if value
109
+ @parser.parse!(text)
110
+ rescue ParseError => e
111
+ @out.puts @json ? failure(e, text) : "error: #{e.message}"
112
+ false
113
+ end
114
+
115
+ # schema/failure.json, as the tools give it, plus the input line, which a tool never echoes. Like the tools, a
116
+ # position over the whole phrase is left out.
117
+ def failure(e, text)
118
+ text = text.dup.force_encoding(Encoding::UTF_8).scrub
119
+ json = { kind: "failure", text:, code: e.code.to_s, error: e.message }
120
+ json[:position] = [e.position.begin, e.position.end] unless whole?(e.position, text)
121
+ json[:hint] = DST_HINTS.fetch(e.code, e.hint) if e.hint
122
+ JSON.generate(json)
123
+ end
124
+
125
+ def whole?(span, text) = text[0...span.begin].strip.empty? && text[span.end..].to_s.strip.empty?
126
+
127
+ # --next asks for a rule, so a phrase that isn't one fails with recurrence!'s reason
128
+ def reading(text) = @next ? @parser.recurrence!(text) : @parser.read(text)
129
+
130
+ # A rule is several lines, joined into one for stdin's one line per phrase
131
+ def show(value, batch: false)
132
+ return @out.puts(@json ? value.to_json : echo(value)) unless Rule === value
133
+ at = @next && occurrences(value)
134
+ return @out.puts(at ? value.to_h.merge(next: at.map(&:iso8601)).to_json : value.to_json) if @json
135
+ lines = [value.to_s, *value.to_ical.lines(chomp: true)]
136
+ lines << "cron: #{value.cron} (CRON_TZ=#{value.zone})" if value.cron
137
+ lines.concat(at.map { occurrence(_1, value.zone) }) if at
138
+ @out.puts batch ? lines.join(" ") : lines
139
+ end
140
+
141
+ # The rule's first n occurrences, DTSTART's included: the Date before it starts the search
142
+ def occurrences(rule) = rule.next!(@next, after: rule.dtstart.to_date - 1)
143
+
144
+ def occurrence(at, zone)
145
+ text = @parser.describe(at, zone: zone, style: :calendar)
146
+ Time === at ? "#{text} #{Zone.for(zone).abbreviation_at(at.to_i)}" : text
147
+ end
148
+
149
+ # "Fri, Oct 23, 3pm EDT (also: Fri, Oct 16, 3pm)"
150
+ def echo(value)
151
+ return @parser.describe(value) if Duration === value
152
+ text = described(value)
153
+ return text unless value.ambiguous?
154
+ said = abbreviation(value)
155
+ "#{text} (also: #{value.alternatives.map { described(_1, said) }.join("; ")})"
156
+ end
157
+
158
+ # A time of day names its zone, unless describe already did or the echo already has
159
+ def described(r, said = nil)
160
+ text = @parser.describe(r, zone: r.zone, style: :calendar)
161
+ abbr = abbreviation(r)
162
+ return text if abbr.nil? || abbr == said || text.end_with?(abbr) || text.match?(/[+-]\d\d:\d\d\z/)
163
+ "#{text} #{abbr}"
164
+ end
165
+
166
+ def abbreviation(r) = CLOCK_GRAINS.include?(r.grain) ? Zone.for(r.zone).abbreviation_at(r.time.to_i) : nil
167
+ end
168
+ end
@@ -0,0 +1,205 @@
1
+ require "date"
2
+ require_relative "calendar"
3
+ require_relative "zone"
4
+ require_relative "assumption"
5
+ require_relative "locale"
6
+ require_relative "holidays"
7
+
8
+ module Acute
9
+ # Everything a parse depends on besides the text: now, the zone and the direction. It also collects the
10
+ # assumptions the resolver makes, so it is built fresh for every parse.
11
+ # @api private
12
+ class Context
13
+ ISO = /\A(\d{4})-(\d\d)-(\d\d)(?:[T ](\d\d):(\d\d)(?::(\d\d)(?:\.\d+)?)?)?(Z|[+-]\d\d:?\d\d)?\z/
14
+
15
+ # TZ may start with POSIX's ":", and /etc/localtime may link into posix/ or right/, or to a zone the shipped
16
+ # data doesn't hold: only a name acute ships is used, so a parse without zone: never raises for it
17
+ def self.system_zone(tz = ENV["TZ"], link = "/etc/localtime")
18
+ tz = tz&.delete_prefix(":")
19
+ return tz if tz && Zone.known?(tz)
20
+ name = File.readlink(link)[%r{zoneinfo/(?:posix/|right/)?(.+)\z}, 1]
21
+ name && Zone.known?(name) ? name : "UTC"
22
+ rescue SystemCallError
23
+ "UTC"
24
+ end
25
+
26
+ SYSTEM_ZONE = system_zone.freeze
27
+ ZONE_INFERRED = Assumption.for(:zone_inferred)
28
+ NO_NOTES = [].freeze
29
+
30
+ WEEK_STARTS = { sunday: 0, monday: 1 }.freeze
31
+ HEMISPHERES = { north: false, south: true }.freeze
32
+ DST_POLICIES = { compatible: nil, earlier: :earlier, later: :later, reject: :reject }.freeze
33
+ YEAR_BIAS = 50
34
+
35
+ attr_reader :now, :now_local, :zone, :today, :now_secs, :notes, :fiscal_year_start, :strict, :dst_policy,
36
+ :holidays, :year_bias
37
+ # past: true in past context; :back while resolve looks back for the most recent one, which adds no
38
+ # alternatives of its own (D44)
39
+ attr_accessor :dst, :past, :machine
40
+ # D47: the fraction of a second a clock in the text was written with
41
+ attr_accessor :frac
42
+
43
+ # The zone a TimeWithZone, or a Time made with a TZInfo zone, brings with it
44
+ def self.carried_zone(now)
45
+ if now.respond_to?(:time_zone) then now.time_zone.tzinfo
46
+ elsif now.is_a?(Time) && Zone.tzinfo?(now.zone) then now.zone
47
+ end
48
+ end
49
+
50
+ def initialize(now, zone, context, locale = nil, week_start = nil, hemisphere = nil, fiscal_year_start = nil,
51
+ strict = true, dst = nil, holidays = nil, business_days = nil, year_bias = nil)
52
+ @past = case context
53
+ when :future then false
54
+ when :past then true
55
+ else raise OptionError.new("context: must be :future or :past", option: :context, value: context)
56
+ end
57
+ if zone
58
+ @zone = Zone.for(zone)
59
+ else
60
+ @zone = Zone.for(Context.carried_zone(now) || rails_zone || SYSTEM_ZONE)
61
+ @inferred = true
62
+ end
63
+ @now = Time === now ? now.to_i : epoch(now)
64
+ self.zone = @zone
65
+ @locale_zone = @zone.name
66
+ @strict = strict
67
+ @year_bias = YEAR_BIAS
68
+ # Most parses give none of the other options, and then skip their checks
69
+ return unless locale || week_start || hemisphere || fiscal_year_start || strict != true || dst || holidays || business_days || year_bias
70
+ options(now, zone, context, locale, week_start, hemisphere, fiscal_year_start, strict, dst, holidays, business_days, year_bias)
71
+ end
72
+
73
+ def fresh = Context.new(*@args).tap { _1.budget = budget }
74
+
75
+ # D49: the business days this call may still count, shared with the contexts it makes for each piece (T17)
76
+ attr_writer :budget
77
+
78
+ def budget = @budget ||= Resolve::Period::Budget.new
79
+
80
+ # D47: the days business-day counts step over, or nil for Monday to Friday. The built-in holidays only name
81
+ # days; a holidays: given is taken as days off.
82
+ def business_days = @business_days || (@holidays && (budget.workdays ||= Holidays.workdays(@holidays)))
83
+
84
+ # Under a zone named in the text, today and now_secs move with it; returns the block's value
85
+ def in_zone(zone)
86
+ home = @zone
87
+ now_local = @now_local
88
+ self.zone = zone
89
+ out = yield
90
+ @zone = home
91
+ @now_local = now_local
92
+ @today = now_local / 86_400
93
+ @now_secs = now_local - @today * 86_400
94
+ out
95
+ end
96
+
97
+ private def zone=(zone)
98
+ @zone = zone
99
+ @now_local = local = @now + zone.offset_at(@now)
100
+ @today = local / 86_400
101
+ @now_secs = local - @today * 86_400
102
+ end
103
+
104
+ # The wall time of an instant, in local seconds
105
+ def local(utc) = utc + @zone.offset_at(utc)
106
+
107
+ # Found only when a phrase needs it, from the zone given rather than one named in the text
108
+ def locale = @locale ||= Locale.for_zone(@locale_zone)
109
+
110
+ def week_start = @week_start ||= locale.week_start
111
+
112
+ def south = @south.nil? ? (@south = locale.south) : @south
113
+
114
+ def past? = @past
115
+
116
+ def note(assumption)
117
+ if @notes
118
+ @notes << assumption unless @notes.include?(assumption)
119
+ else
120
+ @notes = [assumption]
121
+ end
122
+ end
123
+
124
+ def unnote(assumption) = @notes&.delete(assumption)
125
+
126
+ # The main reading's assumptions: the rules applied, then its DST adjustment, then the zone
127
+ def assumptions(main)
128
+ dst = main.dst unless @dst_policy # D47: a policy the caller chose is not an assumption
129
+ return NO_NOTES unless @notes || dst || @inferred
130
+ list = @notes || []
131
+ list << Assumption.for(dst) if dst
132
+ list << ZONE_INFERRED if @inferred
133
+ list.freeze
134
+ end
135
+
136
+ private
137
+
138
+ def options(now, zone, context, locale, week_start, hemisphere, fiscal_year_start, strict, dst, holidays, business_days, year_bias)
139
+ raise OptionError.new("strict: must be true or false", option: :strict, value: strict) unless strict == true || strict == false
140
+ @locale = Locale.for(locale) if locale
141
+ @week_start = week_start && choice(:week_start, week_start, WEEK_STARTS, ":sunday or :monday")
142
+ @south = hemisphere && choice(:hemisphere, hemisphere, HEMISPHERES, ":north or :south")
143
+ @fiscal_year_start = fiscal_year_start && whole(:fiscal_year_start, fiscal_year_start, 1..12, "a month, 1 to 12")
144
+ unless strict
145
+ # Lenient parsing resolves each piece it tries in a fresh context
146
+ @args = [now || Time.at(@now), zone, context, locale, week_start, hemisphere, fiscal_year_start, strict, dst,
147
+ holidays, business_days, year_bias]
148
+ end
149
+ @dst_policy = dst && choice(:dst, dst, DST_POLICIES, ":compatible, :earlier, :later or :reject")
150
+ @holidays = holidays && Holidays.for(holidays)
151
+ if business_days
152
+ unless business_days.respond_to?(:business_day?)
153
+ raise OptionError.new("business_days: must answer business_day?(date)", option: :business_days,
154
+ value: business_days)
155
+ end
156
+ @business_days = business_days
157
+ end
158
+ @year_bias = whole(:year_bias, year_bias, 0..99, "a number of years, 0 to 99") if year_bias
159
+ end
160
+
161
+ def choice(option, value, table, allowed)
162
+ key = (Symbol === value || (String === value && value.valid_encoding?)) && value.to_sym
163
+ table.fetch(key) { raise OptionError.new("#{option}: must be #{allowed}", option: option, value: value) }
164
+ end
165
+
166
+ def whole(option, value, range, allowed)
167
+ n = Integer === value ? value : (String === value && Integer(value, 10, exception: false))
168
+ return n if n && range.cover?(n)
169
+ raise OptionError.new("#{option}: must be #{allowed}", option: option, value: value)
170
+ end
171
+
172
+ def rails_zone
173
+ Time.zone&.tzinfo if defined?(ActiveSupport::TimeZone) && Time.respond_to?(:zone)
174
+ end
175
+
176
+ def epoch(now)
177
+ case now
178
+ when nil then Time.now.to_i
179
+ when Time then now.to_i
180
+ when DateTime then now.to_time.to_i
181
+ when Date then @zone.local_to_utc((now.jd - 2_440_588) * 86_400, self)
182
+ when String then iso_epoch(now)
183
+ else
184
+ return now.to_i if now.respond_to?(:time_zone)
185
+ raise OptionError.new("now: must be a Time, Date or ISO 8601 String", option: :now, value: now)
186
+ end
187
+ end
188
+
189
+ def iso_epoch(str)
190
+ m = str.valid_encoding? && ISO.match(str) or raise OptionError.new("now: #{Acute.quote(str)} is not ISO 8601", option: :now, value: str)
191
+ y, mo, d, h, mi, s = m.captures.first(6).map(&:to_i)
192
+ offset = m[7]&.delete(":")
193
+ # an offset is within ±23:59, as a zone's is
194
+ unless mo.between?(1, 12) && d.between?(1, Calendar.days_in_month(y, mo)) && h < 24 && mi < 60 && s < 60 &&
195
+ (offset.nil? || offset == "Z" || (offset[1, 2].to_i < 24 && offset[3, 2].to_i < 60))
196
+ raise OptionError.new("now: #{Acute.quote(str)} is not a valid time", option: :now, value: str)
197
+ end
198
+ local = Calendar.days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + s
199
+ return @zone.local_to_utc(local, self) unless offset
200
+ return local if offset == "Z"
201
+ sign = offset.start_with?("-") ? -1 : 1
202
+ local - sign * (offset[1, 2].to_i * 3600 + offset[3, 2].to_i * 60)
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,316 @@
1
+ module Acute
2
+ def self.describe(value, 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
+ style: :relative)
5
+ raise OptionError.new("style: must be :relative or :calendar", option: :style, value: style) unless Describe::STYLES.include?(style)
6
+ zone ||= value.zone if Result === value
7
+ ctx = Context.new(now, zone, context, locale, week_start, hemisphere, fiscal_year_start, strict, dst, holidays,
8
+ business_days, year_bias)
9
+ return Describe.duration(value) if Duration === value
10
+ return value.to_s if Rule === value
11
+ # strict: is left out: what describe writes always reads back strictly
12
+ opts = { now: Time.at(ctx.now), zone: zone || ctx.zone.name, context: context, locale: locale,
13
+ week_start: week_start, hemisphere: hemisphere, fiscal_year_start: fiscal_year_start, dst: dst,
14
+ holidays: holidays, business_days: business_days, year_bias: year_bias }
15
+ Describe.new(value, style == :calendar, ctx, opts).text
16
+ end
17
+
18
+ # English that Acute.parse reads back as the value given (D45). Candidates come most natural first; each is
19
+ # parsed with the same now, zone and options, and the first that gives the value as its only reading wins.
20
+ # The last candidates write the date, the year, the zone and finally ISO 8601, which always read back.
21
+ # @api private
22
+ class Describe
23
+ STYLES = %i[relative calendar].freeze
24
+ MONTHS = Result::MONTHS
25
+ WEEKDAYS = Result::WEEKDAYS
26
+ MONTH_NAMES = Ractor.make_shareable(%w[January February March April May June July August September October
27
+ November December])
28
+ DAY_NAMES = Ractor.make_shareable(%w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday])
29
+ PARTS = Ractor.make_shareable(%w[morning afternoon evening night])
30
+ SEASONS = Ractor.make_shareable(%w[spring summer autumn winter])
31
+ DAY = 86_400
32
+
33
+ # Hours, minutes and seconds carried upward ("5400s" is "1h 30m"); never into days, which DST can stretch (D23)
34
+ def self.duration(d)
35
+ p = d.parts
36
+ return d.to_s unless p.keys.intersect?(%i[hours minutes seconds])
37
+ total = p.fetch(:hours, 0) * 3600 + p.fetch(:minutes, 0) * 60 + p.fetch(:seconds, 0)
38
+ h, rest = total.divmod(3600)
39
+ m, s = rest.divmod(60)
40
+ Duration.new(**p.except(:hours, :minutes, :seconds), hours: h, minutes: m, seconds: s).to_s
41
+ end
42
+
43
+ # "9am", "9:30am", "9:00:30am", "12am" for midnight
44
+ def self.clock(secs, meridiem = true)
45
+ h = secs / 3600
46
+ m = secs / 60 % 60
47
+ s = secs % 60
48
+ out = (h % 12 == 0 ? 12 : h % 12).to_s
49
+ out << format(":%02d", m) if m != 0 || s != 0
50
+ out << format(":%02d", s) if s != 0
51
+ out << (h < 12 ? "am" : "pm") if meridiem
52
+ out
53
+ end
54
+
55
+ # A value as the span it stands for: an instant, or begin and end with either open
56
+ Target = Struct.new(:b, :e, :point, :instant) do
57
+ def self.for(value, ctx)
58
+ case value
59
+ when Result
60
+ new(value.begin&.to_i, value.end&.to_i, value.time.to_i, value.instant?)
61
+ when Range then range(value, ctx)
62
+ when Date then range(value..value, ctx)
63
+ when DateTime, Time then new(nil, nil, value.to_time.to_i, true)
64
+ else
65
+ raise ArgumentError, "expected a Result, Time, Date, Range or Duration, got #{value.class}" unless value.respond_to?(:time_zone)
66
+ new(nil, nil, value.to_time.to_i, true)
67
+ end
68
+ end
69
+
70
+ # The end is exclusive, as in a Result; a Date end includes its day unless the range excludes it
71
+ def self.range(r, ctx)
72
+ b = edge(r.begin, false, ctx)
73
+ e = edge(r.end, !r.exclude_end?, ctx)
74
+ raise ArgumentError, "a range needs at least one end" unless b || e
75
+ raise ArgumentError, "the range runs backwards or is empty (a day the zone skipped is empty)" if b && e && e <= b
76
+ new(b, e, nil, false)
77
+ end
78
+
79
+ def self.edge(x, through, ctx)
80
+ case x
81
+ when nil then nil
82
+ when DateTime, Time then x.to_time.to_i
83
+ when Date then ctx.zone.local_to_utc((x.jd - 2_440_588 + (through ? 1 : 0)) * DAY, ctx)
84
+ else
85
+ raise ArgumentError, "a range's ends must be Times or Dates, got #{x.class}" unless x.respond_to?(:time_zone)
86
+ x.to_time.to_i
87
+ end
88
+ end
89
+ end
90
+
91
+ def initialize(value, calendar, ctx, opts)
92
+ @calendar = calendar
93
+ @ctx = ctx
94
+ @zone = ctx.zone
95
+ @opts = opts
96
+ @today = ctx.today
97
+ @day_first = ctx.locale.day_first
98
+ t = Target.for(value, ctx)
99
+ @b, @e, @point, @instant = t.b, t.e, t.point, t.instant
100
+ end
101
+
102
+ def text
103
+ found = to_enum(:candidates).find { fits?(_1) }
104
+ return found if found
105
+ return @last unless @point && !@instant
106
+ # No part of the day says this span's point, so the span alone is written
107
+ @point = nil
108
+ to_enum(:candidates).find { fits?(_1) } || @last
109
+ end
110
+
111
+ private
112
+
113
+ def fits?(text)
114
+ @last = text
115
+ r = Acute.parse(text, **@opts)
116
+ return false unless r && !r.ambiguous?
117
+ return !r.span? && r.time.to_i == @point if @instant
118
+ r.begin&.to_i == @b && r.end&.to_i == @e && (@point.nil? || r.time.to_i == @point)
119
+ end
120
+
121
+ def candidates(&)
122
+ return instant(@point, &) if @instant
123
+ return before(&) unless @b
124
+ return after(&) unless @e
125
+ part_of_day(&) if @point && @point != @b
126
+ aligned?(@b) && aligned?(@e) ? days(&) : span(&)
127
+ end
128
+
129
+ def instant(p)
130
+ unless @calendar
131
+ yield "now" if p == @ctx.now
132
+ w = day_word(day_of(p))
133
+ yield "#{w} at #{clock(secs_of(p))}" if w
134
+ end
135
+ dated(p) { yield _1 }
136
+ end
137
+
138
+ # "Sun, Oct 18, 9am", then with the year, its zone's abbreviation, its offset, and ISO 8601
139
+ def dated(p)
140
+ d = day_of(p)
141
+ c = clock(secs_of(p))
142
+ yield "#{date(d, false)}, #{c}"
143
+ at = "#{date(d, true)}, #{c}"
144
+ yield at
145
+ yield "#{at} #{@zone.abbreviation_at(p)}"
146
+ yield "#{at} #{offset(p)}"
147
+ yield iso(p)
148
+ end
149
+
150
+ def days_named(d)
151
+ if !@calendar && (w = day_word(d))
152
+ yield w
153
+ end
154
+ yield date(d, false)
155
+ yield date(d, true)
156
+ end
157
+
158
+ def after
159
+ if aligned?(@b)
160
+ d = day_of(@b)
161
+ days_named(d) { yield "#{_1} onwards" }
162
+ days_named(d - 1) { yield "after #{_1}" }
163
+ end
164
+ yield "after #{clock(secs_of(@b))}" unless @calendar
165
+ dated(@b) do |x|
166
+ yield "after #{x}"
167
+ yield "#{x} onwards"
168
+ end
169
+ end
170
+
171
+ def before
172
+ days_named(day_of(@e)) { yield "before #{_1}" } if aligned?(@e)
173
+ yield "before #{clock(secs_of(@e))}" unless @calendar
174
+ dated(@e) { yield "before #{_1}" }
175
+ end
176
+
177
+ # "tonight", "tomorrow morning", "Sun, Oct 18, morning": a part of the day with its point
178
+ def part_of_day
179
+ d = day_of(@point)
180
+ unless @calendar
181
+ if d == @today
182
+ yield "tonight"
183
+ PARTS.each { yield "this #{_1}" }
184
+ end
185
+ yield "last night" if d == @today - 1
186
+ if (w = day_word(d))
187
+ PARTS.each { yield "#{w} #{_1}" }
188
+ end
189
+ end
190
+ [false, true].each { |year| PARTS.each { |part| yield "#{date(d, year)}, #{part}" } }
191
+ end
192
+
193
+ # Whole days: a day, a named period, or the first and last days
194
+ def days(&)
195
+ d1 = day_of(@b)
196
+ d2 = day_of(@e)
197
+ return days_named(d1, &) if d2 - d1 == 1
198
+ y1, m1, dd1 = ymd(d1)
199
+ y2, m2, dd2 = ymd(d2)
200
+ months = (y2 - y1) * 12 + m2 - m1
201
+ if d2 - d1 == 7 && Calendar.weekday(d1) == @ctx.week_start
202
+ relative("week", &)
203
+ yield "the week of #{md(d1, false)}"
204
+ yield "the week of #{md(d1, true)}"
205
+ elsif dd1 == 1 && dd2 == 1
206
+ if months == 1
207
+ relative("month", &)
208
+ yield MONTH_NAMES[m1 - 1]
209
+ yield "#{MONTH_NAMES[m1 - 1]} #{y1}"
210
+ elsif months == 3
211
+ if m1 % 3 == 1
212
+ relative("quarter", &)
213
+ yield "Q#{m1 / 3 + 1}"
214
+ yield "Q#{m1 / 3 + 1} #{y1}"
215
+ end
216
+ SEASONS.each { yield "#{_1} #{y1}" }
217
+ elsif m1 == 1 && m2 == 1
218
+ if months == 12
219
+ relative("year", &)
220
+ yield y1.to_s
221
+ end
222
+ yield "the #{y1}s" if months == 120 && y1 % 10 == 0
223
+ yield "#{y1} – #{y2 - 1}"
224
+ end
225
+ end
226
+ yield "#{md(d1, false)} – #{md(d2 - 1, false)}"
227
+ yield "#{md(d1, true)} – #{md(d2 - 1, true)}"
228
+ yield "#{iso(@b)} – #{iso(@e)}"
229
+ end
230
+
231
+ def relative(unit)
232
+ return if @calendar
233
+ yield "this #{unit}"
234
+ yield "next #{unit}"
235
+ yield "last #{unit}"
236
+ end
237
+
238
+ # A span with clock times: "today, 2–5pm", "since Oct 10, 2pm", "Oct 10, 2pm – Oct 17, 2pm"
239
+ def span
240
+ d1 = day_of(@b)
241
+ s1 = secs_of(@b)
242
+ s2 = secs_of(@e)
243
+ d2 = day_of(@e)
244
+ c1 = clock(s1)
245
+ c2 = clock(s2)
246
+ if !@calendar && @e == @ctx.now
247
+ days_named(d1) { yield "since #{_1}" } if aligned?(@b)
248
+ yield "since #{md(d1, false)}, #{c1}"
249
+ dated(@b) { yield "since #{_1}" }
250
+ end
251
+ if d2 == d1 || (d2 == d1 + 1 && aligned?(@e))
252
+ short = s2 != 0 && (s1 < 43_200) == (s2 < 43_200) ? "#{clock(s1, false)}–#{c2}" : "#{c1}–#{c2}"
253
+ days_named(d1) do |x|
254
+ yield "#{x}, #{short}"
255
+ yield "#{x}, #{c1} – #{c2}"
256
+ end
257
+ end
258
+ if !@calendar && (w1 = day_word(d1)) && (w2 = day_word(d2))
259
+ yield "#{w1}, #{c1} – #{w2}, #{c2}"
260
+ end
261
+ yield "#{md(d1, false)}, #{c1} – #{md(d2, false)}, #{c2}"
262
+ yield "#{md(d1, true)}, #{c1} – #{md(d2, true)}, #{c2}"
263
+ yield "#{md(d1, true)}, #{c1} #{@zone.abbreviation_at(@b)} – #{md(d2, true)}, #{c2} #{@zone.abbreviation_at(@e)}"
264
+ yield "#{iso(@b)} – #{iso(@e)}"
265
+ end
266
+
267
+ # "today", "tomorrow", "yesterday", "Friday" up to six days ahead, "last Friday" up to six back
268
+ def day_word(d)
269
+ case d - @today
270
+ when 0 then "today"
271
+ when 1 then "tomorrow"
272
+ when -1 then "yesterday"
273
+ when 2..6 then DAY_NAMES[Calendar.weekday(d)]
274
+ when -6..-2 then "last #{DAY_NAMES[Calendar.weekday(d)]}"
275
+ end
276
+ end
277
+
278
+ def ymd(d)
279
+ x = Calendar.civil_from_days(d)
280
+ [x / 10_000, x / 100 % 100, x % 100]
281
+ end
282
+
283
+ # "Oct 18" or "18 Oct", with the year: "Oct 18, 2026", "18 Oct 2026"
284
+ def md(d, year)
285
+ y, m, dd = ymd(d)
286
+ @day_first ? "#{dd} #{MONTHS[m - 1]}#{" #{y}" if year}" : "#{MONTHS[m - 1]} #{dd}#{", #{y}" if year}"
287
+ end
288
+
289
+ # "Sun, Oct 18" or "Sun 18 Oct"
290
+ def date(d, year)
291
+ wd = WEEKDAYS[Calendar.weekday(d)]
292
+ @day_first ? "#{wd} #{md(d, year)}" : "#{wd}, #{md(d, year)}"
293
+ end
294
+
295
+ def clock(secs, meridiem = true) = Describe.clock(secs, meridiem)
296
+
297
+ def local(epoch) = epoch + @zone.offset_at(epoch)
298
+
299
+ def day_of(epoch) = local(epoch).div(DAY)
300
+
301
+ def secs_of(epoch) = local(epoch) % DAY
302
+
303
+ def day_start(d) = @zone.local_to_utc(d * DAY, @ctx)
304
+
305
+ def aligned?(epoch) = day_start(day_of(epoch)) == epoch
306
+
307
+ def offset(epoch)
308
+ o = @zone.offset_at(epoch)
309
+ format("%s%02d:%02d", o < 0 ? "-" : "+", o.abs / 3600, o.abs / 60 % 60)
310
+ end
311
+
312
+ def iso(epoch) = Time.at(epoch, in: @zone.offset_at(epoch)).strftime("%FT%T%:z")
313
+ end
314
+
315
+ private_constant :Describe
316
+ end