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.
data/readme.md CHANGED
@@ -7,12 +7,12 @@
7
7
 
8
8
  ## Highlights
9
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
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` 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`
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 turn each line feed into a CRLF, which SOML does not allow:
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
- An int is an `Integer` and a float is a `Float` in both directions, so `3` and `3.0` keep their types:
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 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.
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
- With this mapping, two things always hold:
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`, and `SOML.generate(SOML.parse(canonical), canonical: true) == canonical`.
114
+ - `SOML.generate(SOML.parse(text), canonical: true)` is the canonical form of `text`.
111
115
 
112
- An instant is a `Time` in UTC, because the offset is how the instant was written, not part of its value:
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`, because a document is always a collection.
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, 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.
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, and `TypeError` when `source` is not a string.
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, which a document may not contain.
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 at the position:
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 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.
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`, because a document is a collection
183
- - `Float::NAN`, which SOML does not have (use `nil` for a missing value)
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 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'`
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 `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`
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. Canonical form is unique for a value: two equal values give the same text, so it can be hashed, signed, or compared.
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 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.
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, as the specification states:
231
+ In detail:
227
232
 
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.
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 the spelling of every other value. For tools that update a config file, such as a dependency bumper or a `set` command.
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 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.
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, 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}]`.
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, 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]`.
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 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.
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 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.
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 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
+ - `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
- 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.
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 | `json` | `oj` | `toml-rb` |
523
+ | | this gem | `toml-rb` | `json` | `oj` |
519
524
  |---|---|---|---|---|
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 |
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
- `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:
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 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.
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
@@ -0,0 +1,2 @@
1
+ dependencies:
2
+ - name: date
data/sig/soml.rbs CHANGED
@@ -3,7 +3,7 @@ module SOML
3
3
  VERSION: String
4
4
 
5
5
  # A value in a document, as `parse` returns it.
6
- type value = String | Integer | Float | bool | nil | Time | Duration | Array[value] | Hash[String | Symbol, value]
6
+ type value = String | Integer | Float | bool | nil | Date | Time | Duration | Array[value] | Hash[String | Symbol, value]
7
7
 
8
8
  # A whole document, which is always an object or an array.
9
9
  type document = Hash[String | Symbol, value] | Array[value]
@@ -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, Array[path_segment] path, untyped value, ?delete: false) -> String
28
- | (string source, Array[path_segment] path, delete: true) -> String
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.1
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 message
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: