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,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