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 +7 -0
- data/lib/soml/duration.rb +167 -0
- data/lib/soml/edit.rb +468 -0
- data/lib/soml/error.rb +171 -0
- data/lib/soml/format.rb +331 -0
- data/lib/soml/generator.rb +364 -0
- data/lib/soml/parser.rb +1856 -0
- data/lib/soml/tree.rb +239 -0
- data/lib/soml/version.rb +6 -0
- data/lib/soml-lang.rb +4 -0
- data/lib/soml.rb +226 -0
- data/license +9 -0
- data/readme.md +552 -0
- data/sig/soml.rbs +143 -0
- metadata +58 -0
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
|