# frozen-string-literal: true module Nanaimo # Transforms plist strings into Plist objects. # class Reader autoload :StringScanner, 'strscan' # Raised when attempting to read a plist with an unsupported file format. # class UnsupportedPlistFormatError < Error # @return [Symbol] The unsupported format. # attr_reader :format def initialize(format) @format = format end def to_s "#{format} plists are currently unsupported" end end # Raised when parsing fails. # class ParseError < Error # @return [[Integer, Integer]] The (line, column) offset into the plist # where the error occurred # attr_accessor :location # @return [String] The contents of the plist. # attr_accessor :plist_string def to_s "[!] #{super}#{context}" end def context(n = 2) line_number, column = location line_number -= 1 lines = plist_string.split(NEWLINE) s = line_number.succ.to_s.size indent = "#{' ' * s}# " indicator = "#{line_number.succ}> " m = ::String.new("\n") m << "#{indent}-------------------------------------------\n" m << lines[[line_number - n, 0].max...line_number].map do |l| "#{indent}#{l}\n" end.join line = lines[line_number].to_s m << "#{indicator}#{line}\n" m << ' ' * indent.size m << line[0, column.pred].gsub(/[^\t]/, ' ') m << "^\n" m << Array(lines[line_number.succ..[lines.count.pred, line_number + n].min]).map do |l| l.strip.empty? ? '' : "#{indent}#{l}\n" end.join m << "#{indent}-------------------------------------------\n" end end # @param plist_contents [String] # # @return [Symbol] The file format of the plist in the given string. # def self.plist_type(plist_contents) case plist_contents when /\Abplist/ :binary when /\A<\?xml/ :xml else :ascii end end # @param file_path [String] # # @return [Plist] A parsed plist from the given file # def self.from_file(file_path) new(File.read(file_path)) end # @param contents [String] The plist to be parsed # def initialize(contents) @scanner = StringScanner.new(contents) end # Parses the contents of the plist # # @return [Plist] The parsed Plist object. # def parse! plist_format = ensure_ascii_plist! read_string_encoding root_object = parse_object eat_whitespace! raise_parser_error ParseError, 'Found additional characters after parsing the root plist object' unless @scanner.eos? Nanaimo::Plist.new(root_object, plist_format) end private def ensure_ascii_plist! self.class.plist_type(@scanner.string).tap do |plist_format| raise UnsupportedPlistFormatError, plist_format unless plist_format == :ascii end end def read_string_encoding # TODO end def parse_object(already_parsed_comment: false) _comment = skip_to_non_space_matching_annotations unless already_parsed_comment start_pos = @scanner.pos raise_parser_error ParseError, 'Unexpected end of string while parsing' if @scanner.eos? if @scanner.skip(/\{/) parse_dictionary elsif @scanner.skip(/\(/) parse_array elsif @scanner.skip(//) raise_parser_error ParseError, "Data missing closing '>'" end data.chomp!('>') data.delete!(' ') unless data.size.even? @scanner.unscan raise_parser_error ParseError, 'Data has an uneven number of hex digits' end data = [data].pack('H*') Nanaimo::Data.new(data, nil) end def current_character @scanner.peek(1) end def read_singleline_comment unless comment = @scanner.scan_until(NEWLINE) raise_parser_error ParseError, 'Failed to terminate single line comment' end comment end def eat_whitespace! @scanner.skip(MANY_WHITESPACES) end NEWLINE_CHARACTERS = %W(\x0A \x0D \u2028 \u2029).freeze NEWLINE = Regexp.union(*NEWLINE_CHARACTERS) WHITESPACE_CHARACTERS = NEWLINE_CHARACTERS + %W(\x09 \x0B \x0C \x20) WHITESPACE = Regexp.union(*WHITESPACE_CHARACTERS) MANY_WHITESPACES = /#{WHITESPACE}+/ def read_multiline_comment unless annotation = @scanner.scan(%r{(?:.+?)(?=\*/)}m) raise_parser_error ParseError, 'Failed to terminate multiline comment' end @scanner.skip(%r{\*/}) annotation end def skip_to_non_space_matching_annotations annotation = ''.freeze until @scanner.eos? eat_whitespace! # Comment Detection if @scanner.skip(%r{//}) annotation = read_singleline_comment next elsif @scanner.skip(%r{/\*}) annotation = read_multiline_comment next end break end annotation end def location_in(scanner) pos = scanner.charpos line = scanner.string[0..scanner.charpos].scan(NEWLINE).size + 1 column = pos - (scanner.string.rindex(NEWLINE, pos - 1) || -1) column = [1, column].max [line, column] end def raise_parser_error(klass, message) exception = klass.new(message).tap do |error| error.location = location_in(@scanner) error.plist_string = @scanner.string end raise(exception) end end end