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.
- checksums.yaml +7 -0
- data/lib/soml/duration.rb +167 -0
- data/lib/soml/edit.rb +468 -0
- data/lib/soml/error.rb +171 -0
- data/lib/soml/format.rb +331 -0
- data/lib/soml/generator.rb +364 -0
- data/lib/soml/parser.rb +1856 -0
- data/lib/soml/tree.rb +239 -0
- data/lib/soml/version.rb +6 -0
- data/lib/soml-lang.rb +4 -0
- data/lib/soml.rb +226 -0
- data/license +9 -0
- data/readme.md +552 -0
- data/sig/soml.rbs +143 -0
- metadata +58 -0
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
|