rake-pipeline 0.5.0 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (55) hide show
  1. data/.travis.yml +12 -0
  2. data/Gemfile +1 -0
  3. data/README.markdown +1 -1
  4. data/README.yard +61 -32
  5. data/Rakefile +9 -0
  6. data/bin/rakep +1 -24
  7. data/lib/generators/rake/pipeline/install/install_generator.rb +70 -0
  8. data/lib/rake-pipeline.rb +117 -53
  9. data/lib/rake-pipeline/cli.rb +56 -0
  10. data/lib/rake-pipeline/dsl.rb +3 -140
  11. data/lib/rake-pipeline/dsl/pipeline_dsl.rb +168 -0
  12. data/lib/rake-pipeline/dsl/project_dsl.rb +108 -0
  13. data/lib/rake-pipeline/dynamic_file_task.rb +188 -0
  14. data/lib/rake-pipeline/file_wrapper.rb +1 -1
  15. data/lib/rake-pipeline/filter.rb +45 -15
  16. data/lib/rake-pipeline/filters.rb +3 -1
  17. data/lib/rake-pipeline/filters/{concat.rb → concat_filter.rb} +0 -0
  18. data/lib/rake-pipeline/filters/ordering_concat_filter.rb +38 -0
  19. data/lib/rake-pipeline/filters/pipeline_finalizing_filter.rb +19 -0
  20. data/lib/rake-pipeline/graph.rb +178 -0
  21. data/lib/rake-pipeline/manifest.rb +63 -0
  22. data/lib/rake-pipeline/manifest_entry.rb +34 -0
  23. data/lib/rake-pipeline/matcher.rb +65 -30
  24. data/lib/rake-pipeline/middleware.rb +15 -12
  25. data/lib/rake-pipeline/precompile.rake +8 -0
  26. data/lib/rake-pipeline/project.rb +280 -0
  27. data/lib/rake-pipeline/railtie.rb +16 -1
  28. data/lib/rake-pipeline/server.rb +15 -0
  29. data/lib/rake-pipeline/version.rb +2 -2
  30. data/rake-pipeline.gemspec +2 -0
  31. data/spec/cli_spec.rb +71 -0
  32. data/spec/concat_filter_spec.rb +1 -27
  33. data/spec/{dsl_spec.rb → dsl/pipeline_dsl_spec.rb} +32 -18
  34. data/spec/dsl/project_dsl_spec.rb +41 -0
  35. data/spec/dynamic_file_task_spec.rb +111 -0
  36. data/spec/encoding_spec.rb +6 -8
  37. data/spec/file_wrapper_spec.rb +19 -2
  38. data/spec/filter_spec.rb +120 -22
  39. data/spec/graph_spec.rb +56 -0
  40. data/spec/manifest_entry_spec.rb +51 -0
  41. data/spec/manifest_spec.rb +67 -0
  42. data/spec/matcher_spec.rb +35 -2
  43. data/spec/middleware_spec.rb +123 -75
  44. data/spec/ordering_concat_filter_spec.rb +39 -0
  45. data/spec/pipeline_spec.rb +95 -34
  46. data/spec/project_spec.rb +293 -0
  47. data/spec/rake_acceptance_spec.rb +307 -67
  48. data/spec/rake_tasks_spec.rb +21 -0
  49. data/spec/spec_helper.rb +11 -48
  50. data/spec/support/spec_helpers/file_utils.rb +35 -0
  51. data/spec/support/spec_helpers/filters.rb +16 -0
  52. data/spec/support/spec_helpers/input_helpers.rb +23 -0
  53. data/spec/support/spec_helpers/memory_file_wrapper.rb +31 -0
  54. data/tools/perfs +107 -0
  55. metadata +100 -12
