Protocol::URLSourceProtocolURLPath

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 path String | 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 components Array(String)

The decoded path components.

parameter encoding Object

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 target String

The destination path (where you want to go).

parameter from String

The source path (where you are starting from).

returns String

The relative path from from to target.

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 from String | Array(String) | Path

The source path.

returns Path

The relative path from from to 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 encoded String | Nil

The encoded URL path.

parameter segments Array(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 extension Boolean

Whether to include the final file extension.

returns String | Nil

The final component, or nil for 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 level Integer

The number of components to remove.

returns Path

The parent path.

raises ArgumentError

If level is 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 encoding Object

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 other Object

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 other Object

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 root String

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 other String | Array(String) | Path

The path to resolve.

parameter pop Boolean

Whether to remove the final base component first.

parameter simplify Boolean

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