rake-pipeline 0.5.0 → 0.7.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.
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