soml-lang 0.0.1 → 0.0.2
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 +14 -36
- data/lib/soml/edit.rb +42 -23
- data/lib/soml/error.rb +27 -17
- data/lib/soml/format.rb +14 -4
- data/lib/soml/generator.rb +33 -23
- data/lib/soml/parser.rb +172 -200
- data/lib/soml/tree.rb +195 -64
- data/lib/soml/version.rb +1 -1
- data/lib/soml.rb +49 -43
- data/readme.md +114 -60
- data/sig/soml.rbs +98 -2
- metadata +4 -2
data/lib/soml/tree.rb
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SOML
|
|
4
|
-
#
|
|
4
|
+
# A read-only syntax tree, returned by {SOML.parse_tree} and used by {SOML.format} and {SOML.edit}.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# @api private
|
|
6
|
+
# Every offset is a byte offset into the UTF-8 source, and `finish` is the offset after the node. Use {#position} for line and column. The document is checked by {Parser} first, so the builder can assume valid input. A tree from {SOML.parse_tree} is deeply frozen. Node constructors are internal. Tokens keep the source text of keys, scalar values, and punctuation, and comments are listed separately, in source order.
|
|
9
7
|
class Tree
|
|
10
8
|
# The nodes are structs rather than `Data`, whose `new` builds a hash of keywords for every node, which made building a tree several times as slow as parsing the document. Nothing changes a node once it is made.
|
|
11
9
|
|
|
12
|
-
# A `# ...` comment (`:line`) or a `/* ... */` comment (`:block`).
|
|
13
|
-
Comment = Struct.new(:type, :start, :finish)
|
|
10
|
+
# A `# ...` comment (`:line`) or a `/* ... */` comment (`:block`). `value` is its text without the delimiters.
|
|
11
|
+
Comment = Struct.new(:type, :start, :finish, :value)
|
|
12
|
+
|
|
13
|
+
# A lexical token with its original source text in `value`. `type` is `:punctuator`, `:bare_key`, `:string`, `:integer`, `:float`, `:keyword`, `:instant`, or `:duration`. Quoted keys are strings; booleans and null are keywords. Comments and whitespace are excluded.
|
|
14
|
+
Token = Struct.new(:type, :start, :finish, :value)
|
|
14
15
|
|
|
15
16
|
# An object, written with braces or, only at the top level, without them.
|
|
16
17
|
ObjectNode = Struct.new(:members, :braced, :start, :finish) do
|
|
@@ -26,18 +27,26 @@ module SOML
|
|
|
26
27
|
def type = :member
|
|
27
28
|
end
|
|
28
29
|
|
|
29
|
-
# A key, with its decoded `value`.
|
|
30
|
-
KeyNode = Struct.new(:value, :start, :finish)
|
|
30
|
+
# A key, with its decoded string `value` and `style` of `:bare`, `:literal`, or `:escaped`.
|
|
31
|
+
KeyNode = Struct.new(:value, :start, :finish, :style) do
|
|
32
|
+
def type = :key
|
|
33
|
+
end
|
|
31
34
|
|
|
32
|
-
# A value that is not a collection. `block` is whether it is a block string.
|
|
33
|
-
ScalarNode = Struct.new(:block, :start, :finish) do
|
|
35
|
+
# A value that is not a collection. `kind` is `:string`, `:integer`, `:float`, `:boolean`, `:null`, `:instant`, or `:duration`, and `value` is decoded as by SOML.parse. `block` is whether it is a block string. Strings have `style: :literal` or `:escaped`; integers have `radix: 2`, `8`, `10`, or `16`. In other kinds, `style` and `radix` are nil and `block` is false.
|
|
36
|
+
ScalarNode = Struct.new(:block, :start, :finish, :kind, :value, :style, :radix) do
|
|
34
37
|
def type = :scalar
|
|
35
38
|
end
|
|
36
39
|
|
|
37
40
|
# The characters that end the search for the closing quote of a `"..."` string.
|
|
38
41
|
ESCAPED_STRING_SPECIAL = /["\\]/
|
|
39
42
|
|
|
40
|
-
|
|
43
|
+
# The radix of an integer by the letter of its prefix, after its `0`.
|
|
44
|
+
RADIX_PREFIXES = {0x62 => 2, 0x6F => 8, 0x78 => 16}.freeze
|
|
45
|
+
|
|
46
|
+
# The distance in bytes between two of the character counts that {#position} starts from.
|
|
47
|
+
CHECKPOINT_BYTES = 1024
|
|
48
|
+
|
|
49
|
+
private_constant :ESCAPED_STRING_SPECIAL, :RADIX_PREFIXES, :CHECKPOINT_BYTES
|
|
41
50
|
|
|
42
51
|
# @return [String] The source, in UTF-8.
|
|
43
52
|
attr_reader :source
|
|
@@ -48,49 +57,135 @@ module SOML
|
|
|
48
57
|
# @return [Array<Comment>] Every comment, in source order.
|
|
49
58
|
attr_reader :comments
|
|
50
59
|
|
|
60
|
+
# @return [Array<Token>] Every lexical token, in source order, with exclusive UTF-8 byte spans. Comments are in {#comments}.
|
|
61
|
+
attr_reader :tokens
|
|
62
|
+
|
|
51
63
|
# @param source [String] The document, decoded as {SOML.parse} decodes it.
|
|
64
|
+
# @param lean [Boolean] Internal, for {SOML.format} and {SOML.edit}: whether to leave out the tokens and the line index, and not freeze the tree, because they read neither, and the tokens and the deep freeze each cost about as much as building the nodes. A lean tree has no {#tokens}, no comment text, and no {#position}.
|
|
52
65
|
# @raise [ParseError] When the document is not valid, the same as {SOML.parse}.
|
|
53
|
-
def initialize(source)
|
|
66
|
+
def initialize(source, lean: false)
|
|
54
67
|
# Validates the whole document, so the builder below can assume valid input.
|
|
55
68
|
parser = Parser.new(source)
|
|
56
|
-
parser.parse
|
|
57
|
-
@source = parser.source
|
|
69
|
+
value = parser.parse
|
|
70
|
+
@source = parser.source.dup.freeze
|
|
58
71
|
@length = @source.bytesize
|
|
59
72
|
@index = 0
|
|
60
73
|
@comments = []
|
|
74
|
+
@tokens = lean ? nil : []
|
|
61
75
|
|
|
62
76
|
skip_trivia
|
|
63
77
|
|
|
64
78
|
@body = case @source.getbyte(@index)
|
|
65
79
|
when 0x7B # {
|
|
66
|
-
parse_object
|
|
80
|
+
parse_object(value)
|
|
67
81
|
when 0x5B # [
|
|
68
|
-
parse_array
|
|
82
|
+
parse_array(value)
|
|
69
83
|
else
|
|
70
|
-
parse_bare_object
|
|
84
|
+
parse_bare_object(value)
|
|
71
85
|
end
|
|
72
86
|
|
|
73
87
|
skip_trivia
|
|
88
|
+
return if lean
|
|
89
|
+
|
|
90
|
+
# The start of each line, so that {#position} finds the line of an offset with a binary search, rather than by counting the line breaks before it, which is quadratic for a linter that asks for the position of every node.
|
|
91
|
+
@line_starts = [0]
|
|
92
|
+
line_break = -1
|
|
93
|
+
@line_starts << line_break + 1 while (line_break = @source.byteindex("\n", line_break + 1))
|
|
94
|
+
|
|
95
|
+
# The number of characters before every `CHECKPOINT_BYTES`th byte, so that {#position} counts the characters before an offset from the nearest one, rather than from the start of its line, which is quadratic for a linter on a long line. In an ASCII source, every character is one byte.
|
|
96
|
+
unless @source.ascii_only?
|
|
97
|
+
count = 0
|
|
98
|
+
|
|
99
|
+
@checkpoints = 0.step(@length, CHECKPOINT_BYTES).map do |offset|
|
|
100
|
+
before = count
|
|
101
|
+
count += character_count(offset, CHECKPOINT_BYTES)
|
|
102
|
+
before
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Only the builder reads it.
|
|
107
|
+
remove_instance_variable(:@index)
|
|
108
|
+
|
|
109
|
+
Ractor.make_shareable(self)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Yield the body and all its descendants in source order, each node before its children and each member's key before its value. Comments are available separately through {#comments}.
|
|
113
|
+
#
|
|
114
|
+
# @return [Enumerator] When no block is given.
|
|
115
|
+
def each_node
|
|
116
|
+
return enum_for(__method__) unless block_given?
|
|
117
|
+
|
|
118
|
+
pending = [@body]
|
|
119
|
+
|
|
120
|
+
until pending.empty?
|
|
121
|
+
node = pending.pop
|
|
122
|
+
yield node
|
|
123
|
+
|
|
124
|
+
case node.type
|
|
125
|
+
when :object
|
|
126
|
+
node.members.reverse_each { pending << it }
|
|
127
|
+
when :array
|
|
128
|
+
node.elements.reverse_each { pending << it }
|
|
129
|
+
when :member
|
|
130
|
+
pending.push(node.value, node.key)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
self
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The 1-based line and column of a byte offset in {#source}. Columns count Unicode characters, not bytes or display cells. EOF is accepted. Unlike tree spans, {ParseError#offset} counts characters.
|
|
138
|
+
#
|
|
139
|
+
# @param byte_offset [Integer] An offset at a UTF-8 character boundary.
|
|
140
|
+
# @return [Array<Integer>] The line and column.
|
|
141
|
+
# @raise [TypeError] When the offset is not an integer.
|
|
142
|
+
# @raise [ArgumentError] When the offset is out of bounds or inside a character.
|
|
143
|
+
def position(byte_offset)
|
|
144
|
+
raise TypeError, 'The byte offset must be an integer' unless Integer === byte_offset
|
|
145
|
+
raise ArgumentError, 'The byte offset must be within the source' unless byte_offset.between?(0, @length)
|
|
146
|
+
|
|
147
|
+
byte = @source.getbyte(byte_offset)
|
|
148
|
+
raise ArgumentError, 'The byte offset must be at a character boundary' if byte&.between?(0x80, 0xBF)
|
|
149
|
+
|
|
150
|
+
# The number of lines that start at or before the offset is its line.
|
|
151
|
+
line = @line_starts.bsearch_index { it > byte_offset } || @line_starts.length
|
|
152
|
+
line_start = @line_starts[line - 1]
|
|
153
|
+
[line, character_index(byte_offset) - character_index(line_start) + 1]
|
|
74
154
|
end
|
|
75
155
|
|
|
76
156
|
private
|
|
77
157
|
|
|
158
|
+
# The number of characters before `offset`, which is at a character boundary.
|
|
159
|
+
def character_index(offset)
|
|
160
|
+
return offset unless @checkpoints
|
|
161
|
+
|
|
162
|
+
checkpoint, rest = offset.divmod(CHECKPOINT_BYTES)
|
|
163
|
+
@checkpoints[checkpoint] + character_count(offset - rest, rest)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The number of characters that start in the `length` bytes from `start`: the bytes that are not UTF-8 continuation bytes. The bytes may begin or end inside a character.
|
|
167
|
+
def character_count(start, length)
|
|
168
|
+
bytes = @source.byteslice(start, length).b
|
|
169
|
+
bytes.bytesize - bytes.count("\x80-\xBF".b)
|
|
170
|
+
end
|
|
171
|
+
|
|
78
172
|
def skip_trivia
|
|
79
173
|
source = @source
|
|
80
174
|
index = @index
|
|
81
175
|
|
|
176
|
+
# A lean tree, which has no tokens, has no comment text either, because {SOML.format} and {SOML.edit} copy comments from the source.
|
|
82
177
|
while (byte = source.getbyte(index))
|
|
83
178
|
case byte
|
|
84
179
|
when 0x20, 0x09, 0x0A
|
|
85
180
|
index += 1
|
|
86
181
|
when 0x23 # #
|
|
87
182
|
finish = source.byteindex("\n", index) || @length
|
|
88
|
-
@comments << Comment.new(:line, index, finish)
|
|
183
|
+
@comments << Comment.new(:line, index, finish, @tokens && source.byteslice(index + 1, finish - index - 1))
|
|
89
184
|
index = finish
|
|
90
185
|
when 0x2F # /
|
|
91
186
|
# The parser checked that every block comment ends, so the search finds its `*/`.
|
|
92
187
|
finish = source.byteindex('*/', index + 2) + 2
|
|
93
|
-
@comments << Comment.new(:block, index, finish)
|
|
188
|
+
@comments << Comment.new(:block, index, finish, @tokens && source.byteslice(index + 2, finish - index - 4))
|
|
94
189
|
index = finish
|
|
95
190
|
else
|
|
96
191
|
break
|
|
@@ -100,96 +195,97 @@ module SOML
|
|
|
100
195
|
@index = index
|
|
101
196
|
end
|
|
102
197
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
members << parse_member
|
|
198
|
+
# The decoded collection supplies each value in source order. A block with two parameters reads a member of a hash without the array of its key and value that a block with one would get.
|
|
199
|
+
def parse_bare_object(value)
|
|
200
|
+
members = value.map do |name, decoded|
|
|
201
|
+
member = parse_member(name, decoded)
|
|
108
202
|
skip_trivia
|
|
109
|
-
|
|
203
|
+
member
|
|
110
204
|
end
|
|
111
205
|
|
|
112
206
|
ObjectNode.new(members, false, members.first.start, members.last.finish)
|
|
113
207
|
end
|
|
114
208
|
|
|
115
|
-
def parse_object
|
|
209
|
+
def parse_object(value)
|
|
116
210
|
start = @index
|
|
117
|
-
members = parse_items
|
|
211
|
+
members = parse_items { value.map { |name, decoded| parse_item(parse_member(name, decoded)) } }
|
|
118
212
|
ObjectNode.new(members, true, start, @index)
|
|
119
213
|
end
|
|
120
214
|
|
|
121
|
-
def parse_array
|
|
215
|
+
def parse_array(value)
|
|
122
216
|
start = @index
|
|
123
|
-
elements = parse_items
|
|
217
|
+
elements = parse_items { value.map { parse_item(parse_value(it)) } }
|
|
124
218
|
ArrayNode.new(elements, start, @index)
|
|
125
219
|
end
|
|
126
220
|
|
|
127
|
-
# The items between an opening
|
|
128
|
-
def parse_items
|
|
129
|
-
|
|
130
|
-
@index += 1
|
|
221
|
+
# The items between an opening and a closing bracket, which are consumed too, from the block, which reads each with `parse_item`. The parser has already checked the separators, including that a comma is on the line of the item before it.
|
|
222
|
+
def parse_items
|
|
223
|
+
parse_punctuator
|
|
131
224
|
skip_trivia
|
|
225
|
+
items = yield
|
|
226
|
+
parse_punctuator
|
|
227
|
+
items
|
|
228
|
+
end
|
|
132
229
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
next unless @source.getbyte(@index) == 0x2C # ,
|
|
230
|
+
# Consumes the trivia and the comma after `item`, and returns it.
|
|
231
|
+
def parse_item(item)
|
|
232
|
+
skip_trivia
|
|
137
233
|
|
|
138
|
-
|
|
234
|
+
if @source.getbyte(@index) == 0x2C # ,
|
|
235
|
+
parse_punctuator
|
|
139
236
|
skip_trivia
|
|
140
237
|
end
|
|
141
238
|
|
|
142
|
-
|
|
143
|
-
items
|
|
239
|
+
item
|
|
144
240
|
end
|
|
145
241
|
|
|
146
|
-
def parse_member
|
|
147
|
-
key = parse_key
|
|
148
|
-
|
|
242
|
+
def parse_member(name, decoded)
|
|
243
|
+
key = parse_key(name)
|
|
244
|
+
parse_punctuator # :
|
|
149
245
|
skip_trivia
|
|
150
|
-
value = parse_value
|
|
246
|
+
value = parse_value(decoded)
|
|
151
247
|
MemberNode.new(key, value, key.start, value.finish)
|
|
152
248
|
end
|
|
153
249
|
|
|
154
|
-
def parse_key
|
|
250
|
+
def parse_key(value)
|
|
155
251
|
source = @source
|
|
156
252
|
start = @index
|
|
157
253
|
byte = source.getbyte(start)
|
|
158
254
|
|
|
159
255
|
if byte == 0x27 || byte == 0x22 # ' "
|
|
160
256
|
finish = string_end(start)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
257
|
+
style = byte == 0x22 ? :escaped : :literal
|
|
258
|
+
else
|
|
259
|
+
finish = start
|
|
260
|
+
finish += 1 while (byte = source.getbyte(finish)) && BARE_KEY_BYTE[byte]
|
|
261
|
+
style = :bare
|
|
166
262
|
end
|
|
167
263
|
|
|
168
|
-
|
|
169
|
-
finish += 1 while (byte = source.getbyte(finish)) && BARE_KEY_BYTE[byte]
|
|
264
|
+
# Keys are decoded by the parser itself, so that the tree and `SOML.parse` cannot disagree.
|
|
170
265
|
@index = finish
|
|
171
|
-
|
|
266
|
+
add_token(style == :bare ? :bare_key : :string, start, finish)
|
|
267
|
+
KeyNode.new(value, start, finish, style)
|
|
172
268
|
end
|
|
173
269
|
|
|
174
|
-
def parse_value
|
|
270
|
+
def parse_value(value)
|
|
175
271
|
source = @source
|
|
176
272
|
start = @index
|
|
177
273
|
|
|
178
274
|
case (byte = source.getbyte(start))
|
|
179
275
|
when 0x7B # {
|
|
180
|
-
parse_object
|
|
276
|
+
parse_object(value)
|
|
181
277
|
when 0x5B # [
|
|
182
|
-
parse_array
|
|
278
|
+
parse_array(value)
|
|
183
279
|
when 0x27, 0x22 # ' "
|
|
184
|
-
return parse_block_string(byte) if source.getbyte(start + 1) == byte && source.getbyte(start + 2) == byte
|
|
280
|
+
return parse_block_string(byte, value) if source.getbyte(start + 1) == byte && source.getbyte(start + 2) == byte
|
|
185
281
|
|
|
186
282
|
@index = string_end(start)
|
|
187
|
-
|
|
283
|
+
scalar_node(value, false, start, @index)
|
|
188
284
|
else
|
|
189
285
|
finish = start + 1
|
|
190
286
|
finish += 1 while (byte = source.getbyte(finish)) && NUMBER_BYTE[byte]
|
|
191
287
|
@index = finish
|
|
192
|
-
|
|
288
|
+
scalar_node(value, false, start, finish)
|
|
193
289
|
end
|
|
194
290
|
end
|
|
195
291
|
|
|
@@ -209,7 +305,7 @@ module SOML
|
|
|
209
305
|
end
|
|
210
306
|
|
|
211
307
|
# The block ends at the first line whose first non-whitespace content is a run of exactly as many quotes as the opening delimiter.
|
|
212
|
-
def parse_block_string(quote)
|
|
308
|
+
def parse_block_string(quote, value)
|
|
213
309
|
source = @source
|
|
214
310
|
start = @index
|
|
215
311
|
delimiter_length = 0
|
|
@@ -224,16 +320,51 @@ module SOML
|
|
|
224
320
|
|
|
225
321
|
if run_length == delimiter_length
|
|
226
322
|
@index = delimiter_start + delimiter_length
|
|
227
|
-
return
|
|
323
|
+
return scalar_node(value, true, start, @index)
|
|
228
324
|
end
|
|
229
325
|
|
|
230
326
|
line_start = source.byteindex("\n", line_start) + 1
|
|
231
327
|
end
|
|
232
328
|
end
|
|
233
329
|
|
|
234
|
-
#
|
|
235
|
-
def
|
|
236
|
-
|
|
330
|
+
# Consume one bracket, colon, or comma that the parser has already validated.
|
|
331
|
+
def parse_punctuator
|
|
332
|
+
add_token(:punctuator, @index, @index + 1)
|
|
333
|
+
@index += 1
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Record a token while scanning, so punctuation inside strings and comments never becomes a separate token. A lean tree has none.
|
|
337
|
+
def add_token(type, start, finish)
|
|
338
|
+
@tokens&.push(Token.new(type, start, finish, @source.byteslice(start, finish - start)))
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# The value of the scalar from `start` to `finish`, already read by the parser as part of the document, with the spelling metadata that a lint rule needs.
|
|
342
|
+
def scalar_node(value, block, start, finish)
|
|
343
|
+
style = nil
|
|
344
|
+
radix = nil
|
|
345
|
+
|
|
346
|
+
kind = case value
|
|
347
|
+
when String
|
|
348
|
+
style = @source.getbyte(start) == 0x27 ? :literal : :escaped
|
|
349
|
+
:string
|
|
350
|
+
when Integer
|
|
351
|
+
# A radix integer has no sign, so its prefix is at the start.
|
|
352
|
+
radix = @source.getbyte(start) == 0x30 ? RADIX_PREFIXES.fetch(@source.getbyte(start + 1), 10) : 10
|
|
353
|
+
:integer
|
|
354
|
+
when Float
|
|
355
|
+
:float
|
|
356
|
+
when true, false
|
|
357
|
+
:boolean
|
|
358
|
+
when nil
|
|
359
|
+
:null
|
|
360
|
+
when Time
|
|
361
|
+
:instant
|
|
362
|
+
when Duration
|
|
363
|
+
:duration
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
add_token(kind == :boolean || kind == :null ? :keyword : kind, start, finish)
|
|
367
|
+
ScalarNode.new(block, start, finish, kind, value, style, radix)
|
|
237
368
|
end
|
|
238
369
|
end
|
|
239
370
|
end
|
data/lib/soml/version.rb
CHANGED
data/lib/soml.rb
CHANGED
|
@@ -23,10 +23,10 @@ module SOML
|
|
|
23
23
|
}.freeze
|
|
24
24
|
|
|
25
25
|
# A byte that a bare key can hold, as a table of 256 booleans, for the {Parser} and the {Tree}.
|
|
26
|
-
BARE_KEY_BYTE = Array.new(256) {
|
|
26
|
+
BARE_KEY_BYTE = Array.new(256) { it.chr.match?(/[A-Za-z0-9_-]/) }.freeze
|
|
27
27
|
|
|
28
28
|
# A number, an instant, or a duration is one maximal run of these bytes, which the {Parser} then validates as a whole. A keyword ends at the first byte that is not one, too, because the parser checked that it is followed by whitespace, a separator, a closing bracket, a comment, or the end.
|
|
29
|
-
NUMBER_BYTE = Array.new(256) {
|
|
29
|
+
NUMBER_BYTE = Array.new(256) { it.chr.match?(/[A-Za-z0-9_.:+-]/) }.freeze
|
|
30
30
|
|
|
31
31
|
# A line of a block string without one of these is blank. A search for one character, because a repeat over a long line needs memory for every character.
|
|
32
32
|
NOT_BLANK_CHARACTER = /[^\t ]/
|
|
@@ -35,13 +35,13 @@ module SOML
|
|
|
35
35
|
NO_VALUE = Object.new.freeze
|
|
36
36
|
|
|
37
37
|
# The indentation of each level, so that the {Generator} and the {Printer} make no string for each line they indent. The {Printer} indents a value after a comment one level deeper than its key, so each container can be two levels deeper than the one that holds it, and a value after a comment in the deepest container is at level 200.
|
|
38
|
-
INDENTS = Array.new((MAX_DEPTH * 2) + 1) {
|
|
38
|
+
INDENTS = Array.new((MAX_DEPTH * 2) + 1) { ("\t" * it).freeze }.freeze
|
|
39
39
|
|
|
40
40
|
private_constant :MAX_DEPTH, :INT64_MIN, :INT64_MAX, :DURATION_UNITS, :NO_VALUE, :BARE_KEY_BYTE, :NUMBER_BYTE, :NOT_BLANK_CHARACTER, :INDENTS
|
|
41
41
|
|
|
42
42
|
# 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.
|
|
43
43
|
#
|
|
44
|
-
# @param key [String, Symbol] A key
|
|
44
|
+
# @param key [String, Symbol] A key, or a symbol, which is read as its name. A key in an encoding that is not compatible with ASCII, such as UTF-16, is converted to UTF-8 first. Any other object gives false.
|
|
45
45
|
# @return [Boolean]
|
|
46
46
|
#
|
|
47
47
|
# @example
|
|
@@ -54,8 +54,8 @@ module SOML
|
|
|
54
54
|
# Only a String or a Symbol is a key. Anything else, such as a number or nil, could never be a bare key, so the answer is false rather than a crash on its missing `encoding`.
|
|
55
55
|
return false unless String === key || Symbol === key
|
|
56
56
|
|
|
57
|
-
# The byte scan needs a String, so a Symbol is read as its name. A String, even a subclass, is left as it is
|
|
58
|
-
key = key.
|
|
57
|
+
# The byte scan needs a String, so a Symbol is read as its name. A String, even a subclass, is left as it is.
|
|
58
|
+
key = key.name if Symbol === key
|
|
59
59
|
key = key.encode(Encoding::UTF_8) unless key.encoding.ascii_compatible?
|
|
60
60
|
|
|
61
61
|
# A scan of the bytes, which is faster than the regex it replaces and decides the same: a byte that is not an ASCII letter, digit, `_`, or `-` means the key needs quotes. A non-ASCII character is a run of bytes none of which a bare key can hold.
|
|
@@ -71,8 +71,8 @@ module SOML
|
|
|
71
71
|
end
|
|
72
72
|
|
|
73
73
|
true
|
|
74
|
-
rescue
|
|
75
|
-
#
|
|
74
|
+
rescue EncodingError
|
|
75
|
+
# A key that does not convert to UTF-8.
|
|
76
76
|
false
|
|
77
77
|
end
|
|
78
78
|
|
|
@@ -94,13 +94,23 @@ module SOML
|
|
|
94
94
|
# postgres: {host: 'db.internal'}
|
|
95
95
|
# SOML
|
|
96
96
|
# #=> {"name" => "api-gateway", "replicas" => 3, "timeout" => #<SOML::Duration 30s>, "postgres" => {"host" => "db.internal"}}
|
|
97
|
-
def self.parse(source, symbolize_names: false, freeze: false)
|
|
98
|
-
|
|
99
|
-
|
|
97
|
+
def self.parse(source, symbolize_names: false, freeze: false) = Parser.new(source, symbolize_names:, freeze:).parse
|
|
98
|
+
|
|
99
|
+
# Parse a document into a read-only syntax tree for linters and other tools. Nodes have decoded values, spelling, and UTF-8 byte spans. The tree holds a frozen copy of the source.
|
|
100
|
+
#
|
|
101
|
+
# @param source [String] The document, read as {SOML.parse} reads it.
|
|
102
|
+
# @return [Tree] The deeply frozen tree, including its source, tokens, and comments.
|
|
103
|
+
# @raise [ParseError, TypeError, EncodingError] As for {SOML.parse}.
|
|
104
|
+
# @example
|
|
105
|
+
# tree = SOML.parse_tree('port: 0x1F90 # Default')
|
|
106
|
+
# node = tree.body.members.first.value
|
|
107
|
+
# [node.kind, node.value, node.radix, tree.position(node.start)]
|
|
108
|
+
# #=> [:integer, 8080, 16, [1, 7]]
|
|
109
|
+
def self.parse_tree(source) = Tree.new(source)
|
|
100
110
|
|
|
101
111
|
# Read and parse a file.
|
|
102
112
|
#
|
|
103
|
-
# The file is read in binary mode, so that a
|
|
113
|
+
# The file is read in binary mode, so that a text-mode conversion cannot hide a carriage return. An error names the file, as in `Duplicate key port at config.soml:3:1`.
|
|
104
114
|
#
|
|
105
115
|
# @param path [String, #to_path] The file, such as a `Pathname` or a `File`.
|
|
106
116
|
# @param symbolize_names [Boolean] See {SOML.parse}.
|
|
@@ -111,15 +121,13 @@ module SOML
|
|
|
111
121
|
#
|
|
112
122
|
# @example
|
|
113
123
|
# config = SOML.load_file('config.soml', symbolize_names: true)
|
|
114
|
-
def self.load_file(path, symbolize_names: false, freeze: false)
|
|
115
|
-
Parser.new(File.binread(path), symbolize_names:, freeze:, path: File.path(path)).parse
|
|
116
|
-
end
|
|
124
|
+
def self.load_file(path, symbolize_names: false, freeze: false) = Parser.new(File.binread(path), symbolize_names:, freeze:, path: File.path(path)).parse
|
|
117
125
|
|
|
118
126
|
# Write a hash or an array as SOML.
|
|
119
127
|
#
|
|
120
|
-
# Members keep the order of their hash, nesting
|
|
128
|
+
# 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.
|
|
121
129
|
#
|
|
122
|
-
# With `canonical: true`, members are sorted by key too, so
|
|
130
|
+
# With `canonical: true`, members are sorted by key too, so two equal values give the same text, which can be hashed, signed, or compared.
|
|
123
131
|
#
|
|
124
132
|
# @param value [Hash, Array] The document.
|
|
125
133
|
# @param canonical [Boolean] Whether to write canonical form, with members sorted by key.
|
|
@@ -132,20 +140,18 @@ module SOML
|
|
|
132
140
|
#
|
|
133
141
|
# SOML.generate({name: 'api-gateway', replicas: 3, ratio: 0.5}, canonical: true)
|
|
134
142
|
# #=> "name: 'api-gateway'\nratio: 0.5\nreplicas: 3\n"
|
|
135
|
-
def self.generate(value, canonical: false)
|
|
136
|
-
Generator.new(canonical:).generate(value)
|
|
137
|
-
end
|
|
143
|
+
def self.generate(value, canonical: false) = Generator.new(canonical:).generate(value)
|
|
138
144
|
|
|
139
145
|
# Format a document. Returns the document with its layout normalized, ending with one line feed.
|
|
140
146
|
#
|
|
141
|
-
# The layout follows the formatter 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.
|
|
147
|
+
# The layout follows the formatter 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.
|
|
142
148
|
#
|
|
143
|
-
# In detail
|
|
149
|
+
# In detail:
|
|
144
150
|
#
|
|
145
|
-
# - A block comment
|
|
146
|
-
# - One space follows each
|
|
147
|
-
# - 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
|
|
148
|
-
# - A block string begins on the line after its key,
|
|
151
|
+
# - A block comment before a value on the same line, as in `1, /* note */ 2`, stays in front of that value.
|
|
152
|
+
# - One space follows each `:`.
|
|
153
|
+
# - 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.
|
|
154
|
+
# - A block string begins on the line after its key, indented to that line. Its lines are not otherwise changed.
|
|
149
155
|
# - There are no blank lines at the start of the file or directly inside brackets.
|
|
150
156
|
#
|
|
151
157
|
# @param source [String] The document, read as {SOML.parse} reads it.
|
|
@@ -160,41 +166,41 @@ module SOML
|
|
|
160
166
|
#
|
|
161
167
|
# SOML.format("pool: {\nmin: 2, max: 16}")
|
|
162
168
|
# #=> "pool: {\n\tmin: 2\n\tmax: 16\n}\n"
|
|
163
|
-
def self.format(source)
|
|
164
|
-
Printer.new(Tree.new(source)).print
|
|
165
|
-
end
|
|
169
|
+
def self.format(source) = Printer.new(Tree.new(source, lean: true)).print
|
|
166
170
|
|
|
167
|
-
# Change one value in a document, and keep everything else as it is written: comments, member order, layout, and
|
|
171
|
+
# 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.
|
|
172
|
+
#
|
|
173
|
+
# Instead of a path, pass a member or a value node from {SOML.parse_tree} of the same source. A member selects its value for replacement and the whole member for deletion. Keys, comments, and the root collection cannot be edited this way. Reparse after each edit before selecting another node.
|
|
168
174
|
#
|
|
169
|
-
# The value at `path` is replaced, or added when it does not exist
|
|
175
|
+
# The value at `path` is replaced, or added when it does not exist, and `delete: true` removes it. Only the changed part of the text is rewritten, so a formatted document stays formatted.
|
|
170
176
|
#
|
|
171
|
-
# - A new value is written as {SOML.generate} writes it
|
|
177
|
+
# - 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}]`.
|
|
172
178
|
# - A new member goes after the last member of its object. Missing objects on the way are created.
|
|
173
|
-
# - A new item can be added at the end of an array,
|
|
174
|
-
# - A new member or item goes on its own line without a comma, after the comments that the one before it owns (see below)
|
|
175
|
-
# - A removed member or item takes the comments it owns, which
|
|
176
|
-
# - A removed member or item takes its comma with it. When
|
|
179
|
+
# - A new item can be added at the end of an array, at the index that is its length.
|
|
180
|
+
# - 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]]`.
|
|
181
|
+
# - 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.
|
|
182
|
+
# - 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]`.
|
|
177
183
|
# - Removing the only member of a document without braces leaves `{}`.
|
|
178
184
|
#
|
|
179
|
-
# Comments inside a
|
|
185
|
+
# 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. 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.
|
|
180
186
|
#
|
|
181
187
|
# The result is parsed before it is returned, so a bug in `edit` raises a `RuntimeError` rather than returning a broken document.
|
|
182
188
|
#
|
|
183
189
|
# @overload edit(source, path, value)
|
|
184
190
|
# @param source [String] The document, read as {SOML.parse} reads it.
|
|
185
|
-
# @param path [Array<String, Symbol, Integer
|
|
191
|
+
# @param path [Array<String, Symbol, Integer>, Tree::MemberNode, Tree::ObjectNode, Tree::ArrayNode, Tree::ScalarNode] A node from the same source, or the keys and array indexes that lead to the value, such as `['servers', 0, 'port']`.
|
|
186
192
|
# @param value [Object] The new value, of the types that {SOML.generate} accepts. Write a `Hash` with braces, because without them Ruby passes its members as keyword arguments.
|
|
187
193
|
# @return [String] The changed document, in UTF-8.
|
|
188
194
|
# @overload edit(source, path, delete: true)
|
|
189
195
|
# @param source [String] The document, read as {SOML.parse} reads it.
|
|
190
|
-
# @param path [Array<String, Symbol, Integer
|
|
196
|
+
# @param path [Array<String, Symbol, Integer>, Tree::MemberNode, Tree::ObjectNode, Tree::ArrayNode, Tree::ScalarNode] A node from the same source, or the keys and array indexes that lead to the value.
|
|
191
197
|
# @param delete [true] Remove the value.
|
|
192
198
|
# @return [String] The changed document, in UTF-8.
|
|
193
199
|
# @raise [ParseError] When the document is not valid, the same as {SOML.parse}.
|
|
194
|
-
# @raise [ArgumentError] When both a value and `delete: true` are given, or neither.
|
|
195
|
-
# @raise [TypeError] When `source` is not a string, when `path` is not a non-empty array of keys and
|
|
196
|
-
# @raise [IndexError] When a value is set at an index past the end of its array, or at an index under a value
|
|
197
|
-
# @raise [GenerateError] For a value or a key that {SOML.generate} cannot write, and when the change would nest
|
|
200
|
+
# @raise [ArgumentError] When both a value and `delete: true` are given, or neither, or when the selected node is the root or cannot be found in the source.
|
|
201
|
+
# @raise [TypeError] When `source` is not a string, when `path` is not a supported 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.
|
|
202
|
+
# @raise [IndexError] When a value is set at an index past the end of its array, or at an index under a missing value.
|
|
203
|
+
# @raise [GenerateError] For a value or a key that {SOML.generate} cannot write, and when the change would nest more than 100 levels deep.
|
|
198
204
|
# @raise [EncodingError] When `source` is in an encoding other than UTF-8, binary, or US-ASCII, and does not convert to UTF-8.
|
|
199
205
|
#
|
|
200
206
|
# @example
|