diff --git a/CHANGELOG.md b/CHANGELOG.md index e6961d9..bfd36ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/TruePath.Tests/LocalPathTests.cs b/TruePath.Tests/LocalPathTests.cs index dd5f3e0..1a95456 100644 --- a/TruePath.Tests/LocalPathTests.cs +++ b/TruePath.Tests/LocalPathTests.cs @@ -33,11 +33,13 @@ 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); @@ -45,6 +47,22 @@ public void ConstructionTest(string pathString, string expectedValue) } [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("../..", "../../..")] @@ -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); @@ -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)); @@ -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(); @@ -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(); diff --git a/TruePath.Tests/NormalizationTests.cs b/TruePath.Tests/NormalizationTests.cs index 227f869..1793b17 100644 --- a/TruePath.Tests/NormalizationTests.cs +++ b/TruePath.Tests/NormalizationTests.cs @@ -20,7 +20,7 @@ public void NormalizedPathDoesNotEndWithDirSeparator(List 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); diff --git a/TruePath.Tests/PathExtensionsTests.cs b/TruePath.Tests/PathExtensionsTests.cs index 71f349c..f128371 100644 --- a/TruePath.Tests/PathExtensionsTests.cs +++ b/TruePath.Tests/PathExtensionsTests.cs @@ -7,6 +7,7 @@ namespace TruePath.Tests; public class PathExtensionsTests { [Theory] + [InlineData(".", ".")] [InlineData("..", ".")] [InlineData("foo/bar.txt", ".txt")] [InlineData("/foo/bar.txt", ".txt")] @@ -44,6 +45,7 @@ public void GetExtensionWithoutDotTests(string path, string expected) } [Theory] + [InlineData(".", "")] [InlineData("..", ".")] [InlineData("foo/bar.txt", "bar")] [InlineData("/foo/bar.txt", "bar")] @@ -62,6 +64,7 @@ public void GetFilenameWithoutExtensionTests(string path, string expected) } [Theory] + [InlineData(".")] [InlineData("..")] [InlineData("file.txt")] [InlineData("file..txt")] diff --git a/TruePath.Tests/PathStringsTests.cs b/TruePath.Tests/PathStringsTests.cs index f1166ba..7af6a3f 100644 --- a/TruePath.Tests/PathStringsTests.cs +++ b/TruePath.Tests/PathStringsTests.cs @@ -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/../../..", "../..")] @@ -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")] diff --git a/TruePath/LocalPath.cs b/TruePath/LocalPath.cs index 454fa2b..abbad93 100644 --- a/TruePath/LocalPath.cs +++ b/TruePath/LocalPath.cs @@ -13,7 +13,7 @@ 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 +/// The 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). /// @@ -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) @@ -140,8 +139,8 @@ public PathKind Kind /// /// - /// The parent of a relative path consisting of a single segment is the empty path (the current - /// directory), not . Similarly, on Windows, the parent of a bare drive (C:, the + /// The parent of a relative path consisting of a single segment is . (the current directory), not + /// . Similarly, on Windows, the parent of a bare drive (C:, the /// current directory of that drive) is C:... /// public LocalPath? Parent @@ -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 / ".."; } @@ -219,7 +220,7 @@ public override bool Equals(object? obj) /// /// /// - /// An empty path designates the current directory. It is a prefix of every relative path that stays + /// The 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. /// @@ -237,7 +238,7 @@ public override bool Equals(object? obj) /// /// On Windows, paths relative to the current directory of a drive (such as C:Windows) are only related /// if they have the same drive letter. A bare drive (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 . does for relative paths. /// /// /// Note that C:\ will not be considered as a prefix of C:Folder, even though they @@ -280,10 +281,7 @@ public bool IsPrefixOf(LocalPath other) private static bool IsSegmentPrefix(ReadOnlySpan prefix, ReadOnlySpan 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; @@ -292,6 +290,13 @@ private static bool IsSegmentPrefix(ReadOnlySpan prefix, ReadOnlySpan + /// Whether a normalized relative path (or its part left after stripping a drive letter or the leading .. + /// references) designates the directory it is relative to. + /// + private static bool IsCurrentDirectory(ReadOnlySpan value) => + value.Length == 0 || value is "."; + private static bool IsSameDrive(string a, string b) => char.ToUpperInvariant(a[0]) == char.ToUpperInvariant(b[0]); /// @@ -307,7 +312,7 @@ private static bool IsSegmentPrefix(ReadOnlySpan prefix, ReadOnlySpanresolved against the current directory, the same way /// 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 - /// a/b relative to a is b. An empty path designates the current directory. + /// a/b relative to a is b. The path . designates the current directory. /// /// /// If the resolved paths have different roots (on Windows, e.g. paths on different drives), there's no relative @@ -317,13 +322,10 @@ private static bool IsSegmentPrefix(ReadOnlySpan prefix, ReadOnlySpan 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 } @@ -346,8 +348,8 @@ public LocalPath RelativeTo(LocalPath basePath) /// /// (x): is appended after a separator, so /// C:\base / x is C:\base\x. No separator is added after a bare drive: C: / x is - /// C:x. Appending an empty path returns the base path unchanged, and appending to an empty path - /// returns . + /// C:x. Appending . (the current directory) returns the base path unchanged, and appending + /// to . returns . /// /// /// (\x): the drive of the base path is kept, if there is @@ -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; diff --git a/TruePath/PathExtensions.cs b/TruePath/PathExtensions.cs index 1392c5c..6472bc1 100644 --- a/TruePath/PathExtensions.cs +++ b/TruePath/PathExtensions.cs @@ -78,8 +78,8 @@ public static string GetFilenameWithoutExtension(this IPath path) => /// /// /// - /// Thrown if the 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:\, + /// Thrown if the has no file name to change: it designates the current directory + /// (.), it consists of a root or a drive designator (Windows) alone, such as C:\, /// / or C:, or its last segment is a parent directory reference (..). /// /// @@ -139,8 +139,8 @@ public static TPath WithExtension(this TPath path, string? extension) whe /// /// /// - /// Thrown if the 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:\, / + /// Thrown if the has no file name to change: it designates the current directory + /// (.), it consists of a root or a drive designator alone, such as C:\, / /// or C:, or its last segment is a parent directory reference (..). /// /// @@ -197,8 +197,8 @@ public static AbsolutePath WithExtension(this AbsolutePath path, string? extensi /// /// /// - /// Thrown if the 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:\, / + /// Thrown if the has no file name to change: it designates the current directory + /// (.), it consists of a root or a drive designator alone, such as C:\, / /// or C:, or its last segment is a parent directory reference (..). /// /// diff --git a/TruePath/PathKind.cs b/TruePath/PathKind.cs index 70a8041..54a8107 100644 --- a/TruePath/PathKind.cs +++ b/TruePath/PathKind.cs @@ -29,8 +29,8 @@ public enum PathKind Absolute, /// - /// A path relative to the current directory: e.g. Windows, ..\Windows, or the empty path (the - /// current directory itself). + /// A path relative to the current directory: e.g. Windows, ..\Windows, or . (the current + /// directory itself). /// /// /// On Unix, every path not starting with / is of this kind, including C:foo and \foo. diff --git a/TruePath/PathStrings.cs b/TruePath/PathStrings.cs index 24f17b6..8207adb 100644 --- a/TruePath/PathStrings.cs +++ b/TruePath/PathStrings.cs @@ -38,8 +38,9 @@ public static class PathStrings /// /// 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, + /// to the current directory is normalized to .: an empty string, ., ./. and + /// a/.. all become . (on Windows, a bare drive such as C: or C:. is + /// normalized to C:, though). 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. The only exception is a parent directory reference directly after the root: the root /// has no parent, so such a reference is dropped (e.g. /.. is normalized to /, and @@ -169,7 +170,7 @@ block is ".." { if (array != null) ArrayPool.Shared.Return(array); - return containsDriveLetter ? path[..2] : string.Empty; + return containsDriveLetter ? path[..2] : "."; } // remove / at the end of path diff --git a/docs/index.md b/docs/index.md index 941c4ce..1cea0ad 100644 --- a/docs/index.md +++ b/docs/index.md @@ -50,8 +50,8 @@ The paths are stored in the **normalized form**. - All the `Path.AltDirectorySeparatorChar` are converted to `Path.DirectorySeparatorChar` (e.g. `/` to `\` on Windows). - Any repeated separators in the input are collapsed to only one separator (e.g. `//` to just `/` on Unix). - Any sequence of current and parent directory marks (subsequently, `.` and `..`) is resolved if possible: e.g. - `foo/bar/../.` 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 marks at the start of a relative path cannot be resolved, + `foo/bar/../.` is resolved to just `foo`. A path that resolves to the current directory is normalized to `.` (an + empty string, `.` and `a/..` all become `.`). Parent directory marks at the start of a relative path cannot be resolved, and are preserved: `..` and `../..` are not affected by the normalization. A parent directory mark directly after the root is dropped, since the root has no parent: `/..` is normalized to `/`, and `C:\..\x` to `C:\x`. - All trailing separators are trimmed (e.g. `a/b/c/d////` is normalized to `a/b/c/d`), except for the case of the root folder: `/` (Unix) or `X:\` (Windows) keeps one final separator (because it would become an empty string or a reference to the current path on disk `X:` without the trailing separator).