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.
- checksums.yaml +4 -4
- data/lib/soml/duration.rb +17 -39
- data/lib/soml/edit.rb +42 -23
- data/lib/soml/error.rb +27 -17
- data/lib/soml/format.rb +17 -4
- data/lib/soml/generator.rb +59 -29
- data/lib/soml/parser.rb +221 -215
- data/lib/soml/tree.rb +198 -64
- data/lib/soml/version.rb +1 -1
- data/lib/soml.rb +54 -44
- data/readme.md +123 -62
- data/sig/manifest.yaml +2 -0
- data/sig/soml.rbs +100 -4
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 808fb7da3c2ca5866146bef2193cbf96c4d4e91077c65ada841afd077b1e5282
|
|
4
|
+
data.tar.gz: 03113f26b03a51156a4c7b30ac2dbde6fda0e908df06f5b11c349c69ef2619d7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9ede6de7ba3eeea51fa44fe111832a090018d5f5ab589232702b4c0d8cff010f04da9e1a91c52a8a726d77afe2e9b37c14b070631a70bdbece238cc73ece047b
|
|
7
|
+
data.tar.gz: 2ba19a6d60471afa8d03dc54c0277bf628ee7cd0ba57620a9c5ea0c353805f2843160fa7dc8c1356215cbe12fbfede87b401c2cda355cf90c1c851073d0bd2b4
|
data/lib/soml/duration.rb
CHANGED
|
@@ -29,16 +29,14 @@ 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
|
-
# 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.
|
|
34
|
+
# Make a duration from a number of seconds, the inverse of {#to_r}. The seconds are rounded to the nearest nanosecond, with ties to even, because a `Float` such as `0.1` is not exact.
|
|
37
35
|
#
|
|
38
36
|
# @param seconds [Numeric] An `Integer`, a `Float`, a `Rational`, or another real number.
|
|
39
37
|
# @return [Duration]
|
|
40
38
|
# @raise [TypeError] When `seconds` is not a number.
|
|
41
|
-
# @raise [RangeError] When `seconds` is not finite
|
|
39
|
+
# @raise [RangeError] When `seconds` is not finite or not real, or the rounded nanosecond count is outside the 64-bit range.
|
|
42
40
|
#
|
|
43
41
|
# @example
|
|
44
42
|
# SOML::Duration.from_seconds(90)
|
|
@@ -50,7 +48,7 @@ module SOML
|
|
|
50
48
|
# `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
49
|
raise TypeError, "A duration is made from a number of seconds, got #{Parser.describe_argument(seconds)}" unless Numeric === seconds
|
|
52
50
|
|
|
53
|
-
new(nanoseconds: (seconds.to_r * 1_000_000_000).round)
|
|
51
|
+
new(nanoseconds: (seconds.to_r * 1_000_000_000).round(half: :even))
|
|
54
52
|
end
|
|
55
53
|
|
|
56
54
|
# @param nanoseconds [Integer] The length in nanoseconds.
|
|
@@ -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
|
|
@@ -311,6 +321,8 @@ module SOML
|
|
|
311
321
|
# Only spaces and tabs are whitespace in SOML. Any other character, such as a no-break space, is content.
|
|
312
322
|
def trim_trailing_whitespace(text)
|
|
313
323
|
# A loop rather than a regex, which would keep a backtracking entry for every space of a long run.
|
|
324
|
+
#
|
|
325
|
+
# Note: Not using `rstrip(" \t")` because it is slower for typical short lines.
|
|
314
326
|
finish = text.bytesize
|
|
315
327
|
finish -= 1 while finish.positive? && ((byte = text.getbyte(finish - 1)) == 0x20 || byte == 0x09)
|
|
316
328
|
text.byteslice(0, finish)
|
|
@@ -320,10 +332,11 @@ module SOML
|
|
|
320
332
|
def reindent_block_string(text, indentation)
|
|
321
333
|
lines = text.split("\n", -1)
|
|
322
334
|
closing = lines.last
|
|
335
|
+
# Note: Not using `lstrip(" \t")` because it allocates a string and is slower than this scan.
|
|
323
336
|
old_length = 0
|
|
324
337
|
old_length += 1 while (byte = closing.getbyte(old_length)) == 0x20 || byte == 0x09
|
|
325
338
|
|
|
326
|
-
lines.
|
|
339
|
+
lines.map.with_index do |line, index|
|
|
327
340
|
index.zero? || !line.match?(NOT_BLANK_CHARACTER) ? line : indentation + line.byteslice(old_length, line.bytesize - old_length)
|
|
328
341
|
end.join("\n")
|
|
329
342
|
end
|
data/lib/soml/generator.rb
CHANGED
|
@@ -6,6 +6,8 @@ module SOML
|
|
|
6
6
|
# @api private
|
|
7
7
|
class Generator
|
|
8
8
|
# A byte that cannot be in a `'...'` string: a control character, an apostrophe, or DEL. A string with one is written as `"..."`. The set is a byte table, because a scan of it is faster than the regex it replaces.
|
|
9
|
+
#
|
|
10
|
+
# Note: Not using `String#count` because it is slower for typical short strings.
|
|
9
11
|
NEEDS_ESCAPED_BYTE = Array.new(256) { |byte| byte < 0x20 || byte == 0x27 || byte == 0x7F }.freeze
|
|
10
12
|
|
|
11
13
|
ESCAPED_CHARACTER = /[\x00-\x1F"\\\x7F]/
|
|
@@ -22,6 +24,20 @@ module SOML
|
|
|
22
24
|
|
|
23
25
|
private_constant :NEEDS_ESCAPED_BYTE, :ESCAPED_CHARACTER, :ESCAPES
|
|
24
26
|
|
|
27
|
+
# 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.
|
|
28
|
+
#
|
|
29
|
+
# @param key [String] A key that {#member_name} checked.
|
|
30
|
+
# @return [String]
|
|
31
|
+
def self.format_key(key) = SOML.bare_key?(key) ? key : new.generate_value(key, 0)
|
|
32
|
+
|
|
33
|
+
# 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.
|
|
34
|
+
#
|
|
35
|
+
# Note: Not using `sprintf` because its padding is slower than `to_s.rjust`.
|
|
36
|
+
#
|
|
37
|
+
# @param nanoseconds [Integer] The fraction, below one second.
|
|
38
|
+
# @return [String]
|
|
39
|
+
def self.format_fraction(nanoseconds) = nanoseconds.zero? ? '' : ".#{nanoseconds.to_s.rjust(9, '0').rstrip('0')}"
|
|
40
|
+
|
|
25
41
|
# @param canonical [Boolean] Whether members are sorted by key, as in canonical form, rather than kept in the order of their hash.
|
|
26
42
|
# @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
43
|
def initialize(canonical: false, one_line: false)
|
|
@@ -111,6 +127,11 @@ module SOML
|
|
|
111
127
|
write_string(value.name)
|
|
112
128
|
when Time
|
|
113
129
|
@buffer << format_instant(value)
|
|
130
|
+
# `DateTime` is a subclass of `Date`, so it must come first.
|
|
131
|
+
when DateTime
|
|
132
|
+
raise GenerateError, 'Cannot generate a DateTime. Convert it to a Time with to_time'
|
|
133
|
+
when Date
|
|
134
|
+
@buffer << format_date(value)
|
|
114
135
|
when Duration
|
|
115
136
|
@buffer << value.to_s
|
|
116
137
|
else
|
|
@@ -130,7 +151,7 @@ module SOML
|
|
|
130
151
|
enter(value, depth)
|
|
131
152
|
inner_indent = INDENTS[indent.length + 1]
|
|
132
153
|
|
|
133
|
-
if value
|
|
154
|
+
if Array === value
|
|
134
155
|
write_array(value, depth, indent, inner_indent)
|
|
135
156
|
else
|
|
136
157
|
write_hash(value, depth, indent, inner_indent)
|
|
@@ -145,6 +166,7 @@ module SOML
|
|
|
145
166
|
@buffer << '[' << @line_break
|
|
146
167
|
index = 0
|
|
147
168
|
|
|
169
|
+
# Note: Not using `each_index` because its block overhead slows this hot loop under YJIT.
|
|
148
170
|
while index < array.length
|
|
149
171
|
@buffer << item_prefix(index, inner_indent)
|
|
150
172
|
|
|
@@ -198,6 +220,7 @@ module SOML
|
|
|
198
220
|
|
|
199
221
|
# Yields each member with its key as a UTF-8 string, in the order of the hash, or sorted by key for canonical form.
|
|
200
222
|
def each_member(hash)
|
|
223
|
+
# Note: Not using a `hash.each_key` enumerator because it is slower than iterating the key array.
|
|
201
224
|
keys = hash.keys
|
|
202
225
|
sort_keys!(keys) if @canonical
|
|
203
226
|
names = {}
|
|
@@ -216,29 +239,28 @@ module SOML
|
|
|
216
239
|
# Sorts the keys by their UTF-8 names. UTF-8 byte order is code point order, which is the canonical order.
|
|
217
240
|
def sort_keys!(keys)
|
|
218
241
|
# 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? {
|
|
242
|
+
if keys.all? { String === it && utf8_compatible?(it) } || keys.all? { Symbol === it && utf8_compatible?(it) }
|
|
220
243
|
keys.sort!
|
|
221
244
|
else
|
|
222
|
-
keys.
|
|
245
|
+
keys.sort_by! { member_name(it) }
|
|
223
246
|
end
|
|
224
247
|
end
|
|
225
248
|
|
|
226
|
-
def utf8_compatible?(key)
|
|
227
|
-
key.encoding == Encoding::UTF_8 || key.encoding == Encoding::US_ASCII
|
|
228
|
-
end
|
|
249
|
+
def utf8_compatible?(key) = key.encoding == Encoding::UTF_8 || key.encoding == Encoding::US_ASCII
|
|
229
250
|
|
|
230
251
|
# A key that `member_name` checked, so it is valid UTF-8 without a carriage return.
|
|
231
252
|
def write_key(key)
|
|
232
253
|
if SOML.bare_key?(key)
|
|
233
254
|
@buffer << key
|
|
234
255
|
else
|
|
235
|
-
|
|
256
|
+
write_quoted(key)
|
|
236
257
|
end
|
|
237
258
|
end
|
|
238
259
|
|
|
239
|
-
def write_string(string)
|
|
240
|
-
string = to_utf8(string, 'string')
|
|
260
|
+
def write_string(string) = write_quoted(to_utf8(string, 'string'))
|
|
241
261
|
|
|
262
|
+
# Writes a valid UTF-8 string between quotes, as a `'...'` string when it can be one.
|
|
263
|
+
def write_quoted(string)
|
|
242
264
|
# One scan settles the common case: no quote and no control character.
|
|
243
265
|
index = 0
|
|
244
266
|
length = string.bytesize
|
|
@@ -248,6 +270,7 @@ module SOML
|
|
|
248
270
|
end
|
|
249
271
|
|
|
250
272
|
if index == length
|
|
273
|
+
# Note: Not using `append_as_bytes` because it is slower for typical short strings in the full generator.
|
|
251
274
|
@buffer << "'" << string << "'"
|
|
252
275
|
return
|
|
253
276
|
end
|
|
@@ -291,7 +314,7 @@ module SOML
|
|
|
291
314
|
|
|
292
315
|
mantissa, exponent = text.delete_prefix('-').split('e')
|
|
293
316
|
whole, fraction = mantissa.split('.')
|
|
294
|
-
digits = "#{whole}#{fraction}".
|
|
317
|
+
digits = "#{whole}#{fraction}".rstrip('0')
|
|
295
318
|
length = digits.length
|
|
296
319
|
|
|
297
320
|
# The decimal point is after `point` digits, so the value is 0.digits × 10^point.
|
|
@@ -311,41 +334,48 @@ module SOML
|
|
|
311
334
|
value.negative? ? "-#{body}" : body
|
|
312
335
|
end
|
|
313
336
|
|
|
337
|
+
# Use the native components, even for a subclass that overrides them, and without converting through an instant or another calendar.
|
|
338
|
+
def format_date(date)
|
|
339
|
+
text = Date.instance_method(:iso8601).bind_call(date)
|
|
340
|
+
year = Date.instance_method(:year).bind_call(date)
|
|
341
|
+
raise GenerateError, "Cannot generate the date #{text}, because it is outside the years 0001 to 9999" unless year.between?(1, 9999)
|
|
342
|
+
raise GenerateError, "Cannot generate the Julian date #{text}. Convert it to a Gregorian Date with new_start(Date::GREGORIAN), which must be in the years 0001 to 9999" unless Date.instance_method(:gregorian?).bind_call(date)
|
|
343
|
+
|
|
344
|
+
text
|
|
345
|
+
end
|
|
346
|
+
|
|
314
347
|
def format_instant(time)
|
|
315
|
-
|
|
348
|
+
# `getutc` makes a copy, which a time in UTC, as `SOML.parse` returns it, does not need.
|
|
349
|
+
utc = time.utc? ? time : time.getutc
|
|
316
350
|
nanoseconds = utc.nsec
|
|
317
351
|
|
|
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)
|
|
352
|
+
# 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`.
|
|
353
|
+
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?
|
|
354
|
+
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
355
|
|
|
324
|
-
# The
|
|
325
|
-
|
|
326
|
-
|
|
356
|
+
# The year is 0001 to 9999, so `xmlschema` writes four digits, and a time in UTC ends with `Z`.
|
|
357
|
+
#
|
|
358
|
+
# Note: Not using `strftime` because Ruby 4's core `xmlschema` is faster.
|
|
359
|
+
text = utc.xmlschema
|
|
360
|
+
nanoseconds.zero? ? text : "#{text.chop}#{Generator.format_fraction(nanoseconds)}Z"
|
|
327
361
|
end
|
|
328
362
|
|
|
329
|
-
#
|
|
363
|
+
# An anonymous class, such as one from `Data.define`, is named by its nearest named superclass.
|
|
330
364
|
def describe(value)
|
|
331
365
|
case value
|
|
332
366
|
when nil, true, false
|
|
333
367
|
value.inspect
|
|
334
368
|
else
|
|
335
|
-
type =
|
|
369
|
+
type = Parser.class_of(value)
|
|
336
370
|
named = type
|
|
337
|
-
named = named.superclass until named
|
|
338
|
-
name = Parser.lossy_utf8(named.equal?(type) ?
|
|
371
|
+
named = named.superclass until (name = Parser.module_name(named))
|
|
372
|
+
name = Parser.lossy_utf8(named.equal?(type) ? name : "anonymous #{name}")
|
|
339
373
|
"#{name.match?(/\A[AEIOUaeiou]/) ? 'an' : 'a'} #{name}"
|
|
340
374
|
end
|
|
341
375
|
end
|
|
342
376
|
|
|
343
377
|
def describe_unsupported(value)
|
|
344
|
-
if defined?(::
|
|
345
|
-
'Cannot generate a DateTime. Convert it to a Time with to_time'
|
|
346
|
-
elsif defined?(::Date) && ::Date === value
|
|
347
|
-
"Cannot generate a Date, because a date is not an instant. Write it as a String, such as '#{value}'"
|
|
348
|
-
elsif defined?(::ActiveSupport::Duration) && ::ActiveSupport::Duration === value
|
|
378
|
+
if defined?(::ActiveSupport::Duration) && ::ActiveSupport::Duration === value
|
|
349
379
|
# Rails code writes `5.minutes`. Its days, months, and years follow the calendar, so it is not converted.
|
|
350
380
|
"Cannot generate an ActiveSupport::Duration, because its days, months, and years are not a fixed length. Use a SOML::Duration, such as SOML::Duration.parse('5m')"
|
|
351
381
|
elsif Numeric === value
|
|
@@ -357,7 +387,7 @@ module SOML
|
|
|
357
387
|
elsif Pathname === value
|
|
358
388
|
"Cannot generate #{describe(value)}. Convert it to a String with to_s"
|
|
359
389
|
else
|
|
360
|
-
"Cannot generate #{describe(value)}. Only Hash, Array, String, Symbol, Integer, Float, true, false, nil, Time, and SOML::Duration can be generated"
|
|
390
|
+
"Cannot generate #{describe(value)}. Only Hash, Array, String, Symbol, Integer, Float, true, false, nil, Date, Time, and SOML::Duration can be generated"
|
|
361
391
|
end
|
|
362
392
|
end
|
|
363
393
|
end
|