blob: ce4ac2c16a657ff08852c829823a36f2aab8c894 [file] [log] [blame]
require 'active_support/multibyte/unicode'
module Pod
class Sandbox
# The PathList class is designed to perform multiple glob matches against
# a given directory. Basically, it generates a list of all the children
# paths and matches the globs patterns against them, resulting in just one
# access to the file system.
#
# @note A PathList once it has generated the list of the paths this is
# updated only if explicitly requested by calling
# {#read_file_system}
#
class PathList
# @return [Pathname] The root of the list whose files and directories
# are used to perform the matching operations.
#
attr_accessor :root
# Initialize a new instance
#
# @param [Pathname] root The root of the PathList.
#
def initialize(root)
root_dir = ActiveSupport::Multibyte::Unicode.normalize(root.to_s)
@root = Pathname.new(root_dir)
@glob_cache = {}
end
# @return [Array<String>] The list of absolute the path of all the files
# contained in {root}.
#
def files
read_file_system unless @files
@files
end
# @return [Array<String>] The list of absolute the path of all the
# directories contained in {root}.
#
def dirs
read_file_system unless @dirs
@dirs
end
# @return [void] Reads the file system and populates the files and paths
# lists.
#
def read_file_system
unless root.exist?
raise Informative, "Attempt to read non existent folder `#{root}`."
end
escaped_root = escape_path_for_glob(root)
absolute_paths = Pathname.glob(escaped_root + '**/*', File::FNM_DOTMATCH).lazy
dirs_and_files = absolute_paths.reject { |path| path.basename.to_s =~ /^\.\.?$/ }
dirs, files = dirs_and_files.partition { |path| File.directory?(path) }
root_length = root.cleanpath.to_s.length + File::SEPARATOR.length
sorted_relative_paths_from_full_paths = lambda do |paths|
relative_paths = paths.lazy.map do |path|
path_string = path.to_s
path_string.slice(root_length, path_string.length - root_length)
end
relative_paths.sort_by(&:upcase)
end
@dirs = sorted_relative_paths_from_full_paths.call(dirs)
@files = sorted_relative_paths_from_full_paths.call(files)
@glob_cache = {}
end
#-----------------------------------------------------------------------#
public
# @!group Globbing
# Similar to {glob} but returns the absolute paths.
#
# @param [String,Array<String>] patterns
# @see #relative_glob
#
# @param [Hash] options
# @see #relative_glob
#
# @return [Array<Pathname>]
#
def glob(patterns, options = {})
relative_glob(patterns, options).map { |p| root + p }
end
# The list of relative paths that are case insensitively matched by a
# given pattern. This method emulates {Dir#glob} with the
# {File::FNM_CASEFOLD} option.
#
# @param [String,Array<String>] patterns
# A single {Dir#glob} like pattern, or a list of patterns.
#
# @param [Hash] options
#
# @option options [String] :dir_pattern
# An optional pattern to append to a pattern, if it is the path
# to a directory.
#
# @option options [Array<String>] :exclude_patterns
# Exclude specific paths given by those patterns.
#
# @option options [Array<String>] :include_dirs
# Additional paths to take into account for matching.
#
# @return [Array<Pathname>]
#
def relative_glob(patterns, options = {})
return [] if patterns.empty?
cache_key = options.merge(:patterns => patterns)
cached_value = @glob_cache[cache_key]
return cached_value if cached_value
dir_pattern = options[:dir_pattern]
exclude_patterns = options[:exclude_patterns]
include_dirs = options[:include_dirs]
if include_dirs
full_list = files + dirs
else
full_list = files
end
list = Array(patterns).map do |pattern|
if directory?(pattern) && dir_pattern
pattern += '/' unless pattern.end_with?('/')
pattern += dir_pattern
end
expanded_patterns = dir_glob_equivalent_patterns(pattern)
full_list.select do |path|
expanded_patterns.any? do |p|
File.fnmatch(p, path, File::FNM_CASEFOLD | File::FNM_PATHNAME)
end
end
end.flatten
list = list.map { |path| Pathname.new(path) }
if exclude_patterns
exclude_options = { :dir_pattern => '**/*', :include_dirs => include_dirs }
list -= relative_glob(exclude_patterns, exclude_options)
end
@glob_cache[cache_key] = list
end
#-----------------------------------------------------------------------#
private
# @!group Private helpers
# @return [Bool] Wether a path is a directory. The result of this method
# computed without accessing the file system and is case
# insensitive.
#
# @param [String, Pathname] sub_path The path that could be a directory.
#
def directory?(sub_path)
sub_path = sub_path.to_s.downcase.sub(/\/$/, '')
dirs.any? { |dir| dir.downcase == sub_path }
end
# @return [Array<String>] An array of patterns converted from a
# {Dir.glob} pattern to patterns that {File.fnmatch} can handle.
# This is used by the {#relative_glob} method to emulate
# {Dir.glob}.
#
# The expansion provides support for:
#
# - Literals
#
# dir_glob_equivalent_patterns('{file1,file2}.{h,m}')
# => ["file1.h", "file1.m", "file2.h", "file2.m"]
#
# - Matching the direct children of a directory with `**`
#
# dir_glob_equivalent_patterns('Classes/**/file.m')
# => ["Classes/**/file.m", "Classes/file.m"]
#
# @param [String] pattern A {Dir#glob} like pattern.
#
def dir_glob_equivalent_patterns(pattern)
pattern = pattern.gsub('/**/', '{/**/,/}')
values_by_set = {}
pattern.scan(/\{[^}]*\}/) do |set|
values = set.gsub(/[{}]/, '').split(',')
values_by_set[set] = values
end
if values_by_set.empty?
[pattern]
else
patterns = [pattern]
values_by_set.each do |set, values|
patterns = patterns.map do |old_pattern|
values.map do |value|
old_pattern.gsub(set, value)
end
end.flatten
end
patterns
end
end
# Escapes the glob metacharacters from a given path so it can used in
# Dir#glob and similar methods.
#
# @note See CocoaPods/CocoaPods#862.
#
# @param [String, Pathname] path
# The path to escape.
#
# @return [Pathname] The escaped path.
#
def escape_path_for_glob(path)
result = path.to_s
characters_to_escape = ['[', ']', '{', '}', '?', '*']
characters_to_escape.each do |character|
result.gsub!(character, "\\#{character}")
end
Pathname.new(result)
end
#-----------------------------------------------------------------------#
end
end
end