soml-lang 0.0.2 → 0.0.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 451e02a56833294ea93a0ebb269889d8c20c377329572e6a27090a2d2a6a621b
4
- data.tar.gz: 2956309298dbd2934603257d28903ee913b30f4a34a45f11261a09f6b96487a8
3
+ metadata.gz: 808fb7da3c2ca5866146bef2193cbf96c4d4e91077c65ada841afd077b1e5282
4
+ data.tar.gz: 03113f26b03a51156a4c7b30ac2dbde6fda0e908df06f5b11c349c69ef2619d7
5
5
  SHA512:
6
- metadata.gz: 43bba0ce5a930333bcd33bb064a989e8a9cbb9b71bf409358778087c646f1a324306a264d5d8c575c76511ec2a56ef844c1b64198c8e1237ae12adc7b77adcd4
7
- data.tar.gz: 57e43736b8cace1e4f049e34186f1cb4a47dc125ca854956fc93dc66ee732a5c465def2935dd9c96e0693b35d229ae5d1e2ae5668e9ba9edc2669c596055e7af
6
+ metadata.gz: 9ede6de7ba3eeea51fa44fe111832a090018d5f5ab589232702b4c0d8cff010f04da9e1a91c52a8a726d77afe2e9b37c14b070631a70bdbece238cc73ece047b
7
+ data.tar.gz: 2ba19a6d60471afa8d03dc54c0277bf628ee7cd0ba57620a9c5ea0c353805f2843160fa7dc8c1356215cbe12fbfede87b401c2cda355cf90c1c851073d0bd2b4
data/lib/soml/duration.rb CHANGED
@@ -31,12 +31,12 @@ module SOML
31
31
  # @raise [TypeError] When `text` is not a string.
32
32
  def self.parse(text) = Parser.new(text).parse_duration_literal
33
33
 
34
- # Make a duration from a number of seconds, the inverse of {#to_r}. The seconds are rounded to the nearest nanosecond, because a `Float` such as `0.1` is not exact.
34
+ # Make a duration from a number of seconds, the inverse of {#to_r}. The seconds are rounded to the nearest nanosecond, with ties to even, because a `Float` such as `0.1` is not exact.
35
35
  #
36
36
  # @param seconds [Numeric] An `Integer`, a `Float`, a `Rational`, or another real number.
37
37
  # @return [Duration]
38
38
  # @raise [TypeError] When `seconds` is not a number.
39
- # @raise [RangeError] When `seconds` is not finite, not real, or outside the 64-bit range of nanoseconds.
39
+ # @raise [RangeError] When `seconds` is not finite or not real, or the rounded nanosecond count is outside the 64-bit range.
40
40
  #
41
41
  # @example
42
42
  # SOML::Duration.from_seconds(90)
@@ -48,7 +48,7 @@ module SOML
48
48
  # `Numeric ===` checks the class itself, so an `ActiveSupport::Duration`, which only claims to be a `Numeric`, must be converted with `to_r` first. Its months are an average length.
49
49
  raise TypeError, "A duration is made from a number of seconds, got #{Parser.describe_argument(seconds)}" unless Numeric === seconds
50
50
 
51
- new(nanoseconds: (seconds.to_r * 1_000_000_000).round)
51
+ new(nanoseconds: (seconds.to_r * 1_000_000_000).round(half: :even))
52
52
  end
53
53
 
54
54
  # @param nanoseconds [Integer] The length in nanoseconds.
data/lib/soml/format.rb CHANGED
@@ -321,6 +321,8 @@ module SOML
321
321
  # Only spaces and tabs are whitespace in SOML. Any other character, such as a no-break space, is content.
322
322
  def trim_trailing_whitespace(text)
323
323
  # A loop rather than a regex, which would keep a backtracking entry for every space of a long run.
324
+ #
325
+ # Note: Not using `rstrip(" \t")` because it is slower for typical short lines.
324
326
  finish = text.bytesize
325
327
  finish -= 1 while finish.positive? && ((byte = text.getbyte(finish - 1)) == 0x20 || byte == 0x09)
326
328
  text.byteslice(0, finish)
@@ -330,6 +332,7 @@ module SOML
330
332
  def reindent_block_string(text, indentation)
331
333
  lines = text.split("\n", -1)
332
334
  closing = lines.last
335
+ # Note: Not using `lstrip(" \t")` because it allocates a string and is slower than this scan.
333
336
  old_length = 0
334
337
  old_length += 1 while (byte = closing.getbyte(old_length)) == 0x20 || byte == 0x09
335
338
 
@@ -6,6 +6,8 @@ module SOML
6
6
  # @api private
7
7
  class Generator
8
8
  # A byte that cannot be in a `'...'` string: a control character, an apostrophe, or DEL. A string with one is written as `"..."`. The set is a byte table, because a scan of it is faster than the regex it replaces.
9
+ #
10
+ # Note: Not using `String#count` because it is slower for typical short strings.
9
11
  NEEDS_ESCAPED_BYTE = Array.new(256) { |byte| byte < 0x20 || byte == 0x27 || byte == 0x7F }.freeze
10
12
 
