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.



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

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.



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

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.



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

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, explicit: false) ⇒ 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"


80
81
82
# File 'lib/protocol/url/path.rb', line 80

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

Instance Method Details

#<=>(other) ⇒ Object

Paths compare by their exact encoded representation.



205
206
207
208
209
# File 'lib/protocol/url/path.rb', line 205

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

#==(other) ⇒ Object



213
214
215
216
217
218
219
# File 'lib/protocol/url/path.rb', line 213

def ==(other)
	if other.is_a?(String)
		return encoded == other
	else
		return eql?(other)
	end
end

#absolute?Boolean

Returns:

  • (Boolean)


121
122
123
# File 'lib/protocol/url/path.rb', line 121

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.



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

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.



190
191
192
# File 'lib/protocol/url/path.rb', line 190

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

#directory?Boolean

Returns:

  • (Boolean)


131
132
133
# File 'lib/protocol/url/path.rb', line 131

def directory?
	encoded.end_with?(SEPARATOR)
end

#empty?Boolean

Returns:

  • (Boolean)


200
201
202
# File 'lib/protocol/url/path.rb', line 200

def empty?
	encoded.empty?
end

#encodedObject Also known as: to_s, to_str



195
196
197
# File 'lib/protocol/url/path.rb', line 195

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

#eql?(other) ⇒ Boolean

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

Returns:

  • (Boolean)


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

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

#freezeObject

Freeze the path and materialize both lossless representations.



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

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

#hashObject



229
230
231
# File 'lib/protocol/url/path.rb', line 229

def hash
	encoded.hash
end

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

Resolve another path relative to this path.



329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
# File 'lib/protocol/url/path.rb', line 329

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)


245
246
247
248
249
250
251
252
253
254
255
256
# File 'lib/protocol/url/path.rb', line 245

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

#normalizeObject

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 #simplify separately when application semantics permit resolving dot segments or collapsing repeated separators.



273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
# File 'lib/protocol/url/path.rb', line 273

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

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



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

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, explicit: false) ⇒ Object

Calculate this path relative to another path.



363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
# File 'lib/protocol/url/path.rb', line 363

def relative(from, explicit: false)
	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 explicit && relative_segments.first != ".."
		# Identify same-directory references explicitly:
		relative_segments.unshift(".")
	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

#relative?Boolean

Returns:

  • (Boolean)


126
127
128
# File 'lib/protocol/url/path.rb', line 126

def relative?
	!absolute?
end

#segmentsObject



178
179
180
# File 'lib/protocol/url/path.rb', line 178

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.



316
317
318
319
320
321
# File 'lib/protocol/url/path.rb', line 316

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.



300
301
302
303
304
305
306
307
308
# File 'lib/protocol/url/path.rb', line 300

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