diff --git a/lib/utopia/path.rb b/lib/utopia/path.rb index af80d641..ec8bfdde 100644 --- a/lib/utopia/path.rb +++ b/lib/utopia/path.rb @@ -1,12 +1,20 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2009-2025, by Samuel Williams. +# Copyright, 2009-2026, by Samuel Williams. require "protocol/url/path" module Utopia - # Represents a path as an array of path components. Useful for efficient URL manipulation. + # Represents an application path as a traversal through a tree. + # + # Each component names a node and `/` represents the edge between adjacent nodes. A leading empty component anchors the traversal at the root, while a trailing empty component preserves an explicit final edge and denotes a directory: + # + # - `["foo", "bar"]` represents `foo/bar`. + # - `["", "foo", "bar"]` represents `/foo/bar`. + # - `["", "foo", "bar", ""]` represents `/foo/bar/`. + # + # The structural root is represented by `[""]` and contains no traversed edge. It is intentionally distinct from parsing `/`, which preserves the explicit edge as `["", ""]`. Both serialize as `/`, but they retain different structural representations. In particular, the structural root maps to an empty local path so it can be resolved relative to an application root. class Path include Comparable @@ -36,7 +44,7 @@ def empty? @components.empty? end - # Construct the root path. + # Construct the structural root path without an explicit trailing separator. # @returns [Path] The root path. def self.root self.new([""]) diff --git a/test/utopia/path.rb b/test/utopia/path.rb index 3ef258f4..57a78b70 100755 --- a/test/utopia/path.rb +++ b/test/utopia/path.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Released under the MIT License. -# Copyright, 2012-2025, by Samuel Williams. +# Copyright, 2012-2026, by Samuel Williams. require "utopia/path" @@ -22,12 +22,27 @@ with ".root" do let(:path) {subject.root} - it "is a root path" do + it "represents the structural root" do expect(path).to be == [""] expect(path).not.to be(:relative?) expect(path).to be(:absolute?) + expect(path).to be(:directory?) + expect(path).not.to be(:file?) expect(path).to have_attributes(local_path: be == "") end + + it "is structurally distinct from an explicit root separator" do + explicit_path = subject["/"] + + expect(path).not.to be == explicit_path + expect(path.to_s).to be == explicit_path.to_s + expect(path.to_url_path).to be == explicit_path.to_url_path + end + + it "traverses the structural root exactly once" do + expect(path.descend.to_a).to be == [path] + expect(path.ascend.to_a).to be == [path] + end end with ".create" do @@ -131,6 +146,42 @@ end end + with "root-preserving operations" do + it "does not pop the structural root" do + path = subject.root + + expect(path.pop).to be_nil + expect(path).to be == subject.root + end + + it "returns to the structural root after popping the final component" do + path = subject["/foo"] + + expect(path.pop).to be == "foo" + expect(path).to be == subject.root + end + + it "returns the structural root as the parent of a top-level component" do + expect(subject["/foo"].dirname).to be == subject.root + end + + it "simplifies traversal back to the structural root" do + expect(subject["/foo/.."].simplify).to be == subject.root + end + + it "uses the structural root when splitting a top-level component" do + expect(subject["/foo"].split("foo")).to be == [subject.root, subject[""]] + end + + it "uses the structural root as a prefix of absolute paths" do + expect(subject["/foo/bar"].start_with?(subject.root)).to be == true + end + + it "computes a parent traversal to the structural root" do + expect(subject.root.shortest_path(subject["/nested/index"])).to be == subject[".."] + end + end + with "#+" do it "can add root path as string" do root = Utopia::Path["/invoices/_template"]