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.blank? ? path : "#{path}/" @includes = FileList[] @excludes = [] # Expand source files added to this path. expand_src = proc { @includes.map{ |file| file.to_s }.uniq } @sources = [ expand_src ] # Add files and directories added to this path. @actions = [] << proc do |file_map| expand_src.call.each do |path| unless excluded?(path) if File.directory?(path) in_directory path 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 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? @includes.include *files.flatten 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 = files.flatten.map(&:to_s) @excludes |= files @excludes |= files.reject { |f| f =~ /\*$/ }.map { |f| "#{f}/*" } 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) return self if path.blank? return root.path(path[1..-1]) if path[0] == ?/ 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 unless excluded?(file) if File.directory?(file) in_directory file do |file, rel_path| path = rel_path.split("/")[1..-1] path.unshift as unless as == "." dest = "#{@path}#{path.join('/')}" 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 end def in_directory(dir) prefix = Regexp.new("^" + Regexp.escape(File.dirname(dir) + File::SEPARATOR)) FileList.recursive(dir).reject { |file| excluded?(file) }. each { |file| yield file, file.sub(prefix, "") } end def excluded?(file) @excludes.any? { |exclude| File.fnmatch(exclude, file) } 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 = { ""=>Path.new(self, "") } @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 do |name, object| @file_map[name] = nil unless name.blank? object.add_files(@file_map) end 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[""].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[""].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[""].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[""] if name.blank? normalized = name.split("/").inject([]) do |path, part| case part when ".", nil, "" path when ".." path[0...-1] else path << part end end.join("/") @paths[normalized] ||= Path.new(self, normalized) 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.recursive(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::ZipOutputStream.open name do |zip| seen = {} mkpath = lambda do |dir| unless dir == "." || seen[dir] mkpath.call File.dirname(dir) zip.put_next_entry dir + '/' seen[dir] = true end end file_map.each do |path, content| mkpath.call File.dirname(path) if content.respond_to?(:call) zip.put_next_entry path content.call zip elsif content.nil? || File.directory?(content.to_s) mkpath.call path else zip.put_next_entry path File.open content.to_s, "rb" do |is| while data = is.read(4096) zip << data end end end 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