soml-lang 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/lib/soml/duration.rb +167 -0
- data/lib/soml/edit.rb +468 -0
- data/lib/soml/error.rb +171 -0
- data/lib/soml/format.rb +331 -0
- data/lib/soml/generator.rb +364 -0
- data/lib/soml/parser.rb +1856 -0
- data/lib/soml/tree.rb +239 -0
- data/lib/soml/version.rb +6 -0
- data/lib/soml-lang.rb +4 -0
- data/lib/soml.rb +226 -0
- data/license +9 -0
- data/readme.md +552 -0
- data/sig/soml.rbs +143 -0
- metadata +58 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SOML
|
|
4
|
+
# The writer. It walks the value once and appends to one string, so the time is linear in the output however deep the nesting. It writes canonical form, except that members keep the order of their hash unless `canonical` is true.
|
|
5
|
+
#
|
|
6
|
+
# @api private
|
|
7
|
+
class Generator
|
|
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
|
+
NEEDS_ESCAPED_BYTE = Array.new(256) { |byte| byte < 0x20 || byte == 0x27 || byte == 0x7F }.freeze
|
|
10
|
+
|
|
11
|
+
ESCAPED_CHARACTER = /[\x00-\x1F"\\\x7F]/
|
|
12
|
+
|
|
13
|
+
# Shareable, so that a ractor can read it.
|
|
14
|
+
ESCAPES = Ractor.make_shareable(
|
|
15
|
+
[*(0x00..0x1F), 0x7F].to_h { |code| [code.chr, format('\\u{%x}', code)] }.merge(
|
|
16
|
+
'"' => '\\"',
|
|
17
|
+
'\\' => '\\\\',
|
|
18
|
+
"\n" => '\\n',
|
|
19
|
+
"\t" => '\\t'
|
|
20
|
+
)
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
private_constant :NEEDS_ESCAPED_BYTE, :ESCAPED_CHARACTER, :ESCAPES
|
|
24
|
+
|
|
25
|
+
# @param canonical [Boolean] Whether members are sorted by key, as in canonical form, rather than kept in the order of their hash.
|
|
26
|
+
# @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
|
+
def initialize(canonical: false, one_line: false)
|
|
28
|
+
@canonical = canonical
|
|
29
|
+
@one_line = one_line
|
|
30
|
+
@line_break = one_line ? '' : "\n"
|
|
31
|
+
@buffer = +''
|
|
32
|
+
@ancestors = {}.compare_by_identity
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param value [Hash, Array]
|
|
36
|
+
# @return [String]
|
|
37
|
+
def generate(value)
|
|
38
|
+
case value
|
|
39
|
+
when Hash
|
|
40
|
+
enter(value, 1)
|
|
41
|
+
|
|
42
|
+
# A brace-less object needs at least one entry, so the empty object keeps its braces.
|
|
43
|
+
return +"{}\n" if value.empty?
|
|
44
|
+
|
|
45
|
+
write_members(value, 1, '')
|
|
46
|
+
when Array
|
|
47
|
+
write_container(value, 1, '')
|
|
48
|
+
@buffer << "\n"
|
|
49
|
+
else
|
|
50
|
+
raise GenerateError, "The top-level value must be a Hash or an Array, because a document is a collection, not #{describe(value)}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
@buffer
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# One value, for {Editor}. A container's own depth is `depth`, and its lines are indented as if it were at the top level.
|
|
57
|
+
#
|
|
58
|
+
# @param value [Object]
|
|
59
|
+
# @param depth [Integer]
|
|
60
|
+
# @return [String]
|
|
61
|
+
def generate_value(value, depth)
|
|
62
|
+
write_value(value, depth, '')
|
|
63
|
+
@buffer
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The key as a UTF-8 string, checked outside the block that writes its member, so that an invalid key is reported at the path of its object. {Editor} reads the keys of a path with it too.
|
|
67
|
+
#
|
|
68
|
+
# @param key [String, Symbol]
|
|
69
|
+
# @param written [Boolean] Whether the key is written. A key that is only looked for, as in the path of a removal, may have a carriage return, because no document has such a key, so it finds nothing.
|
|
70
|
+
# @return [String]
|
|
71
|
+
def member_name(key, written: true)
|
|
72
|
+
name = case key
|
|
73
|
+
when String
|
|
74
|
+
to_utf8(key, 'key')
|
|
75
|
+
when Symbol
|
|
76
|
+
to_utf8(key.name, 'key')
|
|
77
|
+
else
|
|
78
|
+
# Only an `Integer` or a `Float` is shown, as in `an Integer (1)`, because `nil`, `true`, and `false` are named already. Anything else, such as an array, or a `Numeric` subclass with its instance variables, can be too deep for `inspect`, which then overflows the machine stack and can crash the process rather than raise, and a `BasicObject` has no `inspect`.
|
|
79
|
+
shown = Integer === key || Float === key ? " (#{key.inspect})" : ''
|
|
80
|
+
|
|
81
|
+
raise GenerateError, "A key must be a String or a Symbol, not #{describe(key)}#{shown}, because a key is never converted"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
raise GenerateError, "Cannot generate the key #{Parser.describe_path([name])}, because it contains a carriage return (U+000D), which is not representable" if written && name.include?("\r")
|
|
85
|
+
|
|
86
|
+
name
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# Writes a value whose own depth, if it is a container, is `depth`, and whose lines after the first are indented by `indent`.
|
|
92
|
+
def write_value(value, depth, indent)
|
|
93
|
+
case value
|
|
94
|
+
when String
|
|
95
|
+
write_string(value)
|
|
96
|
+
when Integer
|
|
97
|
+
raise GenerateError, "Cannot generate the integer #{value}, because it is outside the 64-bit range" unless value.between?(INT64_MIN, INT64_MAX)
|
|
98
|
+
|
|
99
|
+
@buffer << value.to_s
|
|
100
|
+
when Hash, Array
|
|
101
|
+
write_container(value, depth, indent)
|
|
102
|
+
when true
|
|
103
|
+
@buffer << 'true'
|
|
104
|
+
when false
|
|
105
|
+
@buffer << 'false'
|
|
106
|
+
when nil
|
|
107
|
+
@buffer << 'null'
|
|
108
|
+
when Float
|
|
109
|
+
@buffer << format_float(value)
|
|
110
|
+
when Symbol
|
|
111
|
+
write_string(value.name)
|
|
112
|
+
when Time
|
|
113
|
+
@buffer << format_instant(value)
|
|
114
|
+
when Duration
|
|
115
|
+
@buffer << value.to_s
|
|
116
|
+
else
|
|
117
|
+
raise GenerateError, describe_unsupported(value)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# `@ancestors` holds only the containers on the way to this one, so the same array in two places is written twice, and only an array inside itself is circular.
|
|
122
|
+
def enter(value, depth)
|
|
123
|
+
raise GenerateError, "Cannot generate a value nested more than #{MAX_DEPTH} levels deep" if depth > MAX_DEPTH
|
|
124
|
+
raise GenerateError, 'Cannot generate a circular structure' if @ancestors.key?(value)
|
|
125
|
+
|
|
126
|
+
@ancestors[value] = true
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def write_container(value, depth, indent)
|
|
130
|
+
enter(value, depth)
|
|
131
|
+
inner_indent = INDENTS[indent.length + 1]
|
|
132
|
+
|
|
133
|
+
if value.is_a?(Array)
|
|
134
|
+
write_array(value, depth, indent, inner_indent)
|
|
135
|
+
else
|
|
136
|
+
write_hash(value, depth, indent, inner_indent)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
@ancestors.delete(value)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def write_array(array, depth, indent, inner_indent)
|
|
143
|
+
return @buffer << '[]' if array.empty?
|
|
144
|
+
|
|
145
|
+
@buffer << '[' << @line_break
|
|
146
|
+
index = 0
|
|
147
|
+
|
|
148
|
+
while index < array.length
|
|
149
|
+
@buffer << item_prefix(index, inner_indent)
|
|
150
|
+
|
|
151
|
+
begin
|
|
152
|
+
write_value(array[index], depth + 1, inner_indent)
|
|
153
|
+
rescue GenerateError => error
|
|
154
|
+
error.path.unshift(index)
|
|
155
|
+
raise
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
@buffer << @line_break
|
|
159
|
+
index += 1
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
@buffer << indent unless @one_line
|
|
163
|
+
@buffer << ']'
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def write_hash(hash, depth, indent, inner_indent)
|
|
167
|
+
return @buffer << '{}' if hash.empty?
|
|
168
|
+
|
|
169
|
+
@buffer << '{' << @line_break
|
|
170
|
+
write_members(hash, depth, inner_indent)
|
|
171
|
+
@buffer << indent unless @one_line
|
|
172
|
+
@buffer << '}'
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Writes each member of `hash`, whose own depth is `depth`, on its own line, indented by `indent`, or after a comma on one line.
|
|
176
|
+
def write_members(hash, depth, indent)
|
|
177
|
+
index = 0
|
|
178
|
+
|
|
179
|
+
each_member(hash) do |key, member|
|
|
180
|
+
@buffer << item_prefix(index, indent)
|
|
181
|
+
index += 1
|
|
182
|
+
write_key(key)
|
|
183
|
+
@buffer << ': '
|
|
184
|
+
write_value(member, depth + 1, indent)
|
|
185
|
+
@buffer << @line_break
|
|
186
|
+
rescue GenerateError => error
|
|
187
|
+
error.path.unshift(key)
|
|
188
|
+
raise
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# What comes before the member or item at `index` in a container whose members or items are indented by `indent`: a comma and a space after the first one on one line, and otherwise the indentation of a new line.
|
|
193
|
+
def item_prefix(index, indent)
|
|
194
|
+
return indent unless @one_line
|
|
195
|
+
|
|
196
|
+
index.zero? ? '' : ', '
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# 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
|
+
def each_member(hash)
|
|
201
|
+
keys = hash.keys
|
|
202
|
+
sort_keys!(keys) if @canonical
|
|
203
|
+
names = {}
|
|
204
|
+
|
|
205
|
+
keys.each do |key|
|
|
206
|
+
name = member_name(key)
|
|
207
|
+
|
|
208
|
+
# Two keys that are the same string, such as `:a` and `'a'`, would be a duplicate key.
|
|
209
|
+
raise GenerateError, "Cannot generate the key #{Parser.describe_path([name])} twice, because two keys, such as :a and 'a', are the same string" if names.key?(name)
|
|
210
|
+
|
|
211
|
+
names[name] = true
|
|
212
|
+
yield name, hash[key]
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Sorts the keys by their UTF-8 names. UTF-8 byte order is code point order, which is the canonical order.
|
|
217
|
+
def sort_keys!(keys)
|
|
218
|
+
# 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) }
|
|
220
|
+
keys.sort!
|
|
221
|
+
else
|
|
222
|
+
keys.replace(keys.map { |key| [member_name(key), key] }.sort_by!(&:first).map!(&:last))
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def utf8_compatible?(key)
|
|
227
|
+
key.encoding == Encoding::UTF_8 || key.encoding == Encoding::US_ASCII
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# A key that `member_name` checked, so it is valid UTF-8 without a carriage return.
|
|
231
|
+
def write_key(key)
|
|
232
|
+
if SOML.bare_key?(key)
|
|
233
|
+
@buffer << key
|
|
234
|
+
else
|
|
235
|
+
write_string(key)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def write_string(string)
|
|
240
|
+
string = to_utf8(string, 'string')
|
|
241
|
+
|
|
242
|
+
# One scan settles the common case: no quote and no control character.
|
|
243
|
+
index = 0
|
|
244
|
+
length = string.bytesize
|
|
245
|
+
|
|
246
|
+
while index < length && !NEEDS_ESCAPED_BYTE[string.getbyte(index)]
|
|
247
|
+
index += 1
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
if index == length
|
|
251
|
+
@buffer << "'" << string << "'"
|
|
252
|
+
return
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
raise GenerateError, 'Cannot generate a string containing a carriage return (U+000D), which is not representable' if string.include?("\r")
|
|
256
|
+
|
|
257
|
+
@buffer << '"' << string.gsub(ESCAPED_CHARACTER, ESCAPES) << '"'
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def to_utf8(string, kind)
|
|
261
|
+
case string.encoding
|
|
262
|
+
when Encoding::UTF_8
|
|
263
|
+
# Already UTF-8.
|
|
264
|
+
when Encoding::BINARY, Encoding::US_ASCII
|
|
265
|
+
# Bytes are read as UTF-8, as `SOML.parse` reads them. A US-ASCII string often holds other bytes, such as a file read with `LANG=C`.
|
|
266
|
+
string = string.dup.force_encoding(Encoding::UTF_8) unless string.ascii_only?
|
|
267
|
+
else
|
|
268
|
+
begin
|
|
269
|
+
string = string.encode(Encoding::UTF_8)
|
|
270
|
+
rescue EncodingError => error
|
|
271
|
+
raise GenerateError, "Cannot generate a #{kind} that cannot be converted to UTF-8: #{error.message}"
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
raise GenerateError, "Cannot generate a #{kind} that is not valid #{string.encoding}" unless string.valid_encoding?
|
|
276
|
+
|
|
277
|
+
string
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# The shortest decimal that reads back as the same float, laid out as ECMAScript `Number::toString` does, with `.0` added when there is neither a fraction nor an exponent, so that a float never reads back as an int.
|
|
281
|
+
def format_float(value)
|
|
282
|
+
raise GenerateError, 'NaN is not representable. Use nil for a missing value' if value.nan?
|
|
283
|
+
return value.positive? ? 'infinity' : '-infinity' if value.infinite?
|
|
284
|
+
|
|
285
|
+
# Covers -0.0 too, since zero has one value whatever its sign.
|
|
286
|
+
return '0.0' if value.zero?
|
|
287
|
+
|
|
288
|
+
# `Float#to_s` gives the shortest digits that read back as the same float. Without an exponent, which it uses below 0.0001 and from 1e16, its layout is already canonical.
|
|
289
|
+
text = value.to_s
|
|
290
|
+
return text unless text.include?('e')
|
|
291
|
+
|
|
292
|
+
mantissa, exponent = text.delete_prefix('-').split('e')
|
|
293
|
+
whole, fraction = mantissa.split('.')
|
|
294
|
+
digits = "#{whole}#{fraction}".sub(/0+\z/, '')
|
|
295
|
+
length = digits.length
|
|
296
|
+
|
|
297
|
+
# The decimal point is after `point` digits, so the value is 0.digits × 10^point.
|
|
298
|
+
point = exponent.to_i + whole.length
|
|
299
|
+
|
|
300
|
+
body = if length <= point && point <= 21
|
|
301
|
+
"#{digits}#{'0' * (point - length)}.0"
|
|
302
|
+
# `Float#to_s` only uses an exponent when the point is outside the digits, so this step of the spec is not reached today. It stays, so that the layout is the spec's whatever `to_s` does.
|
|
303
|
+
elsif point.positive? && point < length
|
|
304
|
+
"#{digits[0, point]}.#{digits[point..]}"
|
|
305
|
+
elsif point > -6 && point <= 0
|
|
306
|
+
"0.#{'0' * -point}#{digits}"
|
|
307
|
+
else
|
|
308
|
+
"#{digits[0]}#{length > 1 ? ".#{digits[1..]}" : ''}e#{point - 1}"
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
value.negative? ? "-#{body}" : body
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def format_instant(time)
|
|
315
|
+
utc = time.getutc
|
|
316
|
+
nanoseconds = utc.nsec
|
|
317
|
+
|
|
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)
|
|
323
|
+
|
|
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'
|
|
327
|
+
end
|
|
328
|
+
|
|
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.
|
|
330
|
+
def describe(value)
|
|
331
|
+
case value
|
|
332
|
+
when nil, true, false
|
|
333
|
+
value.inspect
|
|
334
|
+
else
|
|
335
|
+
type = Kernel.instance_method(:class).bind_call(value)
|
|
336
|
+
named = type
|
|
337
|
+
named = named.superclass until named.name
|
|
338
|
+
name = Parser.lossy_utf8(named.equal?(type) ? type.name : "anonymous #{named.name}")
|
|
339
|
+
"#{name.match?(/\A[AEIOUaeiou]/) ? 'an' : 'a'} #{name}"
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
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
|
|
349
|
+
# Rails code writes `5.minutes`. Its days, months, and years follow the calendar, so it is not converted.
|
|
350
|
+
"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
|
+
elsif Numeric === value
|
|
352
|
+
"Cannot generate #{describe(value)}. Convert it to an Integer or a Float"
|
|
353
|
+
elsif Struct === value || Data === value
|
|
354
|
+
"Cannot generate #{describe(value)}. Convert it to a Hash with to_h"
|
|
355
|
+
elsif Set === value
|
|
356
|
+
"Cannot generate #{describe(value)}. Convert it to an Array with to_a"
|
|
357
|
+
elsif Pathname === value
|
|
358
|
+
"Cannot generate #{describe(value)}. Convert it to a String with to_s"
|
|
359
|
+
else
|
|
360
|
+
"Cannot generate #{describe(value)}. Only Hash, Array, String, Symbol, Integer, Float, true, false, nil, Time, and SOML::Duration can be generated"
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|