soml-lang 0.0.1 → 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 +17 -39
- data/lib/soml/edit.rb +42 -23
- data/lib/soml/error.rb +27 -17
- data/lib/soml/format.rb +17 -4
- data/lib/soml/generator.rb +59 -29
- data/lib/soml/parser.rb +221 -215
- data/lib/soml/tree.rb +198 -64
- data/lib/soml/version.rb +1 -1
- data/lib/soml.rb +54 -44
- data/readme.md +123 -62
- data/sig/manifest.yaml +2 -0
- data/sig/soml.rbs +100 -4
- metadata +6 -3
data/readme.md
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
|
|
8
8
|
## Highlights
|
|
9
9
|
|
|
10
|
-
- Strict: every rule in the specification
|
|
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
|
|
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`, 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
|
+
- 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
|
-
- Lossless: `SOML.format`
|
|
15
|
-
- Safe: linear time and memory on every input
|
|
14
|
+
- Lossless: `SOML.format` and `SOML.edit` keep comments, member order, and spelling
|
|
15
|
+
- Safe: linear time and memory on every input, no stack overflow on deep input, and every document error is a `SOML::ParseError`
|
|
16
16
|
- Ractor-safe, with no dependencies
|
|
17
17
|
|
|
18
18
|
## Install
|
|
@@ -27,7 +27,7 @@ Or in a `Gemfile`:
|
|
|
27
27
|
gem 'soml-lang'
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
Requires Ruby 4.0 or later.
|
|
30
|
+
[On RubyGems](https://rubygems.org/gems/soml-lang). Requires Ruby 4.0 or later.
|
|
31
31
|
|
|
32
32
|
## Usage
|
|
33
33
|
|
|
@@ -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'
|
|
@@ -71,7 +74,7 @@ config[:postgres][:host]
|
|
|
71
74
|
#=> "db.internal"
|
|
72
75
|
```
|
|
73
76
|
|
|
74
|
-
Write a file in binary mode, so that Windows does not
|
|
77
|
+
Write a file in binary mode, so that Windows does not write CRLF, which SOML does not allow:
|
|
75
78
|
|
|
76
79
|
```ruby
|
|
77
80
|
File.binwrite('config.soml', SOML.generate(config))
|
|
@@ -86,12 +89,13 @@ 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` | |
|
|
92
96
|
| object | `Hash` with `String` keys, in document order, or `Symbol` keys with `symbolize_names: true` | `Symbol` keys |
|
|
93
97
|
|
|
94
|
-
|
|
98
|
+
`3` and `3.0` keep their types in both directions:
|
|
95
99
|
|
|
96
100
|
```ruby
|
|
97
101
|
SOML.parse("a: 3\nb: 3.0")
|
|
@@ -102,14 +106,16 @@ SOML.generate({a: 3, b: 3.0})
|
|
|
102
106
|
```
|
|
103
107
|
|
|
104
108
|
> [!IMPORTANT]
|
|
105
|
-
> Ruby
|
|
109
|
+
> In Ruby, `3 == 3.0`, so `SOML.parse('a: 3') == SOML.parse('a: 3.0')` is true. Use `eql?` to also compare the types.
|
|
106
110
|
|
|
107
|
-
|
|
111
|
+
Two things always hold:
|
|
108
112
|
|
|
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
|
-
- `SOML.generate(SOML.parse(text), canonical: true)` is the canonical form of `text
|
|
114
|
+
- `SOML.generate(SOML.parse(text), canonical: true)` is the canonical form of `text`.
|
|
111
115
|
|
|
112
|
-
|
|
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
|
+
|
|
118
|
+
An instant is a `Time` in UTC, because the offset is not part of its value:
|
|
113
119
|
|
|
114
120
|
```ruby
|
|
115
121
|
SOML.parse('a: 2026-09-19T14:00:00.123456789+02:00')['a']
|
|
@@ -120,11 +126,11 @@ SOML.parse('a: 2026-09-19T14:00:00.123456789+02:00')['a']
|
|
|
120
126
|
|
|
121
127
|
### SOML.parse(source, symbolize_names: false, freeze: false)
|
|
122
128
|
|
|
123
|
-
Parse a document. Returns a `Hash` or an `Array
|
|
129
|
+
Parse a document. Returns a `Hash` or an `Array`.
|
|
124
130
|
|
|
125
|
-
`source` is a `String`, or an object with `to_str`. A UTF-8, binary, or US-ASCII string is read as UTF-8
|
|
131
|
+
`source` is a `String`, or an object with `to_str`. A UTF-8, binary, or US-ASCII string is read as UTF-8. A string in another encoding, such as UTF-16, is converted to UTF-8 first, as `JSON.parse` does.
|
|
126
132
|
|
|
127
|
-
Raises `SOML::ParseError` when the document is not valid,
|
|
133
|
+
Raises `SOML::ParseError` when the document is not valid (including invalid UTF-8), `TypeError` when `source` is not a string, and `EncodingError` when it does not convert to UTF-8.
|
|
128
134
|
|
|
129
135
|
#### symbolize_names
|
|
130
136
|
|
|
@@ -157,9 +163,9 @@ Ractor.shareable?(config)
|
|
|
157
163
|
|
|
158
164
|
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
165
|
|
|
160
|
-
The file is read in binary mode, so that a text-mode conversion cannot hide a carriage return
|
|
166
|
+
The file is read in binary mode, so that a text-mode conversion cannot hide a carriage return.
|
|
161
167
|
|
|
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
|
|
168
|
+
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:
|
|
163
169
|
|
|
164
170
|
```ruby
|
|
165
171
|
SOML.load_file('config.soml')
|
|
@@ -168,9 +174,7 @@ SOML.load_file('config.soml')
|
|
|
168
174
|
|
|
169
175
|
### SOML.generate(value, canonical: false)
|
|
170
176
|
|
|
171
|
-
Write a `Hash` or an `Array` as SOML. Members keep the order of their hash, nesting
|
|
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.
|
|
177
|
+
Write a `Hash` or an `Array` as SOML. Members keep the order of their hash, nesting uses braces and tabs, and the text ends with one line feed. Everything but the member order follows canonical form. Use `canonical: true` when the text is hashed, signed, or compared.
|
|
174
178
|
|
|
175
179
|
```ruby
|
|
176
180
|
SOML.generate({name: 'api', ports: [80, 443], description: 'The edge service'})
|
|
@@ -179,14 +183,15 @@ SOML.generate({name: 'api', ports: [80, 443], description: 'The edge service'})
|
|
|
179
183
|
|
|
180
184
|
Raises `SOML::GenerateError` for a value that cannot be written:
|
|
181
185
|
|
|
182
|
-
- a top-level value that is not a `Hash` or an `Array
|
|
183
|
-
- `Float::NAN
|
|
186
|
+
- a top-level value that is not a `Hash` or an `Array`
|
|
187
|
+
- `Float::NAN` (use `nil` for a missing value)
|
|
184
188
|
- an `Integer` outside the 64-bit range
|
|
185
|
-
- a `Time` outside the years 0001 to 9999 in UTC, or
|
|
186
|
-
- a
|
|
187
|
-
- a
|
|
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`
|
|
191
|
+
- a string or key with a carriage return, or that is not valid UTF-8 and does not convert to it
|
|
192
|
+
- a key that is not a `String` or a `Symbol`, and two keys that are the same string, such as `:a` and `'a'`
|
|
188
193
|
- a circular reference, and nesting deeper than 100 levels
|
|
189
|
-
- 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`.
|
|
190
195
|
|
|
191
196
|
The message says where the value is:
|
|
192
197
|
|
|
@@ -200,7 +205,7 @@ SOML.generate({servers: [{ratio: Float::NAN}]})
|
|
|
200
205
|
Type: `Boolean`\
|
|
201
206
|
Default: `false`
|
|
202
207
|
|
|
203
|
-
Whether to write canonical form, with the members of each object sorted by key.
|
|
208
|
+
Whether to write canonical form, with the members of each object sorted by key. Two equal values give the same canonical text.
|
|
204
209
|
|
|
205
210
|
```ruby
|
|
206
211
|
SOML.generate({name: 'api', ports: [80, 443], description: 'The edge service'}, canonical: true)
|
|
@@ -211,7 +216,7 @@ SOML.generate({name: 'api', ports: [80, 443], description: 'The edge service'},
|
|
|
211
216
|
|
|
212
217
|
Format a document. Returns the document with its layout normalized, ending with one line feed.
|
|
213
218
|
|
|
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
|
|
219
|
+
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 on one line stays on one line, as in `ports: [80, 443]`. Put a line break anywhere inside it to split it. Comments, member order, block strings, and the spelling of every value stay as they are.
|
|
215
220
|
|
|
216
221
|
`source` is read as `SOML.parse` reads it, with the same errors.
|
|
217
222
|
|
|
@@ -223,19 +228,19 @@ SOML.format("pool: {\nmin: 2, max: 16}")
|
|
|
223
228
|
#=> "pool: {\n\tmin: 2\n\tmax: 16\n}\n"
|
|
224
229
|
```
|
|
225
230
|
|
|
226
|
-
In detail
|
|
231
|
+
In detail:
|
|
227
232
|
|
|
228
|
-
- A block comment
|
|
229
|
-
- One space follows each
|
|
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
|
|
231
|
-
- A block string begins on the line after its key,
|
|
233
|
+
- A block comment before a value on the same line, as in `1, /* note */ 2`, stays in front of that value.
|
|
234
|
+
- One space follows each `:`.
|
|
235
|
+
- 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.
|
|
236
|
+
- A block string begins on the line after its key, indented to that line. Its lines are not otherwise changed.
|
|
232
237
|
- There are no blank lines at the start of the file or directly inside brackets.
|
|
233
238
|
|
|
234
|
-
### SOML.edit(source, path, value)
|
|
239
|
+
### SOML.edit(source, path, value) and SOML.edit(source, path, delete: true)
|
|
235
240
|
|
|
236
|
-
Change one value in a document, and keep everything else as it is written: comments, member order, layout, and
|
|
241
|
+
Change one value in a document, and keep everything else as it is written: comments, member order, layout, and spelling. For tools that update a config file, such as a dependency bumper or a `set` command.
|
|
237
242
|
|
|
238
|
-
The value at `path` is replaced, or added when it does not exist
|
|
243
|
+
The value at `path` is replaced, or added when it does not exist. `delete: true` removes it instead. Only the changed part of the text is rewritten, so a formatted document stays formatted.
|
|
239
244
|
|
|
240
245
|
```ruby
|
|
241
246
|
SOML.edit("name: 'api' # The service\nport: 8080\n", ['port'], 9090)
|
|
@@ -254,28 +259,28 @@ Read and write the file in binary mode, as for `SOML.generate`:
|
|
|
254
259
|
File.binwrite('config.soml', SOML.edit(File.binread('config.soml'), ['postgres', 'port'], 5433))
|
|
255
260
|
```
|
|
256
261
|
|
|
257
|
-
- A new value is written as `SOML.generate` writes it
|
|
262
|
+
- A new value is written as `SOML.generate` writes it (keeping member order), at the indentation of its line. So `0xFF` replaced by `255` is written `255`. In a one-line container, it is written on one line too, so `[1, 2]` with a new item `{a: 3}` becomes `[1, 2, {a: 3}]`.
|
|
258
263
|
- 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,
|
|
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)
|
|
261
|
-
- A removed member or item takes the comments it owns, which
|
|
262
|
-
- A removed member or item takes its comma with it. When
|
|
264
|
+
- A new item can be added at the end of an array, at the index that is its length.
|
|
265
|
+
- A new member or item goes on its own line without a comma, after the comments that the one before it owns (see below). The commas of the others stay as they are. When something follows the one before it on the same line, such as the `}` of `{a: 1}`, it goes on that line after a comma, as in `{a: 1, b: 2}`. In an empty `[]` or `{}`, it goes on its own line, unless that container is inside a one-line container, so `a: [1, []]` becomes `a: [1, [2]]`.
|
|
266
|
+
- A removed member or item takes the comments it owns, which are the ones `SOML.format` keeps with it: the comments after it on its line (also after its comma), and the block comments before it on its line. So removing `2` from `[1, /* note */ 2]` gives `[1]`. A comment on a line of its own stays. Its lines are removed when nothing else is on them, including a line that a block comment after it continues onto. Removing every item of a container closes it up to `[]` or `{}`, unless a comment is left inside.
|
|
267
|
+
- A removed member or item takes its comma with it. When it is the last one without a comma after it, it takes the comma directly before it instead, so `[1, 2]` becomes `[1]`.
|
|
263
268
|
- Removing the only member of a document without braces leaves `{}`.
|
|
264
269
|
|
|
265
|
-
Comments inside a
|
|
270
|
+
Comments inside a replaced or removed value 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.
|
|
266
271
|
|
|
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
|
|
272
|
+
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 it is safe to remove the same path twice. Use `SOML.edit(source, path, delete: true).b == source.b` to tell whether something was removed. The `.b` is needed because a source read in binary mode is not `==` to the UTF-8 result when it has non-ASCII characters.
|
|
268
273
|
|
|
269
274
|
The result is parsed before it is returned, so a bug in `SOML.edit` raises a `RuntimeError` rather than returning a broken document.
|
|
270
275
|
|
|
271
276
|
Raises:
|
|
272
277
|
|
|
273
278
|
- `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
|
|
275
|
-
- `IndexError` when a value is set at an index past the end of its array, or at an index under a value
|
|
276
|
-
- `SOML::GenerateError` for a value or a key that `SOML.generate` cannot write, and when the change would nest
|
|
277
|
-
- `ArgumentError` when both a value and `delete: true` are given, or neither
|
|
278
|
-
- `EncodingError`
|
|
279
|
+
- `TypeError` when `source` is not a string, when `path` is not a member node, a value node, or a non-empty array of keys and indexes, when it leads through a scalar, or when it has a key where an array is or an index where an object is
|
|
280
|
+
- `IndexError` when a value is set at an index past the end of its array, or at an index under a missing value
|
|
281
|
+
- `SOML::GenerateError` for a value or a key that `SOML.generate` cannot write, and when the change would nest more than 100 levels deep. Its path is the place in the document.
|
|
282
|
+
- `ArgumentError` when both a value and `delete: true` are given, or neither, or when `path` is the root node or a node that is not in `source`
|
|
283
|
+
- `EncodingError` as for `SOML.parse`
|
|
279
284
|
|
|
280
285
|
```ruby
|
|
281
286
|
SOML.edit('ports: [80]', ['ports', 5], 443)
|
|
@@ -284,9 +289,9 @@ SOML.edit('ports: [80]', ['ports', 5], 443)
|
|
|
284
289
|
|
|
285
290
|
#### path
|
|
286
291
|
|
|
287
|
-
Type: `Array<String | Symbol | Integer
|
|
292
|
+
Type: `Array<String | Symbol | Integer>`, or a node from `SOML.parse_tree`
|
|
288
293
|
|
|
289
|
-
The keys and array indexes that lead to the value, such as `['servers', 0, 'port']
|
|
294
|
+
The keys and array indexes that lead to the value, such as `['servers', 0, 'port']`, or a member or value node from `SOML.parse_tree(source)` (see [Autofixes](#autofixes)).
|
|
290
295
|
|
|
291
296
|
#### value
|
|
292
297
|
|
|
@@ -347,7 +352,7 @@ SOML::Duration.parse('1.5h')
|
|
|
347
352
|
|
|
348
353
|
#### SOML::Duration.from_seconds(seconds)
|
|
349
354
|
|
|
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.
|
|
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.
|
|
351
356
|
|
|
352
357
|
```ruby
|
|
353
358
|
SOML::Duration.from_seconds(90)
|
|
@@ -513,18 +518,18 @@ SOML.generate({timeout: SOML::Duration.from_seconds(5.minutes.to_r)})
|
|
|
513
518
|
|
|
514
519
|
## Benchmark
|
|
515
520
|
|
|
516
|
-
|
|
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.
|
|
517
522
|
|
|
518
|
-
| | this gem | `
|
|
523
|
+
| | this gem | `toml-rb` | `json` | `oj` |
|
|
519
524
|
|---|---|---|---|---|
|
|
520
|
-
| Read `service` (3 KB) |
|
|
521
|
-
| Read `platform` (35 KB) | 0.
|
|
522
|
-
| Read `earthquakes` (733 KB) |
|
|
523
|
-
| Write `service` |
|
|
524
|
-
| Write `platform` | 0.
|
|
525
|
-
| 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 |
|
|
526
531
|
|
|
527
|
-
`
|
|
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:
|
|
528
533
|
|
|
529
534
|
```sh
|
|
530
535
|
bundle config set --local with benchmark
|
|
@@ -532,11 +537,67 @@ bundle install
|
|
|
532
537
|
bundle exec ruby --yjit bench/benchmark.rb
|
|
533
538
|
```
|
|
534
539
|
|
|
540
|
+
## Syntax tree
|
|
541
|
+
|
|
542
|
+
`SOML.parse_tree(source)` returns a deeply frozen `SOML::Tree` with `source` (a UTF-8 copy), `body` (an object or array), `tokens`, and `comments`. It accepts the same inputs and raises the same errors as `SOML.parse`, without freezing the caller's string. Only valid documents are supported; there is no recovery from incomplete editor input.
|
|
543
|
+
|
|
544
|
+
```ruby
|
|
545
|
+
tree = SOML.parse_tree('port: 0x1F90 # Default')
|
|
546
|
+
node = tree.body.members.first.value
|
|
547
|
+
|
|
548
|
+
node.kind #=> :integer
|
|
549
|
+
node.value #=> 8080
|
|
550
|
+
node.radix #=> 16
|
|
551
|
+
tree.source.byteslice(node.start...node.finish) #=> "0x1F90"
|
|
552
|
+
tree.position(node.start) #=> [1, 7]
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
Every node, token, and comment has `start` and exclusive `finish` UTF-8 byte offsets into `tree.source`. Use `byteslice` to read spans. Collections include their brackets; a bare root object spans its first key through its last value. `position(byte_offset)` returns one-based `[line, column]`, counting Unicode characters, including tabs as one character. It accepts character boundaries through EOF, raising `TypeError` for non-integers and `ArgumentError` for invalid offsets. Unlike tree spans, `ParseError#offset` counts characters.
|
|
556
|
+
|
|
557
|
+
`each_node` yields the body and its descendants in source order, parents before children and member keys before values; without a block it returns an Enumerator. Tokens and comments are separate, each in source order. Node constructors are internal.
|
|
558
|
+
|
|
559
|
+
| Class under `SOML::Tree` | `type` | Fields besides `start` and `finish` |
|
|
560
|
+
|---|---|---|
|
|
561
|
+
| `ObjectNode` | `:object` | `members`, `braced` |
|
|
562
|
+
| `ArrayNode` | `:array` | `elements` |
|
|
563
|
+
| `MemberNode` | `:member` | `key`, `value` (a node) |
|
|
564
|
+
| `KeyNode` | `:key` | `value` (decoded string), `style` (`:bare`, `:literal`, or `:escaped`) |
|
|
565
|
+
| `ScalarNode` | `:scalar` | `kind`, `value` (decoded), `block`, `style`, `radix` |
|
|
566
|
+
| `Comment` | `:line` or `:block` | `value` (text without delimiters) |
|
|
567
|
+
| `Token` | See below | `value` (original source text) |
|
|
568
|
+
|
|
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`.
|
|
570
|
+
|
|
571
|
+
### Syntax highlighting
|
|
572
|
+
|
|
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.
|
|
574
|
+
|
|
575
|
+
```ruby
|
|
576
|
+
(tree.tokens + tree.comments).sort_by(&:start).each do |token|
|
|
577
|
+
text = tree.source.byteslice(token.start...token.finish)
|
|
578
|
+
# Highlight text using token.type, preserving whitespace between spans.
|
|
579
|
+
end
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
Tokens never overlap and exclude whitespace and comments. When rendering HTML, escape all source text, including comments and gaps.
|
|
583
|
+
|
|
584
|
+
### Autofixes
|
|
585
|
+
|
|
586
|
+
`SOML.edit` accepts a member or non-root value node in place of a path, using its existing comment and layout rules. Members select their value for replacement and the whole member for deletion. Keys, tokens, comments, and the root collection are not accepted.
|
|
587
|
+
|
|
588
|
+
```ruby
|
|
589
|
+
source = "port: 0x1F90 # Default\n"
|
|
590
|
+
node = SOML.parse_tree(source).body.members.first.value
|
|
591
|
+
SOML.edit(source, node, 9090)
|
|
592
|
+
#=> "port: 9090 # Default\n"
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
Nodes must come from the same source text. Reparse after editing before selecting another node. For spelling fixes, apply non-overlapping span replacements from the end backwards to `tree.source.b`, restore UTF-8 with `force_encoding(Encoding::UTF_8)`, then validate with `SOML.parse`.
|
|
596
|
+
|
|
535
597
|
## Limits
|
|
536
598
|
|
|
537
599
|
- **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
|
|
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.
|
|
600
|
+
- **The gem name.** The gem is [`soml-lang`](https://rubygems.org/gems/soml-lang), because `soml` on RubyGems is an unrelated format. It also defines `module SOML`, so the two gems cannot be loaded together.
|
|
540
601
|
|
|
541
602
|
## Development
|
|
542
603
|
|
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]
|
|
@@ -17,15 +17,21 @@ module SOML
|
|
|
17
17
|
# Write a hash or an array as SOML, keeping the member order of each hash, or as canonical form with `canonical: true`.
|
|
18
18
|
def self.generate: (Hash[untyped, untyped] | Array[untyped] value, ?canonical: boolish) -> String
|
|
19
19
|
|
|
20
|
+
# Parse a document into a read-only syntax tree for linters and other tools.
|
|
21
|
+
def self.parse_tree: (string source) -> Tree
|
|
22
|
+
|
|
20
23
|
# Format a document, keeping its comments, member order, and spelling.
|
|
21
24
|
def self.format: (string source) -> String
|
|
22
25
|
|
|
23
26
|
# A key in an object or an index in an array.
|
|
24
27
|
type path_segment = String | Symbol | Integer
|
|
25
28
|
|
|
29
|
+
# What `edit` changes: the keys and indexes that lead to a value, or a member or a non-root value node from `parse_tree` of the same source.
|
|
30
|
+
type edit_target = Array[path_segment] | Tree::MemberNode | Tree::ObjectNode | Tree::ArrayNode | Tree::ScalarNode
|
|
31
|
+
|
|
26
32
|
# Change one value in a document, and keep everything else as it is written.
|
|
27
|
-
def self.edit: (string source,
|
|
28
|
-
| (string source,
|
|
33
|
+
def self.edit: (string source, edit_target path, untyped value, ?delete: false) -> String
|
|
34
|
+
| (string source, edit_target path, delete: true) -> String
|
|
29
35
|
|
|
30
36
|
# 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
37
|
def self.bare_key?: (untyped key) -> bool
|
|
@@ -82,7 +88,7 @@ module SOML
|
|
|
82
88
|
# Read a duration written as in a document, such as `1h30m`.
|
|
83
89
|
def self.parse: (string text) -> Duration
|
|
84
90
|
|
|
85
|
-
# 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.
|
|
86
92
|
def self.from_seconds: (Numeric seconds) -> Duration
|
|
87
93
|
|
|
88
94
|
def self.new: (nanoseconds: Integer) -> Duration
|
|
@@ -140,4 +146,94 @@ module SOML
|
|
|
140
146
|
|
|
141
147
|
def deconstruct_keys: (Array[Symbol]? keys) -> { nanoseconds: Integer }
|
|
142
148
|
end
|
|
149
|
+
|
|
150
|
+
# A read-only syntax tree, from `parse_tree`. Every offset is a UTF-8 byte offset into `source`, and `finish` is exclusive.
|
|
151
|
+
class Tree
|
|
152
|
+
# A node of the tree.
|
|
153
|
+
type node = ObjectNode | ArrayNode | MemberNode | KeyNode | ScalarNode
|
|
154
|
+
|
|
155
|
+
# A `# ...` comment or a `/* ... */` comment, with its text without the delimiters.
|
|
156
|
+
class Comment < Struct[untyped]
|
|
157
|
+
attr_reader type: :line | :block
|
|
158
|
+
attr_reader start: Integer
|
|
159
|
+
attr_reader finish: Integer
|
|
160
|
+
attr_reader value: String
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# A lexical token, with its source text.
|
|
164
|
+
class Token < Struct[untyped]
|
|
165
|
+
attr_reader type: :punctuator | :bare_key | :string | :integer | :float | :keyword | :date | :instant | :duration
|
|
166
|
+
attr_reader start: Integer
|
|
167
|
+
attr_reader finish: Integer
|
|
168
|
+
attr_reader value: String
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# An object, written with braces or, only at the top level, without them.
|
|
172
|
+
class ObjectNode < Struct[untyped]
|
|
173
|
+
attr_reader members: Array[MemberNode]
|
|
174
|
+
attr_reader braced: bool
|
|
175
|
+
attr_reader start: Integer
|
|
176
|
+
attr_reader finish: Integer
|
|
177
|
+
def type: () -> :object
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
class ArrayNode < Struct[untyped]
|
|
181
|
+
attr_reader elements: Array[ObjectNode | ArrayNode | ScalarNode]
|
|
182
|
+
attr_reader start: Integer
|
|
183
|
+
attr_reader finish: Integer
|
|
184
|
+
def type: () -> :array
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# A `key: value` member.
|
|
188
|
+
class MemberNode < Struct[untyped]
|
|
189
|
+
attr_reader key: KeyNode
|
|
190
|
+
attr_reader value: ObjectNode | ArrayNode | ScalarNode
|
|
191
|
+
attr_reader start: Integer
|
|
192
|
+
attr_reader finish: Integer
|
|
193
|
+
def type: () -> :member
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# A key, with its decoded string and how it is written.
|
|
197
|
+
class KeyNode < Struct[untyped]
|
|
198
|
+
attr_reader value: String
|
|
199
|
+
attr_reader start: Integer
|
|
200
|
+
attr_reader finish: Integer
|
|
201
|
+
attr_reader style: :bare | :literal | :escaped
|
|
202
|
+
def type: () -> :key
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# A value that is not a collection, with its decoded value and how it is written.
|
|
206
|
+
class ScalarNode < Struct[untyped]
|
|
207
|
+
attr_reader block: bool
|
|
208
|
+
attr_reader start: Integer
|
|
209
|
+
attr_reader finish: Integer
|
|
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
|
+
attr_reader style: (:literal | :escaped)?
|
|
213
|
+
attr_reader radix: (2 | 8 | 10 | 16)?
|
|
214
|
+
def type: () -> :scalar
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# The source, in UTF-8.
|
|
218
|
+
attr_reader source: String
|
|
219
|
+
|
|
220
|
+
# The document's collection.
|
|
221
|
+
attr_reader body: ObjectNode | ArrayNode
|
|
222
|
+
|
|
223
|
+
# Every comment, in source order.
|
|
224
|
+
attr_reader comments: Array[Comment]
|
|
225
|
+
|
|
226
|
+
# Every lexical token, in source order.
|
|
227
|
+
attr_reader tokens: Array[Token]
|
|
228
|
+
|
|
229
|
+
# `lean` is internal, for `format` and `edit`.
|
|
230
|
+
def initialize: (string source, ?lean: boolish) -> void
|
|
231
|
+
|
|
232
|
+
# Yield the body and all its descendants in source order.
|
|
233
|
+
def each_node: () { (node) -> void } -> self
|
|
234
|
+
| () -> Enumerator[node, self]
|
|
235
|
+
|
|
236
|
+
# The 1-based line and column of a byte offset in `source`.
|
|
237
|
+
def position: (Integer byte_offset) -> [Integer, Integer]
|
|
238
|
+
end
|
|
143
239
|
end
|
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,8 +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.
|
|
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.
|
|
15
16
|
email:
|
|
16
17
|
- sindresorhus@gmail.com
|
|
17
18
|
executables: []
|
|
@@ -30,6 +31,7 @@ files:
|
|
|
30
31
|
- lib/soml/version.rb
|
|
31
32
|
- license
|
|
32
33
|
- readme.md
|
|
34
|
+
- sig/manifest.yaml
|
|
33
35
|
- sig/soml.rbs
|
|
34
36
|
homepage: https://github.com/soml-lang/soml-ruby
|
|
35
37
|
licenses:
|
|
@@ -37,6 +39,7 @@ licenses:
|
|
|
37
39
|
metadata:
|
|
38
40
|
source_code_uri: https://github.com/soml-lang/soml-ruby
|
|
39
41
|
bug_tracker_uri: https://github.com/soml-lang/soml-ruby/issues
|
|
42
|
+
changelog_uri: https://github.com/soml-lang/soml-ruby/releases
|
|
40
43
|
rubygems_mfa_required: 'true'
|
|
41
44
|
rdoc_options: []
|
|
42
45
|
require_paths:
|