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
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- On Windows, `AbsolutePath / LocalPath` now resolves a path relative to the current directory of another drive against the current directory of that drive, so the result is always absolute: e.g. `C:\base / D:x` is `D:\x` if the current directory of drive `D:` is its root. Previously, the result was `D:x`, which is not absolute.
- On Windows, `LocalPath.ResolveToCurrentDirectory` now resolves `\x` against the drive of the current directory, and `D:x` against the current directory of drive `D:`.
- **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 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 path of kind `PathKind.Relative` that does not begin with a `..` reference.
- **Breaking:** path normalization now represents the current directory as `.` instead of an empty string: `PathStrings.Normalize` returns `"."` for `""`, `"."` and `"a/.."`, and so do `LocalPath.Value` and `ToString()` (and `LocalPath.Parent` of a single-segment relative path is `.`). As a consequence, `LocalPath.FileName` of such a path is `.`, the same way it is `..` for `..`, and `GetExtensionWithDot` returns `.` for it.
- `LocalPath.IsPrefixOf` now treats the current directory 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 path of kind `PathKind.Relative` that does not begin with a `..` reference.
- The platform-default path comparers (`LocalPath.PlatformDefaultComparer`, `AbsolutePath.PlatformDefaultComparer`) are now case-insensitive on iOS and tvOS as well, matching the .NET runtime.

