soml-lang 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/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`, `: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
- 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,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
- def parse_bare_object
104
- members = []
105
-
106
- loop do
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
- break if @index >= @length
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(0x7D) { parse_member }
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(0x5D) { parse_value }
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 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
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
- until @source.getbyte(@index) == closing
134
- items << yield
135
- skip_trivia
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
- @index += 1
235
+ if @source.getbyte(@index) == 0x2C # ,
236
+ parse_punctuator
139
237
  skip_trivia
140
238
  end
141
239
 
142
- @index += 1
143
- items
240
+ item
144
241
  end
145
242
 
146
- def parse_member
147
- key = parse_key
148
- @index += 1 # :
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
- @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)
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
- finish = start
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
- KeyNode.new(source.byteslice(start, finish - start), start, finish)
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
- ScalarNode.new(false, start, @index)
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
- ScalarNode.new(false, start, finish)
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 ScalarNode.new(true, start, @index)
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
- # 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
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
@@ -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.3'
6
6
  end
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) { |byte| byte.chr.match?(/[A-Za-z0-9_-]/) }.freeze
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) { |byte| byte.chr.match?(/[A-Za-z0-9_.:+-]/) }.freeze
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) { |level| ("\t" * level).freeze }.freeze
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 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.
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, as the regex did.
58
- key = key.to_s unless String === 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 ArgumentError, EncodingError
75
- # Invalid bytes, or a key that does not convert to UTF-8.
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
- Parser.new(source, symbolize_names:, freeze:).parse
99
- end
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 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`.
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 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.
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 the text is canonical form. Two equal values then give the same text, which can be hashed, signed, or compared.
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. 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.
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, as the specification states:
153
+ # In detail:
144
154
  #
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.
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 the spelling of every other value. For tools that update a config file, such as a dependency bumper or a `set` command.
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 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.
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, 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}]`.
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, 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]`.
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 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.
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>] The keys and array indexes that lead to the value, such as `['servers', 0, 'port']`.
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>] The keys and array indexes that lead to the value.
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 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.
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