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/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`, `:date`, `: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`, `:date`, `: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,136 @@ 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
|
+
# Note: Not using a complement count because it offers no measured speedup.
|
|
170
|
+
bytes.bytesize - bytes.count("\x80-\xBF".b)
|
|
171
|
+
end
|
|
172
|
+
|
|
78
173
|
def skip_trivia
|
|
79
174
|
source = @source
|
|
80
175
|
index = @index
|
|
81
176
|
|
|
177
|
+
# A lean tree, which has no tokens, has no comment text either, because {SOML.format} and {SOML.edit} copy comments from the source.
|
|
82
178
|
while (byte = source.getbyte(index))
|
|
83
179
|
case byte
|
|
84
180
|
when 0x20, 0x09, 0x0A
|
|
85
181
|
index += 1
|
|
86
182
|
when 0x23 # #
|
|
87
183
|
finish = source.byteindex("\n", index) || @length
|
|
88
|
-
@comments << Comment.new(:line, index, finish)
|
|
184
|
+
@comments << Comment.new(:line, index, finish, @tokens && source.byteslice(index + 1, finish - index - 1))
|
|
89
185
|
index = finish
|
|
90
186
|
when 0x2F # /
|
|
91
187
|
# The parser checked that every block comment ends, so the search finds its `*/`.
|
|
92
188
|
finish = source.byteindex('*/', index + 2) + 2
|
|
93
|
-
@comments << Comment.new(:block, index, finish)
|
|
189
|
+
@comments << Comment.new(:block, index, finish, @tokens && source.byteslice(index + 2, finish - index - 4))
|
|
94
190
|
index = finish
|
|
95
191
|
else
|
|
96
192
|
break
|
|
@@ -100,96 +196,97 @@ module SOML
|
|
|
100
196
|
@index = index
|
|
101
197
|
end
|
|
102
198
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
members << parse_member
|
|
199
|
+
# 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.
|
|
200
|
+
def parse_bare_object(value)
|
|
201
|
+
members = value.map do |name, decoded|
|
|
202
|
+
member = parse_member(name, decoded)
|
|
108
203
|
skip_trivia
|
|
109
|
-
|
|
204
|
+
member
|
|
110
205
|
end
|
|
111
206
|
|
|
112
207
|
ObjectNode.new(members, false, members.first.start, members.last.finish)
|
|
113
208
|
end
|
|
114
209
|
|
|
115
|
-
def parse_object
|
|
210
|
+
def parse_object(value)
|
|
116
211
|
start = @index
|
|
117
|
-
members = parse_items
|
|
212
|
+
members = parse_items { value.map { |name, decoded| parse_item(parse_member(name, decoded)) } }
|
|
118
213
|
ObjectNode.new(members, true, start, @index)
|
|
119
214
|
end
|
|
120
215
|
|
|
121
|
-
def parse_array
|
|
216
|
+
def parse_array(value)
|
|
122
217
|
start = @index
|
|
123
|
-
elements = parse_items
|
|
218
|
+
elements = parse_items { value.map { parse_item(parse_value(it)) } }
|
|
124
219
|
ArrayNode.new(elements, start, @index)
|
|
125
220
|
end
|
|
126
221
|
|
|
127
|
-
# The items between an opening
|
|
128
|
-
def parse_items
|
|
129
|
-
|
|
130
|
-
@index += 1
|
|
222
|
+
# 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.
|
|
223
|
+
def parse_items
|
|
224
|
+
parse_punctuator
|
|
131
225
|
skip_trivia
|
|
226
|
+
items = yield
|
|
227
|
+
parse_punctuator
|
|
228
|
+
items
|
|
229
|
+
end
|
|
132
230
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
next unless @source.getbyte(@index) == 0x2C # ,
|
|
231
|
+
# Consumes the trivia and the comma after `item`, and returns it.
|
|
232
|
+
def parse_item(item)
|
|
233
|
+
skip_trivia
|
|
137
234
|
|
|
138
|
-
|
|
235
|
+
if @source.getbyte(@index) == 0x2C # ,
|
|
236
|
+
parse_punctuator
|
|
139
237
|
skip_trivia
|
|
140
238
|
end
|
|
141
239
|
|
|
142
|
-
|
|
143
|
-
items
|
|
240
|
+
item
|
|
144
241
|
end
|
|
145
242
|
|
|
146
|
-
def parse_member
|
|
147
|
-
key = parse_key
|
|
148
|
-
|
|
243
|
+
def parse_member(name, decoded)
|
|
244
|
+
key = parse_key(name)
|
|
245
|
+
parse_punctuator # :
|
|
149
246
|
skip_trivia
|
|
150
|
-
value = parse_value
|
|
247
|
+
value = parse_value(decoded)
|
|
151
248
|
MemberNode.new(key, value, key.start, value.finish)
|
|
152
249
|
end
|
|
153
250
|
|
|
154
|
-
def parse_key
|
|
251
|
+
def parse_key(value)
|
|
155
252
|
source = @source
|
|
156
253
|
start = @index
|
|
157
254
|
byte = source.getbyte(start)
|
|
158
255
|
|
|
159
256
|
if byte == 0x27 || byte == 0x22 # ' "
|
|
160
257
|
finish = string_end(start)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
258
|
+
style = byte == 0x22 ? :escaped : :literal
|
|
259
|
+
else
|
|
260
|
+
finish = start
|
|
261
|
+
finish += 1 while (byte = source.getbyte(finish)) && BARE_KEY_BYTE[byte]
|
|
262
|
+
style = :bare
|
|
166
263
|
end
|
|
167
264
|
|
|
168
|
-
|
|
169
|
-
finish += 1 while (byte = source.getbyte(finish)) && BARE_KEY_BYTE[byte]
|
|
265
|
+
# Keys are decoded by the parser itself, so that the tree and `SOML.parse` cannot disagree.
|
|
170
266
|
@index = finish
|
|
171
|
-
|
|
267
|
+
add_token(style == :bare ? :bare_key : :string, start, finish)
|
|
268
|
+
KeyNode.new(value, start, finish, style)
|
|
172
269
|
end
|
|
173
270
|
|
|
174
|
-
def parse_value
|
|
271
|
+
def parse_value(value)
|
|
175
272
|
source = @source
|
|
176
273
|
start = @index
|
|
177
274
|
|
|
178
275
|
case (byte = source.getbyte(start))
|
|
179
276
|
when 0x7B # {
|
|
180
|
-
parse_object
|
|
277
|
+
parse_object(value)
|
|
181
278
|
when 0x5B # [
|
|
182
|
-
parse_array
|
|
279
|
+
parse_array(value)
|
|
183
280
|
when 0x27, 0x22 # ' "
|
|
184
|
-
return parse_block_string(byte) if source.getbyte(start + 1) == byte && source.getbyte(start + 2) == byte
|
|
281
|
+
return parse_block_string(byte, value) if source.getbyte(start + 1) == byte && source.getbyte(start + 2) == byte
|
|
185
282
|
|
|
186
283
|
@index = string_end(start)
|
|
187
|
-
|
|
284
|
+
scalar_node(value, false, start, @index)
|
|
188
285
|
else
|
|
189
286
|
finish = start + 1
|
|
190
287
|
finish += 1 while (byte = source.getbyte(finish)) && NUMBER_BYTE[byte]
|
|
191
288
|
@index = finish
|
|
192
|
-
|
|
289
|
+
scalar_node(value, false, start, finish)
|
|
193
290
|
end
|
|
194
291
|
end
|
|
195
292
|
|
|
@@ -209,7 +306,7 @@ module SOML
|
|
|
209
306
|
end
|
|
210
307
|
|
|
211
308
|
# 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)
|
|
309
|
+
def parse_block_string(quote, value)
|
|
213
310
|
source = @source
|
|
214
311
|
start = @index
|
|
215
312
|
delimiter_length = 0
|
|
@@ -224,16 +321,53 @@ module SOML
|
|
|
224
321
|
|
|
225
322
|
if run_length == delimiter_length
|
|
226
323
|
@index = delimiter_start + delimiter_length
|
|
227
|
-
return
|
|
324
|
+
return scalar_node(value, true, start, @index)
|
|
228
325
|
end
|
|
229
326
|
|
|
230
327
|
line_start = source.byteindex("\n", line_start) + 1
|
|
231
328
|
end
|
|
232
329
|
end
|
|
233
330
|
|
|
234
|
-
#
|
|
235
|
-
def
|
|
236
|
-
|
|
331
|
+
# Consume one bracket, colon, or comma that the parser has already validated.
|
|
332
|
+
def parse_punctuator
|
|
333
|
+
add_token(:punctuator, @index, @index + 1)
|
|
334
|
+
@index += 1
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Record a token while scanning, so punctuation inside strings and comments never becomes a separate token. A lean tree has none.
|
|
338
|
+
def add_token(type, start, finish)
|
|
339
|
+
@tokens&.push(Token.new(type, start, finish, @source.byteslice(start, finish - start)))
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# 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.
|
|
343
|
+
def scalar_node(value, block, start, finish)
|
|
344
|
+
style = nil
|
|
345
|
+
radix = nil
|
|
346
|
+
|
|
347
|
+
kind = case value
|
|
348
|
+
when String
|
|
349
|
+
style = @source.getbyte(start) == 0x27 ? :literal : :escaped
|
|
350
|
+
:string
|
|
351
|
+
when Integer
|
|
352
|
+
# A radix integer has no sign, so its prefix is at the start.
|
|
353
|
+
radix = @source.getbyte(start) == 0x30 ? RADIX_PREFIXES.fetch(@source.getbyte(start + 1), 10) : 10
|
|
354
|
+
:integer
|
|
355
|
+
when Float
|
|
356
|
+
:float
|
|
357
|
+
when true, false
|
|
358
|
+
:boolean
|
|
359
|
+
when nil
|
|
360
|
+
:null
|
|
361
|
+
when Date
|
|
362
|
+
:date
|
|
363
|
+
when Time
|
|
364
|
+
:instant
|
|
365
|
+
when Duration
|
|
366
|
+
:duration
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
add_token(kind == :boolean || kind == :null ? :keyword : kind, start, finish)
|
|
370
|
+
ScalarNode.new(block, start, finish, kind, value, style, radix)
|
|
237
371
|
end
|
|
238
372
|
end
|
|
239
373
|
end
|
data/lib/soml/version.rb
CHANGED
data/lib/soml.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'date'
|
|
4
|
+
|
|
3
5
|
require_relative 'soml/version'
|
|
4
6
|
|
|
5
7
|
# Read and write SOML, a config format for humans.
|
|
@@ -23,10 +25,10 @@ module SOML
|
|
|
23
25
|
}.freeze
|
|
24
26
|
|
|
25
27
|
# 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) {
|
|
28
|
+
BARE_KEY_BYTE = Array.new(256) { it.chr.match?(/[A-Za-z0-9_-]/) }.freeze
|
|
27
29
|
|
|
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) {
|
|
30
|
+
# A number, a date, 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.
|
|
31
|
+
NUMBER_BYTE = Array.new(256) { it.chr.match?(/[A-Za-z0-9_.:+-]/) }.freeze
|
|
30
32
|
|
|
31
33
|
# 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
34
|
NOT_BLANK_CHARACTER = /[^\t ]/
|
|
@@ -35,13 +37,13 @@ module SOML
|
|
|
35
37
|
NO_VALUE = Object.new.freeze
|
|
36
38
|
|
|
37
39
|
# 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) {
|
|
40
|
+
INDENTS = Array.new((MAX_DEPTH * 2) + 1) { ("\t" * it).freeze }.freeze
|
|
39
41
|
|
|
40
42
|
private_constant :MAX_DEPTH, :INT64_MIN, :INT64_MAX, :DURATION_UNITS, :NO_VALUE, :BARE_KEY_BYTE, :NUMBER_BYTE, :NOT_BLANK_CHARACTER, :INDENTS
|
|
41
43
|
|
|
42
44
|
# 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
45
|
#
|
|
44
|
-
# @param key [String, Symbol] A key
|
|
46
|
+
# @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
47
|
# @return [Boolean]
|
|
46
48
|
#
|
|
47
49
|
# @example
|
|
@@ -54,11 +56,13 @@ module SOML
|
|
|
54
56
|
# 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
57
|
return false unless String === key || Symbol === key
|
|
56
58
|
|
|
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.
|
|
59
|
+
# The byte scan needs a String, so a Symbol is read as its name. A String, even a subclass, is left as it is.
|
|
60
|
+
key = key.name if Symbol === key
|
|
59
61
|
key = key.encode(Encoding::UTF_8) unless key.encoding.ascii_compatible?
|
|
60
62
|
|
|
61
63
|
# 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.
|
|
64
|
+
#
|
|
65
|
+
# Note: Not using `String#count` because it is slower for typical short keys.
|
|
62
66
|
return false if key.empty?
|
|
63
67
|
|
|
64
68
|
index = 0
|
|
@@ -71,8 +75,8 @@ module SOML
|
|
|
71
75
|
end
|
|
72
76
|
|
|
73
77
|
true
|
|
74
|
-
rescue
|
|
75
|
-
#
|
|
78
|
+
rescue EncodingError
|
|
79
|
+
# A key that does not convert to UTF-8.
|
|
76
80
|
false
|
|
77
81
|
end
|
|
78
82
|
|
|
@@ -94,13 +98,23 @@ module SOML
|
|
|
94
98
|
# postgres: {host: 'db.internal'}
|
|
95
99
|
# SOML
|
|
96
100
|
# #=> {"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
|
-
|
|
101
|
+
def self.parse(source, symbolize_names: false, freeze: false) = Parser.new(source, symbolize_names:, freeze:).parse
|
|
102
|
+
|
|
103
|
+
# 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.
|
|
104
|
+
#
|
|
105
|
+
# @param source [String] The document, read as {SOML.parse} reads it.
|
|
106
|
+
# @return [Tree] The deeply frozen tree, including its source, tokens, and comments.
|
|
107
|
+
# @raise [ParseError, TypeError, EncodingError] As for {SOML.parse}.
|
|
108
|
+
# @example
|
|
109
|
+
# tree = SOML.parse_tree('port: 0x1F90 # Default')
|
|
110
|
+
# node = tree.body.members.first.value
|
|
111
|
+
# [node.kind, node.value, node.radix, tree.position(node.start)]
|
|
112
|
+
# #=> [:integer, 8080, 16, [1, 7]]
|
|
113
|
+
def self.parse_tree(source) = Tree.new(source)
|
|
100
114
|
|
|
101
115
|
# Read and parse a file.
|
|
102
116
|
#
|
|
103
|
-
# The file is read in binary mode, so that a
|
|
117
|
+
# 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
118
|
#
|
|
105
119
|
# @param path [String, #to_path] The file, such as a `Pathname` or a `File`.
|
|
106
120
|
# @param symbolize_names [Boolean] See {SOML.parse}.
|
|
@@ -111,15 +125,13 @@ module SOML
|
|
|
111
125
|
#
|
|
112
126
|
# @example
|
|
113
127
|
# 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
|
|
128
|
+
def self.load_file(path, symbolize_names: false, freeze: false) = Parser.new(File.binread(path), symbolize_names:, freeze:, path: File.path(path)).parse
|
|
117
129
|
|
|
118
130
|
# Write a hash or an array as SOML.
|
|
119
131
|
#
|
|
120
|
-
# Members keep the order of their hash, nesting
|
|
132
|
+
# 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
133
|
#
|
|
122
|
-
# With `canonical: true`, members are sorted by key too, so
|
|
134
|
+
# 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
135
|
#
|
|
124
136
|
# @param value [Hash, Array] The document.
|
|
125
137
|
# @param canonical [Boolean] Whether to write canonical form, with members sorted by key.
|
|
@@ -132,20 +144,18 @@ module SOML
|
|
|
132
144
|
#
|
|
133
145
|
# SOML.generate({name: 'api-gateway', replicas: 3, ratio: 0.5}, canonical: true)
|
|
134
146
|
# #=> "name: 'api-gateway'\nratio: 0.5\nreplicas: 3\n"
|
|
135
|
-
def self.generate(value, canonical: false)
|
|
136
|
-
Generator.new(canonical:).generate(value)
|
|
137
|
-
end
|
|
147
|
+
def self.generate(value, canonical: false) = Generator.new(canonical:).generate(value)
|
|
138
148
|
|
|
139
149
|
# Format a document. Returns the document with its layout normalized, ending with one line feed.
|
|
140
150
|
#
|
|
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.
|
|
151
|
+
# 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
152
|
#
|
|
143
|
-
# In detail
|
|
153
|
+
# In detail:
|
|
144
154
|
#
|
|
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,
|
|
155
|
+
# - A block comment before a value on the same line, as in `1, /* note */ 2`, stays in front of that value.
|
|
156
|
+
# - One space follows each `:`.
|
|
157
|
+
# - 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.
|
|
158
|
+
# - A block string begins on the line after its key, indented to that line. Its lines are not otherwise changed.
|
|
149
159
|
# - There are no blank lines at the start of the file or directly inside brackets.
|
|
150
160
|
#
|
|
151
161
|
# @param source [String] The document, read as {SOML.parse} reads it.
|
|
@@ -160,41 +170,41 @@ module SOML
|
|
|
160
170
|
#
|
|
161
171
|
# SOML.format("pool: {\nmin: 2, max: 16}")
|
|
162
172
|
# #=> "pool: {\n\tmin: 2\n\tmax: 16\n}\n"
|
|
163
|
-
def self.format(source)
|
|
164
|
-
Printer.new(Tree.new(source)).print
|
|
165
|
-
end
|
|
173
|
+
def self.format(source) = Printer.new(Tree.new(source, lean: true)).print
|
|
166
174
|
|
|
167
|
-
# Change one value in a document, and keep everything else as it is written: comments, member order, layout, and
|
|
175
|
+
# 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.
|
|
176
|
+
#
|
|
177
|
+
# 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
178
|
#
|
|
169
|
-
# The value at `path` is replaced, or added when it does not exist
|
|
179
|
+
# 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
180
|
#
|
|
171
|
-
# - A new value is written as {SOML.generate} writes it
|
|
181
|
+
# - 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
182
|
# - 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
|
|
183
|
+
# - A new item can be added at the end of an array, at the index that is its length.
|
|
184
|
+
# - 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]]`.
|
|
185
|
+
# - 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.
|
|
186
|
+
# - 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
187
|
# - Removing the only member of a document without braces leaves `{}`.
|
|
178
188
|
#
|
|
179
|
-
# Comments inside a
|
|
189
|
+
# 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
190
|
#
|
|
181
191
|
# The result is parsed before it is returned, so a bug in `edit` raises a `RuntimeError` rather than returning a broken document.
|
|
182
192
|
#
|
|
183
193
|
# @overload edit(source, path, value)
|
|
184
194
|
# @param source [String] The document, read as {SOML.parse} reads it.
|
|
185
|
-
# @param path [Array<String, Symbol, Integer
|
|
195
|
+
# @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
196
|
# @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
197
|
# @return [String] The changed document, in UTF-8.
|
|
188
198
|
# @overload edit(source, path, delete: true)
|
|
189
199
|
# @param source [String] The document, read as {SOML.parse} reads it.
|
|
190
|
-
# @param path [Array<String, Symbol, Integer
|
|
200
|
+
# @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
201
|
# @param delete [true] Remove the value.
|
|
192
202
|
# @return [String] The changed document, in UTF-8.
|
|
193
203
|
# @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
|
|
204
|
+
# @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.
|
|
205
|
+
# @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.
|
|
206
|
+
# @raise [IndexError] When a value is set at an index past the end of its array, or at an index under a missing value.
|
|
207
|
+
# @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
208
|
# @raise [EncodingError] When `source` is in an encoding other than UTF-8, binary, or US-ASCII, and does not convert to UTF-8.
|
|
199
209
|
#
|
|
200
210
|
# @example
|