Skip to content

Commit 900759c

Browse files
committed
Expanded and simplified implementation.
1 parent b06d413 commit 900759c

File tree

11 files changed

+618
-139
lines changed

11 files changed

+618
-139
lines changed

lib/protocol/url/absolute.rb

Lines changed: 3 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,7 @@ def +(other)
3333
# Protocol-relative URL: inherit scheme from base:
3434
return Absolute.new(@scheme, other.authority, other.path, other.query, other.fragment)
3535
when Relative
36-
# Already a Relative, use directly:
37-
when Reference
38-
other = Relative.new(other.path, other.query, other.fragment)
36+
# Already a Relative, use directly.
3937
when String
4038
other = URL[other]
4139
# If parsing resulted in an Absolute URL, handle it:
@@ -61,14 +59,10 @@ def +(other)
6159
# Fragment-only: keep everything from base, just change fragment:
6260
Absolute.new(@scheme, @authority, @path, @query, other.fragment || @fragment)
6361
end
64-
elsif other.path.start_with?("/")
65-
# Absolute path: use base scheme+authority with new path:
66-
Absolute.new(@scheme, @authority, normalize_path(other.path), other.query, other.fragment)
6762
else
6863
# Relative path: merge with base path:
69-
merged_path = merge_paths(@path, other.path)
70-
normalized_path = normalize_path(merged_path)
71-
Absolute.new(@scheme, @authority, normalized_path, other.query, other.fragment)
64+
path = Path.expand(@path, other.path)
65+
Absolute.new(@scheme, @authority, path, other.query, other.fragment)
7266
end
7367
end
7468

@@ -82,64 +76,6 @@ def append(buffer = String.new)
8276
def to_s
8377
append
8478
end
85-
86-
def absolute?
87-
true
88-
end
89-
90-
def relative?
91-
false
92-
end
93-
94-
private
95-
96-
# Merge a base path with a relative path according to RFC 3986 Section 5.2.3
97-
def merge_paths(base, relative)
98-
# If base has authority and empty path, use "/" + relative
99-
if @authority && !@authority.empty? && base.empty?
100-
return "/" + relative
101-
end
102-
103-
# Otherwise, remove everything after the last "/" in base and append relative
104-
if base.include?("/")
105-
base.sub(/\/[^\/]*\z/, "/") + relative
106-
else
107-
relative
108-
end
109-
end
110-
111-
# Remove dot-segments from a path according to RFC 3986 Section 5.2.4
112-
def normalize_path(path)
113-
# Remember if path starts with "/" (absolute path)
114-
absolute = path.start_with?("/")
115-
# Remember if path ends with "/" or "/." or "/.."
116-
trailing_slash = path.end_with?("/") || path.end_with?("/.") || path.end_with?("/..")
117-
118-
output = []
119-
input = path.split("/", -1)
120-
121-
input.each do |segment|
122-
if segment == ".."
123-
# Go up one level (pop), but not beyond root
124-
output.pop unless output.empty? || (absolute && output.size == 1 && output.first == "")
125-
elsif segment != "." && segment != ""
126-
# Keep all segments except "." and empty
127-
output << segment
128-
end
129-
end
130-
131-
# For absolute paths, ensure we start with /
132-
if absolute
133-
result = "/" + output.join("/")
134-
else
135-
result = output.join("/")
136-
end
137-
138-
# Add trailing slash if original had one or ended with dot-segments
139-
result += "/" if trailing_slash && !result.end_with?("/")
140-
141-
result
142-
end
14379
end
14480
end
14581
end

