summaryrefslogtreecommitdiffstats
path: root/lib/puppet/util/fileparsing.rb
blob: 23d02ea60280b2fa4e3887bed79d0bcab41f9fc0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
# 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
        # LAK:NOTE See http://snurl.com/21zf8  [groups_google_com] 
        x = 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:
    # * <tt>:absent</tt>: What to use as value within a line, when a field is
    #   absent.  Note that in the record object, the literal :absent symbol is
    #   used, and not this value.  Defaults to "".
    # * <tt>:fields</tt>: The list of fields, as an array.  By default, all
    #   fields are considered required.
    # * <tt>:joiner</tt>: How to join fields together.  Defaults to '\t'.
    # * <tt>:optional</tt>: Which fields are optional.  If these are missing,
    #   you'll just get the 'absent' value instead of an ArgumentError.
    # * <tt>:rts</tt>: 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.
    # * <tt>:separator</tt>: 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