# frozen_string_literal: true class Nanoc::DataSources::Filesystem < Nanoc::DataSource # Contains useful functions for managing the filesystem. # # @api private module Tools # Error that is raised when too many symlink indirections are encountered. class MaxSymlinkDepthExceededError < ::Nanoc::Core::TrivialError # @return [String] The last filename that was attempted to be # resolved before giving up attr_reader :filename # @param [String] filename The last filename that was attempted to be # resolved before giving up def initialize(filename) @filename = filename super("Too many indirections while resolving symlinks. I gave up after finding out #{filename} was yet another symlink. Sorry!") end end # Error that is raised when a file of an unknown type is encountered # (something other than file, directory or link). class UnsupportedFileTypeError < ::Nanoc::Core::TrivialError # @return [String] The filename of the file whose type is not supported attr_reader :filename # @param [String] filename The filename of the file whose type is not # supported def initialize(filename) @filename = filename super("The file at #{filename} is of an unsupported type (expected file, directory or link, but it is #{File.ftype(filename)}") end end module_function # Returns all files in the given directory and directories below it, # following symlinks up to a maximum of `recursion_limit` times. # # @param [String] dir_name The name of the directory whose contents to # fetch # # @param [String, Array, nil] extra_files The list of extra patterns # to extend the file search for files not found by default, example # "**/.{htaccess,htpasswd}" # # @param [Integer] recursion_limit The maximum number of times to # recurse into a symlink to a directory # # @return [Array] A list of file names # # @raise [MaxSymlinkDepthExceededError] if too many indirections are # encountered while resolving symlinks # # @raise [UnsupportedFileTypeError] if a file of an unsupported type is # detected (something other than file, directory or link) def all_files_in(dir_name, extra_files, recursion_limit = 10) all_files_and_dirs_in(dir_name, extra_files).map do |fn| case File.ftype(fn) when 'link' if recursion_limit.zero? raise MaxSymlinkDepthExceededError.new(fn) else absolute_target = resolve_symlink(fn) if File.file?(absolute_target) fn else all_files_in(absolute_target, extra_files, recursion_limit - 1).map do |sfn| fn + sfn[absolute_target.size..-1] end end end when 'file' fn when 'directory' nil else raise UnsupportedFileTypeError.new(fn) end end.compact.flatten end # Expands the path (including resolving ~ for home directory) and returns # a relative path to the expanded path. def expand_and_relativize_path(path) from = Pathname.new(Dir.getwd) to = Pathname.new(File.expand_path(path)) to.relative_path_from(from).to_s end # Returns all files and directories in the given directory and # directories below it. # # @param [String] dir_name The name of the directory whose contents to # fetch # # @param [String, Array, nil] extra_files The list of extra patterns # to extend the file search for files not found by default, example # "**/.{htaccess,htpasswd}" # # @return [Array] A list of files and directories # # @raise [Nanoc::Core::TrivialError] when pattern can not be handled def all_files_and_dirs_in(dir_name, extra_files) base_patterns = ["#{dir_name}/**/*"] extra_patterns = case extra_files when nil [] when String ["#{dir_name}/#{extra_files.dup.delete_prefix('/')}"] when Array extra_files.map { |extra_file| "#{dir_name}/#{extra_file.dup.delete_prefix('/')}" } else raise( Nanoc::Core::TrivialError, "Do not know how to handle extra_files: #{extra_files.inspect}", ) end patterns = base_patterns + extra_patterns Dir.glob(patterns) end # Resolves the given symlink into an absolute path. # # @param [String] filename The filename of the symlink to resolve # # @param [Integer] recursion_limit The maximum number of times to recurse # into a symlink # # @return [String] The absolute resolved filename of the symlink target # # @raise [MaxSymlinkDepthExceededError] if too many indirections are # encountered while resolving symlinks # # @raise [UnsupportedFileTypeError] if a file of an unsupported type is # detected (something other than file, directory or link) def resolve_symlink(filename, recursion_limit = 5) target = File.readlink(filename) absolute_target = File.expand_path(target, File.dirname(filename)) case File.ftype(absolute_target) when 'link' if recursion_limit.zero? raise MaxSymlinkDepthExceededError.new(absolute_target) else resolve_symlink(absolute_target, recursion_limit - 1) end when 'file', 'directory' absolute_target else raise UnsupportedFileTypeError.new(absolute_target) end end # Reads the content of the file with the given name and returns a string # in UTF-8 encoding. The original encoding of the string is derived from # the default external encoding, but this can be overridden by the # “encoding” configuration attribute in the data source configuration. def read_file(filename, config:) # Read begin data = File.read(filename) rescue => e raise Errors::FileUnreadable.new(filename, e) end # Set original encoding, if any if config && config[:encoding] original_encoding = Encoding.find(config[:encoding]) data.force_encoding(config[:encoding]) else original_encoding = data.encoding end # Set encoding to UTF-8 begin data.encode!('UTF-8') rescue raise Errors::InvalidEncoding.new(filename, original_encoding) end # Verify unless data.valid_encoding? raise Errors::InvalidEncoding.new(filename, original_encoding) end # Remove UTF-8 BOM (ugly) data.delete!("\xEF\xBB\xBF") data end end end