require 'fileutils'
require 'hrx'
require 'pathname'
require 'set'
require_relative 'util'
# A directory that may represent either a physical directory on disk or a
# directory within an HRX::Archive.
class SassSpec::Directory
# A cache of parsed HRX files, indexed by their corresponding directory paths.
def self._hrx_cache
@hrx_cache ||= {}
end
# The directory's path, possibly including components within an HRX file.
attr_reader :path
# Returns whether this is a virtual HRX directory.
def hrx?
!!@archive
end
# Creates a Directory from a `path`, which may go into an HRX file. For
# example, if `path` is `path/to/archive/subdir` and `path/to/archive.hrx`
# exists, this will load `subdir` from within `path/to/archive.hrx`.
def initialize(path)
@path = Pathname.new(path)
@path = @path.relative_path_from(Pathname.new(Dir.pwd)) if @path.absolute?
# Always use forward slashes on Windows, because HRX requires them.
@path = Pathname.new(@path.to_s.gsub(/\\/, '/')) if Gem.win_platform?
if %w[.. .].include?(@path.each_filename.first)
raise ArgumentError.new("#{path} must be beneath the working directory")
end
return if Dir.exist?(@path)
SassSpec::Util.each_directory(@path).with_index do |dir, i|
archive_path = dir + ".hrx"
if self.class._hrx_cache[dir] || File.exist?(archive_path)
@parent_archive_path = archive_path
@parent_archive = self.class._hrx_cache[dir] ||= HRX::Archive.load(archive_path)
@path_in_parent = @path.each_filename.drop(i + 1).join("/")
@archive =
if @path_in_parent.empty?
@parent_archive
else
@parent_archive.child_archive(@path_in_parent)
end
return
end
end
raise ArgumentError.new("#{path} doesn't exist")
end
# Returns the parent as a SassSpec::Directory, or `nil` if this is the root
# spec directory.
def parent
dirname = File.dirname(@path)
dirname == "." ? nil : SassSpec::Directory.new(dirname)
end
# Returns a list of all files in this directory that match `pattern`, relative
# to the directory root.
#
# This includes files within HRX files in this directory.
def glob(pattern)
if hrx?
@archive.glob(pattern).select {|e| e.is_a?(HRX::File)}.map(&:path)
else
recursive = pattern.include?("**")
physical_pattern =
if recursive
"{#{File.join(@path, pattern)},#{File.join(@path, '**/*.hrx')}}"
else
File.join(@path, pattern)
end
seen = Set.new
Dir.glob(physical_pattern, File::FNM_EXTGLOB).flat_map do |path|
# Dir.glob() can emit the same path multiple times if multiple `{}`
# patterns cover it.
next [] if seen.include?(path)
seen << path
next [] if Dir.exists?(path)
absolute = Pathname.new(path).expand_path
relative = absolute.relative_path_from(@path.expand_path).to_s
next relative unless path.end_with?(".hrx")
next [] unless recursive
dir = path[0...-".hrx".length]
relative_dir = relative[0...-".hrx".length]
archive = self.class._hrx_cache[dir] ||= HRX::Archive.load(path)
archive.glob(pattern).map {|inner| "#{relative_dir}/#{inner.path}"}
end
end
end
# Returns whether a file exists at `path` within this directory.
def file?(path)
if hrx?
@archive[path].is_a?(HRX::File)
elsif (dir, basename = split_if_nested(path))
dir.file?(basename)
else
File.file?(File.join(@path, path))
end
rescue ArgumentError, HRX::Error
# If we get a directory-doesn't-exist error for a nested directory, return
# false. This could catch unrelated errors, but it's probably not likely
# enough to be worth creating a custom exception class.
return false
end
# Reads the file at `path` within this directory.
def read(path)
if hrx?
@archive.read(path)
elsif (dir, basename = split_if_nested(path))
dir.read(basename)
else
File.read(File.join(@path, path), binmode: true, encoding: "ASCII-8BIT")
end
end
# Writes `contents` to `path` within this directory.
def write(path, contents)
if hrx?
@archive.write(path, contents, comment: :copy)
_write!
elsif (dir, basename = split_if_nested(path))
dir.write(basename, contents)
else
File.write(File.join(@path, path), contents, binmode: true)
end
end
# Deletes the file at `path` within this directory.
#
# If `if_exists` is `true`, don't throw an error if the file doesn't exist.
def delete(path, if_exists: false)
return if if_exists && !file?(path)
if hrx?
@archive.delete(path)
_write!
elsif (dir, basename = split_if_nested(path))
dir.delete(basename, if_exists: if_exists)
else
File.unlink(File.join(@path, path))
end
end
# Renames the file at `old` to `new`.
def rename(old, new)
old_dir, old_basename = split_if_nested(old) || [self, old]
new_dir, new_basename = split_if_nested(new) || [self, new]
if old_dir.hrx? && new_dir.hrx?
unless old_file = old_dir.archive[old_basename]
raise "#@path/old doesn't exist"
end
new_dir.archive.add(
HRX::File.new(new_basename, old_file.content, comment: old_file.comment),
after: new_dir == old_dir ? old_file.path : nil)
new_dir._write!
old_dir.delete(old_basename)
else
new_dir.write(new_basename, old_dir.read(old_basename))
old_dir.delete(old_basename)
end
end
# Deletes this directory and everything it contains.
def delete_dir!
if hrx?
if @parent_archive.equal?(@archive)
_delete_parent!
else
@parent_archive.delete(@path_in_parent, recursive: true)
_write!
end
else
FileUtils.rm_rf(@path)
end
end
# If this directory refers to an HRX file, runs a block with the archive's
# directory and all its contents physically present on the filesystem next to
# the archive.
#
# If this is a normal directory, runs the block with the filesystem as-is.
def with_real_files
return yield unless @archive
files = @archive.entries.select {|entry| entry.is_a?(HRX::File)}.to_a
if parent && parent.hrx? && files.any? {|file| _reaches_out?(file)}
return parent.with_real_files {yield}
end
outermost_new_dir = SassSpec::Util.each_directory(@path).find {|dir| !Dir.exist?(dir)}
files.each do |file|
path = File.join(@path, file.path)
FileUtils.mkdir_p(File.dirname(path))
File.write(path, file.content)
end
yield
ensure
FileUtils.rm_rf(outermost_new_dir) if outermost_new_dir
end
# Returns an HRX representation of this directory.
def to_hrx
archive = HRX::Archive.new
glob("**/*").each do |path|
archive << HRX::File.new("#{self.path}/#{path}", read(path))
end
archive.to_hrx
end
def inspect
"#<SassSpec::Directory:#{@path}>"
end
def to_s
@path.to_s
end
protected
# The directory's underlying HRX archive. `nil` if `hrx?` is `false`.
attr_reader :archive
# Writes `@parent_archive` to disk.
def _write!
if @parent_archive.entries.empty?
_delete_parent!
else
@parent_archive.write!(@parent_archive_path)
end
end
private
# If `path` points to a subdirectory of this directory, returns the nested
# `Directory` object and the basename of the file. Otherwise, returns `nil`.
def split_if_nested(path)
dirname, basename = File.split(path)
dirname == '.' ? nil : [SassSpec::Directory.new(@path.join(dirname)), basename]
end
# Returns whether `file` contains enough `../` references to reach outside
# this directory.
def _reaches_out?(file)
depth = file.path.count("/")
file.content.scan(%r{(?:\.\./)+}).any? {|match| match.count("/") > depth}
end
# Deletes `@parent_archive` from disk and from the archive cache.
def _delete_parent!
File.unlink(@parent_archive_path)
self.class._hrx_cache.delete(@parent_archive_path.sub(/\.hrx$/, ''))
end
end