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