require "crystal/system/path" # A `Path` represents a filesystem path and allows path-handling operations # such as querying its components as well as semantic manipulations. # # A path is hierarchical and composed of a sequence of directory and file name # elements separated by a special separator or delimiter. A root component, # that identifies a file system hierarchy, may also be present. # The name element that is farthest from the root of the directory hierarchy is # the name of a file or directory. The other name elements are directory names. # A `Path` can represent a root, a root and a sequence of names, or simply one or # more name elements. # A `Path` is considered to be an empty path if it consists solely of one name # element that is empty or equal to `"."`. Accessing a file using an empty path # is equivalent to accessing the default directory of the process. # # # Examples # # ``` # Path["foo/bar/baz.cr"].parent # => Path["foo/bar"] # Path["foo/bar/baz.cr"].basename # => "baz.cr" # Path["./foo/../bar"].normalize # => Path["bar"] # Path["~/bin"].expand(home: true) # => Path["/home/crystal/bin"] # ``` # # For now, its methods are purely lexical, there is no direct filesystem access. # # Path handling comes in different kinds depending on operating system: # # * `Path.posix()` creates a new POSIX path # * `Path.windows()` creates a new Windows path # * `Path.new()` means `Path.posix` on POSIX platforms and `Path.windows()` # on Windows platforms. # # ``` # # On POSIX system: # Path.new("foo", "bar", "baz.cr") == Path.posix("foo/bar/baz.cr") # # On Windows system: # Path.new("foo", "bar", "baz.cr") == Path.windows("foo\\bar\\baz.cr") # ``` # # The main differences between Windows and POSIX paths: # * POSIX paths use forward slash (`/`) as only path separator, Windows paths use # backslash (`\`) as default separator but also recognize forward slashes. # * POSIX paths are generally case-sensitive, Windows paths case-insensitive # (see `#<=>`). # * A POSIX path is absolute if it begins with a forward slash (`/`). A Windows path # is absolute if it starts with both a drive and a root (see `#absolute?`). # # ``` # Path.posix("/foo/./bar").normalize # => Path.posix("/foo/bar") # Path.windows("/foo/./bar").normalize # => Path.windows("\\foo\\bar") # # Path.posix("/foo").absolute? # => true # Path.windows("/foo").absolute? # => false # # Path.posix("foo") == Path.posix("FOO") # => false # Path.windows("foo") == Path.windows("FOO") # => true # ``` struct Path include Comparable(Path) class Error < Exception end enum Kind : UInt8 # TODO: Consider adding NATIVE member, see https://github.com/crystal-lang/crystal/pull/5635#issuecomment-441237811 POSIX WINDOWS def self.native : Kind {% if flag?(:win32) %} WINDOWS {% else %} POSIX {% end %} end end # The file/directory separator characters of the current platform. # `{'/'}` on POSIX, `{'\\', '/'}` on Windows. SEPARATORS = separators(Kind.native) # :nodoc: def self.separators(kind) if kind.windows? {'\\', '/'} else {'/'} end end # Creates a new `Path` of native kind. # # When compiling for a windows target, this is equal to `Path.windows()`, # otherwise `Path.posix` is used. def self.new(name : String = "") : Path new(name.check_no_null_byte, Kind.native) end # :ditto: def self.new(path : Path) : Path path.to_native end # :ditto: def self.new(name : String | Path, *parts : String | Path) : Path new(name).join(*parts) end # :ditto: def self.[](name : String | Path, *parts) : Path new(name, *parts) end # :ditto: def self.new(parts : Enumerable) : Path new("").join(parts) end # :ditto: def self.[](parts : Enumerable) : Path new(parts) end # Creates a new `Path` of POSIX kind. def self.posix(name : String = "") : Path new(name.check_no_null_byte, Kind::POSIX) end # :ditto: def self.posix(path : Path) : Path path.to_posix end # :ditto: def self.posix(name : String | Path, *parts : String | Path) : Path posix(name).join(parts) end # :ditto: def self.posix(parts : Enumerable) : Path posix("").join(parts) end # Creates a new `Path` of Windows kind. def self.windows(name : String = "") : Path new(name.check_no_null_byte, Kind::WINDOWS) end # :ditto: def self.windows(path : Path) : Path path.to_windows end # :ditto: def self.windows(name : String | Path, *parts : String | Path) : Path windows(name).join(parts) end # :ditto: def self.windows(parts : Enumerable) : Path windows("").join(parts) end # :nodoc: protected def initialize(@name : String, @kind : Kind) end # Internal helper method to create a new `Path` of the same kind as `self`. private def new_instance(string : String, kind = @kind) : Path Path.new(string, kind) end # Returns `true` if this is a Windows path. def windows? : Bool @kind.windows? end # Returns `true` if this is a POSIX path. def posix? : Bool @kind.posix? end # Returns `true` if this is a native path for the target platform. def native? : Bool @kind == Kind.native end # Returns all components of this path except the last one. # # ``` # Path["/foo/bar/file.cr"].dirname # => "/foo/bar" # ``` def dirname : String return "." if @name.empty? slice = @name.to_slice sep = self.separators.map &.ord pos = slice.size - 1 stage = 0 slice.reverse_each do |byte| is_separator = byte.in? sep # The stages are ordered like this to improve performance # Trailing separators are possible but unlikely (stage 0) # There will probably only be one separator between filename and dirname (stage 2) # There will probably be multiple characters in the filename which need to be skipped (stage 1) case stage when 1 # Wait until separator stage += 1 if is_separator when 2 # Remove trailing separators break unless is_separator when 0 # Wait until past trailing separators stage += 1 unless is_separator end pos -= 1 end case stage when 0 # Path only consists of separators String.new(slice[0, 1]) when 1 # Path has no parent (ex. "hello/", "C:/", "crystal") return anchor.to_s if windows? && (windows_drive? || dos_local_device_path?) "." else # Path has a parent (ex. "a/a", "/home/user//", "C://Users/mmm", "\\wsl.localhost\Debian") if windows? && (anchor = self.anchor) && pos < anchor.to_s.bytesize anchor.to_s else @name.byte_slice(0, {pos, 0}.max + 1) end end end # Returns the parent path of this path. # # If the path is empty, it returns `"."`. If the path is rooted # and in the top-most hierarchy, the root path is returned. # # ``` # Path["foo/bar/file.cr"].parent # => Path["foo/bar"] # Path["foo"].parent # => Path["."] # Path["/foo"].parent # => Path["/"] # Path["/"].parent # => Path["/"] # Path[""].parent # => Path["."] # Path["foo/bar/."].parent # => Path["foo/bar"] # ``` def parent : Path new_instance dirname end # Returns all parent paths of this path beginning with the topmost path. # # ``` # Path["foo/bar/file.cr"].parents # => [Path["."], Path["foo"], Path["foo/bar"]] # ``` def parents : Array(Path) parents = [] of Path each_parent do |parent| parents << parent end parents end # Yields each parent of this path beginning with the topmost parent. # # ``` # Path["foo/bar/file.cr"].each_parent { |parent| puts parent } # # Path["."] # # Path["foo"] # # Path["foo/bar"] # ``` def each_parent(&block : Path ->) return if empty? first_char = @name.char_at(0) unless separators.includes?(first_char) || (first_char == '.' && separators.includes?(@name.byte_at?(1).try &.unsafe_chr)) || (windows? && (windows_drive? || unc_share?)) yield new_instance(".") end pos_memo = nil each_part_separator_index do |start_pos, length| # Delay yielding for each part to avoid yielding for the last part, which # means the entire path. if pos_memo yield new_instance(@name.byte_slice(0, pos_memo)) end pos_memo = start_pos + length end end # Returns the last component of this path. # # If *suffix* is given, it is stripped from the end. # # In case the last component is the empty string (i.e. the path has a trailing # separator), the second to last component is returned. # For a path that only consists of an anchor, or an empty path, the base name # is equivalent to the full path. # # ``` # Path["/foo/bar/file.cr"].basename # => "file.cr" # Path["/foo/bar/"].basename # => "bar" # Path["/foo/bar/."].basename # => "." # Path["/"].basename # => "/" # Path[""].basename # => "" # ``` def basename(suffix : String? = nil) : String suffix.try &.check_no_null_byte return "" if @name.empty? return @name if @name.size == 1 && separators.includes?(@name[0]) bytes = @name.to_slice current = bytes.size - 1 separators = self.separators.map &.ord # skip trailing separators while separators.includes?(bytes[current]) && current > 0 current -= 1 end # read suffix if suffix && suffix.bytesize <= current && suffix == @name.byte_slice(current - suffix.bytesize + 1, suffix.bytesize) current -= suffix.bytesize end # one character left? return @name.byte_slice(0, 1) if current == 0 end_pos = {current, 1}.max # read basename while !separators.includes?(bytes[current]) && current > 0 current -= 1 end start_pos = current + 1 if start_pos == 1 && !separators.includes?(bytes[current]) start_pos = 0 end @name.byte_slice(start_pos, end_pos - start_pos + 1) end # Returns the extension of this path, or an empty string if it has no extension. # # ``` # Path["foo.cr"].extension # => ".cr" # Path["foo"].extension # => "" # Path["foo.tar.gz"].extension # => ".gz" # ``` def extension : String return "" if @name.bytesize < 3 bytes = @name.to_slice separators = self.separators.map &.ord # Ignore trailing separators offset = bytes.size - 1 while bytes.unsafe_fetch(offset).in? separators return "" if offset == 0 offset -= 1 end # Get the first occurrence of a separator or a '.' past the trailing separators dot_index = bytes.rindex(offset: offset) { |byte| byte === '.' || byte.in? separators } # Return "" if '.' is the first character (ex. ".dotfile"), # or if the '.' character follows after a separator (ex. "pathto/.dotfile") # or if the character at the returned index is a separator (ex. "no/extension") # or if the filename ends with a '.' return "" unless dot_index return "" if dot_index == 0 return "" if dot_index == offset return "" if bytes.unsafe_fetch(dot_index - 1).in?(separators) return "" if bytes.unsafe_fetch(dot_index).in?(separators) String.new(bytes[dot_index, offset - dot_index + 1]) end # Returns the last component of this path without the extension. # # This is equivalent to `self.basename(self.extension)`. # # ``` # Path["file.cr"].stem # => "file" # Path["file.tar.gz"].stem # => "file.tar" # Path["foo/file.cr"].stem # => "file" # ``` def stem : String basename(extension) end # Removes redundant elements from this path and returns the shortest equivalent path by purely lexical processing. # It applies the following rules iteratively until no further processing can be done: # # 1. Replace multiple slashes with a single slash. # 2. Eliminate each `.` path name element (the current directory). # 3. Eliminate each `..` path name element (the parent directory) preceded # by a non-`..` element along with the latter. # 4. Eliminate `..` elements that begin a rooted path: # that is, replace `"/.."` by `"/"` at the beginning of a path. # # If the path turns to be empty, the current directory (`"."`) is returned. # # The returned path ends in a slash if it is the root (`"/"`, `\`, or `C:\`), # or if this path is a Windows local device path that also ends in a slash. # This trailing slash is significant; `\\.\C:` refers to the _volume_ `C:`, on # which most I/O functions fail, whereas `\\.\C:\` refers to the root # _directory_ of said volume. # # See also Rob Pike: *[Lexical File Names in Plan 9 or Getting Dot-Dot Right](https://9p.io/sys/doc/lexnames.html)* def normalize(*, remove_final_separator : Bool = true) : Path return new_instance "." if empty? drive, root = drive_and_root reader = Char::Reader.new(@name) dotdot = 0 separators = self.separators add_separator_at_end = (!remove_final_separator || (windows? && dos_local_device_path?)) && ends_with_separator? new_name = String.build do |str| if drive str << drive.gsub('/', '\\') reader.pos += drive.bytesize end if root str << separators[0] reader.next_char dotdot = str.bytesize end anchor_pos = str.bytesize while (char = reader.current_char) != Char::ZERO curr_pos = reader.pos if separators.includes?(char) # empty path element reader.next_char elsif char == '.' && (reader.pos + 1 == @name.bytesize || separators.includes?(reader.peek_next_char)) # . element reader.next_char elsif char == '.' && reader.next_char == '.' && (reader.pos + 1 == @name.bytesize || separators.includes?(reader.peek_next_char)) # .. element: remove to last / reader.next_char if str.bytesize > dotdot str.back 1 while str.bytesize > dotdot && !separators.includes?((str.buffer + str.bytesize).value.unsafe_chr) str.back 1 end elsif !root if str.bytesize > 0 str << separators[0] end str << ".." dotdot = str.bytesize end else reader.pos = curr_pos # make sure to reset lookahead used in previous condition # real path element # add slash if needed if str.bytesize > anchor_pos && !separators.includes?((str.buffer + str.bytesize - 1).value.unsafe_chr) str << separators[0] end loop do str << char char = reader.next_char break if separators.includes?(char) || char == Char::ZERO end end end if str.empty? str << '.' end last_char = (str.buffer + str.bytesize - 1).value.unsafe_chr if add_separator_at_end && !separators.includes?(last_char) str << separators[0] end end new_instance new_name end # Yields each component of this path as a `String`. # # ``` # Path.new("foo/bar/").each_part # yields: "foo", "bar" # ``` # # See `#parts` for more examples. def each_part(& : String ->) each_part_separator_index do |start_pos, length| yield @name.byte_slice(start_pos, length) end end # Returns the components of this path as an `Array(String)`. # # ``` # Path.new("foo/bar/").parts # => ["foo", "bar"] # Path.new("/Users/foo/bar.cr").parts # => ["/", "Users", "foo", "bar.cr"] # Path.windows("C:\\Users\\foo\\bar.cr").parts # => ["C:\\", "Users", "foo", "bar.cr"] # Path.posix("C:\\Users\\foo\\bar.cr").parts # => ["C:\\Users\\foo\\bar.cr"] # ``` def parts : Array(String) parts = [] of String each_part do |part| parts << part end parts end # Returns an iterator over all components of this path. # # ``` # parts = Path.new("foo/bar/").each_part # parts.next # => "foo" # parts.next # => "bar" # parts.next # => Iterator::Stop::INSTANCE # ``` # # See `#parts` for more examples. def each_part : Iterator(String) PartIterator.new(self) end private def each_part_separator_index(&) reader = Char::Reader.new(@name) start_pos = reader.pos if anchor = self.anchor reader.pos = anchor.@name.bytesize # Path is absolute, consume leading separators while separators.includes?(reader.current_char) break unless reader.has_next? reader.next_char end start_pos = reader.pos yield 0, start_pos end last_was_separator = false separators = self.separators while next_part = Path.next_part_separator_index(reader, last_was_separator, separators) reader, last_was_separator, start_pos = next_part break if reader.pos == start_pos yield start_pos, reader.pos - start_pos end end # :nodoc: def self.next_part_separator_index(reader : Char::Reader, last_was_separator, separators) start_pos = reader.pos reader.each do |char| if separators.includes?(char) if last_was_separator next end return reader, true, start_pos elsif last_was_separator start_pos = reader.pos last_was_separator = false end end unless last_was_separator {reader, false, start_pos} end end # :nodoc: class PartIterator include Iterator(String) def initialize(@path : Path) @reader = Char::Reader.new(@path.@name) @last_was_separator = false @anchor_processed = false end def next start_pos = next_pos return stop unless start_pos return stop if start_pos == @reader.pos @path.@name.byte_slice(start_pos, @reader.pos - start_pos) end private def next_pos unless @anchor_processed @anchor_processed = true if anchor_pos = process_anchor return anchor_pos end end next_part = Path.next_part_separator_index(@reader, @last_was_separator, @path.separators) return unless next_part @reader, @last_was_separator, start_pos = next_part start_pos end private def process_anchor anchor = @path.anchor return unless anchor reader = @reader reader.pos = anchor.@name.bytesize # Path is absolute, consume leading separators while @path.separators.includes?(reader.current_char) return unless reader.has_next? reader.next_char end @reader = reader 0 end end private def windows_drive? @name.byte_at?(1) === ':' && @name.char_at(0).ascii_letter? end # Converts this path to a native path. # # * `#to_kind` performs a configurable conversion. def to_native : Path to_kind(Kind.native) end # Converts this path to a Windows path. # # This creates a new instance with the same string representation but with # `Kind::WINDOWS`. If `#windows?` is true, this is a no-op. # # ``` # Path.posix("foo/bar").to_windows # => Path.windows("foo/bar") # Path.windows("foo/bar").to_windows # => Path.windows("foo/bar") # ``` # # When *mappings* is `true` (default), forbidden characters in Windows paths are # substituted by replacement characters when converting from a POSIX path. # Replacements are calculated by adding `0xF000` to their codepoint. # For example, the backslash character `U+005C` becomes `U+F05C`. # # ``` # Path.posix("foo\\bar").to_windows(mappings: true) # => Path.windows("foo\uF05Cbar") # Path.posix("foo\\bar").to_windows(mappings: false) # => Path.windows("foo\\bar") # ``` # # * `#to_posix` performs the inverse conversion. # * `#to_kind` performs a configurable conversion. def to_windows(*, mappings : Bool = true) : Path name = @name if posix? && mappings name = name.tr(WINDOWS_ESCAPE_CHARACTERS, WINDOWS_ESCAPED_CHARACTERS) end new_instance(name, Kind::WINDOWS) end # :nodoc: WINDOWS_ESCAPE_CHARACTERS = %("*:<>?\\| ) # :nodoc: WINDOWS_ESCAPED_CHARACTERS = "\uF022\uF02A\uF03A\uF03C\uF03E\uF03F\uF05C\uF07C\uF020" # Converts this path to a POSIX path. # # It returns a new instance with `Kind::POSIX` and all occurrences of Windows' # backslash file separators (`\\`) replaced by forward slash (`/`). # If `#posix?` is true, this is a no-op. # # ``` # Path.windows("foo/bar\\baz").to_posix # => Path.posix("foo/bar/baz") # Path.posix("foo/bar\\baz").to_posix # => Path.posix("foo/bar\\baz") # ``` # # When *mappings* is `true` (default), replacements for forbidden characters in Windows # paths are substituted by the original characters when converting to a POSIX path. # Originals are calculated by subtracting `0xF000` from the replacement codepoint. # For example, the `U+F05C` becomes `U+005C`, the backslash character. # # ``` # Path.windows("foo\uF05Cbar").to_posix(mappings: true) # => Path.posix("foo\\bar") # Path.windows("foo\uF05Cbar").to_posix(mappings: false) # => Path.posix("foo\uF05Cbar") # ``` # # * `#to_windows` performs the inverse conversion. # * `#to_kind` performs a configurable conversion. def to_posix(*, mappings : Bool = true) : Path name = @name if windows? name = name.gsub('\\', '/') if mappings name = name.tr(WINDOWS_ESCAPED_CHARACTERS, WINDOWS_ESCAPE_CHARACTERS) end end new_instance(name, Kind::POSIX) end # Converts this path to the given *kind*. # # See `#to_windows` and `#to_posix` for details. # # * `#to_native` converts to the native path semantics. def to_kind(kind, *, mappings : Bool = true) : Path if kind.posix? to_posix(mappings: mappings) else to_windows(mappings: mappings) end end # Converts this path to an absolute path. Relative paths are # referenced from the current working directory of the process (`Dir.current`) # unless *base* is given, in which case it will be used as the reference path. # # ``` # Path["foo"].expand # => Path["/current/path/foo"] # Path["~/foo"].expand(home: "/bar") # => Path["/bar/foo"] # Path["baz"].expand("/foo/bar") # => Path["/foo/bar/baz"] # ``` # # *home* specifies the home directory which `~` will expand to. # "~" is expanded to the value passed to *home*. # If it is `false` (default), home is not expanded. # If `true`, it is expanded to the user's home directory (`Path.home`). # # If *expand_base* is `true`, *base* itself will be expanded in `Dir.current` # if it is not an absolute path. This guarantees the method returns an absolute # path (assuming that `Dir.current` is absolute). def expand(base : Path | String = Dir.current, *, home : Path | String | Bool = false, expand_base = true) : Path base = Path.new(base) unless base.is_a?(Path) base = base.to_kind(@kind) if base == self # expanding base, avoid recursion return new_instance(@name).normalize(remove_final_separator: false) end name = @name if home if name == "~" name = resolve_home(home).to_s elsif name.starts_with?("~/") || (windows? && name.starts_with?("~\\")) name = resolve_home(home).join(name.byte_slice(2, name.bytesize - 2)).to_s end end if new_instance(name).absolute? expanded = name else unless base.absolute? || !expand_base base = base.expand end if name.empty? expanded = base elsif windows? base_drive, base_root = base.drive_and_root drive, root = new_instance(name).drive_and_root if drive && base_root base_relative = base_drive ? base.@name.lchop(base_drive) : base.@name expanded = "#{drive}#{base_relative}#{separators[0]}#{name.lchop(drive)}" elsif root if base_drive expanded = "#{base_drive}#{name}" else expanded = name end else if base_root expanded = base.join(name) else expanded = String.build do |io| if drive io << drive elsif base_drive io << base_drive end base_relative = base.@name base_relative = base_relative.lchop(base_drive) if base_drive name_relative = drive ? name.lchop(drive) : name io << base_relative io << separators[0] unless base_relative.empty? io << name_relative end end end else expanded = base.join(name) end end expanded = new_instance(expanded) unless expanded.is_a?(Path) expanded.normalize(remove_final_separator: false) end private def resolve_home(home) case home when String then home = Path[home] when Bool then home = Path.home when Path # no transformation needed end home.to_kind(@kind).normalize end # Appends the given *part* to this path and returns the joined path. # # ``` # Path["foo"].join("bar") # => Path["foo/bar"] # Path["foo/"].join("/bar") # => Path["foo/bar"] # Path["/foo/"].join("/bar/") # => Path["/foo/bar/"] # ``` # # Joining an empty string (`""`) appends a trailing path separator. # In case the path already ends with a trailing separator, no additional # separator is added. # # ``` # Path["a/b"].join("") # => Path["a/b/"] # Path["a/b/"].join("") # => Path["a/b/"] # Path["a/b/"].join("c") # => Path["a/b/c"] # ``` def join(part) : Path # If we are joining a single part we can use `String.new` instead of # `String.build` which avoids an extra allocation. # Given that `File.join(arg1, arg2)` is the most common usage # it's good if we can optimize this case. if part.is_a?(Path) part = part.to_kind(@kind).to_s else part = part.to_s part.check_no_null_byte end if @name.empty? if part.empty? # We could use `separators[0].to_s` but then we'd have to # convert Char to String which involves a memory allocation return new_instance(windows? ? "\\" : "/") else return new_instance(part) end end bytesize = @name.bytesize + part.bytesize # bytesize of the resulting string add_separator = false # do we need to add a separate between the parts? part_ptr = part.to_unsafe # where do we start copying from `part`? part_bytesize = part.bytesize # how much do we copy from `part`? case {ends_with_separator?, starts_with_separator?(part)} when {true, true} # There are separators on both sides so we'll just lchop from the right part bytesize -= 1 part_ptr += 1 part_bytesize -= 1 when {false, false} # No separators on any side so we need to add one bytesize += 1 add_separator = true else # There's at least on separator in the middle, so nothing to do end new_name = String.new(bytesize) do |buffer| # Copy name buffer.copy_from(@name.to_unsafe, @name.bytesize) buffer += @name.bytesize # Add separator if needed if add_separator buffer.value = separators[0].ord.to_u8 buffer += 1 end # Copy the part buffer.copy_from(part_ptr, part_bytesize) {bytesize, @name.single_byte_optimizable? && part.single_byte_optimizable? ? bytesize : 0} end new_instance new_name end # Appends the given *parts* to this path and returns the joined path. # # ``` # Path["foo"].join("bar", "baz") # => Path["foo/bar/baz"] # Path["foo/"].join("/bar/", "/baz") # => Path["foo/bar/baz"] # Path["/foo/"].join("/bar/", "/baz/") # => Path["/foo/bar/baz/"] # ``` # # See `join(part)` for details. def join(*parts) : Path join parts end # Appends the given *parts* to this path and returns the joined path. # # ``` # Path["foo"].join("bar", "baz") # => Path["foo/bar/baz"] # Path["foo/"].join(Path["/bar/", "/baz"]) # => Path["foo/bar/baz"] # Path["/foo/"].join("/bar/", "/baz/") # => Path["/foo/bar/baz/"] # ``` # # Non-matching paths are implicitly converted to this path's kind. # # ``` # Path.posix("foo/bar").join(Path.windows("baz\\baq")) # => Path.posix("foo/bar/baz/baq") # Path.windows("foo\\bar").join(Path.posix("baz/baq")) # => Path.windows("foo\\bar\\baz/baq") # ``` # # See `join(part)` for details. def join(parts : Enumerable) : Path parts.reduce(self) { |path, part| path.join(part) } end # Appends the given *part* to this path and returns the joined path. # # ``` # Path["foo"] / "bar" / "baz" # => Path["foo/bar/baz"] # Path["foo/"] / Path["/bar/baz"] # => Path["foo/bar/baz"] # ``` # # See `join(part)` for details. def /(part : Path | String) : Path join(part) end # Resolves path *name* in this path's parent directory. # # Raises `Path::Error` if `#parent` is `nil`. def sibling(name : Path | String) : Path if parent = self.parent parent.join(name) else raise Error.new("Can't resolve sibling for a path without parent directory") end end private def empty? @name.empty? || @name == "." end # Returns a relative path that is lexically equivalent to `self` when joined # to *base* with an intervening separator. # # The returned path is in normalized form. # # That means with normalized paths `base.join(target.relative_to(base))` is # equivalent to `target`. # # Returns `nil` if `self` cannot be expressed as relative to *base* or if # knowing the current working directory would be necessary to resolve it. The # latter can be avoided by expanding the paths first. # # For Windows paths, the drive and the root must be identical; relative paths # between different path types are not supported, even if they would resolve # to the same roots (e.g. `\\.\C:\foo` and `C:\foo` are not equivalent, nor # are `\\?\UNC\server\share\foo` and `\\server\share\foo`). def relative_to?(base : Path) : Path? # work on normalized paths otherwise we would need to backtrack on `..` parts base = base.normalize target = self.normalize base_anchor = base.anchor target_anchor = target.anchor # if paths have a different anchors, there can't be a relative path between # them. if base_anchor != target_anchor return nil end # check for trivial case of equal paths if base == target return new_instance(".") end base_iterator = base.each_part target_iterator = target.each_part if target_anchor # process anchors, we have already established they're equal base_iterator.next target_iterator.next end # consume both paths simultaneously as long as they have identical components base_part = base_iterator.next target_part = target_iterator.next while base_part.is_a?(String) && target_part.is_a?(String) if base_part.compare(target_part, case_insensitive: windows?) != 0 break end base_part = base_iterator.next target_part = target_iterator.next end parts = [] of String # base_path is not consumed, so we go up before descending into target_path if base_part.is_a?(String) # Can't relativize upwards from current working directory without knowing # its path if base_part == ".." return nil end parts << ".." unless base_part == "." base_iterator.each do parts << ".." end end # target_path is not consumed, so we append what's left to the relative path if target_part.is_a?(String) parts << target_part target_iterator.each do |part| parts << part end end new_instance(parts.join(separators[0])) end # :ditto: def relative_to?(base : String) : Path? relative_to?(new_instance(base)) end # Same as `#relative_to` but returns `self` if `self` can't be expressed as # relative path to *base*. def relative_to(base : Path | String) : Path relative_to?(base) || self end # Compares this path to *other*. # # The comparison is performed strictly lexically: `foo` and `./foo` are *not* # treated as equal. Nor are paths of different `kind`. # To compare paths semantically, they need to be normalized and converted to # the same kind. # # ``` # Path["foo"] <=> Path["foo"] # => 0 # Path["foo"] <=> Path["./foo"] # => 1 # Path["foo"] <=> Path["foo/"] # => -1 # Path.posix("foo") <=> Path.windows("foo") # => -1 # ``` # # Comparison is case-sensitive for POSIX paths and case-insensitive for # Windows paths. # # ``` # Path.posix("foo") <=> Path.posix("FOO") # => 1 # Path.windows("foo") <=> Path.windows("FOO") # => 0 # ``` def <=>(other : Path) ord = @name.compare(other.@name, case_insensitive: windows? || other.windows?) return ord if ord != 0 @kind <=> other.@kind end # Returns `true` if this path is considered equivalent to *other*. # # The comparison is performed strictly lexically: `foo` and `./foo` are *not* # treated as equal. Nor are paths of different `kind`. # To compare paths semantically, they need to be normalized and converted to # the same kind. # # ``` # Path["foo"] == Path["foo"] # => true # Path["foo"] == Path["./foo"] # => false # Path["foo"] == Path["foo/"] # => false # Path.posix("foo") == Path.windows("foo") # => false # ``` # # Comparison is case-sensitive for POSIX paths and case-insensitive for # Windows paths. # # ``` # Path.posix("foo") == Path.posix("FOO") # => false # Path.windows("foo") == Path.windows("FOO") # => true # ``` def ==(other : self) return false if @kind != other.@kind @name.compare(other.@name, case_insensitive: windows? || other.windows?) == 0 end def hash(hasher) name = @name if windows? name = name.downcase end hasher = name.hash(hasher) @kind.hash(hasher) end # Returns a path representing the drive component or `nil` if this path does not contain a drive. # # See `#anchor` for the combination of drive and `#root`. # # ``` # Path.windows("C:\\Program Files").drive # => Path.windows("C:") # Path.windows("\\\\host\\share\\folder").drive # => Path.windows("\\\\host\\share") # Path.windows("\\\\.\\NUL").drive # => Path.windows("\\\\.") # Path.windows("//?").drive # => Path.windows("//?") # ``` # # NOTE: Drives are only available for Windows paths. It can be a drive letter # (`C:`), a UNC share (`\\host\share`), or a root local device path (`\\.`, # `\\?`). def drive : Path? drive_end, _ = drive_and_root_indices if drive_end drive = @name.byte_slice(0, drive_end) new_instance drive end end # Returns the root path component of this path or `nil` if it is not rooted. # # See `#anchor` for the combination of `#drive` and root. # # ``` # Path["/etc/"].root # => Path["/"] # Path.windows("C:Program Files").root # => nil # Path.windows("C:\\Program Files").root # => Path.windows("\\") # Path.windows("\\\\host\\share\\folder").root # => Path.windows("\\") # Path.windows("//./NUL").root # => Path.windows("/") # Path.windows("\\\\?").root # => nil # ``` def root : Path? drive_end, root_end = drive_and_root_indices if root_end root_start = drive_end || 0 root = @name.byte_slice(root_start, root_end - root_start) new_instance root end end # Returns the concatenation of `#drive` and `#root`. # # ``` # Path["/etc/"].anchor # => Path["/"] # Path.windows("C:Program Files").anchor # => Path.windows("C:") # Path.windows("C:\\Program Files").anchor # => Path.windows("C:\\") # Path.windows("\\\\host\\share\\folder").anchor # => Path.windows("\\\\host\\share\\") # Path.windows("\\\\.\\NUL").anchor # => Path.windows("\\\\.\\") # Path.windows("//?").anchor # => Path.windows("//?") # ``` def anchor : Path? drive_end, root_end = drive_and_root_indices anchor_end = root_end || drive_end if anchor_end new_instance(@name.byte_slice(0, anchor_end)) end end # Returns a tuple of `#drive` and `#root` as strings. def drive_and_root : {String?, String?} drive_end, root_end = drive_and_root_indices if drive_end drive = @name.byte_slice(0, drive_end) end if root_end root_start = drive_end || 0 root = @name.byte_slice(root_start, root_end - root_start) end {drive, root} end private def drive_and_root_indices : {Int32?, Int32?} if windows? if windows_drive? if separators.includes?(@name.byte_at?(2).try(&.chr)) {2, 3} else {2, nil} end elsif dos_local_device_path? if separators.includes?(@name.byte_at?(3).try(&.chr)) {3, 4} else {3, nil} end elsif unc_share = unc_share? unc_share elsif starts_with_separator? {nil, 1} else {nil, nil} end elsif absolute? # posix {nil, 1} else {nil, nil} end end private def dos_local_device_path? # `//./`, `\\?` etc. @name.size >= 3 && separators.includes?(@name.to_unsafe[0].unsafe_chr) && separators.includes?(@name.to_unsafe[1].unsafe_chr) && {'.', '?'}.includes?(@name.to_unsafe[2].unsafe_chr) end private def unc_share? # Test for UNC share # path: //share/share # part: 1122222 33333 # Grammar definition: https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-dtyp/62e862f4-2a51-452e-8eeb-dc4ff5ee33cc?redirectedfrom=MSDN return unless @name.size >= 5 reader = Char::Reader.new(@name) # 1. Consume two leading separators char = reader.current_char return unless separators.includes?(char) && char == reader.next_char reader.next_char # 2. Consume first path component # The first component is either an IPv4 address or a hostname. # IPv6 addresses are converted into hostnames by replacing all `:`s with # `-`s, and then appending `.ipv6-literal.net`, so raw IPv6 addresses cannot # appear here. # Hostname follows the grammar of `reg-name` in [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). return if separators.includes?(reader.current_char) while true char = reader.current_char break if separators.includes?(char) if char == '%' # percent encoded character return unless reader.has_next? reader.next_char return unless reader.current_char.ascii_number? return unless reader.has_next? reader.next_char return unless reader.current_char.ascii_number? else # unreserved / sub-delims return unless char.ascii_alphanumeric? || char.in?('_', '.', '-', '~', '!', '$', ';', '=') || char.in?('&'..',') end return unless reader.has_next? reader.next_char end # Consume separator char = reader.next_char return if separators.includes?(char) return unless reader.has_next? reader.next_char # 3. Consume second path component # `share-name` in UNC grammar while true char = reader.current_char break if separators.includes?(char) || !reader.has_next? return unless char.ascii_alphanumeric? || char.in?(' ', '!', '-', '.', '@', '^', '_', '`', '{', '}', '~') || char.in?('#'..')') || char.ord.in?(0x80..0xFF) reader.next_char end # Consume optional trailing separators share_end = reader.pos while reader.has_next? char = reader.next_char break unless separators.includes?(char) end unless reader.pos == share_end root_end = reader.pos end return share_end, root_end end # Returns `true` if this path is absolute. # # A POSIX path is absolute if it begins with a forward slash (`/`). # # A Windows path is absolute if it begins with a drive letter (`C:`), a UNC # share (`\\server\share`), or a root local device path (`\\.`, `\\?`), which # is then followed by a root path separator. Drive-relative paths (`C:foo`), # rooted paths (`\foo`), and root local device paths (`\\.`) are not absolute. def absolute? : Bool separators = self.separators if windows? first_is_separator = false starts_with_double_separator = false found_share_name = false found_dot_or_question_mark = false @name.each_char_with_index do |char, index| case index when 0 if separators.includes?(char) first_is_separator = true else return false unless char.ascii_letter? end when 1 if first_is_separator && separators.includes?(char) starts_with_double_separator = true else return false unless char == ':' end else case char when .in?(separators) if index == 2 return !starts_with_double_separator && !found_share_name elsif index == 3 && found_dot_or_question_mark return true elsif found_share_name return true else found_share_name = true end when '.', '?' if index == 2 found_dot_or_question_mark = true end end end end false else separators.includes?(@name[0]?) end end # Returns `true` if this path is relative. # # See `#absolute?` def relative? : Bool !absolute? end # :nodoc: def separators Path.separators(@kind) end def ends_with_separator? : Bool ends_with_separator?(@name) end private def ends_with_separator?(name) separators.any? { |separator| name.ends_with?(separator) } end private def starts_with_separator?(name = @name) separators.any? { |separator| name.starts_with?(separator) } end # Returns the string representation of this path. def to_s : String @name end # Appends the string representation of this path to *io*. def to_s(io : IO) io << to_s end # Inspects this path to *io*. def inspect(io : IO) if native? io << "Path[" @name.inspect(io) io << ']' else io << "Path." io << (windows? ? "windows" : "posix") io << '(' @name.inspect(io) io << ')' end end # Returns a new `URI` with `file` scheme from this path. # # A URI can only be created with an absolute path. Raises `Path::Error` if # this path is not absolute. def to_uri : URI raise Error.new("Cannot create a URI from relative path") unless absolute? URI.new(scheme: "file", path: @name) end # Returns the path of the home directory of the current user. def self.home : Path new(Crystal::System::Path.home) end end