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,63 @@
1
+ require 'json'
2
+
3
+ module Rake
4
+ class Pipeline
5
+ # A Manifest is a container for storing dynamic dependency information.
6
+ # A {DynamicFileTask} will use a {Manifest} to keep track of its dynamic
7
+ # dependencies. This allows us to avoid scanning a file for dynamic
8
+ # dependencies if its contents have not changed.
9
+ class Manifest
10
+ attr_accessor :entries
11
+ attr_accessor :manifest_file
12
+
13
+ def initialize(manifest_file="manifest.json")
14
+ @manifest_file ||= manifest_file
15
+ @entries = {}
16
+ end
17
+
18
+ # Get the manifest off the file system, if it exists.
19
+ def read_manifest
20
+ @entries = File.file?(manifest_file) ? JSON.parse(File.read(manifest_file)) : {}
21
+
22
+ # convert the manifest JSON into a Hash of ManifestEntry objects
23
+ @entries.each do |file, raw|
24
+ @entries[file] = Rake::Pipeline::ManifestEntry.from_hash(raw)
25
+ end
26
+
27
+ self
28
+ end
29
+
30
+ # Write a JSON representation of this manifest out to disk if we
31
+ # have entries to save.
32
+ def write_manifest
33
+ unless @entries.empty?
34
+ File.open(manifest_file, "w") do |file|
35
+ file.puts JSON.generate(as_json)
36
+ end
37
+ end
38
+ end
39
+
40
+ # Convert this Manifest into a hash suitable for converting to
41
+ # JSON.
42
+ def as_json
43
+ hash = {}
44
+
45
+ @entries.each do |name, entry|
46
+ hash[name] = entry.as_json
47
+ end
48
+
49
+ hash
50
+ end
51
+
52
+ # Look up an entry by filename.
53
+ def [](key)
54
+ @entries[key]
55
+ end
56
+
57
+ # Set an entry
58
+ def []=(key, value)
59
+ @entries[key] = value
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,34 @@
1
+ module Rake
2
+ class Pipeline
3
+ # Represents a single entry in a dynamic dependency {Manifest}.
4
+ class ManifestEntry
5
+ # Create a new entry from the given hash.
6
+ def self.from_hash(hash)
7
+ entry = new
8
+
9
+ entry.mtime = DateTime.parse(hash["mtime"]).to_time
10
+
11
+ hash["deps"].each do |dep, time_string|
12
+ entry.deps[dep] = DateTime.parse(time_string).to_time
13
+ end
14
+
15
+ entry
16
+ end
17
+
18
+ attr_accessor :deps, :mtime
19
+
20
+ def initialize(deps={}, mtime=nil)
21
+ @deps, @mtime = deps, mtime
22
+ end
23
+
24
+ def as_json
25
+ { :deps => @deps, :mtime => @mtime }
26
+ end
27
+
28
+ def ==(other)
29
+ mtime == other.mtime
30
+ deps == other.deps
31
+ end
32
+ end
33
+ end
34
+ end
@@ -9,7 +9,7 @@ module Rake
9
9
  #
10
10
  # For instance, to restrict filters to operating on
11
11
  # JavaScript files in the +app+ directory, the Matcher's
12
- # {Pipeline#input_root input_root} should be +"app"+,
12
+ # {Pipeline#inputs inputs} should include +"app"+,
13
13
  # and its glob would be +"*.js"+.
14
14
  #
15
15
  # In general, you should not use Matcher directly. Instead use
@@ -17,13 +17,68 @@ module Rake
17
17
  class Matcher < Pipeline
18
18
  attr_reader :glob
19
19
 
20
+ # @return [Rake::Pipeline] the Rake::Pipeline that contains
21
+ # this matcher.
22
+ attr_accessor :pipeline
23
+
20
24
  # A glob matcher that a filter's input files must match
21
25
  # in order to be processed by the filter.
22
26
  #
23
27
  # @return [String]
24
28
  def glob=(pattern)
