nestedtext 3.2.1 → 4.2.0
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 +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
|