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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1da7419c0865c3f10d59dab2f4e5b8d638b814a11ed322a2885c7d6e3e3aecee
4
- data.tar.gz: ed89f8ed61c0c624cf3023ec9f3199b76eec6e7f090992df90409f065e175937
3
+ metadata.gz: 808fb7da3c2ca5866146bef2193cbf96c4d4e91077c65ada841afd077b1e5282
4
+ data.tar.gz: 03113f26b03a51156a4c7b30ac2dbde6fda0e908df06f5b11c349c69ef2619d7
5
5
  SHA512:
6
- metadata.gz: de26c20a01ef8fa144bf98402e2ae7d145f9b1ba22dfbd7b8fa02c283a3e66efbdf0060a9d5718ace01f1240384af0090c19717a802e3563e8c1d9f4534f182a
7
- data.tar.gz: 65dce6ea77612c06ab6b5d656f1aa4bd030306c590416013b508797b47dd03ebedd3faa7be0ab9deb83d79d246ebc8b448cd7b1ed4db86f4f39d9e600351009c
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, not real, or outside the 64-bit range of nanoseconds.
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(3_600_000_000_000)
132
- minutes, rest = rest.divmod(60_000_000_000)
133
- seconds, fraction = rest.divmod(1_000_000_000)
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
- 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
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 = Tree.new(source)
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.select { it.type == :block }.to_h { [it.finish, it.start] }
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 = SOML.bare_key?(key) ? key : Generator.new.generate_value(key, 0)
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, content_start(container)) if removal.is_whole_lines
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. `content_start` is the container's, from `content_start`.
271
- def take_blank_line(removal, container, content_start)
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
- removal.finish = @bytes.byteindex("\n", removal.finish) + 1
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 = @bytes.byteindex("\n", offset) + 1 while offset < @length && blank_line?(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; end
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
- # 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.
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.each_with_index do |line_start, index|
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
- (0..items.length).each do |index|
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
- (0..items.length).each do |index|
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, items = node.type == :object ? ['{', '}', node.members] : ['[', ']', node.elements]
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.each_with_index.map do |line, index|
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
@@ -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.is_a?(Array)
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? { |key| String === key && utf8_compatible?(key) } || keys.all? { |key| Symbol === key && utf8_compatible?(key) }
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.replace(keys.map { |key| [member_name(key), key] }.sort_by!(&:first).map!(&:last))
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
- write_string(key)
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}".sub(/0+\z/, '')
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
- utc = time.getutc
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 #{time.inspect}, because it has a fraction of a second finer than a nanosecond. Round it to nanoseconds with time.round(9)" unless utc.subsec * 1_000_000_000 == nanoseconds
320
- raise GenerateError, "Cannot generate the instant #{time.inspect}, because in UTC it falls outside the years 0001 to 9999" unless utc.year.between?(1, 9999)
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 fraction is nine digits with the trailing zeros removed.
325
- text << ".#{format('%09d', nanoseconds).sub(/0+\z/, '')}" if nanoseconds.positive?
326
- text << 'Z'
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
- # The class is read with `Kernel#class`, which a `BasicObject` does not have. An anonymous class, such as one from `Data.define`, is named by its nearest named superclass.
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 = Kernel.instance_method(:class).bind_call(value)
369
+ type = Parser.class_of(value)
336
370
  named = type
337
- named = named.superclass until named.name
338
- name = Parser.lossy_utf8(named.equal?(type) ? type.name : "anonymous #{named.name}")
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?(::DateTime) && ::DateTime === value
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