25
29
  @glob = pattern
26
- scanner = StringScanner.new(pattern)
30
+ if pattern.kind_of?(Regexp)
31
+ @pattern = pattern
32
+ else
33
+ @pattern = scan_string
34
+ end
35
+ end
36
+
37
+ # A list of the output files that invoking this pipeline will
38
+ # generate. This will include the outputs of files matching
39
+ # the {#glob glob} and any inputs that did not match the
40
+ # glob.
41
+ #
42
+ # This will make those inputs available to any additional
43
+ # filters or matchers.
44
+ #
45
+ # @return [Array<FileWrapper>]
46
+ def output_files
47
+ super + input_files.reject do |file|
48
+ file.path =~ @pattern
49
+ end
50
+ end
51
+
52
+ # Override {Pipeline#finalize} to do nothing. We want to pass
53
+ # on our unmatched inputs to the next part of the pipeline.
54
+ #
55
+ # @return [void]
56
+ # @api private
57
+ def finalize
58
+ end
59
+
60
+ protected
61
+ # Let our containing pipeline generate temp directories for us.
62
+ def generate_tmpdir
63
+ pipeline.generate_tmpdir
64
+ end
65
+
66
+ private
67
+ # Override the default {Pipeline#eligible_input_files}
68
+ # to include only files that match the {#glob glob}.
69
+ #
70
+ # @return [Array<FileWrapper>]
71
+ def eligible_input_files
72
+ input_files.select do |file|
73
+ file.path =~ @pattern
74
+ end
75
+ end
76
+
77
+ # Convert string to regexp using StringScanner
78
+ #
79
+ # @return [Regexp]
80
+ def scan_string
81
+ scanner = StringScanner.new(glob)
27
82
 
28
83
  output, pos = "", 0
29
84
 
@@ -71,34 +126,14 @@ module Rake
71
126
  end
72
127
  end
73
128
 
74
- # anchor the pattern either at the beginning of the
75
- # path or at any "/" character
76
- @pattern = Regexp.new("(^|/)#{output}$", "i")
77
- end
78
-
79
- # A list of the output files that invoking this pipeline will
80
- # generate. This will include the outputs of files matching
81
- # the {#glob glob} and any inputs that did not match the
82
- # glob.
83
- #
84
- # This will make those inputs available to any additional
85
- # filters or matchers.
86
- #
87
- # @return [Array<FileWrapper>]
88
- def output_files
89
- super + input_files.reject do |file|
90
- file.path =~ @pattern
91
- end
92
- end
93
-
94
- private
95
- # Override the default {Pipeline#eligible_input_files}
96
- # to include only files that match the {#glob glob}.
97
- #
98
- # @return [Array<FileWrapper>]
99
- def eligible_input_files
100
- input_files.select do |file|
101
- file.path =~ @pattern
129
+ if glob.include?("/")
130
+ # if the pattern includes a /, it must match the
131
+ # entire input, not just the end.
132
+ Regexp.new("^#{output}$", "i")
133
+ else
134
+ # anchor the pattern either at the beginning of the
135
+ # path or at any "/" character
136
+ Regexp.new("(^|/)#{output}$", "i")
102
137
  end
103
138
  end
104
139
  end
@@ -14,19 +14,14 @@ module Rake
14
14
  # ...
15
15
  # }
16
16
  class Middleware
17
- attr_accessor :pipeline
17
+ attr_accessor :project
18
18
 
19
- # @param [#call] a Rack application
20
- # @param [Pipeline] a Rake::Pipeline
19
+ # @param [#call] app a Rack application
20
+ # @param [String|Rake::Pipeline] pipeline either a path to an
21
+ # Assetfile to use to build a pipeline, or an existing pipeline.
21
22
  def initialize(app, pipeline)
22
23
  @app = app
23
-
24
- if pipeline.is_a?(String)
25
- pipeline_source = File.read(pipeline)
26
- pipeline = Pipeline.class_eval "build do\n#{pipeline_source}\nend", pipeline, 1
27
- end
28
-
29
- @pipeline = pipeline
24
+ @project = Rake::Pipeline::Project.new(pipeline)
30
25
  end
