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.
data/lib/soml/tree.rb CHANGED
@@ -1,16 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SOML
4
- # The syntax tree of a valid document, for {SOML.format} and {SOML.edit}.
4
+ # A read-only syntax tree, returned by {SOML.parse_tree} and used by {SOML.format} and {SOML.edit}.
5
5
  #
6
- # It is a port of the JS reference's `tree.ts`, with only what the formatter and the editor read. Every offset is a byte offset into the UTF-8 source, and `finish` is the offset after the node. A node has no line and column, a scalar has no value, and there is no list of tokens. The document is checked by {Parser} first, so the builder can assume valid input, and the comments are collected in source order.
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
- private_constant(*constants(false))
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
- def parse_bare_object
104
- members = []
105
-
106
- loop do
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
- break if @index >= @length
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(0x7D) { parse_member }
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(0x5D) { parse_value }
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 bracket and its `closing` bracket, which is consumed too. The parser has already checked the separators, including that a comma is on the line of the item before it.
128
- def parse_items(closing)
129
- items = []
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
- until @source.getbyte(@index) == closing
134
- items << yield
135
- skip_trivia
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
- @index += 1
234
+ if @source.getbyte(@index) == 0x2C # ,
235
+ parse_punctuator
139
236
  skip_trivia
140
237
  end
141
238
 
142
- @index += 1
143
- items
239
+ item
144
240
  end
145
241
 
146
- def parse_member
147
- key = parse_key
148
- @index += 1 # :
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
- @index = finish
162
- text = source.byteslice(start + 1, finish - start - 2)
163
-
164
- # A `"..."` key with an escape is decoded by the parser itself, so that the tree and `SOML.parse` cannot disagree.
165
- return KeyNode.new(byte == 0x22 && text.include?('\\') ? read_scalar(start, finish) : text, start, finish)
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
- finish = start
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
- KeyNode.new(source.byteslice(start, finish - start), start, finish)
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
- ScalarNode.new(false, start, @index)
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
- ScalarNode.new(false, start, finish)
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 ScalarNode.new(true, start, @index)
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
- # The value of the scalar from `start` to `finish`, read by the parser. A scalar's validity does not depend on its context, so it parses on its own inside an array.
235
- def read_scalar(start, finish)
236
- Parser.new("[#{@source.byteslice(start, finish - start)}]").parse.first
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module SOML
4
4
  # The version of this gem.
5
- VERSION = '0.0.1'
5
+ VERSION = '0.0.2'
6
6
  end
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) { |byte| byte.chr.match?(/[A-Za-z0-9_-]/) }.freeze
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) { |byte| byte.chr.match?(/[A-Za-z0-9_.:+-]/) }.freeze
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) { |level| ("\t" * level).freeze }.freeze
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 in UTF-8, 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 read as the UTF-8 string it converts to, as {SOML.generate} reads it. Any other object is not a key, so it gives false.
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, as the regex did.
58
- key = key.to_s unless String === 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 ArgumentError, EncodingError
75
- # Invalid bytes, or a key that does not convert to UTF-8.
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
- Parser.new(source, symbolize_names:, freeze:).parse
99
- end
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 carriage return, which a document may not contain, is not hidden by a text-mode conversion. An error names the file, as in `Duplicate key port at config.soml:3:1`.
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 is written with braces and tabs, and the text ends with one line feed. Comments and block strings are never written. Everything but the order of members is canonical form, so the text is canonical form when the keys are already sorted.
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 the text is canonical form. Two equal values then give the same text, which can be hashed, signed, or compared.
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. An object or an array whose brackets are on one line stays on one line, as in `ports: [80, 443]`, with a comma and a space between its members or items. To give a one-line container one member or item per line, put a line break anywhere inside it. Comments, member order, block strings, and the spelling of every value stay as they are, so the value never changes. The one change inside a block comment is the same layout rule: trailing whitespace is removed, and runs of blank lines collapse to one.
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, as the specification states:
149
+ # In detail:
144
150
  #
145
- # - 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.
146
- # - One space follows each `:`, as in canonical form.
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, and the blank lines between them are removed.
148
- # - 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.
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 the spelling of every other value. For tools that update a config file, such as a dependency bumper or a `set` command.
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 yet, and `delete: true` removes it. Only the changed part of the text is rewritten, and an edit to a formatted document leaves it formatted.
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, 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}]`.
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, with the index that is its length.
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), 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]]`.
175
- # - 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.
176
- # - 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]`.
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 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. Removing a value that does not exist changes nothing and is not an error: a missing member, a missing object or array on the way, or an index at or past the end of its array. 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 removed. The `.b` compares the bytes, because a source read in binary mode is not `==` to the UTF-8 result when it has a character that is not ASCII. This holds for a source in UTF-8, binary, or US-ASCII, because one in another encoding is converted to UTF-8.
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>] The keys and array indexes that lead to the value, such as `['servers', 0, 'port']`.
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>] The keys and array indexes that lead to the value.
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 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.
196
- # @raise [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.
197
- # @raise [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.
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