diff --git a/CHANGELOG.md b/CHANGELOG.md
index 629fc43..6ee3ea5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+### Changed
+- **Breaking:** `LocalPath.StartsWith` and `AbsolutePath.StartsWith` now compare whole path segments instead of raw strings, which makes them exact inverses of `IsPrefixOf` as originally intended in [#43](https://github.com/ForNeVeR/TruePath/issues/43). For example, `new LocalPath("/foo1").StartsWith(new LocalPath("/foo"))` is now `false`, where it used to be `true`.
+- `LocalPath.IsPrefixOf` now returns `false` whenever the two paths differ in absoluteness: an absolute path is never a prefix of a relative one, nor the other way round. Previously, the result depended on an incidental string comparison.
+- `LocalPath.IsPrefixOf` now treats an empty path — the normalized form of `""`, `"."` and `"a/.."`, and the parent of any single-segment relative path — as the current directory, so it is a prefix of every relative path that does not begin with a `..` reference.
+
+### Fixed
+- `LocalPath.IsPrefixOf` and `StartsWith` now compare path strings ordinally. Previously they used the current culture, which ignores collation-ignorable characters, so a path could be reported as a prefix of an unrelated one.
+
## [1.12.0] - 2026-03-14
### Changed
- Update the dependencies.
diff --git a/TruePath.Tests/AbsolutePathTests.cs b/TruePath.Tests/AbsolutePathTests.cs
index c31c266..95dd2f4 100644
--- a/TruePath.Tests/AbsolutePathTests.cs
+++ b/TruePath.Tests/AbsolutePathTests.cs
@@ -143,24 +143,19 @@ public void ParentIsCalculatedCorrectly(string relativePath, string? expectedRel
}
[Theory]
- [InlineData("/home/user", "/home/user/documents")]
- [InlineData("/home/usEr", "/home/User/documents")]
- [InlineData("/home/user/documents", "/home/user/documents")]
- [InlineData("/home/user/documents", "/home/user")]
- public void IsPrefixOfShouldBeEquivalentToStartsWith(string pathA, string pathB)
+ [InlineData("/home/user", "/home/user/documents", true)]
+ [InlineData("/home/usEr", "/home/User/documents", false)]
+ [InlineData("/home/user/documents", "/home/user/documents", true)]
+ [InlineData("/home/user/documents", "/home/user", false)]
+ public void IsPrefixOfShouldBeEquivalentToStartsWith(string pathA, string pathB, bool expected)
{
- if (OperatingSystem.IsWindows()) return;
-
// Arrange
var a = new AbsolutePath(pathA);
var b = new AbsolutePath(pathB);
- // Act
- var isPrefix = a.IsPrefixOf(b);
- var startsWith = b.Value.StartsWith(a.Value);
-
// Assert
- Assert.Equal(isPrefix, startsWith);
+ Assert.Equal(expected, a.IsPrefixOf(b));
+ Assert.Equal(expected, b.StartsWith(a));
}
[Theory]
diff --git a/TruePath.Tests/LocalPathTests.cs b/TruePath.Tests/LocalPathTests.cs
index baeeb3d..d5c877b 100644
--- a/TruePath.Tests/LocalPathTests.cs
+++ b/TruePath.Tests/LocalPathTests.cs
@@ -92,9 +92,52 @@ public void AbsolutePathIsNormalizedOnCreation()
[InlineData("/foo", "/foo1/bar/", false)]
[InlineData("/foo", "/foo1", false)]
[InlineData("/foo", "/foo", true)]
- public void IsPrefixOf(string prefix, string other, bool result)
+ [InlineData("/", "/frob", true)]
+ [InlineData("", "foo", true)]
+ [InlineData(".", "foo", true)]
+ [InlineData(".", "", true)]
+ [InlineData("a/..", "foo/bar", true)]
+ [InlineData(".", "/foo", false)]
+ [InlineData("", "/foo", false)]
+ [InlineData("/", "foo", false)]
+ [InlineData("/foo", "foo/bar", false)]
+ [InlineData("", "../evil", false)]
+ [InlineData(".", "..", false)]
+ [InlineData("", "..bar", true)]
+ [InlineData("\u00ADfoo", "fooo/bar", false)]
+ [InlineData("\u00ADfoo", "\u00ADfoo/bar", true)]
+ public void IsPrefixOfAndStartsWith(string prefix, string other, bool result)
{
- Assert.Equal(result, new LocalPath(prefix).IsPrefixOf(new LocalPath(other)));
+ var a = new LocalPath(prefix);
+ var b = new LocalPath(other);
+
+ Assert.Equal(result, a.IsPrefixOf(b));
+ Assert.Equal(result, b.StartsWith(a));
+ }
+
+ [Fact]
+ public void EmptyParentIsPrefixOfDescendantPath()
+ {
+ var parent = new LocalPath("foo").Parent;
+ Assert.NotNull(parent);
+ Assert.Equal("", parent.Value.Value);
+ Assert.True(parent.Value.IsPrefixOf(new LocalPath("bar")));
+ }
+
+ [Fact]
+ public void EmptyParentIsNotPrefixOfPathEscapingUpwards()
+ {
+ var parent = new LocalPath("foo").Parent;
+ Assert.NotNull(parent);
+ Assert.False(parent.Value.IsPrefixOf(new LocalPath("../evil")));
+ }
+
+ [Fact]
+ public void IsPrefixOfRooted()
+ {
+ var root = new LocalPath(OperatingSystem.IsWindows() ? @"A:\" : "/");
+ var subRoot = root / "frob";
+ Assert.True(root.IsPrefixOf(subRoot));
}
[Fact]
diff --git a/TruePath/AbsolutePath.cs b/TruePath/AbsolutePath.cs
index eb71479..4632b78 100644
--- a/TruePath/AbsolutePath.cs
+++ b/TruePath/AbsolutePath.cs
@@ -85,7 +85,7 @@ public AbsolutePath PathRoot() =>
public string FileName => Underlying.FileName;
///
- public bool StartsWith(AbsolutePath other) => Value.StartsWith(other.Value);
+ public bool StartsWith(AbsolutePath other) => other.IsPrefixOf(this);
///
/// Creates a new path instance of type from the specified string value.
@@ -95,13 +95,7 @@ public AbsolutePath PathRoot() =>
public static AbsolutePath Create(string value) => new(value);
///
- public bool IsPrefixOf(AbsolutePath other)
- {
- if (!(Value.Length <= other.Value.Length && other.Value.StartsWith(Value))) return false;
- return other.Value.Length == Value.Length ||
- Value[Value.Length - 1] == Path.DirectorySeparatorChar ||
- other.Value[Value.Length] == Path.DirectorySeparatorChar;
- }
+ public bool IsPrefixOf(AbsolutePath other) => Underlying.IsPrefixOf(other.Underlying);
/// Gets or sets the current working directory as an AbsolutePath instance.
/// The current working directory.
diff --git a/TruePath/IPath.cs b/TruePath/IPath.cs
index e7f01b1..6a61d03 100644
--- a/TruePath/IPath.cs
+++ b/TruePath/IPath.cs
@@ -38,7 +38,16 @@ public interface IPath where TPath : IPath
#endif
///
- /// Checks for a non-strict prefix: if the paths are equal, then they are still considered prefixes of each other.
+ /// Checks for a non-strict prefix: if the paths are equal, then they are still considered prefixes of each other,
+ /// so every path is a prefix of itself.
+ ///
+ ///
+ /// The comparison is performed over whole path segments: /foo is not a prefix of
+ /// /foo1, even though one path string starts with the other.
+ ///
+ ///
+ /// This is the exact inverse of : a.IsPrefixOf(b) means the same as
+ /// b.StartsWith(a).
///
/// Note that currently this comparison is case-sensitive.
bool IsPrefixOf(TPath other);
@@ -47,6 +56,18 @@ public interface IPath where TPath : IPath
/// Determines whether the current path starts with the specified path.
///
/// The path to compare to the current path.
+ ///
+ /// Checks for a non-strict prefix: if the paths are equal, then each still starts with the other, so every path
+ /// starts with itself.
+ ///
+ ///
+ /// The comparison is performed over whole path segments: /foo1 does not start with
+ /// /foo, even though one path string starts with the other.
+ ///
+ ///
+ /// This is the exact inverse of : a.StartsWith(b) means the same as
+ /// b.IsPrefixOf(a).
+ ///
/// Note that currently this comparison is case-sensitive.
bool StartsWith(TPath other);
diff --git a/TruePath/LocalPath.cs b/TruePath/LocalPath.cs
index a19d929..350b0a1 100644
--- a/TruePath/LocalPath.cs
+++ b/TruePath/LocalPath.cs
@@ -10,6 +10,11 @@ namespace TruePath;
/// A path pointing to a place in the local file system.
/// It may be either absolute or relative.
///
+/// An empty path designates the current directory. Normalizing an empty string, . or
+/// a/.. produces it, and so does of a relative path consisting of a single segment
+/// (e.g. the parent of foo).
+///
+///
/// Always stored in a normalized form. Read the documentation on to
/// know what form of normalization the path uses.
///
@@ -41,6 +46,10 @@ public readonly struct LocalPath(string value) : IEquatable, ICompara
private static char Separator => Path.DirectorySeparatorChar;
+ private static bool StartsWithParentDirectoryReference(string value) =>
+ value.Length >= 2 && value[0] == '.' && value[1] == '.'
+ && (value.Length == 2 || value[2] == Separator);
+
///
public string Value { get; } = PathStrings.Normalize(value);
@@ -51,9 +60,15 @@ public readonly struct LocalPath(string value) : IEquatable, ICompara
/// will be an additional requirement for a path to be either a DOS device path or start from a disk letter.
///
///
+ // TODO[#224]: narrowing this to true absolute paths (kind 1 in the taxonomy at IsPrefixOf) requires updating
+ // IsPrefixOf in the same change: it relies on this property to tell rooted paths from relative ones.
public bool IsAbsolute => Path.IsPathRooted(Value);
///
+ ///
+ /// The parent of a relative path consisting of a single segment is the empty path (the current
+ /// directory), not .
+ ///
public LocalPath? Parent
{
get
@@ -108,7 +123,7 @@ public override bool Equals(object? obj)
}
///
- public bool StartsWith(LocalPath other) => Value.StartsWith(other.Value);
+ public bool StartsWith(LocalPath other) => other.IsPrefixOf(this);
///
/// Creates a new instance from the specified string value.
@@ -118,10 +133,45 @@ public override bool Equals(object? obj)
public static LocalPath Create(string value) => new(value);
///
+ ///
+ ///
+ /// An empty path designates the current directory. It is a prefix of every relative path that stays
+ /// at or below that directory, which means every relative path that does not begin with a ..
+ /// reference.
+ ///
+ ///
+ /// An absolute path is never a prefix of a relative one, and vice versa: such a comparison
+ /// would require resolving the relative path against the current directory, which this type never does. Any
+ /// pair of paths differing in is reported as unrelated.
+ ///
+ ///
public bool IsPrefixOf(LocalPath other)
{
- if (!(Value.Length <= other.Value.Length && other.Value.StartsWith(Value))) return false;
- return other.Value.Length == Value.Length || other.Value[Value.Length] == Separator;
+ // TODO[#224]: IsAbsolute is Path.IsPathRooted, which is too coarse for this algorithm. On Windows there are
+ // really four kinds of path, and no path of one kind should ever be considered a prefix of a path of another:
+ // 1. true absolute: C:\Windows
+ // 2. rooted diskless: \Windows
+ // 3. current on disk: C: (and C:Windows, relative to the current directory of drive C:)
+ // 4. true relative: Windows, ..\Windows
+ // AbsolutePath exists to cover kind 1 only, while LocalPath is applicable to all four. IsPathRooted answers
+ // true for kinds 1, 2 and 3 alike, so the check below only separates {1, 2, 3} from {4}. When IsAbsolute is
+ // eventually narrowed to kind 1 - as its own documentation anticipates - this comparison must not simply
+ // follow it: it needs the full four-way distinction. The path kind should then be extracted into a separate
+ // field or property and matched on here.
+ if (IsAbsolute != other.IsAbsolute) return false;
+
+ // The empty path is the current directory, so every path at or below it has it as a prefix - but one
+ // starting with a ".." reference points outside it. Normalization only ever keeps such references at the
+ // very start of a path, so testing the first segment is enough.
+ if (Value.Length == 0) return !StartsWithParentDirectoryReference(other.Value);
+
+ // TODO[#225]: this comparison is ordinal, while Equals follows the platform default, so on Windows and macOS
+ // two paths may be equal and yet not prefixes of each other.
+ if (!(Value.Length <= other.Value.Length && other.Value.StartsWith(Value, StringComparison.Ordinal)))
+ return false;
+ return other.Value.Length == Value.Length ||
+ Value[Value.Length - 1] == Separator ||
+ other.Value[Value.Length] == Separator;
}
///
diff --git a/TruePath/PathStrings.cs b/TruePath/PathStrings.cs
index 8f3ca29..1054176 100644
--- a/TruePath/PathStrings.cs
+++ b/TruePath/PathStrings.cs
@@ -28,10 +28,12 @@ public static class PathStrings
/// / on Unix),
///
/// -
- /// resolving any sequence of current and parent directory marks (subsequently, . and ..)
- /// if possible (meaning they will not be replaced if they are in the root position: paths such as
- /// . or ../.. will not be affected by the normalization, while e.g. foo/../. will
- /// be resolved to just foo),
+ /// resolving any sequence of current and parent directory references (subsequently, . and
+ /// ..) if possible (e.g. foo/../. is resolved to just foo). A path that resolves
+ /// to the current directory is normalized to an empty path: both . and a/..
+ /// become "". Parent directory references that cannot be resolved are preserved,
+ /// since there is nothing above them to fold into: .. and ../.. are not affected by the
+ /// normalization,
///
/// -
/// trimming all trailing separators (e.g. a/b/c/d//// is normalized to