lib/opal/hike.rb



# frozen_string_literal: true

# Copyright (c) 2011 Sam Stephenson
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

require 'pathname'

module Opal
  # Taken from hike v1.2.3
  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 collection of `Pathname`s.
      attr_reader :paths

      # `Index#extensions` is an immutable collection of extensions.
      attr_reader :extensions

      # `Index.new` is an internal method. Instead of constructing it
      # directly, create a `Trail` and call `Trail#index`.
      def initialize(root, paths, extensions)
        @root = root

        # Freeze is used here so an error is throw if a mutator method
        # is called on the array. Mutating `@paths`, `@extensions`
        # would have unpredictable results.
        @paths      = paths.dup.freeze
        @extensions = extensions.dup.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_path)
        base_path = Pathname.new(@root)
        logical_path = Pathname.new(logical_path.sub(/^\//, ''))

        if logical_path.to_s =~ %r{^\.\.?/}
          find_in_base_path(logical_path, base_path) { |path| return path }
        else
          find_in_paths(logical_path) { |path| return path }
        end

        nil
      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)
        @entries[path.to_s] ||= begin
          pathname = Pathname.new(path)
          if pathname.directory?
            pathname.entries.reject { |entry| entry.to_s =~ /^\.|~$|^\#.*\#$/ }.sort
          else
            []
          end
        end
      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]
        elsif File.exist?(path)
          @stats[key] = File.stat(path)
        else
          @stats[key] = nil
        end
      end

      protected

      def extract_options!(arguments)
        arguments.last.is_a?(Hash) ? arguments.pop.dup : {}
      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)
        extension_pattern = extensions.map { |e| Regexp.escape(e) }.join('|')
        /^#{basename}(?:#{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)
        matches.sort_by do |match|
          extnames = match.sub(basename.to_s, '').to_s.scan(/\.[^.]+/)
          extnames.inject(0) do |sum, ext|
            index = extensions.index(ext)
            if index
              sum + index + 1
            else
              sum
            end
          end
        end
      end
    end

    # `Trail` is the public container class for holding paths and extensions.
    class Trail
      # `Trail#paths` is a mutable `Paths` collection.
      #
      #     trail = Hike::Trail.new
      #     trail.paths.push "~/Projects/hike/lib", "~/Projects/hike/test"
      #
      # The order of the paths is significant. Paths in the beginning of
      # the collection will be checked first. In the example above,
      # `~/Projects/hike/lib/hike.rb` would shadow the existent of
      # `~/Projects/hike/test/hike.rb`.
      attr_reader :paths

      # `Trail#extensions` is a mutable `Extensions` collection.
      #
      #     trail = Hike::Trail.new
      #     trail.paths.push "~/Projects/hike/lib"
      #     trail.extensions.push ".rb"
      #
      # Extensions allow you to find files by just their name omitting
      # their extension. Is similar to Ruby's require mechanism that
      # allows you to require files with specifiying `foo.rb`.
      attr_reader :extensions

      # A Trail accepts an optional root path that defaults to your
      # current working directory. Any relative paths added to
      # `Trail#paths` will expanded relative to the root.
      def initialize(root = '.')
        @root       = Pathname.new(root).expand_path
        @paths      = []
        @extensions = []
      end

      # `Trail#root` returns root path as a `String`. This attribute is immutable.
      def root
        @root.to_s
      end

      # Append `path` to `Paths` collection
      def append_paths(*paths)
        @paths.concat(paths.map { |p| normalize_path(p) })
      end

      # Append `extension` to `Extensions` collection
      def append_extensions(*extensions)
        @extensions.concat(extensions.map { |e| normalize_extension(e) })
      end

      # `Trail#find` returns a the expand path for a logical path in the
      # path collection.
      #
      #     trail = Hike::Trail.new "~/Projects/hike"
      #     trail.extensions.push ".rb"
      #     trail.paths.push "lib", "test"
      #
      #     trail.find "hike/trail"
      #     # => "~/Projects/hike/lib/hike/trail.rb"
      #
      #     trail.find "test_trail"
      #     # => "~/Projects/hike/test/test_trail.rb"
      #
      def find(*args, &block)
        index.find(*args, &block)
      end

      # `Trail#index` returns an `Index` object that has the same
      # interface as `Trail`. An `Index` is a cached `Trail` object that
      # does not update when the file system changes. If you are
      # confident that you are not making changes the paths you are
      # searching, `index` will avoid excess system calls.
      #
      #     index = trail.index
      #     index.find "hike/trail"
      #     index.find "test_trail"
      #
      def index
        Index.new(root, paths, extensions)
      end

      # `Trail#entries` is equivalent to `Dir#entries`. It is not
      # recommend to use this method for general purposes. It exists for
      # parity with `Index#entries`.
      def entries(path)
        pathname = Pathname.new(path)
        if pathname.directory?
          pathname.entries.reject { |entry| entry.to_s =~ /^\.|~$|^\#.*\#$/ }.sort
        else
          []
        end
      end

      # `Trail#stat` is equivalent to `File#stat`. It is not
      # recommend to use this method for general purposes. It exists for
      # parity with `Index#stat`.
      def stat(path)
        if File.exist?(path)
          File.stat(path.to_s)
        else
          # nil
        end
      end

      private

      def normalize_extension(ext)
        ext.start_with?('.') ? ext : ".#{ext}"
      end

      def normalize_path(path)
        path = Pathname.new(path)
        path = @root.join(path) if path.relative?
        path.expand_path.to_s
      end
    end
  end
end