require 'pathname' module Hike # `Index` is an internal cached variant of `Trail`. It assumes the # file system does not change between `find` calls. All `stat` and # `entries` calls are cached for the lifetime of the `Index` object. class Index # `Index#paths` is an immutable `Paths` collection. attr_reader :paths # `Index#extensions` is an immutable `Extensions` collection. attr_reader :extensions # `Index#aliases` is an immutable `Hash` mapping an extension to # an `Array` of aliases. attr_reader :aliases # `Index.new` is an internal method. Instead of constructing it # directly, create a `Trail` and call `Trail#index`. def initialize(root, paths, extensions, aliases) @root = root # Freeze is used here so an error is throw if a mutator method # is called on the array. Mutating `@paths`, `@extensions`, or # `@aliases` would have unpredictable results. @paths = paths.dup.freeze @extensions = extensions.dup.freeze @aliases = aliases.inject({}) { |h, (k, a)| h[k] = a.dup.freeze; h }.freeze @pathnames = paths.map { |path| Pathname.new(path) } @stats = {} @entries = {} @patterns = {} end # `Index#root` returns root path as a `String`. This attribute is immutable. def root @root.to_s end # `Index#index` returns `self` to be compatable with the `Trail` interface. def index self end # The real implementation of `find`. `Trail#find` generates a one # time index and delegates here. # # See `Trail#find` for usage. def find(*logical_paths, &block) if block_given? options = extract_options!(logical_paths) base_path = Pathname.new(options[:base_path] || @root) logical_paths.each do |logical_path| logical_path = Pathname.new(logical_path.sub(/^\//, '')) if relative?(logical_path) find_in_base_path(logical_path, base_path, &block) else find_in_paths(logical_path, &block) end end nil else find(*logical_paths) do |path| return path end end end # A cached version of `Dir.entries` that filters out `.` files and # `~` swap files. Returns an empty `Array` if the directory does # not exist. def entries(path) key = path.to_s @entries[key] ||= Pathname.new(path).entries.reject { |entry| entry.to_s =~ /^\.|~$|^\#.*\#$/ }.sort rescue Errno::ENOENT @entries[key] = [] end # A cached version of `File.stat`. Returns nil if the file does # not exist. def stat(path) key = path.to_s if @stats.key?(key) @stats[key] else begin @stats[key] = File.stat(path) rescue Errno::ENOENT @stats[key] = nil end end end protected def extract_options!(arguments) arguments.last.is_a?(Hash) ? arguments.pop.dup : {} end def relative?(logical_path) logical_path.to_s =~ /^\.\.?\// end # Finds logical path across all `paths` def find_in_paths(logical_path, &block) dirname, basename = logical_path.split @pathnames.each do |base_path| match(base_path.join(dirname), basename, &block) end end # Finds relative logical path, `../test/test_trail`. Requires a # `base_path` for reference. def find_in_base_path(logical_path, base_path, &block) candidate = base_path.join(logical_path) dirname, basename = candidate.split match(dirname, basename, &block) if paths_contain?(dirname) end # Checks if the path is actually on the file system and performs # any syscalls if necessary. def match(dirname, basename) # Potential `entries` syscall matches = entries(dirname) pattern = pattern_for(basename) matches = matches.select { |m| m.to_s =~ pattern } sort_matches(matches, basename).each do |path| pathname = dirname.join(path) # Potential `stat` syscall stat = stat(pathname) # Exclude directories if stat && stat.file? yield pathname.to_s end end end # Returns true if `dirname` is a subdirectory of any of the `paths` def paths_contain?(dirname) paths.any? { |path| dirname.to_s[0, path.length] == path } end # Cache results of `build_pattern_for` def pattern_for(basename) @patterns[basename] ||= build_pattern_for(basename) end # Returns a `Regexp` that matches the allowed extensions. # # pattern_for("index.html") #=> /^index(.html|.htm)(.builder|.erb)*$/ def build_pattern_for(basename) extname = basename.extname aliases = find_aliases_for(extname) if aliases.any? basename = basename.basename(extname) aliases = [extname] + aliases aliases_pattern = aliases.map { |e| Regexp.escape(e) }.join("|") basename_re = Regexp.escape(basename.to_s) + "(?:#{aliases_pattern})" else basename_re = Regexp.escape(basename.to_s) end extension_pattern = extensions.map { |e| Regexp.escape(e) }.join("|") /^#{basename_re}(?:#{extension_pattern})*$/ end # Sorts candidate matches by their extension # priority. Extensions in the front of the `extensions` carry # more weight. def sort_matches(matches, basename) aliases = find_aliases_for(basename.extname) matches.sort_by do |match| extnames = match.sub(basename.to_s, '').to_s.scan(/\.[^.]+/) extnames.inject(0) do |sum, ext| if i = extensions.index(ext) sum + i + 1 elsif i = aliases.index(ext) sum + i + 11 else sum end end end end def find_aliases_for(extension) @aliases.inject([]) do |aliases, (key, value)| aliases.push(key) if value == extension aliases end end end end