# # TODOs: # * Relative paths # * Fix Path#/: If it contains glob chars, but a file already exists with that name, assume we're not globbing (fixes problems with {}, [], etc.) require 'epitools/minimal' require 'epitools/core_ext/enumerable' require 'epitools/core_ext/string' # # Path: An object-oriented wrapper for files. (Combines useful methods from FileUtils, File, Dir, and more!) # # To create a path object, or array of path objects, throw whatever you want into Path[]: # # These returns a single path object: # passwd = Path["/etc/passwd"] # also_passwd = Path["/etc"] / "passwd" # joins two paths # parent_dir = Path["/usr/local/bin"] / ".." # joins two paths (up one dir) # # These return an array of path objects: # pictures = Path["photos/*.{jpg,png}"] # globbing # notes = Path["notes/2014/**/*.txt"] # recursive globbing # everything = Path["/etc"].ls # # Each Path object has the following attributes, which can all be modified: # # path => the absolute path, as a string # filename => just the name and extension # basename => just the filename (without extension) # ext => just the extension # dir => just the directory # dirs => an array of directories # # Some commonly used methods: # # path.file? # path.exists? # path.dir? # path.mtime # path.xattrs # path.symlink? # path.broken_symlink? # path.symlink_target # path.executable? # path.chmod(0o666) # # Interesting examples: # # Path["*.jpeg"].each { |path| path.rename(:ext=>"jpg") } # renames .jpeg to .jpg # # files = Path["/etc"].ls # all files in directory # morefiles = Path["/etc"].ls_R # all files in directory tree # # Path["*.txt"].each(&:gzip!) # # Path["filename.txt"] << "Append data!" # appends data to a file # # string = Path["filename.txt"].read # read all file data into a string # json = Path["filename.json"].read_json # read and parse JSON # doc = Path["filename.html"].read_html # read and parse HTML # xml = Path["filename.xml"].parse # figure out the format and parse it (as XML) # # Path["saved_data.marshal"].write(data.marshal) # Save your data! # data = Path["saved_data.marshal"].unmarshal # Load your data! # # Path["unknown_file"].mimetype # sniff the file to determine its mimetype # Path["unknown_file"].mimetype.image? # ...is this some kind of image? # # Path["otherdir/"].cd do # temporarily change to "otherdir/" # p Path.ls # end # p Path.ls # # The `Path#dirs` attribute is a split up version of the directory # (eg: Path["/usr/local/bin"].dirs => ["usr", "local", "bin"]). # # You can modify the dirs array to change subsets of the directory. Here's an example that # finds out if you're in a git repo: # # def inside_a_git_repo? # path = Path.pwd # start at the current directory # while path.dirs.any? # if (path/".git").exists? # return true # else # path.dirs.pop # go up one level # end # end # false # end # # Swap two files: # # a, b = Path["file_a", "file_b"] # temp = a.with(:ext => a.ext+".swapping") # return a modified version of this object # a.mv(temp) # b.mv(a) # temp.mv(b) # # Paths can be created for existant and non-existant files. # # To create a nonexistant path object that thinks it's a directory, just add a '/' at the end. # (eg: Path["/etc/nonexistant/"]). # # Performance has been an important factor in Path's design, so doing crazy things with Path # usually doesn't kill performance. Go nuts! # # class Path # The directories in the path, split into an array. (eg: ['usr', 'src', 'linux']) attr_reader :dirs # The filename without an extension attr_reader :base # The file extension, including the . (eg: ".mp3") attr_reader :ext ############################################################################### # Initializers ############################################################################### def initialize(newpath, hints={}) self.send("path=", newpath, hints) end def initialize_copy(other) @dirs = other.dirs && other.dirs.dup @base = other.base && other.base.dup @ext = other.ext && other.ext.dup end def self.glob(str, hints={}) Dir[str].map { |entry| new(entry, hints) } end def self.[](path) case path when Path path when String if path =~ %r{^[a-z\-]+://}i # URL? Path::URL.new(path) elsif path =~ /^javascript:/ Path::JS.new(path) else # todo: highlight backgrounds of codeblocks to show indent level & put boxes (or rules?) around (between?) double-spaced regions path = Path.expand_path(path) if path =~ /(^|[^\\])[\?\*\{\}]/ # contains unescaped glob chars? glob(path) else new(path) end end end end ############################################################################### # Setters ############################################################################### attr_writer :base attr_writer :dirs # # This is the core that initializes the whole class. # # Note: The `hints` parameter contains options so `path=` doesn't have to touch the filesytem as much. # def path=(newpath, hints={}) if hints[:type] or File.exists? newpath if hints[:type] == :dir or File.directory? newpath self.dir = newpath else self.dir, self.filename = File.split(newpath) end else if newpath.endswith(File::SEPARATOR) # ends in '/' self.dir = newpath else self.dir, self.filename = File.split(newpath) end end # FIXME: Make this work with globs. if hints[:relative] update(relative_to(Path.pwd)) elsif hints[:relative_to] update(relative_to(hints[:relative_to])) end end def filename=(newfilename) if newfilename.nil? @ext, @base = nil, nil else ext = File.extname(newfilename) if ext.blank? @ext = nil @base = newfilename else self.ext = ext if pos = newfilename.rindex(ext) @base = newfilename[0...pos] end end end end def dir=(newdir) dirs = File.expand_path(newdir).split(File::SEPARATOR) dirs = dirs[1..-1] if dirs.size > 0 @dirs = dirs end # TODO: Figure out how to fix the 'path.with(:ext=>ext+".other")' problem (when 'ext == nil')... def ext=(newext) if newext.blank? @ext = nil return end newext = newext[1..-1] if newext.startswith('.') if newext['.'] self.filename = basename + '.' + newext else @ext = newext end end # # Clear out the internal state of this object, so that it can be reinitialized. # def reset! [:@dirs, :@base, :@ext].each { |var| remove_instance_variable(var) rescue nil } self end # # Reload this path (updates cached values.) # def reload! temp = path reset! self.path = temp self end def update(other) @dirs = other.dirs @base = other.base @ext = other.ext end ## getters # Joins and returns the full path def path if d = dir File.join(d, (filename || "") ) else "" end end # # Is this a relative path? # def relative? # FIXME: Need a Path::Relative subclass, so that "dir/filename" can be valid. # (If the user changes dirs, the relative path should change too.) dirs.first == ".." end # # Path relative to current directory (Path.pwd) # def relative relative_to(pwd) end def relative_to(anchor) # stage 1: go ".." until we find a common dir prefix # (discard everything and go '/' if there's no common dir) # stage 2: append the rest of the other path first_mismatch = dirs.zip(anchor.dirs).index { |a,b| a != b } num_dots = anchor.dirs.size - first_mismatch result = self.dup result.dirs = ([".."] * num_dots) + dirs[first_mismatch..-1] result end # The current directory (with a trailing /) def dir if dirs if relative? File.join(*dirs) else File::SEPARATOR + File.join(*dirs) end else nil end end def filename if base if ext base + "." + ext else base end else nil end end alias_method :name, :filename def exts extensions = basename.split('.')[1..-1] extensions += [@ext] if @ext extensions end ############################################################################### # inspect ############################################################################### def inspect "#" end ############################################################################### # fstat ############################################################################### def exists? File.exists? path end def size File.size path end def lstat @lstat ||= File.lstat self # to cache, or not to cache? that is the question. # File.lstat self # ...answer: not to cache! end def mode lstat.mode end def mtime lstat.mtime end def mtime=(new_mtime) File.utime(atime, new_mtime, path) @lstat = nil new_mtime end def ctime lstat.ctime end def atime lstat.atime end def atime=(new_atime) File.utime(new_atime, mtime, path) @lstat = nil new_atime end # FIXME: Does the current user own this file? def owner? raise "STUB" end def executable? mode & 0o111 > 0 end alias_method :exe?, :executable? def writable? mode & 0o222 > 0 end def readable? mode & 0o444 > 0 end def dir? File.directory? path end def file? File.file? path end def symlink? File.symlink? path end def broken_symlink? File.symlink?(path) and not File.exists?(path) end def symlink_target Path[dir] / File.readlink(path.gsub(/\/$/, '')) end alias_method :readlink, :symlink_target alias_method :target, :symlink_target def uri? false end def url? uri? end def child_of?(parent) parent.parent_of? self end def parent_of?(child) dirs == child.dirs[0...dirs.size] end ############################################################################### # Comparisons ############################################################################### include Comparable def <=>(other) case other when Path self.path <=> other.path when String self.path == other else raise "Invalid comparison: Path to #{other.class}" end end def ==(other) self.path == other.to_s end ############################################################################### # Joining paths ############################################################################### # # Path["/etc"].join("anything{}").path == "/etc/anything{}" # (globs ignored) # def join(other) Path.new File.join(self, other) end # # Path["/etc"]/"passwd" == Path["/etc/passwd"] # (globs permitted) # def /(other) # / <- fixes jedit syntax highlighting bug. # TODO: make it work for "/dir/dir"/"/dir/file" #Path.new( File.join(self, other) ) Path[ File.join(self, other) ] end ############################################################################### # Xattrs ############################################################################### # # Read xattrs from file (requires "getfattr" to be in the path) # def self.getfattr(path) # # file: Scissor_Sisters_-_Invisible_Light.flv # user.m.options="-c" cmd = %w[getfattr -d -m - -e base64] + [path] attrs = {} IO.popen(cmd, "rb", :err=>[:child, :out]) do |io| io.each_line do |line| if line =~ /^([^=]+)=0s(.+)/ key = $1 value = $2.from_base64 # unpack base64 string value = value.encode("UTF-8", "UTF-8") # set string's encoding to UTF-8 attrs[key] = value end end end attrs end # # Set xattrs on a file (requires "setfattr" to be in the path) # def self.setfattr(path, key, value) cmd = %w[setfattr] if value == nil # delete cmd += ["-x", key] else # set cmd += ["-n", key, "-v", value] end cmd << path IO.popen(cmd, "rb", :err=>[:child, :out]) do |io| result = io.each_line.to_a error = {:cmd => cmd, :result => result.to_s}.inspect raise error if result.any? end end # # Return a hash of all of this file's xattrs. # (Metadata key=>valuse pairs, supported by most modern filesystems.) # def attrs @attrs ||= Path.getfattr(path) end alias_method :xattrs, :attrs # # Set this file's xattrs. (Optimized so that only changed attrs are written to disk.) # def attrs=(new_attrs) changes = attrs.diff(new_attrs) changes.each do |key, (old, new)| case new when String, Numeric, true, false, nil self[key] = new else raise "Error: Can't use a #{new.class} as an xattr value. Try passing a String." end end end # # Retrieve one of this file's xattrs # def [](key) attrs[key] end # # Set this file's xattr # def []=(key, value) Path.setfattr(path, key, value) @attrs = nil end ############################################################################### # Opening/Reading files ############################################################################### def open(mode="rb", &block) if block_given? File.open(path, mode, &block) else File.open(path, mode) end end alias_method :io, :open alias_method :stream, :open def read(length=nil, offset=nil) File.read(path, length, offset) end # # All the lines in this file, chomped. # def each_line io.each_line end alias_method :lines, :each_line def grep(pat) return to_enum(:grep, pat).to_a unless block_given? each_line do |line| yield line if line =~ pat end end def nicelines lines.map(&:chomp) end def unmarshal read.unmarshal end def ls; Path[File.join(path, "*")]; end def ls_r; Path[File.join(path, "**/*")]; end def ls_dirs ls.select(&:dir?) #Dir.glob("#{path}*/", File::FNM_DOTMATCH).map { |s| Path.new(s, :type=>:dir) } end def ls_files ls.select(&:file?) #Dir.glob("#{path}*", File::FNM_DOTMATCH).map { |s| Path.new(s, :type=>:file) } end def siblings ls - [self] end def touch open("a") { } self end ############################################################################### # Modifying files ############################################################################### # # Append data to this file (accepts a string, an IO, or it can yield the file handle to a block.) # def append(data=nil) self.open("ab") do |f| if data and not block_given? if data.is_an? IO IO.copy_stream(data, f) else f.write(data) end else yield f end end self end alias_method :<<, :append # # Overwrite the data in this file (accepts a string, an IO, or it can yield the file handle to a block.) # def write(data=nil) self.open("wb") do |f| if data and not block_given? if data.is_an? IO IO.copy_stream(data, f) else f.write(data) end else yield f end end end ############################################################################### # Parsing files ############################################################################### # # Parse the file based on the file extension. # (Handles json, html, yaml, xml, bson, and marshal.) # def parse case ext.downcase when 'json' read_json when 'html', 'htm' read_html when 'yaml', 'yml' read_yaml when 'xml', 'rdf', 'rss' read_xml when 'csv' read_csv when 'marshal' read_marshal when 'bson' read_bson else raise "Unrecognized format: #{ext}" end end # Parse the file as JSON def read_json JSON.load(io) end alias_method :from_json, :read_json # Convert the object to JSON and write it to the file (overwriting the existing file). def write_json(object) write object.to_json end def read_html Nokogiri::HTML(io) end alias_method :from_html, :read_html # Convert the object to YAML and write it to the file (overwriting the existing file). def write_yaml(object) write object.to_yaml end # Parse the file as YAML def read_yaml YAML.load(io) end alias_method :from_yaml, :read_yaml # Parse the file as CSV def read_csv(opts={}) CSV.open(io, opts).each end alias_method :from_csv, :read_csv def read_xml Nokogiri::XML(io) end def read_marshal Marshal.load(io) end def write_marshal(object) write object.marshal end def read_bson BSON.deserialize(read) end def write_bson(object) write BSON.serialize(object) end # # Change into the directory. If a block is given, it changes into # the directory for the duration of the block, then puts you back where you # came from once the block is finished. # def cd(&block) Path.cd(path, &block) end # # A private method for handling arguments to mv and rename. # def arg_to_path(arg) case arg when String, Path Path[arg] when Hash self.with(arg) else raise "Error: argument must be a path (a String or a Path), or a hash of attributes to replace in the Path." end end private :arg_to_path # # Renames the file, but doesn't change the current Path object, and returns a Path that points at the new filename. # # Examples: # Path["file"].rename("newfile") #=> Path["newfile"] # Path["SongySong.mp3"].rename(:basename=>"Songy Song") # Path["Songy Song.mp3"].rename(:ext=>"aac") # Path["Songy Song.aac"].rename(:dir=>"/music2") # Path["/music2/Songy Song.aac"].exists? #=> true # def rename(arg) dest = arg_to_path(arg) raise "Error: destination (#{dest.inspect}) already exists" if dest.exists? raise "Error: can't rename #{self.inspect} because source location doesn't exist." unless exists? File.rename(path, dest) dest end alias_method :ren, :rename # # Works the same as "rename", but the destination can be on another disk. # def mv(arg) dest = arg_to_path(arg) raise "Error: can't move #{self.inspect} because source location doesn't exist." unless exists? FileUtils.mv(path, dest) dest end alias_method :move, :mv # # Rename the file and change this Path object so that it points to the destination file. # def rename!(arg) update(rename(arg)) end alias_method :ren!, :rename! # # Moves the file (overwriting the destination if it already exists). Also points the current Path object at the new destination. # def mv!(arg) update(mv(arg)) end alias_method :move!, :mv! # # Find a backup filename that doesn't exist yet by appending "(1)", "(2)", etc. to the current filename. # def numbered_backup_file return self unless exists? n = 1 loop do new_file = with(:basename => "#{basename} (#{n})") return new_file unless new_file.exists? n += 1 end end # # Return a copy of this Path with ".bak" at the end # def backup_file with(:filename => filename+".bak") end # # Rename this file, "filename.ext", to "filename (1).ext" (or (2), or (3), or whatever number is available.) # (Does not modify this Path object.) # def numbered_backup! rename(numbered_backup_file) end # # Rename this file, "filename.ext", to "filename.ext.bak". # (Does not modify this Path object.) # def backup! rename(backup_file) end # # Generate two almost identical methods: mkdir and mkdir_p # { :mkdir => "Dir.mkdir", :mkdir_p =>"FileUtils.mkdir_p" }.each do |method, command| class_eval %{ def #{method} if exists? if directory? reload! else raise "Error: A file by this name already exists." end else #{command} path # Make the directory reload! self end end } end def cp_r(dest) FileUtils.cp_r(path, dest) #if Path[dest].exists? dest end def cp(dest) FileUtils.cp(path, dest) dest end def ln_s(dest) Path.ln_s(self, dest) end ## Owners and permissions def chmod(mode) FileUtils.chmod(mode, self) self end def chown(usergroup) user, group = usergroup.split(":") FileUtils.chown(user, group, self) self end def chmod_R(mode) if directory? FileUtils.chmod_R(mode, self) self else raise "Not a directory." end end def chown_R(usergroup) user, group = usergroup.split(":") if directory? FileUtils.chown_R(user, group, self) self else raise "Not a directory." end end ## Dangerous methods. def rm if directory? and not symlink? Dir.rmdir(self) == 0 else File.unlink(self) == 1 end end alias_method :delete!, :rm alias_method :unlink!, :rm alias_method :remove!, :rm def truncate(offset=0) File.truncate(self, offset) if exists? end ## Checksums def sha1 Digest::SHA1.file(self).hexdigest end def sha2 Digest::SHA2.file(self).hexdigest end def md5 Digest::MD5.file(self).hexdigest end alias_method :md5sum, :md5 ## http://ruby-doc.org/stdlib/libdoc/zlib/rdoc/index.html # # gzip the file, returning the result as a string # def gzip(level=nil) Zlib.deflate(read, level) end # # gunzip the file, returning the result as a string # def gunzip(level=nil) Zlib.inflate(read, level) end # # Quickly gzip a file, creating a new .gz file, without removing the original, # and returning a Path to that new file. # def gzip!(level=nil) gz_file = self.with(:filename=>filename+".gz") raise "#{gz_file} already exists" if gz_file.exists? open("rb") do |input| Zlib::GzipWriter.open(gz_file) do |gzwriter| IO.copy_stream(input, gzwriter) end end update(gz_file) end # # Quickly gunzip a file, creating a new file, without removing the original, # and returning a Path to that new file. # def gunzip! raise "Not a .gz file" unless ext == "gz" regular_file = self.with(:ext=>nil) regular_file.open("wb") do |output| Zlib::GzipReader.open(self) do |gzreader| IO.copy_stream(gzreader, output) end end update(regular_file) end # # Match the full path against a regular expression # def =~(pattern) to_s =~ pattern end # # Find the parent directory. If the `Path` is a filename, it returns the containing directory. # def parent if file? with(:filename=>nil) else with(:dirs=>dirs[0...-1]) end end # # Follows all symlinks to give the true location of a path. # if File.respond_to?(:realpath) def realpath Path.new File.realpath(path) end else def realpath require 'pathname' Path.new Pathname.new(path).realpath end end # # Find the file's mimetype (first from file extension, then by magic) # def mimetype mimetype_from_ext || magic end alias_method :identify, :mimetype # # Find the file's mimetype (only using the file extension) # def mimetype_from_ext MimeMagic.by_extension(ext) end # # Find the file's mimetype (by magic) # def magic open { |io| MimeMagic.by_magic(io) } end # # Returns the filetype (as a standard file extension), verified with Magic. # # (In other words, this will give you the *true* extension, even if the file's # extension is wrong.) # # Note: Prefers long extensions (eg: jpeg over jpg) # # TODO: rename type => magicext? # def type @cached_type ||= begin if file? or symlink? ext = self.ext magic = self.magic if ext and magic if magic.extensions.include? ext ext else magic.ext # in case the supplied extension is wrong... end elsif !ext and magic magic.ext elsif ext and !magic ext else # !ext and !magic :unknown end elsif dir? :directory end end end ## aliases alias_method :to_path, :path alias_method :to_str, :path alias_method :to_s, :path alias_method :pathname, :path alias_method :basename, :base alias_method :basename=, :base= alias_method :extname, :ext alias_method :extname=, :ext= alias_method :dirname, :dir alias_method :dirname=, :dir= alias_method :extension, :ext alias_method :extension=, :ext= alias_method :directory, :dir alias_method :directory=, :dir= alias_method :directory?, :dir? alias_method :exist?, :exists? ############################################################################ ## Class Methods # # FileUtils-like class-method versions of instance methods # (eg: `Path.mv(src, dest)`) # # Note: Methods with cardinality 1 (`method/1`) are instance methods that take # one parameter, and hence, class methods that take two parameters. # AUTOGENERATED_CLASS_METHODS = %w[ mkdir mkdir_p sha1 sha2 md5 rm truncate realpath mv/1 move/1 chmod/1 chown/1 chown_R/1 chmod_R/1 ].each do |spec| method, cardinality = spec.split("/") cardinality = cardinality.to_i class_eval %{ def self.#{method}(path#{", *args" if cardinality > 0}) Path[path].#{method}#{"(*args)" if cardinality > 0} end } end # # Same as File.expand_path, except preserves the trailing '/'. # def self.expand_path(orig_path) new_path = File.expand_path orig_path new_path << "/" if orig_path.endswith "/" new_path end # # TODO: Remove the tempfile when the Path object is garbage collected or freed. # def self.tmpfile(prefix="tmp") path = Path[ Tempfile.new(prefix).path ] yield path if block_given? path end alias_class_method :tempfile, :tmpfile alias_class_method :tmp, :tmpfile def self.home Path[ENV['HOME']] end def self.pwd Path.new expand_path(Dir.pwd) end def self.pushd @@dir_stack ||= [] @@dir_stack.push pwd end def self.popd @@dir_stack ||= [pwd] @@dir_stack.pop end # # Change into the directory "dest". If a block is given, it changes into # the directory for the duration of the block, then puts you back where you # came from once the block is finished. # def self.cd(dest, &block) dest = Path[dest] raise "Can't 'cd' into #{dest}" unless dest.dir? if block_given? orig = pwd Dir.chdir(dest) result = yield dest Dir.chdir(orig) result else Dir.chdir(dest) dest end end def self.ls(path); Path[path].ls end def self.ls_r(path); Path[path].ls_r; end def self.ln_s(src, dest) FileUtils.ln_s(src, dest) Path[dest] end ## TODO: Verbose mode #def self.verbose=(value); @@verbose = value; end #def self.verbose; @@verbose ||= false; end if Sys.windows? PATH_SEPARATOR = ";" BINARY_EXTENSION = ".exe" else PATH_SEPARATOR = ":" BINARY_EXTENSION = "" end # # A clone of `/usr/bin/which`: pass in the name of a binary, and it'll search the PATH # returning the absolute location of the binary if it exists, or `nil` otherwise. # # (Note: If you pass more than one argument, it'll return an array of `Path`s instead of # a single path.) # def self.which(bin, *extras) if extras.empty? ENV["PATH"].split(PATH_SEPARATOR).find do |path| result = (Path[path] / (bin + BINARY_EXTENSION)) return result if result.exists? end nil else ([bin] + extras).map { |bin| which(bin) } end end # # No-op (returns self) # def to_Path self end end class Path::Relative < Path # FIXME: Implement this. end # # A wrapper for URL objects. # class Path::URL < Path attr_reader :uri # # TODO: only include certain methods from Path (delegate style) # (eg: remove commands that write) def initialize(uri, hints={}) @uri = URI.parse(uri) self.path = @uri.path end def uri? true end # # Example: # # When this is: http://host.com:port/path/filename.ext?param1=value1¶m2=value2&... # def to_s; uri.to_s; end # # ...this is: 'http' # def scheme; uri.scheme; end alias_method :protocol, :scheme # # ...and this is: 'host.com' # def host; uri.host; end # # ...and this is: 80 # def port; uri.port; end # # ...and this is: {param1: value1, param2: value2, ...etc... } # def query if query = uri.query query.to_params else nil end end def join(other) Path.new URI.join(path, other).to_s end # ...and `path` is /path/filename.ext def open(mode="r", &block) require 'open-uri' if block_given? open(to_s, mode, &block) else open(to_s, mode) end end # Note: open is already aliased to io in parent class. def read(*args) require 'open-uri' case scheme when /https?/i io.read(*args) else raise "No connector for #{scheme} yet. Please fix!" end end end