nestedtext 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,24 @@
1
+ require "nestedtext/dumper"
2
+
3
+ module NestedText
4
+ module NTEncodeStrictMixing
5
+ def to_nt(indentation: 4, strict: true)
6
+ Dumper.new(EncodeOptions.new(indentation, strict)).dump self
7
+ end
8
+ end
9
+
10
+ module NTEncodeMixing
11
+ def to_nt(indentation: 4)
12
+ Dumper.new(EncodeOptions.new(indentation, false)).dump self
13
+ end
14
+ end
15
+
16
+ class EncodeOptions
17
+ attr_reader :indentation, :strict
18
+
19
+ def initialize(indentation = 4, strict = true)
20
+ @indentation = indentation
21
+ @strict = strict
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "word_wrap"
4
+ require "word_wrap/core_ext"
5
+
6
+ require "nestedtext/constants"
7
+
8
+ module NestedText
9
+ # Top level ParseError for clients to rescue.
10
+ class Error < StandardError; end
11
+
12
+ module Errors
13
+ # TODO: rename all Subclasses to ParseXError, just like for Dump
14
+ class ParseError < Error
15
+ attr_reader :lineno, :colno, :message_raw
16
+
17
+ def initialize(line, colno, message)
18
+ # Note, both line and column number are 0-indexed.
19
+ # But for human display we make them 1-indexed.
20
+ @lineno = line.lineno
21
+ @colno = colno
22
+ @message_raw = message
23
+ super(pretty_message(line))
24
+ end
25
+
26
+ private
27
+
28
+ def pretty_message(line)
29
+ lineno_disp = @lineno + 1
30
+ colno_disp = @colno + 1
31
+ prefix = "\nParse ParseError (line #{lineno_disp}, column #{colno_disp}): "
32
+
33
+ last_lines = ""
34
+ # From one line to another, we can at most have 1 digits length difference.
35
+ digits = lineno_disp.to_s.length
36
+ unless line.prev.nil?
37
+ lline_indent = " " * line.prev.indentation
38
+ prev_lineno_disp = line.prev.lineno + 1
39
+ last_lines += "\n\t#{prev_lineno_disp.to_s.rjust(digits)}│#{lline_indent}#{line.prev.content}"
40
+ end
41
+ line_indent = " " * line.indentation
42
+ last_lines += "\n\t#{lineno_disp}│#{line_indent}#{line.content}"
43
+
44
+ marker_indent = colno_disp + digits # +1 for the "|"
45
+ marker = "\n\t" + " " * marker_indent + "^"
46
+
47
+ prefix + @message_raw + last_lines + marker
48
+ end
49
+ end
50
+
51
+ class LineTagUnknown < ParseError
52
+ def initialize(line, tag)
53
+ super(line, line.indentation, "The Line tag #{tag} is not among the allowed ones #{Line::ALLOWED_LINE_TAGS}")
54
+ end
55
+ end
56
+
57
+ class LineTagNotDetected < ParseError
58
+ def initialize(line)
59
+ super(line, line.indentation, "unrecognized line.")
60
+ end
61
+ end
62
+
63
+ class LineTypeExpectedListItem < ParseError
64
+ def initialize(line)
65
+ super(line, line.indentation, "expected list item.")
66
+ end
67
+ end
68
+
69
+ class MultilineKeyNoValue < ParseError
70
+ def initialize(line)
71
+ super(line, line.indentation, "multiline key requires a value.")
72
+ end
73
+ end
74
+
75
+ class InlineDictSyntaxError < ParseError
76
+ def initialize(line, colno, wrong_char)
77
+ super(line, line.indentation + colno, "expected ‘,’ or ‘}’, found ‘#{wrong_char}’.")
78
+ end
79
+ end
80
+
81
+ class InlineDictKeySyntaxError < ParseError
82
+ def initialize(line, colno, wrong_char)
83
+ super(line, line.indentation + colno, "expected ‘:’, found ‘#{wrong_char}’.")
84
+ end
85
+ end
86
+
87
+ class InlineMissingValue < ParseError
88
+ def initialize(line, colno)
89
+ super(line, line.indentation + colno, "expected value.")
90
+ end
91
+ end
92
+
93
+ class InlineListSyntaxError < ParseError
94
+ def initialize(line, colno, wrong_char)
95
+ super(line, line.indentation + colno, "expected ‘,’ or ‘]’, found ‘#{wrong_char}’.")
96
+ end
97
+ end
98
+
99
+ class InlineNoClosingDelimiter < ParseError
100
+ def initialize(line, colno)
101
+ super(line, line.indentation + colno, "line ended without closing delimiter.")
102
+ end
103
+ end
104
+
105
+ class InlineExtraCharactersAfterDelimiter < ParseError
106
+ def initialize(line, colno, extra_chars)
107
+ character_str = extra_chars.length > 1 ? "characters" : "character"
108
+ super(line, line.indentation + colno, "extra #{character_str} after closing delimiter: ‘#{extra_chars}’.")
109
+ end
110
+ end
111
+
112
+ class InvalidIndentation < ParseError
113
+ def initialize(line, ind_exp)
114
+ prev_line = line.prev
115
+ if prev_line.nil? && ind_exp == 0
116
+ message = "top-level content must start in column 1."
117
+ elsif !prev_line.nil? &&
118
+ prev_line.attribs.key?("value") &&
119
+ prev_line.indentation < line.indentation &&
120
+ %i[dict_item list_item].member?(prev_line.tag)
121
+ message = "invalid indentation."
122
+ elsif !prev_line.nil? && line.indentation < prev_line.indentation
123
+ # Can't use ind_exp here, because it's a difference if the previous line was further indented. See test_load_error_dict_10
124
+ message = "invalid indentation, partial dedent."
125
+ else
126
+ message = "invalid indentation."
127
+ end
128
+ # Need to wrap like official tests. #wrap always add an extra \n we need to chop off.
129
+ message_wrapped = message.wrap(70).chop
130
+ super(line, ind_exp, message_wrapped)
131
+ end
132
+ end
133
+
134
+ class LineTypeNotExpected < ParseError
135
+ def initialize(line, type_exps, type_act)
136
+ super(line, line.indentation, "The current line was detected to be #{type_act}, but we expected to see any of [#{type_exps.join(", ")}] here.")
137
+ end
138
+ end
139
+
140
+ class LineTypeExpectedDictItem < ParseError
141
+ def initialize(line)
142
+ super(line, line.indentation, "expected dictionary item.")
143
+ end
144
+ end
145
+
146
+ class InvalidIndentationChar < ParseError
147
+ def initialize(line)
148
+ printable_char = line.content[0].dump.gsub(/"/, "")
149
+
150
+ # Looking for non-breaking space is just to be compatialbe with official tests.
151
+ explanation = ""
152
+ if printable_char == '\\u00A0'
153
+ printable_char = '\\xa0'
154
+ explanation = " (NO-BREAK SPACE)"
155
+ end
156
+
157
+ message = "invalid character in indentation: '#{printable_char}'#{explanation}."
158
+ super(line, line.indentation, message)
159
+ end
160
+ end
161
+
162
+ class DictDuplicateKey < ParseError
163
+ def initialize(line)
164
+ super(line, line.indentation, "duplicate key: #{line.attribs["key"]}.")
165
+ end
166
+ end
167
+
168
+ class ParseCustomClassNotFound < ParseError
169
+ def initialize(line, class_name)
170
+ super(line, line.indentation, "Detected an encode custom class #{class_name} however we can't find it, so it can't be deserialzied.")
171
+ end
172
+ end
173
+
174
+ class ParseCustomClassNoCreateMethod < ParseError
175
+ def initialize(line, class_name)
176
+ super(line, line.indentation, "Detected an encode custom class #{class_name} but it does not have a #nt_create method, so it can't be deserialzied.")
177
+ end
178
+ end
179
+
180
+ class UnsupportedTopLevelTypeError < Error
181
+ def initialize(type_class)
182
+ super("The given top level type #{type_class&.name} is unsupported. Chose between #{TOP_LEVEL_TYPES.join(", ")}.")
183
+ end
184
+ end
185
+
186
+ class WrongInputTypeError < Error
187
+ def initialize(class_exps, class_act)
188
+ super("The given input type #{class_act.class.name} is unsupported. Expected to be of types #{class_exps.map(&:name).join(", ")}")
189
+ end
190
+ end
191
+
192
+ class TopLevelTypeMismatchParsedType < Error
193
+ def initialize(class_exp, class_act)
194
+ super("The requested top level class #{class_exp.name} is not the same as the actual parsed top level class #{class_act}.")
195
+ end
196
+ end
197
+
198
+ class AssertionError < Error; end
199
+
200
+ class LineScannerIsEmpty < AssertionError
201
+ def initialize
202
+ super("There is no more input to consume. You should have checked this with #empty? before calling.")
203
+ end
204
+ end
205
+
206
+ class InlineScannerIsEmpty < AssertionError
207
+ def initialize
208
+ super("There is no more input to consume. You should have checked this with #empty? before calling.")
209
+ end
210
+ end
211
+
212
+ class DumpBadIO < Error
213
+ def initialize(io)
214
+ super("When giving the io argument, it must be of type IO (respond to #write, #fsync). Given: #{io.class.name}")
215
+ end
216
+ end
217
+
218
+ class DumpFileBadPath < Error
219
+ def initialize(path)
220
+ super("Must supply a string to a file path that can be written to. Given: #{path}")
221
+ end
222
+ end
223
+
224
+ class DumpError < Error
225
+ attr_reader :culprit
226
+
227
+ def initialize(culprit, message)
228
+ # Note, both line and column number are 0-indexed.
229
+ # But for human display we make them 1-indexed.
230
+ @culprit = culprit
231
+ super(message)
232
+ end
233
+ end
234
+
235
+ class DumpUnsupportedTypeError < DumpError
236
+ def initialize(obj, culprit)
237
+ # Needed to pass official test.
238
+ class_name = obj.is_a?(Integer) ? "int" : obj.class.name
239
+ super(culprit, "unsupported type (#{class_name}).")
240
+ end
241
+ end
242
+
243
+ class DumpCyclicReferencesDetected < DumpError
244
+ def initialize(culprit)
245
+ super(culprit, "cyclic reference found: cannot be dumped.")
246
+ end
247
+ end
248
+
249
+ class DumpHashKeyStrictString < DumpError
250
+ def initialize(obj)
251
+ super(obj, "keys must be strings.")
252
+ end
253
+ end
254
+
255
+ def self.raise_unrecognized_line(line)
256
+ # [[:space:]] include all Unicode spaces e.g. non-breakable space which \s does not.
257
+ raise InvalidIndentationChar, line if line.content.chr =~ /[[:space:]]/
258
+
259
+ raise LineTagNotDetected, line
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,7 @@
1
+ module NestedText
2
+ def self.assert_valid_top_level_type(top_class)
3
+ unless !top_class.nil? && top_class.is_a?(Class) && TOP_LEVEL_TYPES.map(&:object_id).include?(top_class.object_id)
4
+ raise Errors::UnsupportedTopLevelTypeError, top_class
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ require "nestedtext/errors"
6
+ require "nestedtext/scanners"
7
+ require "nestedtext/helpers"
8
+
9
+ module NestedText
10
+ class Parser
11
+ # TODO: document that caller is responsible for closing IO after done with Parser.
12
+ def initialize(io, top_class, strict: true)
13
+ assert_valid_input_type io
14
+ NestedText.assert_valid_top_level_type(top_class)
15
+ @top_class = top_class
16
+ @strict = strict
17
+ @line_scanner = LineScanner.new(io)
18
+ @inline_scanner = nil
19
+ end
20
+
21
+ def parse
22
+ result = parse_any(0)
23
+ case @top_class.object_id
24
+ when Object.object_id
25
+ raise Errors::AssertionError, "Parsed result is of unexpected type." if
26
+ !result.nil? && ![Hash, Array, String].include?(result.class) && @strict
27
+ when Hash.object_id
28
+ result = {} if result.nil?
29
+ raise Errors::TopLevelTypeMismatchParsedType.new(@top_class, result) unless result.instance_of?(Hash)
30
+ when Array.object_id
31
+ result = [] if result.nil?
32
+ raise Errors::TopLevelTypeMismatchParsedType.new(@top_class, result) unless result.instance_of?(Array)
33
+ when String.object_id
34
+ result = "" if result.nil?
35
+ raise Errors::TopLevelTypeMismatchParsedType.new(@top_class, result) unless result.instance_of?(String)
36
+ else
37
+ raise Errors::UnsupportedTopLevelTypeError, @top_class
38
+ end
39
+ result
40
+ end
41
+
42
+ private
43
+
44
+ def assert_valid_input_type(input)
45
+ unless input.nil? || input.is_a?(IO) || input.is_a?(StringIO)
46
+ raise Errors::WrongInputTypeError.new([IO, StringIO], input)
47
+ end
48
+ end
49
+
50
+ def parse_any(indentation)
51
+ return nil if @line_scanner.peek.nil?
52
+
53
+ case @line_scanner.peek.tag # TODO: Use Null Pattern instead with a EndOfInput tag?
54
+ when :list_item
55
+ parse_list_item(indentation)
56
+ when :dict_item, :key_item
57
+ parse_dict_item(indentation)
58
+ when :string_item
59
+ parse_string_item(indentation)
60
+ when :inline_dict
61
+ parse_inline_dict
62
+ when :inline_list
63
+ parse_inline_list
64
+ when :unrecognized
65
+ Errors.raise_unrecognized_line(@line_scanner.peek)
66
+ else
67
+ raise Errors::AssertionError, "Unexpected line tag! #{@line_scanner.peek.tag}"
68
+ end
69
+ end
70
+
71
+ def parse_list_item(indentation)
72
+ result = []
73
+ while !@line_scanner.peek.nil? && @line_scanner.peek.indentation >= indentation
74
+ line = @line_scanner.read_next
75
+
76
+ Errors.raise_unrecognized_line(line) if line.tag == :unrecognized
77
+ raise Errors::LineTypeExpectedListItem, line unless line.tag == :list_item
78
+ raise Errors::InvalidIndentation.new(line, indentation) if line.indentation != indentation
79
+
80
+ value = line.attribs["value"]
81
+ if value.nil?
82
+ if !@line_scanner.peek.nil? && @line_scanner.peek.indentation > indentation
83
+ value = parse_any(@line_scanner.peek.indentation)
84
+ elsif @line_scanner.peek.nil? || @line_scanner.peek.tag == :list_item
85
+ value = ""
86
+ end
87
+ end
88
+
89
+ result << value
90
+ end
91
+ result
92
+ end
93
+
94
+ def parse_dict_item(indentation)
95
+ result = {}
96
+ first_line = nil
97
+ while !@line_scanner.peek.nil? && @line_scanner.peek.indentation >= indentation
98
+ line = @line_scanner.read_next
99
+ first_line = line if first_line.nil?
100
+ Errors.raise_unrecognized_line(line) if line.tag == :unrecognized
101
+ raise Errors::InvalidIndentation.new(line, indentation) if line.indentation != indentation
102
+ raise Errors::LineTypeExpectedDictItem, line unless %i[dict_item key_item].include? line.tag
103
+
104
+ value = nil
105
+ key = nil
106
+ if line.tag == :dict_item
107
+ key = line.attribs["key"]
108
+ value = line.attribs["value"]
109
+ if value.nil?
110
+ value = ""
111
+ if !@line_scanner.peek.nil? && @line_scanner.peek.indentation > indentation
112
+ value = parse_any(@line_scanner.peek.indentation)
113
+ end
114
+ end
115
+ else # :key_item
116
+ key = line.attribs["key"]
117
+ while @line_scanner.peek&.tag == :key_item && @line_scanner.peek.indentation == indentation
118
+ line = @line_scanner.read_next
119
+ key += "\n" + line.attribs["key"]
120
+ end
121
+ exp_types = %i[dict_item key_item list_item string_item]
122
+ if @line_scanner.peek.nil?
123
+ value = ""
124
+ else
125
+ unless exp_types.member?(@line_scanner.peek.tag)
126
+ raise Errors::LineTypeNotExpected.new(line, exp_types, line.tag)
127
+ end
128
+ raise Errors::MultilineKeyNoValue, line unless @line_scanner.peek.indentation > indentation
129
+
130
+ value = parse_any(@line_scanner.peek.indentation)
131
+ end
132
+ end
133
+ raise Errors::DictDuplicateKey, line if result.key? key
134
+
135
+ result[key] = value
136
+ end
137
+
138
+ # Custom class decoding.
139
+ if !@strict && result.length == 2 && result.key?(CUSTOM_CLASS_KEY)
140
+ class_name = result[CUSTOM_CLASS_KEY]
141
+ begin
142
+ clazz = class_name == "nil" ? NilClass : Object.const_get(class_name, false)
143
+ rescue NameError
144
+ raise Errors::ParseCustomClassNotFound.new(first_line, class_name)
145
+ end
146
+ if clazz.respond_to? :nt_create
147
+ result = clazz.nt_create(result["data"])
148
+ else
149
+ raise Errors::ParseCustomClassNoCreateMethod.new(first_line, class_name)
150
+ end
151
+ end
152
+
153
+ result
154
+ end
155
+
156
+ def parse_string_item(indentation)
157
+ result = []
158
+ while !@line_scanner.peek.nil? && @line_scanner.peek.indentation >= indentation
159
+ line = @line_scanner.read_next
160
+ raise Errors::InvalidIndentation.new(line, indentation) if line.indentation != indentation
161
+ raise Errors::LineTypeNotExpected.new(line, %i[string_item], line.tag) unless line.tag == :string_item
162
+
163
+ value = line.attribs["value"]
164
+ result << value
165
+ end
166
+ result.join("\n")
167
+ end
168
+
169
+ def parse_inline_key
170
+ key = []
171
+ until @inline_scanner.empty? || [":", "{", "}", "[", "]", ","].include?(@inline_scanner.peek)
172
+ key << @inline_scanner.read_next
173
+ end
174
+ if @inline_scanner.empty?
175
+ raise Errors::InlineNoClosingDelimiter.new(@inline_scanner.line,
176
+ @inline_scanner.pos)
177
+ end
178
+
179
+ last_char = @inline_scanner.read_next
180
+ if last_char == "}" && key.empty?
181
+ raise Errors::InlineMissingValue.new(@inline_scanner.line, @inline_scanner.pos - 1)
182
+ end
183
+ unless last_char == ":"
184
+ raise Errors::InlineDictKeySyntaxError.new(@inline_scanner.line, @inline_scanner.pos - 1, last_char)
185
+ end
186
+
187
+ key.join.strip
188
+ end
189
+
190
+ def parse_inline
191
+ return nil if @inline_scanner.peek.nil?
192
+
193
+ result = nil
194
+ # Trim leading whitespaces
195
+ @inline_scanner.read_next while !@inline_scanner.empty? && [" ", "\t"].include?(@inline_scanner.peek)
196
+ case @inline_scanner.peek
197
+ when "{"
198
+ result = {}
199
+ first = true
200
+ loop do
201
+ @inline_scanner.read_next
202
+ break if first && @inline_scanner.peek == "}"
203
+
204
+ first = false
205
+ key = parse_inline_key
206
+ value = parse_inline
207
+ result[key] = value
208
+ break unless @inline_scanner.peek == ","
209
+ end
210
+ if @inline_scanner.empty?
211
+ raise Errors::InlineNoClosingDelimiter.new(@inline_scanner.line,
212
+ @inline_scanner.pos)
213
+ end
214
+ last_char = @inline_scanner.read_next
215
+ unless last_char == "}"
216
+ raise Errors::InlineDictSyntaxError.new(@inline_scanner.line, @inline_scanner.pos - 1,
217
+ last_char)
218
+ end
219
+
220
+ when "["
221
+ result = []
222
+ first = true # TODO: can be replaced by checking result.empty? below?
223
+ loop do
224
+ @inline_scanner.read_next
225
+ break if first && @inline_scanner.peek == "]"
226
+
227
+ first = false
228
+ result << parse_inline
229
+ break unless @inline_scanner.peek == ","
230
+ end
231
+ if @inline_scanner.empty?
232
+ raise Errors::InlineNoClosingDelimiter.new(@inline_scanner.line,
233
+ @inline_scanner.pos)
234
+ end
235
+ last_char = @inline_scanner.read_next
236
+
237
+ if last_char != "]"
238
+ if result[-1] == ""
239
+ raise Errors::InlineMissingValue.new(@inline_scanner.line, @inline_scanner.pos - 1)
240
+ else
241
+ raise Errors::InlineListSyntaxError.new(@inline_scanner.line, @inline_scanner.pos - 1,
242
+ last_char)
243
+ end
244
+ end
245
+ else # Inline string
246
+ inline_string = []
247
+ until @inline_scanner.empty? || ["{", "}", "[", "]", ","].include?(@inline_scanner.peek)
248
+ inline_string << @inline_scanner.read_next
249
+ end
250
+ result = inline_string.join.rstrip # Trim trailing whitespaces that lead up to next break point.
251
+ end
252
+ # Trim trailing whitespaces
253
+ @inline_scanner.read_next while !@inline_scanner.empty? && [" ", "\t"].include?(@inline_scanner.peek)
254
+ result
255
+ end
256
+
257
+ def parse_inline_dict
258
+ @inline_scanner = InlineScanner.new(@line_scanner.read_next)
259
+ result = parse_inline
260
+ unless @inline_scanner.empty?
261
+ raise Errors::InlineExtraCharactersAfterDelimiter.new(@inline_scanner.line, @inline_scanner.pos,
262
+ @inline_scanner.remaining)
263
+ end
264
+ unless result.is_a? Hash
265
+ raise Errors::AssertionError,
266
+ "Expected inline value to be Hash but is #{result.class.name}"
267
+ end
268
+
269
+ result
270
+ end
271
+
272
+ def parse_inline_list
273
+ @inline_scanner = InlineScanner.new(@line_scanner.read_next)
274
+ result = parse_inline
275
+ unless @inline_scanner.empty?
276
+ raise Errors::InlineExtraCharactersAfterDelimiter.new(@inline_scanner.line, @inline_scanner.pos,
277
+ @inline_scanner.remaining)
278
+ end
279
+ unless result.is_a? Array
280
+ raise Errors::AssertionError,
281
+ "Expected inline value to be Array but is #{result.class.name}"
282
+ end
283
+
284
+ result
285
+ end
286
+ end
287
+ end