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,91 @@
1
+ # Proleptic Gregorian dates as day numbers (days since 1970-01-01), after Howard Hinnant's algorithms.
2
+ module Acute
3
+ # @api private
4
+ module Calendar
5
+ MONTH_DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze
6
+
7
+ def self.days_from_civil(y, m, d)
8
+ y -= 1 if m <= 2
9
+ era = y / 400
10
+ yoe = y - era * 400
11
+ doy = (153 * (m > 2 ? m - 3 : m + 9) + 2) / 5 + d - 1
12
+ doe = yoe * 365 + yoe / 4 - yoe / 100 + doy
13
+ era * 146_097 + doe - 719_468
14
+ end
15
+
16
+ # The day number of January 1st of year y
17
+ def self.year_start(y)
18
+ y -= 1
19
+ 365 * y + y / 4 - y / 100 + y / 400 - 719_162
20
+ end
21
+
22
+ # The year whose January 1st is day d, or nil when d is no January 1st. The guess counts average years
23
+ # to the year's middle, which is exact for every January 1st.
24
+ def self.year_if_first(d)
25
+ y = (d + 719_344) * 400 / 146_097
26
+ y + 1 if 365 * y + y / 4 - y / 100 + y / 400 - 719_162 == d
27
+ end
28
+
29
+ # The year holding day d. The guess counts average years from year 1 and is the year or the one before.
30
+ def self.year_of(d)
31
+ y = (d + 719_162) * 400 / 146_097 + 1
32
+ 365 * y + y / 4 - y / 100 + y / 400 - 719_162 <= d ? y + 1 : y
33
+ end
34
+
35
+ # D17: the day n (not 0) Mondays to Fridays after day d, or before it when n is negative. A weekend counts from the
36
+ # Friday before it going forward, and the Monday after it going back.
37
+ def self.add_weekdays(d, n)
38
+ m = (d + 3) % 7 # Monday 0 to Sunday 6
39
+ if n > 0
40
+ d -= m - 4 if m > 4
41
+ r = n % 5
42
+ d + n / 5 * 7 + r + ((m > 4 ? 4 : m) + r > 4 ? 2 : 0)
43
+ else
44
+ n = -n
45
+ d += 7 - m if m > 4
46
+ r = n % 5
47
+ d - n / 5 * 7 - r - ((m > 4 ? 0 : m) < r ? 2 : 0)
48
+ end
49
+ end
50
+
51
+ # Packed as y * 10000 + m * 100 + d, so the hot path allocates no Array
52
+ def self.civil_from_days(z)
53
+ z += 719_468
54
+ era = z / 146_097
55
+ doe = z - era * 146_097
56
+ yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365
57
+ y = yoe + era * 400
58
+ doy = doe - (365 * yoe + yoe / 4 - yoe / 100)
59
+ mp = (5 * doy + 2) / 153
60
+ d = doy - (153 * mp + 2) / 5 + 1
61
+ m = mp < 10 ? mp + 3 : mp - 9
62
+ y += 1 if m <= 2
63
+ y * 10_000 + m * 100 + d
64
+ end
65
+
66
+ def self.leap?(y) = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
67
+
68
+ def self.days_in_month(y, m) = m == 2 && leap?(y) ? 29 : MONTH_DAYS[m]
69
+
70
+ # 0 = Sunday
71
+ def self.weekday(days) = (days + 4) % 7
72
+
73
+ # The Monday that starts ISO week 1 of year y: the week holding January 4th
74
+ def self.iso_week_one(y)
75
+ jan4 = days_from_civil(y, 1, 4)
76
+ jan4 - (jan4 + 3) % 7
77
+ end
78
+
79
+ # The day number of Western Easter Sunday, by the anonymous Gregorian algorithm (Meeus, Jones, Butcher)
80
+ def self.easter(y)
81
+ a = y % 19
82
+ b = y / 100
83
+ c = y % 100
84
+ h = (19 * a + b - b / 4 - (b - (b + 8) / 25 + 1) / 3 + 15) % 30
85
+ l = (32 + 2 * (b % 4) + 2 * (c / 4) - h - c % 4) % 7
86
+ m = (a + 11 * h + 22 * l) / 451
87
+ n = h + l - 7 * m + 114
88
+ days_from_civil(y, n / 31, n % 31 + 1)
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,176 @@
1
+ require_relative "../acute"
2
+
3
+ # chronic 0.10.2's API on acute's parser, for code written against chronic. It takes chronic's options and
4
+ # returns what chronic returns, but the answers follow acute's rules (D46).
5
+ module Chronic
6
+ VERSION = "0.10.2".freeze
7
+
8
+ class << self
9
+ attr_accessor :time_class, :debug
10
+
11
+ def parse(text, options = {}) = Parser.new(options).parse(text)
12
+ end
13
+ self.time_class = ::Time
14
+ self.debug = false
15
+
16
+ class Span < Range
17
+ def width = (self.end - self.begin).to_i
18
+
19
+ def +(other) = Span.new(self.begin + other, self.end + other)
20
+
21
+ def -(other) = self + -other
22
+
23
+ def to_s = "(#{self.begin}..#{self.end})"
24
+ end
25
+
26
+ class Parser
27
+ DEFAULT_OPTIONS = Ractor.make_shareable({
28
+ context: :future, now: nil, hours24: nil, week_start: nil, guess: true, ambiguous_time_range: 6,
29
+ endian_precedence: %i[middle little], ambiguous_year_future_bias: 50
30
+ })
31
+
32
+ Reading = Struct.new(:begin, :end, :point, :span, :frac)
33
+ Notes = Struct.new(:dst)
34
+ NUMERIC_DATE = %r{(?<!\d)(\d{1,2})(?:[/\-.]|\s+)(\d{1,2})(?!\d)}
35
+
36
+ attr_accessor :now
37
+ attr_reader :options
38
+
39
+ def initialize(options = {})
40
+ @options = DEFAULT_OPTIONS.merge(options)
41
+ @endian = options[:endian_precedence]
42
+ @now = @options[:now] || Chronic.time_class.now
43
+ end
44
+
45
+ def parse(text)
46
+ zone = Acute::Zone.for(zone_name)
47
+ result = read(text.to_s, zone) or return
48
+ reading = choose(result, text.to_s, zone)
49
+ out = guess_reading(reading, zone, @options[:guess])
50
+ Range === out ? Span.new(at(out.begin), at(out.end)) : at(out)
51
+ end
52
+
53
+ def guess(span, mode = :middle)
54
+ return span unless mode
55
+ return span.end if mode == :end
56
+ return span.begin unless span.width > 1 && (mode == true || mode == :middle)
57
+ # the middle on the wall clock, so a day with a DST change still guesses 12:00 (D46)
58
+ shift = span.begin.utc_offset - span.end.utc_offset
59
+ late = span.begin + (span.width + shift) / 2
60
+ late.utc_offset == span.end.utc_offset ? late : span.begin + (span.width - shift) / 2
61
+ end
62
+
63
+ private
64
+
65
+ # Chronic skips words it doesn't know, as acute does with strict: false: text with such words gives the
66
+ # first time mentioned in it; text acute knows every word of and still refuses ("5th tuesday in february")
67
+ # stays nil (D46)
68
+ def read(text, zone)
69
+ Acute.parse(text, now: @now, zone: zone.name, context: @options[:context] == :past ? :past : :future,
70
+ week_start: @options[:week_start], strict: false,
71
+ year_bias: @options[:ambiguous_year_future_bias])
72
+ end
73
+
74
+ # Chronic's options for ambiguous text pick among acute's readings; acute's order stands where they don't
75
+ def choose(result, text, zone)
76
+ codes = result.assumptions.map(&:code)
77
+ clock = nil
78
+ all = ([result] + result.alternatives).map do |r|
79
+ # D47: chronic guesses inside every span but a clock time someone said ("4pm", "at 5:30"), so "next
80
+ # hour", one grain long and an instant to acute, is guessed too
81
+ long = !r.span? && r.end - r.begin > 1
82
+ clock = said_clock?(result.matched) if long && clock.nil?
83
+ span = r.span? || (long && !clock)
84
+ Reading.new(r.begin&.to_i, r.end&.to_i, r.time.to_i, span, r.time.subsec)
85
+ end
86
+ if codes.include?(:bare_hour_window)
87
+ # :none sets no window: the next (or in the past, latest) occurrence of the hour on either half of the day
88
+ all = if @options[:ambiguous_time_range] == :none
89
+ sign = @options[:context] == :past ? -1 : 1
90
+ all.sort_by { _1.begin.to_i * sign }
91
+ else
92
+ prefer(all) { |r| hour_window.include?(wall(r.point, zone) / 3600 % 24) }
93
+ end
94
+ end
95
+ # only when given: without it the locale decides, as D10 says
96
+ if @endian && codes.include?(:numeric_date_order) && (m = NUMERIC_DATE.match(text))
97
+ a, b = m[1].to_i, m[2].to_i
98
+ Array(@endian).reverse_each do |endian|
99
+ all = prefer(all) do |r|
100
+ _, month, day = civil(r.point, zone)
101
+ endian == :little ? [day, month] == [a, b] : [month, day] == [a, b]
102
+ end
103
+ end
104
+ end
105
+ all.first
106
+ end
107
+
108
+ def said_clock?(text) = clock_in?(Acute::Grammar.parse(Acute::Lexer.lex(text)))
109
+
110
+ def clock_in?(node)
111
+ case node
112
+ when Acute::Grammar::Clock then true
113
+ when Struct, Array then node.each.any? { clock_in?(_1) }
114
+ else false
115
+ end
116
+ end
117
+
118
+ def prefer(all)
119
+ hits = all.select { yield _1 }
120
+ hits.empty? ? all : hits + (all - hits)
121
+ end
122
+
123
+ # The hours an hour written without am/pm may mean: chronic's window from ambiguous_time_range, or the
124
+ # hour as written under hours24
125
+ def hour_window
126
+ range = @options[:ambiguous_time_range]
127
+ start = @options[:hours24] == true || range == :none ? 1 : Integer(range)
128
+ start...(start + 12)
129
+ end
130
+
131
+ # Epochs, or a Range of them when guess is false, which in chronic always returns a Span; an instant keeps
132
+ # its written fraction of a second (D47)
133
+ def guess_reading(r, zone, mode)
134
+ return (r.begin && r.begin + r.frac)..(r.end && r.end + r.frac) unless mode || (r.begin.nil? && r.end.nil?)
135
+ return r.point + r.frac unless r.span && r.begin && r.end
136
+ return r.end if mode == :end
137
+ return r.begin unless mode == true || mode == :middle
138
+ mid = (wall(r.begin, zone) + wall(r.end, zone)) / 2
139
+ zone.local_to_utc(mid, Notes.new)
140
+ end
141
+
142
+ def wall(epoch, zone) = epoch + zone.offset_at(epoch)
143
+
144
+ def civil(epoch, zone)
145
+ ymd = Acute::Calendar.civil_from_days(wall(epoch, zone).div(86_400))
146
+ [ymd / 10_000, ymd / 100 % 100, ymd % 100]
147
+ end
148
+
149
+ # Chronic builds times in time_class: Time's local zone, or a zone like Time.zone
150
+ def zone_name
151
+ klass = Chronic.time_class
152
+ return klass.tzinfo if klass.respond_to?(:tzinfo)
153
+ return Acute::Context::SYSTEM_ZONE unless Ractor.current == Ractor.main # the cache is the main Ractor's
154
+ tz = ENV["TZ"]
155
+ cached = Parser.system_zone
156
+ return cached[1] if cached && cached[0] == tz
157
+ Parser.system_zone = [tz, Acute::Context.system_zone]
158
+ Parser.system_zone[1]
159
+ end
160
+
161
+ def at(epoch)
162
+ return if epoch.nil?
163
+ klass = Chronic.time_class
164
+ return klass.at(epoch) if klass.respond_to?(:tzinfo)
165
+ time = Time.at(epoch)
166
+ if klass == ::Date then time.to_date
167
+ elsif klass == ::DateTime then time.to_datetime
168
+ else time
169
+ end
170
+ end
171
+
172
+ class << self
173
+ attr_accessor :system_zone
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,321 @@
1
+ require "strscan"
2
+ require_relative "../acute"
3
+
4
+ # chronic_duration's API, 0.10.6 and GitLab's 0.12.0 fork, on acute. Its tokeniser, number words and fixed unit
5
+ # lengths are kept, so answers match the gems. Where the gem would skip words and acute reads the whole text as
6
+ # a duration ("half an hour", "1 fortnight"), acute's reading wins, at the same fixed lengths (D46).
7
+ module ChronicDuration
8
+ extend self
9
+
10
+ class DurationParseError < StandardError
11
+ end
12
+
13
+ VERSION = "0.12.0".freeze
14
+ FULL_WEEKS_PER_MONTH = 4
15
+ YEAR = 31_557_600
16
+ UNITS = Ractor.make_shareable(%w[seconds minutes hours days weeks months years])
17
+ CHRONO_UNITS = Ractor.make_shareable(UNITS - ["weeks"])
18
+ JOIN_WORDS = Ractor.make_shareable(%w[and with plus])
19
+ FLOAT = /[0-9]*\.?[0-9]+/
20
+ # "4:13:02" wherever it stands (D46). GitLab's 0.12 read it only as the whole text, so "2 days, 4:13:02" lost
21
+ # its 4:13; upstream split the whole text at colons, so "Duration: 2 hours" gained a minute.
22
+ CHRONO = /(?<![\d:.])\d+(?:\s*:\s*\d+){1,5}(?:\.\d*)?(?![\d:])/
23
+ MAPPINGS = Ractor.make_shareable({
24
+ "seconds" => "seconds", "second" => "seconds", "secs" => "seconds", "sec" => "seconds", "s" => "seconds",
25
+ "minutes" => "minutes", "minute" => "minutes", "mins" => "minutes", "min" => "minutes", "m" => "minutes",
26
+ "hours" => "hours", "hour" => "hours", "hrs" => "hours", "hr" => "hours", "h" => "hours",
27
+ "days" => "days", "day" => "days", "dy" => "days", "d" => "days",
28
+ "weeks" => "weeks", "week" => "weeks", "wks" => "weeks", "wk" => "weeks", "w" => "weeks",
29
+ "months" => "months", "mo" => "months", "mos" => "months", "month" => "months",
30
+ "years" => "years", "year" => "years", "yrs" => "years", "yr" => "years", "y" => "years"
31
+ })
32
+ DIVIDERS = Ractor.make_shareable({
33
+ micro: { years: "y", months: "mo", weeks: "w", days: "d", hours: "h", minutes: "m", seconds: "s" },
34
+ short: { years: "y", months: "mo", weeks: "w", days: "d", hours: "h", minutes: "m", seconds: "s" },
35
+ default: { years: " yr", months: " mo", weeks: " wk", days: " day", hours: " hr", minutes: " min", seconds: " sec",
36
+ pluralize: true },
37
+ long: { years: " year", months: " month", weeks: " week", days: " day", hours: " hour", minutes: " minute",
38
+ seconds: " second", pluralize: true },
39
+ chrono: { years: ":", months: ":", weeks: ":", days: ":", hours: ":", minutes: ":", seconds: ":", keep_zero: true }
40
+ })
41
+
42
+ class << self
43
+ attr_accessor :hours_per_day
44
+ attr_reader :days_per_month, :raise_exceptions
45
+ attr_writer :days_per_week
46
+
47
+ def raise_exceptions=(value)
48
+ @raise_exceptions = !!value
49
+ end
50
+
51
+ # upstream sets the week's days; GitLab derives them from the month's. Whichever was set last counts (D46).
52
+ def days_per_week = @days_per_week || days_per_month / FULL_WEEKS_PER_MONTH
53
+
54
+ def days_per_month=(value)
55
+ @days_per_month = value
56
+ @days_per_week = nil
57
+ end
58
+ end
59
+ self.raise_exceptions = false
60
+ self.hours_per_day = 24
61
+ self.days_per_month = 30
62
+
63
+ def parse(string, opts = {})
64
+ # T3: past the 512 bytes acute reads, before the rewrites rather than after them
65
+ if string.bytesize > Acute::MAX_LENGTH
66
+ raise DurationParseError, "The string is longer than #{Acute::MAX_LENGTH} bytes." if ChronicDuration.raise_exceptions
67
+ return
68
+ end
69
+ words, skipped = clean(string)
70
+ result = acute_seconds(string, opts) unless skipped.empty?
71
+ if result.nil?
72
+ if !skipped.empty? && ChronicDuration.raise_exceptions
73
+ raise DurationParseError, "An invalid word #{skipped[0].inspect} was used in the string to be parsed."
74
+ end
75
+ result = calculate_from_words(words, opts)
76
+ end
77
+ !opts[:keep_zero] && result == 0 ? nil : result
78
+ end
79
+
80
+ def output(seconds, opts = {})
81
+ int = seconds.to_i
82
+ seconds = int if seconds - int == 0
83
+ style = opts[:format] || :default
84
+ hours_per_day, days_per_month, days_per_week = settings(opts)
85
+ years = months = weeks = days = hours = minutes = 0
86
+ decimal_places = seconds.to_s.split(".").last.length if seconds.is_a?(Float)
87
+
88
+ day = hours_per_day * 3600
89
+ month = days_per_month * day
90
+ if seconds >= YEAR && seconds % YEAR < seconds % month
91
+ years = seconds / YEAR
92
+ months = seconds % YEAR / month
93
+ days = seconds % YEAR % month / day
94
+ hours = seconds % YEAR % month % day / 3600
95
+ minutes = seconds % YEAR % month % day % 3600 / 60
96
+ seconds = seconds % YEAR % month % day % 3600 % 60
97
+ elsif seconds >= 60
98
+ minutes = (seconds / 60).to_i
99
+ seconds %= 60
100
+ if minutes >= 60
101
+ hours = (minutes / 60).to_i
102
+ minutes = (minutes % 60).to_i
103
+ if !opts[:limit_to_hours] && hours >= hours_per_day
104
+ days = (hours / hours_per_day).to_i
105
+ hours = (hours % hours_per_day).to_i
106
+ if opts[:weeks]
107
+ if days >= days_per_week
108
+ weeks = (days / days_per_week).to_i
109
+ days = (days % days_per_week).to_i
110
+ if weeks >= FULL_WEEKS_PER_MONTH
111
+ months = (weeks / FULL_WEEKS_PER_MONTH).to_i
112
+ weeks = (weeks % FULL_WEEKS_PER_MONTH).to_i
113
+ end
114
+ end
115
+ elsif days >= days_per_month
116
+ months = (days / days_per_month).to_i
117
+ days = (days % days_per_month).to_i
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ dividers = DIVIDERS[style]
124
+ joiner = %i[micro chrono].include?(style) ? "" : opts.fetch(:joiner, " ")
125
+ counts = { years: years, months: months, weeks: weeks, days: days, hours: hours, minutes: minutes, seconds: seconds }
126
+ # the gem's compact! returned nil when no unit was dropped, so weeks: true with every unit shown raised (D46)
127
+ result = counts.filter_map do |unit, num|
128
+ next if unit == :weeks && !opts[:weeks]
129
+ num = format("%.#{decimal_places}f", num) if num.is_a?(Float) && unit == :seconds
130
+ keep_zero = dividers[:keep_zero] || (opts[:keep_zero] if unit == :seconds)
131
+ humanize_time_unit(num, dividers[unit], dividers[:pluralize], keep_zero)
132
+ end
133
+ result = result[0...opts[:units]] if opts[:units]
134
+ result = result.join(joiner)
135
+ result = chrono(result, decimal_places) if style == :chrono
136
+ result.empty? ? nil : result
137
+ end
138
+
139
+ private
140
+
141
+ def humanize_time_unit(number, unit, pluralize, keep_zero)
142
+ return if number == 0 && !keep_zero
143
+ res = "#{number}#{unit}"
144
+ res << "s" if number != 1 && pluralize
145
+ res
146
+ end
147
+
148
+ # Pad each part to two digits, then drop leading zero parts, a leading zero and the trailing colon
149
+ def chrono(str, decimal_places)
150
+ str.split(":").map { |n| n.include?(".") ? format("%04.#{decimal_places}f", n) : format("%02d", n) }
151
+ .join(":").gsub(/^(00:)+/, "").gsub(/^0/, "").gsub(/:$/, "")
152
+ end
153
+
154
+ # Per-call options over the module's settings; GitLab's per-call days_per_month also sets the week
155
+ def settings(opts)
156
+ hours_per_day = opts[:hours_per_day] || ChronicDuration.hours_per_day
157
+ days_per_month = opts[:days_per_month] || ChronicDuration.days_per_month
158
+ days_per_week = opts[:days_per_week] ||
159
+ (opts[:days_per_month] ? days_per_month / FULL_WEEKS_PER_MONTH : ChronicDuration.days_per_week)
160
+ [hours_per_day, days_per_month, days_per_week]
161
+ end
162
+
163
+ def lengths(opts)
164
+ hours_per_day, days_per_month, days_per_week = settings(opts)
165
+ day = 3600 * hours_per_day
166
+ { "years" => YEAR, "months" => day * days_per_month, "weeks" => day * days_per_week, "days" => day,
167
+ "hours" => 3600, "minutes" => 60, "seconds" => 1 }
168
+ end
169
+
170
+ def calculate_from_words(string, opts)
171
+ seconds = lengths(opts)
172
+ words = string.split
173
+ words.each_with_index.sum do |v, k|
174
+ v.match?(FLOAT) ? convert_to_number(v) * seconds.fetch(words[k + 1] || opts[:default_unit] || "seconds", 0) : 0
175
+ end
176
+ end
177
+
178
+ # acute's reading of the whole text, each unit said at the gem's fixed lengths, so "2.5 weeks" of 5-day weeks
179
+ # is 12.5 days. A business day has no such length, so the gem's answer stands. A fraction anywhere makes a
180
+ # Float, as the gem's own decimals do.
181
+ def acute_seconds(string, opts)
182
+ tokens = Acute::Lexer.lex(string)
183
+ return if Acute::Failure === tokens
184
+ units = Acute::Grammar.duration(tokens)
185
+ return if Acute::Failure === units
186
+ parts = units.map { |u| [*Acute::Duration::UNIT_PART.fetch(u.unit), u.count] }
187
+ return if parts.any? { |part, *| part == :business_days }
188
+ seconds = lengths(opts)
189
+ total = parts.sum { |part, k, count| count * k * seconds.fetch(part.to_s) }
190
+ parts.all? { |*, count| count.to_r.denominator == 1 } ? total.to_i : total.to_f
191
+ end
192
+
193
+ def cleanup(string) = clean(string)[0]
194
+
195
+ def clean(string)
196
+ res = filter_by_type(Numerizer.numerize(string.downcase))
197
+ res = res.gsub(FLOAT) { " #{_1} " }.squeeze(" ").strip
198
+ filter_through_white_list(res)
199
+ end
200
+
201
+ def convert_to_number(string) = string.to_f % 1 > 0 ? string.to_f : string.to_i
202
+
203
+ # "3:41:59" is 3 hours 41 minutes 59 seconds
204
+ def filter_by_type(string)
205
+ string.gsub(CHRONO) do |chrono|
206
+ chrono.delete(" ").split(":").reverse.each_with_index.map { |v, k| "#{v} #{CHRONO_UNITS[k]}" }.reverse.join(" ")
207
+ end
208
+ end
209
+
210
+ # The units named, with numbers kept and other words skipped; the skipped words come back too
211
+ def filter_through_white_list(string)
212
+ res = []
213
+ skipped = []
214
+ string.split.each do |word|
215
+ next res << word.strip if word.match?(FLOAT)
216
+ stripped = word.strip.delete_prefix(",").delete_suffix(",")
217
+ if MAPPINGS.key?(stripped) then res << MAPPINGS[stripped]
218
+ elsif !JOIN_WORDS.include?(stripped) then skipped << word
219
+ end
220
+ end
221
+ # "day" or "minute 30sec": a unit first counts one
222
+ res.unshift(1) if !res.empty? && MAPPINGS[res[0]]
223
+ [res.join(" "), skipped]
224
+ end
225
+
226
+ # numerizer 0.2.0, which both gems use for number words
227
+ module Numerizer
228
+ DIRECT_NUMS = [
229
+ ["eleven", "11"], ["twelve", "12"], ["thirteen", "13"], ["fourteen", "14"], ["fifteen", "15"], ["sixteen", "16"],
230
+ ["seventeen", "17"], ["eighteen", "18"], ["nineteen", "19"], ["ninteen", "19"], ["zero", "0"], ["ten", "10"],
231
+ ['\ba[\b^$]', "1"]
232
+ ].freeze
233
+ SINGLE_NUMS = [
234
+ ["one", 1], ["two", 2], ["three", 3], ["four", 4], ["five", 5], ["six", 6], ["seven", 7],
235
+ ["eight", 8], ["nine", 9]
236
+ ].freeze
237
+ TEN_PREFIXES = [
238
+ ["twenty", 20], ["thirty", 30], ["forty", 40], ["fourty", 40], ["fifty", 50], ["sixty", 60],
239
+ ["seventy", 70], ["eighty", 80], ["ninety", 90]
240
+ ].freeze
241
+ BIG_PREFIXES = [
242
+ ["hundred", 100], ["thousand", 1000], ["million", 1_000_000], ["billion", 1_000_000_000],
243
+ ["trillion", 1_000_000_000_000]
244
+ ].freeze
245
+ FRACTIONS = [
246
+ ["half", 2], ["third(s)?", 3], ["fourth(s)?", 4], ["quarter(s)?", 4], ["fifth(s)?", 5],
247
+ ["sixth(s)?", 6], ["seventh(s)?", 7], ["eighth(s)?", 8], ["nineth(s)?", 9]
248
+ ].freeze
249
+ SINGLE_ORDINALS = [
250
+ ["first", 1], ["third", 3], ["fourth", 4], ["fifth", 5], ["sixth", 6], ["seventh", 7],
251
+ ["eighth", 8], ["ninth", 9]
252
+ ].freeze
253
+ DIRECT_ORDINALS = [
254
+ ["tenth", "10"], ["eleventh", "11"], ["twelfth", "12"], ["thirteenth", "13"], ["fourteenth", "14"],
255
+ ["fifteenth", "15"], ["sixteenth", "16"], ["seventeenth", "17"], ["eighteenth", "18"], ["nineteenth", "19"],
256
+ ["twentieth", "20"], ["thirtieth", "30"], ["fourtieth", "40"], ["fiftieth", "50"], ["sixtieth", "60"],
257
+ ["seventieth", "70"], ["eightieth", "80"], ["ninetieth", "90"]
258
+ ].freeze
259
+ # The inner pairs too, so a Ractor can read them
260
+ [DIRECT_NUMS, SINGLE_NUMS, TEN_PREFIXES, BIG_PREFIXES, FRACTIONS, SINGLE_ORDINALS, DIRECT_ORDINALS].each { Ractor.make_shareable(_1) }
261
+
262
+ module_function
263
+
264
+ def numerize(string)
265
+ string = string.dup
266
+ string.gsub!(/ +|([^\d])-([^\d])/, '\1 \2')
267
+ (DIRECT_NUMS + SINGLE_NUMS).each do |word, n|
268
+ string.gsub!(/(^|\W)#{word}(?=$|\W)/i, "\\1<num>#{n}")
269
+ end
270
+ TEN_PREFIXES.each do |tp, t|
271
+ SINGLE_NUMS.each { |dn, d| string.gsub!(/(^|\W)#{tp}#{dn}(?=$|\W)/i, "\\1<num>#{t + d}") }
272
+ SINGLE_ORDINALS.each { |dn, d| string.gsub!(/(^|\W)#{tp}(\s)?#{dn}(?=$|\W)/i, "\\1<num>#{t + d}#{dn[-2, 2]}") }
273
+ string.gsub!(/(^|\W)#{tp}(?=$|\W)/i, "\\1<num>#{t}")
274
+ end
275
+ FRACTIONS.each do |word, d|
276
+ string.gsub!(/a #{word}(?=$|\W)/i, "<num>1/#{d}")
277
+ string.gsub!(/\s#{word}(?=$|\W)/i, "/#{d}")
278
+ end
279
+ (DIRECT_ORDINALS + SINGLE_ORDINALS).each do |word, n|
280
+ string.gsub!(/(^|\W)#{word}(?=$|\W)/i, "\\1<num>#{n}#{word[-2, 2]}")
281
+ end
282
+ # a fraction after a number adds to it
283
+ string.gsub!(%r{(\d+)(?: | and |-)+(<num>|\s)*(\d+)\s*/\s*(\d+)}i) { ($1.to_f + ($3.to_f / $4.to_f)).to_s }
284
+ BIG_PREFIXES.each do |word, n|
285
+ string.gsub!(/(?:<num>)?(\d*) *#{word}/i) { $1.empty? ? n.to_s : "<num>#{n * $1.to_i}" }
286
+ andition(string)
287
+ end
288
+ andition(string)
289
+ string.gsub("<num>", "")
290
+ end
291
+
292
+ # "<num>20 <num>4" is 24, and "<num>100 and <num>5" 105
293
+ def andition(string)
294
+ sc = StringScanner.new(string)
295
+ while sc.scan_until(/<num>(\d+)( | and )<num>(\d+)(?=[^\w]|$)/i)
296
+ next unless sc[2].include?("and") || sc[1].size > sc[3].size
297
+ string[(sc.pos - sc.matched_size)..(sc.pos - 1)] = "<num>#{sc[1].to_i + sc[3].to_i}"
298
+ sc.reset
299
+ end
300
+ end
301
+ end
302
+ private_constant :Numerizer
303
+ end
304
+
305
+ # Ported from chronic_duration 0.10.6 and gitlab_chronic_duration 0.12.0, and from numerizer 0.2.0:
306
+ #
307
+ # Copyright (c) Henry Poydar (chronic_duration); Copyright (c) 2008 Tom Preston-Werner (numerizer)
308
+ #
309
+ # Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
310
+ # documentation files (the "Software"), to deal in the Software without restriction, including without limitation
311
+ # the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
312
+ # permit persons to whom the Software is furnished to do so, subject to the following conditions:
313
+ #
314
+ # The above copyright notice and this permission notice shall be included in all copies or substantial portions of
315
+ # the Software.
316
+ #
317
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
318
+ # THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
319
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
320
+ # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
321
+ # SOFTWARE.