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
data/lib/soml/error.rb
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SOML
|
|
4
|
+
# The base class of the errors this library raises.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised by {SOML.parse}, {SOML.load_file}, and {SOML::Duration.parse} when the input is not valid.
|
|
8
|
+
#
|
|
9
|
+
# The {#message} is the reason and the position, on one line, with the file when the document came from {SOML.load_file}. The {#detailed_message}, which Ruby shows for an uncaught error, adds a code frame.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# begin
|
|
13
|
+
# SOML.parse('name: api-gateway')
|
|
14
|
+
# rescue SOML::ParseError => error
|
|
15
|
+
# error.message
|
|
16
|
+
# #=> "Unexpected “api-gateway”. A string value must be quoted, as in 'api-gateway' at line 1, column 7"
|
|
17
|
+
# error.code_frame
|
|
18
|
+
# #=> "> 1 | name: api-gateway\n | ^"
|
|
19
|
+
# end
|
|
20
|
+
class ParseError < Error
|
|
21
|
+
CONTEXT_LINES = 2
|
|
22
|
+
MAX_LINE_WIDTH = 100
|
|
23
|
+
|
|
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
|
|
28
|
+
|
|
29
|
+
# @return [String] What is wrong, without the position.
|
|
30
|
+
attr_reader :reason
|
|
31
|
+
|
|
32
|
+
# @return [Integer] The 1-based line of the error.
|
|
33
|
+
attr_reader :line
|
|
34
|
+
|
|
35
|
+
# @return [Integer] The 1-based column of the error, counted in characters.
|
|
36
|
+
attr_reader :column
|
|
37
|
+
|
|
38
|
+
# @return [Integer] The 0-based character index of the error in the source read as UTF-8, so for a UTF-8 source, `source[error.offset]` is the character at the error.
|
|
39
|
+
attr_reader :offset
|
|
40
|
+
|
|
41
|
+
# @return [String, nil] The file the document is from, when it was read by {SOML.load_file}.
|
|
42
|
+
attr_reader :path
|
|
43
|
+
|
|
44
|
+
# @api private
|
|
45
|
+
# @param reason [String]
|
|
46
|
+
# @param source [String] The valid UTF-8 text before the error, at least.
|
|
47
|
+
# @param byte_offset [Integer] Where the error is in `source`, in bytes.
|
|
48
|
+
# @param path [String, nil] The file the source is from.
|
|
49
|
+
def initialize(reason, source, byte_offset, path = nil)
|
|
50
|
+
@reason = sanitize(reason)
|
|
51
|
+
|
|
52
|
+
# A copy, which shares the bytes, so that a caller that changes its string afterwards does not change the code frame.
|
|
53
|
+
@source = source.dup
|
|
54
|
+
before = source.byteslice(0, byte_offset)
|
|
55
|
+
@offset = before.length
|
|
56
|
+
@line = before.count("\n") + 1
|
|
57
|
+
@line_start = (before.rindex("\n") || -1) + 1
|
|
58
|
+
@column = @offset - @line_start + 1
|
|
59
|
+
@path = path
|
|
60
|
+
|
|
61
|
+
# A file position is written as editors and terminals read it.
|
|
62
|
+
super(path ? "#{@reason} at #{show_path(path)}:#{@line}:#{@column}" : "#{@reason} at line #{@line}, column #{@column}")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Up to three lines ending at the error, with a caret under the position. A long line is clipped around the position.
|
|
66
|
+
#
|
|
67
|
+
# @return [String]
|
|
68
|
+
def code_frame
|
|
69
|
+
# A frozen error, such as one made shareable for a ractor, cannot keep it.
|
|
70
|
+
return create_code_frame if frozen?
|
|
71
|
+
|
|
72
|
+
@code_frame ||= create_code_frame
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The message and the code frame, which Ruby shows for an uncaught error.
|
|
76
|
+
#
|
|
77
|
+
# @return [String]
|
|
78
|
+
def detailed_message(highlight: false, **)
|
|
79
|
+
"#{super}\n\n#{code_frame}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def create_code_frame
|
|
85
|
+
# The error line and up to two lines before it, found by searching back from the error, so that an error late in a long document does not split the whole document.
|
|
86
|
+
line_starts = [@line_start]
|
|
87
|
+
|
|
88
|
+
while line_starts.length <= CONTEXT_LINES && line_starts.first.positive?
|
|
89
|
+
# The line before starts after the line break before its own line break. A negative start would count from the end.
|
|
90
|
+
search_from = line_starts.first - 2
|
|
91
|
+
line_starts.unshift(search_from.negative? ? 0 : (@source.rindex("\n", search_from) || -1) + 1)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
first_line = @line - line_starts.length + 1
|
|
95
|
+
gutter_width = @line.to_s.length
|
|
96
|
+
output = []
|
|
97
|
+
|
|
98
|
+
line_starts.each_with_index do |line_start, index|
|
|
99
|
+
number = first_line + index
|
|
100
|
+
is_error_line = number == @line
|
|
101
|
+
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)
|
|
103
|
+
gutter = "#{is_error_line ? '>' : ' '} #{number.to_s.rjust(gutter_width)} |"
|
|
104
|
+
output << (text.empty? ? gutter : "#{gutter} #{text}")
|
|
105
|
+
|
|
106
|
+
next unless is_error_line
|
|
107
|
+
|
|
108
|
+
# The tabs of the line are kept, so the caret lines up whatever the tab width is.
|
|
109
|
+
padding = text[0, pointer].gsub(/[^\t]/, ' ')
|
|
110
|
+
output << " #{' ' * gutter_width} | #{padding}^"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
output.join("\n")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# A long line, such as a minified document, is cut to a window around the pointer.
|
|
117
|
+
def clip(text, pointer)
|
|
118
|
+
return [text, pointer] if text.length <= MAX_LINE_WIDTH
|
|
119
|
+
|
|
120
|
+
start = (pointer - (MAX_LINE_WIDTH / 2)).clamp(0, text.length - MAX_LINE_WIDTH)
|
|
121
|
+
finish = start + MAX_LINE_WIDTH
|
|
122
|
+
prefix = start.positive? ? '…' : ''
|
|
123
|
+
suffix = finish < text.length ? '…' : ''
|
|
124
|
+
["#{prefix}#{text[start...finish]}#{suffix}", pointer - start + prefix.length]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def sanitize(text)
|
|
128
|
+
text.gsub(UNPRINTABLE, "\uFFFD")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The path in UTF-8, so that it joins the reason, with what is not printable replaced, as in the reason.
|
|
132
|
+
def show_path(path)
|
|
133
|
+
sanitize(Parser.lossy_utf8(path))
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Raised by {SOML.generate} for a value that cannot be written.
|
|
138
|
+
#
|
|
139
|
+
# @example
|
|
140
|
+
# begin
|
|
141
|
+
# SOML.generate({servers: [{ratio: Float::NAN}]})
|
|
142
|
+
# rescue SOML::GenerateError => error
|
|
143
|
+
# error.message
|
|
144
|
+
# #=> "NaN is not representable. Use nil for a missing value (at servers[0].ratio)"
|
|
145
|
+
# error.path
|
|
146
|
+
# #=> ["servers", 0, "ratio"]
|
|
147
|
+
# end
|
|
148
|
+
class GenerateError < Error
|
|
149
|
+
# The message as it was given, which `to_s` adds the path to. So a copy made with another message, as by `raise error, 'Other'`, has that message as its reason and keeps the path.
|
|
150
|
+
#
|
|
151
|
+
# @!method reason
|
|
152
|
+
# @return [String] What is wrong, without the location.
|
|
153
|
+
alias_method :reason, :to_s
|
|
154
|
+
|
|
155
|
+
# @return [Array<String, Integer>] The keys and indexes that lead to the value, or an empty array for the top-level value.
|
|
156
|
+
attr_reader :path
|
|
157
|
+
|
|
158
|
+
# @api private
|
|
159
|
+
def initialize(reason)
|
|
160
|
+
@path = []
|
|
161
|
+
super
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# @return [String] The reason, and the path to the value when it is not the top-level value.
|
|
165
|
+
def to_s
|
|
166
|
+
return reason if @path.empty?
|
|
167
|
+
|
|
168
|
+
"#{reason} (at #{Parser.describe_path(@path)})"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
data/lib/soml/format.rb
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SOML
|
|
4
|
+
# The formatter. It is a close port of the JS reference's `format.ts`: it prints the {Tree} of a document with the layout of the specification's formatter, and copies every token and comment from the source, so the value and the spelling cannot change.
|
|
5
|
+
#
|
|
6
|
+
# @api private
|
|
7
|
+
class Printer
|
|
8
|
+
# @param tree [Tree]
|
|
9
|
+
def initialize(tree)
|
|
10
|
+
@source = tree.source
|
|
11
|
+
@body = tree.body
|
|
12
|
+
@comments = tree.comments
|
|
13
|
+
@output = +''
|
|
14
|
+
@comment_index = 0
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [String] The formatted document, ending with one line feed.
|
|
18
|
+
def print
|
|
19
|
+
items = @body.type == :object && !@body.braced ? @body.members : [@body]
|
|
20
|
+
|
|
21
|
+
# The document is a list without brackets: the members of a brace-less object, or one braced collection.
|
|
22
|
+
write_list(items, 0, @source.bytesize, 0, false)
|
|
23
|
+
@output << "\n"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# Starts a new line at `level`, with one blank line before it when the source had one.
|
|
29
|
+
def new_line(level, is_blank)
|
|
30
|
+
return if @output.empty?
|
|
31
|
+
|
|
32
|
+
@output << (is_blank ? "\n\n" : "\n") << INDENTS[level]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The start of the line that `offset` is on, but never before `previous_end`, which is the start of the range when there is no line break in it.
|
|
36
|
+
def line_start_of(offset, previous_end)
|
|
37
|
+
index = offset
|
|
38
|
+
index -= 1 while index > previous_end && @source.getbyte(index - 1) != 0x0A
|
|
39
|
+
index
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Whether no line break comes between `start` and `finish`. The scan stops at `finish`.
|
|
43
|
+
def same_line?(start, finish)
|
|
44
|
+
index = start
|
|
45
|
+
|
|
46
|
+
while index < finish
|
|
47
|
+
return false if @source.getbyte(index) == 0x0A
|
|
48
|
+
|
|
49
|
+
index += 1
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Whether a line between `start` and `finish` holds only spaces and tabs, if anything. The scan stops at `finish`.
|
|
56
|
+
def blank_line_between?(start, finish)
|
|
57
|
+
index = start
|
|
58
|
+
|
|
59
|
+
while index < finish
|
|
60
|
+
if @source.getbyte(index) == 0x0A
|
|
61
|
+
following = index + 1
|
|
62
|
+
following += 1 while following < finish && (@source.getbyte(following) == 0x20 || @source.getbyte(following) == 0x09)
|
|
63
|
+
return true if following < finish && @source.getbyte(following) == 0x0A
|
|
64
|
+
|
|
65
|
+
index = following
|
|
66
|
+
else
|
|
67
|
+
index += 1
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The next comment that ends at or before `finish`, without consuming it.
|
|
75
|
+
def peek_comment(finish)
|
|
76
|
+
comment = @comments[@comment_index]
|
|
77
|
+
comment if comment && comment.finish <= finish
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def write_comment(comment)
|
|
81
|
+
@comment_index += 1
|
|
82
|
+
text = @source.byteslice(comment.start, comment.finish - comment.start)
|
|
83
|
+
|
|
84
|
+
if comment.type == :line
|
|
85
|
+
@output << trim_trailing_whitespace(text)
|
|
86
|
+
return
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The lines of a block comment keep their own indentation. Trailing whitespace is removed, and runs of blank lines collapse to one, as everywhere outside a block string.
|
|
90
|
+
lines = text.split("\n", -1).map { trim_trailing_whitespace(it) }
|
|
91
|
+
|
|
92
|
+
# The first line starts with `/*`, so it is never empty.
|
|
93
|
+
@output << lines.reject.with_index { |line, index| line.empty? && lines[index - 1].empty? }.join("\n")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The offset of the `,` after an item in the source, or `nil` when there is none.
|
|
97
|
+
def find_comma(start, finish)
|
|
98
|
+
source = @source
|
|
99
|
+
index = start
|
|
100
|
+
|
|
101
|
+
# Between two items there is only whitespace, comments, and at most one comma.
|
|
102
|
+
while index < finish
|
|
103
|
+
case source.getbyte(index)
|
|
104
|
+
when 0x2C # ,
|
|
105
|
+
return index
|
|
106
|
+
when 0x23 # #
|
|
107
|
+
index = source.byteindex("\n", index) || finish
|
|
108
|
+
when 0x2F # /
|
|
109
|
+
index = source.byteindex('*/', index + 2) + 1
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
index += 1
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Writes `items` one per line at `level`, with the comments between them, from `start` to `finish` in the source.
|
|
119
|
+
#
|
|
120
|
+
# A comment on the line of the item before it, or of the opening bracket, stays at the end of that line. The exception is a block comment after the comma that the next item follows on the same line, as in `[1, /* note */ 2]`: it stays in front of that item. Every other comment starts a new line, and a comment or an item that follows a block comment on its line stays on that line.
|
|
121
|
+
def write_list(items, start, finish, level, is_bracketed)
|
|
122
|
+
previous_end = start
|
|
123
|
+
item_end = nil
|
|
124
|
+
# Whether a comment may stay at the end of the line before it, which needs an item or an opening bracket there.
|
|
125
|
+
can_trail = is_bracketed
|
|
126
|
+
has_written = false
|
|
127
|
+
is_on_same_line = false
|
|
128
|
+
|
|
129
|
+
# One more round than there are items, for the comments after the last one.
|
|
130
|
+
(0..items.length).each do |index|
|
|
131
|
+
item = items[index]
|
|
132
|
+
item_start = item ? item.start : finish
|
|
133
|
+
comma = is_bracketed ? find_comma(previous_end, item_start) : nil
|
|
134
|
+
|
|
135
|
+
# Found once per item rather than once per comment, which would be quadratic in the number of comments on one line.
|
|
136
|
+
item_line_start = item && line_start_of(item_start, previous_end)
|
|
137
|
+
|
|
138
|
+
while (comment = peek_comment(item_start))
|
|
139
|
+
is_trailing = can_trail && !is_on_same_line && trailing?(comment, previous_end, item_end, item_line_start, comma)
|
|
140
|
+
|
|
141
|
+
if is_trailing || is_on_same_line
|
|
142
|
+
@output << ' '
|
|
143
|
+
else
|
|
144
|
+
new_line(level, has_written && blank_line_between?(previous_end, comment.start))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
write_comment(comment)
|
|
148
|
+
has_written ||= !is_trailing
|
|
149
|
+
|
|
150
|
+
# A block comment that the next comment or item follows on the same line keeps it on that line.
|
|
151
|
+
next_start = peek_comment(item_start)&.start || item_start
|
|
152
|
+
is_on_same_line = comment.type == :block && !is_trailing && same_line?(comment.finish, next_start)
|
|
153
|
+
previous_end = comment.finish
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
return if item.nil?
|
|
157
|
+
|
|
158
|
+
if is_on_same_line
|
|
159
|
+
@output << ' '
|
|
160
|
+
else
|
|
161
|
+
new_line(level, has_written && blank_line_between?(previous_end, item_start))
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
write_item(item, level)
|
|
165
|
+
previous_end = item.finish
|
|
166
|
+
item_end = previous_end
|
|
167
|
+
can_trail = true
|
|
168
|
+
has_written = true
|
|
169
|
+
is_on_same_line = false
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Writes a container that is on one line in the source on one line, with a comma and a space between its members or items and no trailing comma. Only a block comment can be inside it, and each one stays where it is among the items and commas.
|
|
174
|
+
def write_one_line(items, start, finish, level, opening, closing)
|
|
175
|
+
@output << opening
|
|
176
|
+
previous_end = start + 1
|
|
177
|
+
has_written_token = false
|
|
178
|
+
|
|
179
|
+
(0..items.length).each do |index|
|
|
180
|
+
item = items[index]
|
|
181
|
+
item_start = item ? item.start : finish - 1
|
|
182
|
+
# The comma after the last item is left out.
|
|
183
|
+
comma = item && find_comma(previous_end, item_start)
|
|
184
|
+
has_written_comma = false
|
|
185
|
+
|
|
186
|
+
while (comment = peek_comment(item_start))
|
|
187
|
+
if !has_written_comma && comma && comma < comment.start
|
|
188
|
+
@output << ','
|
|
189
|
+
has_written_comma = true
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
@output << ' ' if has_written_token
|
|
193
|
+
write_comment(comment)
|
|
194
|
+
has_written_token = true
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
break if item.nil?
|
|
198
|
+
|
|
199
|
+
if comma && !has_written_comma
|
|
200
|
+
@output << ', '
|
|
201
|
+
elsif has_written_token
|
|
202
|
+
@output << ' '
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
write_item(item, level)
|
|
206
|
+
previous_end = item.finish
|
|
207
|
+
has_written_token = true
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
@output << closing
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def write_item(item, level)
|
|
214
|
+
if item.type == :member
|
|
215
|
+
write_member(item, level)
|
|
216
|
+
else
|
|
217
|
+
write_value(item, level)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Whether a comment stays at the end of the line before it. That line ends at the item before it, or at the comma after that item.
|
|
222
|
+
def trailing?(comment, previous_end, item_end, item_line_start, comma)
|
|
223
|
+
is_after_comma = !comma.nil? && comment.start > comma
|
|
224
|
+
|
|
225
|
+
# A block comment after the comma that the next item follows on the same line stays in front of that item. The comment ends on the item's line when it ends after the start of that line.
|
|
226
|
+
return false if item_line_start && comment.type == :block && (comma.nil? || is_after_comma) && comment.finish >= item_line_start
|
|
227
|
+
|
|
228
|
+
# A comment after the comma ends the comma's line, which is later than the item's when a block comment that spans lines comes between them. The comma is removed, so the comment moves to the end of the item's line, which only holds when nothing came between them.
|
|
229
|
+
line_end = is_after_comma && previous_end == item_end ? comma + 1 : previous_end
|
|
230
|
+
same_line?(line_end, comment.start)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def write_member(member, level)
|
|
234
|
+
key = member.key
|
|
235
|
+
value = member.value
|
|
236
|
+
@output << @source.byteslice(key.start, key.finish - key.start) << ':'
|
|
237
|
+
block_string = value.type == :scalar && value.block
|
|
238
|
+
|
|
239
|
+
if peek_comment(value.start).nil?
|
|
240
|
+
# A block string begins on the next line, one level deeper, so its delimiters and content line up.
|
|
241
|
+
if block_string
|
|
242
|
+
new_line(level + 1, false)
|
|
243
|
+
write_value(value, level + 1)
|
|
244
|
+
return
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
@output << ' '
|
|
248
|
+
write_value(value, level)
|
|
249
|
+
return
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# With comments between the `:` and the value, the line breaks between them are kept, but not the blank lines, and a value on a new line is one level deeper.
|
|
253
|
+
previous_end = key.finish + 1
|
|
254
|
+
value_level = level
|
|
255
|
+
|
|
256
|
+
while (comment = peek_comment(value.start))
|
|
257
|
+
value_level = level + 1 if write_separator(previous_end, comment.start, level + 1)
|
|
258
|
+
write_comment(comment)
|
|
259
|
+
previous_end = comment.finish
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
if block_string
|
|
263
|
+
new_line(level + 1, false)
|
|
264
|
+
value_level = level + 1
|
|
265
|
+
elsif write_separator(previous_end, value.start, level + 1)
|
|
266
|
+
value_level = level + 1
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
write_value(value, value_level)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# A space when `finish` is on the line of `start` in the source, and otherwise a new line at `level`. Returns whether it started a new line.
|
|
273
|
+
def write_separator(start, finish, level)
|
|
274
|
+
if same_line?(start, finish)
|
|
275
|
+
@output << ' '
|
|
276
|
+
return false
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
new_line(level, false)
|
|
280
|
+
true
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def write_value(node, level)
|
|
284
|
+
start = node.start
|
|
285
|
+
finish = node.finish
|
|
286
|
+
|
|
287
|
+
if node.type == :scalar
|
|
288
|
+
text = @source.byteslice(start, finish - start)
|
|
289
|
+
@output << (node.block ? reindent_block_string(text, INDENTS[level]) : text)
|
|
290
|
+
return
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
opening, closing, items = node.type == :object ? ['{', '}', node.members] : ['[', ']', node.elements]
|
|
294
|
+
|
|
295
|
+
if items.empty? && peek_comment(finish).nil?
|
|
296
|
+
@output << opening << closing
|
|
297
|
+
return
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
if same_line?(start, finish)
|
|
301
|
+
write_one_line(items, start, finish, level, opening, closing)
|
|
302
|
+
return
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
@output << opening
|
|
306
|
+
write_list(items, start + 1, finish - 1, level + 1, true)
|
|
307
|
+
new_line(level, false)
|
|
308
|
+
@output << closing
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Only spaces and tabs are whitespace in SOML. Any other character, such as a no-break space, is content.
|
|
312
|
+
def trim_trailing_whitespace(text)
|
|
313
|
+
# A loop rather than a regex, which would keep a backtracking entry for every space of a long run.
|
|
314
|
+
finish = text.bytesize
|
|
315
|
+
finish -= 1 while finish.positive? && ((byte = text.getbyte(finish - 1)) == 0x20 || byte == 0x09)
|
|
316
|
+
text.byteslice(0, finish)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Moves a block string to a new indentation. Its content is relative to the closing delimiter's indentation, so replacing that indentation on every line keeps the value. A blank line is left as it is, because it becomes an empty line either way.
|
|
320
|
+
def reindent_block_string(text, indentation)
|
|
321
|
+
lines = text.split("\n", -1)
|
|
322
|
+
closing = lines.last
|
|
323
|
+
old_length = 0
|
|
324
|
+
old_length += 1 while (byte = closing.getbyte(old_length)) == 0x20 || byte == 0x09
|
|
325
|
+
|
|
326
|
+
lines.each_with_index.map do |line, index|
|
|
327
|
+
index.zero? || !line.match?(NOT_BLANK_CHARACTER) ? line : indentation + line.byteslice(old_length, line.bytesize - old_length)
|
|
328
|
+
end.join("\n")
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|