@@ -0,0 +1,188 @@
1
+ module Rake
2
+ class Pipeline
3
+ # This class extends Rake's {Rake::FileTask} class to add support
4
+ # for dynamic dependencies. Typically, Rake handles static dependencies,
5
+ # where a file's dependencies are known before the task is invoked.
6
+ # A {DynamicFileTask} also supports dynamic dependencies, meaning the
7
+ # file's dependencies can be determined just before invoking the task.
8
+ # Because calculating a file's dependencies at runtime may be an expensive
9
+ # operation (it could involve reading the file from disk and parsing it
10
+ # to extract dependency information, for example), the results of this
11
+ # calculation are stored on disk in a manifest file, and reused on
12
+ # subsequent runs if possible.
13
+ #
14
+ # For example, consider this file app.c:
15
+ #
16
+ # #include "app.h"
17
+ # some_stuff();
18
+ #
19
+ # If we have a task that compiles app.c into app.o, it needs to
20
+ # process app.c to look for additional dependencies specified
21
+ # by the file itself.
22
+ class DynamicFileTask < Rake::FileTask
23
+ attr_writer :last_manifest
24
+ attr_writer :manifest
25
+
26
+ # @return [Boolean] true if the task has a block to invoke
27
+ # for dynamic dependencies, false otherwise.
28
+ def has_dynamic_block?
29
+ !!@dynamic
30
+ end
31
+
32
+ def last_manifest
33
+ @last_manifest ||= Rake::Pipeline::Manifest.new
34
+ end
35
+
36
+ def manifest
37
+ @manifest ||= Rake::Pipeline::Manifest.new
38
+ end
39
+
40
+ # @return [ManifestEntry] the manifest entry from the last time
41
+ # this task was run, usually read off the filesystem.
42
+ def last_manifest_entry
43
+ last_manifest[name]
44
+ end
45
+
46
+ # @return [ManifestEntry] the manifest entry from the current
47
+ # manifest. This is the entry that will be written to disk after
48
+ # the task runs.
49
+ def manifest_entry
50
+ manifest[name]
51
+ end
52
+
53
+ # Set the current manifest entry,
54
+ #
55
+ # @param [ManifestEntry] new_entry
56
+ # @return [ManifestEntry]
57
+ def manifest_entry=(new_entry)
58
+ manifest[name] = new_entry
59
+ end
60
+
61
+ # In addition to the regular FileTask check, A DynamicFileTask is
62
+ # needed if it has no manifest entry from a previous run, or if
63
+ # one of its dynamic dependencies has been modified.
64
+ #
65
+ # @return [Boolean]
66
+ def needed?
67
+ return true if super
68
+
69
+ # if we have no manifest, this file task is needed
70
+ return true unless last_manifest_entry
71
+
72
+ # If any of this task's dynamic dependencies have changed,
73
+ # this file task is needed
74
+ last_manifest_entry.deps.each do |dep, time|
75
+ return true if File.mtime(dep) > time
76
+ end
77
+
78
+ # Otherwise, it's not needed
79
+ false
80
+ end
81
+
82
+ # Add a block that will return dynamic dependencies. This
83
+ # block can assume that all static dependencies are up
84
+ # to date.
85
+ #
86
+ # @return [DynamicFileTask] self
87
+ def dynamic(&block)
88
+ @dynamic = block
89
+ self
90
+ end
91
+
92
+ # Invoke the task's dynamic block.
93
+ def invoke_dynamic_block
94
+ @dynamic.call(self)
95
+ end
96
+
97
+ # At runtime, we will call this to get dynamic prerequisites.
98
+ #
99
+ # @return [Array[String]] an array of paths to the task's
100
+ # dynamic dependencies.
101
+ def dynamic_prerequisites
102
+ @dynamic_prerequisites ||= begin
103
+ dynamics = if has_dynamic_block?
104
+ dynamic_prerequisites_from_manifest || invoke_dynamic_block
105
+ else
106
+ []
107
+ end
108
+
109
+ # Make sure we don't dynamically depend on ourselves, as
110
+ # that will create a circular reference, and that makes
111
+ # everybody sad.
112
+ dynamics.reject { |x| x == name }
113
+ end
114
+ end
115
+
116
+ # Override rake's invoke_prerequisites method to invoke
117
+ # static prerequisites and then any dynamic prerequisites.
118
+ def invoke_prerequisites(task_args, invocation_chain)
119
+ super
120
+
121
+ # If we don't have a dynamic block, just act like a regular FileTask.
122
+ return unless has_dynamic_block?
123
+
124
+ # Retrieve the dynamic prerequisites. If all goes well,
125
+ # we will not have to invoke the dynamic block to do this.
126
+ dynamics = dynamic_prerequisites
127
+
128
+ # invoke dynamic prerequisites just as we would invoke
129
+ # static prerequisites.
130
+ dynamics.each do |prereq|
131
+ task = lookup_prerequisite(prereq)
132
+ prereq_args = task_args.new_scope(task.arg_names)
133
+ task.invoke_with_call_chain(prereq_args, invocation_chain)
134
+ end
135
+
136
+ # Create a new manifest entry for each dynamic dependency.
137
+ # When the pipeline finishes, these manifest entries will be written
138
+ # to the file system.
139
+ entry = Rake::Pipeline::ManifestEntry.new
140
+
141
+ dynamics.each do |dynamic|
142
+ entry.deps.merge!(dynamic => mtime_or_now(dynamic))
143
+ end
144
+
145
+ self.manifest_entry = entry
146
+ end
147
+
148
+ # After invoking a task, add the mtime of the task's output
149
+ # to its current manifest entry.
150
+ def invoke_with_call_chain(*)
151
+ super
152
+ return unless has_dynamic_block?
153
+
154
+ manifest_entry.mtime = mtime_or_now(name)
155
+ end
156
+
157
+ private
158
+ # @return the mtime of the given file if it exists, and
159
+ # the current time otherwise.
160
+ def mtime_or_now(filename)
161
+ File.file?(filename) ? File.mtime(filename) : Time.now
162
+ end
163
+
164
+ # @return [Array<String>] a list of file paths that this
165
+ # task depends on.
166
+ # @return [nil] if the dependencies couldn't be read
167
+ # from the manifest.
168
+ def dynamic_prerequisites_from_manifest
169
+ # Try to avoid invoking the dynamic block if this file
170
+ # is not needed. If so, we may have all the information
171
+ # we need in the manifest file.
172
+ if !needed? && last_manifest_entry
173
+ mtime = last_manifest_entry.mtime
174
+ end
175
+
176
+ # If the output file of this task still exists and
177
+ # it hasn't been updated, we can simply return the
178
+ # list of dependencies in the manifest, which
179
+ # come from the return value of the dynamic block
180
+ # in a previous run.
181
+ if File.exist?(name) && mtime == File.mtime(name)
182
+ return last_manifest_entry.deps.map { |k,v| k }
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
188
+
@@ -66,7 +66,7 @@ module Rake
66
66
  #