11
13
  ESCAPED_CHARACTER = /[\x00-\x1F"\\\x7F]/
@@ -30,6 +32,8 @@ module SOML
30
32
 
31
33
  # The fraction of a second of an instant or a duration as a document writes it: a point and nine digits with the trailing zeros removed, or nothing when there is none.
32
34
  #
35
+ # Note: Not using `sprintf` because its padding is slower than `to_s.rjust`.
36
+ #
33
37
  # @param nanoseconds [Integer] The fraction, below one second.
34
38
  # @return [String]
35
39
  def self.format_fraction(nanoseconds) = nanoseconds.zero? ? '' : ".#{nanoseconds.to_s.rjust(9, '0').rstrip('0')}"
@@ -123,6 +127,11 @@ module SOML
123
127
  write_string(value.name)
124
128
  when Time
125
129
  @buffer << format_instant(value)
130
+ # `DateTime` is a subclass of `Date`, so it must come first.
131
+ when DateTime
132
+ raise GenerateError, 'Cannot generate a DateTime. Convert it to a Time with to_time'
133
+ when Date
134
+ @buffer << format_date(value)
126
135
  when Duration
127
136
  @buffer << value.to_s
128
137
  else
@@ -157,6 +166,7 @@ module SOML
157
166
  @buffer << '[' << @line_break
158
167
  index = 0
159
168
 
169
+ # Note: Not using `each_index` because its block overhead slows this hot loop under YJIT.
160
170
  while index < array.length
161
171
  @buffer << item_prefix(index, inner_indent)
162
172
 
@@ -210,6 +220,7 @@ module SOML
210
220
 
211
221
  # Yields each member with its key as a UTF-8 string, in the order of the hash, or sorted by key for canonical form.
212
222
  def each_member(hash)
223
+ # Note: Not using a `hash.each_key` enumerator because it is slower than iterating the key array.
213
224
  keys = hash.keys
214
225
  sort_keys!(keys) if @canonical
215
226
  names = {}
@@ -259,6 +270,7 @@ module SOML
259
270
  end
260
271
 
261
272
  if index == length
273
+ # Note: Not using `append_as_bytes` because it is slower for typical short strings in the full generator.
262
274
  @buffer << "'" << string << "'"
263
275
  return
264
276
  end
@@ -322,6 +334,16 @@ module SOML
322
334
  value.negative? ? "-#{body}" : body
323
335
  end
324
336
 
337
+ # Use the native components, even for a subclass that overrides them, and without converting through an instant or another calendar.
338
+ def format_date(date)
339
+ text = Date.instance_method(:iso8601).bind_call(date)
340
+ year = Date.instance_method(:year).bind_call(date)
341
+ raise GenerateError, "Cannot generate the date #{text}, because it is outside the years 0001 to 9999" unless year.between?(1, 9999)
342
+ raise GenerateError, "Cannot generate the Julian date #{text}. Convert it to a Gregorian Date with new_start(Date::GREGORIAN), which must be in the years 0001 to 9999" unless Date.instance_method(:gregorian?).bind_call(date)
343
+
344
+ text
345
+ end
346
+
325
347
  def format_instant(time)
326
348
  # `getutc` makes a copy, which a time in UTC, as `SOML.parse` returns it, does not need.
327
349
  utc = time.utc? ? time : time.getutc
@@ -332,6 +354,8 @@ module SOML
332
354
  raise GenerateError, "Cannot generate the instant #{Time.instance_method(:inspect).bind_call(time)}, because in UTC it falls outside the years 0001 to 9999" unless utc.year.between?(1, 9999)
333
355
 
334
356
  # The year is 0001 to 9999, so `xmlschema` writes four digits, and a time in UTC ends with `Z`.
357
+ #
358
+ # Note: Not using `strftime` because Ruby 4's core `xmlschema` is faster.
335
359
  text = utc.xmlschema
336
360
  nanoseconds.zero? ? text : "#{text.chop}#{Generator.format_fraction(nanoseconds)}Z"
337
361
  end
@@ -351,11 +375,7 @@ module SOML
351
375
  end
352
376
 
353
377
  def describe_unsupported(value)
354
- if defined?(::DateTime) && ::DateTime === value
355
- 'Cannot generate a DateTime. Convert it to a Time with to_time'
356
- elsif defined?(::Date) && ::Date === value
357
- "Cannot generate a Date, because a date is not an instant. Write it as a String, such as '#{value}'"
358
- elsif defined?(::ActiveSupport::Duration) && ::ActiveSupport::Duration === value
378
+ if defined?(::ActiveSupport::Duration) && ::ActiveSupport::Duration === value
359
379
  # Rails code writes `5.minutes`. Its days, months, and years follow the calendar, so it is not converted.
360
380
  "Cannot generate an ActiveSupport::Duration, because its days, months, and years are not a fixed length. Use a SOML::Duration, such as SOML::Duration.parse('5m')"
361
381
  elsif Numeric === value
@@ -367,7 +387,7 @@ module SOML
367
387
  elsif Pathname === value
368
388
  "Cannot generate #{describe(value)}. Convert it to a String with to_s"
369
389
  else
370
- "Cannot generate #{describe(value)}. Only Hash, Array, String, Symbol, Integer, Float, true, false, nil, Time, and SOML::Duration can be generated"
390
+ "Cannot generate #{describe(value)}. Only Hash, Array, String, Symbol, Integer, Float, true, false, nil, Date, Time, and SOML::Duration can be generated"
371
391
  end
372
392
  end
373
393
  end
data/lib/soml/parser.rb CHANGED
@@ -43,7 +43,7 @@ module SOML
43
43
 
44
44
  DURATION_UNIT_NAMES = DURATION_UNITS.keys.freeze
45
45
 
46
- INSTANT_PREFIX = /\A[0-9]{4}-[0-9]{2}-[0-9]{2}/
46
+ DATE_PREFIX = /\A[0-9]{4}-[0-9]{2}-[0-9]{2}/
47
47
  INSTANT = /\A[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})\z/
