require "zip/zip"
require "zip/zipfilesystem"
module Buildr
# Base class for ZipTask, TarTask and other archives.
class ArchiveTask < Rake::FileTask
# Which files go where. All the rules for including, excluding and merging files
# are handled by this object.
class Path #:nodoc:
# Returns the archive from this path.
attr_reader :root
def initialize(root, path)
@root = root
@path = "#{path}/" if path
@files = FileList[]
# Expand source files added to this path.
expand_src = proc { @files.map{ |file| file.to_s }.uniq }
@sources = [ expand_src ]
# Add files and directories added to this path.
@actions = [] << proc do |file_map|
file_map[@path] = nil if @path
expand_src.call.each do |path|
if File.directory?(path)
in_directory(path, @files) do |file, rel_path|
dest = "#{@path}#{rel_path}"
puts "Adding #{dest}" if Rake.application.options.trace
file_map[dest] = file
end
else
puts "Adding #{@path}#{File.basename(path)}" if Rake.application.options.trace
file_map["#{@path}#{File.basename(path)}"] = path
end
end
end
end
# :call-seq:
# include(*files) => self
# include(*files, :path=>path) => self
# include(file, :as=>name) => self
# include(:from=>path) => self
# include(*files, :merge=>true) => self
def include(*args)
options = args.pop if Hash === args.last
files = args.flatten
if options.nil? || options.empty?
@files.include *files.map { |file| file.to_s }
elsif options[:path]
sans_path = options.reject { |k,v| k == :path }
path(options[:path]).include *files + [sans_path]
elsif options[:as]
raise "You can only use the :as option in combination with the :path option" unless options.size == 1
raise "You can only use one file with the :as option" unless files.size == 1
include_as files.first.to_s, options[:as]
elsif options[:from]
raise "You can only use the :from option in combination with the :path option" unless options.size == 1
raise "You canont use the :from option with file names" unless files.empty?
[options[:from]].flatten.each { |path| include_as path.to_s, "." }
elsif options[:merge]
raise "You can only use the :merge option in combination with the :path option" unless options.size == 1
files.each { |file| merge file }
else
raise "Unrecognized option #{options.keys.join(", ")}"
end
self
end
alias :add :include
# :call-seq:
# exclude(*files) => self
def exclude(*files)
@files.exclude *files
self
end
# :call-seq:
# merge(*files) => Merge
# merge(*files, :path=>name) => Merge
def merge(*args)
options = args.pop if Hash === args.last
files = args.flatten
if options.nil? || options.empty?
files.collect do |file|
@sources << proc { file.to_s }
expander = ZipExpander.new(file)
@actions << proc { |file_map| expander.expand(file_map, @path) }
expander
end.first
elsif options[:path]
sans_path = options.reject { |k,v| k == :path }
path(options[:path]).merge *files + [sans_path]
self
else
raise "Unrecognized option #{options.keys.join(", ")}"
end
end
# Returns a Path relative to this one.
def path(path)
path.blank? ? self : @root.path("#{@path}#{path}")
end
# Returns all the source files.
def sources() #:nodoc:
@sources.map{ |source| source.call }.flatten
end
def add_files(file_map) #:nodoc:
@actions.each { |action| action.call(file_map) }
end
def to_s()
@path || ""
end
protected
def include_as(source, as)
@sources << proc { source }
@actions << proc do |file_map|
file = source.to_s
file_map[@path] = nil if @path
if File.directory?(file)
in_directory(file) do |file, rel_path|
dest = as == "." ? (@path || "") + rel_path.split("/")[1..-1].join("/") : "#{@path}#{as}#{rel_path}"
puts "Adding #{dest}" if Rake.application.options.trace
file_map[dest] = file
end
else
puts "Adding #{@path}#{as}" if Rake.application.options.trace
file_map["#{@path}#{as}"] = file
end
end
end
def in_directory(dir, excludes = nil)
prefix = Regexp.new("^" + Regexp.escape(File.dirname(dir) + File::SEPARATOR))
FileList["#{dir}/**/*"].
reject { |file| File.directory?(file) || (excludes && excludes.exclude?(file)) }.
each { |file| yield file, file.sub(prefix, "") }
end
end
# Extend one Zip file into another.
class ZipExpander #:nodoc:
def initialize(zip_file)
@zip_file = zip_file.to_s
@includes = []
@excludes = []
end
def include(*files)
@includes |= files
self
end
def exclude(*files)
@excludes |= files
self
end
def expand(file_map, path)
@includes = ["*"] if @includes.empty?
Zip::ZipFile.open(@zip_file) do |source|
source.entries.reject { |entry| entry.directory? }.each do |entry|
if @includes.any? { |pattern| File.fnmatch(pattern, entry.name) } &&
!@excludes.any? { |pattern| File.fnmatch(pattern, entry.name) }
dest = "#{path}#{entry.name}"
puts "Adding #{dest}" if Rake.application.options.trace
file_map[dest] = lambda { |output| output.write source.read(entry) }
end
end
end
end
end
def initialize(*args) #:nodoc:
super
@paths = { nil=>Path.new(self, nil) }
@prepares = []
# Make sure we're the last enhancements, so other enhancements can add content.
enhance do
@file_map = {}
enhance do
send "create" if respond_to?(:create)
# We're here because the archive file does not exist, or one of the files is newer than the archive contents;
# we need to make sure the archive doesn't exist (e.g. opening an existing Zip will add instead of create).
# We also want to protect against partial updates.
rm name, :verbose=>false rescue nil
mkpath File.dirname(name), :verbose=>false
begin
@paths.each { |name, object| object.add_files(@file_map) }
create_from @file_map
rescue
rm name, :verbose=>false rescue nil
raise
end
end
end
end
# :call-seq:
# include(*files) => self
# include(*files, :path=>path) => self
# include(file, :as=>name) => self
# include(:from=>path) => self
# include(*files, :merge=>true) => self
#
# Include files in this archive, or when called on a path, within that path. Returns self.
#
# The first form accepts a list of files, directories and glob patterns and adds them to the archive.
# For example, to include the file foo, directory bar (including all files in there) and all files under baz:
# zip(..).include("foo", "bar", "baz/*")
#
# The second form is similar but adds files/directories under the specified path. For example,
# to add foo as bar/foo:
# zip(..).include("foo", :path=>"bar")
# The :path option is the same as using the path method:
# zip(..).path("bar").include("foo")
# All other options can be used in combination with the :path option.
#
# The third form adds a file or directory under a different name. For example, to add the file foo under the
# name bar:
# zip(..).include("foo", :as=>"bar")
#
# The fourth form adds the contents of a directory using the directory as a prerequisite:
# zip(..).include(:from=>"foo")
# Unlike include("foo")
it includes the contents of the directory, not the directory itself.
# Unlike include("foo/*")
, it uses the directory timestamp for dependency management.
#
# The fifth form includes the contents of another archive by expanding it into this archive. For example:
# zip(..).include("foo.zip", :merge=>true).include("bar.zip")
# You can also use the method #merge.
def include(*files)
@paths[nil].include *files
self
end
alias :add :include
# :call-seq:
# exclude(*files) => self
#
# Excludes files and returns self. Can be used in combination with include to prevent some files from being included.
def exclude(*files)
@paths[nil].exclude *files
self
end
# :call-seq:
# merge(*files) => Merge
# merge(*files, :path=>name) => Merge
#
# Merges another archive into this one by including the individual files from the merged archive.
#
# Returns an object that supports two methods: include and exclude. You can use these methods to merge
# only specific files. For example:
# zip(..).merge("src.zip").include("module1/*")
def merge(*files)
@paths[nil].merge *files
end
# :call-seq:
# path(name) => Path
#
# Returns a path object. Use the path object to include files under a path, for example, to include
# the file "foo" as "bar/foo":
# zip(..).path("bar").include("foo")
#
# Returns a Path object. The Path object implements all the same methods, like include, exclude, merge
# and so forth. It also implements path and root, so that:
# path("foo").path("bar") == path("foo/bar")
# path("foo").root == root
def path(name)
return @paths[nil] if name.blank?
@paths[name] ||= Path.new(self, name)
end
# :call-seq:
# root() => ArchiveTask
#
# Call this on an archive to return itself, and on a path to return the archive.
def root()
self
end
# :call-seq:
# with(options) => self
#
# Passes options to the task and returns self. Some tasks support additional options, for example,
# the WarTask supports options like :manifest, :libs and :classes.
#
# For example:
# package(:jar).with(:manifest=>"MANIFEST_MF")
def with(options)
options.each do |key, value|
begin
send "#{key}=", value
rescue NameError
if respond_to?(:[]=) # Backward compatible with Buildr 1.1.
warn_deprecated "The []= method is deprecated, please use attribute accessors instead."
self[key] = value
else
raise ArgumentError, "This task does not support the option #{key}."
end
end
end
self
end
def invoke_prerequisites() #:nodoc:
@prepares.each { |prepare| prepare.call(self) }
@prepares.clear
@prerequisites |= @paths.collect { |name, path| path.sources }.flatten
super
end
def needed?() #:nodoc:
return true unless File.exist?(name)
# You can do something like:
# include("foo", :path=>"foo").exclude("foo/bar", path=>"foo").
# include("foo/bar", :path=>"foo/bar")
# This will play havoc if we handled all the prerequisites together
# under the task, so instead we handle them individually for each path.
#
# We need to check that any file we include is not newer than the
# contents of the Zip. The file itself but also the directory it's
# coming from, since some tasks touch the directory, e.g. when the
# content of target/classes is included into a WAR.
most_recent = @paths.collect { |name, path| path.sources }.flatten.
each { |src| File.directory?(src) ? FileList["#{src}/**/*"] | [src] : src }.flatten.
select { |file| File.exist?(file) }.collect { |file| File.stat(file).mtime }.max
File.stat(name).mtime < (most_recent || Rake::EARLY) || super
end
protected
# Adds a prepare block. These blocks are called early on for adding more content to
# the archive, before invoking prerequsities. Anything you add here will be invoked
# as a prerequisite and used to determine whether or not to generate this archive.
# In contrast, enhance blocks are evaluated after it was decided to create this archive.
def prepare(&block)
@prepares << block
end
def []=(key, value) #:nodoc:
raise ArgumentError, "This task does not support the option #{key}."
end
end
# The ZipTask creates a new Zip file. You can include any number of files and and directories,
# use exclusion patterns, and include files into specific directories.
#
# For example:
# zip("test.zip").tap do |task|
# task.include "srcs"
# task.include "README", "LICENSE"
# end
#
# See Buildr#zip and ArchiveTask.
class ZipTask < ArchiveTask
private
def create_from(file_map)
Zip::ZipFile.open(name, Zip::ZipFile::CREATE) do |zip|
zip.restore_permissions = true
file_map.each do |path, content|
zip.mkdir path unless content || zip.find_entry(path)
zip.add path, content if String === content
zip.get_output_stream(path) { |output| content.call(output) } if content.respond_to?(:call)
end
end
end
end
# :call-seq:
# zip(file) => ZipTask
#
# The ZipTask creates a new Zip file. You can include any number of files and
# and directories, use exclusion patterns, and include files into specific
# directories.
#
# For example:
# zip("test.zip").tap do |task|
# task.include "srcs"
# task.include "README", "LICENSE"
# end
def zip(file)
ZipTask.define_task(file)
end
# An object for unzipping a file into a target directory. You can tell it to include
# or exclude only specific files and directories, and also to map files from particular
# paths inside the zip file into the target directory. Once ready, call #extract.
#
# Usually it is more convenient to create a file task for extracting the zip file
# (see #unzip) and pass this object as a prerequisite to other tasks.
#
# See Buildr#unzip.
class Unzip
# The zip file to extract.
attr_accessor :zip_file
# The target directory to extract to.
attr_accessor :target
# Initialize with hash argument of the form target=>zip_file.
def initialize(args)
@target, @zip_file = Rake.application.resolve_args(args)
@paths = {}
end
# :call-seq:
# extract()
#
# Extract the zip file into the target directory.
#
# You can call this method directly. However, if you are using the #unzip method,
# it creates a file task for the target directory: use that task instead as a
# prerequisite. For example:
# build unzip(dir=>zip_file)
# Or:
# unzip(dir=>zip_file).target.invoke
def extract()
# If no paths specified, then no include/exclude patterns
# specified. Nothing will happen unless we include all files.
if @paths.empty?
@paths[nil] = FromPath.new(self, nil)
@paths[nil].include "*"
end
# Otherwise, empty unzip creates target as a file when touching.
mkpath target.to_s, :verbose=>false
Zip::ZipFile.open(zip_file.to_s) do |zip|
entries = zip.collect
@paths.each do |path, patterns|
patterns.map(entries).each do |dest, entry|
next if entry.directory?
dest = File.expand_path(dest, target.to_s)
puts "Extracting #{dest}" if Rake.application.options.trace
mkpath File.dirname(dest), :verbose=>false rescue nil
entry.extract(dest) { true }
end
end
end
# Let other tasks know we updated the target directory.
touch target.to_s, :verbose=>false
end
# :call-seq:
# include(*files) => self
# include(*files, :path=>name) => self
#
# Include all files that match the patterns and returns self.
#
# Use include if you only want to unzip some of the files, by specifying
# them instead of using exclusion. You can use #include in combination
# with #exclude.
def include(*files)
if Hash === files.last
from_path(files.pop[:path]).include *files
else
from_path(nil).include *files
end
self
end
alias :add :include
# :call-seq:
# exclude(*files) => self
#
# Exclude all files that match the patterns and return self.
#
# Use exclude to unzip all files except those that match the pattern.
# You can use #exclude in combination with #include.
def exclude(*files)
if Hash === files.last
from_path(files.pop[:path]).exclude *files
else
from_path(nil).exclude *files
end
self
end
# :call-seq:
# from_path(name) => Path
#
# Allows you to unzip from a path. Returns an object you can use to
# specify which files to include/exclude relative to that path.
# Expands the file relative to that path.
#
# For example:
# unzip(Dir.pwd=>"test.jar").from_path("etc").include("LICENSE")
# will unzip etc/LICENSE into ./LICENSE.
#
# This is different from:
# unzip(Dir.pwd=>"test.jar").include("etc/LICENSE")
# which unzips etc/LICENSE into ./etc/LICENSE.
def from_path(name)
@paths[name] ||= FromPath.new(self, name)
end
alias :path :from_path
# :call-seq:
# root() => Unzip
#
# Returns the root path, essentially the Unzip object itself. In case you are wondering
# down paths and want to go back.
def root()
self
end
# Returns the path to the target directory.
def to_s()
target.to_s
end
class FromPath #:nodoc:
def initialize(unzip, path)
@unzip = unzip
if path
@path = path[-1] == ?/ ? path : path + "/"
else
@path = ""
end
end
# See UnzipTask#include
def include(*files) #:doc:
@include ||= []
@include |= files
self
end
# See UnzipTask#exclude
def exclude(*files) #:doc:
@exclude ||= []
@exclude |= files
self
end
def map(entries)
includes = @include || ["*"]
excludes = @exclude || []
entries.inject({}) do |map, entry|
short = entry.name.sub(@path, "")
if includes.any? { |pat| File.fnmatch(pat, short) } &&
!excludes.any? { |pat| File.fnmatch(pat, short) }
map[short] = entry
end
map
end
end
# Documented in Unzip.
def root()
@unzip
end
# The target directory to extract to.
def target()
@unzip.target
end
end
end
# :call-seq:
# unzip(to_dir=>zip_file) => Zip
#
# Creates a task that will unzip a file into the target directory. The task name
# is the target directory, the prerequisite is the file to unzip.
#
# This method creates a file task to expand the zip file. It returns an Unzip object
# that specifies how the file will be extracted. You can include or exclude specific
# files from within the zip, and map to different paths.
#
# The Unzip object's to_s method return the path to the target directory, so you can
# use it as a prerequisite. By keeping the Unzip object separate from the file task,
# you overlay additional work on top of the file task.
#
# For example:
# unzip("all"=>"test.zip")
# unzip("src"=>"test.zip").include("README", "LICENSE")
# unzip("libs"=>"test.zip").from_path("libs")
def unzip(args)
target, zip_file = Rake.application.resolve_args(args)
task = file(File.expand_path(target.to_s)=>zip_file)
Unzip.new(task=>zip_file).tap do |setup|
task.enhance { setup.extract }
end
end
end
module Zip #:nodoc:
class ZipCentralDirectory #:nodoc:
# Patch to add entries in alphabetical order.
def write_to_stream(io)
offset = io.tell
@entrySet.sort { |a,b| a.name <=> b.name }.each { |entry| entry.write_c_dir_entry(io) }
write_e_o_c_d(io, offset)
end
end
end