soml-lang 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/soml/duration.rb +14 -36
- data/lib/soml/edit.rb +42 -23
- data/lib/soml/error.rb +27 -17
- data/lib/soml/format.rb +14 -4
- data/lib/soml/generator.rb +33 -23
- data/lib/soml/parser.rb +172 -200
- data/lib/soml/tree.rb +195 -64
- data/lib/soml/version.rb +1 -1
- data/lib/soml.rb +49 -43
- data/readme.md +114 -60
- data/sig/soml.rbs +98 -2
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 451e02a56833294ea93a0ebb269889d8c20c377329572e6a27090a2d2a6a621b
|
|
4
|
+
data.tar.gz: 2956309298dbd2934603257d28903ee913b30f4a34a45f11261a09f6b96487a8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 43bba0ce5a930333bcd33bb064a989e8a9cbb9b71bf409358778087c646f1a324306a264d5d8c575c76511ec2a56ef844c1b64198c8e1237ae12adc7b77adcd4
|
|
7
|
+
data.tar.gz: 57e43736b8cace1e4f049e34186f1cb4a47dc125ca854956fc93dc66ee732a5c465def2935dd9c96e0693b35d229ae5d1e2ae5668e9ba9edc2669c596055e7af
|
data/lib/soml/duration.rb
CHANGED
|
@@ -29,9 +29,7 @@ module SOML
|
|
|
29
29
|
# @return [Duration]
|
|
30
30
|
# @raise [ParseError] When `text` is not a valid duration.
|
|
31
31
|
# @raise [TypeError] When `text` is not a string.
|
|
32
|
-
def self.parse(text)
|
|
33
|
-
Parser.new(text).parse_duration_literal
|
|
34
|
-
end
|
|
32
|
+
def self.parse(text) = Parser.new(text).parse_duration_literal
|
|
35
33
|
|
|
36
34
|
# 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
35
|
#
|
|
@@ -80,15 +78,11 @@ module SOML
|
|
|
80
78
|
|
|
81
79
|
# @return [Duration] The same length in the other direction.
|
|
82
80
|
# @raise [RangeError] For the most negative duration, whose opposite is outside the 64-bit range.
|
|
83
|
-
def -@
|
|
84
|
-
Duration.new(nanoseconds: -nanoseconds)
|
|
85
|
-
end
|
|
81
|
+
def -@ = Duration.new(nanoseconds: -nanoseconds)
|
|
86
82
|
|
|
87
83
|
# @return [Duration]
|
|
88
84
|
# @raise [RangeError] For the most negative duration, whose length is outside the 64-bit range.
|
|
89
|
-
def abs
|
|
90
|
-
negative? ? -self : self
|
|
91
|
-
end
|
|
85
|
+
def abs = negative? ? -self : self
|
|
92
86
|
|
|
93
87
|
# A number has no unit, so only a duration can be added.
|
|
94
88
|
#
|
|
@@ -100,27 +94,19 @@ module SOML
|
|
|
100
94
|
# @example
|
|
101
95
|
# SOML::Duration.parse('1h') + SOML::Duration.parse('30m')
|
|
102
96
|
# #=> #<SOML::Duration 1h30m>
|
|
103
|
-
def +(other)
|
|
104
|
-
Duration.new(nanoseconds: nanoseconds + nanoseconds_of(other))
|
|
105
|
-
end
|
|
97
|
+
def +(other) = Duration.new(nanoseconds: nanoseconds + nanoseconds_of(other))
|
|
106
98
|
|
|
107
99
|
# @param other [Duration]
|
|
108
100
|
# @return [Duration]
|
|
109
101
|
# @raise [TypeError] When `other` is not a duration.
|
|
110
102
|
# @raise [RangeError] When the difference is outside the 64-bit range.
|
|
111
|
-
def -(other)
|
|
112
|
-
Duration.new(nanoseconds: nanoseconds - nanoseconds_of(other))
|
|
113
|
-
end
|
|
103
|
+
def -(other) = Duration.new(nanoseconds: nanoseconds - nanoseconds_of(other))
|
|
114
104
|
|
|
115
105
|
# @return [Rational] The length in seconds, exactly.
|
|
116
|
-
def to_r
|
|
117
|
-
Rational(nanoseconds, 1_000_000_000)
|
|
118
|
-
end
|
|
106
|
+
def to_r = Rational(nanoseconds, 1_000_000_000)
|
|
119
107
|
|
|
120
108
|
# @return [Float] The length in seconds, for `sleep` and `Timeout.timeout`.
|
|
121
|
-
def to_f
|
|
122
|
-
nanoseconds.fdiv(1_000_000_000)
|
|
123
|
-
end
|
|
109
|
+
def to_f = nanoseconds.fdiv(1_000_000_000)
|
|
124
110
|
|
|
125
111
|
# @return [String] The canonical spelling, such as `1h30m`, `1.5s`, or `0s`.
|
|
126
112
|
def to_s
|
|
@@ -128,32 +114,24 @@ module SOML
|
|
|
128
114
|
return +'0s' if nanoseconds.zero?
|
|
129
115
|
|
|
130
116
|
magnitude = nanoseconds.abs
|
|
131
|
-
hours, rest = magnitude.divmod(
|
|
132
|
-
minutes, rest = rest.divmod(
|
|
133
|
-
seconds, fraction = rest.divmod(
|
|
117
|
+
hours, rest = magnitude.divmod(DURATION_UNITS['h'])
|
|
118
|
+
minutes, rest = rest.divmod(DURATION_UNITS['m'])
|
|
119
|
+
seconds, fraction = rest.divmod(DURATION_UNITS['s'])
|
|
134
120
|
text = nanoseconds.negative? ? +'-' : +''
|
|
135
121
|
text << "#{hours}h" if hours.positive?
|
|
136
122
|
text << "#{minutes}m" if minutes.positive?
|
|
137
123
|
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
124
|
+
# The fraction is written like an instant's: nine digits with the trailing zeros removed.
|
|
125
|
+
text << "#{seconds}#{Generator.format_fraction(fraction)}s" if seconds.positive? || fraction.positive?
|
|
144
126
|
|
|
145
127
|
text
|
|
146
128
|
end
|
|
147
129
|
|
|
148
130
|
# @return [String]
|
|
149
|
-
def inspect
|
|
150
|
-
"#<SOML::Duration #{self}>"
|
|
151
|
-
end
|
|
131
|
+
def inspect = "#<SOML::Duration #{self}>"
|
|
152
132
|
|
|
153
133
|
# @api private
|
|
154
|
-
def pretty_print(printer)
|
|
155
|
-
printer.text(inspect)
|
|
156
|
-
end
|
|
134
|
+
def pretty_print(printer) = printer.text(inspect)
|
|
157
135
|
|
|
158
136
|
private
|
|
159
137
|
|
data/lib/soml/edit.rb
CHANGED
|
@@ -13,13 +13,20 @@ module SOML
|
|
|
13
13
|
private_constant :Removal, :Replacement
|
|
14
14
|
|
|
15
15
|
# @param source [String] The document.
|
|
16
|
-
# @param path [Array<String, Symbol, Integer
|
|
16
|
+
# @param path [Array<String, Symbol, Integer>, Tree::MemberNode, Tree::ObjectNode, Tree::ArrayNode, Tree::ScalarNode]
|
|
17
17
|
# @param value [Object] The new value, unless `delete` is true.
|
|
18
18
|
# @param delete [Boolean] Whether to remove the value instead.
|
|
19
19
|
def initialize(source, path, value, delete:)
|
|
20
|
+
case path
|
|
21
|
+
when Tree::MemberNode, Tree::ObjectNode, Tree::ArrayNode, Tree::ScalarNode
|
|
22
|
+
tree = Tree.new(source, lean: true)
|
|
23
|
+
path = node_path(tree.body, path)
|
|
24
|
+
raise ArgumentError, 'The node must be a member or a non-root value from the same source' unless path
|
|
25
|
+
end
|
|
26
|
+
|
|
20
27
|
# `Array#all?` on something else than an array, such as a hash, would read it in another way.
|
|
21
28
|
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'
|
|
29
|
+
raise TypeError, 'The path must be a non-empty array of keys and array indexes, or a member or value node from SOML.parse_tree'
|
|
23
30
|
end
|
|
24
31
|
|
|
25
32
|
# 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.
|
|
@@ -38,7 +45,7 @@ module SOML
|
|
|
38
45
|
@value = value
|
|
39
46
|
@delete = delete
|
|
40
47
|
|
|
41
|
-
tree
|
|
48
|
+
tree ||= Tree.new(source, lean: true)
|
|
42
49
|
@source = tree.source
|
|
43
50
|
# 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
51
|
@bytes = @source.b
|
|
@@ -52,7 +59,7 @@ module SOML
|
|
|
52
59
|
# The end of each comment, by its start, so that the trivia between tokens can be skipped.
|
|
53
60
|
@comment_ends = @comments.to_h { [it.start, it.finish] }
|
|
54
61
|
# 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.
|
|
62
|
+
@block_comment_starts = @comments.filter_map { [it.finish, it.start] if it.type == :block }.to_h
|
|
56
63
|
end
|
|
57
64
|
|
|
58
65
|
# @return [String] The changed document.
|
|
@@ -83,6 +90,25 @@ module SOML
|
|
|
83
90
|
|
|
84
91
|
private
|
|
85
92
|
|
|
93
|
+
# Resolve a parsed node to the path that the editor follows, or `nil` when it is not a member or a non-root value of `body`. Matching complete nodes also rejects mismatched spans or values; callers must still use the same source. The search goes down through the one member or item whose span holds the node, so it reads one of them at each level.
|
|
94
|
+
def node_path(body, target)
|
|
95
|
+
path = []
|
|
96
|
+
container = body
|
|
97
|
+
|
|
98
|
+
while container.type == :object || container.type == :array
|
|
99
|
+
is_object = container.type == :object
|
|
100
|
+
items = is_object ? container.members : container.elements
|
|
101
|
+
index = items.bsearch_index { it.finish > target.start }
|
|
102
|
+
item = index && items[index]
|
|
103
|
+
return if item.nil? || item.start > target.start
|
|
104
|
+
|
|
105
|
+
path << (is_object ? item.key.value : index)
|
|
106
|
+
return path if item == target || (is_object && item.value == target)
|
|
107
|
+
|
|
108
|
+
container = is_object ? item.value : item
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
86
112
|
def edit_in_array(array, index, depth)
|
|
87
113
|
position = @path[index]
|
|
88
114
|
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
|
|
@@ -140,7 +166,7 @@ module SOML
|
|
|
140
166
|
|
|
141
167
|
return if @delete
|
|
142
168
|
|
|
143
|
-
written_key =
|
|
169
|
+
written_key = Generator.format_key(key)
|
|
144
170
|
new_value = nest(index + 1)
|
|
145
171
|
insert(object, object.members.last) { |indentation| "#{written_key}: #{value_text(new_value, index + 1, depth + 1, indentation, object)}" }
|
|
146
172
|
end
|
|
@@ -192,7 +218,7 @@ module SOML
|
|
|
192
218
|
# 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
219
|
takes_comma_before = node.equal?(items.last) && @source.getbyte(skip_trivia(node.finish)) != 0x2C
|
|
194
220
|
removal = node_removal(node, takes_comma_before)
|
|
195
|
-
take_blank_line(removal, container
|
|
221
|
+
take_blank_line(removal, container) if removal.is_whole_lines
|
|
196
222
|
|
|
197
223
|
if items.length == 1 && braced?(container) && !comment_left?(container, removal)
|
|
198
224
|
@replacements << Replacement.new(container.start + 1, container.finish - 1, '')
|
|
@@ -267,21 +293,22 @@ module SOML
|
|
|
267
293
|
Removal.new(is_last ? skip_spaces_back(start) : start, after, false)
|
|
268
294
|
end
|
|
269
295
|
|
|
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.
|
|
271
|
-
def take_blank_line(removal, container
|
|
296
|
+
# 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.
|
|
297
|
+
def take_blank_line(removal, container)
|
|
272
298
|
is_blank_before = removal.start.positive? && blank_line?(line_start(removal.start - 1))
|
|
273
299
|
is_blank_after = removal.finish < @length && blank_line?(removal.finish)
|
|
274
300
|
|
|
275
301
|
if is_blank_before && (is_blank_after || at_closing?(removal.finish, container))
|
|
276
302
|
removal.start = line_start(removal.start - 1)
|
|
277
|
-
elsif is_blank_after && removal.start == content_start
|
|
278
|
-
|
|
303
|
+
elsif is_blank_after && removal.start == content_start(container)
|
|
304
|
+
# The line is blank, so its line break follows its spaces.
|
|
305
|
+
removal.finish = skip_spaces(removal.finish) + 1
|
|
279
306
|
end
|
|
280
307
|
end
|
|
281
308
|
|
|
282
309
|
# The offset after the blank lines that start at `offset`.
|
|
283
310
|
def skip_blank_lines(offset)
|
|
284
|
-
offset =
|
|
311
|
+
offset = skip_spaces(offset) + 1 while blank_line?(offset)
|
|
285
312
|
offset
|
|
286
313
|
end
|
|
287
314
|
|
|
@@ -299,9 +326,7 @@ module SOML
|
|
|
299
326
|
line_end && skip_blank_lines(line_end + 1)
|
|
300
327
|
end
|
|
301
328
|
|
|
302
|
-
def blank_line?(line_start)
|
|
303
|
-
@source.getbyte(skip_spaces(line_start)) == 0x0A
|
|
304
|
-
end
|
|
329
|
+
def blank_line?(line_start) = @source.getbyte(skip_spaces(line_start)) == 0x0A
|
|
305
330
|
|
|
306
331
|
def line_start(offset)
|
|
307
332
|
return 0 if offset.zero?
|
|
@@ -435,13 +460,9 @@ module SOML
|
|
|
435
460
|
offset
|
|
436
461
|
end
|
|
437
462
|
|
|
438
|
-
def space?(byte)
|
|
439
|
-
byte == 0x20 || byte == 0x09
|
|
440
|
-
end
|
|
463
|
+
def space?(byte) = byte == 0x20 || byte == 0x09
|
|
441
464
|
|
|
442
|
-
def braced?(container)
|
|
443
|
-
container.type == :array || container.braced
|
|
444
|
-
end
|
|
465
|
+
def braced?(container) = container.type == :array || container.braced
|
|
445
466
|
|
|
446
467
|
# `@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
468
|
def nest(index)
|
|
@@ -461,8 +482,6 @@ module SOML
|
|
|
461
482
|
def describe_path(path) = Parser.describe_path(path)
|
|
462
483
|
|
|
463
484
|
# The value that holds `@path[index]`.
|
|
464
|
-
def describe_parent(index)
|
|
465
|
-
index.zero? ? 'the document' : describe_path(@path.first(index))
|
|
466
|
-
end
|
|
485
|
+
def describe_parent(index) = index.zero? ? 'the document' : describe_path(@path.first(index))
|
|
467
486
|
end
|
|
468
487
|
end
|
data/lib/soml/error.rb
CHANGED
|
@@ -2,7 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
module SOML
|
|
4
4
|
# The base class of the errors this library raises.
|
|
5
|
-
class Error < StandardError
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
# Characters that a terminal acts on rather than shows: control characters other than tab, and bidirectional controls. They are replaced in an error message, so that printing it cannot run a terminal escape or reorder the text.
|
|
7
|
+
UNPRINTABLE = /[\p{Bidi_Control}[\p{Cc}&&[^\t]]]/
|
|
8
|
+
|
|
9
|
+
private_constant :UNPRINTABLE
|
|
10
|
+
|
|
11
|
+
# `text` with the characters that a terminal acts on replaced, for every message that can show a value or a name from the caller.
|
|
12
|
+
#
|
|
13
|
+
# @api private
|
|
14
|
+
# @param text [String]
|
|
15
|
+
# @return [String]
|
|
16
|
+
def self.sanitize(text) = text.gsub(UNPRINTABLE, "\uFFFD")
|
|
17
|
+
end
|
|
6
18
|
|
|
7
19
|
# Raised by {SOML.parse}, {SOML.load_file}, and {SOML::Duration.parse} when the input is not valid.
|
|
8
20
|
#
|
|
@@ -21,10 +33,7 @@ module SOML
|
|
|
21
33
|
CONTEXT_LINES = 2
|
|
22
34
|
MAX_LINE_WIDTH = 100
|
|
23
35
|
|
|
24
|
-
|
|
25
|
-
UNPRINTABLE = /[\p{Bidi_Control}[\p{Cc}&&[^\t]]]/
|
|
26
|
-
|
|
27
|
-
private_constant :CONTEXT_LINES, :MAX_LINE_WIDTH, :UNPRINTABLE
|
|
36
|
+
private_constant :CONTEXT_LINES, :MAX_LINE_WIDTH
|
|
28
37
|
|
|
29
38
|
# @return [String] What is wrong, without the position.
|
|
30
39
|
attr_reader :reason
|
|
@@ -47,7 +56,7 @@ module SOML
|
|
|
47
56
|
# @param byte_offset [Integer] Where the error is in `source`, in bytes.
|
|
48
57
|
# @param path [String, nil] The file the source is from.
|
|
49
58
|
def initialize(reason, source, byte_offset, path = nil)
|
|
50
|
-
@reason = sanitize(reason)
|
|
59
|
+
@reason = Error.sanitize(reason)
|
|
51
60
|
|
|
52
61
|
# A copy, which shares the bytes, so that a caller that changes its string afterwards does not change the code frame.
|
|
53
62
|
@source = source.dup
|
|
@@ -75,9 +84,7 @@ module SOML
|
|
|
75
84
|
# The message and the code frame, which Ruby shows for an uncaught error.
|
|
76
85
|
#
|
|
77
86
|
# @return [String]
|
|
78
|
-
def detailed_message(highlight: false, **)
|
|
79
|
-
"#{super}\n\n#{code_frame}"
|
|
80
|
-
end
|
|
87
|
+
def detailed_message(highlight: false, **) = "#{super}\n\n#{code_frame}"
|
|
81
88
|
|
|
82
89
|
private
|
|
83
90
|
|
|
@@ -95,11 +102,10 @@ module SOML
|
|
|
95
102
|
gutter_width = @line.to_s.length
|
|
96
103
|
output = []
|
|
97
104
|
|
|
98
|
-
line_starts.
|
|
99
|
-
number = first_line + index
|
|
105
|
+
line_starts.each.with_index(first_line) do |line_start, number|
|
|
100
106
|
is_error_line = number == @line
|
|
101
107
|
line_end = @source.index("\n", line_start) || @source.length
|
|
102
|
-
text, pointer = clip(sanitize(@source[line_start...line_end]), is_error_line ? @offset - line_start : 0)
|
|
108
|
+
text, pointer = clip(Error.sanitize(@source[line_start...line_end]), is_error_line ? @offset - line_start : 0)
|
|
103
109
|
gutter = "#{is_error_line ? '>' : ' '} #{number.to_s.rjust(gutter_width)} |"
|
|
104
110
|
output << (text.empty? ? gutter : "#{gutter} #{text}")
|
|
105
111
|
|
|
@@ -124,13 +130,9 @@ module SOML
|
|
|
124
130
|
["#{prefix}#{text[start...finish]}#{suffix}", pointer - start + prefix.length]
|
|
125
131
|
end
|
|
126
132
|
|
|
127
|
-
def sanitize(text)
|
|
128
|
-
text.gsub(UNPRINTABLE, "\uFFFD")
|
|
129
|
-
end
|
|
130
|
-
|
|
131
133
|
# The path in UTF-8, so that it joins the reason, with what is not printable replaced, as in the reason.
|
|
132
134
|
def show_path(path)
|
|
133
|
-
sanitize(Parser.lossy_utf8(path))
|
|
135
|
+
Error.sanitize(Parser.lossy_utf8(path))
|
|
134
136
|
end
|
|
135
137
|
end
|
|
136
138
|
|
|
@@ -158,7 +160,15 @@ module SOML
|
|
|
158
160
|
# @api private
|
|
159
161
|
def initialize(reason)
|
|
160
162
|
@path = []
|
|
163
|
+
|
|
164
|
+
# The reason may name a class or show a value, as in `Cannot generate a Foo`, which can hold anything, so it is sanitized as the reason of a `ParseError` is.
|
|
165
|
+
super(Error.sanitize(reason))
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# A copy, as made by `raise error, 'Other'`, has its own path, so that adding to the path of one does not change the other.
|
|
169
|
+
def initialize_copy(other)
|
|
161
170
|
super
|
|
171
|
+
@path = @path.dup
|
|
162
172
|
end
|
|
163
173
|
|
|
164
174
|
# @return [String] The reason, and the path to the value when it is not the top-level value.
|
data/lib/soml/format.rb
CHANGED
|
@@ -5,6 +5,11 @@ module SOML
|
|
|
5
5
|
#
|
|
6
6
|
# @api private
|
|
7
7
|
class Printer
|
|
8
|
+
# The brackets of a container, by its type, so that writing one allocates no array for them.
|
|
9
|
+
BRACKETS = Ractor.make_shareable({object: ['{', '}'], array: ['[', ']']})
|
|
10
|
+
|
|
11
|
+
private_constant :BRACKETS
|
|
12
|
+
|
|
8
13
|
# @param tree [Tree]
|
|
9
14
|
def initialize(tree)
|
|
10
15
|
@source = tree.source
|
|
@@ -27,6 +32,7 @@ module SOML
|
|
|
27
32
|
|
|
28
33
|
# Starts a new line at `level`, with one blank line before it when the source had one.
|
|
29
34
|
def new_line(level, is_blank)
|
|
35
|
+
# The first line of the file has no line break before it.
|
|
30
36
|
return if @output.empty?
|
|
31
37
|
|
|
32
38
|
@output << (is_blank ? "\n\n" : "\n") << INDENTS[level]
|
|
@@ -100,6 +106,7 @@ module SOML
|
|
|
100
106
|
|
|
101
107
|
# Between two items there is only whitespace, comments, and at most one comma.
|
|
102
108
|
while index < finish
|
|
109
|
+
# A comma inside a comment is not one. Each jump lands on the line feed after a line comment, or on the `/` of a block comment's end, and the loop steps past it.
|
|
103
110
|
case source.getbyte(index)
|
|
104
111
|
when 0x2C # ,
|
|
105
112
|
return index
|
|
@@ -123,11 +130,12 @@ module SOML
|
|
|
123
130
|
item_end = nil
|
|
124
131
|
# Whether a comment may stay at the end of the line before it, which needs an item or an opening bracket there.
|
|
125
132
|
can_trail = is_bracketed
|
|
133
|
+
# Whether the list has written something other than a comment at the end of the opening bracket's line. A blank line is kept only after that, so none is left directly inside the bracket or at the start of the file.
|
|
126
134
|
has_written = false
|
|
127
135
|
is_on_same_line = false
|
|
128
136
|
|
|
129
137
|
# One more round than there are items, for the comments after the last one.
|
|
130
|
-
(
|
|
138
|
+
0.upto(items.length) do |index|
|
|
131
139
|
item = items[index]
|
|
132
140
|
item_start = item ? item.start : finish
|
|
133
141
|
comma = is_bracketed ? find_comma(previous_end, item_start) : nil
|
|
@@ -176,7 +184,7 @@ module SOML
|
|
|
176
184
|
previous_end = start + 1
|
|
177
185
|
has_written_token = false
|
|
178
186
|
|
|
179
|
-
(
|
|
187
|
+
0.upto(items.length) do |index|
|
|
180
188
|
item = items[index]
|
|
181
189
|
item_start = item ? item.start : finish - 1
|
|
182
190
|
# The comma after the last item is left out.
|
|
@@ -290,8 +298,10 @@ module SOML
|
|
|
290
298
|
return
|
|
291
299
|
end
|
|
292
300
|
|
|
293
|
-
opening, closing
|
|
301
|
+
opening, closing = BRACKETS[node.type]
|
|
302
|
+
items = node.type == :object ? node.members : node.elements
|
|
294
303
|
|
|
304
|
+
# Every comment before the container is already written, so a next comment that ends by `finish` is inside it. A container that holds only comments is not empty.
|
|
295
305
|
if items.empty? && peek_comment(finish).nil?
|
|
296
306
|
@output << opening << closing
|
|
297
307
|
return
|
|
@@ -323,7 +333,7 @@ module SOML
|
|
|
323
333
|
old_length = 0
|
|
324
334
|
old_length += 1 while (byte = closing.getbyte(old_length)) == 0x20 || byte == 0x09
|
|
325
335
|
|
|
326
|
-
lines.
|
|
336
|
+
lines.map.with_index do |line, index|
|
|
327
337
|
index.zero? || !line.match?(NOT_BLANK_CHARACTER) ? line : indentation + line.byteslice(old_length, line.bytesize - old_length)
|
|
328
338
|
end.join("\n")
|
|
329
339
|
end
|
data/lib/soml/generator.rb
CHANGED
|
@@ -22,6 +22,18 @@ module SOML
|
|
|
22
22
|
|
|
23
23
|
private_constant :NEEDS_ESCAPED_BYTE, :ESCAPED_CHARACTER, :ESCAPES
|
|
24
24
|
|
|
25
|
+
# A key as a document writes it, bare when it can be, for {Parser} to suggest a key and for {Editor} to write a new member.
|
|
26
|
+
#
|
|
27
|
+
# @param key [String] A key that {#member_name} checked.
|
|
28
|
+
# @return [String]
|
|
29
|
+
def self.format_key(key) = SOML.bare_key?(key) ? key : new.generate_value(key, 0)
|
|
30
|
+
|
|
31
|
+
# The fraction of a second of an instant or a duration as a document writes it: a point and nine digits with the trailing zeros removed, or nothing when there is none.
|
|
32
|
+
#
|
|
33
|
+
# @param nanoseconds [Integer] The fraction, below one second.
|
|
34
|
+
# @return [String]
|
|
35
|
+
def self.format_fraction(nanoseconds) = nanoseconds.zero? ? '' : ".#{nanoseconds.to_s.rjust(9, '0').rstrip('0')}"
|
|
36
|
+
|
|
25
37
|
# @param canonical [Boolean] Whether members are sorted by key, as in canonical form, rather than kept in the order of their hash.
|
|
26
38
|
# @param one_line [Boolean] Whether a container is written on one line, as in `[1, {a: 2}]`, for {Editor} to write a value inside a container that is on one line.
|
|
27
39
|
def initialize(canonical: false, one_line: false)
|
|
@@ -130,7 +142,7 @@ module SOML
|
|
|
130
142
|
enter(value, depth)
|
|
131
143
|
inner_indent = INDENTS[indent.length + 1]
|
|
132
144
|
|
|
133
|
-
if value
|
|
145
|
+
if Array === value
|
|
134
146
|
write_array(value, depth, indent, inner_indent)
|
|
135
147
|
else
|
|
136
148
|
write_hash(value, depth, indent, inner_indent)
|
|
@@ -216,29 +228,28 @@ module SOML
|
|
|
216
228
|
# Sorts the keys by their UTF-8 names. UTF-8 byte order is code point order, which is the canonical order.
|
|
217
229
|
def sort_keys!(keys)
|
|
218
230
|
# The common cases, all keys strings or all symbols in UTF-8 or US-ASCII, sort the keys themselves, because then their byte order is the order of their UTF-8 names. A string from `Symbol#to_s` is in US-ASCII. Anything else, such as a symbol in ISO-8859-1, sorts the keys by their UTF-8 names.
|
|
219
|
-
if keys.all? {
|
|
231
|
+
if keys.all? { String === it && utf8_compatible?(it) } || keys.all? { Symbol === it && utf8_compatible?(it) }
|
|
220
232
|
keys.sort!
|
|
221
233
|
else
|
|
222
|
-
keys.
|
|
234
|
+
keys.sort_by! { member_name(it) }
|
|
223
235
|
end
|
|
224
236
|
end
|
|
225
237
|
|
|
226
|
-
def utf8_compatible?(key)
|
|
227
|
-
key.encoding == Encoding::UTF_8 || key.encoding == Encoding::US_ASCII
|
|
228
|
-
end
|
|
238
|
+
def utf8_compatible?(key) = key.encoding == Encoding::UTF_8 || key.encoding == Encoding::US_ASCII
|
|
229
239
|
|
|
230
240
|
# A key that `member_name` checked, so it is valid UTF-8 without a carriage return.
|
|
231
241
|
def write_key(key)
|
|
232
242
|
if SOML.bare_key?(key)
|
|
233
243
|
@buffer << key
|
|
234
244
|
else
|
|
235
|
-
|
|
245
|
+
write_quoted(key)
|
|
236
246
|
end
|
|
237
247
|
end
|
|
238
248
|
|
|
239
|
-
def write_string(string)
|
|
240
|
-
string = to_utf8(string, 'string')
|
|
249
|
+
def write_string(string) = write_quoted(to_utf8(string, 'string'))
|
|
241
250
|
|
|
251
|
+
# Writes a valid UTF-8 string between quotes, as a `'...'` string when it can be one.
|
|
252
|
+
def write_quoted(string)
|
|
242
253
|
# One scan settles the common case: no quote and no control character.
|
|
243
254
|
index = 0
|
|
244
255
|
length = string.bytesize
|
|
@@ -291,7 +302,7 @@ module SOML
|
|
|
291
302
|
|
|
292
303
|
mantissa, exponent = text.delete_prefix('-').split('e')
|
|
293
304
|
whole, fraction = mantissa.split('.')
|
|
294
|
-
digits = "#{whole}#{fraction}".
|
|
305
|
+
digits = "#{whole}#{fraction}".rstrip('0')
|
|
295
306
|
length = digits.length
|
|
296
307
|
|
|
297
308
|
# The decimal point is after `point` digits, so the value is 0.digits × 10^point.
|
|
@@ -312,30 +323,29 @@ module SOML
|
|
|
312
323
|
end
|
|
313
324
|
|
|
314
325
|
def format_instant(time)
|
|
315
|
-
|
|
326
|
+
# `getutc` makes a copy, which a time in UTC, as `SOML.parse` returns it, does not need.
|
|
327
|
+
utc = time.utc? ? time : time.getutc
|
|
316
328
|
nanoseconds = utc.nsec
|
|
317
329
|
|
|
318
|
-
# A time made from a float, such as `Time.at(Time.now.to_f)`, has the float's binary fraction.
|
|
319
|
-
raise GenerateError, "Cannot generate the instant #{
|
|
320
|
-
raise GenerateError, "Cannot generate the instant #{
|
|
321
|
-
|
|
322
|
-
text = format('%04d-%02d-%02dT%02d:%02d:%02d', utc.year, utc.month, utc.day, utc.hour, utc.min, utc.sec)
|
|
330
|
+
# A time made from a float, such as `Time.at(Time.now.to_f)`, has the float's binary fraction. A subclass of `Time` may define its own `inspect`.
|
|
331
|
+
raise GenerateError, "Cannot generate the instant #{Time.instance_method(:inspect).bind_call(time)}, because it has a fraction of a second finer than a nanosecond. Round it to nanoseconds with time.round(9)" unless (1_000_000_000 % utc.subsec.denominator).zero?
|
|
332
|
+
raise GenerateError, "Cannot generate the instant #{Time.instance_method(:inspect).bind_call(time)}, because in UTC it falls outside the years 0001 to 9999" unless utc.year.between?(1, 9999)
|
|
323
333
|
|
|
324
|
-
# The
|
|
325
|
-
text
|
|
326
|
-
text
|
|
334
|
+
# The year is 0001 to 9999, so `xmlschema` writes four digits, and a time in UTC ends with `Z`.
|
|
335
|
+
text = utc.xmlschema
|
|
336
|
+
nanoseconds.zero? ? text : "#{text.chop}#{Generator.format_fraction(nanoseconds)}Z"
|
|
327
337
|
end
|
|
328
338
|
|
|
329
|
-
#
|
|
339
|
+
# An anonymous class, such as one from `Data.define`, is named by its nearest named superclass.
|
|
330
340
|
def describe(value)
|
|
331
341
|
case value
|
|
332
342
|
when nil, true, false
|
|
333
343
|
value.inspect
|
|
334
344
|
else
|
|
335
|
-
type =
|
|
345
|
+
type = Parser.class_of(value)
|
|
336
346
|
named = type
|
|
337
|
-
named = named.superclass until named
|
|
338
|
-
name = Parser.lossy_utf8(named.equal?(type) ?
|
|
347
|
+
named = named.superclass until (name = Parser.module_name(named))
|
|
348
|
+
name = Parser.lossy_utf8(named.equal?(type) ? name : "anonymous #{name}")
|
|
339
349
|
"#{name.match?(/\A[AEIOUaeiou]/) ? 'an' : 'a'} #{name}"
|
|
340
350
|
end
|
|
341
351
|
end
|