48
48
  LOCAL_DATE_TIME = /\A[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?\z/
49
49
  INSTANT_FORMAT = 'An instant is written as 2026-09-19T14:00:00Z, with an optional fraction of up to nine digits and an offset of Z or ±HH:MM'
@@ -175,6 +175,8 @@ module SOML
175
175
  end
176
176
 
177
177
  # The length of `text` in UTF-16 code units, as JS counts it. A character outside the BMP is two code units, and its UTF-8 lead byte is 0xF0 to 0xF4. `String#count` with that range of characters instead would build a table of a million entries.
178
+ #
179
+ # Note: Not using UTF-16 encoding because it copies the text and is slower for short messages.
178
180
  def self.utf16_length(text) = text.length + text.b.count("\xF0-\xF4".b)
179
181
 
180
182
  # The string as `JSON.stringify` writes it, as the JS reference shows a quoted key, so that a control character, such as a line feed, shows as its escape.
@@ -727,9 +729,9 @@ module SOML
727
729
  when 0x69 # i
728
730
  keyword?('infinity') ? Float::INFINITY : fail_unexpected_value
729
731
  when 0x2D # -
730
- @source.getbyte(@index + 1) == 0x69 && keyword?('-infinity') ? -Float::INFINITY : (number = parse_number_or_instant)
732
+ @source.getbyte(@index + 1) == 0x69 && keyword?('-infinity') ? -Float::INFINITY : (number = parse_number_or_temporal)
731
733
  when 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39
732
- number = parse_number_or_instant
734
+ number = parse_number_or_temporal
733
735
  else
734
736
  fail_unexpected_value
735
737
  end
@@ -1077,7 +1079,7 @@ module SOML
1077
1079
  value << text.byteslice(chunk_start, text.bytesize - chunk_start)
1078
1080
  end
1079
1081
 
1080
- def parse_number_or_instant
1082
+ def parse_number_or_temporal
1081
1083
  plain = parse_plain_number
1082
1084
  return plain unless plain.nil?
1083
1085
 
@@ -1086,7 +1088,12 @@ module SOML
1086
1088
  text = @source.byteslice(start, finish - start)
1087
1089
  @index = finish
1088
1090
 
1089
- return parse_instant(text, start) if text.match?(INSTANT_PREFIX)
1091
+ if text.match?(DATE_PREFIX)
1092
+ # A time after spaces or tabs still gets the instant separator diagnostic.
1093
+ return parse_date(text, start) if text.bytesize == 10 && !space_separated_time?
1094
+
1095
+ return parse_instant(text, start)
1096
+ end
1090
1097
 
1091
1098
  kind = classify_number(text)
1092
1099
  return parse_duration(text, start) if kind.nil? && duration_like?(text)
@@ -1316,6 +1323,9 @@ module SOML
1316
1323
  end
1317
1324
 
1318
1325
  # The float nearest to `significand × 10^exponent`, rounding a tie to even, as IEEE 754 does. It uses exact integer arithmetic rather than `String#to_f`, which warns when a value overflows or underflows.
1326
+ #
1327
+ # Note: Not using `Float()` because it warns at the limits.
1328
+ # Note: Not using `Rational#to_f` because it can round twice (Ruby bug #22246).
1319
1329
  def decimal_to_float(significand, exponent)
1320
1330
  numerator = exponent.negative? ? significand : significand * (10**exponent)
1321
1331
  denominator = exponent.negative? ? 10**-exponent : 1
@@ -1338,11 +1348,28 @@ module SOML
1338
1348
  Math.ldexp(quotient, shift)
1339
1349
  end
1340
1350
 
1351
+ # Whether a time follows after spaces or tabs, as in `2026-11-01 12:00`, which an instant's error describes better than a date followed by another entry.
1352
+ def space_separated_time?
1353
+ time_start = skip_spaces(@index)
1354
+ time_start > @index && @source.byteslice(time_start, 5).b.match?(/\A[0-9]{1,2}:[0-9]{2}/)
1355
+ end
1356
+
1357
+ # A calendar date alone, validated as a whole ten-byte token without a time or timezone conversion.
1358
+ def parse_date(text, start)
1359
+ year = digits_value(text, 0, 4)
1360
+ month = digits_value(text, 5, 2)
1361
+ day = digits_value(text, 8, 2)
1362
+ reason = calendar_error(year, month, day)
1363
+ fail_at("Invalid date #{text}: #{reason}", start) if reason
1364
+
1365
+ Date.new(year, month, day, Date::GREGORIAN)
1366
+ end
1367
+
1341
1368
  def parse_instant(text, start)
1342
1369
  unless text.bytesize <= MAX_DIAGNOSED_LENGTH && text.match?(INSTANT)
1343
- # A date, a space, and a time, as TOML and Python write a date and time.
1344
- time_start = @index + 1
1345
- finish = @source.getbyte(@index) == 0x20 && digit?(@source.getbyte(time_start)) ? number_end(time_start) : @index
1370
+ # A date, spaces or tabs, and a time, as TOML and Python write a date and time.
1371
+ time_start = skip_spaces(@index)
1372
+ finish = time_start > @index && digit?(@source.getbyte(time_start)) ? number_end(time_start) : @index
1346
1373
  time = finish > time_start ? @source.byteslice(time_start, finish - time_start) : ''
1347
1374
  # More of the instant may follow, as in `14:00:00,5Z` or `14:00:00[Europe/Oslo]`, so it may have an offset.
1348
1375
  is_whole_value = value_end?(finish) && !(@source.getbyte(finish) == 0x2C && digit?(@source.getbyte(finish + 1)))
@@ -1363,10 +1390,8 @@ module SOML
1363
1390
  fraction_length = offset_start == 19 ? 0 : offset_start - 20
1364
1391
 
1365
1392
  fail_instant(text, start, 'a fractional second has at most nine digits') if fraction_length > 9
1366
- fail_instant(text, start, 'the year must be 0001 to 9999') if year.zero?
1367
- fail_instant(text, start, 'the month must be 01 to 12') unless month.between?(1, 12)
1368
- last_day = days_in_month(year, month)
1369
- fail_instant(text, start, "the day must be 01 to #{last_day} in that month") unless day.between?(1, last_day)
1393
+ reason = calendar_error(year, month, day)
1394
+ fail_instant(text, start, reason) if reason
1370
1395
  fail_instant(text, start, 'the hour must be 00 to 23') if hour > 23
1371
1396
  fail_instant(text, start, 'the minute must be 00 to 59') if minute > 59
1372
1397
  fail_instant(text, start, 'the second must be 00 to 59, and a leap second is not representable') if second > 59
@@ -1390,6 +1415,8 @@ module SOML
1390
1415
  end
1391
1416
 
1392
1417
  # The value of the `count` decimal digits at `offset` in `text`, which the caller checked are digits.
1418
+ #
1419
+ # Note: Not using a slice and `to_i` because they are slower for these small fields.
1393
1420
  def digits_value(text, offset, count)
1394
1421
  value = 0
1395
1422
  finish = offset + count
@@ -1464,6 +1491,7 @@ module SOML
1464
1491
  unless fraction.empty?
1465
1492
  scale = 10**fraction.bytesize
1466
1493
  fraction_nanoseconds = fraction.to_i * size
1494
+ # Note: Not using `divmod` because it allocates an array and is slower for ordinary short fractions.
1467
1495
  fail_duration(text, start, 'it is not a whole number of nanoseconds') unless (fraction_nanoseconds % scale).zero?
1468
1496
  total += fraction_nanoseconds / scale
1469
1497
  end
@@ -1507,7 +1535,7 @@ module SOML
1507
1535
  skip_spaces(start) == offset ? offset - start : nil
1508
1536
  end
1509
1537
 
1510
- # The end of the number, instant, or duration that starts at `start`: the end of the run of the bytes that one can hold.
1538
+ # The end of the number, date, instant, or duration that starts at `start`: the end of the run of the bytes that one can hold.
1511
1539
  def number_end(start)
1512
1540
  finish = start
1513
1541
 
@@ -1565,10 +1593,21 @@ module SOML
1565
1593
 
1566
1594
  def digit_or_underscore?(byte) = byte == 0x5F || digit?(byte)
1567
1595
 
1568
- def words_with_spaces?(text) = text.each_byte.all? { space?(it) || BARE_KEY_BYTE[it] }
1596
+ # Note: Not using `String#count` because it scans the whole input even when its first character is invalid.
1597
+ def words_with_spaces?(text) = !text.match?(/[^A-Za-z0-9_ \t-]/)
1598
+
1599
+ # Shared Gregorian validation for dates and the calendar portion of instants.
1600
+ def calendar_error(year, month, day)
1601
+ return 'the year must be 0001 to 9999' if year.zero?
1602
+ return 'the month must be 01 to 12' unless month.between?(1, 12)
1603
+
1604
+ last_day = days_in_month(year, month)
1605
+ "the day must be 01 to #{last_day} in that month" unless day.between?(1, last_day)
1606
+ end
1569
1607
 
1570
1608
  def days_in_month(year, month)
1571
1609
  if month == 2
1610
+ # Note: Not using `Date.gregorian_leap?` because it is slower.
1572
1611
  is_leap_year = (year % 4).zero? && (!(year % 100).zero? || (year % 400).zero?)
1573
1612
  return is_leap_year ? 29 : 28
1574
1613
  end
@@ -1577,6 +1616,8 @@ module SOML
1577
1616
  end
1578
1617
 
1579
1618
  # Days since 1970-01-01 in the proleptic Gregorian calendar, from Howard Hinnant's `days_from_civil`.
1619
+ #
1620
+ # Note: Not using `Time.utc` because constructing it here is slower.
1580
1621
  def days_from_civil(year, month, day)
1581
1622
  year -= 1 if month <= 2
1582
1623
  era = year.div(400)
@@ -1758,13 +1799,6 @@ module SOML
1758
1799
  text.match?(/[A-Za-z]/) ? "Invalid number “#{abbreviate(text)}”. A string value must be quoted#{quoting_example(unquoted_text)}" : "Invalid number “#{abbreviate(text)}”"
1759
1800
  end
1760
1801
 
1761
- # A date alone, which is not an instant. The instant it could be is only shown when the date exists, so that the example is valid.
1762
- def describe_date(date)
1763
- year, month, day = date.split('-').map(&:to_i)
1764
- is_existing = year >= 1 && month.between?(1, 12) && day.between?(1, days_in_month(year, month))
1765
- "#{date} is a date, not an instant. Write a date as a string, as in '#{date}'#{is_existing ? ". An instant needs a time and an offset, as in #{date}T00:00:00Z" : ''}"
1766
- end
1767
-
1768
1802
  # An offset on its own after spaces, as in `2026-09-19T14:00:00 Z`, which belongs directly after the time, or `nil`.
1769
1803
  def offset_after_space
1770
1804
  offset_start = skip_spaces(@index)
@@ -1782,7 +1816,7 @@ module SOML
1782
1816
  if text.bytesize > MAX_DIAGNOSED_LENGTH
1783
1817
  generic
1784
1818
  elsif text.match?(/\A[0-9]{4}-[0-9]{2}-[0-9]{2}\z/)
1785
- time.match?(/\A[0-9]{2}:[0-9]{2}/) ? describe_space_separated_instant(text, time, is_whole_value) : describe_date(text)
1819
+ describe_space_separated_instant(text, time, is_whole_value)
1786
1820
  elsif text.match?(/\A[0-9]{4}-[0-9]{2}-[0-9]{2}t/)
1787
1821
  'The date and time separator in an instant is an uppercase “T”'
1788
1822
  elsif text.end_with?('z')
data/lib/soml/tree.rb CHANGED
@@ -10,7 +10,7 @@ module SOML
10
10
  # A `# ...` comment (`:line`) or a `/* ... */` comment (`:block`). `value` is its text without the delimiters.
11
11
  Comment = Struct.new(:type, :start, :finish, :value)
12
12
 
13
- # A lexical token with its original source text in `value`. `type` is `:punctuator`, `:bare_key`, `:string`, `:integer`, `:float`, `:keyword`, `:instant`, or `:duration`. Quoted keys are strings; booleans and null are keywords. Comments and whitespace are excluded.
13
+ # A lexical token with its original source text in `value`. `type` is `:punctuator`, `:bare_key`, `:string`, `:integer`, `:float`, `:keyword`, `:date`, `:instant`, or `:duration`. Quoted keys are strings; booleans and null are keywords. Comments and whitespace are excluded.
14
14
  Token = Struct.new(:type, :start, :finish, :value)
15
15
 
16
16
  # An object, written with braces or, only at the top level, without them.
@@ -32,7 +32,7 @@ module SOML
32
32
  def type = :key
33
33
  end
34
34
 
35
- # A value that is not a collection. `kind` is `:string`, `:integer`, `:float`, `:boolean`, `:null`, `:instant`, or `:duration`, and `value` is decoded as by SOML.parse. `block` is whether it is a block string. Strings have `style: :literal` or `:escaped`; integers have `radix: 2`, `8`, `10`, or `16`. In other kinds, `style` and `radix` are nil and `block` is false.
35
+ # A value that is not a collection. `kind` is `:string`, `:integer`, `:float`, `:boolean`, `:null`, `:date`, `:instant`, or `:duration`, and `value` is decoded as by SOML.parse. `block` is whether it is a block string. Strings have `style: :literal` or `:escaped`; integers have `radix: 2`, `8`, `10`, or `16`. In other kinds, `style` and `radix` are nil and `block` is false.
36
36
  ScalarNode = Struct.new(:block, :start, :finish, :kind, :value, :style, :radix) do
37
37
  def type = :scalar
38
38
  end
@@ -166,6 +166,7 @@ module SOML
166
166
  # The number of characters that start in the `length` bytes from `start`: the bytes that are not UTF-8 continuation bytes. The bytes may begin or end inside a character.
167
167
  def character_count(start, length)
168
168
  bytes = @source.byteslice(start, length).b
169
+ # Note: Not using a complement count because it offers no measured speedup.
169
170
  bytes.bytesize - bytes.count("\x80-\xBF".b)
170
171
  end
171
172
 
@@ -357,6 +358,8 @@ module SOML
357
358
  :boolean
358
359
  when nil
359
360
  :null
361
+ when Date
362
+ :date
360
363
  when Time
361
364
  :instant
362
365
  when Duration
data/lib/soml/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module SOML
4
4
  # The version of this gem.
5
- VERSION = '0.0.2'
5
+ VERSION = '0.0.3'
6
6
  end
data/lib/soml.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'date'
4
+
3
5
  require_relative 'soml/version'
4
6
 
5
7
  # Read and write SOML, a config format for humans.
@@ -25,7 +27,7 @@ module SOML
25
27
  # A byte that a bare key can hold, as a table of 256 booleans, for the {Parser} and the {Tree}.
26
28
  BARE_KEY_BYTE = Array.new(256) { it.chr.match?(/[A-Za-z0-9_-]/) }.freeze
27
29
 
28
- # A number, an instant, or a duration is one maximal run of these bytes, which the {Parser} then validates as a whole. A keyword ends at the first byte that is not one, too, because the parser checked that it is followed by whitespace, a separator, a closing bracket, a comment, or the end.
30
+ # A number, a date, an instant, or a duration is one maximal run of these bytes, which the {Parser} then validates as a whole. A keyword ends at the first byte that is not one, too, because the parser checked that it is followed by whitespace, a separator, a closing bracket, a comment, or the end.
29
31
  NUMBER_BYTE = Array.new(256) { it.chr.match?(/[A-Za-z0-9_.:+-]/) }.freeze
30
32
 
31
33
  # A line of a block string without one of these is blank. A search for one character, because a repeat over a long line needs memory for every character.
@@ -59,6 +61,8 @@ module SOML
59
61
  key = key.encode(Encoding::UTF_8) unless key.encoding.ascii_compatible?
60
62
 
61
63
  # A scan of the bytes, which is faster than the regex it replaces and decides the same: a byte that is not an ASCII letter, digit, `_`, or `-` means the key needs quotes. A non-ASCII character is a run of bytes none of which a bare key can hold.
64
+ #
65
+ # Note: Not using `String#count` because it is slower for typical short keys.
62
66
  return false if key.empty?
63
67
 
64
68
  index = 0
data/readme.md CHANGED
@@ -8,7 +8,7 @@
8
8
  ## Highlights
9
9
 
10
10
  - Strict: enforces every rule in the specification, with the same error messages as the JavaScript reference implementation
11
- - Exact: an int is an `Integer`, a float is a `Float`, an instant is a `Time` exact to the nanosecond, and a duration is an exact count of nanoseconds
11
+ - Exact: an int is an `Integer`, a float is a `Float`, a date is a Gregorian `Date`, an instant is a `Time` exact to the nanosecond, and a duration is an exact count of nanoseconds
12
12
  - Canonical: `SOML.generate(value, canonical: true)` writes the one canonical form, so equal values give equal text
13
13
  - Familiar: the API of `json`, with `parse`, `load_file`, `generate`, `symbolize_names:`, and `freeze:`
14
14
  - Lossless: `SOML.format` and `SOML.edit` keep comments, member order, and spelling
@@ -40,6 +40,7 @@ config = SOML.parse(<<~SOML)
40
40
  replicas: 3
41
41
  timeout: 30.0
42
42
  grace: 1m30s
43
+ launch-date: 2027-09-19
43
44
  deployed-at: 2026-09-19T14:00:00Z
44
45
  postgres: {host: 'db.internal'}
45
46
  SOML
@@ -48,6 +49,7 @@ SOML
48
49
  # "replicas" => 3,
49
50
  # "timeout" => 30.0,
50
51
  # "grace" => #<SOML::Duration 1m30s>,
52
+ # "launch-date" => Date.new(2027, 9, 19, Date::GREGORIAN),
51
53
  # "deployed-at" => 2026-09-19 14:00:00 UTC,
52
54
  # "postgres" => {"host" => "db.internal"}
53
55
  # }
@@ -57,6 +59,7 @@ puts SOML.generate(config)
57
59
  # replicas: 3
58
60
  # timeout: 30.0
59
61
  # grace: 1m30s
62
+ # launch-date: 2027-09-19
60
63
  # deployed-at: 2026-09-19T14:00:00Z
61
64
  # postgres: {
62
65
  # host: 'db.internal'
@@ -86,6 +89,7 @@ File.binwrite('config.soml', SOML.generate(config))
86
89
  | float | `Float` | |
87
90
  | bool | `true`, `false` | |
88
91
  | null | `nil` | |
92
+ | date | `Date`, in the proleptic Gregorian calendar | a Gregorian `Date` in years 0001 to 9999 |
89
93
  | instant | `Time`, in UTC | a `Time` with any offset |
90
94
  | duration | [`SOML::Duration`](#somlduration) | |
91
95
  | array | `Array` | |
@@ -109,6 +113,8 @@ Two things always hold:
109
113
  - `SOML.parse(SOML.generate(value))` equals `value`, for a value made of the types `parse` returns. (A `Time` comes back in UTC, a `Symbol` as a `String`, and `-0.0` as `0.0`.)
110
114
  - `SOML.generate(SOML.parse(text), canonical: true)` is the canonical form of `text`.
111
115
 
116
+ A date is a `Date` with Gregorian year, month, and day, without a time or timezone. For a date before 1582-10-15, construct it with `Date.new(year, month, day, Date::GREGORIAN)`, as in `Date.new(1582, 10, 10, Date::GREGORIAN)`. Ruby's default calendar uses the Julian calendar before 1582-10-05, skips 1582-10-05 through 1582-10-14, and uses the Gregorian calendar from 1582-10-15. Julian dates and `DateTime` values are rejected when writing.
117
+
112
118
  An instant is a `Time` in UTC, because the offset is not part of its value:
113
119
 
114
120
  ```ruby
@@ -181,10 +187,11 @@ Raises `SOML::GenerateError` for a value that cannot be written:
181
187
  - `Float::NAN` (use `nil` for a missing value)
182
188
  - an `Integer` outside the 64-bit range
183
189
  - a `Time` outside the years 0001 to 9999 in UTC, or finer than a nanosecond
190
+ - a `Date` outside the years 0001 to 9999, or a Julian (non-Gregorian) `Date`
184
191
  - a string or key with a carriage return, or that is not valid UTF-8 and does not convert to it
185
192
  - a key that is not a `String` or a `Symbol`, and two keys that are the same string, such as `:a` and `'a'`
186
193
  - a circular reference, and nesting deeper than 100 levels
187
- - an object of any other class, such as a `Date` (write it as a string), a `Rational`, or an `ActiveSupport::Duration` (use a `SOML::Duration`). A `Struct`, `Data`, `Set`, or `Pathname` is not converted, because it would read back as another class. The message names the conversion to use, such as `to_h`.
194
+ - an object of any other class, such as a `DateTime` (convert it with `to_time`), a `Rational`, or an `ActiveSupport::Duration` (use a `SOML::Duration`). A `Struct`, `Data`, `Set`, or `Pathname` is not converted, because it would read back as another class. The message names the conversion to use, such as `to_h`.
188
195
 
189
196
  The message says where the value is:
190
197
 
@@ -345,7 +352,7 @@ SOML::Duration.parse('1.5h')
345
352
 
346
353
  #### SOML::Duration.from_seconds(seconds)
347
354
 
348
- Make a duration from a number of seconds, the inverse of `#to_r`. The seconds are rounded to the nearest nanosecond, because a `Float` such as `0.1` is not exact. Raises `TypeError` when `seconds` is not a number, and `RangeError` when it is not finite or is outside the 64-bit range.
355
+ Make a duration from a number of seconds, the inverse of `#to_r`. The seconds are rounded to the nearest nanosecond, with ties to even, because a `Float` such as `0.1` is not exact. Raises `TypeError` when `seconds` is not a number, and `RangeError` when it is not finite or the rounded nanosecond count is outside the 64-bit range.
349
356
 
350
357
  ```ruby
351
358
  SOML::Duration.from_seconds(90)
@@ -511,16 +518,16 @@ SOML.generate({timeout: SOML::Duration.from_seconds(5.minutes.to_r)})
511
518
 
512
519
  ## Benchmark
513
520
 
514
- CPU time to read and write the shared [benchmark fixtures](https://github.com/soml-lang/soml/blob/main/benchmark/readme.md), which hold the same data in SOML, JSON, and TOML, on an Apple M-series machine with Ruby 4.0 and YJIT. The numbers are rough. Lower is better.
521
+ CPU time to read and write the shared [benchmark fixtures](https://github.com/soml-lang/soml/blob/main/benchmark/readme.md), which hold the same data in SOML, JSON, and TOML, on an Apple M-series machine with Ruby 4.0.6 and YJIT. The numbers are rough. Lower is better.
515
522
 
516
523
  | | this gem | `toml-rb` | `json` | `oj` |
517
524
  |---|---|---|---|---|
518
- | Read `service` (3 KB) | 78 µs | 174 µs | 10 µs | 11 µs |
519
- | Read `platform` (35 KB) | 0.61 ms | 1.2 ms | 0.10 ms | 0.07 ms |
520
- | Read `earthquakes` (733 KB) | 14 ms | 51 ms | 2.2 ms | 2.8 ms |
521
- | Write `service` | 51 µs | 105 µs | 8 µs | 8 µs |
522
- | Write `platform` | 0.34 ms | 0.65 ms | 0.06 ms | 0.03 ms |
523
- | Write `earthquakes` | 15 ms | 26 ms | 2.1 ms | 3.2 ms |
525
+ | Read `service` (3 KB) | 59 µs | 152 µs | 9 µs | 10 µs |
526
+ | Read `platform` (35 KB) | 0.54 ms | 1.3 ms | 0.09 ms | 0.07 ms |
527
+ | Read `earthquakes` (733 KB) | 11 ms | 43 ms | 2.0 ms | 2.5 ms |
528
+ | Write `service` | 46 µs | 97 µs | 7 µs | 7 µs |
529
+ | Write `platform` | 0.35 ms | 0.61 ms | 0.05 ms | 0.03 ms |
530
+ | Write `earthquakes` | 12 ms | 27 ms | 1.9 ms | 2.7 ms |
524
531
 
525
532
  `toml-rb` is pure Ruby, like this gem, and `json` and `oj` are C extensions. The script also compares `tomlib`, `perfect_toml`, and `tomlrb`, and times `SOML.format`, `SOML.edit`, and `SOML.parse_tree` on each fixture:
526
533
 
@@ -559,11 +566,11 @@ Every node, token, and comment has `start` and exclusive `finish` UTF-8 byte off
559
566
  | `Comment` | `:line` or `:block` | `value` (text without delimiters) |
560
567
  | `Token` | See below | `value` (original source text) |
561
568
 
562
- Scalar kinds are `:string`, `:integer`, `:float`, `:boolean`, `:null`, `:instant`, and `:duration`, with the same Ruby values as `SOML.parse`. Strings have `style: :literal` or `:escaped` and a `block` boolean; integers have `radix: 2`, `8`, `10`, or `16`. Otherwise, `style` and `radix` are `nil`, and `block` is `false`.
569
+ Scalar kinds are `:string`, `:integer`, `:float`, `:boolean`, `:null`, `:date`, `:instant`, and `:duration`, with the same Ruby values as `SOML.parse`. Strings have `style: :literal` or `:escaped` and a `block` boolean; integers have `radix: 2`, `8`, `10`, or `16`. Otherwise, `style` and `radix` are `nil`, and `block` is `false`.
563
570
 
564
571
  ### Syntax highlighting
565
572
 
566
- Token types are `:punctuator` (`{}[]:,`), `:bare_key`, `:string`, `:integer`, `:float`, `:keyword` (`true`, `false`, `null`), `:instant`, and `:duration`. Quoted keys and whole block strings are string tokens; escapes stay within strings. Infinities are floats. Use key nodes to distinguish quoted keys from string values.
573
+ Token types are `:punctuator` (`{}[]:,`), `:bare_key`, `:string`, `:integer`, `:float`, `:keyword` (`true`, `false`, `null`), `:date`, `:instant`, and `:duration`. Quoted keys and whole block strings are string tokens; escapes stay within strings. Infinities are floats. Use key nodes to distinguish quoted keys from string values.
567
574
 
568
575
  ```ruby
569
576
  (tree.tokens + tree.comments).sort_by(&:start).each do |token|
data/sig/manifest.yaml ADDED
@@ -0,0 +1,2 @@
1
+ dependencies:
2
+ - name: date
data/sig/soml.rbs CHANGED
@@ -3,7 +3,7 @@ module SOML
3
3
  VERSION: String
4
4
 
5
5
  # A value in a document, as `parse` returns it.
6
- type value = String | Integer | Float | bool | nil | Time | Duration | Array[value] | Hash[String | Symbol, value]
6
+ type value = String | Integer | Float | bool | nil | Date | Time | Duration | Array[value] | Hash[String | Symbol, value]
7
7
 
8
8
  # A whole document, which is always an object or an array.
9
9
  type document = Hash[String | Symbol, value] | Array[value]
@@ -88,7 +88,7 @@ module SOML
88
88
  # Read a duration written as in a document, such as `1h30m`.
89
89
  def self.parse: (string text) -> Duration
90
90
 
91
- # Make a duration from a number of seconds, rounded to the nearest nanosecond.
91
+ # Make a duration from a number of seconds, rounded to the nearest nanosecond, with ties to even.
92
92
  def self.from_seconds: (Numeric seconds) -> Duration
93
93
 
94
94
  def self.new: (nanoseconds: Integer) -> Duration
@@ -162,7 +162,7 @@ module SOML
162
162
 
163
163
  # A lexical token, with its source text.
164
164
  class Token < Struct[untyped]
165
- attr_reader type: :punctuator | :bare_key | :string | :integer | :float | :keyword | :instant | :duration
165
+ attr_reader type: :punctuator | :bare_key | :string | :integer | :float | :keyword | :date | :instant | :duration
166
166
  attr_reader start: Integer
167
167
  attr_reader finish: Integer
168
168
  attr_reader value: String
@@ -207,8 +207,8 @@ module SOML
207
207
  attr_reader block: bool
208
208
  attr_reader start: Integer
209
209
  attr_reader finish: Integer
210
- attr_reader kind: :string | :integer | :float | :boolean | :null | :instant | :duration
211
- attr_reader value: String | Integer | Float | bool | nil | Time | Duration
210
+ attr_reader kind: :string | :integer | :float | :boolean | :null | :date | :instant | :duration
211
+ attr_reader value: String | Integer | Float | bool | nil | Date | Time | Duration
212
212
  attr_reader style: (:literal | :escaped)?
213
213
  attr_reader radix: (2 | 8 | 10 | 16)?
214
214
  def type: () -> :scalar
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: soml-lang
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sindre Sorhus
@@ -10,9 +10,9 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: A strict parser and a generator for SOML that keeps member order or writes
13
- canonical form, with exact integers, instants, and durations, and an error message
14
- that says what is wrong and where. With a formatter, an editor that keeps comments
15
- and layout, and a syntax tree for linters.
13
+ canonical form, with exact integers, dates, instants, and durations, and an error
14
+ message that says what is wrong and where. With a formatter, an editor that keeps
15
+ comments and layout, and a syntax tree for linters.
16
16
  email:
17
17
  - sindresorhus@gmail.com
18
18
  executables: []
@@ -31,6 +31,7 @@ files:
31
31
  - lib/soml/version.rb
32
32
  - license
33
33
  - readme.md
34
+ - sig/manifest.yaml
34
35
  - sig/soml.rbs
35
36
  homepage: https://github.com/soml-lang/soml-ruby
36
37
  licenses: