lib/pathutil.rb



# ----------------------------------------------------------------------------
# Frozen-string-literal: true
# Copyright: 2015-2016 Jordon Bedwell - MIT License
# Encoding: utf-8
# ----------------------------------------------------------------------------

require "pathutil/helpers"
require "forwardable/extended"
require "find"

#

class Pathutil
  attr_writer :encoding
  extend Forwardable::Extended
  extend Helpers

  # --------------------------------------------------------------------------

  def initialize(path)
    return @path = path if path.is_a?(String)
    return @path = path.to_path if path.respond_to?(:to_path)
    return @path = path.to_s
  end

  # --------------------------------------------------------------------------
  # Search backwards for a file (like Rakefile, _config.yml, opts.yml).
  # @note It will return all results that it finds across all ascending paths.
  # @param backwards how far do you wish to search backwards in that path?
  # @param file the file you are searching for.
  #
  # @example
  #   Pathutil.new("~/").expand_path.search_backwards(".bashrc") => [
  #     #<Pathutil:/home/user/.bashrc>
  #   ]
  # --------------------------------------------------------------------------

  def search_backwards(file, backwards: Float::INFINITY)
    ary = []

    ascend.with_index(1).each do |path, index|
      if index > backwards
        break

      else
        Dir.chdir path do
          if block_given?
            file = self.class.new(file)
            if yield(file)
              ary.push(
                file
              )
            end

          elsif File.exist?(file)
            ary.push(self.class.new(
              path.join(file)
            ))
          end
        end
      end
    end

    ary
  end

  # --------------------------------------------------------------------------

  def read_yaml(throw_missing: false, **kwd)
    self.class.load_yaml(
      read, **kwd
    )

  rescue Errno::ENOENT
    throw_missing ? raise : (
      return {}
    )
  end

  # --------------------------------------------------------------------------

  def read_json(throw_missing: false)
    JSON.parse(
      read
    )

  rescue Errno::ENOENT
    throw_missing ? raise : (
      return {}
    )
  end

  # --------------------------------------------------------------------------
  # Splits the path into all parts so that you can do step by step comparisons
  # @note The blank part is intentionally left there so that you can rejoin.
  #
  # @example
  #   Pathutil.new("/my/path").split_path # => [
  #     "", "my", "path"
  #   ]
  # --------------------------------------------------------------------------

  def split_path
    @path.split(
      File::SEPARATOR
    )
  end

  # --------------------------------------------------------------------------
  # @see `String#==` for more details.
  # A stricter version of `==` that also makes sure the object matches.
  # @param [Pathutil] other the comparee.
  # @return true, false
  # --------------------------------------------------------------------------

  def ===(other)
    other.is_a?(self.class) && @path == other
  end

  # --------------------------------------------------------------------------
  # @example Pathutil.new("/hello") >= Pathutil.new("/") # => true
  # @example Pathutil.new("/hello") >= Pathutil.new("/hello") # => true
  # Checks to see if a path falls within a path and deeper or is the other.
  # @param path the path that should be above the object.
  # @return true, false
  # --------------------------------------------------------------------------

  def >=(other)
    mine, other = expanded_paths(other)
    return true if other == mine
    mine.in_path?(other)
  end

  # --------------------------------------------------------------------------
  # @example Pathutil.new("/hello/world") > Pathutil.new("/hello") # => true
  # Strictly checks to see if a path is deeper but within the path of the other.
  # @param path the path that should be above the object.
  # @return true, false
  # --------------------------------------------------------------------------

  def >(other)
    mine, other = expanded_paths(other)
    return false if other == mine
    mine.in_path?(other)
  end

  # --------------------------------------------------------------------------
  # @example Pathutil.new("/") < Pathutil.new("/hello") # => true
  # Strictly check to see if a path is behind other path but within it.
  # @param path the path that should be below the object.
  # @return true, false
  # --------------------------------------------------------------------------

  def <(other)
    mine, other = expanded_paths(other)
    return false if other == mine
    other.in_path?(mine)
  end

  # --------------------------------------------------------------------------
  # Check to see if a path is behind the other path butt within it.
  # @example Pathutil.new("/hello") < Pathutil.new("/hello") # => true
  # @example Pathutil.new("/") < Pathutil.new("/hello") # => true
  # @param path the path that should be below the object.
  # @return true, false
  # --------------------------------------------------------------------------

  def <=(other)
    mine, other = expanded_paths(other)
    return true if other == mine
    other.in_path?(mine)
  end

  # --------------------------------------------------------------------------
  # @note "./" is considered relative.
  # Check to see if the path is absolute, as in: starts with "/"
  # @return true, false
  # --------------------------------------------------------------------------

  def absolute?
    @path.start_with?("/")
  end

  # --------------------------------------------------------------------------
  # Break apart the path and yield each with the previous parts.
  # @return Enumerator if no block is given.
  #
  # @example
  #   Pathutil.new("/hello/world").ascend.to_a # => [
  #     "/", "/hello", "/hello/world"
  #   ]
  #
  # @example
  #   Pathutil.new("/hello/world").ascend do |path|
  #     $stdout.puts path
  #   end
  #
  #   /
  #   /hello
  #   /hello/world
  # --------------------------------------------------------------------------

  def ascend
    unless block_given?
      return to_enum(
        __method__
      )
    end

    yield(
      path = self
    )

    while (new_path = path.dirname)
      if path == new_path || new_path == "."
        break
      else
        path = new_path
        yield  new_path
      end
    end

    nil
  end

  # --------------------------------------------------------------------------
  # Break apart the path in reverse order and descend into the path.
  # @return Enumerator if no block is given.
  #
  # @example
  #   Pathutil.new("/hello/world").descend.to_a # => [
  #     "/hello/world", "/hello", "/"
  #   ]
  #
  # @example
  #   Pathutil.new("/hello/world").descend do |path|
  #     $stdout.puts path
  #   end
  #
  #   /hello/world
  #   /hello
  #   /
  # --------------------------------------------------------------------------

  def descend
    unless block_given?
      return to_enum(
        __method__
      )
    end

    ascend.to_a.reverse_each do |val|
      yield val
    end

    nil
  end

  # --------------------------------------------------------------------------
  # Wraps `readlines` and allows you to yield on the result.
  #
  # @example
  #   Pathutil.new("/hello/world").each_line do |line|
  #     $stdout.puts line
  #   end
  #
  #   Hello
  #   World
  # --------------------------------------------------------------------------

  def each_line
    return to_enum(__method__) unless block_given?
    readlines.each do |line|
      yield line
    end

    nil
  end

  # --------------------------------------------------------------------------
  # @see `File#fnmatch` for more information.
  # Unlike traditional `fnmatch`, with this one `Regexp` is allowed.
  # @param [String, Regexp] matcher the matcher used, can be a `Regexp`
  # @example Pathutil.new("/hello").fnmatch?("/hello") # => true
  # @example Pathutil.new("/hello").fnmatch?(/h/) # => true
  # @return true, false
  # --------------------------------------------------------------------------

  def fnmatch?(matcher)
    matcher.is_a?(Regexp) ? !!(self =~ matcher) : \
      File.fnmatch(self, matcher)
  end

  # --------------------------------------------------------------------------
  # Allows you to quickly determine if the file is the root folder.
  # @return true, false
  # --------------------------------------------------------------------------

  def root?
    self == File::SEPARATOR
  end

  # --------------------------------------------------------------------------
  # @param [Pathutil, String] path the reference.
  # Allows you to check if the current path is in the path you want.
  # @return true, false
  # --------------------------------------------------------------------------

  def in_path?(path)
    path = self.class.new(path).expand_path.split_path
    mine = (symlink?? expand_path.realpath : expand_path).split_path
    path.each_with_index { |part, index| return false if mine[index] != part }
    true
  end

  # --------------------------------------------------------------------------

  def inspect
    "#<#{self.class}:#{@path}>"
  end

  # --------------------------------------------------------------------------
  # Grab all of the children from the current directory, including hidden.
  # @return Array<Pathutils>
  # --------------------------------------------------------------------------

  def children
    ary = []

    Dir.foreach(@path) do |path|
      if path == "." || path == ".."
        next
      else
        path = self.class.new(File.join(@path, path))
        yield path if block_given?
        ary.push(
          path
        )
      end
    end

    ary
  end

  # --------------------------------------------------------------------------
  # @see `File::Constants` for a list of flags.
  # Allows you to glob however you wish to glob in the current `Pathutils`
  # @param [String] flags the flags you want to ship to the glob.
  # @param [String] pattern the pattern A.K.A: "**/*"
  # @return Enumerator unless a  block is given.
  # --------------------------------------------------------------------------

  def glob(pattern, flags = 0)
    unless block_given?
      return to_enum(
        __method__, pattern, flags
      )
    end

    chdir do
      Dir.glob(pattern, flags).each do |file|
        yield self.class.new(
          File.join(@path, file)
        )
      end
    end

    nil
  end

  # --------------------------------------------------------------------------
  # @note you do not need to ship a block at all.
  # Move to the current directory temporarily (or for good) and do work son.
  # @return 0, 1 if no block given
  # --------------------------------------------------------------------------

  def chdir
    if !block_given?
      Dir.chdir(
        @path
      )

    else
      Dir.chdir @path do
        yield
      end
    end
  end

  # --------------------------------------------------------------------------
  # @return Enumerator if no block is given.
  # Find all files without care and yield the given block.
  # @see Find.find
  # --------------------------------------------------------------------------

  def find
    return to_enum(__method__) unless block_given?
    Find.find @path do |val|
      yield self.class.new(val)
    end
  end

  # --------------------------------------------------------------------------
  # Splits the path returning each part (filename) back to you.
  # @return Enumerator if no block is given.
  # --------------------------------------------------------------------------

  def each_filename
    return to_enum(__method__) unless block_given?
    @path.split(File::SEPARATOR).delete_if(&:empty?).each do |file|
      yield file
    end
  end

  # --------------------------------------------------------------------------

  def parent
    return self if @path == "/"
    self.class.new(absolute?? File.dirname(@path) : File.join(
      @path, ".."
    ))
  end

  # --------------------------------------------------------------------------
  # Split the file into its dirname and basename, so you can do stuff.
  # @return File.dirname, File.basename
  # --------------------------------------------------------------------------

  def split
    File.split(@path).collect! do |path|
      self.class.new(path)
    end
  end

  # --------------------------------------------------------------------------
  # Replace a files extension with your given extension.
  # --------------------------------------------------------------------------

  def sub_ext(ext)
    self.class.new(@path.chomp(File.extname(@path)) + ext)
  end

  # --------------------------------------------------------------------------
  # A less complex version of `relative_path_from` that simply uses a
  # `Regexp` and returns the full path if it cannot be relatively determined.
  # @return Pathutils the relative path if it can be determined or is relative.
  # @return Pathutils the full path if relative path cannot be determined
  # --------------------------------------------------------------------------

  def relative_path_from(from)
    from = self.class.new(from).expand_path.gsub(%r!/$!, "")
    self.class.new(expand_path.gsub(%r!^#{from.regexp_escape}/!, ""))
  end

  # --------------------------------------------------------------------------
  # Expands the path and left joins the root to the path.
  # @param [String, Pathutil] root the root you wish to enforce on it.
  # @return Pathutil the enforced path with given root.
  # --------------------------------------------------------------------------

  def enforce_root(root)
    curr, root = expanded_paths(root)
    if curr.in_path?(root)
      return curr

    else
      File.join(
        root, curr
      )
    end
  end

  # --------------------------------------------------------------------------
  # Copy a directory, allowing symlinks if the link falls inside of the root.
  # --------------------------------------------------------------------------

  def safe_copy(to, root: nil)
    raise ArgumentError, "must give a root" unless root
    to = self.class.new(to)

    root = self.class.new(root)
    return safe_copy_directory(to, :root => root) if directory?
    safe_copy_file(to, :root => root)
  end

  # --------------------------------------------------------------------------
  # @see `self.class.normalize` as this is an alias.
  # --------------------------------------------------------------------------

  def normalize
    return @normalize ||= begin
      self.class.normalize
    end
  end

  # --------------------------------------------------------------------------
  # @see `self.class.encoding` as this is an alias.
  # --------------------------------------------------------------------------

  def encoding
    return @encoding ||= begin
      self.class.encoding
    end
  end

  # --------------------------------------------------------------------------
  # Read took two steroid shots: it can normalize your string, and encode.
  # --------------------------------------------------------------------------

  def read(*args, **kwd)
    kwd[:encoding] ||= encoding

    if normalize[:read]
      File.read(self, *args, kwd).encode({
        :universal_newline => true
      })

    else
      File.read(
        self, *args, kwd
      )
    end
  end

  # --------------------------------------------------------------------------
  # Binread took two steroid shots: it can normalize your string, and encode.
  # --------------------------------------------------------------------------

  def binread(*args, **kwd)
    kwd[:encoding] ||= encoding

    if normalize[:read]
      File.binread(self, *args, kwd).encode({
        :universal_newline => true
      })

    else
      File.read(
        self, *args, kwd
      )
    end
  end

  # --------------------------------------------------------------------------
  # Readlines took two steroid shots: it can normalize your string, and encode.
  # --------------------------------------------------------------------------

  def readlines(*args, **kwd)
    kwd[:encoding] ||= encoding

    if normalize[:read]
      File.readlines(self, *args, kwd).encode({
        :universal_newline => true
      })

    else
      File.readlines(
        self, *args, kwd
      )
    end
  end

  # --------------------------------------------------------------------------
  # Write took two steroid shots: it can normalize your string, and encode.
  # --------------------------------------------------------------------------

  def write(data, *args, **kwd)
    kwd[:encoding] ||= encoding

    if normalize[:write]
      File.write(self, data.encode(
        :crlf_newline => true
      ), *args, kwd)

    else
      File.write(
        self, data, *args, kwd
      )
    end
  end

  # --------------------------------------------------------------------------
  # Binwrite took two steroid shots: it can normalize your string, and encode.
  # --------------------------------------------------------------------------

  def binwrite(data, *args, **kwd)
    kwd[:encoding] ||= encoding

    if normalize[:write]
      File.binwrite(self, data.encode(
        :crlf_newline => true
      ), *args, kwd)

    else
      File.binwrite(
        self, data, *args, kwd
      )
    end
  end

  # --------------------------------------------------------------------------
  # @api returns the current objects expanded path and their expanded path.
  # --------------------------------------------------------------------------

  private
  def expanded_paths(path)
    return expand_path, self.class.new(path).expand_path
  end

  # --------------------------------------------------------------------------

  private
  def safe_copy_file(to, root: nil)
    raise Errno::EPERM, "#{self} not in #{root}" unless in_path?(root)
    FileUtils.cp(self, to, {
      :preserve => true
    })
  end

  # --------------------------------------------------------------------------

  private
  def safe_copy_directory(to, root: nil)
    if !in_path?(root)
      raise Errno::EPERM, "#{self} not in #{
        root
      }"

    else
      to.mkdir_p unless to.exist?
      children do |file|
        if !file.in_path?(root)
          raise Errno::EPERM, "#{file} not in #{
            root
          }"

        elsif file.file?
          FileUtils.cp(file, to, {
            :preserve => true
          })

        else
          path = file.realpath
          path.safe_copy(to.join(file.basename), {
            :root => root
          })
        end
      end
    end
  end

  # --------------------------------------------------------------------------

  class << self
    attr_writer :encoding

    # ------------------------------------------------------------------------
    # Get the current directory that Ruby knows about.
    # ------------------------------------------------------------------------

    def pwd
      new(
        Dir.pwd
      )
    end

    alias gcwd pwd
    alias cwd  pwd

    # ------------------------------------------------------------------------
    # Aliases the default system encoding to us so that we can do most read
    # and write operations with that encoding, instead of being crazy.
    # @note you are encouraged to override this if you need to.
    # ------------------------------------------------------------------------

    def encoding
      return @encoding ||= begin
        Encoding.default_external
      end
    end

    # ------------------------------------------------------------------------
    # Normalize CRLF -> LF on Windows reads, to ease  your troubles.
    # Normalize LF -> CLRF on Windows write, to ease their troubles.
    # ------------------------------------------------------------------------

    def normalize
      return @normalize ||= {
        :read  => Gem.win_platform?,
        :write => Gem.win_platform?
      }
    end

    # ------------------------------------------------------------------------

    def tmpdir(*args)
      rtn = new(make_tmpname(*args)).tap(&:mkdir)
      ObjectSpace.define_finalizer(rtn, proc do
        rtn.rm_rf
      end)

      rtn
    end

    # ------------------------------------------------------------------------

    def tmpfile(*args)
      rtn = new(make_tmpname(*args)).tap(&:touch)
      ObjectSpace.define_finalizer(rtn, proc do
        rtn.rm_rf
      end)

      rtn
    end
  end

  # --------------------------------------------------------------------------

  rb_delegate :sub,     :to => :@path, :wrap => true
  rb_delegate :chomp,   :to => :@path, :wrap => true
  rb_delegate :gsub,    :to => :@path, :wrap => true
  rb_delegate :=~,      :to => :@path
  rb_delegate :==,      :to => :@path
  rb_delegate :to_s,    :to => :@path
  rb_delegate :freeze,  :to => :@path
  rb_delegate :frozen?, :to => :@path
  rb_delegate :to_str,  :to => :@path
  rb_delegate :"!~",    :to => :@path
  rb_delegate :<=>,     :to => :@path

  # --------------------------------------------------------------------------

  rb_delegate :basename,     :to => :File, :args => :@path, :wrap => true
  rb_delegate :dirname,      :to => :File, :args => :@path, :wrap => true
  rb_delegate :readlink,     :to => :File, :args => :@path, :wrap => true
  rb_delegate :expand_path,  :to => :File, :args => :@path, :wrap => true
  rb_delegate :realdirpath,  :to => :File, :args => :@path, :wrap => true
  rb_delegate :realpath,     :to => :File, :args => :@path, :wrap => true
  rb_delegate :rename,       :to => :File, :args => :@path, :wrap => true
  rb_delegate :join,         :to => :File, :args => :@path, :wrap => true
  rb_delegate :size,         :to => :File, :args => :@path
  rb_delegate :link,         :to => :File, :args => :@path
  rb_delegate :atime,        :to => :File, :args => :@path
  rb_delegate :chown,        :to => :File, :args => :@path
  rb_delegate :ctime,        :to => :File, :args => :@path
  rb_delegate :lstat,        :to => :File, :args => :@path
  rb_delegate :utime,        :to => :File, :args => :@path
  rb_delegate :lchmod,       :to => :File, :args => :@path
  rb_delegate :sysopen,      :to => :File, :args => :@path
  rb_delegate :birthtime,    :to => :File, :args => :@path
  rb_delegate :mountpoint?,  :to => :File, :args => :@path
  rb_delegate :truncate,     :to => :File, :args => :@path
  rb_delegate :symlink,      :to => :File, :args => :@path
  rb_delegate :extname,      :to => :File, :args => :@path
  rb_delegate :lchown,       :to => :File, :args => :@path
  rb_delegate :zero?,        :to => :File, :args => :@path
  rb_delegate :ftype,        :to => :File, :args => :@path
  rb_delegate :chmod,        :to => :File, :args => :@path
  rb_delegate :mtime,        :to => :File, :args => :@path
  rb_delegate :open,         :to => :File, :args => :@path
  rb_delegate :stat,         :to => :File, :args => :@path

  # --------------------------------------------------------------------------

  rb_delegate :pipe?,            :to => :FileTest, :args => :@path
  rb_delegate :file?,            :to => :FileTest, :args => :@path
  rb_delegate :owned?,           :to => :FileTest, :args => :@path
  rb_delegate :setgid?,          :to => :FileTest, :args => :@path
  rb_delegate :socket?,          :to => :FileTest, :args => :@path
  rb_delegate :readable?,        :to => :FileTest, :args => :@path
  rb_delegate :blockdev?,        :to => :FileTest, :args => :@path
  rb_delegate :directory?,       :to => :FileTest, :args => :@path
  rb_delegate :readable_real?,   :to => :FileTest, :args => :@path
  rb_delegate :world_readable?,  :to => :FileTest, :args => :@path
  rb_delegate :executable_real?, :to => :FileTest, :args => :@path
  rb_delegate :world_writable?,  :to => :FileTest, :args => :@path
  rb_delegate :writable_real?,   :to => :FileTest, :args => :@path
  rb_delegate :executable?,      :to => :FileTest, :args => :@path
  rb_delegate :writable?,        :to => :FileTest, :args => :@path
  rb_delegate :grpowned?,        :to => :FileTest, :args => :@path
  rb_delegate :chardev?,         :to => :FileTest, :args => :@path
  rb_delegate :symlink?,         :to => :FileTest, :args => :@path
  rb_delegate :sticky?,          :to => :FileTest, :args => :@path
  rb_delegate :setuid?,          :to => :FileTest, :args => :@path
  rb_delegate :exist?,           :to => :FileTest, :args => :@path
  rb_delegate :size?,            :to => :FileTest, :args => :@path

  # --------------------------------------------------------------------------

  rb_delegate :rm_rf,   :to => :FileUtils, :args => :@path
  rb_delegate :rm_r,    :to => :FileUtils, :args => :@path
  rb_delegate :rm_f,    :to => :FileUtils, :args => :@path
  rb_delegate :rm,      :to => :FileUtils, :args => :@path
  rb_delegate :cp_r,    :to => :FileUtils, :args => :@path
  rb_delegate :touch,   :to => :FileUtils, :args => :@path
  rb_delegate :mkdir_p, :to => :FileUtils, :args => :@path
  rb_delegate :mkpath,  :to => :FileUtils, :args => :@path
  rb_delegate :cp,      :to => :FileUtils, :args => :@path

  # --------------------------------------------------------------------------

  rb_delegate :each_child, :to => :children
  rb_delegate :each_entry, :to => :children
  rb_delegate :to_a,       :to => :children

  # --------------------------------------------------------------------------

  rb_delegate :opendir, :to => :Dir, :alias_of => :open
  rb_delegate :relative?, :to => :self, :alias_of => :absolute?, :bool => :reverse
  rb_delegate :regexp_escape, :to => :Regexp, :args => :@path, :alias_of => :escape
  rb_delegate :to_regexp, :to => :Regexp, :args => :@path, :alias_of => :new
  rb_delegate :shellescape, :to => :Shellwords, :args => :@path
  rb_delegate :mkdir, :to => :Dir, :args => :@path

  # --------------------------------------------------------------------------
  # alias last basename, alias first dirname, alias ext extname
  # --------------------------------------------------------------------------

  alias + join
  alias delete rm
  alias rmtree rm_r
  alias to_path to_s
  alias last basename
  alias entries children
  alias make_symlink symlink
  alias fnmatch fnmatch?
  alias make_link link
  alias first dirname
  alias rmdir rm_r
  alias unlink rm
  alias / join
end