### Fixed
Expand All @@ -35,7 +36,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- `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.
- Path normalization no longer drops the root separator when a rooted path ends with `..`: `C:\base\..` is now normalized to `C:\` (was `C:`, a path relative to the current directory of drive `C:`), and `/base/..` to `/` (was an empty path, i.e. the current directory). This affects `PathStrings.Normalize`, `LocalPath` and `AbsolutePath`.
- [#95](https://github.com/ForNeVeR/TruePath/issues/95): Path normalization now drops a `..` directly after the root, since the root has no parent: `/..` is normalized to `/`, and `C:\..\x` to `C:\x`, the same as `Path.GetFullPath` does. Thanks to @sinthu-14 for help!
- `LocalPath.RelativeTo` no longer throws an exception when either path is empty (i.e. designates the current directory).
- `LocalPath.RelativeTo` no longer throws an exception when either path designates the current directory.
- On .NET Standard 2.0 (e.g., .NET Framework), `LocalPath.RelativeTo` and `AbsolutePath.RelativeTo` now use a port of the .NET runtime's `Path.GetRelativePath`, and return the same results as on .NET 8+ (except for the stream syntax, see [#235](https://github.com/ForNeVeR/TruePath/issues/235)). Previously, the result was wrong for a destination equal to or above the base path, and for names containing `%XX` sequences.

## [1.12.0] - 2026-03-14
Expand Down
41 changes: 34 additions & 7 deletions TruePath.Tests/LocalPathTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,36 @@ public void RootedPathAboveRootHasNoParent(string relativePath)
}

[Theory]
[InlineData(".", "")]
[InlineData("", ".")]
[InlineData(".", ".")]
[InlineData("a/..", ".")]
[InlineData("..", "..")]
[InlineData("../..", "../..")]
[InlineData(".../...", ".../...")]
[InlineData(".../..", "")]
[InlineData(".../..", ".")]
public void ConstructionTest(string pathString, string expectedValue)
{
var path = new LocalPath(pathString);
Assert.Equal(expectedValue.Replace('/', Path.DirectorySeparatorChar), path.Value);
}

[Theory]
[InlineData("")]
[InlineData(".")]
[InlineData("./.")]
[InlineData("a/..")]
public void CurrentDirectoryValue(string pathString)
{
var path = new LocalPath(pathString);

Assert.Equal(".", path.Value);
Assert.Equal(".", path.ToString());
Assert.Equal(PathKind.Relative, path.Kind);
Assert.Equal(".", path.FileName);
}

[Theory]
[InlineData("", "..")]
[InlineData(".", "..")]
[InlineData("..", "../..")]
[InlineData("../..", "../../..")]
Expand Down Expand Up @@ -187,16 +205,16 @@ public void IsPrefixOfRequiresWholeSegmentRegardlessOfCase()
}

[Fact]
public void EmptyParentIsPrefixOfDescendantPath()
public void CurrentDirectoryParentIsPrefixOfDescendantPath()
{
var parent = new LocalPath("foo").Parent;
Assert.NotNull(parent);
Assert.Equal("", parent.Value.Value);
Assert.Equal(".", parent.Value.Value);
Assert.True(parent.Value.IsPrefixOf(new LocalPath("bar")));
}

[Fact]
public void EmptyParentIsNotPrefixOfPathEscapingUpwards()
public void CurrentDirectoryParentIsNotPrefixOfPathEscapingUpwards()
{
var parent = new LocalPath("foo").Parent;
Assert.NotNull(parent);
Expand Down Expand Up @@ -296,7 +314,10 @@ public void ResolveToCurrentDirectoryForDriveRelativePathsOnWindows()
[InlineData("a", "a/b", "b")]
[InlineData("a/b", "a", "..")]
[InlineData("a/c", "a/b", "../b")]
[InlineData("a", "a", "")]
[InlineData("a", "a", ".")]
[InlineData("", "a", "a")]
[InlineData("a", "", "..")]
[InlineData("", "", ".")]
public void RelativeToForRelativePaths(string from, string to, string expected)
{
var relativePath = new LocalPath(to).RelativeTo(new LocalPath(from));
Expand Down Expand Up @@ -513,7 +534,10 @@ public void AppendMatrixOnWindows()
("", "D:x", "D:x", "D:x"),
("", "C:x", "C:x", "C:x"),
("", "c:x", "c:x", "c:x"),
("", "", "", ""),
("", "", ".", ""),
(".", "x", "x", @".\x"),
("x", ".", "x", @"x\."),
("C:", ".", "C:", @"C:\."),
];

var failures = new List<string>();
Expand Down Expand Up @@ -548,6 +572,9 @@ public void AppendMatrixOnUnix()
("base", "C:x", "base/C:x"),
("C:", "x", "C:/x"),
("", "x", "x"),
("", "", "."),
(".", "x", "x"),
("x", ".", "x"),
];

var failures = new List<string>();
Expand Down
2 changes: 1 addition & 1 deletion TruePath.Tests/NormalizationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public void NormalizedPathDoesNotEndWithDirSeparator(List<string> pathParts)
var normalizedPath = PathStrings.Normalize(sourcePath);

testOutputHelper.WriteLine($"{sourcePath} => {normalizedPath}");
Assert.True(normalizedPath == ""
Assert.True(normalizedPath == "."
|| normalizedPath == Path.DirectorySeparatorChar.ToString()
|| normalizedPath is [_, ':', _]
|| normalizedPath[^1] != Path.DirectorySeparatorChar);
Expand Down
3 changes: 3 additions & 0 deletions TruePath.Tests/PathExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ namespace TruePath.Tests;
public class PathExtensionsTests
{
[Theory]
[InlineData(".", ".")]
[InlineData("..", ".")]
[InlineData("foo/bar.txt", ".txt")]
[InlineData("/foo/bar.txt", ".txt")]
Expand Down Expand Up @@ -44,6 +45,7 @@ public void GetExtensionWithoutDotTests(string path, string expected)
}

[Theory]
[InlineData(".", "")]
[InlineData("..", ".")]
[InlineData("foo/bar.txt", "bar")]
[InlineData("/foo/bar.txt", "bar")]
Expand All @@ -62,6 +64,7 @@ public void GetFilenameWithoutExtensionTests(string path, string expected)
}

[Theory]
[InlineData(".")]
[InlineData("..")]
[InlineData("file.txt")]
[InlineData("file..txt")]
Expand Down
10 changes: 7 additions & 3 deletions TruePath.Tests/PathStringsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,15 @@ public void SeparatorsAreDeduplicated(string input, string expected)
}

[Theory]
[InlineData(".", "")]
[InlineData("", ".")]
[InlineData(".", ".")]
[InlineData("./.", ".")]
[InlineData("./", ".")]
[InlineData("./foo", "foo")]
[InlineData("..", "..")]
[InlineData("./..", "..")]
[InlineData("a/..", "")]
[InlineData("a/..", ".")]
[InlineData("a/../", ".")]
[InlineData("a/../..", "..")]
[InlineData("a/../../.", "..")]
[InlineData("a/../../..", "../..")]
Expand All @@ -68,7 +72,7 @@ public void SeparatorsAreDeduplicated(string input, string expected)
[InlineData("../../../foo", "../../../foo")]
[InlineData("../foo/..", "..")]
[InlineData("...", "...")]
[InlineData(".../..", "")]
[InlineData(".../..", ".")]
[InlineData(".../...", ".../...")]
[InlineData(".../../...", "...")]
[InlineData("foo/bar/../file.ext", "foo/file.ext")]
Expand Down
44 changes: 23 additions & 21 deletions TruePath/LocalPath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ 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
/// The path <c>.</c> 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>
Expand Down Expand Up @@ -80,7 +80,6 @@ public PathKind Kind
{
get
{
if (Value.Length == 0) return PathKind.Relative;
if (!PathStrings.IsDriveBasedSystem) return Value[0] == Separator ? PathKind.Absolute : PathKind.Relative;

if (HasDriveLetter)
Expand Down Expand Up @@ -140,8 +139,8 @@ public PathKind Kind

/// <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"/>. Similarly, on Windows, the parent of a bare drive (<c>C:</c>, the
/// The parent of a relative path consisting of a single segment is <c>.</c> (the current directory), not
/// <see langword="null"/>. Similarly, on Windows, the parent of a bare drive (<c>C:</c>, the
/// current directory of that drive) is <c>C:..</c>.
/// </remarks>
public LocalPath? Parent
Expand All @@ -154,7 +153,9 @@ public LocalPath? Parent
{
// For C:foo, the rest after the drive letter is a relative path, and follows the same rules.
var relativePart = kind == PathKind.DriveCurrentDirectoryRelative ? Value[2..] : Value;
if (relativePart == "" || relativePart == ".." || relativePart.EndsWith($"{Separator}.."))
if (IsCurrentDirectory(relativePart.AsSpan())
|| relativePart == ".."
|| relativePart.EndsWith($"{Separator}.."))
return this / "..";
}

Expand Down Expand Up @@ -219,7 +220,7 @@ public override bool Equals(object? obj)
/// <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
/// The path <c>.</c> 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>
Expand All @@ -237,7 +238,7 @@ public override bool Equals(object? obj)
/// <para>
/// On Windows, paths relative to the current directory of a drive (such as <c>C:Windows</c>) are only related
/// if they have the same drive letter. A bare drive (<c>C:</c>) designates that drive's current directory, and
/// behaves the same way as the empty path does for relative paths.
/// behaves the same way as <c>.</c> does for relative paths.
/// </para>
/// <para>
/// Note that <c>C:\</c> will <i>not</i> be considered as a prefix of <c>C:Folder</c>, even though they
Expand Down Expand Up @@ -280,10 +281,7 @@ public bool IsPrefixOf(LocalPath other)

private static bool IsSegmentPrefix(ReadOnlySpan<char> prefix, ReadOnlySpan<char> path)
{
// 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 (prefix.Length == 0) return !StartsWithParentDirectoryReference(path);
if (IsCurrentDirectory(prefix)) return !StartsWithParentDirectoryReference(path);

if (!path.StartsWith(prefix, PlatformDefaultPathComparer.DefaultStringComparison))
return false;
Expand All @@ -292,6 +290,13 @@ private static bool IsSegmentPrefix(ReadOnlySpan<char> prefix, ReadOnlySpan<char
path[prefix.Length] == Separator;
}

/// <summary>
/// Whether a normalized relative path (or its part left after stripping a drive letter or the leading <c>..</c>
/// references) designates the directory it is relative to.
/// </summary>
private static bool IsCurrentDirectory(ReadOnlySpan<char> value) =>
value.Length == 0 || value is ".";

private static bool IsSameDrive(string a, string b) => char.ToUpperInvariant(a[0]) == char.ToUpperInvariant(b[0]);

/// <summary>
Expand All @@ -307,7 +312,7 @@ private static bool IsSegmentPrefix(ReadOnlySpan<char> prefix, ReadOnlySpan<char
/// If either path is not absolute, it is first <b>resolved</b> against the current directory, the same way
/// <see cref="ResolveToCurrentDirectory"/> does, so the result may depend on the current directory (or a
/// drive-specific current directory on Windows). Two relative paths are resolved against the same directory, so
/// <c>a/b</c> relative to <c>a</c> is <c>b</c>. An empty path designates the current directory.
/// <c>a/b</c> relative to <c>a</c> is <c>b</c>. The path <c>.</c> designates the current directory.
/// </para>
/// <para>
/// If the resolved paths have different roots (on Windows, e.g. paths on different drives), there's no relative
Expand All @@ -317,13 +322,10 @@ private static bool IsSegmentPrefix(ReadOnlySpan<char> prefix, ReadOnlySpan<char
/// </remarks>
public LocalPath RelativeTo(LocalPath basePath)
{
// An empty value means the current directory, but GetRelativePath doesn't work for empty strings.
var relativeTo = basePath.Value.Length == 0 ? "." : basePath.Value;
var path = Value.Length == 0 ? "." : Value;
#if NET8_0_OR_GREATER
return new(Path.GetRelativePath(relativeTo, path));
return new(Path.GetRelativePath(basePath.Value, Value));
#else
return new(PathPolyfills.GetRelativePath(relativeTo, path));
return new(PathPolyfills.GetRelativePath(basePath.Value, Value));
#endif
}

Expand All @@ -346,8 +348,8 @@ public LocalPath RelativeTo(LocalPath basePath)
/// <item>
/// <see cref="PathKind.Relative"/> (<c>x</c>): <paramref name="b"/> is appended after a separator, so
/// <c>C:\base / x</c> is <c>C:\base\x</c>. No separator is added after a bare drive: <c>C: / x</c> is
/// <c>C:x</c>. Appending an empty path returns the base path unchanged, and appending to an empty path
/// returns <paramref name="b"/>.
/// <c>C:x</c>. Appending <c>.</c> (the current directory) returns the base path unchanged, and appending
/// to <c>.</c> returns <paramref name="b"/>.
/// </item>
/// <item>
/// <see cref="PathKind.DriveRootRelative"/> (<c>\x</c>): the drive of the base path is kept, if there is
Expand Down Expand Up @@ -408,8 +410,8 @@ private static string Append(LocalPath basePath, LocalPath b)
private static string Join(LocalPath basePath, string relativePath)
{
var baseValue = basePath.Value;
if (relativePath.Length == 0) return baseValue;
if (baseValue.Length == 0) return relativePath;
if (IsCurrentDirectory(relativePath.AsSpan())) return baseValue;
if (baseValue == ".") return relativePath;

// A bare drive (C:) designates the current directory of the drive, and C:x is a path relative to it.
var isBareDrive = baseValue.Length == 2 && basePath.HasDriveLetter;
Expand Down
12 changes: 6 additions & 6 deletions TruePath/PathExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ public static string GetFilenameWithoutExtension(this IPath path) =>
/// </returns>
/// <exception cref="ArgumentException">
/// <para>
/// Thrown if the <paramref name="path"/> has no file name to change: it is empty (which designates the
/// current directory), it consists of a root or a drive designator (Windows) alone, such as <c>C:\</c>,
/// Thrown if the <paramref name="path"/> has no file name to change: it designates the current directory
/// (<c>.</c>), it consists of a root or a drive designator (Windows) alone, such as <c>C:\</c>,
/// <c>/</c> or <c>C:</c>, or its last segment is a parent directory reference (<c>..</c>).
/// </para>
/// <para>
Expand Down Expand Up @@ -139,8 +139,8 @@ public static TPath WithExtension<TPath>(this TPath path, string? extension) whe
/// </returns>
/// <exception cref="ArgumentException">
/// <para>
/// Thrown if the <paramref name="path"/> has no file name to change: it is empty (which designates the
/// current directory), it consists of a root or a drive designator alone, such as <c>C:\</c>, <c>/</c>
/// Thrown if the <paramref name="path"/> has no file name to change: it designates the current directory
/// (<c>.</c>), it consists of a root or a drive designator alone, such as <c>C:\</c>, <c>/</c>
/// or <c>C:</c>, or its last segment is a parent directory reference (<c>..</c>).
/// </para>
/// <para>
Expand Down Expand Up @@ -197,8 +197,8 @@ public static AbsolutePath WithExtension(this AbsolutePath path, string? extensi
/// </returns>
/// <exception cref="ArgumentException">
/// <para>
/// Thrown if the <paramref name="path"/> has no file name to change: it is empty (which designates the
/// current directory), it consists of a root or a drive designator alone, such as <c>C:\</c>, <c>/</c>
/// Thrown if the <paramref name="path"/> has no file name to change: it designates the current directory
/// (<c>.</c>), it consists of a root or a drive designator alone, such as <c>C:\</c>, <c>/</c>
/// or <c>C:</c>, or its last segment is a parent directory reference (<c>..</c>).
/// </para>
/// <para>
Expand Down
4 changes: 2 additions & 2 deletions TruePath/PathKind.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ public enum PathKind
Absolute,

/// <summary>
/// A path relative to the current directory: e.g. <c>Windows</c>, <c>..\Windows</c>, or the empty path (the
/// current directory itself).
/// A path relative to the current directory: e.g. <c>Windows</c>, <c>..\Windows</c>, or <c>.</c> (the current
/// directory itself).
/// </summary>
/// <remarks>
/// On Unix, every path not starting with <c>/</c> is of this kind, including <c>C:foo</c> and <c>\foo</c>.
Expand Down
Loading
Loading