jsonata 2.2.2.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,1237 @@
1
+ # frozen_string_literal: true
2
+
3
+ # (c) Copyright IBM Corp. 2018 All Rights Reserved
4
+ # Project name: JSONata
5
+ # This project is licensed under the MIT License, see LICENSE
6
+
7
+ # Port of jsonata-js src/datetime.js
8
+ #
9
+ # DateTime formatting and parsing functions
10
+ # Implements the xpath-functions format-date-time specification
11
+ #
12
+ # JavaScript Date objects are represented by UTC Ruby Time objects.
13
+
14
+ module Jsonata
15
+ module DateTime
16
+ FEW = %w[Zero One Two Three Four Five Six Seven Eight Nine Ten
17
+ Eleven Twelve Thirteen Fourteen Fifteen Sixteen Seventeen Eighteen Nineteen].freeze
18
+ ORDINALS = %w[Zeroth First Second Third Fourth Fifth Sixth Seventh Eighth Ninth Tenth
19
+ Eleventh Twelfth Thirteenth Fourteenth Fifteenth Sixteenth Seventeenth Eighteenth Nineteenth].freeze
20
+ DECADES = %w[Twenty Thirty Forty Fifty Sixty Seventy Eighty Ninety Hundred].freeze
21
+ MAGNITUDES = %w[Thousand Million Billion Trillion].freeze
22
+
23
+ # converts a number into english words
24
+ # @param value - the value to format
25
+ # @param ordinal - ordinal or cardinal form
26
+ # @returns representation in words
27
+ def number_to_words(value, ordinal)
28
+ lookup = nil
29
+ lookup = lambda do |num, prev, ord|
30
+ words = +''
31
+ if num <= 19
32
+ words = (prev ? ' and ' : '') + (ord ? ORDINALS[num] : FEW[num])
33
+ elsif num < 100
34
+ tens = Utils.js_floor(num / 10.0)
35
+ remainder = num % 10
36
+ words = (prev ? ' and ' : '') + DECADES[tens - 2]
37
+ if remainder.positive?
38
+ words += "-#{lookup.call(remainder, false, ord)}"
39
+ elsif ord
40
+ words = "#{words[0...-1]}ieth"
41
+ end
42
+ elsif num < 1000
43
+ hundreds = Utils.js_floor(num / 100.0)
44
+ remainder = num % 100
45
+ words = "#{', ' if prev}#{FEW[hundreds]} Hundred"
46
+ if remainder.positive?
47
+ words += lookup.call(remainder, true, ord)
48
+ elsif ord
49
+ words += 'th'
50
+ end
51
+ else
52
+ mag = (Math.log10(num) / 3).floor
53
+ mag = MAGNITUDES.length if mag > MAGNITUDES.length # the largest word
54
+ factor = 10.0**(mag * 3)
55
+ mant = Utils.js_floor(num / factor)
56
+ remainder = Utils.normalize_number(num - (mant * factor))
57
+ words = "#{', ' if prev}#{lookup.call(mant, false, false)} #{MAGNITUDES[mag - 1]}"
58
+ if remainder.positive?
59
+ words += lookup.call(remainder, true, ord)
60
+ elsif ord
61
+ words += 'th'
62
+ end
63
+ end
64
+ words
65
+ end
66
+
67
+ lookup.call(value, false, ordinal)
68
+ end
69
+
70
+ WORD_VALUES = {}.tap do |word_values|
71
+ FEW.each_with_index do |word, index|
72
+ word_values[word.downcase] = index
73
+ end
74
+ ORDINALS.each_with_index do |word, index|
75
+ word_values[word.downcase] = index
76
+ end
77
+ DECADES.each_with_index do |word, index|
78
+ lword = word.downcase
79
+ word_values[lword] = (index + 2) * 10
80
+ word_values["#{lword[0, word.length - 1]}ieth"] = word_values[lword]
81
+ end
82
+ word_values['hundredth'] = 100
83
+ MAGNITUDES.each_with_index do |word, index|
84
+ lword = word.downcase
85
+ val = 10**((index + 1) * 3)
86
+ word_values[lword] = val
87
+ word_values["#{lword}th"] = val
88
+ end
89
+ end.freeze
90
+
91
+ # Converts a number in english words to numeric value
92
+ # @param text - the number in words
93
+ # @returns the numeric value
94
+ def words_to_number(text)
95
+ parts = text.split(/,\s|\sand\s|[\s\\-]/, -1)
96
+ values = parts.map { |part| WORD_VALUES[part] }
97
+ segs = [0]
98
+ values.each do |value|
99
+ if !value.nil? && value < 100
100
+ top = segs.pop
101
+ if top >= 1000
102
+ segs.push(top)
103
+ top = 0
104
+ end
105
+ segs.push(top + value)
106
+ else
107
+ segs.push(value.nil? ? Float::NAN : segs.pop * value.to_f)
108
+ end
109
+ end
110
+ Utils.normalize_number(segs.reduce(0) { |a, b| a + b })
111
+ end
112
+
113
+ ROMAN_NUMERALS = [
114
+ [1000, 'm'],
115
+ [900, 'cm'],
116
+ [500, 'd'],
117
+ [400, 'cd'],
118
+ [100, 'c'],
119
+ [90, 'xc'],
120
+ [50, 'l'],
121
+ [40, 'xl'],
122
+ [10, 'x'],
123
+ [9, 'ix'],
124
+ [5, 'v'],
125
+ [4, 'iv'],
126
+ [1, 'i']
127
+ ].freeze
128
+
129
+ ROMAN_VALUES = {'M' => 1000, 'D' => 500, 'C' => 100, 'L' => 50, 'X' => 10, 'V' => 5, 'I' => 1}.freeze
130
+
131
+ # converts a number to roman numerals
132
+ # @param value - the number
133
+ # @returns the number in roman numerals
134
+ def decimal_to_roman(value)
135
+ ROMAN_NUMERALS.each do |numeral|
136
+ return numeral[1] + decimal_to_roman(value - numeral[0]) if value >= numeral[0]
137
+ end
138
+ ''
139
+ end
140
+
141
+ # converts roman numerals to a number
142
+ # @param roman - roman number
143
+ # @returns the numeric value
144
+ def roman_to_decimal(roman)
145
+ decimal = 0
146
+ max = 1
147
+ (roman.length - 1).downto(0) do |i|
148
+ digit = roman[i]
149
+ value = ROMAN_VALUES[digit]
150
+ if value < max
151
+ decimal -= value
152
+ else
153
+ max = value
154
+ decimal += value
155
+ end
156
+ end
157
+ decimal
158
+ end
159
+
160
+ # converts a number to spreadsheet style letters
161
+ # @param value - the number
162
+ # @param a_char - the character representing the start of the sequence, e.g. 'A'
163
+ # @returns the letters
164
+ def decimal_to_letters(value, a_char)
165
+ letters = []
166
+ a_code = a_char.ord
167
+ while value.positive?
168
+ letters.unshift((((value - 1) % 26) + a_code).chr(Encoding::UTF_8))
169
+ value = ((value - 1) / 26).floor
170
+ end
171
+ letters.join
172
+ end
173
+
174
+ # converts spreadsheet style letters to a number
175
+ # @param letters - the letters
176
+ # @param a_char - the character representing the start of the sequence, e.g. 'A'
177
+ # @returns the numeric value
178
+ def letters_to_decimal(letters, a_char)
179
+ a_code = a_char.ord
180
+ decimal = 0
181
+ letters.length.times do |i|
182
+ decimal += (letters[letters.length - i - 1].ord - a_code + 1) * (26**i)
183
+ end
184
+ decimal
185
+ end
186
+
187
+ # Formats an integer as specified by the XPath fn:format-integer function
188
+ # See https://www.w3.org/TR/xpath-functions-31/#func-format-integer
189
+ # @param value - the number to be formatted
190
+ # @param picture - the picture string that specifies the format
191
+ # @returns the formatted number
192
+ def format_integer(value, picture)
193
+ return nil if value.nil?
194
+
195
+ value = Utils.js_floor(value)
196
+
197
+ format = analyse_integer_picture(picture)
198
+ _format_integer(value, format)
199
+ end
200
+
201
+ FORMATS = {
202
+ :DECIMAL => 'decimal',
203
+ :LETTERS => 'letters',
204
+ :ROMAN => 'roman',
205
+ :WORDS => 'words',
206
+ :SEQUENCE => 'sequence'
207
+ }.freeze
208
+
209
+ TCASE = {
210
+ :UPPER => 'upper',
211
+ :LOWER => 'lower',
212
+ :TITLE => 'title'
213
+ }.freeze
214
+
215
+ # formats an integer using a preprocessed representation of the picture string
216
+ # @param value - the number to be formatted
217
+ # @param format - the preprocessed representation of the pucture string
218
+ # @returns the formatted number
219
+ def _format_integer(value, format)
220
+ formatted_integer = nil
221
+ negative = value.negative?
222
+ value = value.abs
223
+ case format[:primary]
224
+ when FORMATS[:LETTERS]
225
+ formatted_integer = decimal_to_letters(value, format[:case] == TCASE[:UPPER] ? 'A' : 'a')
226
+ when FORMATS[:ROMAN]
227
+ formatted_integer = decimal_to_roman(value)
228
+ formatted_integer = formatted_integer.upcase if format[:case] == TCASE[:UPPER]
229
+ when FORMATS[:WORDS]
230
+ formatted_integer = number_to_words(value, format[:ordinal])
231
+ if format[:case] == TCASE[:UPPER]
232
+ formatted_integer = formatted_integer.upcase
233
+ elsif format[:case] == TCASE[:LOWER]
234
+ formatted_integer = formatted_integer.downcase
235
+ end
236
+ when FORMATS[:DECIMAL]
237
+ formatted_integer = Utils.number_to_s(value)
238
+ # TODO use functionPad
239
+ pad_length = format[:mandatory_digits] - formatted_integer.length
240
+ if pad_length.positive?
241
+ padding = '0' * pad_length
242
+ formatted_integer = padding + formatted_integer
243
+ end
244
+ if format[:zero_code] != 0x30
245
+ formatted_integer = Utils.string_to_array(formatted_integer).map do |code|
246
+ (code.ord + format[:zero_code] - 0x30).chr(Encoding::UTF_8)
247
+ end.join
248
+ end
249
+ # insert the grouping-separator-signs, if any
250
+ if format[:regular]
251
+ n = ((formatted_integer.length - 1) / format[:grouping_separators][:position]).floor
252
+ n.downto(1) do |ii|
253
+ pos = formatted_integer.length - (ii * format[:grouping_separators][:position])
254
+ formatted_integer = formatted_integer[0, pos] + format[:grouping_separators][:character] +
255
+ formatted_integer[pos..]
256
+ end
257
+ else
258
+ format[:grouping_separators].reverse_each do |separator|
259
+ pos = formatted_integer.length - separator[:position]
260
+ formatted_integer = formatted_integer[0, pos] + separator[:character] + formatted_integer[pos..]
261
+ end
262
+ end
263
+
264
+ if format[:ordinal]
265
+ suffix123 = {'1' => 'st', '2' => 'nd', '3' => 'rd'}
266
+ last_digit = formatted_integer[-1]
267
+ suffix = suffix123[last_digit]
268
+ suffix = 'th' if !suffix || (formatted_integer.length > 1 && formatted_integer[-2] == '1')
269
+ formatted_integer += suffix
270
+ end
271
+ when FORMATS[:SEQUENCE]
272
+ raise Jsonata::Error.new('D3130', :value => format[:token])
273
+ end
274
+ formatted_integer = "-#{formatted_integer}" if negative
275
+
276
+ formatted_integer
277
+ end
278
+
279
+ # TODO what about decimal groups in the unicode supplementary planes (surrogate pairs) ???
280
+ DECIMAL_GROUPS = [0x30, 0x0660, 0x06F0, 0x07C0, 0x0966, 0x09E6, 0x0A66, 0x0AE6, 0x0B66, 0x0BE6, 0x0C66, 0x0CE6,
281
+ 0x0D66, 0x0DE6, 0x0E50, 0x0ED0, 0x0F20, 0x1040, 0x1090, 0x17E0, 0x1810, 0x1946, 0x19D0, 0x1A80,
282
+ 0x1A90, 0x1B50, 0x1BB0, 0x1C40, 0x1C50, 0xA620, 0xA8D0, 0xA900, 0xA9D0, 0xA9F0, 0xAA50, 0xABF0,
283
+ 0xFF10].freeze
284
+
285
+ # preprocesses the picture string
286
+ # @param picture - picture string
287
+ # @returns analysed picture
288
+ def analyse_integer_picture(picture)
289
+ format = {
290
+ :type => 'integer',
291
+ :primary => FORMATS[:DECIMAL],
292
+ :case => TCASE[:LOWER],
293
+ :ordinal => false
294
+ }
295
+
296
+ semicolon = picture.rindex(';')
297
+ if semicolon.nil?
298
+ primary_format = picture
299
+ else
300
+ primary_format = picture[0, semicolon]
301
+ format_modifier = picture[(semicolon + 1)..]
302
+ format[:ordinal] = true if format_modifier[0] == 'o'
303
+ end
304
+
305
+ case primary_format
306
+ when 'A', 'a'
307
+ format[:case] = TCASE[:UPPER] if primary_format == 'A'
308
+ format[:primary] = FORMATS[:LETTERS]
309
+ when 'I', 'i'
310
+ format[:case] = TCASE[:UPPER] if primary_format == 'I'
311
+ format[:primary] = FORMATS[:ROMAN]
312
+ when 'W'
313
+ format[:case] = TCASE[:UPPER]
314
+ format[:primary] = FORMATS[:WORDS]
315
+ when 'Ww'
316
+ format[:case] = TCASE[:TITLE]
317
+ format[:primary] = FORMATS[:WORDS]
318
+ when 'w'
319
+ format[:primary] = FORMATS[:WORDS]
320
+ else
321
+ # this is a decimal-digit-pattern if it contains a decimal digit (from any unicode decimal digit group)
322
+ zero_code = nil
323
+ mandatory_digits = 0
324
+ optional_digits = 0
325
+ grouping_separators = []
326
+ separator_position = 0
327
+ # reverse the array to determine positions of grouping-separator-signs
328
+ format_codepoints = Utils.string_to_array(primary_format).map(&:ord).reverse
329
+ format_codepoints.each do |code_point|
330
+ # step though each char in the picture to determine the digit group
331
+ digit = false
332
+ DECIMAL_GROUPS.each do |group|
333
+ next unless code_point.between?(group, group + 9)
334
+
335
+ # codepoint is part of this decimal group
336
+ digit = true
337
+ mandatory_digits += 1
338
+ separator_position += 1
339
+ if zero_code.nil?
340
+ zero_code = group
341
+ elsif group != zero_code
342
+ # error! different decimal groups in the same pattern
343
+ raise Jsonata::Error.new('D3131')
344
+ end
345
+ break
346
+ end
347
+ next if digit
348
+
349
+ if code_point == 0x23 # # - optional-digit-sign
350
+ separator_position += 1
351
+ optional_digits += 1
352
+ else
353
+ # neither a decimal-digit-sign ot optional-digit-sign, assume it is a grouping-separator-sign
354
+ grouping_separators.push({
355
+ :position => separator_position,
356
+ :character => code_point.chr(Encoding::UTF_8)
357
+ })
358
+ end
359
+ end
360
+ if mandatory_digits.positive?
361
+ format[:primary] = FORMATS[:DECIMAL]
362
+ # TODO validate decimal-digit-pattern
363
+
364
+ # the decimal digit family (codepoint offset)
365
+ format[:zero_code] = zero_code
366
+ # the number of mandatory digits
367
+ format[:mandatory_digits] = mandatory_digits
368
+ # the number of optional digits
369
+ format[:optional_digits] = optional_digits
370
+ # grouping separator template
371
+ # are the grouping-separator-signs 'regular'?
372
+ regular_repeat = lambda do |separators|
373
+ # are the grouping positions regular? i.e. same interval between each of them
374
+ # is there at least one separator?
375
+ return 0 if separators.empty?
376
+
377
+ # are all the characters the same?
378
+ sep_char = separators[0][:character]
379
+ separators.drop(1).each do |separator|
380
+ return 0 if separator[:character] != sep_char
381
+ end
382
+ # are they equally spaced?
383
+ indexes = separators.map { |separator| separator[:position] }
384
+ # find the greatest common divisor of all the positions
385
+ factor = indexes.reduce { |a, b| a.gcd(b) }
386
+ # is every position separated by this divisor? If so, it's regular
387
+ (1..indexes.length).each do |index|
388
+ return 0 unless indexes.include?(index * factor)
389
+ end
390
+ factor
391
+ end
392
+
393
+ regular = regular_repeat.call(grouping_separators)
394
+ if regular.positive?
395
+ format[:regular] = true
396
+ format[:grouping_separators] = {
397
+ :position => regular,
398
+ :character => grouping_separators[0][:character]
399
+ }
400
+ else
401
+ format[:regular] = false
402
+ format[:grouping_separators] = grouping_separators
403
+ end
404
+
405
+ else
406
+ # this is a 'numbering sequence' which the spec says is implementation-defined
407
+ # this implementation doesn't support any numbering sequences at the moment.
408
+ format[:primary] = FORMATS[:SEQUENCE]
409
+ format[:token] = primary_format
410
+ end
411
+ end
412
+
413
+ format
414
+ end
415
+
416
+ DEFAULT_PRESENTATION_MODIFIERS = {
417
+ 'Y' => '1', 'M' => '1', 'D' => '1', 'd' => '1', 'F' => 'n', 'W' => '1', 'w' => '1', 'X' => '1', 'x' => '1',
418
+ 'H' => '1', 'h' => '1', 'P' => 'n', 'm' => '01', 's' => '01', 'f' => '1', 'Z' => '01:01', 'z' => '01:01',
419
+ 'C' => 'n', 'E' => 'n'
420
+ }.freeze
421
+
422
+ # Section 9.8.4.1 the format specifier is an array of string literals and variable markers
423
+ # analyse the date-time picture string
424
+ # @param picture - picture string
425
+ # @returns the analysed string
426
+ def analyse_date_time_picture(picture)
427
+ spec = []
428
+ format = {
429
+ :type => 'datetime',
430
+ :parts => spec
431
+ }
432
+ add_literal = lambda do |start, end_pos|
433
+ if end_pos > start
434
+ literal = picture[start...end_pos]
435
+ # replace any doubled ]] with single ]
436
+ # what if there are instances of single ']' ? - the spec doesn't say
437
+ literal = literal.gsub(']]', ']')
438
+ spec.push({:type => 'literal', :value => literal})
439
+ end
440
+ end
441
+
442
+ start = 0
443
+ pos = 0
444
+ while pos < picture.length
445
+ if picture[pos] == '['
446
+ # check it's not a doubled [[
447
+ if picture[pos + 1] == '['
448
+ # literal [
449
+ add_literal.call(start, pos)
450
+ spec.push({:type => 'literal', :value => '['})
451
+ pos += 2
452
+ start = pos
453
+ next
454
+ end
455
+ # start of variable marker
456
+ # push the string literal (if there is one) onto the array
457
+ add_literal.call(start, pos)
458
+ start = pos
459
+ # search forward to closing ]
460
+ pos = picture.index(']', start)
461
+ # TODO handle error case if pos === -1
462
+ if pos.nil?
463
+ # error - no closing bracket
464
+ raise Jsonata::Error.new('D3135')
465
+ end
466
+
467
+ marker = picture[(start + 1)...pos]
468
+ # whitespace within a variable marker is ignored (i.e. remove it)
469
+ marker = marker.gsub(/\s+/, '')
470
+ defn = {
471
+ :type => 'marker',
472
+ :component => marker[0] || '' # 1. The component specifier is always present and is always a single letter.
473
+ }
474
+ comma = marker.rindex(',') # 2. The width modifier may be recognized by the presence of a comma
475
+ pres_mod = nil # the presentation modifiers
476
+ if comma.nil?
477
+ pres_mod = marker[1..] || ''
478
+ else
479
+ # Section 9.8.4.2 The Width Modifier
480
+ width_mod = marker[(comma + 1)..]
481
+ dash = width_mod.index('-')
482
+ min = nil
483
+ max = nil
484
+ parse_width = lambda do |wm|
485
+ if wm.nil? || wm == '*'
486
+ nil
487
+ else
488
+ # TODO validate wm is an unsigned int
489
+ Utils.parse_int(wm)
490
+ end
491
+ end
492
+ if dash.nil?
493
+ min = width_mod
494
+ else
495
+ min = width_mod[0, dash]
496
+ max = width_mod[(dash + 1)..]
497
+ end
498
+ width_def = {
499
+ :min => parse_width.call(min),
500
+ :max => parse_width.call(max)
501
+ }
502
+ defn[:width] = width_def
503
+ pres_mod = marker[1...comma] || ''
504
+ end
505
+ if pres_mod.length == 1
506
+ defn[:presentation1] = pres_mod # first presentation modifier
507
+ # TODO validate the first presentation modifier - it's either N, n, Nn or it passes analyseIntegerPicture
508
+ elsif pres_mod.length > 1
509
+ last_char = pres_mod[-1]
510
+ if 'atco'.include?(last_char)
511
+ defn[:presentation2] = last_char
512
+ defn[:ordinal] = true if last_char == 'o'
513
+ # 'c' means 'cardinal' and is the default (i.e. not 'ordinal')
514
+ # 'a' & 't' are ignored (not sure of their relevance to English numbering)
515
+ defn[:presentation1] = pres_mod[0...-1]
516
+ else
517
+ defn[:presentation1] = pres_mod
518
+ # TODO validate the first presentation modifier - it's either N, n, Nn or it passes analyseIntegerPicture,
519
+ # doesn't use ] as grouping separator, and if grouping separator is , then must have width modifier
520
+ end
521
+ else
522
+ # no presentation modifier specified - apply the default;
523
+ defn[:presentation1] = DEFAULT_PRESENTATION_MODIFIERS[defn[:component]]
524
+ end
525
+ if defn[:presentation1].nil?
526
+ # unknown component specifier
527
+ raise Jsonata::Error.new('D3132', :value => defn[:component])
528
+ end
529
+
530
+ if defn[:presentation1][0] == 'n'
531
+ defn[:names] = TCASE[:LOWER]
532
+ elsif defn[:presentation1][0] == 'N'
533
+ defn[:names] = if defn[:presentation1][1] == 'n'
534
+ TCASE[:TITLE]
535
+ else
536
+ TCASE[:UPPER]
537
+ end
538
+ elsif 'YMDdFWwXxHhmsf'.include?(defn[:component])
539
+ integer_pattern = defn[:presentation1]
540
+ integer_pattern += ";#{defn[:presentation2]}" if defn[:presentation2]
541
+ defn[:integer_format] = analyse_integer_picture(integer_pattern)
542
+ if defn[:width] && !defn[:width][:min].nil? && (defn[:integer_format][:mandatory_digits] < defn[:width][:min])
543
+ defn[:integer_format][:mandatory_digits] = defn[:width][:min]
544
+ end
545
+ if defn[:component] == 'Y'
546
+ # Section 9.8.4.4
547
+ defn[:n] = -1
548
+ if defn[:width] && !defn[:width][:max].nil?
549
+ defn[:n] = defn[:width][:max]
550
+ defn[:integer_format][:mandatory_digits] = defn[:n]
551
+ else
552
+ w = defn[:integer_format][:mandatory_digits].to_i + defn[:integer_format][:optional_digits].to_i
553
+ defn[:n] = w if w >= 2
554
+ end
555
+ end
556
+ # if the previous part is also an integer with no intervening markup, then its width for parsing must be precisely defined
557
+ previous_part = spec[-1]
558
+ if previous_part && previous_part[:integer_format]
559
+ previous_part[:integer_format][:parse_width] = previous_part[:integer_format][:mandatory_digits]
560
+ end
561
+ end
562
+ if defn[:component] == 'Z' || defn[:component] == 'z'
563
+ defn[:integer_format] = analyse_integer_picture(defn[:presentation1])
564
+ end
565
+ spec.push(defn)
566
+ start = pos + 1
567
+ end
568
+ pos += 1
569
+ end
570
+ add_literal.call(start, pos)
571
+ format
572
+ end
573
+
574
+ DAYS = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'].freeze
575
+ MONTHS = %w[January February March April May June July August September October November December].freeze
576
+ MILLIS_IN_A_DAY = 1000 * 60 * 60 * 24
577
+
578
+ # Equivalent of Date.UTC(year, month, day, hours, minutes, seconds, millis)
579
+ def self.date_utc(year, month = 0, day = 1, hours = 0, minutes = 0, seconds = 0, millis = 0)
580
+ values = [year, month, day, hours, minutes, seconds, millis]
581
+ return Float::NAN if values.any? { |v| v.nil? || (v.kind_of?(Float) && !v.finite?) }
582
+
583
+ year, month, day, hours, minutes, seconds, millis = values.map(&:truncate)
584
+ year += 1900 if year.between?(0, 99)
585
+ year += month.div(12)
586
+ month %= 12
587
+ days = days_from_civil(year, month + 1, 1) + day - 1
588
+ (days * MILLIS_IN_A_DAY) + (hours * 3_600_000) + (minutes * 60_000) + (seconds * 1000) + millis
589
+ end
590
+
591
+ # number of days since 1970-01-01 of the given proleptic Gregorian date
592
+ def self.days_from_civil(year, month, day)
593
+ year -= 1 if month <= 2
594
+ era = year.div(400)
595
+ yoe = year - (era * 400)
596
+ doy = (((153 * (month + (month > 2 ? -3 : 9))) + 2) / 5) + day - 1
597
+ doe = (yoe * 365) + (yoe / 4) - (yoe / 100) + doy
598
+ (era * 146_097) + doe - 719_468
599
+ end
600
+
601
+ # Equivalent of new Date(millis)
602
+ def self.new_date(millis)
603
+ Time.at(Rational(millis.truncate, 1000), :in => 'UTC')
604
+ end
605
+
606
+ # Equivalent of date.getTime()
607
+ def self.get_time(date)
608
+ (date.to_r * 1000).floor
609
+ end
610
+
611
+ # Equivalent of date.getUTCDay() - 0 is Sunday
612
+ def self.get_utc_day(date)
613
+ date.wday
614
+ end
615
+
616
+ def start_of_first_week(ym)
617
+ # ISO 8601 defines the first week of the year to be the week that contains the first Thursday
618
+ # XPath F&O extends this same definition for the first week of a month
619
+ # the week starts on a Monday - calculate the millis for the start of the first week
620
+ # millis for given 1st Jan of that year (at 00:00 UTC)
621
+ jan1 = DateTime.date_utc(ym[:year], ym[:month])
622
+ day_of_jan1 = DateTime.get_utc_day(DateTime.new_date(jan1))
623
+ day_of_jan1 = 7 if day_of_jan1.zero?
624
+ # if Jan 1 is Fri, Sat or Sun, then add the number of days (in millis) to jan1 to get the start of week 1
625
+ day_of_jan1 > 4 ? jan1 + ((8 - day_of_jan1) * MILLIS_IN_A_DAY) : jan1 - ((day_of_jan1 - 1) * MILLIS_IN_A_DAY)
626
+ end
627
+
628
+ def year_month(year, month)
629
+ {
630
+ :year => year,
631
+ :month => month,
632
+ :next_month => -> { month == 11 ? year_month(year + 1, 0) : year_month(year, month + 1) },
633
+ :previous_month => -> { month.zero? ? year_month(year - 1, 11) : year_month(year, month - 1) },
634
+ :next_year => -> { year_month(year + 1, month) },
635
+ :previous_year => -> { year_month(year - 1, month) }
636
+ }
637
+ end
638
+
639
+ def delta_weeks(start, end_pos)
640
+ ((end_pos - start) / (MILLIS_IN_A_DAY * 7.0)) + 1
641
+ end
642
+
643
+ def get_date_time_fragment(date, component)
644
+ component_value = nil
645
+ case component
646
+ when 'Y' # year
647
+ component_value = date.year
648
+ when 'M' # month in year
649
+ component_value = date.month
650
+ when 'D' # day in month
651
+ component_value = date.day
652
+ when 'd' # day in year
653
+ # millis for given date (at 00:00 UTC)
654
+ today = DateTime.date_utc(date.year, date.month - 1, date.day)
655
+ # millis for given 1st Jan of that year (at 00:00 UTC)
656
+ first_jan = DateTime.date_utc(date.year, 0)
657
+ component_value = ((today - first_jan) / MILLIS_IN_A_DAY) + 1
658
+ when 'F' # day of week
659
+ component_value = DateTime.get_utc_day(date)
660
+ # ISO 8601 defines days 1-7: Mon-Sun
661
+ component_value = 7 if component_value.zero?
662
+ when 'W' # week in year
663
+ this_year = year_month(date.year, 0)
664
+ start_of_week1 = start_of_first_week(this_year)
665
+ today = DateTime.date_utc(this_year[:year], date.month - 1, date.day)
666
+ week = delta_weeks(start_of_week1, today)
667
+ if week > 52
668
+ # might be first week of the following year
669
+ start_of_following_year = start_of_first_week(this_year[:next_year].call)
670
+ week = 1 if today >= start_of_following_year
671
+ elsif week < 1
672
+ # must be end of the previous year
673
+ start_of_previous_year = start_of_first_week(this_year[:previous_year].call)
674
+ week = delta_weeks(start_of_previous_year, today)
675
+ end
676
+ component_value = week.floor
677
+ when 'w' # week in month
678
+ this_month = year_month(date.year, date.month - 1)
679
+ start_of_week1 = start_of_first_week(this_month)
680
+ today = DateTime.date_utc(this_month[:year], this_month[:month], date.day)
681
+ week = delta_weeks(start_of_week1, today)
682
+ if week > 4
683
+ # might be first week of the following month
684
+ start_of_following_month = start_of_first_week(this_month[:next_month].call)
685
+ week = 1 if today >= start_of_following_month
686
+ elsif week < 1
687
+ # must be end of the previous month
688
+ start_of_previous_month = start_of_first_week(this_month[:previous_month].call)
689
+ week = delta_weeks(start_of_previous_month, today)
690
+ end
691
+ component_value = week.floor
692
+ when 'X' # ISO week-numbering year
693
+ # Extension: The F&O spec says nothing about how to access the year associated with the week-of-the-year
694
+ # e.g. Sat 1 Jan 2005 is in the 53rd week of 2004.
695
+ # The 'W' component specifier gives 53, but 'Y' will give 2005.
696
+ # I propose to add 'X' as the component specifier to give the ISO week-numbering year (2004 in this example)
697
+ this_year = year_month(date.year, 0)
698
+ start_of_iso_year = start_of_first_week(this_year)
699
+ end_of_iso_year = start_of_first_week(this_year[:next_year].call)
700
+ now = DateTime.get_time(date)
701
+ component_value = if now < start_of_iso_year
702
+ this_year[:year] - 1
703
+ elsif now >= end_of_iso_year
704
+ this_year[:year] + 1
705
+ else
706
+ this_year[:year]
707
+ end
708
+ when 'x' # ISO week-numbering month
709
+ # Extension: The F&O spec says nothing about how to access the month associated with the week-of-the-month
710
+ # e.g. Sat 1 Jan 2005 is in the 5th week of December 2004.
711
+ # The 'w' component specifier gives 5, but 'W' will give January and 'Y' will give 2005.
712
+ # I propose to add 'x' as the component specifier to give the 'week-numbering' month (December in this example)
713
+ this_month = year_month(date.year, date.month - 1)
714
+ start_of_iso_month = start_of_first_week(this_month)
715
+ next_month = this_month[:next_month].call
716
+ end_of_iso_month = start_of_first_week(next_month)
717
+ now = DateTime.get_time(date)
718
+ component_value = if now < start_of_iso_month
719
+ this_month[:previous_month].call[:month] + 1
720
+ elsif now >= end_of_iso_month
721
+ next_month[:month] + 1
722
+ else
723
+ this_month[:month] + 1
724
+ end
725
+ when 'H' # hour in day (24 hours)
726
+ component_value = date.hour
727
+ when 'h' # hour in half-day (12 hours)
728
+ component_value = date.hour
729
+ component_value %= 12
730
+ component_value = 12 if component_value.zero?
731
+ when 'P' # am/pm marker
732
+ component_value = date.hour >= 12 ? 'pm' : 'am'
733
+ when 'm' # minute in hour
734
+ component_value = date.min
735
+ when 's' # second in minute
736
+ component_value = date.sec
737
+ when 'f' # fractional seconds
738
+ component_value = date.nsec / 1_000_000
739
+ when 'Z', 'z' # timezone
740
+ # since the date object is constructed from epoch millis, the TZ component is always be UTC.
741
+ when 'C' # calendar name
742
+ component_value = 'ISO'
743
+ when 'E' # era
744
+ component_value = 'ISO'
745
+ end
746
+ component_value
747
+ end
748
+
749
+ ISO8601_PICTURE = '[Y0001]-[M01]-[D01]T[H01]:[m01]:[s01].[f001][Z01:01t]'
750
+
751
+ # formats the date/time as specified by the XPath fn:format-dateTime function
752
+ # @param millis - the timestamp to be formatted, in millis since the epoch
753
+ # @param picture - the picture string that specifies the format
754
+ # @param timezone - the timezone to use
755
+ # @returns the formatted timestamp
756
+ def format_date_time(millis, picture, timezone)
757
+ offset_hours = 0
758
+ offset_minutes = 0
759
+
760
+ unless timezone.nil?
761
+ # parse the hour and minute offsets
762
+ # assume for now the format supplied is +hhmm
763
+ offset = Utils.parse_int(timezone) || 0
764
+ offset_hours = (offset / 100.0).floor
765
+ offset_minutes = offset.remainder(100)
766
+ end
767
+
768
+ format_component = lambda do |date, marker_spec|
769
+ component_value = get_date_time_fragment(date, marker_spec[:component])
770
+
771
+ # Section 9.8.4.3 Formatting Integer-Valued Date/Time Components
772
+ if 'YMDdFWwXxHhms'.include?(marker_spec[:component])
773
+ if marker_spec[:component] == 'Y' && marker_spec[:n] != -1
774
+ # Section 9.8.4.4 Formatting the Year Component
775
+ component_value = marker_spec[:n].nil? ? Float::NAN : component_value % (10**marker_spec[:n])
776
+ end
777
+ if marker_spec[:names]
778
+ if marker_spec[:component] == 'M' || marker_spec[:component] == 'x'
779
+ component_value = MONTHS[component_value - 1]
780
+ elsif marker_spec[:component] == 'F'
781
+ component_value = DAYS[component_value]
782
+ else
783
+ raise Jsonata::Error.new('D3133', :value => marker_spec[:component])
784
+ end
785
+ if marker_spec[:names] == TCASE[:UPPER]
786
+ component_value = component_value.upcase
787
+ elsif marker_spec[:names] == TCASE[:LOWER]
788
+ component_value = component_value.downcase
789
+ end
790
+ if marker_spec[:width] && !marker_spec[:width][:max].nil? &&
791
+ component_value.length > marker_spec[:width][:max]
792
+ component_value = component_value[0, marker_spec[:width][:max]]
793
+ end
794
+ else
795
+ component_value = _format_integer(component_value, marker_spec[:integer_format])
796
+ end
797
+ elsif marker_spec[:component] == 'f'
798
+ # TODO Section 9.8.4.5 Formatting Fractional Seconds
799
+ component_value = _format_integer(component_value, marker_spec[:integer_format])
800
+ elsif marker_spec[:component] == 'Z' || marker_spec[:component] == 'z'
801
+ # Section 9.8.4.6 Formatting timezones
802
+ offset = (offset_hours * 100) + offset_minutes
803
+ if marker_spec[:integer_format][:regular]
804
+ component_value = _format_integer(offset, marker_spec[:integer_format])
805
+ else
806
+ num_digits = marker_spec[:integer_format][:mandatory_digits]
807
+ if [1, 2].include?(num_digits)
808
+ component_value = _format_integer(offset_hours, marker_spec[:integer_format])
809
+ component_value += ":#{format_integer(offset_minutes, '00')}" if offset_minutes != 0
810
+ elsif [3, 4].include?(num_digits)
811
+ component_value = _format_integer(offset, marker_spec[:integer_format])
812
+ else
813
+ raise Jsonata::Error.new('D3134', :value => num_digits)
814
+ end
815
+ end
816
+ component_value = "+#{component_value}" if offset >= 0
817
+ component_value = "GMT#{component_value}" if marker_spec[:component] == 'z'
818
+ component_value = 'Z' if offset.zero? && marker_spec[:presentation2] == 't'
819
+ elsif marker_spec[:component] == 'P'
820
+ # Section 9.8.4.7 Formatting Other Components
821
+ # Formatting P for am/pm
822
+ # getDateTimeFragment() always returns am/pm lower case so check for UPPER here
823
+ component_value = component_value.upcase if marker_spec[:names] == TCASE[:UPPER]
824
+ end
825
+ component_value
826
+ end
827
+
828
+ format_spec = if picture.nil?
829
+ # default to ISO 8601 format
830
+ analyse_date_time_picture(ISO8601_PICTURE)
831
+ else
832
+ analyse_date_time_picture(picture)
833
+ end
834
+
835
+ offset_millis = ((60 * offset_hours) + offset_minutes) * 60 * 1000
836
+ date_time = DateTime.new_date(millis + offset_millis)
837
+
838
+ result = +''
839
+ format_spec[:parts].each do |part|
840
+ result << if part[:type] == 'literal'
841
+ part[:value]
842
+ else
843
+ format_component.call(date_time, part).to_s
844
+ end
845
+ end
846
+
847
+ result
848
+ end
849
+
850
+ # Generate a regex to parse integers or timestamps
851
+ # @param format_spec - object representing the format
852
+ # @returns regex
853
+ def generate_regex(format_spec)
854
+ matcher = {}
855
+ if format_spec[:type] == 'datetime'
856
+ matcher[:type] = 'datetime'
857
+ matcher[:parts] = format_spec[:parts].map do |part|
858
+ res = {}
859
+ if part[:type] == 'literal'
860
+ res[:regex] = part[:value].gsub(/[.*+?^${}()|\[\]\\]/) { |m| "\\#{m}" }
861
+ elsif part[:component] == 'Z' || part[:component] == 'z'
862
+ # timezone
863
+ separator = nil
864
+ separator = part[:integer_format][:grouping_separators] unless part[:integer_format][:grouping_separators].kind_of?(Array)
865
+ res[:regex] = +''
866
+ res[:regex] << 'GMT' if part[:component] == 'z'
867
+ res[:regex] << '[-+][0-9]+'
868
+ res[:regex] << "#{separator[:character]}[0-9]+" if separator
869
+ res[:parse] = lambda do |value|
870
+ value = value[3..] if part[:component] == 'z' # remove the leading GMT
871
+ offset_hours = 0
872
+ offset_minutes = 0
873
+ if separator
874
+ offset_hours = Utils.parse_int(value[0, value.index(separator[:character])])
875
+ offset_minutes = Utils.parse_int(value[(value.index(separator[:character]) + 1)..])
876
+ else
877
+ # depends on number of digits
878
+ numdigits = value.length - 1
879
+ if numdigits <= 2
880
+ # just hour offset
881
+ offset_hours = Utils.parse_int(value)
882
+ else
883
+ offset_hours = Utils.parse_int(value[0, 3])
884
+ offset_minutes = Utils.parse_int(value[3..])
885
+ end
886
+ end
887
+ (offset_hours * 60) + offset_minutes
888
+ end
889
+ elsif part[:component] == 'f'
890
+ res[:regex] = '[0-9]+'
891
+ res[:parse] = lambda do |value|
892
+ Float("0.#{value[0, 3]}") * 1000
893
+ end
894
+ elsif part[:integer_format]
895
+ res = generate_regex(part[:integer_format])
896
+ else
897
+ # must be a month or day name
898
+ res[:regex] = '[a-zA-Z]+'
899
+ lookup = {}
900
+ if part[:component] == 'M' || part[:component] == 'x'
901
+ # months
902
+ MONTHS.each_with_index do |name, index|
903
+ if part[:width] && part[:width][:max]
904
+ lookup[name[0, part[:width][:max]]] = index + 1
905
+ else
906
+ lookup[name] = index + 1
907
+ end
908
+ end
909
+ elsif part[:component] == 'F'
910
+ # days
911
+ DAYS.each_with_index do |name, index|
912
+ next unless index.positive?
913
+
914
+ if part[:width] && part[:width][:max]
915
+ lookup[name[0, part[:width][:max]]] = index
916
+ else
917
+ lookup[name] = index
918
+ end
919
+ end
920
+ elsif part[:component] == 'P'
921
+ lookup = {'am' => 0, 'AM' => 0, 'pm' => 1, 'PM' => 1}
922
+ else
923
+ # unsupported 'name' option for this component
924
+ raise Jsonata::Error.new('D3133', :value => part[:component])
925
+ end
926
+ res[:parse] = ->(value) { lookup[value] }
927
+ end
928
+ res[:component] = part[:component]
929
+ res
930
+ end
931
+ else # type === 'integer'
932
+ matcher[:type] = 'integer'
933
+ is_upper = format_spec[:case] == TCASE[:UPPER]
934
+
935
+ case format_spec[:primary]
936
+ when FORMATS[:LETTERS]
937
+ matcher[:regex] = is_upper ? '[A-Z]+' : '[a-z]+'
938
+ matcher[:parse] = lambda do |value|
939
+ letters_to_decimal(value, is_upper ? 'A' : 'a')
940
+ end
941
+ when FORMATS[:ROMAN]
942
+ matcher[:regex] = is_upper ? '[MDCLXVI]+' : '[mdclxvi]+'
943
+ matcher[:parse] = lambda do |value|
944
+ roman_to_decimal(is_upper ? value : value.upcase)
945
+ end
946
+ when FORMATS[:WORDS]
947
+ matcher[:regex] = "(?:#{(WORD_VALUES.keys + ['and', '[\\-, ]']).join('|')})+"
948
+ matcher[:parse] = lambda do |value|
949
+ words_to_number(value.downcase)
950
+ end
951
+ when FORMATS[:DECIMAL]
952
+ matcher[:regex] = +'[0-9]'
953
+ matcher[:regex] << if format_spec[:parse_width]
954
+ "{#{format_spec[:parse_width]}}"
955
+ else
956
+ '+'
957
+ end
958
+ if format_spec[:ordinal]
959
+ # ordinals
960
+ matcher[:regex] << '(?:th|st|nd|rd)'
961
+ end
962
+ matcher[:parse] = lambda do |value|
963
+ digits = value
964
+ if format_spec[:ordinal]
965
+ # strip off the suffix
966
+ digits = value[0...-2]
967
+ end
968
+ # strip out the separators
969
+ if format_spec[:regular]
970
+ digits = digits.split(',', -1).join
971
+ else
972
+ format_spec[:grouping_separators].each do |sep|
973
+ digits = digits.split(sep[:character], -1).join
974
+ end
975
+ end
976
+ if format_spec[:zero_code] != 0x30
977
+ # apply offset
978
+ digits = digits.each_char.map { |char| (char.ord - format_spec[:zero_code] + 0x30).chr(Encoding::UTF_8) }.join
979
+ end
980
+ Utils.parse_int(digits)
981
+ end
982
+ when FORMATS[:SEQUENCE]
983
+ raise Jsonata::Error.new('D3130', :value => format_spec[:token])
984
+ end
985
+
986
+ end
987
+ matcher
988
+ end
989
+
990
+ # parse a string containing an integer as specified by the picture string
991
+ # @param value - the string to parse
992
+ # @param picture - the picture string
993
+ # @returns the parsed number
994
+ def parse_integer(value, picture)
995
+ return nil if value.nil?
996
+
997
+ format_spec = analyse_integer_picture(picture)
998
+ match_spec = generate_regex(format_spec)
999
+ # const fullRegex = '^' + matchSpec.regex + '$';
1000
+ # const matcher = new RegExp(fullRegex);
1001
+ # TODO validate input based on the matcher regex
1002
+ match_spec[:parse].call(value)
1003
+ end
1004
+
1005
+ # parse a string containing a timestamp as specified by the picture string
1006
+ # @param timestamp - the string to parse
1007
+ # @param picture - the picture string
1008
+ # @returns the parsed timestamp in millis since the epoch
1009
+ def parse_date_time(timestamp, picture)
1010
+ format_spec = analyse_date_time_picture(picture)
1011
+ match_spec = generate_regex(format_spec)
1012
+ full_regex = "\\A#{match_spec[:parts].map { |part| "(#{part[:regex]})" }.join}\\z"
1013
+
1014
+ matcher = Regexp.new(full_regex, Regexp::IGNORECASE) # TODO can cache this against the picture
1015
+ info = matcher.match(timestamp)
1016
+ return nil if info.nil?
1017
+
1018
+ # validate what we've just parsed - do we have enough information to create a timestamp?
1019
+ # rules:
1020
+ # The date is specified by one of:
1021
+ # {Y, M, D} (dateA)
1022
+ # or {Y, d} (dateB)
1023
+ # or {Y, x, w, F} (dateC)
1024
+ # or {X, W, F} (dateD)
1025
+ # The time is specified by one of:
1026
+ # {H, m, s, f} (timeA)
1027
+ # or {P, h, m, s, f} (timeB)
1028
+ # All sets can have an optional Z
1029
+ # To create a timestamp (epoch millis) we need both date and time, but we can default missing
1030
+ # information according to the following rules:
1031
+ # - line up one combination of the above from date, and one from time, most significant value (MSV) to least significant (LSV
1032
+ # - for the values that have been captured, if there are any gaps between MSV and LSV, then throw an error
1033
+ # (e.g.) if hour and seconds, but not minutes is given - throw
1034
+ # (e.g.) if month, hour and minutes, but not day-of-month is given - throw
1035
+ # - anything right of the LSV should be defaulted to zero
1036
+ # (e.g.) if hour and minutes given, default seconds and fractional seconds to zero
1037
+ # (e.g.) if date only given, default the time to 0:00:00.000 (midnight)
1038
+ # - anything left of the MSV should be defaulted to the value of that component returned by $now()
1039
+ # (e.g.) if time only given, default the date to today
1040
+ # (e.g.) if month and date given, default to this year (and midnight, by previous rule)
1041
+ # -- default values for X, x, W, w, F will be derived from the values returned by $now()
1042
+
1043
+ # implement the above rules
1044
+ # determine which of the above date/time combinations we have by using bit masks
1045
+
1046
+ # Y X M x W w d D F P H h m s f Z
1047
+ # dateA 1 0 1 0 0 0 0 1 ? 0 - must not appear
1048
+ # dateB 1 0 0 0 0 0 1 0 ? 1 - can appear - relevant
1049
+ # dateC 0 1 0 1 0 1 0 0 1 ? - can appear - ignored
1050
+ # dateD 0 1 0 0 1 0 0 0 1
1051
+ # timeA 0 1 0 1 1 1
1052
+ # timeB 1 0 1 1 1 1
1053
+
1054
+ # create bitmasks based on the above
1055
+ # date mask YXMxWwdD
1056
+ dm_a = 161 # binary 10100001
1057
+ dm_b = 130 # binary 10000010
1058
+ dm_c = 84 # binary 01010100
1059
+ dm_d = 72 # binary 01001000
1060
+ # time mask PHhmsf
1061
+ tm_a = 23 # binary 010111
1062
+ tm_b = 47 # binary 101111
1063
+
1064
+ components = {}
1065
+ (1...info.size).each do |i|
1066
+ mpart = match_spec[:parts][i - 1]
1067
+ components[mpart[:component]] = mpart[:parse].call(info[i]) if mpart[:parse]
1068
+ end
1069
+
1070
+ # nothing specified
1071
+ return nil if components.empty?
1072
+
1073
+ mask = 0
1074
+
1075
+ shift = lambda do |bit|
1076
+ mask <<= 1
1077
+ mask += Utils.truthy?(bit) ? 1 : 0
1078
+ end
1079
+
1080
+ is_type = lambda do |type|
1081
+ # shouldn't match any 0's, must match at least one 1
1082
+ type.allbits?(mask) && type.anybits?(mask)
1083
+ end
1084
+
1085
+ 'YXMxWwdD'.each_char { |part| shift.call(components[part]) }
1086
+
1087
+ date_a = is_type.call(dm_a)
1088
+ date_b = !date_a && is_type.call(dm_b)
1089
+ date_c = is_type.call(dm_c)
1090
+ date_d = !date_c && is_type.call(dm_d)
1091
+
1092
+ mask = 0
1093
+ 'PHhmsf'.each_char { |part| shift.call(components[part]) }
1094
+
1095
+ time_a = is_type.call(tm_a)
1096
+ time_b = !time_a && is_type.call(tm_b)
1097
+
1098
+ # should only be zero or one date type and zero or one time type
1099
+
1100
+ date_comps = if date_b
1101
+ 'YD'
1102
+ elsif date_c
1103
+ 'XxwF'
1104
+ elsif date_d
1105
+ 'XWF'
1106
+ else
1107
+ 'YMD'
1108
+ end
1109
+ time_comps = time_b ? 'Phmsf' : 'Hmsf'
1110
+
1111
+ comps = date_comps + time_comps
1112
+
1113
+ # step through the candidate parts from most significant to least significant
1114
+ # default the most significant unspecified parts to current timestamp component
1115
+ # default the least significant unspecified parts to zero
1116
+ # if any gaps in between the specified parts, throw an error
1117
+
1118
+ now = environment.timestamp # must get the fixed timestamp from jsonata
1119
+
1120
+ start_specified = false
1121
+ end_specified = false
1122
+ comps.each_char do |part|
1123
+ if components[part].nil?
1124
+ if start_specified
1125
+ # past the specified block - default to zero
1126
+ components[part] = 'MDd'.include?(part) ? 1 : 0
1127
+ end_specified = true
1128
+ else
1129
+ # haven't hit the specified block yet, default to current timestamp
1130
+ components[part] = get_date_time_fragment(now, part)
1131
+ end
1132
+ else
1133
+ start_specified = true
1134
+ raise Jsonata::Error.new('D3136') if end_specified
1135
+ end
1136
+ end
1137
+
1138
+ # validate and fill in components
1139
+ if !components['M'].nil? && components['M'].positive?
1140
+ components['M'] -= 1 # Date.UTC requires a zero-indexed month
1141
+ else
1142
+ components['M'] = 0 # default to January
1143
+ end
1144
+ if date_b
1145
+ # millis for given 1st Jan of that year (at 00:00 UTC)
1146
+ first_jan = DateTime.date_utc(components['Y'], 0)
1147
+ offset_millis = (components['d'] - 1) * 1000 * 60 * 60 * 24
1148
+ derived_date = DateTime.new_date(first_jan + offset_millis)
1149
+ components['M'] = derived_date.month - 1
1150
+ components['D'] = derived_date.day
1151
+ end
1152
+ if date_c
1153
+ # TODO implement this
1154
+ # parsing this format not currently supported
1155
+ raise Jsonata::Error.new('D3136')
1156
+ end
1157
+ if date_d
1158
+ # TODO implement this
1159
+ # parsing this format (ISO week date) not currently supported
1160
+ raise Jsonata::Error.new('D3136')
1161
+ end
1162
+
1163
+ if time_b
1164
+ # 12hr to 24hr
1165
+ components['H'] = components['h'] == 12 ? 0 : components['h']
1166
+ components['H'] += 12 if components['P'] == 1
1167
+ end
1168
+
1169
+ millis = DateTime.date_utc(components['Y'], components['M'], components['D'], components['H'], components['m'],
1170
+ components['s'], components['f'])
1171
+ if Utils.truthy?(components['Z']) || Utils.truthy?(components['z'])
1172
+ # adjust for timezone
1173
+ millis -= (Utils.truthy?(components['Z']) ? components['Z'] : components['z']) * 60 * 1000
1174
+ end
1175
+ millis
1176
+ end
1177
+
1178
+ # Regular expression to match an ISO 8601 formatted timestamp
1179
+ ISO8601_REGEX = /\A\d{4}(-[01]\d)?(-[0-3]\d)?(T[0-2]\d:[0-5]\d:[0-5]\d)?(\.\d+)?([+-][0-2]\d:?[0-5]\d|Z)?\z/
1180
+
1181
+ # Equivalent of Date.parse for the ISO 8601 subset accepted by ISO8601_REGEX
1182
+ def self.date_parse(timestamp)
1183
+ m = /\A(\d{4})(?:-(\d\d))?(?:-(\d\d))?(?:T(\d\d):(\d\d):(\d\d))?(?:\.(\d+))?(Z|([+-])(\d\d):?(\d\d))?\z/.match(timestamp)
1184
+ return Float::NAN if m.nil?
1185
+
1186
+ year = m[1].to_i
1187
+ month = (m[2] || '01').to_i
1188
+ day = (m[3] || '01').to_i
1189
+ hours = m[4].to_i
1190
+ minutes = m[5].to_i
1191
+ seconds = m[6].to_i
1192
+ millis = m[7] ? m[7][0, 3].ljust(3, '0').to_i : 0
1193
+ if month < 1 || month > 12 || day < 1 || day > 31 || hours > 24 || (hours == 24 && (minutes + seconds + millis).positive?)
1194
+ return Float::NAN
1195
+ end
1196
+
1197
+ # time values are relative to UTC unless an offset is specified
1198
+ result = (days_from_civil(year, month, day) * MILLIS_IN_A_DAY) + (hours * 3_600_000) + (minutes * 60_000) +
1199
+ (seconds * 1000) + millis
1200
+ if m[9]
1201
+ offset = (m[10].to_i * 60) + m[11].to_i
1202
+ result -= (m[9] == '-' ? -offset : offset) * 60_000
1203
+ end
1204
+ result
1205
+ end
1206
+
1207
+ # Converts an ISO 8601 timestamp to milliseconds since the epoch
1208
+ #
1209
+ # @param timestamp - the timestamp to be converted
1210
+ # @param picture - the picture string defining the format of the timestamp (defaults to ISO 8601)
1211
+ # @returns milliseconds since the epoch
1212
+ def to_millis(timestamp, picture)
1213
+ # undefined inputs always return undefined
1214
+ return nil if timestamp.nil?
1215
+
1216
+ if picture.nil?
1217
+ raise Jsonata::Error.new('D3110', :value => timestamp) unless ISO8601_REGEX.match?(timestamp)
1218
+
1219
+ DateTime.date_parse(timestamp)
1220
+ else
1221
+ parse_date_time(timestamp, picture)
1222
+ end
1223
+ end
1224
+
1225
+ # Converts milliseconds since the epoch to an ISO 8601 timestamp
1226
+ # @param millis - milliseconds since the epoch to be converted
1227
+ # @param picture - the picture string defining the format of the timestamp (defaults to ISO 8601)
1228
+ # @param timezone - the timezone to format the timestamp in (defaults to UTC)
1229
+ # @returns the formatted timestamp
1230
+ def from_millis(millis, picture, timezone)
1231
+ # undefined inputs always return undefined
1232
+ return nil if millis.nil?
1233
+
1234
+ format_date_time(millis, picture, timezone)
1235
+ end
1236
+ end
1237
+ end