rake-pipeline 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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