lib/metaheader.rb in metaheader-1.0 vs lib/metaheader.rb in metaheader-1.1

- old
+ new

@@ -1,40 +1,56 @@ # @test Hello World require 'metaheader/version' class MetaHeader + # @abstract Subclass and override {#parse} to implement a custom parser. class Parser - def self.each(&b) - ObjectSpace.each_object(Class).select { |klass| klass < self }.each &b - end + class << self + # @!visibility private + def inherited(k) + @parsers ||= [] + @parsers << k + end - def initialize(mh) - @mh = mh + # @!visibility private + def each(&b) + @parsers&.each(&b) + end end + # @return [MetaHeader] the current instance def header @mh end + + # @param raw_input [String] + # @return [void] + def parse(raw_input) + raise NotImplementedError + end end REQUIRED = Object.new.freeze OPTIONAL = Object.new.freeze + VALUE = Object.new.freeze + SINGLELINE = Object.new.freeze - Tag = Struct.new :name, :value - + # Whether to fail validation if unknown tags are encoutered. + # @see #validate + # @return [Boolean] attr_accessor :strict - REGEX = /\A(?<prefix>.*?) - (?:@(?<key>\w+)|(?<key>[\w][\w\s]*?)\s*:) - (?:\s+(?<value>[^\n]+))? - \Z/x.freeze - - def self.from_file(file) - self.new File.read(file) + # Create a new instance from the contents of a file. + # @param path [String] path to the file to be read + # @return [MetaHeader] + def self.from_file(path) + self.new File.read(path) end + # Parse every tags found in input up to the first newline. + # @param input [String] def initialize(input) @strict = false @data = {} @last_key = nil @@ -42,132 +58,174 @@ input.each_line {|line| if line.strip.empty? break else - self.<< line + parse line end } Parser.each {|klass| - parser = klass.new self + parser = klass.new + parser.instance_variable_set :@mh, self parser.parse input } end - def <<(line) - # multiline value must have the same prefix - if @last_key && line.index(@last_prefix) == 0 - # remove the line prefix - mline = line[@last_prefix.size..-1] - stripped = mline.strip - - indent_level = mline.index stripped - - if indent_level > 0 - tag = @data[@last_key] - - if tag.value.is_a? String - tag.value += "\n" - else - tag.value = String.new - end - - tag.value += stripped - - return - else - @last_key = nil - end - end - - return unless match = REGEX.match(line) - - # single line - @last_prefix = match[:prefix] - @last_key = match[:key].downcase.gsub(/[^\w]/, '_').to_sym - - value = match[:value] || true - @data[@last_key] = Tag.new match[:key].freeze, value - end - + # Returns the value of a tag by its name, or nil if not found. + # @return [Object, nil] def [](key) tag = @data[key] and tag.value end + # Replaces the value of a tag. + # @param value the new value + # @return value def []=(key, value) @data[key] ||= Tag.new key @data[key].value = value end + # Returns how many tags were found in the input. + # @return [Fixnum] def size @data.size end + # Whether any tags were found in the input. + # @return [Boolean] def empty? @data.empty? end + # Make a hash from the parsed data + # @return [Hash] def to_h Hash[@data.map {|v| [v.first, v.last.value] }] end + # Makes a human-readable representation of the current instance. + # @return [String] def inspect - to_h.inspect + "#<#{self.class} #{to_h}>" end + # Validates parsed data according to a custom set of rules. + # @example + # mh = MetaHeader.new "@hello world\n@chunky bacon" + # mh.validate \ + # hello: [MetaHeader::REQUIRED, MetaHeader::SINGLELINE, /\d/], + # chunky: proc {|value| 'not bacon' unless value == 'bacon' } + # @param rules [Hash] tag_name => rule or array_of_rules + # @return [Array, nil] error list or nil + # @see REQUIRED + # @see OPTIONAL + # @see VALUE + # @see SINGLELINE def validate(rules) errors = Array.new if @strict @data.each_key {|key| - errors << 'unknown tag "%s"' % key unless rules.has_key? key + errors << "unknown tag '%s'" % key unless rules.has_key? key } end rules.each_pair {|key, rule| - if key_errors = validate_key(key, rule) - errors.concat key_errors + if key_error = validate_key(key, rule) + errors << key_error end } - errors.empty? ? nil : errors + errors unless errors.empty? end +private + # @!visibility private + Tag = Struct.new :name, :value + + REGEX = /\A(?<prefix>.*?) + (?:@(?<key>\w+)|(?<key>[\w][\w\s]*?)\s*:) + (?:\s+(?<value>[^\n]+))? + \Z/x.freeze + + def parse(line) + # multiline value must have the same prefix + if @last_key && line.index(@last_prefix) == 0 + # remove the line prefix + mline = line[@last_prefix.size..-1] + stripped = mline.strip + + indent_level = mline.index stripped + + if indent_level > 0 + tag = @data[@last_key] + + if tag.value.is_a? String + tag.value += "\n" + else + tag.value = String.new + end + + tag.value += stripped + + return + else + @last_key = nil + end + end + + return unless match = REGEX.match(line) + + # single line + @last_prefix = match[:prefix] + @last_key = match[:key].downcase.gsub(/[^\w]/, '_').to_sym + + value = match[:value] || true + @data[@last_key] = Tag.new match[:key].freeze, value + end + + def validate_key(key, rules) rules = Array(rules) return if rules.empty? - errors = Array.new - unless @data.has_key? key - if rules.include? OPTIONAL - return nil + if rules.include? REQUIRED + return "missing tag '%s'" % key else - return ['missing tag "%s"' % key] + return nil end end tag = @data[key] - value = tag.value - value = String.new if value == true + str_value = tag.value + str_value = String.new if str_value == true rules.each {|rule| case rule when REQUIRED, OPTIONAL - # do nothing + # nothing to do here: REQUIRED is handled in the code above + when SINGLELINE + if str_value.include? "\n" + return "tag '%s' must be singleline" % tag.name + end + when VALUE + if str_value.empty? + return "missing value for tag '%s'" % tag.name + end when Regexp - unless rule.match value - errors << 'invalid value for tag "%s"' % tag.name + unless rule.match str_value + return "invalid value for tag '%s'" % tag.name end - when Proc - if error = rule[value] - errors << 'invalid value for tag "%s": %s' % [tag.name, error] + when Proc, Method + if error = rule.call(tag.value) + return "invalid value for tag '%s': %s" % [tag.name, error] end else raise ArgumentError end } - errors.empty? ? nil : errors + nil end end