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 +4 -4
- data/lib/soml/duration.rb +3 -3
- data/lib/soml/format.rb +3 -0
- data/lib/soml/generator.rb +26 -6
- data/lib/soml/parser.rb +56 -22
- data/lib/soml/tree.rb +5 -2
- data/lib/soml/version.rb +1 -1
- data/lib/soml.rb +5 -1
- data/readme.md +19 -12
- data/sig/manifest.yaml +2 -0
- data/sig/soml.rbs +5 -5
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 808fb7da3c2ca5866146bef2193cbf96c4d4e91077c65ada841afd077b1e5282
|
|
4
|
+
data.tar.gz: 03113f26b03a51156a4c7b30ac2dbde6fda0e908df06f5b11c349c69ef2619d7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
data/lib/soml/generator.rb
CHANGED
|
@@ -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?(::
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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,
|
|
1344
|
-
time_start = @index
|
|
1345
|
-
finish = @
|
|
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
|
-
|
|
1367
|
-
fail_instant(text, start,
|
|
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
|
-
|
|
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
|
-
|
|
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
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 `
|
|
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) |
|
|
519
|
-
| Read `platform` (35 KB) | 0.
|
|
520
|
-
| Read `earthquakes` (733 KB) |
|
|
521
|
-
| Write `service` |
|
|
522
|
-
| Write `platform` | 0.
|
|
523
|
-
| Write `earthquakes` |
|
|
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
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.
|
|
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
|
|
14
|
-
that says what is wrong and where. With a formatter, an editor that keeps
|
|
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:
|