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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- `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
- [#225](https://github.com/ForNeVeR/TruePath/issues/225): Make path prefix checks use the same platform-default case sensitivity as path equality.
- `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
Expand Down
35 changes: 34 additions & 1 deletion TruePath.Tests/AbsolutePathTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,6 @@ public void ParentIsCalculatedCorrectly(string relativePath, string? expectedRel

[Theory]
[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)
Expand All @@ -158,6 +157,40 @@ public void IsPrefixOfShouldBeEquivalentToStartsWith(string pathA, string pathB,
Assert.Equal(expected, b.StartsWith(a));
}

[Fact]
public void IsPrefixOfFollowsPlatformCaseSensitivityForSameName()
{
var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/");
var a = root / "Foo";
var b = root / "foo";

Assert.Equal(Utils.IsPlatformCaseInsensitive(), a.IsPrefixOf(b));
Assert.Equal(Utils.IsPlatformCaseInsensitive(), b.StartsWith(a));
Assert.Equal(a == b, a.IsPrefixOf(b));
}

[Fact]
public void IsPrefixOfFollowsPlatformCaseSensitivityForDescendant()
{
var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/");
var a = root / "Foo";
var b = root / "foo/file.txt";

Assert.Equal(Utils.IsPlatformCaseInsensitive(), a.IsPrefixOf(b));
Assert.Equal(Utils.IsPlatformCaseInsensitive(), b.StartsWith(a));
}

[Fact]
public void IsPrefixOfRequiresWholeSegmentRegardlessOfCase()
{
var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/");
var a = root / "Foo";
var b = root / "foobar";

Assert.False(a.IsPrefixOf(b));
Assert.False(b.StartsWith(a));
}

[Theory]
[InlineData("sub", "sub/a.txt", true)]
[InlineData("sub", "sub", true)]
Expand Down
33 changes: 32 additions & 1 deletion TruePath.Tests/LocalPathTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public void IsPrefixOfShouldBeEquivalentToStartsWith(string pathA, string pathB)

// Act
var isPrefix = a.IsPrefixOf(b);
var startsWith = b.Value.StartsWith(a.Value);
var startsWith = b.StartsWith(a);

// Assert
Assert.Equal(isPrefix, startsWith);
Expand Down Expand Up @@ -115,6 +115,37 @@ public void IsPrefixOfAndStartsWith(string prefix, string other, bool result)
Assert.Equal(result, b.StartsWith(a));
}

[Fact]
public void IsPrefixOfFollowsPlatformCaseSensitivityForSameName()
{
var a = new LocalPath("Foo");
var b = new LocalPath("foo");

Assert.Equal(Utils.IsPlatformCaseInsensitive(), a.IsPrefixOf(b));
Assert.Equal(Utils.IsPlatformCaseInsensitive(), b.StartsWith(a));
Assert.Equal(a == b, a.IsPrefixOf(b));
}

[Fact]
public void IsPrefixOfFollowsPlatformCaseSensitivityForDescendant()
{
var a = new LocalPath("Foo");
var b = new LocalPath("foo/bar");

Assert.Equal(Utils.IsPlatformCaseInsensitive(), a.IsPrefixOf(b));
Assert.Equal(Utils.IsPlatformCaseInsensitive(), b.StartsWith(a));
}

[Fact]
public void IsPrefixOfRequiresWholeSegmentRegardlessOfCase()
{
var a = new LocalPath("Foo");
var b = new LocalPath("foobar");

Assert.False(a.IsPrefixOf(b));
Assert.False(b.StartsWith(a));
}

[Fact]
public void EmptyParentIsPrefixOfDescendantPath()
{
Expand Down
3 changes: 3 additions & 0 deletions TruePath.Tests/Utils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ internal static string ToNonCanonicalCase(this string path)
return nonCanonicalPath;
}

internal static bool IsPlatformCaseInsensitive() =>
OperatingSystem.IsWindows() || OperatingSystem.IsMacOS();

internal static bool RunsOnCi()
{
return Environment.GetEnvironmentVariable("CI") is not null;
Expand Down
2 changes: 1 addition & 1 deletion TruePath/AbsolutePath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ public static AbsolutePath CurrentWorkingDirectory
public override string ToString() => Value;

/// <summary>Compares the path with another.</summary>
/// <remarks>Note that currently this comparison is case-sensitive.</remarks>
/// <remarks>Uses <see cref="PlatformDefaultComparer"/> for platform-default case sensitivity.</remarks>
public bool Equals(AbsolutePath other) => Equals(other, PlatformDefaultComparer);

/// <summary>
Expand Down
19 changes: 7 additions & 12 deletions TruePath/Comparers/PlatformDefaultPathComparer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,14 @@ namespace TruePath.Comparers;
/// </remarks>
internal class PlatformDefaultPathComparer<TPath> : IPathComparer<TPath> where TPath : IPath
{
private readonly StringComparer _stringComparer;
internal static readonly StringComparison DefaultStringComparison =
RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX)
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;

public PlatformDefaultPathComparer()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
_stringComparer = StringComparer.OrdinalIgnoreCase;
}
else
{
_stringComparer = StringComparer.Ordinal;
}
}
private readonly StringComparer _stringComparer = DefaultStringComparison == StringComparison.OrdinalIgnoreCase
? StringComparer.OrdinalIgnoreCase
: StringComparer.Ordinal;

public bool Equals(TPath? x, TPath? y)
{
Expand Down
4 changes: 2 additions & 2 deletions TruePath/IPath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public interface IPath<TPath> where TPath : IPath<TPath>
/// 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>
/// <remarks>Case sensitivity follows the path type's platform-default comparer.</remarks>
bool IsPrefixOf(TPath other);

/// <summary>
Expand All @@ -68,7 +68,7 @@ public interface IPath<TPath> where TPath : IPath<TPath>
/// 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>
/// <remarks>Case sensitivity follows the path type's platform-default comparer.</remarks>
bool StartsWith(TPath other);

#if NET8_0_OR_GREATER
Expand Down
7 changes: 3 additions & 4 deletions TruePath/LocalPath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ public LocalPath? Parent
public override string ToString() => Value;

/// <summary>Compares the path with another.</summary>
/// <remarks>Note that currently this comparison is case-sensitive.</remarks>
/// <remarks>Uses <see cref="PlatformDefaultComparer"/> for platform-default case sensitivity.</remarks>
public bool Equals(LocalPath other) => Equals(other, PlatformDefaultComparer);

/// <summary>
Expand Down Expand Up @@ -165,9 +165,8 @@ public bool IsPrefixOf(LocalPath other)
// 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)))
if (!(Value.Length <= other.Value.Length &&
other.Value.StartsWith(Value, PlatformDefaultPathComparer<LocalPath>.DefaultStringComparison)))
return false;
return other.Value.Length == Value.Length ||
Value[Value.Length - 1] == Separator ||
Expand Down
Loading