nestedtext 3.2.1 → 4.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -5
- data/README.md +84 -17
- data/lib/nestedtext/constants.rb +6 -3
- data/lib/nestedtext/core_ext.rb +7 -12
- data/lib/nestedtext/core_ext_internal.rb +3 -1
- data/lib/nestedtext/decode.rb +11 -9
- data/lib/nestedtext/dumper.rb +74 -69
- data/lib/nestedtext/encode.rb +7 -6
- data/lib/nestedtext/encode_helpers.rb +4 -3
- data/lib/nestedtext/error.rb +2 -0
- data/lib/nestedtext/errors_internal.rb +85 -61
- data/lib/nestedtext/inline_parser.rb +134 -0
- data/lib/nestedtext/parser.rb +144 -185
- data/lib/nestedtext/scanners.rb +21 -15
- data/lib/nestedtext/version.rb +1 -1
- data/lib/nestedtext.rb +6 -6
- data/nestedtext.gemspec +18 -16
- metadata +18 -2
@@ -1,15 +1,16 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require
|
4
|
-
require
|
3
|
+
require 'word_wrap'
|
4
|
+
require 'word_wrap/core_ext'
|
5
|
+
require 'unicode_utils'
|
5
6
|
|
6
|
-
require
|
7
|
-
require
|
7
|
+
require 'nestedtext/constants'
|
8
|
+
require 'nestedtext/error'
|
8
9
|
|
9
10
|
module NestedText
|
10
11
|
module Errors
|
11
12
|
class InternalError < Error
|
12
|
-
public_class_method :new # Prevent users from
|
13
|
+
public_class_method :new # Prevent users from instantiating.
|
13
14
|
end
|
14
15
|
|
15
16
|
class ParseError < InternalError
|
@@ -26,26 +27,44 @@ module NestedText
|
|
26
27
|
|
27
28
|
private
|
28
29
|
|
29
|
-
def
|
30
|
-
|
31
|
-
|
32
|
-
|
30
|
+
def lineno_disp
|
31
|
+
@lineno + 1
|
32
|
+
end
|
33
|
+
|
34
|
+
def colno_disp
|
35
|
+
@colno + 1
|
36
|
+
end
|
37
|
+
|
38
|
+
# Number of digits in the line number.
|
39
|
+
def lineno_digits
|
40
|
+
lineno_disp.to_s.length
|
41
|
+
end
|
42
|
+
|
43
|
+
def pretty_prefix
|
44
|
+
"\nParse ParseError (line #{lineno_disp}, column #{colno_disp}): "
|
45
|
+
end
|
46
|
+
|
47
|
+
def pretty_line(line)
|
48
|
+
return '' if line.nil?
|
49
|
+
|
50
|
+
lline_indent = ' ' * line.indentation
|
51
|
+
prev_lineno_disp = line.lineno + 1
|
33
52
|
|
34
|
-
last_lines = ""
|
35
53
|
# From one line to another, we can at most have 1 digits length difference.
|
36
|
-
|
37
|
-
|
38
|
-
lline_indent = " " * line.prev.indentation
|
39
|
-
prev_lineno_disp = line.prev.lineno + 1
|
40
|
-
last_lines += "\n\t#{prev_lineno_disp.to_s.rjust(digits)}│#{lline_indent}#{line.prev.content}"
|
41
|
-
end
|
42
|
-
line_indent = " " * line.indentation
|
43
|
-
last_lines += "\n\t#{lineno_disp}│#{line_indent}#{line.content}"
|
54
|
+
"\n\t#{prev_lineno_disp.to_s.rjust(lineno_digits)}│#{lline_indent}#{line.content}"
|
55
|
+
end
|
44
56
|
|
45
|
-
|
46
|
-
|
57
|
+
def pretty_last_lines(line)
|
58
|
+
pretty_line(line.prev) + pretty_line(line)
|
59
|
+
end
|
47
60
|
|
48
|
-
|
61
|
+
def pretty_marker
|
62
|
+
marker_indent = colno_disp + lineno_digits # +1 for the "|"
|
63
|
+
"\n\t#{' ' * marker_indent}^"
|
64
|
+
end
|
65
|
+
|
66
|
+
def pretty_message(line)
|
67
|
+
pretty_prefix + @message_raw + pretty_last_lines(line) + pretty_marker
|
49
68
|
end
|
50
69
|
end
|
51
70
|
|
@@ -57,19 +76,19 @@ module NestedText
|
|
57
76
|
|
58
77
|
class ParseLineTagNotDetectedError < ParseError
|
59
78
|
def initialize(line)
|
60
|
-
super(line, line.indentation,
|
79
|
+
super(line, line.indentation, 'unrecognized line.')
|
61
80
|
end
|
62
81
|
end
|
63
82
|
|
64
83
|
class ParseLineTypeExpectedListItemError < ParseError
|
65
84
|
def initialize(line)
|
66
|
-
super(line, line.indentation,
|
85
|
+
super(line, line.indentation, 'expected list item.')
|
67
86
|
end
|
68
87
|
end
|
69
88
|
|
70
89
|
class ParseMultilineKeyNoValueError < ParseError
|
71
90
|
def initialize(line)
|
72
|
-
super(line, line.indentation,
|
91
|
+
super(line, line.indentation, 'multiline key requires a value.')
|
73
92
|
end
|
74
93
|
end
|
75
94
|
|
@@ -87,7 +106,7 @@ module NestedText
|
|
87
106
|
|
88
107
|
class ParseInlineMissingValueError < ParseError
|
89
108
|
def initialize(line, colno)
|
90
|
-
super(line, line.indentation + colno,
|
109
|
+
super(line, line.indentation + colno, 'expected value.')
|
91
110
|
end
|
92
111
|
end
|
93
112
|
|
@@ -99,13 +118,13 @@ module NestedText
|
|
99
118
|
|
100
119
|
class ParseInlineNoClosingDelimiterError < ParseError
|
101
120
|
def initialize(line, colno)
|
102
|
-
super(line, line.indentation + colno,
|
121
|
+
super(line, line.indentation + colno, 'line ended without closing delimiter.')
|
103
122
|
end
|
104
123
|
end
|
105
124
|
|
106
125
|
class ParseInlineExtraCharactersAfterDelimiterError < ParseError
|
107
126
|
def initialize(line, colno, extra_chars)
|
108
|
-
character_str = extra_chars.length > 1 ?
|
127
|
+
character_str = extra_chars.length > 1 ? 'characters' : 'character'
|
109
128
|
super(line, line.indentation + colno, "extra #{character_str} after closing delimiter: ‘#{extra_chars}’.")
|
110
129
|
end
|
111
130
|
end
|
@@ -113,19 +132,15 @@ module NestedText
|
|
113
132
|
class ParseInvalidIndentationError < ParseError
|
114
133
|
def initialize(line, ind_exp)
|
115
134
|
prev_line = line.prev
|
116
|
-
if prev_line.nil? && ind_exp
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
message = "invalid indentation, partial dedent."
|
126
|
-
else
|
127
|
-
message = "invalid indentation."
|
128
|
-
end
|
135
|
+
message = if prev_line.nil? && ind_exp.zero?
|
136
|
+
'top-level content must start in column 1.'
|
137
|
+
elsif !prev_line.nil? && line.indentation < prev_line.indentation
|
138
|
+
# Can't use ind_exp here, because it's a difference if the previous line was further indented.
|
139
|
+
# See test_load_error_dict_10
|
140
|
+
'invalid indentation, partial dedent.'
|
141
|
+
else
|
142
|
+
'invalid indentation.'
|
143
|
+
end
|
129
144
|
# Need to wrap like official tests. #wrap always add an extra \n we need to chop off.
|
130
145
|
message_wrapped = message.wrap(70).chop
|
131
146
|
super(line, ind_exp, message_wrapped)
|
@@ -134,26 +149,27 @@ module NestedText
|
|
134
149
|
|
135
150
|
class ParseLineTypeNotExpectedError < ParseError
|
136
151
|
def initialize(line, type_exps, type_act)
|
137
|
-
super(line, line.indentation,
|
152
|
+
super(line, line.indentation,
|
153
|
+
"The current line was detected to be #{type_act}, "\
|
154
|
+
"but we expected to see any of [#{type_exps.join(', ')}] here.")
|
138
155
|
end
|
139
156
|
end
|
140
157
|
|
141
158
|
class ParseLineTypeExpectedDictItemError < ParseError
|
142
159
|
def initialize(line)
|
143
|
-
super(line, line.indentation,
|
160
|
+
super(line, line.indentation, 'expected dictionary item.')
|
144
161
|
end
|
145
162
|
end
|
146
163
|
|
147
164
|
class ParseInvalidIndentationCharError < ParseError
|
148
165
|
def initialize(line)
|
149
|
-
|
166
|
+
char = line.content[0]
|
167
|
+
# Official-test kludge; Translate rubys \u00 to python's unicodedata.name \x format.
|
168
|
+
printable_char = char.dump.gsub(/"/, '').gsub(/\\u0*/, '\x').downcase
|
150
169
|
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
printable_char = '\\xa0'
|
155
|
-
explanation = " (NO-BREAK SPACE)"
|
156
|
-
end
|
170
|
+
explanation = ''
|
171
|
+
# Official-test kludge; ASCII chars have printable names too, but they are not used in reference implementation.
|
172
|
+
explanation = " (#{UnicodeUtils.char_name(char)})" unless char.ord < 128
|
157
173
|
|
158
174
|
message = "invalid character in indentation: '#{printable_char}'#{explanation}."
|
159
175
|
super(line, line.indentation, message)
|
@@ -162,19 +178,23 @@ module NestedText
|
|
162
178
|
|
163
179
|
class ParseDictDuplicateKeyError < ParseError
|
164
180
|
def initialize(line)
|
165
|
-
super(line, line.indentation, "duplicate key: #{line.attribs[
|
181
|
+
super(line, line.indentation, "duplicate key: #{line.attribs['key']}.")
|
166
182
|
end
|
167
183
|
end
|
168
184
|
|
169
185
|
class ParseCustomClassNotFoundError < ParseError
|
170
186
|
def initialize(line, class_name)
|
171
|
-
super(line, line.indentation,
|
187
|
+
super(line, line.indentation,
|
188
|
+
"Detected an encode custom class #{class_name} " \
|
189
|
+
"however we can't find it, so it can't be deserialzied.")
|
172
190
|
end
|
173
191
|
end
|
174
192
|
|
175
193
|
class ParseCustomClassNoCreateMethodError < ParseError
|
176
194
|
def initialize(line, class_name)
|
177
|
-
super(line, line.indentation,
|
195
|
+
super(line, line.indentation,
|
196
|
+
"Detected an encode custom class #{class_name} "\
|
197
|
+
"but it does not have a #nt_create method, so it can't be deserialzied.")
|
178
198
|
end
|
179
199
|
end
|
180
200
|
|
@@ -182,13 +202,13 @@ module NestedText
|
|
182
202
|
|
183
203
|
class AssertionLineScannerIsEmptyError < AssertionError
|
184
204
|
def initialize
|
185
|
-
super(
|
205
|
+
super('There is no more input to consume. You should have checked this with #empty? before calling.')
|
186
206
|
end
|
187
207
|
end
|
188
208
|
|
189
209
|
class AssertionInlineScannerIsEmptyError < AssertionError
|
190
210
|
def initialize
|
191
|
-
super(
|
211
|
+
super('There is no more input to consume. You should have checked this with #empty? before calling.')
|
192
212
|
end
|
193
213
|
end
|
194
214
|
|
@@ -206,20 +226,20 @@ module NestedText
|
|
206
226
|
class DumpUnsupportedTypeError < DumpError
|
207
227
|
def initialize(obj, culprit)
|
208
228
|
# Needed to pass official test.
|
209
|
-
class_name = obj.is_a?(Integer) ?
|
229
|
+
class_name = obj.is_a?(Integer) ? 'int' : obj.class.name
|
210
230
|
super(culprit, "unsupported type (#{class_name}).")
|
211
231
|
end
|
212
232
|
end
|
213
233
|
|
214
234
|
class DumpCyclicReferencesDetectedError < DumpError
|
215
235
|
def initialize(culprit)
|
216
|
-
super(culprit,
|
236
|
+
super(culprit, 'cyclic reference found: cannot be dumped.')
|
217
237
|
end
|
218
238
|
end
|
219
239
|
|
220
240
|
class DumpHashKeyStrictStringError < DumpError
|
221
241
|
def initialize(obj)
|
222
|
-
super(obj,
|
242
|
+
super(obj, 'keys must be strings.')
|
223
243
|
end
|
224
244
|
end
|
225
245
|
|
@@ -232,25 +252,29 @@ module NestedText
|
|
232
252
|
|
233
253
|
class UnsupportedTopLevelTypeError < InternalError
|
234
254
|
def initialize(type_class)
|
235
|
-
super("The given top level type #{type_class&.name} is unsupported.
|
255
|
+
super("The given top level type #{type_class&.name} is unsupported." \
|
256
|
+
"Chose between #{TOP_LEVEL_TYPES.join(', ')}.")
|
236
257
|
end
|
237
258
|
end
|
238
259
|
|
239
260
|
class WrongInputTypeError < InternalError
|
240
261
|
def initialize(class_exps, class_act)
|
241
|
-
super("The given input type #{class_act.class.name} is unsupported.
|
262
|
+
super("The given input type #{class_act.class.name} is unsupported. " \
|
263
|
+
"Expected to be of types #{class_exps.map(&:name).join(', ')}")
|
242
264
|
end
|
243
265
|
end
|
244
266
|
|
245
267
|
class TopLevelTypeMismatchParsedTypeError < InternalError
|
246
268
|
def initialize(class_exp, class_act)
|
247
|
-
super("The requested top level class #{class_exp.name}
|
269
|
+
super("The requested top level class #{class_exp.name} " \
|
270
|
+
"is not the same as the actual parsed top level class #{class_act}.")
|
248
271
|
end
|
249
272
|
end
|
250
273
|
|
251
274
|
class DumpBadIOError < InternalError
|
252
275
|
def initialize(io)
|
253
|
-
super(
|
276
|
+
super('When giving the io argument, it must be of type IO (respond to #write, #fsync).' \
|
277
|
+
" Given: #{io.class.name}")
|
254
278
|
end
|
255
279
|
end
|
256
280
|
|
@@ -0,0 +1,134 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'nestedtext/errors_internal'
|
4
|
+
require 'nestedtext/scanners'
|
5
|
+
|
6
|
+
module NestedText
|
7
|
+
# A LL(1) recursive descent parser for inline NT types.
|
8
|
+
class InlineParser # rubocop:disable Metrics/ClassLength
|
9
|
+
def initialize(line)
|
10
|
+
@inline_scanner = InlineScanner.new(line)
|
11
|
+
end
|
12
|
+
|
13
|
+
def parse
|
14
|
+
result = parse_any
|
15
|
+
unless @inline_scanner.empty?
|
16
|
+
raise Errors::ParseInlineExtraCharactersAfterDelimiterError.new(@inline_scanner.line, @inline_scanner.pos,
|
17
|
+
@inline_scanner.remaining)
|
18
|
+
end
|
19
|
+
result
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def parse_any
|
25
|
+
return nil if @inline_scanner.peek.nil?
|
26
|
+
|
27
|
+
consume_whitespaces # Leading
|
28
|
+
result = case @inline_scanner.peek
|
29
|
+
when '{'
|
30
|
+
parse_dict
|
31
|
+
when '['
|
32
|
+
parse_list
|
33
|
+
else # Inline string
|
34
|
+
parse_string
|
35
|
+
end
|
36
|
+
|
37
|
+
consume_whitespaces # Trailing
|
38
|
+
result
|
39
|
+
end
|
40
|
+
|
41
|
+
def consume_whitespaces
|
42
|
+
@inline_scanner.read_next while !@inline_scanner.empty? && [' ', "\t"].include?(@inline_scanner.peek)
|
43
|
+
end
|
44
|
+
|
45
|
+
def parse_key_last_char(key)
|
46
|
+
last_char = @inline_scanner.read_next
|
47
|
+
if last_char == '}' && key.empty?
|
48
|
+
raise Errors::ParseInlineMissingValueError.new(@inline_scanner.line, @inline_scanner.pos - 1)
|
49
|
+
end
|
50
|
+
return if last_char == ':'
|
51
|
+
|
52
|
+
raise Errors::ParseInlineDictKeySyntaxError.new(@inline_scanner.line, @inline_scanner.pos - 1, last_char)
|
53
|
+
end
|
54
|
+
|
55
|
+
def parse_key
|
56
|
+
key = []
|
57
|
+
until @inline_scanner.empty? || [':', '{', '}', '[', ']', ','].include?(@inline_scanner.peek)
|
58
|
+
key << @inline_scanner.read_next
|
59
|
+
end
|
60
|
+
if @inline_scanner.empty?
|
61
|
+
raise Errors::ParseInlineNoClosingDelimiterError.new(@inline_scanner.line,
|
62
|
+
@inline_scanner.pos)
|
63
|
+
end
|
64
|
+
parse_key_last_char(key)
|
65
|
+
key.join.strip
|
66
|
+
end
|
67
|
+
|
68
|
+
def parse_dict_last_char
|
69
|
+
last_char = @inline_scanner.read_next
|
70
|
+
return if last_char == '}'
|
71
|
+
|
72
|
+
raise Errors::ParseInlineDictSyntaxError.new(@inline_scanner.line,
|
73
|
+
@inline_scanner.pos - 1, last_char)
|
74
|
+
end
|
75
|
+
|
76
|
+
def parse_dict
|
77
|
+
result = {}
|
78
|
+
loop do
|
79
|
+
@inline_scanner.read_next
|
80
|
+
break if result.empty? && @inline_scanner.peek == '}'
|
81
|
+
|
82
|
+
key = parse_key
|
83
|
+
value = parse_any
|
84
|
+
result[key] = value
|
85
|
+
break unless @inline_scanner.peek == ','
|
86
|
+
end
|
87
|
+
if @inline_scanner.empty?
|
88
|
+
raise Errors::ParseInlineNoClosingDelimiterError.new(@inline_scanner.line,
|
89
|
+
@inline_scanner.pos)
|
90
|
+
end
|
91
|
+
parse_dict_last_char
|
92
|
+
result
|
93
|
+
end
|
94
|
+
|
95
|
+
def parse_list_last_char(result)
|
96
|
+
last_char = @inline_scanner.read_next
|
97
|
+
return unless last_char != ']'
|
98
|
+
|
99
|
+
if result[-1] == ''
|
100
|
+
raise Errors::ParseInlineMissingValueError.new(@inline_scanner.line,
|
101
|
+
@inline_scanner.pos - 1)
|
102
|
+
else
|
103
|
+
raise Errors::ParseInlineListSyntaxError.new(@inline_scanner.line,
|
104
|
+
@inline_scanner.pos - 1, last_char)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def parse_list
|
109
|
+
result = []
|
110
|
+
loop do
|
111
|
+
@inline_scanner.read_next
|
112
|
+
break if result.empty? && @inline_scanner.peek == ']'
|
113
|
+
|
114
|
+
result << parse_any
|
115
|
+
break unless @inline_scanner.peek == ','
|
116
|
+
end
|
117
|
+
if @inline_scanner.empty?
|
118
|
+
raise Errors::ParseInlineNoClosingDelimiterError.new(@inline_scanner.line,
|
119
|
+
@inline_scanner.pos)
|
120
|
+
end
|
121
|
+
parse_list_last_char(result)
|
122
|
+
result
|
123
|
+
end
|
124
|
+
|
125
|
+
def parse_string
|
126
|
+
inline_string = []
|
127
|
+
until @inline_scanner.empty? || ['{', '}', '[', ']', ','].include?(@inline_scanner.peek)
|
128
|
+
inline_string << @inline_scanner.read_next
|
129
|
+
end
|
130
|
+
inline_string.join.rstrip # Trim trailing whitespaces that lead up to next break point.
|
131
|
+
end
|
132
|
+
end
|
133
|
+
private_constant :InlineParser
|
134
|
+
end
|