67
67
  # @return [String] the {FileWrapper}'s full path
68
68
  def fullpath
69
- raise "#{root}, #{path}" unless root =~ /^\//
69
+ raise "#{root}, #{path}" unless root =~ /^(\/|[a-zA-Z]:[\\\/])/
70
70
  File.join(root, path)
71
71
  end
72
72
 
@@ -46,7 +46,9 @@ module Rake
46
46
  attr_accessor :input_files
47
47
 
48
48
  # @return [Proc] a block that returns the relative output
49
- # filename for a particular input file.
49
+ # filename for a particular input file. If the block accepts
50
+ # just one argument, it will be passed the input's path. If
51
+ # it accepts two, it will also be passed the input itself.
50
52
  attr_accessor :output_name_generator
51
53
 
52
54
  # @return [String] the root directory to write output files
@@ -66,6 +68,10 @@ module Rake
66
68
  # filter should define new rake tasks on.
67
69
  attr_writer :rake_application
68
70
 
71
+ # @return [Rake::Pipeline] the Rake::Pipeline that contains
72
+ # this filter.
73
+ attr_accessor :pipeline
74
+
69
75
  attr_writer :file_wrapper_class
70
76
 
71
77
  # @param [Proc] block a block to use as the Filter's
@@ -73,6 +79,7 @@ module Rake
73
79
  def initialize(&block)
