1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175
|
# frozen_string_literal: true
# Released under the MIT License.
# Copyright, 2025, by Samuel Williams.
require_relative "encoding"
require_relative "path"
module Protocol
module URL
# Represents a relative URL, which does not include a scheme or authority.
class Relative
include Comparable
def initialize(path, query = nil, fragment = nil)
@path = path.to_s
@query = query
@fragment = fragment
end
attr :path
attr :query
attr :fragment
def to_local_path
Path.to_local_path(@path)
end
# @returns [Boolean] If there is a query string.
def query?
@query and !@query.empty?
end
# @returns [Boolean] If there is a fragment.
def fragment?
@fragment and !@fragment.empty?
end
# Combine this relative URL with another URL or path.
#
# @parameter other [String, Absolute, Relative] The URL or path to combine.
# @returns [Absolute, Relative] The combined URL.
#
# @example Combine two relative paths.
# base = Relative.new("/documents/reports/")
# other = Relative.new("invoices/2024.pdf")
# result = base + other
# result.path # => "/documents/reports/invoices/2024.pdf"
#
# @example Navigate to parent directory.
# base = Relative.new("/documents/reports/archive/")
# other = Relative.new("../../summary.pdf")
# result = base + other
# result.path # => "/documents/summary.pdf"
def +(other)
case other
when Absolute
# Relative + Absolute: the absolute URL takes precedence
# You can't apply relative navigation to an absolute URL
other
when Relative
# Relative + Relative: merge paths directly
self.class.new(
Path.expand(self.path, other.path, true),
other.query,
other.fragment
)
when String
# Relative + String: parse and combine
self + URL[other]
else
raise ArgumentError, "Cannot combine Relative URL with #{other.class}"
end
end
# Create a new Relative URL with modified components.
#
# @parameter path [String, nil] The path to merge with the current path.
# @parameter query [String, nil] The query string to use.
# @parameter fragment [String, nil] The fragment to use.
# @parameter pop [Boolean] Whether to pop the last path component before merging.
# @returns [Relative] A new Relative URL with the modified components.
#
# @example Update the query string.
# url = Relative.new("/search", "query=ruby")
# updated = url.with(query: "query=python")
# updated.to_s # => "/search?query=python"
#
# @example Append to the path.
# url = Relative.new("/documents/")
# updated = url.with(path: "report.pdf", pop: false)
# updated.to_s # => "/documents/report.pdf"
def with(path: nil, query: @query, fragment: @fragment, pop: true)
self.class.new(Path.expand(@path, path, pop), query, fragment)
end
# Normalize the path by resolving "." and ".." segments and removing duplicate slashes.
#
# This modifies the URL in-place by simplifying the path component:
# - Removes "." segments (current directory)
# - Resolves ".." segments (parent directory)
# - Collapses multiple consecutive slashes to single slashes (except at start)
#
# @returns [self] The normalized URL.
#
# @example Basic normalization
# url = Relative.new("/foo//bar/./baz/../qux")
# url.normalize!
# url.path # => "/foo/bar/qux"
def normalize!
components = Path.split(@path)
normalized = Path.simplify(components)
@path = Path.join(normalized)
return self
end
# Append the relative URL to the given buffer.
# The path, query, and fragment are expected to already be properly encoded.
def append(buffer = String.new)
buffer << @path
if @query and !@query.empty?
buffer << "?" << @query
end
if @fragment and !@fragment.empty?
buffer << "#" << @fragment
end
return buffer
end
def to_ary
[@path, @query, @fragment]
end
def hash
to_ary.hash
end
def equal?(other)
to_ary == other.to_ary
end
def <=>(other)
to_ary <=> other.to_ary
end
def ==(other)
to_ary == other.to_ary
end
def ===(other)
to_s === other
end
def to_s
append
end
def as_json(...)
to_s
end
def to_json(...)
as_json.to_json(...)
end
def inspect
"#<#{self.class} #{to_s}>"
end
end
end
end
|