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.
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
@@ -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