nepdate 1.0.0.rc.1

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.
@@ -0,0 +1,580 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "json"
5
+
6
+ module NepDate
7
+ # A date in the Bikram Sambat calendar, from 1901-01-01 to 2199-12-30 BS (AD 1844-04-11 to
8
+ # 2143-04-15). Immutable, always a real day, and ordered by its serial day.
9
+ #
10
+ # The class is named +NepDate::Date+ to read naturally as +NepDate::Date.parse("2081-04-15")+.
11
+ # The Gregorian type is the standard library's +::Date+; the two never share a namespace.
12
+ class Date
13
+ include Comparable
14
+
15
+ # Nepal's fixed UTC offset, 5 hours 45 minutes, used when no zone is given. Nepal has no
16
+ # daylight saving time, so a fixed offset is the complete rule.
17
+ DEFAULT_OFFSET = 20_700
18
+ DEFAULT_ZONE = "Asia/Kathmandu"
19
+ OFFSET_ALIASES = {
20
+ "Asia/Kathmandu" => 20_700,
21
+ "Asia/Katmandu" => 20_700,
22
+ "NPT" => 20_700,
23
+ "UTC" => 0,
24
+ "GMT" => 0,
25
+ "Z" => 0
26
+ }.freeze
27
+
28
+ attr_reader :year, :month, :day, :serial
29
+
30
+ def initialize(year, month, day)
31
+ year = Date.integer_argument(year, "year")
32
+ month = Date.integer_argument(month, "month")
33
+ day = Date.integer_argument(day, "day")
34
+ index = year - CalendarTable::MIN_YEAR
35
+ unless index >= 0 && index < CalendarTable::YEARS
36
+ raise OutOfRangeError, "year #{year} is outside #{CalendarTable::MIN_YEAR}..#{CalendarTable::MAX_YEAR}"
37
+ end
38
+ unless month.between?(1, 12)
39
+ raise InvalidMonthError, "month #{month} is outside 1..12"
40
+ end
41
+
42
+ packed = CalendarTable::PACKED.fetch(index)
43
+ length = 29 + ((packed >> (2 * (month - 1))) & 0b11)
44
+ unless day.between?(1, length)
45
+ raise InvalidDayError, "day #{day} is outside 1..#{length}"
46
+ end
47
+
48
+ initialize_parts(year, month, day, CalendarTable::MONTH_START.fetch(index * 12 + month - 1) + day - 1)
49
+ end
50
+
51
+ class << self
52
+ # Builds a BS date and validates the year, then the month, then the day.
53
+ def from_bs(year, month, day)
54
+ new(year, month, day)
55
+ end
56
+ alias_method :from_ymd_parts, :from_bs
57
+
58
+ # Converts a real AD day. The date part is what converts; a time of day is discarded.
59
+ def from_gregorian(year, month, day)
60
+ year = integer_argument(year, "year")
61
+ month = integer_argument(month, "month")
62
+ day = integer_argument(day, "day")
63
+
64
+ unless Calendar.valid_gregorian?(year, month, day)
65
+ raise InvalidGregorianError, "#{year}-#{month}-#{day} is not a real Gregorian date"
66
+ end
67
+
68
+ serial = Calendar.days_from_civil(year, month, day) - CalendarTable::EPOCH_UNIX_DAYS
69
+ unless serial.between?(0, CalendarTable::MAX_SERIAL)
70
+ raise OutOfRangeError, "#{year}-#{month}-#{day} is outside the supported range"
71
+ end
72
+
73
+ from_serial(serial)
74
+ end
75
+
76
+ # Converts a standard library +::Date+ or +::DateTime+ by its date part.
77
+ def from_date(value)
78
+ unless value.is_a?(::Date)
79
+ raise ArgumentError, "expected a ::Date, got #{value.class}"
80
+ end
81
+
82
+ # ::Date already validates its fields, so its Julian day is the serial plus a constant.
83
+ from_serial(value.jd - CalendarTable::JULIAN_EPOCH)
84
+ end
85
+
86
+ # Converts a +Time+ in the time's own offset.
87
+ def from_time(value)
88
+ unless value.is_a?(::Time)
89
+ raise ArgumentError, "expected a ::Time, got #{value.class}"
90
+ end
91
+
92
+ from_gregorian(value.year, value.month, value.day)
93
+ end
94
+
95
+ # The first supported date: 1901-01-01 BS.
96
+ def min
97
+ MIN
98
+ end
99
+
100
+ # The last supported date: 2199-12-30 BS.
101
+ def max
102
+ MAX
103
+ end
104
+
105
+ # Today in +zone+. The zone may be an IANA name for Nepal, +"UTC"+, a fixed offset such as
106
+ # +"+05:45"+, an integer number of seconds, or an object that responds to +utc_offset+.
107
+ # Pass +clock:+ to replace +Time.now+; the callable returns a +Time+, +DateTime+ or +Date+.
108
+ def today(zone: DEFAULT_ZONE, clock: nil)
109
+ offset = offset_seconds(zone)
110
+ now = clock ? clock.call : Time.now
111
+ case now
112
+ when ::Time
113
+ local = now.getlocal(offset)
114
+ from_gregorian(local.year, local.month, local.day)
115
+ when ::DateTime
116
+ from_gregorian(now.year, now.month, now.day)
117
+ when ::Date
118
+ from_gregorian(now.year, now.month, now.day)
119
+ else
120
+ raise TypeError, "clock must return a Time, DateTime or Date, got #{now.class}"
121
+ end
122
+ end
123
+
124
+ # The strict parser of PARSING.md section 1: year, month, day, ASCII or Devanagari digits.
125
+ def parse(text)
126
+ year, month, day = Parse.strict(text)
127
+ new(year, month, day)
128
+ end
129
+
130
+ # The strict parser without an exception. Returns +nil+ on any NepDate error.
131
+ def try_parse(text)
132
+ parse(text)
133
+ rescue Error
134
+ nil
135
+ end
136
+
137
+ # The deterministic human-input parser of PARSING.md section 2.
138
+ def parse_lenient(text)
139
+ year, month, day = Lenient.parse(text)
140
+ new(year, month, day)
141
+ end
142
+
143
+ # The lenient parser without an exception. Returns +nil+ on any NepDate error.
144
+ def try_parse_lenient(text)
145
+ parse_lenient(text)
146
+ rescue Error
147
+ nil
148
+ end
149
+
150
+ # Reads the +YYYYMMDD+ integer. Values outside the eight-digit form are rejected rather
151
+ # than split or truncated.
152
+ def from_ymd(value)
153
+ value = integer_argument(value, "ymd")
154
+ unless (0..99_999_999).cover?(value)
155
+ raise OutOfRangeError, "#{value} is not a positive eight-digit YYYYMMDD value"
156
+ end
157
+
158
+ new(value / 10_000, (value / 100) % 100, value % 100)
159
+ end
160
+ alias_method :from_ymd_int, :from_ymd
161
+
162
+ # Reads the JSON string written by +to_json+: a quoted "YYYY-MM-DD" value. A JSON null or
163
+ # object is rejected, because this port has no zero date (D-01, D-11).
164
+ def from_json(text)
165
+ value = JSON.parse(text)
166
+ unless value.is_a?(String)
167
+ raise ParseError.new(ErrorKind::INVALID_CHARACTER, "the JSON value is not a string")
168
+ end
169
+
170
+ parse(value)
171
+ rescue JSON::ParserError
172
+ raise ParseError.new(ErrorKind::INVALID_CHARACTER, "the text is not a JSON string")
173
+ end
174
+
175
+ # Ordered batch conversion from AD dates. Accepts +::Date+, +DateTime+ and +Time+ values
176
+ # and keeps input order (D-11).
177
+ def from_gregorian_batch(dates)
178
+ dates.map do |value|
179
+ case value
180
+ when Date then value
181
+ when ::Time then from_time(value)
182
+ when ::Date then from_date(value)
183
+ else
184
+ raise ArgumentError, "expected a Date, DateTime or Time, got #{value.class}"
185
+ end
186
+ end
187
+ end
188
+
189
+ # Ordered batch conversion from BS text to AD +::Date+ values (D-11).
190
+ def parse_batch(texts)
191
+ texts.map { |text| parse(text).to_date }
192
+ end
193
+
194
+ # Ordered batch conversion from BS dates to AD +::Date+ values (D-11).
195
+ def to_gregorian_batch(dates)
196
+ dates.map(&:to_date)
197
+ end
198
+
199
+ # The first boundary of the proleptic Gregorian calendar supported by this port.
200
+ def min_gregorian
201
+ MIN.to_date
202
+ end
203
+
204
+ # The last boundary of the proleptic Gregorian calendar supported by this port.
205
+ def max_gregorian
206
+ MAX.to_date
207
+ end
208
+
209
+ # Matches a fixed offset from a string, an integer or an object with +utc_offset+.
210
+ # @api private
211
+ def offset_seconds(zone)
212
+ offset =
213
+ case zone
214
+ when Integer then zone
215
+ when String
216
+ OFFSET_ALIASES.fetch(zone) do
217
+ match = /\A([+-])(\d{2}):?(\d{2})\z/.match(zone)
218
+ raise ArgumentError, "invalid UTC offset #{zone.inspect}" unless match
219
+
220
+ seconds = match[2].to_i * 3600 + match[3].to_i * 60
221
+ (match[1] == "-") ? -seconds : seconds
222
+ end
223
+ when nil then DEFAULT_OFFSET
224
+ else
225
+ if zone.respond_to?(:utc_offset)
226
+ zone.utc_offset
227
+ else
228
+ raise ArgumentError, "zone must be an offset string, integer or utc_offset object, got #{zone.inspect}"
229
+ end
230
+ end
231
+
232
+ unless (-86_400...86_400).cover?(offset)
233
+ raise ArgumentError, "UTC offset #{offset} seconds is outside one day"
234
+ end
235
+
236
+ offset
237
+ end
238
+
239
+ # Builds a date whose parts and serial are already known to be valid. Used by the
240
+ # conversion and arithmetic paths, so they do not validate and recompute a serial twice.
241
+ # @api private
242
+ def from_parts(year, month, day, serial)
243
+ date = allocate
244
+ date.send(:initialize_parts, year, month, day, serial)
245
+ date
246
+ end
247
+
248
+ # @api private
249
+ def from_serial(serial)
250
+ unless serial.between?(0, CalendarTable::MAX_SERIAL)
251
+ raise OutOfRangeError, "serial #{serial} is outside 0..#{CalendarTable::MAX_SERIAL}"
252
+ end
253
+
254
+ index = CalendarTable::BUCKET[serial >> 5]
255
+ index += 1 if serial >= CalendarTable::MONTH_START[index + 1]
256
+ index += 1 if serial >= CalendarTable::MONTH_START[index + 1]
257
+ from_parts(
258
+ CalendarTable::MIN_YEAR + index / 12,
259
+ index % 12 + 1,
260
+ serial - CalendarTable::MONTH_START[index] + 1,
261
+ serial
262
+ )
263
+ end
264
+
265
+ # Public integer arguments are never coerced from floats or strings, so a truncated value
266
+ # cannot silently become a different date.
267
+ # @api private
268
+ def integer_argument(value, label)
269
+ return value if value.is_a?(Integer)
270
+
271
+ raise TypeError, "#{label} must be an Integer, got #{value.class}"
272
+ end
273
+ end
274
+
275
+ # The BS ordinal in this BS year, starting at 1 on Baishakh 1.
276
+ def day_of_year
277
+ serial - CalendarTable::YEAR_START.fetch(year - CalendarTable::MIN_YEAR) + 1
278
+ end
279
+
280
+ # The BS year length, 364 to 367 days (D-12), not Gregorian leap-ness.
281
+ def year_length
282
+ index = year - CalendarTable::MIN_YEAR
283
+ CalendarTable::YEAR_START.fetch(index + 1) - CalendarTable::YEAR_START.fetch(index)
284
+ end
285
+
286
+ # The last valid day of this BS month, 29 to 32.
287
+ def month_length
288
+ Calendar.month_length(year, month)
289
+ end
290
+ alias_method :month_end_day, :month_length
291
+
292
+ def month_enum
293
+ Month.from(month)
294
+ end
295
+ alias_method :month_name, :month_enum
296
+
297
+ def weekday
298
+ Weekday.from((serial + CalendarTable::EPOCH_WEEKDAY) % 7)
299
+ end
300
+ alias_method :day_of_week, :weekday
301
+
302
+ # This year and month on the last day.
303
+ def last_day_of_month
304
+ length = month_length
305
+ Date.from_parts(year, month, length, serial - day + length)
306
+ end
307
+ alias_method :month_end_date, :last_day_of_month
308
+
309
+ def to_date
310
+ civil = Calendar.civil_from_days(serial + CalendarTable::EPOCH_UNIX_DAYS)
311
+ ::Date.new(*civil)
312
+ end
313
+ alias_method :english_date, :to_date
314
+
315
+ def to_datetime(zone: DEFAULT_ZONE)
316
+ offset = Date.offset_seconds(zone)
317
+ ::DateTime.new(year, month, day, 0, 0, 0, Rational(offset, 86_400))
318
+ end
319
+
320
+ def to_time(zone: DEFAULT_ZONE, hour: 0, minute: 0, second: 0)
321
+ offset = Date.offset_seconds(zone)
322
+ civil = Calendar.civil_from_days(serial + CalendarTable::EPOCH_UNIX_DAYS)
323
+ ::Time.new(civil[0], civil[1], civil[2], hour, minute, second, offset)
324
+ end
325
+
326
+ def today?(zone: DEFAULT_ZONE, clock: nil)
327
+ self == Date.today(zone: zone, clock: clock)
328
+ end
329
+ alias_method :is_today, :today?
330
+
331
+ def yesterday?(zone: DEFAULT_ZONE, clock: nil)
332
+ self == Date.today(zone: zone, clock: clock).add_days(-1)
333
+ end
334
+ alias_method :is_yesterday, :yesterday?
335
+
336
+ def tomorrow?(zone: DEFAULT_ZONE, clock: nil)
337
+ self == Date.today(zone: zone, clock: clock).add_days(1)
338
+ end
339
+ alias_method :is_tomorrow, :tomorrow?
340
+
341
+ def fiscal_year
342
+ FiscalYear.containing(self)
343
+ end
344
+
345
+ def quarter
346
+ case month
347
+ when 4..6 then Quarter::Q1
348
+ when 7..9 then Quarter::Q2
349
+ when 10..12 then Quarter::Q3
350
+ else Quarter::Q4
351
+ end
352
+ end
353
+
354
+ def add_days(days)
355
+ unless days.is_a?(Integer)
356
+ raise TypeError, "days must be an Integer, got #{days.class}"
357
+ end
358
+
359
+ target = serial + days
360
+ unless target.between?(0, CalendarTable::MAX_SERIAL)
361
+ raise OutOfRangeError, "day offset #{days} is outside the supported range"
362
+ end
363
+
364
+ Date.from_serial(target)
365
+ end
366
+
367
+ def add_months(months, overflow: :clamp)
368
+ unless months.is_a?(Integer)
369
+ raise TypeError, "months must be an Integer, got #{months.class}"
370
+ end
371
+
372
+ mode = (overflow == :clamp || overflow == :spill) ? overflow : MonthOverflow.coerce(overflow)
373
+ total = (year - CalendarTable::MIN_YEAR) * 12 + (month - 1) + months
374
+ if total < 0 || total >= CalendarTable::YEARS * 12
375
+ raise OutOfRangeError, "month offset #{months} is outside the supported range"
376
+ end
377
+
378
+ target_year = CalendarTable::MIN_YEAR + total / 12
379
+ target_month = total % 12 + 1
380
+ target_index = total
381
+ packed = CalendarTable::PACKED.fetch(target_year - CalendarTable::MIN_YEAR)
382
+ length = 29 + ((packed >> (2 * (target_month - 1))) & 0b11)
383
+ target_start = CalendarTable::MONTH_START.fetch(target_index)
384
+
385
+ if day <= length
386
+ return Date.from_parts(target_year, target_month, day, target_start + day - 1)
387
+ end
388
+ if mode == :clamp
389
+ return Date.from_parts(target_year, target_month, length, target_start + length - 1)
390
+ end
391
+
392
+ spill_year = (target_month == 12) ? target_year + 1 : target_year
393
+ spill_month = (target_month == 12) ? 1 : target_month + 1
394
+ if spill_year > CalendarTable::MAX_YEAR
395
+ raise OutOfRangeError, "month offset #{months} spills past the supported range"
396
+ end
397
+
398
+ spill_day = day - length
399
+ Date.from_parts(
400
+ spill_year, spill_month, spill_day,
401
+ CalendarTable::MONTH_START.fetch(target_index + 1) + spill_day - 1
402
+ )
403
+ end
404
+
405
+ def add_years(years, overflow: :clamp)
406
+ years = Date.integer_argument(years, "years")
407
+ add_months(years * 12, overflow: overflow)
408
+ end
409
+
410
+ # +other - self+, the direction of ALGORITHM.md +days_between(a, b)+ (D-07).
411
+ def days_until(other)
412
+ expect_date!(other)
413
+ other.serial - serial
414
+ end
415
+
416
+ # The signed years, months and days from +self+ to +other+ (ALGORITHM.md section 8a).
417
+ def diff(other)
418
+ expect_date!(other)
419
+ if self <= other
420
+ low = self
421
+ high = other
422
+ negative = false
423
+ else
424
+ low = other
425
+ high = self
426
+ negative = true
427
+ end
428
+
429
+ months = (high.year - low.year) * 12 + (high.month - low.month)
430
+ anchor = low.add_months(months)
431
+ if anchor > high
432
+ months -= 1
433
+ anchor = low.add_months(months)
434
+ end
435
+
436
+ DateDiff.new(
437
+ negative: negative,
438
+ years: months / 12,
439
+ months: months % 12,
440
+ days: high.serial - anchor.serial,
441
+ total_days: days_until(other)
442
+ )
443
+ end
444
+ alias_method :calendar_breakdown, :diff
445
+
446
+ def succ
447
+ add_days(1)
448
+ end
449
+
450
+ def +(other)
451
+ return add_days(other) if other.is_a?(Integer)
452
+
453
+ raise TypeError, "can only add an Integer to a NepDate::Date"
454
+ end
455
+
456
+ # +self - other+ keeps the C# direction and is positive when self is later (D-07). Subtracting
457
+ # an integer is the Ruby convenience for moving backwards.
458
+ def -(other)
459
+ return add_days(-other) if other.is_a?(Integer)
460
+
461
+ expect_date!(other)
462
+ serial - other.serial
463
+ end
464
+
465
+ def <=>(other)
466
+ return nil unless other.is_a?(Date)
467
+
468
+ serial <=> other.serial
469
+ end
470
+
471
+ def compare_to(other)
472
+ self <=> other
473
+ end
474
+
475
+ def ==(other)
476
+ other.is_a?(Date) && serial == other.serial
477
+ end
478
+ alias_method :eql?, :==
479
+
480
+ def hash
481
+ [Date, serial].hash
482
+ end
483
+
484
+ def to_ymd
485
+ year * 10_000 + month * 100 + day
486
+ end
487
+ alias_method :ymd, :to_ymd
488
+
489
+ def iso8601
490
+ Format.iso8601(year, month, day, :en)
491
+ end
492
+ alias_method :isoformat, :iso8601
493
+
494
+ def to_s
495
+ Format.default(year, month, day, :en)
496
+ end
497
+
498
+ def to_short_string(order: :ymd, separator: :slash, pad: true, lang: :en)
499
+ Format.short(year, month, day, order: order, separator: separator, pad: pad, lang: lang)
500
+ end
501
+
502
+ def to_long_string(weekday: false, year: true, pad: true, lang: :en)
503
+ lang = Lang.coerce(lang) unless lang == :en || lang == :ne
504
+ if lang == :ne
505
+ day_text = pad ? Names::TWO_DIGIT_NE[@day] : Names::NUMBER_NE[@day]
506
+ year_text = Names::YEAR_NE[@year - CalendarTable::MIN_YEAR]
507
+ month_text = Names::MONTH_NE[@month]
508
+ weekday_text = Names::WEEKDAY_NE[(serial + CalendarTable::EPOCH_WEEKDAY) % 7] if weekday
509
+ else
510
+ day_text = pad ? Names::TWO_DIGIT_EN[@day] : Names::NUMBER_EN[@day]
511
+ year_text = Names::YEAR_EN[@year - CalendarTable::MIN_YEAR]
512
+ month_text = Names::MONTH_EN[@month]
513
+ weekday_text = Names::WEEKDAY_EN[(serial + CalendarTable::EPOCH_WEEKDAY) % 7] if weekday
514
+ end
515
+
516
+ text = +""
517
+ text << weekday_text << ", " if weekday
518
+ text << month_text << " " << day_text
519
+ text << ", " << year_text if year
520
+ text
521
+ end
522
+
523
+ def to_unicode_string(order: :ymd, separator: :slash, pad: true)
524
+ to_short_string(order: order, separator: separator, pad: pad, lang: :ne)
525
+ end
526
+
527
+ def to_long_unicode_string(weekday: false, year: true, pad: true)
528
+ to_long_string(weekday: weekday, year: year, pad: pad, lang: :ne)
529
+ end
530
+
531
+ def format(pattern, lang: :en)
532
+ Format.pattern(pattern, year: year, month: month, day: day, lang: lang)
533
+ end
534
+
535
+ # The canonical string form, +YYYY-MM-DD+, read by +from_json+.
536
+ def to_json(*_args)
537
+ JSON.generate(iso8601)
538
+ end
539
+
540
+ def to_h
541
+ {year: year, month: month, day: day}
542
+ end
543
+
544
+ def to_a
545
+ [year, month, day]
546
+ end
547
+ alias_method :to_ary, :to_a
548
+
549
+ def deconstruct
550
+ to_a
551
+ end
552
+
553
+ def deconstruct_keys(_keys)
554
+ to_h
555
+ end
556
+
557
+ def inspect
558
+ "#<NepDate::Date #{iso8601}>"
559
+ end
560
+
561
+ private
562
+
563
+ def initialize_parts(year, month, day, serial)
564
+ @year = year
565
+ @month = month
566
+ @day = day
567
+ @serial = serial
568
+ freeze
569
+ end
570
+
571
+ def expect_date!(other)
572
+ raise TypeError, "expected a NepDate::Date, got #{other.class}" unless other.is_a?(Date)
573
+
574
+ other
575
+ end
576
+
577
+ MIN = from_bs(CalendarTable::MIN_YEAR, 1, 1)
578
+ MAX = from_bs(CalendarTable::MAX_YEAR, 12, Calendar.month_length(CalendarTable::MAX_YEAR, 12))
579
+ end
580
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NepDate
4
+ # The signed calendar breakdown between two dates (ALGORITHM.md section 8a). +negative+ is
5
+ # true when the second date is earlier; +total_days+ is the signed day count in the direction
6
+ # from the first date to the second.
7
+ class DateDiff
8
+ attr_reader :years, :months, :days, :total_days
9
+
10
+ def initialize(negative:, years:, months:, days:, total_days:)
11
+ @negative = negative
12
+ @years = years
13
+ @months = months
14
+ @days = days
15
+ @total_days = total_days
16
+ freeze
17
+ end
18
+
19
+ def negative?
20
+ @negative
21
+ end
22
+
23
+ def sign
24
+ negative? ? -1 : 1
25
+ end
26
+
27
+ def to_h
28
+ {negative: negative?, years: years, months: months, days: days, total_days: total_days}
29
+ end
30
+
31
+ def ==(other)
32
+ other.is_a?(DateDiff) &&
33
+ negative? == other.negative? &&
34
+ years == other.years &&
35
+ months == other.months &&
36
+ days == other.days &&
37
+ total_days == other.total_days
38
+ end
39
+ alias_method :eql?, :==
40
+
41
+ def hash
42
+ [DateDiff, negative?, years, months, days, total_days].hash
43
+ end
44
+
45
+ def inspect
46
+ "#<NepDate::DateDiff #{negative? ? "-" : "+"}#{years}y #{months}m #{days}d total=#{total_days}>"
47
+ end
48
+ end
49
+ end