class Path
Represents a URL path without losing its encoded segment boundaries.
String input is interpreted as an encoded URL path. A literal / is structural,
while %2F remains encoded data within a single segment. Decoding is explicit and
controlled by the encoding object passed to Protocol::URL::Path#components.
Definitions
SEPARATOR = "/"
The path separator.
def self.[](path)
Coerce an encoded string or encoded segment array into a path.
Signature
-
parameter
pathString | Array(String) | Path The encoded value to coerce.
-
returns
Path The coerced path, or the existing path unchanged.
Implementation
def self.[](path)
if path.is_a?(self)
return path
elsif path.is_a?(Array)
return self.new(nil, path)
else
return self.new(path.to_s)
end
end
def self.for(components, encoding: Encoding)
Construct a path from decoded components.
Each component is escaped independently, so decoded / characters remain data
inside one encoded segment rather than becoming structural separators.
Signature
-
parameter
componentsArray(String) The decoded path components.
-
parameter
encodingObject An object implementing
escape(String).-
returns
Path The encoded path.
-
raises
ArgumentError If the encoding does not produce one valid encoded segment per component.
Implementation
def self.for(components, encoding: Encoding)
segments = components.map do |component|
segment = encoding.escape(component)
unless segment.is_a?(String) && !segment.include?(SEPARATOR)
raise ArgumentError, "Path encoding produced an invalid segment!"
end
segment
end
return self.new(nil, segments)
end
def self.relative(target, from)
Calculate the relative path from one absolute path to another.
This is useful for generating relative URLs from one location to another, such as creating page-specific import maps or relative links.
Example: Calculate relative path between pages.
Path.relative("/_components/app.js", "/foo/bar/")
# => "../../_components/app.js"
Example: Calculate relative path in same directory.
Path.relative("/docs/guide.html", "/docs/index.html")
# => "guide.html"
Signature
-
parameter
targetString The destination path (where you want to go).
-
parameter
fromString The source path (where you are starting from).
-
returns
String The relative path from
fromtotarget.
Implementation
def self.relative(target, from)
return Path[target].relative(from).to_s
end
def relative(from)
Calculate this path relative to another path.
Signature
-
parameter
fromString | Array(String) | Path The source path.
-
returns
Path The relative path from
fromto this path.
Implementation
def relative(from)
target_segments = self.segments
from_segments = Path[from].segments
# Remove the last component from 'from' to get the directory
from_segments = from_segments[0...-1] if from_segments.size > 0
# Find the common prefix
common_length = 0
[target_segments.size, from_segments.size].min.times do |i|
break if target_segments[i] != from_segments[i]
common_length = i + 1
end
# Preserve the final segment when the target names the containing directory as a file:
if common_length > 0 && common_length == target_segments.size && target_segments.last != ""
common_length -= 1
end
# Calculate how many levels to go up
up_levels = from_segments.size - common_length
# Build the relative path segments
relative_segments = [".."] * up_levels + target_segments[common_length..-1]
# An empty reference identifies the current document, so identify the current directory explicitly:
if relative_segments == [""]
relative_segments = [".", ""]
elsif relative_segments.first&.include?(":")
# A colon in the first segment would be interpreted as a URI scheme:
relative_segments.unshift(".")
end
return Path.new(nil, relative_segments)
end
def initialize(encoded, segments = nil)
Initialize a path from either its complete encoded representation or encoded segments.
Signature
-
parameter
encodedString | Nil The encoded URL path.
-
parameter
segmentsArray(String) | Nil The encoded path segments.
-
raises
ArgumentError If an encoded segment contains a structural separator.
Implementation
def initialize(encoded, segments = nil)
if encoded
@encoded = -encoded
end
if encoded.nil? && segments.nil?
segments = EMPTY_SEGMENTS
elsif segments
segments.each do |segment|
unless segment.is_a?(String) && !segment.include?(SEPARATOR)
raise ArgumentError, "Path contains an invalid encoded segment!"
end
end
segments = segments.map(&:-@).freeze
end
@segments = segments
end
def freeze
Freeze the path and materialize both lossless representations.
Signature
-
returns
Path The frozen path.
Implementation
def freeze
return self if frozen?
self.segments
self.encoded
return super
end
def absolute?
Signature
-
returns
Boolean Whether the path begins at the URL path root.
Implementation
def absolute?
encoded.start_with?(SEPARATOR)
end
def relative?
Signature
-
returns
Boolean Whether the path is relative to another URL path.
Implementation
def relative?
!absolute?
end
def directory?
Signature
-
returns
Boolean Whether the path has a trailing separator.
Implementation
def directory?
encoded.end_with?(SEPARATOR)
end
def basename(extension: true)
The final decoded component. A path with a trailing separator has an empty basename.
Signature
-
parameter
extensionBoolean Whether to include the final file extension.
-
returns
String | Nil The final component, or
nilfor an empty path.
Implementation
def basename(extension: true)
component = self.components.last
return component if extension || component.nil?
if index = component.rindex(".")
basename = component[0...index]
return basename if basename.b.match?(/[^.]/n)
end
return component
end
def parent(level = 1)
Return a path with its final component removed.
The empty path and absolute root are their own parents. For a directory path, this removes the trailing empty component which represents its separator.
Signature
-
parameter
levelInteger The number of components to remove.
-
returns
Path The parent path.
-
raises
ArgumentError If
levelis not a non-negative integer.
Implementation
def parent(level = 1)
unless level.is_a?(Integer) && level >= 0
raise ArgumentError, "Path parent level must be a non-negative integer!"
end
segments = self.segments
return self if level == 0 || segments.empty? || segments == ROOT_SEGMENTS
remaining = segments.size - level
if absolute?
segments = remaining <= 1 ? ROOT_SEGMENTS : segments.first(remaining)
else
segments = remaining <= 0 ? EMPTY_SEGMENTS : segments.first(remaining)
end
return self.class.new(nil, segments)
end
def segments
Signature
-
returns
Array(String) The encoded segments, preserving their exact spelling.
Implementation
def segments
@segments ||= @encoded.split(SEPARATOR, -1).map!(&:-@).freeze
end
def components(encoding = Encoding)
Decode the path segments using the given encoding.
The result is not cached because different encoding objects can produce different
component values. In particular, a decoded component may contain / without
changing its boundary in the returned array.
Signature
-
parameter
encodingObject An object implementing
unescape(String).-
returns
Array(String) The decoded components.
Implementation
def components(encoding = Encoding)
segments.map{|segment| encoding.unescape(segment)}
end
def encoded
Signature
-
returns
String The encoded URL path.
Implementation
def encoded
@encoded ||= @segments.join(SEPARATOR).freeze
end
def empty?
Signature
-
returns
Boolean Whether the path contains no components.
Implementation
def empty?
encoded.empty?
end
def <=>(other)
Paths compare by their exact encoded representation.
Implementation
def <=>(other)
return nil unless other.is_a?(Path)
encoded <=> other.encoded
end
def ==(other)
Signature
-
parameter
otherObject The value to compare with this path.
-
returns
Boolean Whether both values are the same.
Implementation
def ==(other)
if other.is_a?(String)
return encoded == other
else
return eql?(other)
end
end
def eql?(other)
Compare this path with another path using exact encoded string identity.
Signature
-
parameter
otherObject The value to compare with this path.
-
returns
Boolean Whether both paths are the same.
Implementation
def eql?(other)
other.is_a?(Path) && encoded.eql?(other.encoded)
end
def hash
Signature
-
returns
Integer A hash derived from the exact encoded representation.
Implementation
def hash
encoded.hash
end
def local_path(root)
Resolve a URL path beneath a local filesystem root.
Each decoded URL component must map to exactly one local path component. Components
containing NUL or a platform path separator cannot be represented and are rejected.
Absolute URL paths are interpreted relative to root, not the filesystem root.
This establishes lexical containment only. It does not resolve symbolic links or prevent filesystem races while a returned path is subsequently opened.
Signature
-
parameter
rootString The filesystem root beneath which to resolve the URL path.
-
returns
String The expanded local filesystem path.
-
raises
ArgumentError If a URL segment is invalid or the path escapes the specified root.
Implementation
def local_path(root)
root = File.expand_path(root)
root_prefix = root.end_with?(File::SEPARATOR) ? root : root + File::SEPARATOR
components = self.components(Encoding::System)
components.shift if components.first == ""
path = File.expand_path(File.join(root, *components))
return path if path == root || path.start_with?(root_prefix)
raise ArgumentError, "Path escapes the specified root!"
end
def normalize
Normalize the encoded spelling of this path.
Percent-encoded unreserved characters are decoded, retained percent escapes use uppercase hexadecimal digits, and literal characters outside the path segment grammar are percent encoded. Reserved characters retain their encoded or literal form because those forms are not generally equivalent.
This operation preserves the path structure. Use Protocol::URL::Path#simplify separately when
application semantics permit resolving dot segments or collapsing repeated separators.
Signature
-
returns
Path The normalized path, or this path if already normalized.
-
raises
ArgumentError If the path contains malformed percent encoding, NUL, or invalid string encoding.
Implementation
def normalize
encoded = self.encoded
unless encoded.valid_encoding? && encoded.encoding.ascii_compatible?
raise ArgumentError, "Path segment has invalid encoding!"
end
segments = self.segments
normalized_segments = nil
segments.each_with_index do |segment, index|
next unless NORMALIZATION_PATTERN.match?(segment)
normalized = normalize_segment(segment)
next if normalized == segment
normalized_segments ||= segments.dup
normalized_segments[index] = normalized
end
return self unless normalized_segments
return self.class.new(nil, normalized_segments)
end
def simplify!
Simplify this path in place by resolving literal or percent-encoded dot segments and repeated separators.
Signature
-
returns
Path | Nil This path when changed, otherwise
nil.
Implementation
def simplify!
simplified = simplify
return nil if simplified.equal?(self)
@encoded = simplified.encoded
@segments = simplified.segments
return self
end
def simplify
Return a canonical path by resolving literal or percent-encoded dot segments and repeated separators.
Absolute paths do not retain parent components above the root. Relative paths retain leading parent components which cannot be resolved locally.
Signature
-
returns
Path The simplified path, or this path if already canonical.
Implementation
def simplify
segments = simplify_segments
return self unless segments
return self.class.new(nil, segments)
end
def join(other, pop: true, simplify: true)
Resolve another path relative to this path.
Signature
-
parameter
otherString | Array(String) | Path The path to resolve.
-
parameter
popBoolean Whether to remove the final base component first.
-
parameter
simplifyBoolean Whether to simplify the resulting components.
-
returns
Path The resolved path.
Implementation
def join(other, pop: true, simplify: true)
other = Path[other]
return self if other.empty?
if other.absolute?
return simplify ? other.simplify : other
end
segments = self.segments.dup
# RFC2396 Section 5.2:
# 6) a) All but the last segment of the base URI's path component is
# copied to the buffer. In other words, any characters after the
# last (right-most) slash character, if any, are excluded.
if pop and dot_segment(segments.last) != ".."
segments.pop
elsif segments.last == ""
segments.pop
end
segments.concat(other.segments)
if simplify
simplify_segments!(segments)
end
return Path.new(nil, segments)
end
def normalize_segment(segment)
Normalize one encoded path segment:
Implementation
def normalize_segment(segment)
return segment.gsub(NORMALIZATION_PATTERN) do |character|
byte = character.getbyte(0)
if byte == 0
raise ArgumentError, "Path segment contains NUL!"
elsif byte == 0x25
if character.bytesize == 1
raise ArgumentError, "String contains malformed percent encoding!"
end
byte = character.byteslice(1, 2).to_i(16)
if byte == 0
raise ArgumentError, "Path segment contains NUL!"
elsif unreserved_byte?(byte)
byte.chr
else
character.upcase
end
else
Encoding.escape(character)
end
end
end
def unreserved_byte?(byte)
Whether the byte represents an unreserved URI character:
Implementation
def unreserved_byte?(byte)
case byte
when 0x30..0x39, 0x41..0x5A, 0x61..0x7A, 0x2D, 0x2E, 0x5F, 0x7E
return true
else
return false
end
end
def dot_segment(segment)
Identify dot segments, including percent-encoded spellings. RFC 3986 treats
percent-encoded unreserved characters as equivalent to their literal forms;
the WHATWG URL Standard explicitly recognizes %2e, .%2e, %2e., and
%2e%2e as dot segments, case-insensitively.
This classification does not decode or rewrite the stored encoded segment. Paths retain their exact encoded representation unless a structural operation removes the segment. General percent-encoding normalization, such as decoding other unreserved characters or uppercasing hexadecimal digits, must be an explicit operation rather than part of lossless path storage or simplification.
Implementation
def dot_segment(segment)
return nil unless segment
return "." if segment.match?(/\A(?:\.|%2e)\z/i)
return ".." if segment.match?(/\A(?:\.|%2e){2}\z/i)
end
def simplification_index(segments)
Find the first encoded segment which requires simplification.
Implementation
def simplification_index(segments)
absolute = segments.first == ""
regular_segment = false
last_index = segments.size - 1
segments.each_with_index do |segment, index|
dot = dot_segment(segment)
if dot == "."
return index
elsif segment == ""
# Leading and trailing empty components are significant.
return index if index > 0 && index < last_index
elsif dot == ".."
# Absolute paths cannot retain parent components. Relative paths
# can retain them only before the first regular component.
return index if absolute || regular_segment
else
regular_segment = true
end
end
return nil
end
def simplify_segments
Return simplified encoded segments, or nil if they are already canonical.
Implementation
def simplify_segments
segments = self.segments
return nil unless start_index = simplification_index(segments)
segments = segments.dup
simplify_segments!(segments, start_index)
return segments
end
def simplify_segments!(segments, start_index = nil)
Simplify the given encoded segments in place.
Implementation
def simplify_segments!(segments, start_index = nil)
start_index ||= simplification_index(segments)
return nil unless start_index
offset = start_index
index = start_index
last_index = segments.size - 1
while index <= last_index
segment = segments[index]
dot = dot_segment(segment)
if dot == "."
# A trailing dot denotes a directory.
if index == last_index
segments[offset] = ""
offset += 1
end
elsif segment == "" && index != last_index
# Collapse repeated separators:
elsif dot == ".." && offset > 0 && dot_segment(segments[offset - 1]) != ".."
# Pop a component, but never pop the absolute-path root:
offset -= 1 if segments[offset - 1] != ""
# A trailing parent reference also denotes a directory.
if index == last_index
segments[offset] = ""
offset += 1
end
else
segments[offset] = segment if offset < index
offset += 1
end
index += 1
end
if offset < segments.size
segments[offset, segments.size - offset] = EMPTY_SEGMENTS
end
return segments
end