lib/nestedtext/parser.rb
# frozen_string_literal: true
require 'stringio'
require 'nestedtext/errors_internal'
require 'nestedtext/scanners'
require 'nestedtext/constants'
require 'nestedtext/inline_parser'
module NestedText
# A LL(1) recursive descent parser for NT.
class Parser # rubocop:disable Metrics/ClassLength
def self.assert_valid_top_level_type(top_class)
if !top_class.nil? && top_class.is_a?(Class) && TOP_LEVEL_TYPES.map(&:object_id).include?(top_class.object_id)
return
end
raise Errors::UnsupportedTopLevelTypeError, top_class
end
def initialize(io, top_class, strict: false)
assert_valid_input_type io
Parser.assert_valid_top_level_type(top_class)
@top_class = top_class
@strict = strict
@line_scanner = LineScanner.new(io)
end
def parse
result = parse_any(0)
case @top_class.object_id
when Object.object_id
return_object(result)
when Hash.object_id
return_hash(result)
when Array.object_id
return_array(result)
when String.object_id
return_string(result)
else
raise Errors::UnsupportedTopLevelTypeError, @top_class
end
end
private
def return_object(result)
raise Errors::AssertionError, 'Parsed result is of unexpected type.' if \
!result.nil? && ![Hash, Array, String].include?(result.class) && @strict
result
end
def return_hash(result)
result = {} if result.nil?
raise Errors::TopLevelTypeMismatchParsedTypeError.new(@top_class, result) unless result.instance_of?(Hash)
result
end
def return_array(result)
result = [] if result.nil?
raise Errors::TopLevelTypeMismatchParsedTypeError.new(@top_class, result) unless result.instance_of?(Array)
result
end
def return_string(result)
result = '' if result.nil?
raise Errors::TopLevelTypeMismatchParsedTypeError.new(@top_class, result) unless result.instance_of?(String)
result
end
def assert_valid_input_type(input)
return if input.nil? || input.is_a?(IO) || input.is_a?(StringIO)
raise Errors::WrongInputTypeError.new([IO, StringIO], input)
end
def parse_any(indentation)
return nil if @line_scanner.peek.nil?
case @line_scanner.peek.tag
when :list_item
parse_list_item(indentation)
when :dict_item, :key_item
parse_dict_item(indentation)
when :string_item
parse_string_item(indentation)
when :inline_dict
parse_inline_dict
when :inline_list
parse_inline_list
when :unrecognized
Errors.raise_unrecognized_line(@line_scanner.peek)
else
raise Errors::AssertionError, "Unexpected line tag! #{@line_scanner.peek.tag}"
end
end
def parse_list_item_value(indentation, value)
return value unless value.nil?
if !@line_scanner.peek.nil? && @line_scanner.peek.indentation > indentation
parse_any(@line_scanner.peek.indentation)
elsif @line_scanner.peek.nil? || @line_scanner.peek.tag == :list_item
''
end
end
def assert_list_line(line, indentation)
Errors.raise_unrecognized_line(line) if line.tag == :unrecognized
raise Errors::ParseLineTypeExpectedListItemError, line unless line.tag == :list_item
raise Errors::ParseInvalidIndentationError.new(line, indentation) if line.indentation != indentation
end
def parse_list_item(indentation)
result = []
while !@line_scanner.peek.nil? && @line_scanner.peek.indentation >= indentation
line = @line_scanner.read_next
assert_list_line(line, indentation)
result << parse_list_item_value(indentation, line.attribs['value'])
end
result
end
def deserialize_custom_class(hash, first_line)
return hash unless !@strict && hash.length == 2 && hash.key?(CUSTOM_CLASS_KEY)
class_name = hash[CUSTOM_CLASS_KEY]
begin
clazz = class_name == 'nil' ? NilClass : Object.const_get(class_name, false)
rescue NameError
raise Errors::ParseCustomClassNotFoundError.new(first_line, class_name)
end
raise Errors::ParseCustomClassNoCreateMethodError.new(first_line, class_name) unless clazz.respond_to? :nt_create
clazz.nt_create(hash['data'])
end
def parse_kv_dict_item(indentation, line)
key = line.attribs['key']
value = line.attribs['value']
if value.nil?
value = ''
if !@line_scanner.peek.nil? && @line_scanner.peek.indentation > indentation
value = parse_any(@line_scanner.peek.indentation)
end
end
[key, value]
end
def parse_key_item_key(indentation, line)
key = line.attribs['key']
while @line_scanner.peek&.tag == :key_item && @line_scanner.peek.indentation == indentation
line = @line_scanner.read_next
key += "\n#{line.attribs['key']}"
end
key
end
def parse_key_item_value(indentation, line)
return '' if @line_scanner.peek.nil?
exp_types = %i[dict_item key_item list_item string_item]
unless exp_types.member?(@line_scanner.peek.tag)
raise Errors::ParseLineTypeNotExpectedError.new(line, exp_types, line.tag)
end
unless @line_scanner.peek.indentation > indentation
raise Errors::ParseMultilineKeyNoValueError,
line
end
parse_any(@line_scanner.peek.indentation)
end
def parse_kv_key_item(indentation, line)
key = parse_key_item_key(indentation, line)
value = parse_key_item_value(indentation, line)
[key, value]
end
def parse_dict_key_value(line, indentation)
if line.tag == :dict_item
parse_kv_dict_item(indentation, line)
else
parse_kv_key_item(indentation, line)
end
end
def assert_dict_item_line(line, indentation)
Errors.raise_unrecognized_line(line) if line.tag == :unrecognized
raise Errors::ParseInvalidIndentationError.new(line, indentation) if line.indentation != indentation
raise Errors::ParseLineTypeExpectedDictItemError, line unless %i[dict_item key_item].include? line.tag
end
def parse_dict_item(indentation)
result = {}
first_line = nil
while !@line_scanner.peek.nil? && @line_scanner.peek.indentation >= indentation
line = @line_scanner.read_next
first_line = line if first_line.nil?
assert_dict_item_line(line, indentation)
key, value = parse_dict_key_value(line, indentation)
raise Errors::ParseDictDuplicateKeyError, line if result.key? key
result[key] = value
end
deserialize_custom_class(result, first_line)
end
def assert_string_line(line, indentation)
raise Errors::ParseInvalidIndentationError.new(line, indentation) if line.indentation != indentation
raise Errors::ParseLineTypeNotExpectedError.new(line, %i[string_item], line.tag) unless line.tag == :string_item
end
def parse_string_item(indentation)
result = []
while !@line_scanner.peek.nil? && @line_scanner.peek.indentation >= indentation
line = @line_scanner.read_next
assert_string_line(line, indentation)
value = line.attribs['value']
result << value
end
result.join("\n")
end
def parse_inline_dict
result = InlineParser.new(@line_scanner.read_next).parse
unless result.is_a? Hash
raise Errors::AssertionError,
"Expected inline value to be Hash but is #{result.class.name}"
end
result
end
def parse_inline_list
result = InlineParser.new(@line_scanner.read_next).parse
unless result.is_a? Array
raise Errors::AssertionError,
"Expected inline value to be Array but is #{result.class.name}"
end
result
end
end
private_constant :Parser
end