Class: Protocol::URL::Path

Inherits:
Object
  • Object
show all
Includes:
Comparable
Defined in:
lib/protocol/url/path.rb

Overview

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 #components.

Constant Summary collapse

SEPARATOR =

The path separator.

"/"

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(encoded, segments = nil) ⇒ Path

Initialize a path from either its complete encoded representation or encoded segments.



87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/protocol/url/path.rb', line 87

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

Class Method Details

.[](path) ⇒ Object

Coerce an encoded string or encoded segment array into a path.



29
30
31
32
33
34
35
36
37
# File 'lib/protocol/url/path.rb', line 29

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

.for(components, encoding: Encoding) ⇒ Object

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.



48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/protocol/url/path.rb', line 48

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

.relative(target, from) ⇒ Object

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.

Examples:

Calculate relative path between pages.

Path.relative("/_components/app.js", "/foo/bar/")
# => "../../_components/app.js"

Calculate relative path in same directory.

Path.relative("/docs/guide.html", "/docs/index.html")
# => "guide.html"


78
79
80
# File 'lib/protocol/url/path.rb', line 78

def self.relative(target, from)
	return Path[target].relative(from).to_s
end

Instance Method Details

#<=>(other) ⇒ Object

Paths compare by their exact encoded representation.



203
204
205
206
207
# File 'lib/protocol/url/path.rb', line 203

def <=>(other)
	return nil unless other.is_a?(Path)
	
	encoded <=> other.encoded
end

#==(other) ⇒ Object



211
212
213
# File 'lib/protocol/url/path.rb', line 211

def ==(other)
	eql?(other)
end

#absolute?Boolean

Returns:

  • (Boolean)


119
120
121
# File 'lib/protocol/url/path.rb', line 119

def absolute?
	encoded.start_with?(SEPARATOR)
end

#basename(extension: true) ⇒ Object

The final decoded component. A path with a trailing separator has an empty basename.



137
138
139
140
141
142
143
144
145
146
147
# File 'lib/protocol/url/path.rb', line 137

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

#components(encoding = Encoding) ⇒ Object

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.



188
189
190
# File 'lib/protocol/url/path.rb', line 188

def components(encoding = Encoding)
	segments.map{|segment| encoding.unescape(segment)}
end

#directory?Boolean

Returns:

  • (Boolean)


129
130
131
# File 'lib/protocol/url/path.rb', line 129

def directory?
	encoded.end_with?(SEPARATOR)
end

#empty?Boolean

Returns:

  • (Boolean)


198
199
200
# File 'lib/protocol/url/path.rb', line 198

def empty?
	encoded.empty?
end

#encodedObject Also known as: to_s, to_str



193
194
195
# File 'lib/protocol/url/path.rb', line 193

def encoded
	@encoded ||= @segments.join(SEPARATOR).freeze
end

#eql?(other) ⇒ Boolean

Compare this path with another path using exact encoded string identity.

Returns:

  • (Boolean)


218
219
220
# File 'lib/protocol/url/path.rb', line 218

def eql?(other)
	other.is_a?(Path) && encoded.eql?(other.encoded)
end

#freezeObject

Freeze the path and materialize both lossless representations.



109
110
111
112
113
114
115
116
# File 'lib/protocol/url/path.rb', line 109

def freeze
	return self if frozen?
	
	self.segments
	self.encoded
	
	return super
end

#hashObject



223
224
225
# File 'lib/protocol/url/path.rb', line 223

def hash
	encoded.hash
end

#join(other, pop: true, simplify: true) ⇒ Object

Resolve another path relative to this path.



287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
# File 'lib/protocol/url/path.rb', line 287

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

#local_path(root) ⇒ Object

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.

Raises:

  • (ArgumentError)


239
240
241
242
243
244
245
246
247
248
249
250
# File 'lib/protocol/url/path.rb', line 239

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

#parent(level = 1) ⇒ Object

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.



157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/protocol/url/path.rb', line 157

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

#relative(from) ⇒ Object

Calculate this path relative to another path.



320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
# File 'lib/protocol/url/path.rb', line 320

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
	
	# 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]
	
	return Path.new(nil, relative_segments)
end

#relative?Boolean

Returns:

  • (Boolean)


124
125
126
# File 'lib/protocol/url/path.rb', line 124

def relative?
	!absolute?
end

#segmentsObject



176
177
178
# File 'lib/protocol/url/path.rb', line 176

def segments
	@segments ||= @encoded.split(SEPARATOR, -1).map!(&:-@).freeze
end

#simplifyObject

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.



274
275
276
277
278
279
# File 'lib/protocol/url/path.rb', line 274

def simplify
	segments = simplify_segments
	return self unless segments
	
	return self.class.new(nil, segments)
end

#simplify!Object

Simplify this path in place by resolving literal or percent-encoded dot segments and repeated separators.



258
259
260
261
262
263
264
265
266
# File 'lib/protocol/url/path.rb', line 258

def simplify!
	simplified = simplify
	return nil if simplified.equal?(self)
	
	@encoded = simplified.encoded
	@segments = simplified.segments
	
	return self
end