lib/protocol/url/path.rb

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2025, by Samuel Williams.
5+
6+
require_relative "encoding"
7+
8+
module Protocol
9+
module URL
10+
# Represents a relative URL, which does not include a scheme or authority.
11+
module Path
12+
# Split the given path into its components.
13+
#
14+
# - `split("")` => `[]`
15+
# - `split("/")` => `["", ""]`
16+
# - `split("/a/b/c")` => `["", "a", "b", "c"]`
17+
# - `split("a/b/c/")` => `["a", "b", "c", ""]`
18+
#
19+
# @parameter path [String] The path to split.
20+
# @returns [Array(String)] The path components.
21+
def self.split(path)
22+
return path.split("/", -1)
23+
end
24+
25+
# Join the given path components into a single path.
26+
#
27+
# @parameter components [Array(String)] The path components to join.
28+
# @returns [String] The joined path.
29+
def self.join(components)
30+
return components.join("/")
31+
end
32+
33+
# Simplify the given path components by resolving "." and "..".
34+
#
35+
# @parameter components [Array(String)] The path components to simplify.
36+
# @returns [Array(String)] The simplified path components.
37+
def self.simplify(components)
38+
output = []
39+
40+
components.each_with_index do |component, index|
41+
if index == 0 && component == ""
42+
# Preserve leading slash:
43+
output << ""
44+
elsif component == "."
45+
# Handle current directory - trailing . means directory, preserve trailing slash:
46+
output << "" if index == components.size - 1
47+
elsif component == "" && index != components.size - 1
48+
# Ignore empty segments (multiple slashes) except at end - no-op.
49+
elsif component == ".." && output.last && output.last != ".."
50+
# Handle parent directory: go up one level if not at root:
51+
output.pop if output.last != ""
52+
# Trailing .. means directory, preserve trailing slash:
53+
output << "" if index == components.size - 1
54+
else
55+
# Regular path component:
56+
output << component
57+
end
58+
end
59+
60+
return output
61+
end
62+
63+
# @parameter pop [Boolean] whether to remove the last path component of the base path, to conform to URI merging behaviour, as defined by RFC2396.
64+
def self.expand(base, relative, pop = true)
65+
# Empty relative path means no change:
66+
return base if relative.empty?
67+
68+
components = split(base)
69+
70+
# RFC2396 Section 5.2:
71+
# 6) a) All but the last segment of the base URI's path component is
72+
# copied to the buffer. In other words, any characters after the
73+
# last (right-most) slash character, if any, are excluded.
74+
if pop and components.last != ".."
75+
components.pop
76+
elsif components.last == ""
77+
components.pop
78+
end
79+
80+
relative = relative.split("/", -1)
81+
if relative.first == ""
82+
components = relative
83+
else
84+
components.concat(relative)
85+
end
86+
87+
return join(simplify(components))
88+
end
89+
end
90+
end
91+
end

lib/protocol/url/reference.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ def + other
124124
other = self.class[other]
125125

