soml-lang 0.0.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.
data/readme.md ADDED
@@ -0,0 +1,552 @@
1
+ # soml-lang
2
+
3
+ > Read and write [SOML](https://soml.sh), a config format for humans, in Ruby
4
+
5
+ > [!NOTE]
6
+ > The format is a draft.
7
+
8
+ ## Highlights
9
+
10
+ - Strict: every rule in the specification is enforced, and every error says what is wrong and where, with the same message 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
12
+ - Canonical: `SOML.generate(value, canonical: true)` writes the one canonical form, so equal values give equal text, and without it the member order of a hash is kept
13
+ - Familiar: the API of `json`, with `parse`, `load_file`, `generate`, `symbolize_names:`, and `freeze:`
14
+ - Lossless: `SOML.format` normalizes the layout and `SOML.edit` changes one value, and both keep the comments, member order, and spelling of everything else
15
+ - Safe: linear time and memory on every input shape, no stack overflow on deep input, and every error in a document is a `SOML::ParseError`
16
+ - Ractor-safe, with no dependencies
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ gem install soml-lang
22
+ ```
23
+
24
+ Or in a `Gemfile`:
25
+
26
+ ```ruby
27
+ gem 'soml-lang'
28
+ ```
29
+
30
+ Requires Ruby 4.0 or later.
31
+
32
+ ## Usage
33
+
34
+ ```ruby
35
+ require 'soml'
36
+
37
+ config = SOML.parse(<<~SOML)
38
+ # The edge service.
39
+ name: 'api-gateway'
40
+ replicas: 3
41
+ timeout: 30.0
42
+ grace: 1m30s
43
+ deployed-at: 2026-09-19T14:00:00Z
44
+ postgres: {host: 'db.internal'}
45
+ SOML
46
+ #=> {
47
+ # "name" => "api-gateway",
48
+ # "replicas" => 3,
49
+ # "timeout" => 30.0,
50
+ # "grace" => #<SOML::Duration 1m30s>,
51
+ # "deployed-at" => 2026-09-19 14:00:00 UTC,
52
+ # "postgres" => {"host" => "db.internal"}
53
+ # }
54
+
55
+ puts SOML.generate(config)
56
+ # name: 'api-gateway'
57
+ # replicas: 3
58
+ # timeout: 30.0
59
+ # grace: 1m30s
60
+ # deployed-at: 2026-09-19T14:00:00Z
61
+ # postgres: {
62
+ # host: 'db.internal'
63
+ # }
64
+ ```
65
+
66
+ Read a file:
67
+
68
+ ```ruby
69
+ config = SOML.load_file('config.soml', symbolize_names: true)
70
+ config[:postgres][:host]
71
+ #=> "db.internal"
72
+ ```
73
+
74
+ Write a file in binary mode, so that Windows does not turn each line feed into a CRLF, which SOML does not allow:
75
+
76
+ ```ruby
77
+ File.binwrite('config.soml', SOML.generate(config))
78
+ ```
79
+
80
+ ## Types
81
+
82
+ | SOML | Ruby | `generate` also accepts |
83
+ |---|---|---|
84
+ | string | `String`, in UTF-8 | `Symbol`, and a `String` in any encoding that converts to UTF-8 |
85
+ | int | `Integer` | |
86
+ | float | `Float` | |
87
+ | bool | `true`, `false` | |
88
+ | null | `nil` | |
89
+ | instant | `Time`, in UTC | a `Time` with any offset |
90
+ | duration | [`SOML::Duration`](#somlduration) | |
91
+ | array | `Array` | |
92
+ | object | `Hash` with `String` keys, in document order, or `Symbol` keys with `symbolize_names: true` | `Symbol` keys |
93
+
94
+ An int is an `Integer` and a float is a `Float` in both directions, so `3` and `3.0` keep their types:
95
+
96
+ ```ruby
97
+ SOML.parse("a: 3\nb: 3.0")
98
+ #=> {"a" => 3, "b" => 3.0}
99
+
100
+ SOML.generate({a: 3, b: 3.0})
101
+ #=> "a: 3\nb: 3.0\n"
102
+ ```
103
+
104
+ > [!IMPORTANT]
105
+ > Ruby compares `3 == 3.0` as true, so `SOML.parse('a: 3') == SOML.parse('a: 3.0')` is true even though the types differ. `eql?` compares the types too, so `SOML.parse('a: 3').eql?(SOML.parse('a: 3.0'))` is false.
106
+
107
+ With this mapping, two things always hold:
108
+
109
+ - `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
+ - `SOML.generate(SOML.parse(text), canonical: true)` is the canonical form of `text`, and `SOML.generate(SOML.parse(canonical), canonical: true) == canonical`.
111
+
112
+ An instant is a `Time` in UTC, because the offset is how the instant was written, not part of its value:
113
+
114
+ ```ruby
115
+ SOML.parse('a: 2026-09-19T14:00:00.123456789+02:00')['a']
116
+ #=> 2026-09-19 12:00:00.123456789 UTC
117
+ ```
118
+
119
+ ## API
120
+
121
+ ### SOML.parse(source, symbolize_names: false, freeze: false)
122
+
123
+ Parse a document. Returns a `Hash` or an `Array`, because a document is always a collection.
124
+
125
+ `source` is a `String`, or an object with `to_str`. A UTF-8, binary, or US-ASCII string is read as UTF-8, and invalid UTF-8 is a `SOML::ParseError` with its position. A string in another encoding, such as UTF-16, is converted to UTF-8 first, as `JSON.parse` does, and an `EncodingError` is raised when it does not convert.
126
+
127
+ Raises `SOML::ParseError` when the document is not valid, and `TypeError` when `source` is not a string.
128
+
129
+ #### symbolize_names
130
+
131
+ Type: `Boolean`\
132
+ Default: `false`
133
+
134
+ Whether object keys are symbols rather than strings.
135
+
136
+ ```ruby
137
+ SOML.parse('postgres: {host: "db"}', symbolize_names: true)
138
+ #=> {postgres: {host: "db"}}
139
+ ```
140
+
141
+ #### freeze
142
+
143
+ Type: `Boolean`\
144
+ Default: `false`
145
+
146
+ Whether the result is deeply frozen, which also makes it shareable between ractors.
147
+
148
+ ```ruby
149
+ config = SOML.parse("hosts: ['a', 'b']", freeze: true)
150
+ config['hosts'].frozen?
151
+ #=> true
152
+ Ractor.shareable?(config)
153
+ #=> true
154
+ ```
155
+
156
+ ### SOML.load_file(path, symbolize_names: false, freeze: false)
157
+
158
+ Read and parse a file. `path` is a `String`, or an object with `to_path`, such as a `Pathname`. The options are those of `SOML.parse`.
159
+
160
+ The file is read in binary mode, so that a text-mode conversion cannot hide a carriage return, which a document may not contain.
161
+
162
+ Raises `SOML::ParseError` when the document is not valid, and a `SystemCallError`, such as `Errno::ENOENT`, when the file cannot be read. The error names the file, so that an editor or a terminal can open it at the position:
163
+
164
+ ```ruby
165
+ SOML.load_file('config.soml')
166
+ # Raises SOML::ParseError: Duplicate key port at config.soml:3:1
167
+ ```
168
+
169
+ ### SOML.generate(value, canonical: false)
170
+
171
+ Write a `Hash` or an `Array` as SOML. Members keep the order of their hash, nesting is written with braces and tabs, and the text ends with one line feed. Comments and block strings are never written.
172
+
173
+ Everything but the order of members follows canonical form, so the text is canonical form when the keys are already sorted. Use `canonical: true` when the text is hashed, signed, or compared.
174
+
175
+ ```ruby
176
+ SOML.generate({name: 'api', ports: [80, 443], description: 'The edge service'})
177
+ #=> "name: 'api'\nports: [\n\t80\n\t443\n]\ndescription: 'The edge service'\n"
178
+ ```
179
+
180
+ Raises `SOML::GenerateError` for a value that cannot be written:
181
+
182
+ - a top-level value that is not a `Hash` or an `Array`, because a document is a collection
183
+ - `Float::NAN`, which SOML does not have (use `nil` for a missing value)
184
+ - an `Integer` outside the 64-bit range
185
+ - a `Time` outside the years 0001 to 9999 in UTC, or with a fraction of a second finer than a nanosecond
186
+ - a string or key with a carriage return, or one that is not valid UTF-8 or does not convert to UTF-8 (a binary or US-ASCII string is read as UTF-8, as `SOML.parse` reads one)
187
+ - a key that is not a `String` or a `Symbol`, because a key is never converted, and two keys that are the same string, such as `:a` and `'a'`
188
+ - a circular reference, and nesting deeper than 100 levels
189
+ - 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, and the message names the conversion to use, such as `to_h`
190
+
191
+ The message says where the value is:
192
+
193
+ ```ruby
194
+ SOML.generate({servers: [{ratio: Float::NAN}]})
195
+ # Raises SOML::GenerateError: NaN is not representable. Use nil for a missing value (at servers[0].ratio)
196
+ ```
197
+
198
+ #### canonical
199
+
200
+ Type: `Boolean`\
201
+ Default: `false`
202
+
203
+ Whether to write canonical form, with the members of each object sorted by key. Canonical form is unique for a value: two equal values give the same text, so it can be hashed, signed, or compared.
204
+
205
+ ```ruby
206
+ SOML.generate({name: 'api', ports: [80, 443], description: 'The edge service'}, canonical: true)
207
+ #=> "description: 'The edge service'\nname: 'api'\nports: [\n\t80\n\t443\n]\n"
208
+ ```
209
+
210
+ ### SOML.format(source)
211
+
212
+ Format a document. Returns the document with its layout normalized, ending with one line feed.
213
+
214
+ The layout follows the [formatter](https://github.com/soml-lang/soml/blob/main/spec.md#formatting) in the specification: one tab per level, every member and item on its own line with no commas, and no trailing whitespace or runs of blank lines. A container whose brackets are on one line stays on one line, as in `ports: [80, 443]`. Put a line break anywhere inside it to give it one member or item per line. Comments, member order, block strings, and the spelling of every value stay as they are, so the value never changes. Inside a block comment, only trailing whitespace and runs of blank lines change.
215
+
216
+ `source` is read as `SOML.parse` reads it, with the same errors.
217
+
218
+ ```ruby
219
+ SOML.format('pool: {min: 2, max: 16,} # Connections')
220
+ #=> "pool: {min: 2, max: 16} # Connections\n"
221
+
222
+ SOML.format("pool: {\nmin: 2, max: 16}")
223
+ #=> "pool: {\n\tmin: 2\n\tmax: 16\n}\n"
224
+ ```
225
+
226
+ In detail, as the specification states:
227
+
228
+ - A block comment that a value follows on the same line, as in `1, /* note */ 2`, stays in front of that value when each item goes on its own line.
229
+ - One space follows each `:`, as in canonical form.
230
+ - A value on the line after its `key:` moves up to that line, unless a comment comes between them. Then it stays on its own line, one level deeper than the key, and the blank lines between them are removed.
231
+ - A block string begins on the line after its key, and its delimiters and content have the indentation of that line. Its lines are not otherwise changed.
232
+ - There are no blank lines at the start of the file or directly inside brackets.
233
+
234
+ ### SOML.edit(source, path, value)
235
+
236
+ Change one value in a document, and keep everything else as it is written: comments, member order, layout, and the spelling of every other value. For tools that update a config file, such as a dependency bumper or a `set` command.
237
+
238
+ The value at `path` is replaced, or added when it does not exist yet. `SOML.edit(source, path, delete: true)` removes it instead. Only the changed part of the text is rewritten, and an edit to a formatted document leaves it formatted.
239
+
240
+ ```ruby
241
+ SOML.edit("name: 'api' # The service\nport: 8080\n", ['port'], 9090)
242
+ #=> "name: 'api' # The service\nport: 9090\n"
243
+
244
+ SOML.edit("postgres: {\n\thost: 'db'\n}\n", ['postgres', 'port'], 5432)
245
+ #=> "postgres: {\n\thost: 'db'\n\tport: 5432\n}\n"
246
+
247
+ SOML.edit("a: 1\nb: 2\n", ['a'], delete: true)
248
+ #=> "b: 2\n"
249
+ ```
250
+
251
+ Read and write the file in binary mode, as for `SOML.generate`:
252
+
253
+ ```ruby
254
+ File.binwrite('config.soml', SOML.edit(File.binread('config.soml'), ['postgres', 'port'], 5433))
255
+ ```
256
+
257
+ - A new value is written as `SOML.generate` writes it, in canonical form except that its members keep their order, at the indentation of its line. So `0xFF` that is replaced by `255` is written `255`. In a container that is on one line, it is written on one line too, so `[1, 2]` with a new item `{a: 3}` becomes `[1, 2, {a: 3}]`.
258
+ - A new member goes after the last member of its object. Missing objects on the way are created.
259
+ - A new item can be added at the end of an array, with the index that is its length.
260
+ - A new member or item goes on its own line without a comma, after the comments that the one before it owns (see below), and the commas of the other members and items stay as they are. When something follows the member or item before it on the same line, such as the closing bracket of the one-line container `{a: 1}`, it goes on that line after a comma, as in `{a: 1, b: 2}`. In an empty `[]` or `{}`, it goes on a line of its own, unless that container is inside a container on one line, so `a: [1, []]` becomes `a: [1, [2]]`.
261
+ - A removed member or item takes the comments it owns, which in a formatted document are the ones that `SOML.format` keeps with it: the comments after it on its line, also after its comma when nothing else follows there, and the block comments before it on its line, after the comma or bracket before it. So removing `2` from `[1, /* note */ 2]` gives `[1]`. A comment on a line of its own belongs to no member or item, so it stays. A removed member or item also removes its lines when nothing else is on them. A line that a block comment after it continues onto counts as one of its lines. Removing every item of a container closes it up to `[]` or `{}`, unless a comment is left inside.
262
+ - A removed member or item takes its comma with it. When the removed items are the last ones and have no comma after them, they take the comma directly before them instead, when only spaces, tabs, and the comments they own are between, so `[1, 2]` becomes `[1]`.
263
+ - Removing the only member of a document without braces leaves `{}`.
264
+
265
+ Comments inside a value that is replaced or removed are removed with it. In a layout that `SOML.format` never writes, such as an item after a closing block string delimiter on its line, a new item can be indented differently from its neighbors, but the document is always valid and has the right value.
266
+
267
+ Removing a value that does not exist (a missing member, a missing object or array on the way, or an index at or past the end of its array) changes nothing, so removing the same path twice is safe. The result has the same bytes as `source` exactly when nothing was removed, so `SOML.edit(source, path, delete: true).b == source.b` tells whether something was. The `.b` is needed because a source read in binary mode is not `==` to the UTF-8 result when it has a character that is not ASCII.
268
+
269
+ The result is parsed before it is returned, so a bug in `SOML.edit` raises a `RuntimeError` rather than returning a broken document.
270
+
271
+ Raises:
272
+
273
+ - `SOML::ParseError` when `source` is not a valid document, the same as `SOML.parse`
274
+ - `TypeError` when `source` is not a string, when `path` is not a non-empty array of keys and array indexes, when it leads through a value that is not an object or an array, or when it has a key where an array is or an index where an object is
275
+ - `IndexError` when a value is set at an index past the end of its array, or at an index under a value that does not exist
276
+ - `SOML::GenerateError` for a value or a key that `SOML.generate` cannot write, and when the change would nest the document more than 100 levels deep. Its path is the place in the document.
277
+ - `ArgumentError` when both a value and `delete: true` are given, or neither
278
+ - `EncodingError` when `source` is in an encoding other than UTF-8, binary, or US-ASCII, and does not convert to UTF-8, as for `SOML.parse`
279
+
280
+ ```ruby
281
+ SOML.edit('ports: [80]', ['ports', 5], 443)
282
+ # Raises IndexError: Cannot edit ports[5], because the array at ports has 1 item. Add an item at index 1
283
+ ```
284
+
285
+ #### path
286
+
287
+ Type: `Array<String | Symbol | Integer>`
288
+
289
+ The keys and array indexes that lead to the value, such as `['servers', 0, 'port']`.
290
+
291
+ #### value
292
+
293
+ The new value, of the types that `SOML.generate` accepts. `nil` writes `null`. Write a `Hash` with braces, as in `SOML.edit(source, ['pool'], {min: 2})`, because without them Ruby passes its members as keyword arguments.
294
+
295
+ #### delete
296
+
297
+ Type: `Boolean`\
298
+ Default: `false`
299
+
300
+ Remove the value at `path`, instead of setting it.
301
+
302
+ ### SOML.bare_key?(key)
303
+
304
+ Whether a key can be written without quotes, as a bare key: one or more ASCII letters, digits, `_`, or `-`, in any order, so `404` and `-x` are bare keys too. `SOML.generate` writes such a key bare and quotes every other key.
305
+
306
+ ```ruby
307
+ SOML.bare_key?('content-type')
308
+ #=> true
309
+
310
+ SOML.bare_key?('a.b')
311
+ #=> false
312
+ ```
313
+
314
+ ### SOML::Duration
315
+
316
+ A length of time, exact to the nanosecond, which is what a SOML duration such as `1h30m` holds. Ruby has no duration type of its own, and `ActiveSupport::Duration` has days and months, which are not a fixed length.
317
+
318
+ It is immutable, comparable, and usable as a hash key.
319
+
320
+ ```ruby
321
+ timeout = SOML.parse('timeout: 1m30s')['timeout']
322
+ #=> #<SOML::Duration 1m30s>
323
+
324
+ timeout.nanoseconds
325
+ #=> 90000000000
326
+
327
+ sleep(timeout.to_f)
328
+ ```
329
+
330
+ #### SOML::Duration.new(nanoseconds:)
331
+
332
+ Make a duration from a count of nanoseconds, which must be an `Integer` in the 64-bit range, about 292 years either way.
333
+
334
+ ```ruby
335
+ SOML::Duration.new(nanoseconds: 1_500_000_000)
336
+ #=> #<SOML::Duration 1.5s>
337
+ ```
338
+
339
+ #### SOML::Duration.parse(text)
340
+
341
+ Read a duration written as in a document. Raises `SOML::ParseError` when it is not one.
342
+
343
+ ```ruby
344
+ SOML::Duration.parse('1.5h')
345
+ #=> #<SOML::Duration 1h30m>
346
+ ```
347
+
348
+ #### SOML::Duration.from_seconds(seconds)
349
+
350
+ 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.
351
+
352
+ ```ruby
353
+ SOML::Duration.from_seconds(90)
354
+ #=> #<SOML::Duration 1m30s>
355
+
356
+ SOML::Duration.from_seconds(0.1)
357
+ #=> #<SOML::Duration 0.1s>
358
+ ```
359
+
360
+ #### #nanoseconds
361
+
362
+ The length in nanoseconds, an `Integer`.
363
+
364
+ #### #+(other) and #-(other)
365
+
366
+ The sum or the difference of two durations. A number has no unit, so `duration + 5` is a `TypeError`. Raises `RangeError` when the result is outside the 64-bit range.
367
+
368
+ ```ruby
369
+ SOML::Duration.parse('1h') + SOML::Duration.parse('30m')
370
+ #=> #<SOML::Duration 1h30m>
371
+ ```
372
+
373
+ #### #-@ and #abs
374
+
375
+ The opposite duration, and the duration without its sign.
376
+
377
+ #### #zero?, #positive?, and #negative?
378
+
379
+ Whether the length is zero, more than zero, or less than zero.
380
+
381
+ #### #to_r
382
+
383
+ The length in seconds, exactly, as a `Rational`.
384
+
385
+ #### #to_f
386
+
387
+ The length in seconds as a `Float`, for `sleep` and `Timeout.timeout`.
388
+
389
+ #### #to_s
390
+
391
+ The canonical spelling, such as `1h30m`, `1.5s`, `-0.25s`, or `0s`. So `90m` is written `1h30m`, and `-250ms` is written `-0.25s`.
392
+
393
+ ### SOML::ParseError
394
+
395
+ Raised when the input is not a valid document. Subclass of `SOML::Error`, which is a `StandardError`.
396
+
397
+ The `message` is the reason and the position, on one line, so that it reads well in a log. The `detailed_message`, which Ruby shows for an uncaught error, also has a code frame:
398
+
399
+ ```ruby
400
+ SOML.parse("name: 'api'\nreplicas: three")
401
+ # …: Unexpected “three”. A string value must be quoted, as in 'three' at line 2, column 11 (SOML::ParseError)
402
+ #
403
+ # 1 | name: 'api'
404
+ # > 2 | replicas: three
405
+ # | ^
406
+ ```
407
+
408
+ #### reason
409
+
410
+ What is wrong, without the position.
411
+
412
+ #### line
413
+
414
+ The 1-based line.
415
+
416
+ #### column
417
+
418
+ The 1-based column, counted in characters.
419
+
420
+ #### offset
421
+
422
+ The 0-based character index in the source read as UTF-8, so for a UTF-8 source, `source[error.offset]` is the character at the error.
423
+
424
+ #### path
425
+
426
+ The file, when the document was read by `SOML.load_file`, and otherwise `nil`.
427
+
428
+ #### code_frame
429
+
430
+ Up to three lines ending at the error, with a caret under the position. A long line is clipped around the position, and characters that a terminal would act on are replaced.
431
+
432
+ ### SOML::GenerateError
433
+
434
+ Raised by `SOML.generate` for a value that cannot be written. Subclass of `SOML::Error`.
435
+
436
+ #### reason
437
+
438
+ What is wrong, without the location.
439
+
440
+ #### path
441
+
442
+ The keys and indexes that lead to the value, such as `["servers", 0, "ratio"]`, or an empty array for the top-level value.
443
+
444
+ ## Recipes
445
+
446
+ ### Hash a config
447
+
448
+ Equal values give equal canonical text, so hash it to compare, cache, or sign a config. Comments, the order of members, and the spelling of values do not change the hash:
449
+
450
+ ```ruby
451
+ require 'digest'
452
+
453
+ Digest::SHA256.hexdigest(SOML.generate(SOML.parse("b: 1\na: 0x10 # Note."), canonical: true))
454
+ #=> the same as for SOML.parse("a: 16\nb: 1")
455
+ ```
456
+
457
+ ### Compare two configs
458
+
459
+ Canonical form has one sorted member on each line, so a line diff shows every change, including a change of type, which `==` misses, because `3 == 3.0` in Ruby:
460
+
461
+ ```ruby
462
+ before = SOML.generate(SOML.parse("a: 3\nb: 1"), canonical: true)
463
+ after = SOML.generate(SOML.parse("a: 3.0\nb: 1"), canonical: true)
464
+ before.lines - after.lines
465
+ #=> ["a: 3\n"]
466
+ ```
467
+
468
+ ### Check the shape of a config
469
+
470
+ Pattern matching checks the type of each member that a program reads, and binds it to a variable, with no schema library. A config that does not match raises an error that names the member:
471
+
472
+ ```ruby
473
+ config = SOML.load_file('config.soml', symbolize_names: true)
474
+ config => {name: String => name, port: Integer => port, timeout: SOML::Duration => timeout}
475
+ ```
476
+
477
+ For `port: '8080'`, this raises `NoMatchingPatternError` with `Integer === "8080" does not return true`, and without a `port`, it raises `NoMatchingPatternKeyError` with `key not found: :port`.
478
+
479
+ ### Read a config in Rails
480
+
481
+ `config_for` reads YAML only, so read the file in an initializer. With one top-level object for each environment, this works as `config_for` does:
482
+
483
+ ```
484
+ # config/payments.soml
485
+ production: {
486
+ timeout: 30s
487
+ endpoint: 'https://payments.example.com'
488
+ }
489
+ development: {
490
+ timeout: 5m
491
+ endpoint: 'http://localhost:4000'
492
+ }
493
+ ```
494
+
495
+ ```ruby
496
+ # config/initializers/payments.rb
497
+ Rails.application.config.x.payments = SOML.load_file(Rails.root.join('config/payments.soml'), symbolize_names: true, freeze: true).fetch(Rails.env.to_sym)
498
+ ```
499
+
500
+ An instant is a `Time` in UTC, and `in_time_zone` gives it in the zone of the app. A duration is a `SOML::Duration`, and `to_r.seconds` gives an `ActiveSupport::Duration` of the same exact length:
501
+
502
+ ```ruby
503
+ Rails.configuration.x.payments[:timeout].to_r.seconds == 30.seconds
504
+ #=> true
505
+ ```
506
+
507
+ To write an `ActiveSupport::Duration`, convert it with `SOML::Duration.from_seconds`:
508
+
509
+ ```ruby
510
+ SOML.generate({timeout: SOML::Duration.from_seconds(5.minutes.to_r)})
511
+ #=> "timeout: 5m\n"
512
+ ```
513
+
514
+ ## Benchmark
515
+
516
+ The time to read and write each of 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, in CPU time on an Apple M-series machine with Ruby 4.0 and YJIT, which this gem is written for. The machine was busy, so the numbers are rough. Lower is better.
517
+
518
+ | | this gem | `json` | `oj` | `toml-rb` |
519
+ |---|---|---|---|---|
520
+ | Read `service` (3 KB) | 78 µs | 10 µs | 11 µs | 174 µs |
521
+ | Read `platform` (35 KB) | 0.61 ms | 0.10 ms | 0.07 ms | 1.2 ms |
522
+ | Read `earthquakes` (733 KB) | 14 ms | 2.2 ms | 2.8 ms | 51 ms |
523
+ | Write `service` | 51 µs | 8 µs | 8 µs | 105 µs |
524
+ | Write `platform` | 0.34 ms | 0.06 ms | 0.03 ms | 0.65 ms |
525
+ | Write `earthquakes` | 15 ms | 2.1 ms | 3.2 ms | 26 ms |
526
+
527
+ `json` and `oj` are C extensions, and `toml-rb` is pure Ruby, like this gem. The script also compares `tomlib`, `perfect_toml`, and `tomlrb`, and times `SOML.format` and `SOML.edit` on each fixture:
528
+
529
+ ```sh
530
+ bundle config set --local with benchmark
531
+ bundle install
532
+ bundle exec ruby --yjit bench/benchmark.rb
533
+ ```
534
+
535
+ ## Limits
536
+
537
+ - **Nesting is limited to 100 levels**, as the specification requires, in reading and in writing. Every array and object is one level, including the document's own collection.
538
+ - **The gem name.** The name `soml` on RubyGems belongs to an unrelated format, which also defines `module SOML`, so the two gems cannot be loaded in one process.
539
+ - **The syntax tree is not public yet.** `SOML.format` and `SOML.edit` use one, and the [JavaScript](https://github.com/soml-lang/soml-javascript), [Python](https://github.com/soml-lang/soml-python), and [Rust](https://github.com/soml-lang/soml-rust) implementations have a public one, for tools such as linters.
540
+
541
+ ## Development
542
+
543
+ ```sh
544
+ bundle install
545
+ bundle exec rake
546
+ ```
547
+
548
+ `SOML_EXAMPLES=10000 bundle exec rake` runs the property tests with more examples, and `SOML_SEED=1234` repeats a run.
549
+
550
+ The fixtures in [`bench/fixtures`](bench/fixtures) are a copy of the shared ones in the [`soml`](https://github.com/soml-lang/soml/tree/main/benchmark) spec repository. To update them, check out `soml` next to this repository and run `rsync --archive --delete ../soml/benchmark/ bench/fixtures/`.
551
+
552
+ The conformance suite in [`test/conformance`](test/conformance) is a copy of the one in the [`soml`](https://github.com/soml-lang/soml/tree/main/conformance) spec repository. To update it, check out `soml` next to this repository and run `../soml/sync-conformance.sh test/conformance`.
data/sig/soml.rbs ADDED
@@ -0,0 +1,143 @@
1
+ # Read and write SOML, a config format for humans.
2
+ module SOML
3
+ VERSION: String
4
+
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]
7
+
8
+ # A whole document, which is always an object or an array.
9
+ type document = Hash[String | Symbol, value] | Array[value]
10
+
11
+ # Parse a document.
12
+ def self.parse: (string source, ?symbolize_names: boolish, ?freeze: boolish) -> document
13
+
14
+ # Read and parse a file.
15
+ def self.load_file: (path path, ?symbolize_names: boolish, ?freeze: boolish) -> document
16
+
17
+ # Write a hash or an array as SOML, keeping the member order of each hash, or as canonical form with `canonical: true`.
18
+ def self.generate: (Hash[untyped, untyped] | Array[untyped] value, ?canonical: boolish) -> String
19
+
20
+ # Format a document, keeping its comments, member order, and spelling.
21
+ def self.format: (string source) -> String
22
+
23
+ # A key in an object or an index in an array.
24
+ type path_segment = String | Symbol | Integer
25
+
26
+ # Change one value in a document, and keep everything else as it is written.
27
+ def self.edit: (string source, Array[path_segment] path, untyped value, ?delete: false) -> String
28
+ | (string source, Array[path_segment] path, delete: true) -> String
29
+
30
+ # Whether a key can be written without quotes, as a bare key. A key is a `String` or a `Symbol`, so anything else gives false.
31
+ def self.bare_key?: (untyped key) -> bool
32
+
33
+ # The base class of the errors this library raises.
34
+ class Error < StandardError
35
+ end
36
+
37
+ # Raised when the input is not a valid document.
38
+ class ParseError < Error
39
+ # What is wrong, without the position.
40
+ attr_reader reason: String
41
+
42
+ # The 1-based line of the error.
43
+ attr_reader line: Integer
44
+
45
+ # The 1-based column of the error, counted in characters.
46
+ attr_reader column: Integer
47
+
48
+ # The 0-based character index of the error in the source.
49
+ attr_reader offset: Integer
50
+
51
+ # The file the document is from, when it was read by `load_file`.
52
+ attr_reader path: String?
53
+
54
+ # Only the parser makes a `ParseError`. `byte_offset` is where the error is in `source`, in bytes.
55
+ def initialize: (String reason, String source, Integer byte_offset, ?String? path) -> void
56
+
57
+ # Up to three lines ending at the error, with a caret under the position.
58
+ def code_frame: () -> String
59
+
60
+ def detailed_message: (?highlight: boolish, **untyped) -> String
61
+ end
62
+
63
+ # Raised by `generate` for a value that cannot be written.
64
+ class GenerateError < Error
65
+ # What is wrong, without the location.
66
+ attr_reader reason: String
67
+
68
+ # The keys and indexes that lead to the value.
69
+ attr_reader path: Array[String | Integer]
70
+
71
+ # Only the generator makes a `GenerateError`.
72
+ def initialize: (String reason) -> void
73
+
74
+ # The reason, and the path to the value when it is not the top-level value.
75
+ def to_s: () -> String
76
+ end
77
+
78
+ # A length of time, exact to the nanosecond.
79
+ class Duration < Data
80
+ include Comparable
81
+
82
+ # Read a duration written as in a document, such as `1h30m`.
83
+ def self.parse: (string text) -> Duration
84
+
85
+ # Make a duration from a number of seconds, rounded to the nearest nanosecond.
86
+ def self.from_seconds: (Numeric seconds) -> Duration
87
+
88
+ def self.new: (nanoseconds: Integer) -> Duration
89
+ | (Integer nanoseconds) -> Duration
90
+
91
+ def self.[]: (nanoseconds: Integer) -> Duration
92
+ | (Integer nanoseconds) -> Duration
93
+
94
+ def self.members: () -> [:nanoseconds]
95
+
96
+ # The length in nanoseconds.
97
+ attr_reader nanoseconds: Integer
98
+
99
+ def initialize: (nanoseconds: Integer) -> void
100
+
101
+ def <=>: (untyped other) -> Integer?
102
+
103
+ def ==: (untyped other) -> bool
104
+
105
+ def eql?: (untyped other) -> bool
106
+
107
+ def hash: () -> Integer
108
+
109
+ def zero?: () -> bool
110
+
111
+ def positive?: () -> bool
112
+
113
+ def negative?: () -> bool
114
+
115
+ # The same length in the other direction.
116
+ def -@: () -> Duration
117
+
118
+ def abs: () -> Duration
119
+
120
+ def +: (Duration other) -> Duration
121
+
122
+ def -: (Duration other) -> Duration
123
+
124
+ # The length in seconds, exactly.
125
+ def to_r: () -> Rational
126
+
127
+ # The length in seconds.
128
+ def to_f: () -> Float
129
+
130
+ # The canonical spelling, such as `1h30m`.
131
+ def to_s: () -> String
132
+
133
+ def inspect: () -> String
134
+
135
+ def to_h: () -> { nanoseconds: Integer }
136
+
137
+ def with: (?nanoseconds: Integer) -> Duration
138
+
139
+ def deconstruct: () -> [Integer]
140
+
141
+ def deconstruct_keys: (Array[Symbol]? keys) -> { nanoseconds: Integer }
142
+ end
143
+ end