Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 7 additions & 12 deletions TruePath.Tests/AbsolutePathTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
47 changes: 45 additions & 2 deletions TruePath.Tests/LocalPathTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
10 changes: 2 additions & 8 deletions TruePath/AbsolutePath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public AbsolutePath PathRoot() =>
public string FileName => Underlying.FileName;

/// <inheritdoc cref="IPath{TPath}.StartsWith(TPath)"/>
public bool StartsWith(AbsolutePath other) => Value.StartsWith(other.Value);
public bool StartsWith(AbsolutePath other) => other.IsPrefixOf(this);

/// <summary>
/// Creates a new path instance of type <see cref="AbsolutePath" /> from the specified string value.
Expand All @@ -95,13 +95,7 @@ public AbsolutePath PathRoot() =>
public static AbsolutePath Create(string value) => new(value);

/// <inheritdoc cref="IPath{TPath}.IsPrefixOf(TPath)"/>
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);

/// <summary>Gets or sets the current working directory as an AbsolutePath instance.</summary>
/// <value>The current working directory.</value>
Expand Down
23 changes: 22 additions & 1 deletion TruePath/IPath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,16 @@ public interface IPath<TPath> where TPath : IPath<TPath>
#endif

/// <remarks>
/// 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.
/// </remarks>
/// <remarks>
/// The comparison is performed over whole path <b>segments</b>: <c>/foo</c> is <b>not</b> a prefix of
/// <c>/foo1</c>, even though one path string starts with the other.
/// </remarks>
/// <remarks>
/// This is the exact inverse of <see cref="StartsWith"/>: <c>a.IsPrefixOf(b)</c> means the same as
/// <c>b.StartsWith(a)</c>.
/// </remarks>
/// <remarks>Note that currently this comparison is case-sensitive.</remarks>
bool IsPrefixOf(TPath other);
Expand All @@ -47,6 +56,18 @@ public interface IPath<TPath> where TPath : IPath<TPath>
/// Determines whether the current path starts with the specified path.
/// </summary>
/// <param name="other">The path to compare to the current path.</param>
/// <remarks>
/// Checks for a non-strict prefix: if the paths are equal, then each still starts with the other, so every path
/// starts with itself.
/// </remarks>
/// <remarks>
/// The comparison is performed over whole path <b>segments</b>: <c>/foo1</c> does <b>not</b> start with
/// <c>/foo</c>, even though one path string starts with the other.
/// </remarks>
/// <remarks>
/// This is the exact inverse of <see cref="IsPrefixOf"/>: <c>a.StartsWith(b)</c> means the same as
/// <c>b.IsPrefixOf(a)</c>.
/// </remarks>
/// <remarks>Note that currently this comparison is case-sensitive.</remarks>
bool StartsWith(TPath other);

Expand Down
56 changes: 53 additions & 3 deletions TruePath/LocalPath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ namespace TruePath;
/// <para>A path pointing to a place in the local file system.</para>
/// <para>It may be either absolute or relative.</para>
/// <para>
/// An <b>empty</b> path designates the <b>current directory</b>. Normalizing an empty string, <c>.</c> or
/// <c>a/..</c> produces it, and so does <see cref="Parent"/> of a relative path consisting of a single segment
/// (e.g. the parent of <c>foo</c>).
/// </para>
/// <para>
/// Always stored in a normalized form. Read the documentation on <see cref="TruePath.PathStrings.Normalize"/> to
/// know what form of normalization the path uses.
/// </para>
Expand Down Expand Up @@ -41,6 +46,10 @@ public readonly struct LocalPath(string value) : IEquatable<LocalPath>, 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);

/// <inheritdoc cref="IPath.Value"/>
public string Value { get; } = PathStrings.Normalize(value);

Expand All @@ -51,9 +60,15 @@ public readonly struct LocalPath(string value) : IEquatable<LocalPath>, ICompara
/// will be an additional requirement for a path to be either a DOS device path or start from a disk letter.
/// </para>
/// </summary>
// 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);

/// <inheritdoc cref="IPath.Parent"/>
/// <remarks>
/// The parent of a relative path consisting of a single segment is the <b>empty</b> path (the current
/// directory), not <see langword="null"/>.
/// </remarks>
public LocalPath? Parent
{
get
Expand Down Expand Up @@ -108,7 +123,7 @@ public override bool Equals(object? obj)
}

/// <inheritdoc cref="IPath{TPath}.StartsWith(TPath)"/>
public bool StartsWith(LocalPath other) => Value.StartsWith(other.Value);
public bool StartsWith(LocalPath other) => other.IsPrefixOf(this);

/// <summary>
/// Creates a new <see cref="LocalPath"/> instance from the specified string value.
Expand All @@ -118,10 +133,45 @@ public override bool Equals(object? obj)
public static LocalPath Create(string value) => new(value);

/// <inheritdoc cref="IPath{TPath}.IsPrefixOf(TPath)"/>
/// <remarks>
/// <para>
/// An <b>empty</b> 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 <c>..</c>
/// reference.
/// </para>
/// <para>
/// An <b>absolute</b> path is never a prefix of a <b>relative</b> 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 <see cref="IsAbsolute"/> is reported as unrelated.
/// </para>
/// </remarks>
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;
}

/// <summary>
Expand Down
10 changes: 6 additions & 4 deletions TruePath/PathStrings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ public static class PathStrings
/// <c>/</c> on Unix),
/// </item>
/// <item>
/// resolving any sequence of current and parent directory marks (subsequently, <c>.</c> and <c>..</c>)
/// if possible (meaning they will not be replaced if they are in the root position: paths such as
/// <c>.</c> or <c>../..</c> will not be affected by the normalization, while e.g. <c>foo/../.</c> will
/// be resolved to just <c>foo</c>),
/// resolving any sequence of current and parent directory references (subsequently, <c>.</c> and
/// <c>..</c>) if possible (e.g. <c>foo/../.</c> is resolved to just <c>foo</c>). A path that resolves
/// to the current directory is normalized to an <b>empty</b> path: both <c>.</c> and <c>a/..</c>
/// become <c>&quot;&quot;</c>. Parent directory references that cannot be resolved are preserved,
/// since there is nothing above them to fold into: <c>..</c> and <c>../..</c> are not affected by the
/// normalization,
/// </item>
/// <item>
/// trimming <b>all</b> trailing separators (e.g. <c>a/b/c/d////</c> is normalized to
Expand Down
Loading