126126
self.class.new(
127-
expand_path(self.path, other.path, true),
127+
Path.expand(self.path, other.path, true),
128128
other.query,
129129
other.fragment,
130130
other.parameters,
@@ -172,7 +172,7 @@ def with(path: nil, parameters: false, fragment: @fragment, pop: false, merge: t
172172
end
173173

174174
if path
175-
path = expand_path(@path, path, pop)
175+
path = Path.expand(@path, path, pop)
176176
else
177177
path = @path
178178
end

lib/protocol/url/relative.rb

Lines changed: 2 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# Copyright, 2025, by Samuel Williams.
55

66
require_relative "encoding"
7+
require_relative "path"
78

89
module Protocol
910
module URL
@@ -32,7 +33,7 @@ def +(other)
3233
when Relative
3334
# Relative + Relative: merge paths directly
3435
self.class.new(
35-
expand_path(self.path, other.path, true),
36+
Path.expand(self.path, other.path, true),
3637
other.query,
3738
other.fragment
3839
)
@@ -68,75 +69,9 @@ def to_s
6869
append
6970
end
7071

71-
def absolute?
72-
false
73-
end
74-
75-
def relative?
76-
true
77-
end
78-
7972
def inspect
8073
"#<#{self.class} #{to_s}>"
8174
end
82-
83-
private
84-
85-
def split(path)
86-
if path.empty?
87-
[path]
88-
else
89-
path.split("/", -1)
90-
end
91-
end
92-
93-
def expand_parts(path, parts)
94-
parts.each do |part|
95-
if part == "."
96-
# No-op (ignore current directory)
97-
elsif part == ".." and path.last and path.last != ".."
98-
if path.last != ""
99-
# We can go up one level:
100-
path.pop
101-
end
102-
else
103-
path << part
104-
end
105-
end
106-
end
107-
108-
# @parameter pop [Boolean] whether to remove the last path component of the base path, to conform to URI merging behaviour, as defined by RFC2396.
109-
def expand_path(base, relative, pop = true)
110-
if relative.start_with? "/"
111-
return relative
112-
end
113-
114-
path = split(base)
115-
116-
# RFC2396 Section 5.2:
117-
# 6) a) All but the last segment of the base URI's path component is
118-
# copied to the buffer. In other words, any characters after the
119-
# last (right-most) slash character, if any, are excluded.
120-
#
121-
# NOTE: Since ".." and "." are considered special path segments with
122-
# navigational meaning, we treat them intuitively: if the last segment
123-
# is ".." we don't pop it, as it's a navigation instruction rather than
124-
# a filename. This provides more intuitive behavior when combining relative
125-
# paths, which is not explicitly defined by the RFC.
126-
if (pop or path.last == "") and path.last != ".." and path.last != "."
127-
path.pop
128-
end
129-
130-
parts = split(relative)
131-
expand_parts(path, parts)
132-
133-
# Ensure absolute paths start with "":
134-
if path.first != "" and base.start_with?("/")
135-
path.unshift("")
136-
end
137-
138-
return path.join("/")
139-
end
14075
end
14176
end
14277
end

lib/protocol/url/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@
55

66
module Protocol
77
module URL
8-
VERSION = "0.1.0"
8+
VERSION = "0.0.0"
99
end
1010
end

test/protocol/url.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,22 @@
2828
expect(url).to be_a(Protocol::URL::Relative)
2929
expect(url.path).to be == "/_components/"
3030
end
31+
32+
it "returns nil for nil input" do
33+
url = Protocol::URL[nil]
34+
expect(url).to be_nil
35+
end
36+
37+
it "returns same object if already a Relative" do
38+
relative = Protocol::URL::Relative.new("/path")
39+
url = Protocol::URL[relative]
40+
expect(url).to be_equal(relative)
41+
end
42+
43+
it "raises error for invalid input" do
44+
expect do
45+
Protocol::URL[123]
46+
end.to raise_exception(ArgumentError, message: be =~ /Cannot coerce/)
47+
end
3148
end
3249
end

test/protocol/url/absolute.rb

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,36 @@
4242
expect(url.to_s).to be == "http://example.com/path#section/1.2?query"
4343
end
4444
end
45+
46+
with "#+" do
47+
it "returns other when adding Absolute with scheme" do
48+
base = Protocol::URL::Absolute.new("https", "//example.com", "/path")
49+
other = Protocol::URL::Absolute.new("http", "//other.com", "/other")
50+
result = base + other
51+
expect(result).to be_equal(other)
52+
end
53+
54+
it "handles protocol-relative Absolute URL" do
55+
base = Protocol::URL::Absolute.new("https", "//example.com", "/path")
56+
other = Protocol::URL::Absolute.new(nil, "//cdn.example.com", "/lib.js")
57+
result = base + other
58+
expect(result.scheme).to be == "https"
59+
expect(result.authority).to be == "//cdn.example.com"
60+
expect(result.path).to be == "/lib.js"
61+
end
62+
63+
it "handles Reference argument" do
64+
base = Protocol::URL::Absolute.new("https", "//example.com", "/path")
65+
reference = Protocol::URL::Reference.new("other.html", nil, nil, nil)
66+
result = base + reference
67+
expect(result.path).to be == "/other.html"
68+
end
69+
70+
it "raises error for invalid type" do
71+
base = Protocol::URL::Absolute.new("https", "//example.com", "/path")
72+
expect do
73+
base + 123
74+
end.to raise_exception(ArgumentError, message: be =~ /Cannot combine/)
75+
end
76+
end
4577
end

test/protocol/url/encoding.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,18 @@
7575
end.to raise_exception(ArgumentError, message: be =~ /Key length exceeded/)
7676
end
7777
end
78+
79+
describe ".encode with prefix" do
80+
it "returns prefix for nil value" do
81+
result = Protocol::URL::Encoding.encode(nil, "prefix")
82+
expect(result).to be == "prefix"
83+
end
84+
85+
it "handles nested array elements correctly" do
86+
# This tests the line: top -= 1 unless last.include?(nested)
87+
result = Protocol::URL::Encoding.encode({"items" => [{"name" => "a"}, {"name" => "b"}]})
88+
expect(result).to be(:include?, "items[][name]=a")
89+
expect(result).to be(:include?, "items[][name]=b")
90+
end
91+
end
7892
end

0 commit comments

Comments
 (0)