diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 8568a2c..bcf25de 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -298,6 +298,14 @@ messy.to_s # => "https://example.com/a/c/d" If the original path structure is significant, retain the parsed URL and do not call `normalize!`. +To normalize its encoding and resolve dot segments while preserving repeated separators, compose the path operations explicitly: + +``` ruby +url = Protocol::URL["https://example.com/a//b/./c"] +url.path = url.path.normalize.simplify(preserve_empty: true) +url.to_s # => "https://example.com/a//b/c" +``` + ## Best Practices ### Choose the Right Class diff --git a/lib/protocol/url/path.rb b/lib/protocol/url/path.rb index 4fdc179..758a809 100644 --- a/lib/protocol/url/path.rb +++ b/lib/protocol/url/path.rb @@ -291,9 +291,10 @@ def normalize # Simplify this path in place by resolving literal or percent-encoded dot segments and repeated separators. # + # @parameter preserve_empty [Boolean] Whether to preserve empty path segments represented by repeated separators. # @returns [Path | Nil] This path when changed, otherwise `nil`. - def simplify! - simplified = simplify + def simplify!(preserve_empty: false) + simplified = simplify(preserve_empty: preserve_empty) return nil if simplified.equal?(self) @encoded = simplified.encoded @@ -307,9 +308,10 @@ def simplify! # Absolute paths do not retain parent components above the root. Relative paths # retain leading parent components which cannot be resolved locally. # + # @parameter preserve_empty [Boolean] Whether to preserve empty path segments represented by repeated separators. # @returns [Path] The simplified path, or this path if already canonical. - def simplify - segments = simplify_segments + def simplify(preserve_empty: false) + segments = simplify_segments(preserve_empty: preserve_empty) return self unless segments return self.class.new(nil, segments) @@ -432,7 +434,7 @@ def dot_segment(segment) end # Find the first encoded segment which requires simplification. - def simplification_index(segments) + def simplification_index(segments, preserve_empty: false) absolute = segments.first == "" regular_segment = false last_index = segments.size - 1 @@ -444,7 +446,9 @@ def simplification_index(segments) return index elsif segment == "" # Leading and trailing empty components are significant. - return index if index > 0 && index < last_index + if !preserve_empty && index > 0 && index < last_index + return index + end elsif dot == ".." # Absolute paths cannot retain parent components. Relative paths # can retain them only before the first regular component. @@ -458,19 +462,19 @@ def simplification_index(segments) end # Return simplified encoded segments, or nil if they are already canonical. - def simplify_segments + def simplify_segments(preserve_empty: false) segments = self.segments - return nil unless start_index = simplification_index(segments) + return nil unless start_index = simplification_index(segments, preserve_empty: preserve_empty) segments = segments.dup - simplify_segments!(segments, start_index) + simplify_segments!(segments, start_index, preserve_empty: preserve_empty) return segments end # Simplify the given encoded segments in place. - def simplify_segments!(segments, start_index = nil) - start_index ||= simplification_index(segments) + def simplify_segments!(segments, start_index = nil, preserve_empty: false) + start_index ||= simplification_index(segments, preserve_empty: preserve_empty) return nil unless start_index offset = start_index @@ -488,10 +492,18 @@ def simplify_segments!(segments, start_index = nil) offset += 1 end elsif segment == "" && index != last_index - # Collapse repeated separators: + # Preserve or collapse repeated separators according to the requested policy: + if preserve_empty + segments[offset] = segment if offset < index + offset += 1 + end elsif dot == ".." && offset > 0 && dot_segment(segments[offset - 1]) != ".." # Pop a component, but never pop the absolute-path root: - offset -= 1 if segments[offset - 1] != "" + if preserve_empty + offset -= 1 unless segments.first == "" && offset == 1 + elsif segments[offset - 1] != "" + offset -= 1 + end # A trailing parent reference also denotes a directory. if index == last_index diff --git a/releases.md b/releases.md index e051a88..19a14ad 100644 --- a/releases.md +++ b/releases.md @@ -3,6 +3,7 @@ ## Unreleased - Add conservative normalization of encoded URL paths. + - Add an option to preserve empty path segments while simplifying paths. ## v0.12.0 diff --git a/test/protocol/url/path.rb b/test/protocol/url/path.rb index 4001414..e1274c6 100644 --- a/test/protocol/url/path.rb +++ b/test/protocol/url/path.rb @@ -315,6 +315,24 @@ expect(path.simplify.encoded).to be == "/b" end + + it "can preserve repeated separators" do + path = Protocol::URL::Path["/a//b///c"] + + expect(path.simplify(preserve_empty: true)).to be_equal(path) + end + + it "resolves dot segments while preserving repeated separators" do + path = Protocol::URL::Path["/a//b/./c/../d"] + + expect(path.simplify(preserve_empty: true).encoded).to be == "/a//b/d" + end + + it "resolves a parent against an empty segment when preserving repeated separators" do + path = Protocol::URL::Path["/a//../b"] + + expect(path.simplify(preserve_empty: true).encoded).to be == "/a/b" + end end with "#normalize" do @@ -426,6 +444,13 @@ path.simplify! expect(path.components).to be == ["", "a", ""] end + + it "can preserve repeated separators" do + path = Protocol::URL::Path["/a//b/./c"] + + path.simplify!(preserve_empty: true) + expect(path.encoded).to be == "/a//b/c" + end end with "#join" do