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,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NepDate
4
+ # An inclusive, non-empty span of consecutive BS dates. It includes Enumerable, so +each+,
5
+ # +map+, +select+, +to_a+ and the rest work; +length+ is the inclusive day count.
6
+ class DateRange
7
+ include Enumerable
8
+
9
+ attr_reader :start, :end
10
+
11
+ def initialize(start, finish)
12
+ raise TypeError, "start must be a NepDate::Date, got #{start.class}" unless start.is_a?(Date)
13
+ raise TypeError, "end must be a NepDate::Date, got #{finish.class}" unless finish.is_a?(Date)
14
+ raise OutOfRangeError, "a range must not start after it ends" if start > finish
15
+
16
+ @start = start
17
+ @end = finish
18
+ freeze
19
+ end
20
+
21
+ class << self
22
+ def of(start, finish)
23
+ new(start, finish)
24
+ end
25
+
26
+ def single_day(date)
27
+ new(date, date)
28
+ end
29
+
30
+ def from_day_count(start, days)
31
+ days = Date.integer_argument(days, "days")
32
+ raise OutOfRangeError, "a range must span at least one day" if days < 1
33
+
34
+ new(start, start.add_days(days - 1))
35
+ end
36
+
37
+ def for_month(year, month)
38
+ first = Date.from_bs(year, month, 1)
39
+ new(first, first.last_day_of_month)
40
+ end
41
+
42
+ def for_year(year)
43
+ first = Date.from_bs(year, 1, 1)
44
+ new(first, Date.from_bs(year, 12, Calendar.month_length(year, 12)))
45
+ end
46
+
47
+ def current_month(zone: Date::DEFAULT_ZONE, clock: nil)
48
+ today = Date.today(zone: zone, clock: clock)
49
+ for_month(today.year, today.month)
50
+ end
51
+
52
+ def current_fiscal_year(zone: Date::DEFAULT_ZONE, clock: nil)
53
+ FiscalYear.containing(Date.today(zone: zone, clock: clock)).range
54
+ end
55
+
56
+ def current_calendar_year(zone: Date::DEFAULT_ZONE, clock: nil)
57
+ for_year(Date.today(zone: zone, clock: clock).year)
58
+ end
59
+ end
60
+
61
+ def length
62
+ self.end.serial - start.serial + 1
63
+ end
64
+ alias_method :size, :length
65
+
66
+ def first(count = nil)
67
+ return start if count.nil?
68
+
69
+ to_a.first(count)
70
+ end
71
+
72
+ def last(count = nil)
73
+ return self.end if count.nil?
74
+
75
+ to_a.last(count)
76
+ end
77
+
78
+ def empty?
79
+ false
80
+ end
81
+
82
+ def include?(date)
83
+ raise TypeError, "expected a NepDate::Date, got #{date.class}" unless date.is_a?(Date)
84
+
85
+ date.between?(start, self.end)
86
+ end
87
+ alias_method :contains?, :include?
88
+ alias_method :contains, :include?
89
+
90
+ def contains_range?(other)
91
+ expect_range!(other)
92
+ include?(other.start) && include?(other.end)
93
+ end
94
+ alias_method :contains_range, :contains_range?
95
+
96
+ def overlaps?(other)
97
+ expect_range!(other)
98
+ start <= other.end && other.start <= self.end
99
+ end
100
+ alias_method :overlaps, :overlaps?
101
+
102
+ # True when one range starts the day after the other ends. A range cannot start before
103
+ # 1901-01-01 or after 2199-12-30, so a range that ends at the data edge simply has no
104
+ # adjacent successor and is never advanced past it (D-16).
105
+ def adjacent_to?(other)
106
+ expect_range!(other)
107
+ if self.end < other.start
108
+ self.end.add_days(1) == other.start
109
+ elsif other.end < start
110
+ other.end.add_days(1) == start
111
+ else
112
+ false
113
+ end
114
+ end
115
+ alias_method :is_adjacent_to, :adjacent_to?
116
+
117
+ # The overlap, or +nil+ when the ranges do not touch (D-01, D-16).
118
+ def intersect(other)
119
+ expect_range!(other)
120
+ first = (start >= other.start) ? start : other.start
121
+ last = (self.end <= other.end) ? self.end : other.end
122
+ return nil if first > last
123
+
124
+ DateRange.new(first, last)
125
+ end
126
+
127
+ # The smallest range covering both, not a set union.
128
+ def union(other)
129
+ expect_range!(other)
130
+ first = (start <= other.start) ? start : other.start
131
+ last = (self.end >= other.end) ? self.end : other.end
132
+ DateRange.new(first, last)
133
+ end
134
+
135
+ # This range with the other removed: zero, one or two ranges.
136
+ def except(other)
137
+ expect_range!(other)
138
+ overlap = intersect(other)
139
+ return [self] if overlap.nil?
140
+
141
+ result = []
142
+ result << DateRange.new(start, overlap.start.add_days(-1)) if start < overlap.start
143
+ result << DateRange.new(overlap.end.add_days(1), self.end) if self.end > overlap.end
144
+ result
145
+ end
146
+
147
+ def split_by_month
148
+ result = []
149
+ cursor = start
150
+ while cursor <= self.end
151
+ month_end = cursor.last_day_of_month
152
+ segment_end = (month_end <= self.end) ? month_end : self.end
153
+ result << DateRange.new(cursor, segment_end)
154
+ break if segment_end == self.end
155
+
156
+ cursor = segment_end.add_days(1)
157
+ end
158
+ result
159
+ end
160
+
161
+ def split_by_fiscal_quarter
162
+ result = []
163
+ cursor = start
164
+ while cursor <= self.end
165
+ quarter_end = cursor.fiscal_year.quarter_range(cursor.quarter).end
166
+ segment_end = (quarter_end <= self.end) ? quarter_end : self.end
167
+ result << DateRange.new(cursor, segment_end)
168
+ break if segment_end == self.end
169
+
170
+ cursor = segment_end.add_days(1)
171
+ end
172
+ result
173
+ end
174
+
175
+ def dates_with_interval(step = 1)
176
+ step = Date.integer_argument(step, "step")
177
+ raise OutOfRangeError, "the step must be at least 1" if step < 1
178
+
179
+ result = []
180
+ cursor = start
181
+ loop do
182
+ result << cursor
183
+ remaining = cursor.days_until(self.end)
184
+ break if remaining < step
185
+
186
+ cursor = cursor.add_days(step)
187
+ end
188
+ result
189
+ end
190
+
191
+ # Every day except Saturday, and except Sunday when +exclude_sunday+ is true.
192
+ def working_days(exclude_sunday: false)
193
+ each.select do |date|
194
+ !date.weekday.saturday? && !(exclude_sunday && date.weekday.sunday?)
195
+ end
196
+ end
197
+
198
+ # Every Saturday, and Sunday when +include_sunday+ is true.
199
+ def weekend_days(include_sunday: true)
200
+ each.select do |date|
201
+ date.weekday.saturday? || (include_sunday && date.weekday.sunday?)
202
+ end
203
+ end
204
+
205
+ def each
206
+ return enum_for(:each) unless block_given?
207
+
208
+ serial = start.serial
209
+ last = self.end.serial
210
+ while serial <= last
211
+ yield Date.from_serial(serial)
212
+ serial += 1
213
+ end
214
+ self
215
+ end
216
+
217
+ def ==(other)
218
+ other.is_a?(DateRange) && start == other.start && self.end == other.end
219
+ end
220
+ alias_method :eql?, :==
221
+
222
+ def hash
223
+ [DateRange, start, self.end].hash
224
+ end
225
+
226
+ def to_s
227
+ "#{start} - #{self.end}"
228
+ end
229
+
230
+ def to_short_string(order: :ymd, separator: :slash, lang: :en)
231
+ "#{start.to_short_string(order: order, separator: separator, lang: lang)} - " \
232
+ "#{self.end.to_short_string(order: order, separator: separator, lang: lang)}"
233
+ end
234
+
235
+ def to_h
236
+ {start: start, end: self.end}
237
+ end
238
+
239
+ def inspect
240
+ "#<NepDate::DateRange #{start.iso8601}..#{self.end.iso8601}>"
241
+ end
242
+
243
+ private
244
+
245
+ def expect_range!(other)
246
+ raise TypeError, "expected a NepDate::DateRange, got #{other.class}" unless other.is_a?(DateRange)
247
+
248
+ other
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NepDate
4
+ # The rule that rejected a date, a parse or an arithmetic operation. The names match the
5
+ # neutral error kinds of PARSING.md section 4; each is available as a symbol.
6
+ module ErrorKind
7
+ OUT_OF_RANGE = :out_of_range
8
+ INVALID_MONTH = :invalid_month
9
+ INVALID_DAY = :invalid_day
10
+ INVALID_GREGORIAN = :invalid_gregorian
11
+ INVALID_CHARACTER = :invalid_character
12
+ WRONG_GROUP_COUNT = :wrong_group_count
13
+ NUMBER_TOO_LONG = :number_too_long
14
+ AMBIGUOUS = :ambiguous
15
+ UNRECOGNIZED = :unrecognized
16
+
17
+ ALL = [
18
+ OUT_OF_RANGE, INVALID_MONTH, INVALID_DAY, INVALID_GREGORIAN,
19
+ INVALID_CHARACTER, WRONG_GROUP_COUNT, NUMBER_TOO_LONG, AMBIGUOUS, UNRECOGNIZED
20
+ ].freeze
21
+
22
+ # The PascalCase spelling used by the shared vectors, for example +InvalidMonth+.
23
+ def self.name_for(kind)
24
+ kind.to_s.split("_").map(&:capitalize).join
25
+ end
26
+ end
27
+
28
+ # Every failure this library raises. +kind+ says which rule failed, so callers match on the
29
+ # symbol instead of the message.
30
+ class Error < StandardError
31
+ attr_reader :kind
32
+
33
+ def initialize(kind, message)
34
+ @kind = kind.to_sym
35
+ super(message)
36
+ end
37
+
38
+ def kind_name
39
+ ErrorKind.name_for(kind)
40
+ end
41
+ end
42
+
43
+ # Raised when a parsed or constructed date, or an arithmetic result, is outside the supported
44
+ # BS range.
45
+ class OutOfRangeError < Error
46
+ def initialize(message)
47
+ super(ErrorKind::OUT_OF_RANGE, message)
48
+ end
49
+ end
50
+
51
+ # Raised when a month number is outside 1 to 12.
52
+ class InvalidMonthError < Error
53
+ def initialize(message)
54
+ super(ErrorKind::INVALID_MONTH, message)
55
+ end
56
+ end
57
+
58
+ # Raised when a day number is outside the month.
59
+ class InvalidDayError < Error
60
+ def initialize(message)
61
+ super(ErrorKind::INVALID_DAY, message)
62
+ end
63
+ end
64
+
65
+ # Raised when an AD date is not a real Gregorian day.
66
+ class InvalidGregorianError < Error
67
+ def initialize(message)
68
+ super(ErrorKind::INVALID_GREGORIAN, message)
69
+ end
70
+ end
71
+
72
+ # Raised by strict and lenient parsing. +kind+ is the parse error kind from PARSING.md.
73
+ class ParseError < Error
74
+ def initialize(kind, message)
75
+ super
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NepDate
4
+ # A fiscal year, 1 Shrawan through the end of Ashad of the following BS year. The value
5
+ # carries the BS year in which it starts, so a date in months 1 to 3 belongs to the fiscal
6
+ # year that started in the previous BS year (D-08).
7
+ class FiscalYear
8
+ include Comparable
9
+
10
+ attr_reader :start_year
11
+
12
+ def initialize(start_year)
13
+ @start_year = Integer(start_year)
14
+ freeze
15
+ end
16
+
17
+ def self.from(value)
18
+ return value if value.is_a?(FiscalYear)
19
+
20
+ new(value)
21
+ end
22
+
23
+ # The fiscal year containing +date+.
24
+ def self.containing(date)
25
+ raise TypeError, "expected a NepDate::Date, got #{date.class}" unless date.is_a?(Date)
26
+
27
+ new((date.month >= 4) ? date.year : date.year - 1)
28
+ end
29
+
30
+ # 1 Shrawan of the fiscal year.
31
+ def start
32
+ Date.from_bs(start_year, 4, 1)
33
+ end
34
+
35
+ # The last day of Ashad of the following BS year.
36
+ def end
37
+ year = start_year + 1
38
+ Date.from_bs(year, 3, Calendar.month_length(year, 3))
39
+ end
40
+
41
+ def range
42
+ DateRange.new(start, self.end)
43
+ end
44
+
45
+ # The quarter +quarter+ of this fiscal year, clipped to the fiscal year's own bounds.
46
+ def quarter_range(quarter)
47
+ quarter = Quarter.from(quarter)
48
+ start_month, start_offset, end_month, end_offset =
49
+ case quarter.number
50
+ when 1 then [4, 0, 6, 0]
51
+ when 2 then [7, 0, 9, 0]
52
+ when 3 then [10, 0, 12, 0]
53
+ else [1, 1, 3, 1]
54
+ end
55
+
56
+ first = Date.from_bs(start_year + start_offset, start_month, 1)
57
+ last_year = start_year + end_offset
58
+ last = Date.from_bs(last_year, end_month, Calendar.month_length(last_year, end_month))
59
+ DateRange.new(first, last)
60
+ end
61
+
62
+ def quarter_start(quarter)
63
+ quarter_range(quarter).start
64
+ end
65
+
66
+ def quarter_end(quarter)
67
+ quarter_range(quarter).end
68
+ end
69
+
70
+ # The label, "2081/82", with digits in +lang+.
71
+ def label(lang: :en)
72
+ lang = Lang.coerce(lang)
73
+ "#{Names.digits(start_year, lang, width: 4)}/#{Names.digits((start_year + 1) % 100, lang, width: 2)}"
74
+ end
75
+
76
+ def succ
77
+ FiscalYear.new(start_year + 1)
78
+ end
79
+
80
+ def <=>(other)
81
+ return nil unless other.is_a?(FiscalYear)
82
+
83
+ start_year <=> other.start_year
84
+ end
85
+
86
+ def ==(other)
87
+ other.is_a?(FiscalYear) && start_year == other.start_year
88
+ end
89
+ alias_method :eql?, :==
90
+
91
+ def hash
92
+ [FiscalYear, start_year].hash
93
+ end
94
+
95
+ def to_s
96
+ label
97
+ end
98
+
99
+ def inspect
100
+ "#<NepDate::FiscalYear #{start_year}/#{(start_year + 1) % 100}>"
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NepDate
4
+ # Formatting for every public form (FORMATTING.md sections 2 to 5). One pass, no regular
5
+ # expressions in the pattern formatter.
6
+ # @api private
7
+ module Format
8
+ DEFAULT_PATTERNS = ["", "G", "g", "d"].freeze
9
+
10
+ module_function
11
+
12
+ def default(year, month, day, lang)
13
+ lang = Lang.coerce(lang)
14
+ if lang == :ne
15
+ "#{Names::YEAR_NE[year - CalendarTable::MIN_YEAR]}/#{Names::TWO_DIGIT_NE[month]}/#{Names::TWO_DIGIT_NE[day]}"
16
+ else
17
+ "#{Names::YEAR_EN[year - CalendarTable::MIN_YEAR]}/#{Names::TWO_DIGIT_EN[month]}/#{Names::TWO_DIGIT_EN[day]}"
18
+ end
19
+ end
20
+
21
+ def iso8601(year, month, day, lang)
22
+ lang = Lang.coerce(lang)
23
+ if lang == :ne
24
+ "#{Names::YEAR_NE[year - CalendarTable::MIN_YEAR]}-#{Names::TWO_DIGIT_NE[month]}-#{Names::TWO_DIGIT_NE[day]}"
25
+ else
26
+ "#{Names::YEAR_EN[year - CalendarTable::MIN_YEAR]}-#{Names::TWO_DIGIT_EN[month]}-#{Names::TWO_DIGIT_EN[day]}"
27
+ end
28
+ end
29
+
30
+ def short(year, month, day, order:, separator:, pad:, lang:)
31
+ lang = Lang.coerce(lang)
32
+ fields = DateOrder.fields(order)
33
+ separator = Separator.character(separator)
34
+ values = {
35
+ year: Names.digits(year, lang, width: 4),
36
+ month: Names.digits(month, lang, width: pad ? 2 : 0),
37
+ day: Names.digits(day, lang, width: pad ? 2 : 0)
38
+ }
39
+
40
+ fields.map { |field| values.fetch(field) }.join(separator)
41
+ end
42
+
43
+ def long(year, month, day, weekday_number:, include_year:, pad:, lang:)
44
+ lang = Lang.coerce(lang)
45
+ if lang == :ne
46
+ day_text = pad ? Names::TWO_DIGIT_NE[day] : Names::NUMBER_NE[day]
47
+ year_text = Names::YEAR_NE[year - CalendarTable::MIN_YEAR]
48
+ month_text = Names::MONTH_NE[month]
49
+ prefix = weekday_number.nil? ? "" : "#{Names::WEEKDAY_NE[weekday_number]}, "
50
+ else
51
+ day_text = pad ? Names::TWO_DIGIT_EN[day] : Names::NUMBER_EN[day]
52
+ year_text = Names::YEAR_EN[year - CalendarTable::MIN_YEAR]
53
+ month_text = Names::MONTH_EN[month]
54
+ prefix = weekday_number.nil? ? "" : "#{Names::WEEKDAY_EN[weekday_number]}, "
55
+ end
56
+ suffix = include_year ? ", #{year_text}" : ""
57
+
58
+ "#{prefix}#{month_text} #{day_text}#{suffix}"
59
+ end
60
+
61
+ def pattern(pattern, year:, month:, day:, lang:)
62
+ raise TypeError, "format pattern must be a String, got #{pattern.class}" unless pattern.is_a?(String)
63
+
64
+ lang = Lang.coerce(lang)
65
+ return default(year, month, day, lang) if DEFAULT_PATTERNS.include?(pattern)
66
+ return long(year, month, day, weekday_number: nil, include_year: true, pad: true, lang: lang) if pattern == "D"
67
+ return iso8601(year, month, day, lang) if pattern == "s"
68
+
69
+ tokens = {
70
+ year: Names.digits(year, lang, width: 4),
71
+ year2: Names.digits(year % 100, lang, width: 2),
72
+ month_name: Names.month_name(month, lang),
73
+ month_short: Names.month_short(month, lang),
74
+ month_pad: Names.digits(month, lang, width: 2),
75
+ month: Names.digits(month, lang),
76
+ day_pad: Names.digits(day, lang, width: 2),
77
+ day: Names.digits(day, lang)
78
+ }
79
+
80
+ out = +""
81
+ chars = pattern.chars
82
+ index = 0
83
+ while index < chars.length
84
+ char = chars[index]
85
+ if char == "\\"
86
+ # A trailing backslash has nothing to escape, so it is written literally.
87
+ out << ((index + 1 < chars.length) ? chars[index + 1] : "\\")
88
+ index += 2
89
+ next
90
+ end
91
+
92
+ if char == "'"
93
+ closing = ((index + 1)...chars.length).find { |position| chars[position] == "'" }
94
+ if closing
95
+ out << chars[(index + 1)...closing].join
96
+ index = closing + 1
97
+ else
98
+ out << chars[(index + 1)..].join
99
+ break
100
+ end
101
+ next
102
+ end
103
+
104
+ if char == "y"
105
+ finish = run_end(chars, index, "y")
106
+ out << ((finish - index >= 4) ? tokens[:year] : tokens[:year2])
107
+ index = finish
108
+ next
109
+ end
110
+
111
+ if char == "M"
112
+ finish = run_end(chars, index, "M")
113
+ length = finish - index
114
+ value = if length >= 4
115
+ tokens[:month_name]
116
+ elsif length == 3
117
+ tokens[:month_short]
118
+ elsif length == 2
119
+ tokens[:month_pad]
120
+ else
121
+ tokens[:month]
122
+ end
123
+ out << value
124
+ index = finish
125
+ next
126
+ end
127
+
128
+ if char == "d"
129
+ finish = run_end(chars, index, "d")
130
+ out << ((finish - index == 2) ? tokens[:day_pad] : tokens[:day])
131
+ index = finish
132
+ next
133
+ end
134
+
135
+ out << char
136
+ index += 1
137
+ end
138
+
139
+ out
140
+ end
141
+
142
+ def run_end(chars, index, char)
143
+ finish = index + 1
144
+ finish += 1 while finish < chars.length && chars[finish] == char
145
+ finish
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NepDate
4
+ # The field order of short and range formatting (FORMATTING.md section 3). Use the symbols
5
+ # +:ymd+, +:ydm+, +:myd+, +:mdy+, +:dym+ or +:dmy+.
6
+ module DateOrder
7
+ ORDER = {
8
+ ymd: [:year, :month, :day],
9
+ ydm: [:year, :day, :month],
10
+ myd: [:month, :year, :day],
11
+ mdy: [:month, :day, :year],
12
+ dym: [:day, :year, :month],
13
+ dmy: [:day, :month, :year]
14
+ }.freeze
15
+
16
+ ALL = ORDER.keys.freeze
17
+
18
+ def self.coerce(value)
19
+ symbol = value.to_s.downcase.to_sym
20
+ return symbol if ORDER.key?(symbol)
21
+
22
+ raise ArgumentError, "date order must be one of #{ALL.join(", ")}, got #{value.inspect}"
23
+ end
24
+
25
+ def self.fields(value)
26
+ ORDER.fetch(coerce(value))
27
+ end
28
+ end
29
+
30
+ # The separator of short and range formatting (FORMATTING.md section 3). Use +:slash+,
31
+ # +:backslash+, +:dot+, +:underscore+, +:dash+ or +:space+, or pass the character itself.
32
+ module Separator
33
+ CHARACTERS = {
34
+ slash: "/",
35
+ backslash: "\\",
36
+ dot: ".",
37
+ underscore: "_",
38
+ dash: "-",
39
+ space: " "
40
+ }.freeze
41
+
42
+ ALL = CHARACTERS.keys.freeze
43
+ BY_CHARACTER = CHARACTERS.invert.freeze
44
+
45
+ def self.coerce(value)
46
+ return BY_CHARACTER.fetch(value) if CHARACTERS.value?(value)
47
+
48
+ symbol = value.to_s.downcase.to_sym
49
+ return symbol if CHARACTERS.key?(symbol)
50
+
51
+ raise ArgumentError, "separator must be one of #{ALL.join(", ")}, got #{value.inspect}"
52
+ end
53
+
54
+ def self.character(value)
55
+ return value if CHARACTERS.value?(value)
56
+
57
+ CHARACTERS.fetch(coerce(value))
58
+ end
59
+
60
+ def self.name_for(character)
61
+ BY_CHARACTER.fetch(character)
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NepDate
4
+ # The output language: +:en+ for English, +:ne+ for Nepali. Symbols are the Ruby spelling of
5
+ # the C# +Lang+ option used by the other ports.
6
+ module Lang
7
+ EN = :en
8
+ NE = :ne
9
+
10
+ def self.coerce(value)
11
+ return value if value == EN || value == NE
12
+
13
+ case value
14
+ when "en" then EN
15
+ when "ne" then NE
16
+ else
17
+ raise ArgumentError, "language must be :en or :ne, got #{value.inspect}"
18
+ end
19
+ end
20
+ end
21
+ end