# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with this
# work for additional information regarding copyright ownership. The ASF
# licenses this file to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
require 'erb'
module Buildr
# A filter knows how to copy files from one directory to another, applying mappings to the
# contents of these files.
#
# You can specify the mapping using a Hash, and it will map ${key} fields found in each source
# file into the appropriate value in the target file. For example:
#
# filter.using 'version'=>'1.2', 'build'=>Time.now
#
# will replace all occurrences of ${version} with 1.2, and ${build}
# with the current date/time.
#
# You can also specify the mapping by passing a proc or a method, that will be called for
# each source file, with the file name and content, returning the modified content.
#
# Without any mapping, the filter simply copies files from the source directory into the target
# directory.
#
# A filter has one target directory, but you can specify any number of source directories,
# either when creating the filter or calling #from. Include/exclude patterns are specified
# relative to the source directories, so:
# filter.include '*.png'
# will only include PNG files from any of the source directories.
#
# See Buildr#filter.
class Filter
def initialize #:nodoc:
clear
end
# Returns the list of source directories (each being a file task).
attr_reader :sources
# :call-seq:
# clear => self
#
# Clear filter sources and include/exclude patterns
def clear
@include = []
@exclude = []
@sources = FileList[]
@mapper = Mapper.new
self
end
# :call-seq:
# from(*sources) => self
#
# Adds additional directories from which to copy resources.
#
# For example:
# filter.from('src').into('target').using('build'=>Time.now)
def from(*sources)
@sources |= sources.flatten.map { |dir| file(File.expand_path(dir.to_s)) }
self
end
# The target directory as a file task.
attr_reader :target
# :call-seq:
# into(dir) => self
#
# Sets the target directory into which files are copied and returns self.
#
# For example:
# filter.from('src').into('target').using('build'=>Time.now)
def into(dir)
@target = file(File.expand_path(dir.to_s)) { |task| run if target == task }
self
end
# :call-seq:
# include(*files) => self
#
# Specifies files to include and returns self. See FileList#include.
#
# By default all files are included. You can use this method to only include specific
# files from the source directory.
def include(*files)
@include += files
self
end
alias :add :include
# :call-seq:
# exclude(*files) => self
#
# Specifies files to exclude and returns self. See FileList#exclude.
def exclude(*files)
@exclude += files
self
end
# The mapping. See #using.
def mapping #:nodoc:
@mapper.config
end
# The mapper to use. See #using.
def mapper #:nodoc:
@mapper.mapper_type
end
# :call-seq:
# using(mapping) => self
# using { |file_name, contents| ... } => self
#
# Specifies the mapping to use and returns self.
#
# The most typical mapping uses a Hash, and the default mapping uses the Maven style, so
# ${key}
are mapped to the values. You can change that by passing a different
# format as the first argument. Currently supports:
# * :ant -- Map @key@
.
# * :maven -- Map ${key}
(default).
# * :ruby -- Map #{key}
.
# * :erb -- Map <%= key %>
.
# * Regexp -- Maps the matched data (e.g. /=(.*?)=/
#
# For example:
# filter.using 'version'=>'1.2'
# Is the same as:
# filter.using :maven, 'version'=>'1.2'
#
# You can also pass a proc or method. It will be called with the file name and content,
# to return the mapped content.
#
# Without any mapping, all files are copied as is.
#
# To register new mapping type see the Mapper class.
def using(*args, &block)
@mapper.using(*args, &block)
self
end
# :call-seq:
# run => boolean
#
# Runs the filter.
def run
sources.each { |source| raise "Source directory #{source} doesn't exist" unless File.exist?(source.to_s) }
raise 'No target directory specified, where am I going to copy the files to?' if target.nil?
copy_map = sources.flatten.map(&:to_s).inject({}) do |map, source|
files = Util.recursive_with_dot_files(source).
map { |file| Util.relative_path(file, source) }.
select { |file| @include.empty? || @include.any? { |pattern| File.fnmatch(pattern, file) } }.
reject { |file| @exclude.any? { |pattern| File.fnmatch(pattern, file) } }
files.each do |file|
src, dest = File.expand_path(file, source), File.expand_path(file, target.to_s)
map[file] = src if !File.exist?(dest) || File.stat(src).mtime >= File.stat(dest).mtime
end
map
end
mkpath target.to_s
return false if copy_map.empty?
copy_map.each do |path, source|
dest = File.expand_path(path, target.to_s)
if File.directory?(source)
mkpath dest
else
mkpath File.dirname(dest)
if @mapper.mapper_type
mapped = @mapper.transform(File.open(source, 'rb') { |file| file.read }, path)
File.open(dest, 'wb') { |file| file.write mapped }
else # no mapping
cp source, dest
File.chmod(0664, dest)
end
end
end
touch target.to_s
true
end
# Returns the target directory.
def to_s
@target.to_s
end
# This class implements content replacement logic for Filter.
#
# To register a new template engine @:foo@, extend this class with a method like:
#
# def foo_transform(content, path = nil)
# # if this method yields a key, the value comes from the mapping hash
# content.gsub(/world/) { |str| yield :bar }
# end
#
# Then you can use :foo mapping type on a Filter
#
# filter.using :foo, :bar => :baz
#
# Or all by your own, simply
#
# Mapper.new(:foo, :bar => :baz).transform("Hello world") # => "Hello baz"
#
# You can handle configuration arguments by providing a @*_config@ method like:
#
# # The return value of this method is available with the :config accessor.
# def moo_config(*args, &block)
# raise ArgumentError, "Expected moo block" unless block_given?
# { :moos => args, :callback => block }
# end
#
# def moo_transform(content, path = nil)
# content.gsub(/moo+/i) do |str|
# moos = yield :moos # same than config[:moos]
# moo = moos[str.size - 3] || str
# config[:callback].call(moo)
# end
# end
#
# Usage for the @:moo@ mapper would be something like:
#
# mapper = Mapper.new(:moo, 'ooone', 'twoo') do |str|
# i = nil; str.capitalize.gsub(/\w/) { |s| s.send( (i = !i) ? 'upcase' : 'downcase' ) }
# end
# mapper.transform('Moo cow, mooo cows singing mooooo') # => 'OoOnE cow, TwOo cows singing MoOoOo'
class Mapper
attr_reader :mapper_type, :config
def initialize(*args, &block) #:nodoc:
using(*args, &block)
end
def using(*args, &block)
case args.first
when Hash # Maven hash mapping
using :maven, *args
when Binding # Erb binding
using :erb, *args
when Symbol # Mapping from a method
raise ArgumentError, "Unknown mapping type: #{args.first}" unless respond_to?("#{args.first}_transform", true)
configure(*args, &block)
when Regexp # Mapping using a regular expression
raise ArgumentError, 'Expected regular expression followed by mapping hash' unless args.size == 2 && Hash === args[1]
@mapper_type, @config = *args
else
unless args.empty? && block.nil?
raise ArgumentError, 'Expected proc, method or a block' if args.size > 1 || (args.first && block)
@mapper_type = :callback
config = args.first || block
raise ArgumentError, 'Expected proc, method or callable' unless config.respond_to?(:call)
@config = config
end
end
self
end
def transform(content, path = nil)
type = Regexp === mapper_type ? :regexp : mapper_type
raise ArgumentError, "Invalid mapper type: #{type.inspect}" unless respond_to?("#{type}_transform", true)
self.__send__("#{type}_transform", content, path) { |key| config[key] || config[key.to_s.to_sym] }
end
private
def configure(mapper_type, *args, &block)
configurer = method("#{mapper_type}_config") rescue nil
if configurer
@config = configurer.call(*args, &block)
else
raise ArgumentError, "Missing hash argument after :#{mapper_type}" unless args.size == 1 && Hash === args[0]
@config = *args
end
@mapper_type = mapper_type
end
def maven_transform(content, path = nil)
content.gsub(/\$\{.*?\}/) { |str| yield(str[2..-2]) || str }
end
def ant_transform(content, path = nil)
content.gsub(/@.*?@/) { |str| yield(str[1..-2]) || str }
end
def ruby_transform(content, path = nil)
content.gsub(/#\{.*?\}/) { |str| yield(str[2..-2]) || str }
end
def regexp_transform(content, path = nil)
content.gsub(mapper_type) { |str| yield(str.scan(mapper_type).join) || str }
end
def callback_transform(content, path = nil)
config.call(path, content)
end
def erb_transform(content, path = nil)
case config
when Binding, Proc
bnd = config
when Method
bnd = config.to_proc
when Hash
bnd = OpenStruct.new
table = config.inject({}) { |h, e| h[e.first.to_sym] = e.last; h }
bnd.instance_variable_set(:@table, table)
bnd = bnd.instance_eval { binding }
else
bnd = config.instance_eval { binding }
end
require 'erb'
ERB.new(content).result(bnd)
end
def erb_config(*args, &block)
if block_given?
raise ArgumentError, "Expected block or single argument, but both given." unless args.empty?
block
elsif args.size > 1
raise ArgumentError, "Expected block or single argument."
else
args.first
end
end
end # class Mapper
end
# :call-seq:
# filter(*source) => Filter
#
# Creates a filter that will copy files from the source directory(ies) into the target directory.
# You can extend the filter to modify files by mapping ${key} into values in each
# of the copied files, and by including or excluding specific files.
#
# A filter is not a task, you must call the Filter#run method to execute it.
#
# For example, to copy all files from one directory to another:
# filter('src/files').into('target/classes').run
# To include only the text files, and replace each instance of ${build} with the current
# date/time:
# filter('src/files').into('target/classes').include('*.txt').using('build'=>Time.now).run
def filter(*sources)
Filter.new.from(*sources)
end
end