soml-lang 0.0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1da7419c0865c3f10d59dab2f4e5b8d638b814a11ed322a2885c7d6e3e3aecee
4
+ data.tar.gz: ed89f8ed61c0c624cf3023ec9f3199b76eec6e7f090992df90409f065e175937
5
+ SHA512:
6
+ metadata.gz: de26c20a01ef8fa144bf98402e2ae7d145f9b1ba22dfbd7b8fa02c283a3e66efbdf0060a9d5718ace01f1240384af0090c19717a802e3563e8c1d9f4534f182a
7
+ data.tar.gz: 65dce6ea77612c06ab6b5d656f1aa4bd030306c590416013b508797b47dd03ebedd3faa7be0ab9deb83d79d246ebc8b448cd7b1ed4db86f4f39d9e600351009c
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SOML
4
+ # A length of time, exact to the nanosecond: what a SOML duration such as `1h30m` holds.
5
+ #
6
+ # It is immutable, comparable, and usable as a hash key. A duration is a signed count of nanoseconds in the 64-bit range, about 292 years either way.
7
+ #
8
+ # @!attribute [r] nanoseconds
9
+ # @return [Integer] The length in nanoseconds.
10
+ #
11
+ # @example
12
+ # timeout = SOML.parse('timeout: 1m30s')['timeout']
13
+ # #=> #<SOML::Duration 1m30s>
14
+ #
15
+ # timeout.to_f
16
+ # #=> 90.0
17
+ #
18
+ # SOML::Duration.parse('90s') == timeout
19
+ # #=> true
20
+ #
21
+ # SOML::Duration.new(nanoseconds: 1_500_000_000).to_s
22
+ # #=> "1.5s"
23
+ Duration = Data.define(:nanoseconds) do
24
+ include Comparable
25
+
26
+ # Read a duration written as in a document, such as `1h30m`, `1.5s`, or `-250ms`.
27
+ #
28
+ # @param text [String]
29
+ # @return [Duration]
30
+ # @raise [ParseError] When `text` is not a valid duration.
31
+ # @raise [TypeError] When `text` is not a string.
32
+ def self.parse(text)
33
+ Parser.new(text).parse_duration_literal
34
+ end
35
+
36
+ # Make a duration from a number of seconds, the inverse of {#to_r}. The seconds are rounded to the nearest nanosecond, because a `Float` such as `0.1` is not exact.
37
+ #
38
+ # @param seconds [Numeric] An `Integer`, a `Float`, a `Rational`, or another real number.
39
+ # @return [Duration]
40
+ # @raise [TypeError] When `seconds` is not a number.
41
+ # @raise [RangeError] When `seconds` is not finite, not real, or outside the 64-bit range of nanoseconds.
42
+ #
43
+ # @example
44
+ # SOML::Duration.from_seconds(90)
45
+ # #=> #<SOML::Duration 1m30s>
46
+ #
47
+ # SOML::Duration.from_seconds(0.1)
48
+ # #=> #<SOML::Duration 0.1s>
49
+ def self.from_seconds(seconds)
50
+ # `Numeric ===` checks the class itself, so an `ActiveSupport::Duration`, which only claims to be a `Numeric`, must be converted with `to_r` first. Its months are an average length.
51
+ raise TypeError, "A duration is made from a number of seconds, got #{Parser.describe_argument(seconds)}" unless Numeric === seconds
52
+
53
+ new(nanoseconds: (seconds.to_r * 1_000_000_000).round)
54
+ end
55
+
56
+ # @param nanoseconds [Integer] The length in nanoseconds.
57
+ # @raise [TypeError] When `nanoseconds` is not an `Integer`.
58
+ # @raise [RangeError] When `nanoseconds` is outside the 64-bit range.
59
+ def initialize(nanoseconds:)
60
+ raise TypeError, "A duration is a whole number of nanoseconds, got #{Parser.describe_argument(nanoseconds)}" unless Integer === nanoseconds
61
+ raise RangeError, "A duration must be in the 64-bit range of nanoseconds, about 292 years either way, got #{nanoseconds}" unless nanoseconds.between?(INT64_MIN, INT64_MAX)
62
+
63
+ super
64
+ end
65
+
66
+ # @return [Integer, nil]
67
+ def <=>(other)
68
+ # `Module#===` rather than `is_a?`, which a `BasicObject` does not have.
69
+ nanoseconds <=> other.nanoseconds if Duration === other
70
+ end
71
+
72
+ # @return [Boolean]
73
+ def zero? = nanoseconds.zero?
74
+
75
+ # @return [Boolean]
76
+ def positive? = nanoseconds.positive?
77
+
78
+ # @return [Boolean]
79
+ def negative? = nanoseconds.negative?
80
+
81
+ # @return [Duration] The same length in the other direction.
82
+ # @raise [RangeError] For the most negative duration, whose opposite is outside the 64-bit range.
83
+ def -@
84
+ Duration.new(nanoseconds: -nanoseconds)
85
+ end
86
+
87
+ # @return [Duration]
88
+ # @raise [RangeError] For the most negative duration, whose length is outside the 64-bit range.
89
+ def abs
90
+ negative? ? -self : self
91
+ end
92
+
93
+ # A number has no unit, so only a duration can be added.
94
+ #
95
+ # @param other [Duration]
96
+ # @return [Duration]
97
+ # @raise [TypeError] When `other` is not a duration.
98
+ # @raise [RangeError] When the sum is outside the 64-bit range.
99
+ #
100
+ # @example
101
+ # SOML::Duration.parse('1h') + SOML::Duration.parse('30m')
102
+ # #=> #<SOML::Duration 1h30m>
103
+ def +(other)
104
+ Duration.new(nanoseconds: nanoseconds + nanoseconds_of(other))
105
+ end
106
+
107
+ # @param other [Duration]
108
+ # @return [Duration]
109
+ # @raise [TypeError] When `other` is not a duration.
110
+ # @raise [RangeError] When the difference is outside the 64-bit range.
111
+ def -(other)
112
+ Duration.new(nanoseconds: nanoseconds - nanoseconds_of(other))
113
+ end
114
+
115
+ # @return [Rational] The length in seconds, exactly.
116
+ def to_r
117
+ Rational(nanoseconds, 1_000_000_000)
118
+ end
119
+
120
+ # @return [Float] The length in seconds, for `sleep` and `Timeout.timeout`.
121
+ def to_f
122
+ nanoseconds.fdiv(1_000_000_000)
123
+ end
124
+
125
+ # @return [String] The canonical spelling, such as `1h30m`, `1.5s`, or `0s`.
126
+ def to_s
127
+ # Not frozen, as the text that is built below is not.
128
+ return +'0s' if nanoseconds.zero?
129
+
130
+ magnitude = nanoseconds.abs
131
+ hours, rest = magnitude.divmod(3_600_000_000_000)
132
+ minutes, rest = rest.divmod(60_000_000_000)
133
+ seconds, fraction = rest.divmod(1_000_000_000)
134
+ text = nanoseconds.negative? ? +'-' : +''
135
+ text << "#{hours}h" if hours.positive?
136
+ text << "#{minutes}m" if minutes.positive?
137
+
138
+ if seconds.positive? || fraction.positive?
139
+ text << seconds.to_s
140
+ # The fraction is written like an instant's: nine digits with the trailing zeros removed.
141
+ text << ".#{format('%09d', fraction).sub(/0+\z/, '')}" if fraction.positive?
142
+ text << 's'
143
+ end
144
+
145
+ text
146
+ end
147
+
148
+ # @return [String]
149
+ def inspect
150
+ "#<SOML::Duration #{self}>"
151
+ end
152
+
153
+ # @api private
154
+ def pretty_print(printer)
155
+ printer.text(inspect)
156
+ end
157
+
158
+ private
159
+
160
+ # The message is the one Ruby gives for `1 + nil`.
161
+ def nanoseconds_of(other)
162
+ return other.nanoseconds if Duration === other
163
+
164
+ raise TypeError, "#{Parser.conversion_name(other)} can't be coerced into SOML::Duration"
165
+ end
166
+ end
167
+ end
data/lib/soml/edit.rb ADDED
@@ -0,0 +1,468 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SOML
4
+ # The editor behind {SOML.edit}. It is a close port of the JS reference's `edit.ts`: it finds the value at a path in the {Tree} of a document, and makes the change as replacements of parts of the source, so everything else keeps its text.
5
+ #
6
+ # @api private
7
+ class Editor
8
+ # What removing a member or an item takes out of the source. `is_whole_lines` is whether it takes whole lines, which may leave a blank line next to another one.
9
+ Removal = Struct.new(:start, :finish, :is_whole_lines)
10
+
11
+ Replacement = Data.define(:start, :finish, :text)
12
+
13
+ private_constant :Removal, :Replacement
14
+
15
+ # @param source [String] The document.
16
+ # @param path [Array<String, Symbol, Integer>]
17
+ # @param value [Object] The new value, unless `delete` is true.
18
+ # @param delete [Boolean] Whether to remove the value instead.
19
+ def initialize(source, path, value, delete:)
20
+ # `Array#all?` on something else than an array, such as a hash, would read it in another way.
21
+ unless Array === path && !path.empty? && path.all? { |segment| (Integer === segment && !segment.negative?) || String === segment || Symbol === segment }
22
+ raise TypeError, 'The path must be a non-empty array of keys and array indexes'
23
+ end
24
+
25
+ # A key is checked as `generate` checks one, and read as the UTF-8 string it names, so that a symbol finds its string. A key of a removal is not written, so a carriage return in it is no error: it finds nothing.
26
+ generator = Generator.new
27
+ names = []
28
+
29
+ path.each do |segment|
30
+ names << (Integer === segment ? segment : generator.member_name(segment, written: !delete))
31
+ rescue GenerateError => error
32
+ # A key that cannot be written is reported at the place of the object that holds it, as `generate` reports one.
33
+ error.path.unshift(*names)
34
+ raise
35
+ end
36
+
37
+ @path = names.freeze
38
+ @value = value
39
+ @delete = delete
40
+
41
+ tree = Tree.new(source)
42
+ @source = tree.source
43
+ # The same bytes, for the searches for a line break from an offset that may be inside a character, such as the last byte of a line that ends with `é`, which `String#byterindex` refuses in UTF-8.
44
+ @bytes = @source.b
45
+ @length = @source.bytesize
46
+ @body = tree.body
47
+ @comments = tree.comments
48
+ @replacements = []
49
+ # Whether the container being edited is inside one that stays on one line, so that it stays on one line too. An edit follows one path, so one flag is enough.
50
+ @is_inside_one_line = false
51
+
52
+ # The end of each comment, by its start, so that the trivia between tokens can be skipped.
53
+ @comment_ends = @comments.to_h { [it.start, it.finish] }
54
+ # The start of each block comment, by its end, so that the comments before a member or an item can be found.
55
+ @block_comment_starts = @comments.select { it.type == :block }.to_h { [it.finish, it.start] }
56
+ end
57
+
58
+ # @return [String] The changed document.
59
+ def edit
60
+ descend(@body, 0, 1)
61
+
62
+ # Replacements never overlap. A stable sort keeps the creation order of two insertions at the same offset.
63
+ result = +''
64
+ position = 0
65
+
66
+ @replacements.sort_by.with_index { |replacement, index| [replacement.start, index] }.each do |replacement|
67
+ result << @source.byteslice(position, replacement.start - position)
68
+ result << replacement.text
69
+ position = replacement.finish
70
+ end
71
+
72
+ result << @source.byteslice(position, @length - position)
73
+
74
+ # The result is checked, so that a mistake here raises rather than writing a broken document.
75
+ begin
76
+ Parser.new(result).parse
77
+ rescue ParseError
78
+ raise "SOML.edit made an invalid document for the path #{describe_path(@path)}. This is a bug in soml-lang"
79
+ end
80
+
81
+ result
82
+ end
83
+
84
+ private
85
+
86
+ def edit_in_array(array, index, depth)
87
+ position = @path[index]
88
+ raise TypeError, "Cannot edit #{describe_path(@path)}, because #{describe_parent(index)} is an array, so it needs an index, not a key" unless Integer === position
89
+
90
+ elements = array.elements
91
+
92
+ # An index past the end, which may be too large for `Array#[]`.
93
+ if position >= elements.length
94
+ # Removing an item that is not there changes nothing, at the end of the array or past it, and so does removing something below it.
95
+ return if @delete
96
+
97
+ count = elements.length
98
+
99
+ if position != count
100
+ items = count == 1 ? '1 item' : "#{count} items"
101
+ owner = index.zero? ? 'the document' : "the array at #{describe_parent(index)}"
102
+ raise IndexError, "Cannot edit #{describe_path(@path)}, because #{owner} has #{items}. Add an item at index #{count}"
103
+ end
104
+
105
+ new_value = nest(index + 1)
106
+ insert(array, elements.last) { |indentation| value_text(new_value, index + 1, depth + 1, indentation, array) }
107
+ return
108
+ end
109
+
110
+ element = elements[position]
111
+
112
+ if index < @path.length - 1
113
+ @is_inside_one_line = one_line?(array)
114
+ descend(element, index + 1, depth + 1)
115
+ elsif @delete
116
+ remove_node(array, element)
117
+ else
118
+ replace(element, depth + 1, array)
119
+ end
120
+ end
121
+
122
+ def edit_in_object(object, index, depth)
123
+ key = @path[index]
124
+ raise TypeError, "Cannot edit #{describe_path(@path)}, because #{describe_parent(index)} is an object, so it needs a key, not an index" unless String === key
125
+
126
+ member = object.members.find { it.key.value == key }
127
+
128
+ if member
129
+ if index < @path.length - 1
130
+ @is_inside_one_line = one_line?(object)
131
+ descend(member.value, index + 1, depth + 1)
132
+ elsif @delete
133
+ remove_member(object, member)
134
+ else
135
+ replace_member_value(member, depth + 1, object)
136
+ end
137
+
138
+ return
139
+ end
140
+
141
+ return if @delete
142
+
143
+ written_key = SOML.bare_key?(key) ? key : Generator.new.generate_value(key, 0)
144
+ new_value = nest(index + 1)
145
+ insert(object, object.members.last) { |indentation| "#{written_key}: #{value_text(new_value, index + 1, depth + 1, indentation, object)}" }
146
+ end
147
+
148
+ # Edits the path from `index` on, inside `node`, whose own depth is `depth`.
149
+ def descend(node, index, depth)
150
+ case node.type
151
+ when :array
152
+ edit_in_array(node, index, depth)
153
+ when :object
154
+ edit_in_object(node, index, depth)
155
+ else
156
+ raise TypeError, "Cannot edit #{describe_path(@path)}, because #{describe_parent(index)} is not an object or an array"
157
+ end
158
+ end
159
+
160
+ def replace(node, depth, container)
161
+ @replacements << Replacement.new(node.start, node.finish, value_text(@value, @path.length, depth, indentation(node.start), container))
162
+ end
163
+
164
+ # Replaces the value of `member`. A block string begins on the line after its key, and the new value is never a block string, so when only whitespace comes between the `:` and a block string, the new value goes on the key's line, as the formatter writes it.
165
+ def replace_member_value(member, depth, object)
166
+ value = member.value
167
+ colon_end = member.key.finish + 1
168
+
169
+ unless value.type == :scalar && value.block && skip_whitespace(colon_end) == value.start
170
+ replace(value, depth, object)
171
+ return
172
+ end
173
+
174
+ @replacements << Replacement.new(colon_end, value.finish, " #{value_text(@value, @path.length, depth, indentation(member.start), object)}")
175
+ end
176
+
177
+ # Removes `member`. Removing the only member of a document without braces leaves `{}` in its place, because a document is never empty, and the comments that the member owned stay.
178
+ def remove_member(object, member)
179
+ if object.braced || object.members.length > 1
180
+ remove_node(object, member)
181
+ return
182
+ end
183
+
184
+ @replacements << Replacement.new(member.start, member.finish, '{}')
185
+ end
186
+
187
+ # Removes `node` from `container`.
188
+ #
189
+ # The removal also takes a blank line that would be left next to another one or directly inside a bracket. When the only item of a braced container goes and no comment is left inside, its brackets close up, as `[]`.
190
+ def remove_node(container, node)
191
+ items = container.type == :array ? container.elements : container.members
192
+ # When the removed item is the last one and has no comma after it, it takes the comma before it, so that no trailing comma is left, as `[1, 2]` becomes `[1]`.
193
+ takes_comma_before = node.equal?(items.last) && @source.getbyte(skip_trivia(node.finish)) != 0x2C
194
+ removal = node_removal(node, takes_comma_before)
195
+ take_blank_line(removal, container, content_start(container)) if removal.is_whole_lines
196
+
197
+ if items.length == 1 && braced?(container) && !comment_left?(container, removal)
198
+ @replacements << Replacement.new(container.start + 1, container.finish - 1, '')
199
+ return
200
+ end
201
+
202
+ @replacements << Replacement.new(removal.start, removal.finish, '')
203
+ end
204
+
205
+ # Whether a comment inside `container` is outside `removal`.
206
+ def comment_left?(container, removal)
207
+ @comments.any? do |comment|
208
+ comment_start = comment.start
209
+ comment_start > container.start && comment_start < container.finish && (comment_start < removal.start || comment_start >= removal.finish)
210
+ end
211
+ end
212
+
213
+ # What removing a member or an item, with the comments it owns and its comma, takes out of the source. With `takes_comma_before`, a comma directly before it goes too.
214
+ def node_removal(node, takes_comma_before)
215
+ start = owned_start(node.start)
216
+ finish = owned_end(node.finish)
217
+ following = skip_spaces(finish)
218
+ # Only a braced container can have commas.
219
+ has_comma = @source.getbyte(following) == 0x2C
220
+
221
+ # The comma before it is only taken when spaces alone are between them and the comments it owns. The scan back only crosses spaces, so it stays short on a long line.
222
+ before = skip_spaces_back(start)
223
+ removal_start = takes_comma_before && before.positive? && @source.getbyte(before - 1) == 0x2C ? before - 1 : start
224
+ # The comments after its comma go with it when they end its line, as the formatter keeps them after it.
225
+ result = removal(removal_start, has_comma ? line_end_after_trivia(following + 1) || following + 1 : finish)
226
+
227
+ # When it takes the comma before it, the spaces before a comment after it stay, so that the comment does not join the item before the comma.
228
+ if removal_start != start && !result.is_whole_lines && @comment_ends.key?(result.finish)
229
+ result.finish = skip_spaces_back(result.finish)
230
+ end
231
+
232
+ result
233
+ end
234
+
235
+ # The start of the block comments that a member or an item at `offset` owns before it: those on its line after the comma or the opening bracket before it, as `/* note */` in `[1, /* note */ 2]`. The formatter keeps them in front of it. It is `offset` when there are none.
236
+ def owned_start(offset)
237
+ while (comment_start = @block_comment_starts[skip_spaces_back(offset)])
238
+ offset = comment_start
239
+ end
240
+
241
+ offset
242
+ end
243
+
244
+ # The end of the comments that a member or an item ending at `offset` owns after it: those on its line before its comma or the closing bracket, as `/* note */` in `[1 /* note */, 2]` and `[1, 2 /* note */]`, and a line comment at the end of its line. The formatter keeps them after it. It is `offset` when there are none.
245
+ def owned_end(offset)
246
+ while (comment_end = @comment_ends[skip_spaces(offset)])
247
+ offset = comment_end
248
+ end
249
+
250
+ offset
251
+ end
252
+
253
+ # The removal of the text from `start` to `finish`. When nothing else is on its lines, it takes the lines, including a comment at the end of the last one.
254
+ def removal(start, finish)
255
+ line_end = line_end_after_trivia(finish)
256
+
257
+ # The line start is only looked for when nothing follows on the line, because the scan back to it is long for an item in the middle of a long line, and doing it for every item would be quadratic.
258
+ if line_end
259
+ line_start = line_start(start)
260
+ return Removal.new(line_start, [line_end + 1, @length].min, true) if skip_spaces(line_start) == start
261
+ end
262
+
263
+ # The spaces after the text go too, and the spaces before it when nothing follows it on the line or in its container.
264
+ after = skip_spaces(finish)
265
+ byte = @source.getbyte(after)
266
+ is_last = byte.nil? || byte == 0x0A || byte == 0x7D || byte == 0x5D
267
+ Removal.new(is_last ? skip_spaces_back(start) : start, after, false)
268
+ end
269
+
270
+ # Widens a removal of whole lines by a blank line that would otherwise be left next to another blank line, or directly inside a bracket. `content_start` is the container's, from `content_start`.
271
+ def take_blank_line(removal, container, content_start)
272
+ is_blank_before = removal.start.positive? && blank_line?(line_start(removal.start - 1))
273
+ is_blank_after = removal.finish < @length && blank_line?(removal.finish)
274
+
275
+ if is_blank_before && (is_blank_after || at_closing?(removal.finish, container))
276
+ removal.start = line_start(removal.start - 1)
277
+ elsif is_blank_after && removal.start == content_start
278
+ removal.finish = @bytes.byteindex("\n", removal.finish) + 1
279
+ end
280
+ end
281
+
282
+ # The offset after the blank lines that start at `offset`.
283
+ def skip_blank_lines(offset)
284
+ offset = @bytes.byteindex("\n", offset) + 1 while offset < @length && blank_line?(offset)
285
+ offset
286
+ end
287
+
288
+ # Whether only blank lines come between the line at `offset` and the line that closes `container`, or the end of the source.
289
+ def at_closing?(offset, container)
290
+ offset = skip_blank_lines(offset)
291
+ offset == @length || (braced?(container) && skip_spaces(offset) == container.finish - 1)
292
+ end
293
+
294
+ # The start of the first line that is not blank after the line that opens `container`, or after the start of the source. The line that opens a container ends after the comments that follow its bracket, and when an item follows on it, this is `nil`.
295
+ def content_start(container)
296
+ return skip_blank_lines(0) unless braced?(container)
297
+
298
+ line_end = line_end_after_trivia(container.start + 1)
299
+ line_end && skip_blank_lines(line_end + 1)
300
+ end
301
+
302
+ def blank_line?(line_start)
303
+ @source.getbyte(skip_spaces(line_start)) == 0x0A
304
+ end
305
+
306
+ def line_start(offset)
307
+ return 0 if offset.zero?
308
+
309
+ (@bytes.byterindex("\n", offset - 1) || -1) + 1
310
+ end
311
+
312
+ # Adds an item after `previous`, or into the empty `container` when there is none. The block gets the indentation of the item's line, and returns the item.
313
+ #
314
+ # An item on its own line needs no comma, so only an item added on the line of the item before it gets one, as in a one-line container.
315
+ def insert(container, previous)
316
+ if previous.nil?
317
+ closing = container.finish - 1
318
+
319
+ # A container that stays on one line keeps the item on that line, so `[/* note */]` becomes `[/* note */ 1]`, and `[1, []]` becomes `[1, [2]]`.
320
+ if one_line?(container)
321
+ start = skip_spaces_back(closing)
322
+ @replacements << Replacement.new(start, closing, "#{start > container.start + 1 ? ' ' : ''}#{yield ''}")
323
+ return
324
+ end
325
+
326
+ outer = indentation(container.start)
327
+ inner = "#{outer}\t"
328
+ line_start = line_start(closing)
329
+
330
+ # A closing bracket on its own line keeps that line, and the item goes on a line before it.
331
+ @replacements << if line_start > container.start && skip_spaces(line_start) == closing
332
+ Replacement.new(line_start, line_start, "#{inner}#{yield inner}\n")
333
+ else
334
+ Replacement.new(skip_spaces_back(closing), closing, "\n#{inner}#{yield inner}\n#{outer}")
335
+ end
336
+
337
+ return
338
+ end
339
+
340
+ # The new item goes after the comments that the one before it owns, so that they stay with that one.
341
+ previous_end = owned_end(previous.finish)
342
+ following = skip_trivia(previous_end)
343
+ has_comma = @source.getbyte(following) == 0x2C
344
+ after_comma = has_comma ? following + 1 : previous_end
345
+ line_end = line_end_after_trivia(after_comma)
346
+
347
+ # Something follows on the same line, such as the closing bracket of a one-line object, so the item goes on that line too, and takes its indentation.
348
+ if line_end.nil?
349
+ item = yield indentation(after_comma)
350
+ @replacements << (has_comma ? Replacement.new(after_comma, after_comma, " #{item},") : Replacement.new(previous_end, previous_end, ", #{item}"))
351
+ return
352
+ end
353
+
354
+ indentation = indentation(previous.start)
355
+ @replacements << Replacement.new(line_end, line_end, "\n#{indentation}#{yield indentation}")
356
+ end
357
+
358
+ # Whether `container` stays on one line: its brackets are on one line and something is between them, which {SOML.format} keeps, or it is inside a container that stays on one line. An empty `[]` or `{}` on its own gets its new item on a line of its own.
359
+ def one_line?(container)
360
+ return true if @is_inside_one_line
361
+
362
+ start = container.start
363
+ finish = container.finish
364
+ braced?(container) && skip_spaces(start + 1) < finish - 1 && !@source.byteslice(start, finish - start).include?("\n")
365
+ end
366
+
367
+ # `value` as {SOML.generate} writes it, written inside `container` where the value at the first `path_length` segments of the path goes, as a container of depth `depth` whose lines after the first are indented by `indentation`. In a container on one line, the value is on one line too.
368
+ def value_text(value, path_length, depth, indentation, container)
369
+ text = Generator.new(one_line: one_line?(container)).generate_value(value, depth)
370
+
371
+ # Every line break that {Generator} writes is between two lines of a container, because a string writes its line feeds as `\n`.
372
+ indentation.empty? ? text : text.gsub("\n", "\n#{indentation}")
373
+ rescue GenerateError => error
374
+ # The error names the value's place in the document, as `generate` would name it in the whole document.
375
+ error.path.unshift(*@path.first(path_length))
376
+ raise
377
+ end
378
+
379
+ # The spaces and tabs at the start of the line that holds `offset`.
380
+ def indentation(offset)
381
+ line_start = line_start(offset)
382
+
383
+ # A line that a block comment from an earlier line runs into, as in `/* a\n b */ x: 1`, is indented as the line where that comment starts.
384
+ while (comment = comment_across(line_start))
385
+ line_start = line_start(comment.start)
386
+ end
387
+
388
+ @source.byteslice(line_start, skip_spaces(line_start) - line_start)
389
+ end
390
+
391
+ # The comment that starts before `offset` and ends after it, if any. The comments are in source order, so a binary search finds the last one that starts before `offset`.
392
+ def comment_across(offset)
393
+ index = @comments.bsearch_index { it.start >= offset } || @comments.length
394
+ comment = index.positive? ? @comments[index - 1] : nil
395
+ comment if comment && comment.finish > offset
396
+ end
397
+
398
+ # Skips whitespace, line breaks, and comments.
399
+ def skip_trivia(offset)
400
+ offset = skip_line_trivia(offset)
401
+ offset = skip_line_trivia(offset + 1) while @source.getbyte(offset) == 0x0A
402
+ offset
403
+ end
404
+
405
+ # Skips spaces, tabs, and comments, but not a line break outside a comment.
406
+ def skip_line_trivia(offset)
407
+ loop do
408
+ offset = skip_spaces(offset)
409
+ comment_end = @comment_ends[offset]
410
+ return offset if comment_end.nil?
411
+
412
+ offset = comment_end
413
+ end
414
+ end
415
+
416
+ # The offset of the line break that ends the line at `offset`, or the end of the source, when only whitespace and comments come before it. Otherwise `nil`.
417
+ def line_end_after_trivia(offset)
418
+ offset = skip_line_trivia(offset)
419
+ offset if offset == @length || @source.getbyte(offset) == 0x0A
420
+ end
421
+
422
+ # Skips spaces, tabs, and line feeds, but not comments.
423
+ def skip_whitespace(offset)
424
+ offset += 1 while space?(byte = @source.getbyte(offset)) || byte == 0x0A
425
+ offset
426
+ end
427
+
428
+ def skip_spaces(offset)
429
+ offset += 1 while space?(@source.getbyte(offset))
430
+ offset
431
+ end
432
+
433
+ def skip_spaces_back(offset)
434
+ offset -= 1 while offset.positive? && space?(@source.getbyte(offset - 1))
435
+ offset
436
+ end
437
+
438
+ def space?(byte)
439
+ byte == 0x20 || byte == 0x09
440
+ end
441
+
442
+ def braced?(container)
443
+ container.type == :array || container.braced
444
+ end
445
+
446
+ # `@value` inside new objects for the keys of the path from `index` on. An index there names an item of an array that does not exist yet, so it raises an `IndexError`.
447
+ def nest(index)
448
+ nested = @value
449
+
450
+ (@path.length - 1).downto(index) do |position|
451
+ segment = @path[position]
452
+ raise IndexError, "Cannot edit #{describe_path(@path)}, because #{describe_parent(position)} does not exist, so it has no index #{segment}" if Integer === segment
453
+
454
+ nested = {segment => nested}
455
+ end
456
+
457
+ nested
458
+ end
459
+
460
+ # The path comes from the caller, perhaps from user input, so it is cut short, and the characters that are invisible or that a terminal acts on become escapes.
461
+ def describe_path(path) = Parser.describe_path(path)
462
+
463
+ # The value that holds `@path[index]`.
464
+ def describe_parent(index)
465
+ index.zero? ? 'the document' : describe_path(@path.first(index))
466
+ end
467
+ end
468
+ end