74
80
  block ||= proc { |input| input }
75
81
  @output_name_generator = block
82
+ @input_files = []
76
83
  end
77
84
 
78
85
  # Invoke this method in a subclass of Filter to declare that
@@ -148,10 +155,10 @@ module Rake
148
155
  hash = {}
149
156
 
150
157
  input_files.each do |file|
151
- output = output_wrapper(output_name_generator.call(file.path))
152
-
153
- hash[output] ||= []
154
- hash[output] << file
158
+ output_wrappers(file).each do |output|
159
+ hash[output] ||= []
160
+ hash[output] << file
161
+ end
155
162
  end
156
163
 
157
164
  hash
@@ -163,9 +170,7 @@ module Rake
163
170
  # @see #outputs
164
171
  # @return [Array<FileWrapper>]
165
172
  def output_files
166
- input_files.inject([]) do |array, file|
167
- array |= [output_wrapper(output_name_generator.call(file.path))]
168
- end
173
+ input_files.map { |file| output_wrappers(file) }.flatten.uniq
169
174
  end
170
175
 
171
176
  # The Rake::Application that the filter should define new tasks on.
@@ -175,17 +180,27 @@ module Rake
175
180
  @rake_application || Rake.application
176
181
  end
177
182
 
183
+ # @param [FileWrapper] file wrapper to get paths for
184
+ # @return [Array<String>] array of file paths within additional dependencies
185
+ def additional_dependencies(input)
186
+ []
187
+ end
188
+
178
189
  # Generate the Rake tasks for the output files of this filter.
179
190
  #
180
191
  # @see #outputs #outputs (for information on how the output files are determined)
181
192
  # @return [void]
182
193
  def generate_rake_tasks
183
194
  @rake_tasks = outputs.map do |output, inputs|
184
- dependencies = inputs.map(&:fullpath)
185
-
186
- dependencies.each { |path| create_file_task(path) }
195
+ additional_paths = []
196
+ inputs.each do |input|
197
+ create_file_task(input.fullpath).dynamic do
198
+ additional_paths += additional_dependencies(input)
199
+ end
200
+ end
201
+ additional_paths.each { |path| create_file_task(path) }
187
202
 
188
- create_file_task(output.fullpath, dependencies) do
203
+ create_file_task(output.fullpath, inputs.map(&:fullpath)) do
189
204
  output.create { generate_output(inputs, output) }
190
205
  end
191
206
  end
@@ -198,11 +213,26 @@ module Rake
198
213
  end
199
214
 
200
215
  def create_file_task(output, deps=[], &block)
201
- rake_application.define_task(Rake::FileTask, output => deps, &block)
216
+ task = rake_application.define_task(Rake::Pipeline::DynamicFileTask, output => deps, &block)
217
+
218
+ if pipeline && pipeline.project
219
+ task.last_manifest = pipeline.project.last_manifest
220
+ task.manifest = pipeline.project.manifest
221
+ end
222
+
223
+ task
224
+ end
225
+
226
+ def output_wrappers(input)
227
+ output_paths(input).map do |path|
228
+ file_wrapper_class.new(output_root, path, encoding)
229
+ end
202
230
  end
203
231
 
204
- def output_wrapper(file)
205
- file_wrapper_class.new(output_root, file, encoding)
232
+ def output_paths(input)
233
+ args = [ input.path ]
234
+ args << input if output_name_generator.arity == 2
235
+ Array(output_name_generator.call(*args))
206
236
  end
207
237
  end
208
238
  end
