# A mini-language for parsing files. This is only used file the ParsedFile # provider, but it makes more sense to split it out so it's easy to maintain # in one place. # # You can use this module to create simple parser/generator classes. For instance, # the following parser should go most of the way to parsing /etc/passwd: # # class Parser # include Puppet::Util::FileParsing # record_line :user, :fields => %w{name password uid gid gecos home shell}, # :separator => ":" # end # # You would use it like this: # # parser = Parser.new # lines = parser.parse(File.read("/etc/passwd")) # # lines.each do |type, hash| # type will always be :user, since we only have one # p hash # end # # Each line in this case would be a hash, with each field set appropriately. # You could then call 'parser.to_line(hash)' on any of those hashes to generate # the text line again. module Puppet::Util::FileParsing include Puppet::Util attr_writer :line_separator, :trailing_separator # Clear all existing record definitions. Only used for testing. def clear_records @record_types.clear @record_order.clear end def fields(type) type = symbolize(type) if @record_types.include?(type) @record_types[type][:fields].dup else nil end end # Try to match a specific text line. def handle_text_line(line, hash) if line =~ hash[:match] return {:record_type => hash[:name], :line => line} else return nil end end # Try to match a record. def handle_record_line(line, hash) if method = hash[:method] if ret = send(method, line.dup) ret[:record_type] = hash[:name] return ret else return nil end elsif regex = hash[:match] raise "Cannot use matches to handle records yet" # In this case, we try to match the whole line and then use the # match captures to get our fields. if match = regex.match(line) fields = [] ignore = hash[:ignore] || [] p match.captures match.captures.each_with_index do |value, i| fields << value unless ignore.include? i end p fields nil else Puppet.info "Did not match %s" % line nil end else ret = {} sep = hash[:separator] # String "helpfully" replaces ' ' with /\s+/ in splitting, so we # have to work around it. if sep == " " sep = / / end hash[:fields].zip(line.split(sep)) do |param, value| if value and value != "" ret[param] = value else ret[param] = :absent end end ret[:record_type] = hash[:name] return ret end end def line_separator unless defined?(@line_separator) @line_separator = "\n" end @line_separator end # Split text into separate lines using the record separator. def lines(text) # Remove any trailing separators, and then split based on them text.sub(/#{self.line_separator}\Q/,'').split(self.line_separator) end # Split a bunch of text into lines and then parse them individually. def parse(text) lines(text).collect do |line| parse_line(line) end end # Handle parsing a single line. def parse_line(line) unless records? raise Puppet::DevError, "No record types defined; cannot parse lines" end @record_order.each do |name| hash = @record_types[name] unless hash raise Puppet::DevError, "Did not get hash for %s: %s" % [name, @record_types.inspect] end method = "handle_%s_line" % hash[:type] if respond_to?(method) if result = send(method, line, hash) return result end else raise Puppet::DevError, "Somehow got invalid line type %s" % hash[:type] end end return nil end # Define a new type of record. These lines get split into hashes. Valid # options are: # * :absent: What to use when a field is absent. Defaults to "". # * :fields: The list of fields, as an array. By default, all # fields are considered required. # * :joiner: How to join fields together. Defaults to '\t'. # * :optional: Which fields are optional. If these are missing, # you'll just get the 'absent' value instead of an ArgumentError. # * :rts: Whether to remove trailing whitespace. Defaults to false. # If true, whitespace will be removed; if a regex, then whatever matches # the regex will be removed. # * :separator: The record separator. Defaults to /\s+/. def record_line(name, options, &block) unless options.include?(:fields) raise ArgumentError, "Must include a list of fields" end invalidfields = [:record_type, :target, :on_disk] options[:fields] = options[:fields].collect do |field| r = symbolize(field) if invalidfields.include?(r) raise ArgumentError.new("Cannot have fields named %s" % r) end r end options[:absent] ||= "" if options[:optional] options[:optional] = options[:optional].collect { |f| symbolize(f) } else options[:optional] = [] end options[:separator] ||= /\s+/ # Unless they specified a string-based joiner, just use a single # space as the join value. unless options[:separator].is_a?(String) or options[:joiner] options[:joiner] = " " end if block_given? method = "handle_record_line_%s" % name if respond_to?(method) raise "Already have a method defined for this record" end meta_def(method, &block) options[:method] = method end new_line_type(name, :record, options) end # Are there any record types defined? def records? defined?(@record_types) and ! @record_types.empty? end # Define a new type of text record. def text_line(name, options) unless options.include?(:match) raise ArgumentError, "You must provide a :match regex for text lines" end new_line_type(name, :text, options) end # Generate a file from a bunch of hash records. def to_file(records) text = records.collect { |record| to_line(record) }.join(line_separator) if trailing_separator text += line_separator end return text end # Convert our parsed record into a text record. def to_line(details) unless type = @record_types[details[:record_type]] raise ArgumentError, "Invalid record type %s" % details[:record_type] end case type[:type] when :text: return details[:line] else joinchar = type[:joiner] || type[:separator] line = type[:fields].collect { |field| # If the field is marked absent, use the appropriate replacement if details[field] == :absent or details[field].nil? if type[:optional].include?(field) type[:absent] else raise ArgumentError, "Field %s is required" % field end else details[field].to_s end }.reject { |c| c.nil?}.join(joinchar) if regex = type[:rts] # If they say true, then use whitespace; else, use their regex. if regex == true regex = /\s+$/ end return line.sub(regex,'') else return line end end end # Whether to add a trailing separator to the file. Defaults to true def trailing_separator if defined? @trailing_separator return @trailing_separator else return true end end def valid_attr?(type, attr) type = symbolize(type) if @record_types[type] and @record_types[type][:fields].include?(symbolize(attr)) return true else if symbolize(attr) == :ensure return true else false end end end private # Define a new type of record. def new_line_type(name, type, options) @record_types ||= {} @record_order ||= [] name = symbolize(name) if @record_types.include?(name) raise ArgumentError, "Line type %s is already defined" % name end options[:name] = name options[:type] = type @record_types[name] = options @record_order << name return options end end # $Id$