31
26
 
32
27
  # Automatically compiles your assets if required and
@@ -35,9 +30,13 @@ module Rake
35
30
  # @param [Hash] env a Rack environment
36
31
  # @return [Array(Fixnum, Hash, #each)] A rack response
37
32
  def call(env)
38
- pipeline.invoke_clean
33
+ project.invoke_clean
39
34
  path = env["PATH_INFO"]
40
35
 
36
+ if project.maps.has_key?(path)
37
+ return project.maps[path].call(env)
38
+ end
39
+
41
40
  if filename = file_for(path)
42
41
  if File.directory?(filename)
43
42
  index = File.join(filename, "index.html")
@@ -58,7 +57,11 @@ module Rake
58
57
  end
59
58
 
60
59
  def file_for(path)
61
- Dir[File.join(pipeline.output_root, path)].first
60
+ project.pipelines.each do |pipeline|
61
+ file = Dir[File.join(pipeline.output_root, path)].sort.first
62
+ return file unless file.nil?
63
+ end
64
+ nil
62
65
  end
63
66
 
64
67
  def headers_for(path)
@@ -0,0 +1,8 @@
1
+ namespace :assets do
2
+ desc "Precompile assets using Rake::Pipeline"
3
+ task :precompile do
4
+ config = Rails.application.config.rake_pipeline_assetfile
5
+ Rake::Pipeline::Project.new(config).invoke
6
+ end
7
+ end
8
+
@@ -0,0 +1,280 @@
1
+ require "digest"
2
+
3
+ module Rake
4
+ class Pipeline
5
+ # A Project controls the lifecycle of a series of Pipelines,
6
+ # creating them from an Assetfile and recreating them if the
7
+ # Assetfile changes.
8
+ class Project
9
+ # @return [Pipeline] the list of pipelines in the project
10
+ attr_reader :pipelines
11
+
12
+ attr_reader :maps
13
+
14
+ # @return [String|nil] the path to the project's Assetfile
15
+ # or nil if it was created without an Assetfile.
16
+ attr_reader :assetfile_path
17
+
18
+ # @return [String|nil] the digest of the Assetfile the
19
+ # project was created with, or nil if the project
20
+ # was created without an Assetfile.
21
+ attr_reader :assetfile_digest
22
+
23
+ # @return [String] the directory path for temporary files
24
+ attr_reader :tmpdir
25
+
26
+ # @return [String] the directory path where pipelines will
27
+ # write their outputs by default
28
+ attr_reader :default_output_root
29
+
30
+ # @return [Array] a list of filters to be applied before
31
+ # the specified filters in every pipeline
32
+ attr_writer :before_filters
33
+
34
+ # @return [Array] a list of filters to be applied after
35
+ # the specified filters in every pipeline
36
+ attr_writer :after_filters
37
+
38
+ class << self
39
+ # Configure a new project by evaluating a block with the
40
+ # Rake::Pipeline::DSL::ProjectDSL class.
41
+ #
42
+ # @see Rake::Pipeline::Filter Rake::Pipeline::Filter
43
+ #
44
+ # @example
45
+ # Rake::Pipeline::Project.build do
46
+ # tmpdir "tmp"
47
+ # output "public"
48
+ #
49
+ # input "app/assets" do
50
+ # concat "app.js"
51
+ # end
52
+ # end
53
+ #
54
+ # @return [Rake::Pipeline::Project] the newly configured project
55
+ def build(&block)
56
+ project = new
57
+ project.build(&block)
58
+ end
59
+
60
+ # @return [Array[String]] an array of strings that will be
61
+ # appended to {#digested_tmpdir}.
62
+ def digest_additions
63
+ @digest_additions ||= []
64
+ end
65
+
66
+ # Set {.digest_additions} to a sorted copy of the given array.
67
+ def digest_additions=(additions)
68
+ @digest_additions = additions.sort
69
+ end
70
+
71
+ # Add a value to the list of strings to append to the digest
72
+ # temp directory. Libraries can use this to add (for example)
73
+ # their version numbers so that the pipeline will be rebuilt
74
+ # if the library version changes.
75
+ #
76
+ # @example
77
+ # Rake::Pipeline::Project.add_to_digest(Rake::Pipeline::Web::Filters::VERSION)
78
+ #
79
+ # @param [#to_s] str a value to append to {#digested_tmpdir}.
80
+ def add_to_digest(str)
81
+ self.digest_additions << str.to_s
82
+ self.digest_additions.sort!
83
+ end
84
+ end
85
+
86
+ # @param [String|Pipeline] assetfile_or_pipeline
87
+ # if this a String, create a Pipeline from the Assetfile at
88
+ # that path. If it's a Pipeline, just wrap that pipeline.
89
+ def initialize(assetfile_or_pipeline=nil)
90
+ reset!
91
+ if assetfile_or_pipeline.kind_of?(String)
92
+ @assetfile_path = File.expand_path(assetfile_or_pipeline)
93
+ rebuild_from_assetfile(@assetfile_path)
94
+ elsif assetfile_or_pipeline
95
+ @pipelines << assetfile_or_pipeline
96
+ end
97
+ end
98
+
99
+ # Evaluate a block using the Rake::Pipeline::DSL::ProjectDSL
100
+ # DSL against an existing project.
101
+ def build(&block)
102
+ DSL::ProjectDSL.evaluate(self, &block) if block
103
+ self
104
+ end
105
+
106
+ # Invoke all of the project's pipelines.
107
+ #
108
+ # @see Rake::Pipeline#invoke
109
+ def invoke
110
+ @invoke_mutex.synchronize do
111
+ pipelines.each(&:invoke)
112
+ manifest.write_manifest
113
+ end
114
+ end
115
+
116
+ # Invoke all of the project's pipelines, detecting any changes
117
+ # to the Assetfile and rebuilding the pipelines if necessary.
118
+ #
119
+ # @return [void]
120
+ # @see Rake::Pipeline#invoke_clean
121
+ def invoke_clean
122
+ @invoke_mutex.synchronize do
123
+ if assetfile_path
124
+ source = File.read(assetfile_path)
125
+ if digest(source) != assetfile_digest
126
+ rebuild_from_assetfile(assetfile_path, source)
127
+ end
128
+ end
129
+ pipelines.each(&:invoke_clean)
130
+ manifest.write_manifest
131
+ end
132
+ end
133
+
134
+ # Remove the project's temporary and output files.
135
+ def clean
136
+ files_to_clean.each { |file| FileUtils.rm_rf(file) }
137
+ end
138
+
139
+ # Clean out old tmp directories from the pipeline's
140
+ # {Rake::Pipeline#tmpdir}.
141
+ #
142
+ # @return [void]
143
+ def cleanup_tmpdir
144
+ obsolete_tmpdirs.each { |dir| FileUtils.rm_rf(dir) }
145
+ end
146
+
147
+ # Set the default output root of this project and expand its path.
148
+ #
149
+ # @param [String] root this pipeline's output root
150
+ def default_output_root=(root)
151
+ @default_output_root = File.expand_path(root)
152
+ end
153
+
154
+ # Set the temporary directory for this project and expand its path.
155
+ #
156
+ # @param [String] root this project's temporary directory
157
+ def tmpdir=(dir)
158
+ @tmpdir = File.expand_path(dir)
159
+ end
160
+
161
+ # @return [String] A subdirectory of {#tmpdir} with the digest of
162
+ # the Assetfile's contents and any {.digest_additions} in its
163
+ # name.
164
+ def digested_tmpdir
165
+ suffix = assetfile_digest
166
+ unless self.class.digest_additions.empty?
167
+ suffix += "-#{self.class.digest_additions.join('-')}"
168
+ end
169
+ File.join(tmpdir, "rake-pipeline-#{suffix}")
170
+ end
171
+
172
+ # @return Array[String] a list of the paths to temporary directories
173
+ # that don't match the pipline's Assetfile digest.
174
+ def obsolete_tmpdirs
175
+ if File.directory?(tmpdir)
176
+ Dir["#{tmpdir}/rake-pipeline-*"].sort.reject do |dir|
177
+ dir == digested_tmpdir
178
+ end
179
+ else
180
+ []
181
+ end
182
+ end
183
+
184
+ # @return Array[String] a list of files to delete to completely clean
185
+ # out a project's temporary and output files.
186
+ def files_to_clean
187
+ setup_pipelines
188
+ obsolete_tmpdirs + [digested_tmpdir] + output_files.map(&:fullpath)
189
+ end
190
+
191
+ # @return [Array[FileWrapper]] a list of the files that
192
+ # will be generated when this project is invoked.
193
+ def output_files
194
+ setup_pipelines
195
+ pipelines.map(&:output_files).flatten
196
+ end
197
+
198
+ # Build a new pipeline and add it to our list of pipelines.
199
+ def build_pipeline(input, glob=nil, &block)
200
+ pipeline = Rake::Pipeline.build({
201
+ :before_filters => @before_filters,
202
+ :after_filters => @after_filters,
203
+ :output_root => default_output_root,
204
+ :tmpdir => digested_tmpdir,
205
+ :project => self
206
+ }, &block)
207
+
208
+ if input.kind_of?(Array)
209
+ input.each { |x| pipeline.add_input(x) }
210
+ elsif input.kind_of?(Hash)
211
+ pipeline.inputs = input
212
+ else
213
+ pipeline.add_input(input, glob)
214
+ end
215
+
216
+ @pipelines << pipeline
217
+ pipeline
218
+ end
219
+
220
+ # @return [Manifest] the manifest to read dependency information
221
+ # from
222
+ def last_manifest
223
+ @last_manifest ||= begin
224
+ m = Rake::Pipeline::Manifest.new(manifest_path)
225
+ m.read_manifest
226
+ end
227
+ end
228
+
229
+ # @return [Manifest] the manifest to write dependency information
230
+ # to
231
+ def manifest
232
+ @manifest ||= Rake::Pipeline::Manifest.new(manifest_path)
233
+ end
234
+
235
+ # @return [String] the path to the dynamic dependency manifest
236
+ def manifest_path
237
+ File.join(digested_tmpdir, "manifest.json")
238
+ end
239
+
240
+ private
241
+ # Reset this project's internal state to the default values.
242
+ #
243
+ # @return [void]
244
+ def reset!
245
+ @pipelines = []
246
+ @maps = {}
247
+ @tmpdir = "tmp"
248
+ @invoke_mutex = Mutex.new
249
+ @default_output_root = @assetfile_digest = @assetfile_path = nil
250
+ @manifest = @last_manifest = nil
251
+ end
252
+
253
+ # Reconfigure this project based on the Assetfile at path.
254
+ #
255
+ # @param [String] path the path to the Assetfile
256
+ # to use to configure the project.
257
+ # @param [String] source if given, this string is
258
+ # evaluated instead of reading the file at assetfile_path.
259
+ #
260
+ # @return [void]
261
+ def rebuild_from_assetfile(path, source=nil)
262
+ reset!
263
+ source ||= File.read(path)
264
+ @assetfile_digest = digest(source)
265
+ @assetfile_path = path
266
+ build { instance_eval(source, path, 1) }
267
+ end
268
+
269
+ # Setup the pipeline so its output files will be up to date.
270
+ def setup_pipelines
271
+ pipelines.map(&:setup_filters)
272
+ end
273
+
274
+ # @return [String] the SHA1 digest of the given string.
275
+ def digest(str)
276
+ Digest::SHA1.hexdigest(str)
277
+ end
278
+ end
279
+ end
280
+ end