rake-pipeline 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,146 @@
1
+ module Rake
2
+ class Pipeline
3
+ # This class exists purely to provide a convenient DSL for
4
+ # configuring a pipeline.
5
+ #
6
+ # All instance methods of {DSL} are available in the context
7
+ # the block passed to +Rake::Pipeline.+{Pipeline.build}.
8
+ #
9
+ # When configuring a pipeline, you *must* provide both a
10
+ # root, and a series of files using {#input}.
11
+ class DSL
12
+ # @return [Pipeline] the pipeline the DSL should configure
13
+ attr_reader :pipeline
14
+
15
+ # Configure a pipeline with a passed in block.
16
+ #
17
+ # @param [Pipeline] pipeline the pipeline that the DSL
18
+ # should configure.
19
+ # @param [Proc] block the block describing the
20
+ # configuration. This block will be evaluated in
21
+ # the context of a new instance of {DSL}
22
+ # @return [void]
23
+ def self.evaluate(pipeline, &block)
24
+ new(pipeline).instance_eval(&block)
25
+ copy_filter = Rake::Pipeline::ConcatFilter.new
26
+ copy_filter.output_name_generator = proc { |input| input }
27
+ pipeline.add_filter(copy_filter)
28
+ end
29
+
30
+ # Create a new {DSL} to configure a pipeline.
31
+ #
32
+ # @param [Pipeline] pipeline the pipeline that the DSL
33
+ # should configure.
34
+ # @return [void]
35
+ def initialize(pipeline)
36
+ @pipeline = pipeline
37
+ end
38
+
39
+ # Define the input location and files for the pipeline.
40
+ #
41
+ # @example
42
+ # !!!ruby
43
+ # Rake::Pipeline.build do
44
+ # input "app/assets", "**/*.js"
45
+ # # ...
46
+ # end
47
+ #
48
+ # @param [String] root the root path where the pipeline
49
+ # should find its input files.
50
+ # @param [String] glob a file pattern that represents
51
+ # the list of all files that the pipeline should
52
+ # process. The default is +"**/*"+.
53
+ # @return [void]
54
+ def input(root, glob="**/*")
55
+ pipeline.input_root = root
56
+ pipeline.input_glob = glob
57
+ end
58
+
59
+ # Add a filter to the pipeline.
60
+ #
61
+ # In addition to a filter class, {#filter} takes a
62
+ # block that describes how the filter should map
63
+ # input files to output files.
64
+ #
65
+ # By default, the block maps an input file into
66
+ # an output file with the same name.
67
+ #
68
+ # Any additional arguments passed to {#filter} will
69
+ # be passed on to the filter class's constructor.
70
+ #
71
+ # @see Filter#outputs Filter#output (for an example
72
+ # of how a list of input files gets mapped to
73
+ # its outputs)
74
+ #
75
+ # @param [Class] filter_class the class of the filter.
76
+ # @param [Array] ctor_args a list of arguments to pass
77
+ # to the filter's constructor.
78
+ # @param [Proc] block an output file name generator.
79
+ # @return [void]
80
+ def filter(filter_class, *ctor_args, &block)
81
+ filter = filter_class.new(*ctor_args, &block)
82
+ pipeline.add_filter(filter)
83
+ end
84
+
85
+ # Apply a number of filters, but only to files matching
86
+ # a particular pattern.
87
+ #
88
+ # Inside the block passed to {#match match}, you may
89
+ # specify any number of filters that should be applied
90
+ # to files matching the pattern.
91
+ #
92
+ # @param [String] pattern a glob pattern to match
93
+ # @param [Proc] block a block that supplies filters
94
+ # @return [Matcher]
95
+ #
96
+ # @example
97
+ # !!!ruby
98
+ # Pipeline.build do
99
+ # input "app/assets"
100
+ # output "public"
101
+ #
102
+ # # compile coffee files into JS files
103
+ # match "*.coffee" do
104
+ # filter CompileCoffee do |input|
105
+ # input.sub(/coffee$/, "js")
106
+ # end
107
+ # end
108
+ #
109
+ # # because the previous step converted coffeee
110
+ # # into JS, the coffee files will be included here
111
+ # match "*.js" do
112
+ # filter MinifyFilter
113
+ # filter Rake::Pipeline::ConcatFilter, "application.js"
114
+ # end
115
+ # end
116
+ def match(pattern, &block)
117
+ matcher = pipeline.copy(Matcher, &block)
118
+ matcher.glob = pattern
119
+ pipeline.add_filter matcher
120
+ matcher
121
+ end
122
+
123
+ # Specify the output directory for the pipeline.
124
+ #
125
+ # @param [String] root the output directory.
126
+ # @return [void]
127
+ def output(root)
128
+ pipeline.output_root = root
129
+ end
130
+
131
+ # Specify the location of the temporary directory.
132
+ # Filters will store intermediate build artifacts
133
+ # here.
134
+ #
135
+ # This defaults "tmp" in the current working directory.
136
+ #
137
+ # @param [String] root the temporary directory
138
+ # @return [void]
139
+ def tmpdir(root)
140
+ pipeline.tmpdir = root
141
+ end
142
+ end
143
+ end
144
+ end
145
+
146
+
@@ -0,0 +1,17 @@
1
+ module Rake
2
+ class Pipeline
3
+ # The general Rake::Pipeline error class
4
+ class Error < ::StandardError
5
+ end
6
+
7
+ # The error that Rake::Pipeline uses when it detects
8
+ # that a file uses an improper encoding.
9
+ class EncodingError < Error
10
+ end
11
+
12
+ # The error that Rake::Pipeline uses if you try to
13
+ # write to a FileWrapper before creating it.
14
+ class UnopenedFile < Error
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,173 @@
1
+ module Rake
2
+ class Pipeline
3
+ # This class wraps a file for consumption inside of filters. It is
4
+ # initialized with a root and path, and filters usually use the
5
+ # {#read} and {#write} methods to work with these files.
6
+ #
7
+ # The {#root} and +path+ parameters are provided by the {Filter}
8
+ # class' internal implementation. Individual filters do not need
9
+ # to worry about them.
10
+ #
11
+ # The root of a {FileWrapper} is always an absolute path.
12
+ class FileWrapper
13
+ # @return [String] an absolute path representing this {FileWrapper}'s
14
+ # root directory.
15
+ attr_accessor :root
16
+
17
+ # @return [String] the path to the file represented by the {FileWrapper},
18
+ # relative to its {#root}.
19
+ attr_accessor :path
20
+
21
+ # @return [String] the encoding that the file represented by this
22
+ # {FileWrapper} is encoded in. Filters set the {#encoding} to
23
+ # +BINARY+ if they are declared as processing binary data.
24
+ attr_accessor :encoding
25
+
26
+ # Create a new {FileWrapper}, passing in optional root, path, and
27
+ # encoding. Any of the parameters can be ommitted and supplied later.
28
+ #
29
+ # @return [void]
30
+ def initialize(root=nil, path=nil, encoding="UTF-8")
31
+ @root, @path, @encoding = root, path, encoding
32
+ @created_file = nil
33
+ end
34
+
35
+ # Create a new {FileWrapper FileWrapper} with the same root and
36
+ # path as this {FileWrapper FileWrapper}, but with a specified
37
+ # encoding.
38
+ #
39
+ # @param [String] encoding the encoding for the new object
40
+ # @return [FileWrapper]
41
+ def with_encoding(encoding)
42
+ self.class.new(@root, @path, encoding)
43
+ end
44
+
45
+ # A {FileWrapper} is equal to another {FileWrapper} for hashing purposes
46
+ # if they have the same {#root} and {#path}
47
+ #
48
+ # @param [FileWrapper] other another {FileWrapper} to compare.
49
+ # @return [true,false]
50
+ def eql?(other)
51
+ return false unless other.is_a?(self.class)
52
+ root == other.root && path == other.path
53
+ end
54
+ alias == eql?
55
+
56
+ # Similar to {#eql?}, generate a {FileWrapper}'s {#hash} from its {#root}
57
+ # and {#path}
58
+ #
59
+ # @see #eql?
60
+ # @return [Fixnum] a hash code
61
+ def hash
62
+ [root, path].hash
63
+ end
64
+
65
+ # The full path of a FileWrapper is its root joined with its path
66
+ #
67
+ # @return [String] the {FileWrapper}'s full path
68
+ def fullpath
69
+ raise "#{root}, #{path}" unless root =~ /^\//
70
+ File.join(root, path)
71
+ end
72
+
73
+ # Make FileWrappers sortable
74
+ #
75
+ # @param [FileWrapper] other {FileWrapper FileWrapper}
76
+ # @return [Fixnum] -1, 0, or 1
77
+ def <=>(other)
78
+ [root, path, encoding] <=> [other.root, other.path, other.encoding]
79
+ end
80
+
81
+ # Does the file represented by the {FileWrapper} exist in the file system?
82
+ #
83
+ # @return [true,false]
84
+ def exists?
85
+ File.exists?(fullpath)
86
+ end
87
+
88
+ # Read the contents of the file represented by the {FileWrapper}.
89
+ #
90
+ # Read the file using the {FileWrapper}'s encoding, which will result in
91
+ # this method returning a +String+ tagged with the {FileWrapper}'s encoding.
92
+ #
93
+ # @return [String] the contents of the file
94
+ # @raise [EncodingError] when the contents of the file are not valid in the
95
+ # expected encoding specified in {#encoding}.
96
+ def read
97
+ contents = if "".respond_to?(:encode)
98
+ File.read(fullpath, :encoding => encoding)
99
+ else
100
+ File.read(fullpath)
101
+ end
102
+
103
+ if "".respond_to?(:encode) && !contents.valid_encoding?
104
+ raise EncodingError, "The file at the path #{fullpath} is not valid UTF-8. Please save it again as UTF-8."
105
+ end
106
+
107
+ contents
108
+ end
109
+
110
+ # Create a new file at the {FileWrapper}'s {#fullpath}. If the file already
111
+ # exists, it will be overwritten.
112
+ #
113
+ # @api private
114
+ # @yieldparam [File] file the newly created file
115
+ # @return [File] if a block was not given
116
+ def create
117
+ FileUtils.mkdir_p(File.dirname(fullpath))
118
+
119
+ @created_file = if "".respond_to?(:encode)
120
+ File.open(fullpath, "w:#{encoding}")
121
+ else
122
+ File.open(fullpath, "w")
123
+ end
124
+
125
+ if block_given?
126
+ yield @created_file
127
+ end
128
+
129
+ @created_file
130
+ ensure
131
+ if block_given?
132
+ @created_file.close
133
+ @created_file = nil
134
+ end
135
+ end
136
+
137
+ # Close the file represented by the {FileWrapper} if it was previously opened.
138
+ #
139
+ # @api private
140
+ # @return [void]
141
+ def close
142
+ raise IOError, "closed stream" unless @created_file
143
+ @created_file.close
144
+ @created_file = nil
145
+ end
146
+
147
+ # Check to see whether the file represented by the {FileWrapper} is open.
148
+ #
149
+ # @api private
150
+ # @return [true,false]
151
+ def closed?
152
+ @created_file.nil?
153
+ end
154
+
155
+ # Write a String to a previously opened file. This method is called repeatedly
156
+ # by a {Filter}'s +#generate_output+ method and does not create a brand new
157
+ # file for each invocation.
158
+ #
159
+ # @raise [UnopenedFile] if the file is not already opened.
160
+ def write(string)
161
+ raise UnopenedFile unless @created_file
162
+ @created_file.write(string)
163
+ end
164
+
165
+ # @return [String] A pretty representation of the {FileWrapper}.
166
+ def inspect
167
+ "#<FileWrapper root=#{root.inspect} path=#{path.inspect} encoding=#{encoding.inspect}>"
168
+ end
169
+
170
+ alias to_s inspect
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,209 @@
1
+ require "rake"
2
+
3
+ module Rake
4
+ class Pipeline
5
+
6
+ # A Filter is added to a pipeline and converts input files
7
+ # into output files.
8
+ #
9
+ # Filters operate on FileWrappers, which abstract away the
10
+ # root directory of a file, providing a relative path and
11
+ # a mechanism for reading and writing.
12
+ #
13
+ #
14
+ # For instance, a filter to wrap the contents of each file
15
+ # in a JavaScript closure would look like:
16
+ #
17
+ # !!!ruby
18
+ # require "json"
19
+ #
20
+ # class ClosureFilter < Rake::Pipeline::Filter
21
+ # def generate_output(inputs, output)
22
+ # inputs.each do |input|
23
+ # output.write "(function() { #{input.read.to_json} })()"
24
+ # end
25
+ # end
26
+ # end
27
+ #
28
+ # A filter's files come from the input directory or the directory
29
+ # owned by the previous filter, but filters are insulated from
30
+ # this concern.
31
+ #
32
+ # You can call +path+ on a FileWrapper to get the file's relative
33
+ # path, or `fullpath` to get its absolute path, but you should,
34
+ # in general, not use `fullpath` but instead use methods of
35
+ # FileWrapper like `read` and `write` that abstract the details
36
+ # from you.
37
+ #
38
+ # @see ConcatFilter Rake::Pipeline::ConcatFilter for another
39
+ # example filter implementation.
40
+ #
41
+ # @abstract
42
+ class Filter
43
+ # @return [Array<FileWrapper>] an Array of FileWrappers that
44
+ # represent the inputs of this filter. The Pipeline will
45
+ # usually set this up.
46
+ attr_accessor :input_files
47
+
48
+ # @return [Proc] a block that returns the relative output
49
+ # filename for a particular input file.
50
+ attr_accessor :output_name_generator
51
+
52
+ # @return [String] the root directory to write output files
53
+ # to. For the last filter in a pipeline, the pipeline will
54
+ # set this to the pipeline's output. For all other filters,
55
+ # the pipeline will create a temporary directory that it
56
+ # also uses when creating FileWrappers for the next filter's
57
+ # inputs.
58
+ attr_accessor :output_root
59
+
60
+ # @return [Array<Rake::Task>] an Array of Rake tasks created
61
+ # for this filter. Each unique output file will get a
62
+ # single task.
63
+ attr_reader :rake_tasks
64
+
65
+ # @return [Rake::Application] the Rake::Application that the
66
+ # filter should define new rake tasks on.
67
+ attr_writer :rake_application
68
+
69
+ attr_writer :file_wrapper_class
70
+
71
+ # @param [Proc] block a block to use as the Filter's
72
+ # {#output_name_generator}.
73
+ def initialize(&block)
74
+ block ||= proc { |input| input }
75
+ @output_name_generator = block
76
+ end
77
+
78
+ # Invoke this method in a subclass of Filter to declare that
79
+ # it expects to work with BINARY data, and that data that is
80
+ # not valid UTF-8 should be allowed.
81
+ #
82
+ # @return [void]
83
+ def self.processes_binary_files
84
+ define_method(:encoding) { "BINARY" }
85
+ end
86
+
87
+ # @return [Class] the class to use as the wrapper for output
88
+ # files.
89
+ def file_wrapper_class
90
+ @file_wrapper_class ||= FileWrapper
91
+ end
92
+
93
+ # Set the input files to a list of FileWrappers. The filter
94
+ # will map these into equivalent FileWrappers with the
95
+ # filter's encoding applied.
96
+ #
97
+ # By default, a filter's encoding is +UTF-8+, unless
98
+ # it calls #processes_binary_files, which changes it to
99
+ # +BINARY+.
100
+ #
101
+ # @param [Array<FileWrapper>] a list of FileWrapper objects
102
+ def input_files=(files)
103
+ @input_files = files.map do |file|
104
+ file.with_encoding(encoding)
105
+ end
106
+ end
107
+
108
+ # A hash of output files pointing at their associated input
109
+ # files. The output names are created by applying the
110
+ # {#output_name_generator} to each input file.
111
+ #
112
+ # For exmaple, if you had the following input files:
113
+ #
114
+ # javascripts/jquery.js
115
+ # javascripts/sproutcore.js
116
+ # stylesheets/sproutcore.css
117
+ #
118
+ # And you had the following {#output_name_generator}:
119
+ #
120
+ # !!!ruby
121
+ # filter.output_name_generator = proc do |filename|
122
+ # # javascripts/jquery.js becomes:
123
+ # # ["javascripts", "jquery", "js"]
124
+ # directory, file, ext = file.split(/[\.\/]/)
125
+ #
126
+ # "#{directory}.#{ext}"
127
+ # end
128
+ #
129
+ # You would end up with the following hash:
130
+ #
131
+ # !!!ruby
132
+ # {
133
+ # #<FileWrapper path="javascripts.js" root="#{output_root}> => [
134
+ # #<FileWrapper path="javascripts/jquery.js" root="#{previous_filter.output_root}">,
135
+ # #<FileWrapper path="javascripts/sproutcore.js" root="#{previous_filter.output_root}">
136
+ # ],
137
+ # #<FileWrapper path="stylesheets.css" root="#{output_root}"> => [
138
+ # #<FileWrapper path="stylesheets/sproutcore.css" root=#{previous_filter.output_root}">
139
+ # ]
140
+ # }
141
+ #
142
+ # Each output file becomes a Rake task, which invokes the +#generate_output+
143
+ # method defined by the subclass of {Filter} with the Array of inputs and
144
+ # the output (all as {FileWrapper}s).
145
+ #
146
+ # @return [Hash{FileWrapper => Array<FileWrapper>}]
147
+ def outputs
148
+ hash = {}
149
+
150
+ input_files.each do |file|
151
+ output = output_wrapper(output_name_generator.call(file.path))
152
+
153
+ hash[output] ||= []
154
+ hash[output] << file
155
+ end
156
+
157
+ hash
158
+ end
159
+
160
+ # An Array of the {FileWrapper} objects that rerepresent this filter's
161
+ # output files. It is the same as +outputs.keys+.
162
+ #
163
+ # @see #outputs
164
+ # @return [Array<FileWrapper>]
165
+ def output_files
166
+ input_files.inject([]) do |array, file|
167
+ array |= [output_wrapper(output_name_generator.call(file.path))]
168
+ end
169
+ end
170
+
171
+ # The Rake::Application that the filter should define new tasks on.
172
+ #
173
+ # @return [Rake::Application]
174
+ def rake_application
175
+ @rake_application || Rake.application
176
+ end
177
+
178
+ # Generate the Rake tasks for the output files of this filter.
179
+ #
180
+ # @see #outputs #outputs (for information on how the output files are determined)
181
+ # @return [void]
182
+ def generate_rake_tasks
183
+ @rake_tasks = outputs.map do |output, inputs|
184
+ dependencies = inputs.map(&:fullpath)
185
+
186
+ dependencies.each { |path| create_file_task(path) }
187
+
188
+ create_file_task(output.fullpath, dependencies) do
189
+ output.create { generate_output(inputs, output) }
190
+ end
191
+ end
192
+ end
193
+
194
+ private
195
+ # @attr_reader
196
+ def encoding
197
+ "UTF-8"
198
+ end
199
+
200
+ def create_file_task(output, deps=[], &block)
201
+ rake_application.define_task(Rake::FileTask, output => deps, &block)
202
+ end
203
+
204
+ def output_wrapper(file)
205
+ file_wrapper_class.new(output_root, file, encoding)
206
+ end
207
+ end
208
+ end
209
+ end