# 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.
require 'puppet/util/methodhelper'
module Puppet::Util::FileParsing
include Puppet::Util
attr_writer :line_separator, :trailing_separator
class FileRecord
include Puppet::Util
include Puppet::Util::MethodHelper
attr_accessor :absent, :joiner, :rts, :separator, :rollup, :name, :match, :block_eval
attr_reader :fields, :optional, :type
INVALID_FIELDS = [:record_type, :target, :on_disk]
# Customize this so we can do a bit of validation.
def fields=(fields)
@fields = fields.collect do |field|
r = symbolize(field)
if INVALID_FIELDS.include?(r)
raise ArgumentError.new("Cannot have fields named %s" % r)
end
r
end
end
def initialize(type, options = {}, &block)
@type = symbolize(type)
unless [:record, :text].include?(@type)
raise ArgumentError, "Invalid record type %s" % @type
end
set_options(options)
if self.type == :record
# Now set defaults.
self.absent ||= ""
self.separator ||= /\s+/
self.joiner ||= " "
self.optional ||= []
unless defined? @rollup
@rollup = true
end
end
if block_given?
@block_eval ||= :process
# Allow the developer to specify that a block should be instance-eval'ed.
if @block_eval == :instance
instance_eval(&block)
else
meta_def(@block_eval, &block)
end
end
end
# Convert a record into a line by joining the fields together appropriately.
# This is pulled into a separate method so it can be called by the hooks.
def join(details)
joinchar = self.joiner
fields.collect { |field|
# If the field is marked absent, use the appropriate replacement
if details[field] == :absent or details[field] == [:absent] or details[field].nil?
if self.optional.include?(field)
self.absent
else
raise ArgumentError, "Field '%s' is required" % field
end
else
details[field].to_s
end
}.reject { |c| c.nil?}.join(joinchar)
end
# Customize this so we can do a bit of validation.
def optional=(optional)
@optional = optional.collect do |field|
symbolize(field)
end
end
# Create a hook that modifies the hash resulting from parsing.
def post_parse=(block)
meta_def(:post_parse, &block)
end
# Create a hook that modifies the hash just prior to generation.
def pre_gen=(block)
meta_def(:pre_gen, &block)
end
# Are we a text type?
def text?
type == :text
end
def to_line=(block)
meta_def(:to_line, &block)
end
end
# Clear all existing record definitions. Only used for testing.
def clear_records
@record_types.clear
@record_order.clear
end
def fields(type)
if record = record_type(type)
record.fields.dup
else
nil
end
end
# Try to match a specific text line.
def handle_text_line(line, record)
if line =~ record.match
return {:record_type => record.name, :line => line}
else
return nil
end
end
# Try to match a record.
def handle_record_line(line, record)
ret = nil
if record.respond_to?(:process)
if ret = record.send(:process, line.dup)
unless ret.is_a?(Hash)
raise Puppet::DevError,
"Process record type %s returned non-hash" % record.name
end
else
return nil
end
elsif regex = record.match
# 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 = []
ret = {}
record.fields.zip(match.captures).each do |field, value|
if value == record.absent
ret[field] = :absent
else
ret[field] = value
end
end
else
nil
end
else
ret = {}
sep = record.separator
# String "helpfully" replaces ' ' with /\s+/ in splitting, so we
# have to work around it.
if sep == " "
sep = / /
end
line_fields = line.split(sep)
record.fields.each do |param|
value = line_fields.shift
if value and value != record.absent
ret[param] = value
else
ret[param] = :absent
end
end
if record.rollup and ! line_fields.empty?
last_field = record.fields[-1]
val = ([ret[last_field]] + line_fields).join(record.joiner)
ret[last_field] = val
end
end
if ret
ret[:record_type] = record.name
return ret
else
return nil
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)
count = 1
lines(text).collect do |line|
count += 1
if val = parse_line(line)
val
else
error = Puppet::Error.new("Could not parse line %s" % line.inspect)
error.line = count
raise error
end
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 |record|
# These are basically either text or record lines.
method = "handle_%s_line" % record.type
if respond_to?(method)
if result = send(method, line, record)
if record.respond_to?(:post_parse)
record.send(:post_parse, result)
end
return result
end
else
raise Puppet::DevError,
"Somehow got invalid line type %s" % record.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
record = FileRecord.new(:record, options, &block)
record.name = symbolize(name)
new_line_type(record)
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, &block)
unless options.include?(:match)
raise ArgumentError, "You must provide a :match regex for text lines"
end
record = FileRecord.new(:text, options, &block)
record.name = symbolize(name)
new_line_type(record)
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 record = record_type(details[:record_type])
raise ArgumentError, "Invalid record type %s" % details[:record_type].inspect
end
if record.respond_to?(:pre_gen)
details = details.dup
record.send(:pre_gen, details)
end
case record.type
when :text: return details[:line]
else
if record.respond_to?(:to_line)
return record.to_line(details)
end
line = record.join(details)
if regex = record.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 = record_type(type) and record.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(record)
@record_types ||= {}
@record_order ||= []
if @record_types.include?(record.name)
raise ArgumentError, "Line type %s is already defined" % record.name
end
@record_types[record.name] = record
@record_order << record
return record
end
# Retrieve the record object.
def record_type(type)
@record_types[symbolize(type)]
end
end
# $Id: fileparsing.rb 2750 2007-08-06 17:59:37Z luke $