Rename aggregate to concatenate
[buildr.git] / lib / buildr / packaging / archive.rb
1 # Licensed to the Apache Software Foundation (ASF) under one or more
2 # contributor license agreements. See the NOTICE file distributed with this
3 # work for additional information regarding copyright ownership. The ASF
4 # licenses this file to you under the Apache License, Version 2.0 (the
5 # "License"); you may not use this file except in compliance with the License.
6 # You may obtain a copy of the License at
7 #
8 # http://www.apache.org/licenses/LICENSE-2.0
9 #
10 # Unless required by applicable law or agreed to in writing, software
11 # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 # License for the specific language governing permissions and limitations under
14 # the License.
15
16 module Buildr #:nodoc:
17
18 # Base class for ZipTask, TarTask and other archives.
19 class ArchiveTask < Rake::FileTask
20
21 # Which files go where. All the rules for including, excluding and merging files
22 # are handled by this object.
23 class Path #:nodoc:
24
25 # Returns the archive from this path.
26 attr_reader :root
27
28 def initialize(root, path)
29 @root = root
30 @path = path.empty? ? path : "#{path}/"
31 @includes = FileList[]
32 @excludes = []
33 # Expand source files added to this path.
34 expand_src = proc { @includes.map{ |file| file.to_s }.uniq }
35 @sources = [ expand_src ]
36 # Add files and directories added to this path.
37 @actions = [] << proc do |file_map|
38 expand_src.call.each do |path|
39 unless excluded?(path)
40 if File.directory?(path)
41 in_directory path do |file, rel_path|
42 dest = "#{@path}#{rel_path}"
43 unless excluded?(dest)
44 trace "Adding #{dest}"
45 file_map[dest] = file
46 end
47 end
48 end
49 unless File.basename(path) == "."
50 trace "Adding #{@path}#{File.basename(path)}"
51 file_map["#{@path}#{File.basename(path)}"] = path
52 end
53 end
54 end
55 end
56 end
57
58 # :call-seq:
59 # include(*files) => self
60 # include(*files, :path=>path) => self
61 # include(file, :as=>name) => self
62 # include(:from=>path) => self
63 # include(*files, :merge=>true) => self
64 def include(*args)
65 options = Hash === args.last ? args.pop : nil
66 files = to_artifacts(args)
67 raise 'AchiveTask.include() values should not include nil' if files.include? nil
68
69 if options.nil? || options.empty?
70 @includes.include *files.flatten
71 elsif options[:path]
72 sans_path = options.reject { |k,v| k == :path }
73 path(options[:path]).include *files + [sans_path]
74 elsif options[:as]
75 raise 'You can only use the :as option in combination with the :path option' unless options.size == 1
76 raise 'You can only use one file with the :as option' unless files.size == 1
77 include_as files.first.to_s, options[:as]
78 elsif options[:from]
79 raise 'You can only use the :from option in combination with the :path option' unless options.size == 1
80 raise 'You cannot use the :from option with file names' unless files.empty?
81 fail 'AchiveTask.include() :from value should not be nil' if [options[:from]].flatten.include? nil
82 [options[:from]].flatten.each { |path| include_as path.to_s, '.' }
83 elsif options[:merge]
84 raise 'You can only use the :merge option in combination with the :path option' unless options.size == 1
85 files.each { |file| merge file }
86 else
87 raise "Unrecognized option #{options.keys.join(', ')}"
88 end
89 self
90 end
91 alias :add :include
92 alias :<< :include
93
94 # :call-seq:
95 # exclude(*files) => self
96 def exclude(*files)
97 files = to_artifacts(files)
98 @excludes |= files
99 @excludes |= files.reject { |f| f =~ /\*$/ }.map { |f| "#{f}/*" }
100 self
101 end
102
103 # :call-seq:
104 # merge(*files) => Merge
105 # merge(*files, :path=>name) => Merge
106 def merge(*args)
107 options = Hash === args.last ? args.pop : {}
108 files = to_artifacts(args)
109 rake_check_options options, :path
110 raise ArgumentError, "Expected at least one file to merge" if files.empty?
111 path = options[:path] || @path
112 expanders = files.collect do |file|
113 @sources << proc { file.to_s }
114 expander = ZipExpander.new(file)
115 @actions << proc do |file_map|
116 file.invoke() if file.is_a?(Rake::Task)
117 expander.expand(file_map, path)
118 end
119 expander
120 end
121 Merge.new(expanders)
122 end
123
124 # Returns a Path relative to this one.
125 def path(path)
126 return self if path.nil?
127 return root.path(path[1..-1]) if path[0] == ?/
128 root.path("#{@path}#{path}")
129 end
130
131 # Returns all the source files.
132 def sources #:nodoc:
133 @sources.map{ |source| source.call }.flatten
134 end
135
136 def add_files(file_map) #:nodoc:
137 @actions.each { |action| action.call(file_map) }
138 end
139
140 # :call-seq:
141 # exist => boolean
142 #
143 # Returns true if this path exists. This only works if the path has any entries in it,
144 # so exist on path happens to be the opposite of empty.
145 def exist?
146 !entries.empty?
147 end
148
149 # :call-seq:
150 # empty? => boolean
151 #
152 # Returns true if this path is empty (has no other entries inside).
153 def empty?
154 entries.all? { |entry| entry.empty? }
155 end
156
157 # :call-seq:
158 # contain(file*) => boolean
159 #
160 # Returns true if this ZIP file path contains all the specified files. You can use relative
161 # file names and glob patterns (using *, **, etc).
162 def contain?(*files)
163 files.all? { |file| entries.detect { |entry| File.fnmatch(file, entry.to_s) } }
164 end
165
166 # :call-seq:
167 # entry(name) => ZipEntry
168 #
169 # Returns a ZIP file entry. You can use this to check if the entry exists and its contents,
170 # for example:
171 # package(:jar).path("META-INF").entry("LICENSE").should contain(/Apache Software License/)
172 def entry(name)
173 root.entry("#{@path}#{name}")
174 end
175
176 def to_s
177 @path
178 end
179
180 protected
181
182 # Convert objects to artifacts, where applicable
183 def to_artifacts(files)
184 files.flatten.inject([]) do |set, file|
185 case file
186 when ArtifactNamespace
187 set |= file.artifacts
188 when Symbol, Hash
189 set |= [Buildr.artifact(file)]
190 when /([^:]+:){2,4}/ # A spec as opposed to a file name.
191 set |= [Buildr.artifact(file)]
192 when Project
193 set |= Buildr.artifacts(file.packages)
194 when Rake::Task
195 set |= [file]
196 when Struct
197 set |= Buildr.artifacts(file.values)
198 else
199 # non-artifacts passed as-is; in particular, String paths are
200 # unmodified since Rake FileTasks don't use absolute paths
201 set |= [file]
202 end
203 end
204 end
205
206 def include_as(source, as)
207 @sources << proc { source }
208 @actions << proc do |file_map|
209 file = source.to_s
210 file(file).invoke
211 unless excluded?(file)
212 if File.directory?(file)
213 in_directory file do |file, rel_path|
214 path = rel_path.split('/')[1..-1]
215 path.unshift as unless as == '.'
216 dest = "#{@path}#{path.join('/')}"
217 unless excluded?(dest)
218 trace "Adding #{dest}"
219 file_map[dest] = file
220 end
221 end
222 unless as == "."
223 trace "Adding #{@path}#{as}/"
224 file_map["#{@path}#{as}/"] = nil # :as is a folder, so the trailing / is required.
225 end
226 else
227 file_map["#{@path}#{as}"] = file
228 end
229
230 end
231 end
232 end
233
234 def in_directory(dir)
235 prefix = Regexp.new('^' + Regexp.escape(File.dirname(dir) + File::SEPARATOR))
236 Util.recursive_with_dot_files(dir).reject { |file| excluded?(file) }.
237 each { |file| yield file, file.sub(prefix, '') }
238 end
239
240 def excluded?(file)
241 @excludes.any? { |exclude| File.fnmatch(exclude, file) }
242 end
243
244 def entries #:nodoc:
245 return root.entries unless @path
246 @entries ||= root.entries.inject([]) { |selected, entry|
247 selected << entry.name.sub(@path, "") if entry.name.index(@path) == 0
248 selected
249 }
250 end
251
252 end
253
254
255 class Merge
256 def initialize(expanders)
257 @expanders = expanders
258 end
259
260 def include(*files)
261 @expanders.each { |expander| expander.include(*files) }
262 self
263 end
264 alias :<< :include
265
266 def exclude(*files)
267 @expanders.each { |expander| expander.exclude(*files) }
268 self
269 end
270
271 def concatenate(*files)
272 @expanders.each { |expander| expander.concatenate(*files) }
273 self
274 end
275 end
276
277
278 # Extend one Zip file into another.
279 class ZipExpander #:nodoc:
280
281 def initialize(zip_file)
282 @zip_file = zip_file.to_s
283 @includes = []
284 @excludes = []
285 @concatenates = []
286 end
287
288 def include(*files)
289 @includes |= files
290 self
291 end
292 alias :<< :include
293
294 def exclude(*files)
295 @excludes |= files
296 self
297 end
298
299 def concatenate(*files)
300 @concatenates |= files
301 self
302 end
303
304 def expand(file_map, path)
305 @includes = ['*'] if @includes.empty?
306 Zip::File.open(@zip_file) do |source|
307 source.entries.reject { |entry| entry.directory? }.each do |entry|
308 if @includes.any? { |pattern| File.fnmatch(pattern, entry.name) } &&
309 !@excludes.any? { |pattern| File.fnmatch(pattern, entry.name) }
310 dest = path =~ /^\/?$/ ? entry.name : Util.relative_path(path + "/" + entry.name)
311 trace "Adding #{dest}"
312 if @concatenates.any? { |pattern| File.fnmatch(pattern, entry.name) }
313 file_map[dest] << ZipEntryData.new(source, entry)
314 else
315 file_map[dest] = ZipEntryData.new(source, entry)
316 end
317 end
318 end
319 end
320 end
321
322 end
323
324 class ZipEntryData
325 def initialize(zipfile, entry)
326 @zipfile = zipfile
327 @entry = entry
328 end
329
330 def call(output)
331 output.write @zipfile.read(@entry)
332 end
333
334 def mode
335 @entry.unix_perms
336 end
337 end
338
339 def initialize(*args) #:nodoc:
340 super
341 clean
342
343 # Make sure we're the last enhancements, so other enhancements can add content.
344 enhance do
345 @file_map = Hash.new {|h,k| h[k]=[]}
346 enhance do
347 send 'create' if respond_to?(:create)
348 # We're here because the archive file does not exist, or one of the files is newer than the archive contents;
349 # we need to make sure the archive doesn't exist (e.g. opening an existing Zip will add instead of create).
350 # We also want to protect against partial updates.
351 rm name rescue nil
352 mkpath File.dirname(name)
353 begin
354 @paths.each do |name, object|
355 @file_map[name] = nil unless name.empty?
356 object.add_files(@file_map)
357 end
358 create_from @file_map
359 rescue
360 rm name rescue nil
361 raise
362 end
363 end
364 end
365 end
366
367 # :call-seq:
368 # clean => self
369 #
370 # Removes all previously added content from this archive.
371 # Use this method if you want to remove default content from a package.
372 # For example, package(:jar) by default includes compiled classes and resources,
373 # using this method, you can create an empty jar and afterwards add the
374 # desired content to it.
375 #
376 # package(:jar).clean.include path_to('desired/content')
377 def clean
378 @paths = {}
379 @paths[''] = Path.new(self, '')
380 @prepares = []
381 self
382 end
383
384 # :call-seq:
385 # include(*files) => self
386 # include(*files, :path=>path) => self
387 # include(file, :as=>name) => self
388 # include(:from=>path) => self
389 # include(*files, :merge=>true) => self
390 #
391 # Include files in this archive, or when called on a path, within that path. Returns self.
392 #
393 # The first form accepts a list of files, directories and glob patterns and adds them to the archive.
394 # For example, to include the file foo, directory bar (including all files in there) and all files under baz:
395 # zip(..).include('foo', 'bar', 'baz/*')
396 #
397 # The second form is similar but adds files/directories under the specified path. For example,
398 # to add foo as bar/foo:
399 # zip(..).include('foo', :path=>'bar')
400 # The :path option is the same as using the path method:
401 # zip(..).path('bar').include('foo')
402 # All other options can be used in combination with the :path option.
403 #
404 # The third form adds a file or directory under a different name. For example, to add the file foo under the
405 # name bar:
406 # zip(..).include('foo', :as=>'bar')
407 #
408 # The fourth form adds the contents of a directory using the directory as a prerequisite:
409 # zip(..).include(:from=>'foo')
410 # Unlike <code>include('foo')</code> it includes the contents of the directory, not the directory itself.
411 # Unlike <code>include('foo/*')</code>, it uses the directory timestamp for dependency management.
412 #
413 # The fifth form includes the contents of another archive by expanding it into this archive. For example:
414 # zip(..).include('foo.zip', :merge=>true).include('bar.zip')
415 # You can also use the method #merge.
416 def include(*files)
417 fail "AchiveTask.include() called with nil values" if files.include? nil
418 @paths[''].include *files if files.compact.size > 0
419 self
420 end
421 alias :add :include
422 alias :<< :include
423
424 # :call-seq:
425 # exclude(*files) => self
426 #
427 # Excludes files and returns self. Can be used in combination with include to prevent some files from being included.
428 def exclude(*files)
429 @paths[''].exclude *files
430 self
431 end
432
433 # :call-seq:
434 # merge(*files) => Merge
435 # merge(*files, :path=>name) => Merge
436 #
437 # Merges another archive into this one by including the individual files from the merged archive.
438 #
439 # Returns an object that supports two methods: include and exclude. You can use these methods to merge
440 # only specific files. For example:
441 # zip(..).merge('src.zip').include('module1/*')
442 def merge(*files)
443 @paths[''].merge *files
444 end
445
446 # :call-seq:
447 # path(name) => Path
448 #
449 # Returns a path object. Use the path object to include files under a path, for example, to include
450 # the file 'foo' as 'bar/foo':
451 # zip(..).path('bar').include('foo')
452 #
453 # Returns a Path object. The Path object implements all the same methods, like include, exclude, merge
454 # and so forth. It also implements path and root, so that:
455 # path('foo').path('bar') == path('foo/bar')
456 # path('foo').root == root
457 def path(name)
458 return @paths[''] if name.nil?
459 normalized = name.split('/').inject([]) do |path, part|
460 case part
461 when '.', nil, ''
462 path
463 when '..'
464 path[0...-1]
465 else
466 path << part
467 end
468 end.join('/')
469 @paths[normalized] ||= Path.new(self, normalized)
470 end
471
472 # :call-seq:
473 # root => ArchiveTask
474 #
475 # Call this on an archive to return itself, and on a path to return the archive.
476 def root
477 self
478 end
479
480 # :call-seq:
481 # with(options) => self
482 #
483 # Passes options to the task and returns self. Some tasks support additional options, for example,
484 # the WarTask supports options like :manifest, :libs and :classes.
485 #
486 # For example:
487 # package(:jar).with(:manifest=>'MANIFEST_MF')
488 def with(options)
489 options.each do |key, value|
490 begin
491 send "#{key}=", value
492 rescue NoMethodError
493 raise ArgumentError, "#{self.class.name} does not support the option #{key}"
494 end
495 end
496 self
497 end
498
499 def invoke_prerequisites(args, chain) #:nodoc:
500 @prepares.each { |prepare| prepare.call(self) }
501 @prepares.clear
502
503 file_map = Hash.new {|h,k| h[k]=[]}
504 @paths.each do |name, path|
505 path.add_files(file_map)
506 end
507
508 # filter out Procs (dynamic content), nils and others
509 @prerequisites |= file_map.values.select { |src| src.is_a?(String) || src.is_a?(Rake::Task) }
510
511 super
512 end
513
514 def needed? #:nodoc:
515 return true unless File.exist?(name)
516 # You can do something like:
517 # include('foo', :path=>'foo').exclude('foo/bar', path=>'foo').
518 # include('foo/bar', :path=>'foo/bar')
519 # This will play havoc if we handled all the prerequisites together
520 # under the task, so instead we handle them individually for each path.
521 #
522 # We need to check that any file we include is not newer than the
523 # contents of the Zip. The file itself but also the directory it's
524 # coming from, since some tasks touch the directory, e.g. when the
525 # content of target/classes is included into a WAR.
526 most_recent = @paths.collect { |name, path| path.sources }.flatten.
527 select { |file| File.exist?(file) }.collect { |file| File.stat(file).mtime }.max
528 File.stat(name).mtime < (most_recent || Rake::EARLY) || super
529 end
530
531 # :call-seq:
532 # empty? => boolean
533 #
534 # Returns true if this ZIP file is empty (has no other entries inside).
535 def empty?
536 path("").empty
537 end
538
539 # :call-seq:
540 # contain(file*) => boolean
541 #
542 # Returns true if this ZIP file contains all the specified files. You can use absolute
543 # file names and glob patterns (using *, **, etc).
544 def contain?(*files)
545 path("").contain?(*files)
546 end
547
548 protected
549
550 # Adds a prepare block. These blocks are called early on for adding more content to
551 # the archive, before invoking prerequsities. Anything you add here will be invoked
552 # as a prerequisite and used to determine whether or not to generate this archive.
553 # In contrast, enhance blocks are evaluated after it was decided to create this archive.
554 def prepare(&block)
555 @prepares << block
556 end
557
558 def []=(key, value) #:nodoc:
559 raise ArgumentError, "This task does not support the option #{key}."
560 end
561
562 end
563
564
565 end