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.



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

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) ⇒ 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"


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

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.



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

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

#==(other) ⇒ Object



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

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

#absolute?Boolean

Returns:

  • (Boolean)


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

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.



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

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.



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

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

#directory?Boolean

Returns:

  • (Boolean)


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

def directory?
	encoded.end_with?(SEPARATOR)
end

#empty?Boolean

Returns:

  • (Boolean)


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

def empty?
	encoded.empty?
end

#encodedObject Also known as: to_s, to_str



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

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

#eql?(other) ⇒ Boolean

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

Returns:

  • (Boolean)


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

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

#freezeObject

Freeze the path and materialize both lossless representations.



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

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

#hashObject



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

def hash
	encoded.hash
end

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

Resolve another path relative to this path.



324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
# File 'lib/protocol/url/path.rb', line 324

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)


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

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.



268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
# File 'lib/protocol/url/path.rb', line 268

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.



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

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.



357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
# File 'lib/protocol/url/path.rb', line 357

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)


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

def relative?
	!absolute?
end

#segmentsObject



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

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.



311
312
313
314
315
316
# File 'lib/protocol/url/path.rb', line 311

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.



295
296
297
298
299
300
301
302
303
# File 'lib/protocol/url/path.rb', line 295

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