@@ -1 +1,3 @@
1
- require "rake-pipeline/filters/concat"
1
+ require "rake-pipeline/filters/concat_filter"
2
+ require "rake-pipeline/filters/ordering_concat_filter"
3
+ require "rake-pipeline/filters/pipeline_finalizing_filter"
@@ -0,0 +1,38 @@
1
+ class Rake::Pipeline
2
+ # A filter that concats files in a specified order.
3
+ #
4
+ # @example
5
+ # !!!ruby
6
+ # Rake::Pipeline.build do
7
+ # input "app/assets", "**/*.js"
8
+ # output "public"
9
+ #
10
+ # # Concat each file into libs.js but make sure
11
+ # # that jQuery and Ember come first.
12
+ # filter Rake::Pipeline::OrderingConcatFilter, ["jquery.js", "ember.js"], "libs.js"
13
+ # end
14
+ class OrderingConcatFilter < ConcatFilter
15
+
16
+ # @param [Array<String>] ordering an Array of Strings
17
+ # of file names that should come in the specified order
18
+ # @param [String] string the name of the output file to
19
+ # concatenate inputs to.
20
+ # @param [Proc] block a block to use as the Filter's
21
+ # {#output_name_generator}.
22
+ def initialize(ordering, string=nil, &block)
23
+ @ordering = ordering
24
+ super(string, &block)
25
+ end
26
+
27
+ # Extend the {#generate_output} method supplied by {ConcatFilter}.
28
+ # Re-orders the inputs such that the specified files come first.
29
+ # If a file is not in the list it will come after the specified files.
30
+ def generate_output(inputs, output)
31
+ @ordering.reverse.each do |name|
32
+ file = inputs.find{|i| i.path == name }
33
+ inputs.unshift(inputs.delete(file)) if file
34
+ end
35
+ super
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,19 @@
1
+ module Rake
2
+ class Pipeline
3
+ # @private
4
+ #
5
+ # A built-in filter that copies a pipeline's generated files over
6
+ # to its output.
7
+ class PipelineFinalizingFilter < ConcatFilter
8
+
9
+ # @return [Array[FileWrapper]] a list of the pipeline's
10
+ # output files, excluding any files that were originally
11
+ # inputs to the pipeline, meaning they weren't processed
12
+ # by any filter and should not be copied to the output.
13
+ def input_files
14
+ pipeline_input_files = pipeline.input_files
15
+ super.reject { |file| pipeline_input_files.include?(file) }
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,178 @@
1
+ require "set"
2
+
3
+ module Rake
4
+ class Pipeline
5
+ # The goal of this class is to make is easy to implement dynamic
6
+ # dependencies in additional_dependencies without having to parse
7
+ # all the files all of the time.
8
+ #
9
+ # To illustrate, imagine that we have two source files with the
10
+ # following inline dependencies:
11
+ #
12
+ # * application.scss
13
+ # * _core.scss
14
+ # * admin.scss
15
+ # * _admin.scss
16
+ #
17
+ # And further imagine that `_admin.scss` has an inline dependency
18
+ # on `_core.scss`.
19
+ #
20
+ # On initial build, we will scan all of the source files, find
21
+ # the dependencies, and build a node for each file, annotating
22
+ # the source files with `:source => true`. We also store off the
23
+ # `mtime` of each file in its node. We link each file to its
24
+ # dependencies.
25
+ #
26
+ # The `additional_dependencies` are a map of the files to their
27
+ # children, which will be used when generating rake tasks.
28
+ #
29
+ # Later, let's say that we change `_admin.scss`. We will need
30
+ # to unlink its dependencies first (on `_core.scss`), rescan
31
+ # the file, and create nodes for its dependencies. If no new
32
+ # dependencies
33
+
34
+ class Graph
35
+ class MissingNode < StandardError
36
+ end
37
+
38
+ class Node
39
+ # @return [String] the identifier of the node
40
+ attr_reader :name
41
+
42
+ # @return [Set] a Set of parent nodes
43
+ attr_reader :parents
44
+
45
+ # @return [Set] a Set of child nodes
46
+ attr_reader :children
47
+
48
+ # @return [Hash] a Hash of metadata
49
+ attr_reader :metadata
50
+
51
+ # @param [String] name the identifier of the node
52
+ # @param [Hash] metadata an optional hash of metadata
53
+ def initialize(name, metadata={})
54
+ @name = name
55
+ @parents = Set.new
56
+ @children = Set.new
57
+ @metadata = metadata
58
+ end
59
+
60
+ # A node is equal another node if it has the same name.
61
+ # This is because the Graph ensures that only one node
62
+ # with a given name can be created.
63
+ #
64
+ # @param [Node] other the node to compare
65
+ def ==(other)
66
+ @name == other.name
67
+ end
68
+ end
69
+
70
+ def initialize
71
+ @map = {}
72
+ end
73
+
74
+ # @return [Array] an Array of all of the nodes in the graph
75
+ def nodes
76
+ @map.values
77
+ end
78
+
79
+ # Add a new node to the graph. If an existing node with the
80
+ # current name already exists, do not add the node.
81
+ #
82
+ # @param [String] name an identifier for the node.
83
+ # @param [Hash] metadata optional metadata for the node
84
+ def add(name, metadata={})
85
+ return if @map.include?(name)
86
+ @map[name] = Node.new(name, metadata)
87
+ end
88
+
89
+ # Remove a node from the graph. Unlink its parent and children
90
+ # from it.
91
+ #
92
+ # If the existing node does not exist, raise.
93
+ #
94
+ # @param [String] name an identifier for the node
95
+ def remove(name)
96
+ node = verify(name)
97
+
98
+ node.parents.each do |parent_node|
99
+ parent_node.children.delete node
100
+ end
101
+
102
+ node.children.each do |child_node|
103
+ child_node.parents.delete node
104
+ end
105
+
106
+ @map.delete(name)
107
+ end
108
+
109
+ # Add a link from the parent to the child. This link is a
110
+ # two-way link, so the child will be added to the parent's
111
+ # `children` and the parent will be added to the child's
112
+ # `parents`.
113
+ #
114
+ # The parent and child are referenced by node identifier.
115
+ #
116
+ # @param [String] parent the identifier of the parent
117
+ # @param [String] child the identifier of the child
118
+ def link(parent, child)
119
+ parent, child = lookup(parent, child)
120
+
121
+ parent.children << child
122
+ child.parents << parent
123
+ end
124
+
125
+ # Remove a link from the parent to the child.
126
+ #
127
+ # The parent and child are referenced by node identifier.
128
+ #
129
+ # @param [String] parent the identifier of the parent
130
+ # @param [String] child the identifier of the child
131
+ def unlink(parent, child)
132
+ parent, child = lookup(parent, child)
133
+
134
+ parent.children.delete(child)
135
+ child.parents.delete(parent)
136
+ end
137
+
138
+ # Look up a node by name
139
+ #
140
+ # @param [String] name the identifier of the node
141
+ # @return [Node] the node referenced by the specified identifier
142
+ def [](name)
143
+ @map[name]
144
+ end
145
+
146
+ private
147
+ # Verify that the parent and child nodes exist, and return
148
+ # the nodes with the specified identifiers.
149
+ #
150
+ # The parent and child are referenced by node identifier.
151
+ #
152
+ # @param [String] parent the identifier of the parent
153
+ # @param [String] child the identifier of the child
154
+ # @return [Array(Node, Node)] the parent and child nodes
155
+ def lookup(parent, child)
156
+ parent = verify(parent)
157
+ child = verify(child)
158
+
159
+ return parent, child
160
+ end
161
+
162
+ # Verify that a node with a given identifier exists, and
163
+ # if it does, return it.
164
+ #
165
+ # If it does not, raise an exception.
166
+ #
167
+ # @param [String] name the identifier of the node
168
+ # @raise [MissingNode] if a node with the given name is
169
+ # not found, raise.
170
+ # @return [Node] the n
171
+ def verify(name)
172
+ node = @map[name]
173
+ raise MissingNode, "Node #{name} does not exist" unless node
174
+ node
175
+ end
176
+ end
177
+ end
178
+ end