diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f105a03..462ca2b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -78,7 +78,7 @@ jobs: - name: Verify encoding shell: pwsh run: scripts/Test-Encoding.ps1 - nowarn-empty: + xml-doc: runs-on: ubuntu-26.04 env: DOTNET_CLI_TELEMETRY_OPTOUT: 1 @@ -94,7 +94,5 @@ jobs: with: key: ${{ runner.os }}.nuget.${{ hashFiles('**/*.*proj', '**/*.props') }} path: ${{ env.NUGET_PACKAGES }} - - name: Check out the sources - uses: actions/checkout@v7 - - name: Verify with NoWarn as empty - run: dotnet build /p:NoWarn='' --no-incremental + - name: Verify XML documentation + run: dotnet build -p:CheckXmlDoc=true --no-incremental diff --git a/CHANGELOG.md b/CHANGELOG.md index 606ed38..bb98c50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,17 +8,30 @@ Changelog ========= 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). +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), with one exception: the behavior of the members and types marked as `[System.Diagnostics.CodeAnalysis.ExperimentalAttribute]` may change without a major version bump (see [Versioning](README.md#versioning)). ## [Unreleased] +### Added +- `LocalPath.PathRoot`, returning the root of an absolute path, or the drive root of a drive-relative path (e.g. `C:foo`, which only exists on Windows), or `null` when the root can't be determined. +- `AbsolutePath.PathRoot` property. +- [#224](https://github.com/ForNeVeR/TruePath/issues/224): `PathKind` enumeration and `LocalPath.Kind` property, telling an absolute path from a relative one, and on Windows also from a path rooted without a drive letter (`\Windows`) and from a path relative to the current directory of a drive (`C:Windows`). These are **experimental** (diagnostic `TRUEPATH001`): more kinds may be added later, e.g. for UNC paths. + ### Changed +- **Breaking:** on Windows, `LocalPath.IsAbsolute` now only returns `true` for fully qualified paths, such as `C:\Windows`. Paths rooted without a drive letter (`\Windows`), and paths relative to the current directory of a drive (`C:Windows`, `C:`) are not absolute anymore, and `AbsolutePath` now rejects them. Previously, this was following the `Path.IsPathRooted` API. +- **Breaking:** `LocalPath.IsPrefixOf` and `StartsWith` (and their `AbsolutePath` counterparts) now consider paths of different `PathKind` unrelated. On Windows, `C:` is now considered a prefix of `C:Windows`. +- **Breaking:** on Windows, the `/` operator of `LocalPath` and `AbsolutePath` no longer delegates to `Path.Combine`, and instead follows the algorithm of C++ `std::filesystem::path::operator/` ([fs.path.append](https://eel.is/c++draft/fs.path.append)) with case-insensitive drive letters. In particular, `C:\base / \x` is now `C:\x` (was `\x`), `C:\base / C:x` is now `C:\base\x` (was `C:x`), and `C: / x` is now `C:x` (was `C:\x`). On Unix, the behavior is unchanged. +- **Breaking:** `AbsolutePath / LocalPath` now throws an `ArgumentException` if the result is not absolute. This only happens on Windows, when appending a path relative to the current directory of another drive: e.g. `C:\base / D:x`. +- 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 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. +- The platform-default path comparers (`LocalPath.PlatformDefaultComparer`, `AbsolutePath.PlatformDefaultComparer`) are now case-insensitive on iOS and tvOS as well, matching the .NET runtime. ### 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. +- `LocalPath.RelativeTo` no longer throws an exception when either path is empty (i.e. 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+. 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 ### Changed diff --git a/Directory.Build.props b/Directory.Build.props index 9fa6aa8..d12149f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,5 @@ @@ -8,7 +8,7 @@ SPDX-License-Identifier: MIT 1.12.0 MIT - 2024-2026 TruePath contributors <https://github.com/ForNeVeR/TruePath> + 2024-2026 TruePath contributors <https://github.com/ForNeVeR/TruePath>; .NET Foundation and Contributors README.md true true @@ -20,7 +20,8 @@ SPDX-License-Identifier: MIT enable enable true - CS0419;CS1570;CS1571;CS1572;CS1573;CS1574;CS1580;CS1581;CS1584;CS1587;CS1589;CS1590;CS1591;CS1592;CS1598;CS1710;CS1711;CS1712;$(NoWarn) + + CS0419;CS1570;CS1571;CS1572;CS1573;CS1574;CS1580;CS1581;CS1584;CS1587;CS1589;CS1590;CS1591;CS1592;CS1598;CS1710;CS1711;CS1712;$(NoWarn) true true diff --git a/LICENSE.txt b/LICENSE.txt index ea18cde..200b884 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,7 @@ MIT License Copyright (c) 2024-2026 TruePath contributors +Copyright (c) .NET Foundation and Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/README.md b/README.md index 716c2c3..d0d5853 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ @@ -16,7 +16,9 @@ This library aims to fill this gap by providing a set of types that represent pa Also, the methods in the library provide some qualities that are missing from the `System.IO.Path`: say, we aim to provide several ways of path normalization and comparison, the ones that will and will not perform disk IO to resolve paths on case-insensitive file systems. -The library is inspired by the path libraries used in other ecosystems: in particular, Java's [java.nio.file.Path][java.path] and [Kotlin's extensions][kotlin.path]. +The library is inspired by the path libraries used in other ecosystems, in no particular order: +- Java's [java.nio.file.Path][java.path] and [Kotlin's extensions][kotlin.path] for general API shape; +- [\[fs.path.append\]][cpp.fs.path.append] from the C++ standard for path concatenation algorithms. Read more on [the documentation site][docs]. @@ -36,6 +38,10 @@ TruePath provides two NuGet packages: A third-party package, [**TruePath.TestableIO.System.IO**][nuget.true-path.testableio.system-io], adds [TestableIO.System.IO.Abstractions][testable-io.system.io.abstractions] integration on top of TruePath. +Versioning +---------- +This project follows [Semantic Versioning][semver], with one exception: the behavior of the types and members marked as `[System.Diagnostics.CodeAnalysis.ExperimentalAttribute]` may change without a major version bump. Using them causes the compiler to report diagnostic `TRUEPATH001`; suppress it (e.g. by adding `$(NoWarn);TRUEPATH001` into your project file, or with `#pragma warning disable TRUEPATH001`) to acknowledge that. + Documentation ------------- - [Project Documentation Site][docs] @@ -50,6 +56,7 @@ The project is distributed under the terms of [the MIT license][docs.license]. The license indication in the project's sources is compliant with the [REUSE specification v3.3][reuse.spec]. [andivionian-status-classifier]: https://andivionian.fornever.me/v1/#status-ventis- +[cpp.fs.path.append]: https://eel.is/c++draft/fs.path.append [discussions]: https://github.com/ForNeVeR/TruePath/discussions [docs.changelog]: CHANGELOG.md [docs.contributing]: CONTRIBUTING.md @@ -69,5 +76,6 @@ The license indication in the project's sources is compliant with the [REUSE spe [nuget.true-path.testableio.system-io]: https://www.nuget.org/packages/TruePath.TestableIO.System.IO/ [nuget.true-path]: https://www.nuget.org/packages/TruePath [reuse.spec]: https://reuse.software/spec-3.3/ +[semver]: https://semver.org/spec/v2.0.0.html [status-ventis]: https://img.shields.io/badge/status-ventis-yellow.svg [testable-io.system.io.abstractions]: https://github.com/TestableIO/System.IO.Abstractions diff --git a/TruePath.Tests/AbsolutePathTests.cs b/TruePath.Tests/AbsolutePathTests.cs index fa1cea3..31e12ac 100644 --- a/TruePath.Tests/AbsolutePathTests.cs +++ b/TruePath.Tests/AbsolutePathTests.cs @@ -11,7 +11,7 @@ public class AbsolutePathTests [Fact] public void ConstructionTest() { - var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/"); + var root = Utils.SyntheticRoot; var path = new AbsolutePath($"{root}/..."); Assert.Equal($"{root}...", path.Value); } @@ -19,18 +19,18 @@ public void ConstructionTest() [Fact] public void PathRootReturnsRoot() { - var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/"); + var root = Utils.SyntheticRoot; var path = root / "foo" / "bar"; - Assert.Equal(root, path.PathRoot()); + Assert.Equal(root, path.PathRoot); } [Fact] public void PathRootOfRootReturnsItself() { - var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/"); + var root = Utils.SyntheticRoot; - Assert.Equal(root, root.PathRoot()); + Assert.Equal(root, root.PathRoot); } [Fact] @@ -136,21 +136,21 @@ public void ReadKind_IsSymlink() [InlineData("/", null)] public void ParentIsCalculatedCorrectly(string relativePath, string? expectedRelativePath) { - var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/"); + var root = Utils.SyntheticRoot; var parent = root / relativePath; AbsolutePath? expectedPath = expectedRelativePath == null ? null : new(root / expectedRelativePath); Assert.Equal(expectedPath, parent.Parent); } [Theory] - [InlineData("/home/user", "/home/user/documents", true)] - [InlineData("/home/user/documents", "/home/user/documents", true)] - [InlineData("/home/user/documents", "/home/user", false)] + [InlineData("home/user", "home/user/documents", true)] + [InlineData("home/user/documents", "home/user/documents", true)] + [InlineData("home/user/documents", "home/user", false)] public void IsPrefixOfShouldBeEquivalentToStartsWith(string pathA, string pathB, bool expected) { // Arrange - var a = new AbsolutePath(pathA); - var b = new AbsolutePath(pathB); + var a = Utils.SyntheticRoot / pathA; + var b = Utils.SyntheticRoot / pathB; // Assert Assert.Equal(expected, a.IsPrefixOf(b)); @@ -160,7 +160,7 @@ public void IsPrefixOfShouldBeEquivalentToStartsWith(string pathA, string pathB, [Fact] public void IsPrefixOfFollowsPlatformCaseSensitivityForSameName() { - var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/"); + var root = Utils.SyntheticRoot; var a = root / "Foo"; var b = root / "foo"; @@ -172,7 +172,7 @@ public void IsPrefixOfFollowsPlatformCaseSensitivityForSameName() [Fact] public void IsPrefixOfFollowsPlatformCaseSensitivityForDescendant() { - var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/"); + var root = Utils.SyntheticRoot; var a = root / "Foo"; var b = root / "foo/file.txt"; @@ -183,7 +183,7 @@ public void IsPrefixOfFollowsPlatformCaseSensitivityForDescendant() [Fact] public void IsPrefixOfRequiresWholeSegmentRegardlessOfCase() { - var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/"); + var root = Utils.SyntheticRoot; var a = root / "Foo"; var b = root / "foobar"; @@ -199,7 +199,7 @@ public void IsPrefixOfRequiresWholeSegmentRegardlessOfCase() [InlineData("sub/folder", "sub", false)] public void IsPrefixOfRespectsPathSegmentBoundaries(string prefix, string other, bool expected) { - var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/"); + var root = Utils.SyntheticRoot; Assert.Equal(expected, (root / prefix).IsPrefixOf(root / other)); } @@ -207,7 +207,7 @@ public void IsPrefixOfRespectsPathSegmentBoundaries(string prefix, string other, [Fact] public void IsPrefixOfTreatsRootAsPrefixOfDescendants() { - var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/"); + var root = Utils.SyntheticRoot; Assert.True(root.IsPrefixOf(root / "sub" / "a.txt")); } @@ -274,6 +274,45 @@ public void ConstructorThrowsOnNonRootedPath() Assert.Equal(expectedMessage, ex.Message); } + [Theory] + [InlineData(@"\Windows")] + [InlineData("C:Windows")] + [InlineData("C:")] + public void ConstructorThrowsOnDriveRelativePathOnWindows(string path) + { + if (!OperatingSystem.IsWindows()) return; + + var expectedMessage = $"Path \"{path}\" is not absolute."; + var ex = Assert.Throws(() => new AbsolutePath(path)); + Assert.Equal(expectedMessage, ex.Message); + + ex = Assert.Throws(() => new AbsolutePath(new LocalPath(path))); + Assert.Equal(expectedMessage, ex.Message); + } + + [Theory] + [InlineData(@"\x", @"C:\x")] + [InlineData("C:x", @"C:\base\x")] + [InlineData("c:x", @"C:\base\x")] + [InlineData(@"D:\x", @"D:\x")] + public void AppendDriveRelativePathOnWindows(string appended, string expected) + { + if (!OperatingSystem.IsWindows()) return; + + var basePath = new AbsolutePath(@"C:\base"); + Assert.Equal(expected, (basePath / appended).Value); + } + + [Fact] + public void AppendPathRelativeToAnotherDriveThrowsOnWindows() + { + if (!OperatingSystem.IsWindows()) return; + + var basePath = new AbsolutePath(@"C:\base"); + var ex = Assert.Throws(() => basePath / "D:x"); + Assert.Equal("Path \"D:x\" is not absolute.", ex.Message); + } + [Theory] [InlineData("/etc/bin", "/usr/bin", "../../usr/bin")] [InlineData("/usr/bin/log", "/usr/bin", "..")] @@ -290,13 +329,28 @@ public void RelativeToReturnsCorrectRelativePath(string from, string to, string Assert.Equal(expected, relativePath.Value); } + [Theory] + [InlineData("a/c", "a/b", "../b")] + [InlineData("a", "a", ".")] + public void RelativeToReturnsCorrectRelativePathCrossPlatform(string from, string to, string expected) + { + var fromPath = Utils.SyntheticRoot / from; + var toPath = Utils.SyntheticRoot / to; + + LocalPath relativePath = toPath.RelativeTo(fromPath); + + Assert.Equal(new LocalPath(expected), relativePath); + } + [Theory] [InlineData(@"C:\bin", @"D:\bin", @"D:\bin")] + [InlineData(@"C:\bin", @"D:\bin\x", @"D:\bin\x")] [InlineData(@"C:\bin\debug", @"C:\bin", "..")] [InlineData(@"C:\bin", @"C:\bin\log", "log")] + [InlineData(@"c:\bin", @"C:\bin\log", "log")] public void RelativeToReturnsCorrectRelativePathForWindows(string from, string to, string expected) { - if (OperatingSystem.IsWindows() is false) return; + if (!OperatingSystem.IsWindows()) return; var fromPath = new AbsolutePath(from); var toPath = new AbsolutePath(to); diff --git a/TruePath.Tests/GenericInterfaceTests.cs b/TruePath.Tests/GenericInterfaceTests.cs index 63854ec..d611ef5 100644 --- a/TruePath.Tests/GenericInterfaceTests.cs +++ b/TruePath.Tests/GenericInterfaceTests.cs @@ -10,7 +10,7 @@ public class GenericInterfaceTests public void ParentTests() { IPath l = new LocalPath("foo/bar"); - IPath a = new AbsolutePath("/foo/bar"); + IPath a = Utils.SyntheticRoot / "foo/bar"; Assert.Equal("foo", l.Parent?.FileName); Assert.Equal("foo", a.Parent?.FileName); @@ -20,7 +20,7 @@ public void ParentTests() public void FileNameTests() { IPath l = new LocalPath("foo/bar"); - IPath a = new AbsolutePath("/foo/bar"); + IPath a = Utils.SyntheticRoot / "foo/bar"; Assert.Equal("bar", l.FileName); Assert.Equal("bar", a.FileName); @@ -30,7 +30,7 @@ public void FileNameTests() public void OperatorTests() { var l = new LocalPath("foo/bar"); - var a = new AbsolutePath("/foo/bar"); + var a = Utils.SyntheticRoot / "foo/bar"; var fragment = new LocalPath("frog1"); Assert.Equal("frog1", AppendGeneric(l, fragment).FileName); diff --git a/TruePath.Tests/LocalPathTests.cs b/TruePath.Tests/LocalPathTests.cs index ff07080..c126c88 100644 --- a/TruePath.Tests/LocalPathTests.cs +++ b/TruePath.Tests/LocalPathTests.cs @@ -12,7 +12,7 @@ public class LocalPathTests(ITestOutputHelper output) [InlineData("/", null)] public void AbsolutePathParent(string relativePath, string? expectedRelativePath) { - var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/"); + var root = Utils.SyntheticRoot; var parent = root / relativePath; AbsolutePath? expectedPath = expectedRelativePath == null ? null : new(root / expectedRelativePath); Assert.Equal(expectedPath, parent.Parent); @@ -57,6 +57,21 @@ public void RelativePathParent(string path, string? expected) Assert.Equal(expectedPath, parent); } + [Theory] + [InlineData("C:", "C:..")] + [InlineData("C:..", @"C:..\..")] + [InlineData(@"C:..\..", @"C:..\..\..")] + [InlineData("C:foo", "C:")] + [InlineData(@"C:foo\bar", "C:foo")] + public void DriveRelativePathParent(string path, string expected) + { + if (!OperatingSystem.IsWindows()) return; + + var parent = new LocalPath(path).Parent; + + Assert.Equal(expected, parent?.Value); + } + [Theory] [InlineData("user", "user/documents")] [InlineData("usEr", "User/documents")] @@ -166,11 +181,57 @@ public void EmptyParentIsNotPrefixOfPathEscapingUpwards() [Fact] public void IsPrefixOfRooted() { - var root = new LocalPath(OperatingSystem.IsWindows() ? @"A:\" : "/"); + var root = new LocalPath(Utils.SyntheticRoot); var subRoot = root / "frob"; Assert.True(root.IsPrefixOf(subRoot)); } + [Theory] + [InlineData("")] + [InlineData("foo")] + [InlineData("foo/bar")] + [InlineData("..")] + public void PathRootOfRelativePathIsNull(string path) + { + Assert.Null(new LocalPath(path).PathRoot); + } + + [Theory] + [InlineData("")] + [InlineData("foo/bar")] + public void PathRootOfAbsolutePath(string relativePart) + { + var root = Utils.SyntheticRoot; + var path = new LocalPath(root / relativePart); + Assert.Equal(root, path.PathRoot); + } + + [Theory] + [InlineData(@"C:\foo", @"C:\")] + [InlineData(@"C:\", @"C:\")] + [InlineData("C:foo", @"C:\")] + [InlineData(@"C:foo\bar", @"C:\")] + [InlineData("C:", @"C:\")] + [InlineData(@"\foo", null)] + [InlineData(@"\", null)] + public void PathRootOnWindows(string path, string? expectedRoot) + { + if (!OperatingSystem.IsWindows()) return; + + AbsolutePath? expected = expectedRoot == null ? null : new AbsolutePath(expectedRoot); + Assert.Equal(expected, new LocalPath(path).PathRoot); + } + + [Theory] + [InlineData("C:foo")] + [InlineData("C:")] + public void PathRootOfDriveLikePathOnUnixIsNull(string path) + { + if (OperatingSystem.IsWindows()) return; + + Assert.Null(new LocalPath(path).PathRoot); + } + [Fact] public void RelativePathIsNormalizedOnCreation() { @@ -184,7 +245,7 @@ public void RelativePathIsNormalizedOnCreation() [Fact] public void LocalPathConvertedFromAbsolute() { - var absolutePath = new AbsolutePath("/foo/bar"); + var absolutePath = Utils.SyntheticRoot / "foo/bar"; LocalPath localPath1 = absolutePath; var localPath2 = new LocalPath(absolutePath); @@ -214,6 +275,287 @@ public void ResolveToCurrentDirectoryTests() } } + [Fact] + public void ResolveToCurrentDirectoryForDriveRelativePathsOnWindows() + { + if (!OperatingSystem.IsWindows()) return; + + var currentDirectory = AbsolutePath.CurrentWorkingDirectory; + var drive = currentDirectory.Value.Substring(0, 2); + + Assert.Equal(new AbsolutePath(drive + @"\foo"), new LocalPath(@"\foo").ResolveToCurrentDirectory()); + Assert.Equal(currentDirectory / "foo", new LocalPath(drive + "foo").ResolveToCurrentDirectory()); + Assert.Equal(currentDirectory, new LocalPath(drive).ResolveToCurrentDirectory()); + } + + [Theory] + [InlineData("a", "a/b", "b")] + [InlineData("a/b", "a", "..")] + [InlineData("a/c", "a/b", "../b")] + [InlineData("a", "a", "")] + public void RelativeToForRelativePaths(string from, string to, string expected) + { + var relativePath = new LocalPath(to).RelativeTo(new LocalPath(from)); + Assert.Equal(new LocalPath(expected), relativePath); + } + + [Fact] + public void RelativeToResolvesAgainstCurrentDirectory() + { + var currentDirectory = AbsolutePath.CurrentWorkingDirectory; + var foo = new LocalPath("foo"); + + Assert.Equal(foo, foo.RelativeTo(currentDirectory)); + Assert.Equal(foo, new LocalPath(currentDirectory / "foo").RelativeTo(new LocalPath(""))); + Assert.Equal(foo, foo.RelativeTo(new LocalPath("."))); + } + + [Theory] + [InlineData(@"C:\a", @"C:\a\b", "b")] // PathKind.Absolute + [InlineData("a", @"a\b", "b")] // PathKind.Relative + [InlineData(@"\a", @"\a\b", "b")] // PathKind.DriveRootRelative + [InlineData(@"A:x", @"A:x\y", "y")] // PathKind.DriveCurrentDirectoryRelative + public void RelativeToForPathKindsOnWindows(string from, string to, string expected) + { + if (!OperatingSystem.IsWindows()) return; + + var relativePath = new LocalPath(to).RelativeTo(new LocalPath(from)); + Assert.Equal(new LocalPath(expected), relativePath); + } + + [Fact] + public void RelativeToForMixedPathKindsOnWindows() + { + if (!OperatingSystem.IsWindows()) return; + + var currentDirectory = AbsolutePath.CurrentWorkingDirectory; + var drive = currentDirectory.PathRoot; + var foo = new LocalPath("foo"); + var driveCurrentDir = new LocalPath($"{drive.Value.Substring(0, 2)}"); + Assert.Equal(PathKind.DriveCurrentDirectoryRelative, driveCurrentDir.Kind); + + Assert.Equal(foo, new LocalPath(@"\foo").RelativeTo(drive)); + Assert.Equal(foo, (driveCurrentDir / "foo").RelativeTo(currentDirectory)); + Assert.Equal(foo, new LocalPath(currentDirectory / "foo").RelativeTo(driveCurrentDir)); + } + + [Fact] + public void RelativeToReturnsAbsolutePathForDifferentRootsOnWindows() + { + if (!OperatingSystem.IsWindows()) return; + + Assert.Equal(new LocalPath(@"D:\x"), new LocalPath(@"D:\x").RelativeTo(new LocalPath(@"C:\y"))); + Assert.Equal( + new LocalPath(new LocalPath("B:x").ResolveToCurrentDirectory()), + new LocalPath("B:x").RelativeTo(new LocalPath(@"A:\y"))); + + var currentDirectory = AbsolutePath.CurrentWorkingDirectory; + Assert.Equal( + new LocalPath(currentDirectory / "foo"), + new LocalPath("foo").RelativeTo(Utils.NonCurrentSyntheticRoot)); + } + + [Theory] + [InlineData(@"C:\", PathKind.Absolute)] + [InlineData(@"C:\Windows", PathKind.Absolute)] + [InlineData("c:/windows/system32", PathKind.Absolute)] + [InlineData("", PathKind.Relative)] + [InlineData(".", PathKind.Relative)] + [InlineData("Windows", PathKind.Relative)] + [InlineData(@"..\Windows", PathKind.Relative)] + [InlineData(@"Windows\System32", PathKind.Relative)] + [InlineData("1:foo", PathKind.Relative)] + [InlineData(@"\", PathKind.DriveRootRelative)] + [InlineData(@"\Windows", PathKind.DriveRootRelative)] + [InlineData("/Windows", PathKind.DriveRootRelative)] + [InlineData("C:", PathKind.DriveCurrentDirectoryRelative)] + [InlineData("C:Windows", PathKind.DriveCurrentDirectoryRelative)] + [InlineData(@"c:Windows\System32", PathKind.DriveCurrentDirectoryRelative)] + [InlineData("C:..", PathKind.DriveCurrentDirectoryRelative)] + [InlineData("C:.", PathKind.DriveCurrentDirectoryRelative)] + public void KindOnWindows(string path, PathKind expected) + { + if (!OperatingSystem.IsWindows()) return; + + var localPath = new LocalPath(path); + Assert.Equal(expected, localPath.Kind); + Assert.Equal(expected == PathKind.Absolute, localPath.IsAbsolute); + } + + [Theory] + [InlineData("/", PathKind.Absolute)] + [InlineData("/usr/bin", PathKind.Absolute)] + [InlineData("//usr", PathKind.Absolute)] + [InlineData("", PathKind.Relative)] + [InlineData("usr", PathKind.Relative)] + [InlineData("../usr", PathKind.Relative)] + [InlineData("C:", PathKind.Relative)] + [InlineData("C:foo", PathKind.Relative)] + [InlineData(@"C:\foo", PathKind.Relative)] + [InlineData(@"\foo", PathKind.Relative)] + public void KindOnUnix(string path, PathKind expected) + { + if (OperatingSystem.IsWindows()) return; + + var localPath = new LocalPath(path); + Assert.Equal(expected, localPath.Kind); + Assert.Equal(expected == PathKind.Absolute, localPath.IsAbsolute); + } + + [Theory] + [InlineData(@"\", @"\Windows", true)] + [InlineData(@"C:\", @"\Windows", false)] + [InlineData(@"\Windows", @"C:\Windows", false)] + [InlineData("C:", "C:Windows", true)] + [InlineData("c:", "C:Windows", true)] + [InlineData("C:", "C:", true)] + [InlineData("C:Windows", @"C:Windows\System32", true)] + [InlineData("C:Win", "C:Windows", false)] + [InlineData("C:", @"C:..\x", false)] + [InlineData("C:", "D:Windows", false)] + [InlineData("C:", @"C:\Windows", false)] + [InlineData(@"C:\", @"C:Windows", false)] + [InlineData("C:Windows", @"Windows\x", false)] + [InlineData("", "C:Windows", false)] + [InlineData("", @"\Windows", false)] + public void IsPrefixOfAcrossKindsOnWindows(string prefix, string other, bool result) + { + if (!OperatingSystem.IsWindows()) return; + + 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 AppendMatrixOnWindows() + { + if (!OperatingSystem.IsWindows()) return; + + // Every base path kind combined with every appended path kind. The Expected column is the result of + // LocalPath's operator /. It matches C++ std::filesystem::path::operator/ (as observed on MSVC 14.51), except + // that drive letters are compared case-insensitively (the c:x cases), and that the result is normalized. + // The PathCombine column pins the behavior of Path.Combine for the same arguments on purpose, to document + // where the two differ. + (string Base, string Appended, string Expected, string PathCombine)[] cases = + [ + (@"C:\base", "x", @"C:\base\x", @"C:\base\x"), + (@"C:\base", @"\x", @"C:\x", @"\x"), + (@"C:\base", @"D:\x", @"D:\x", @"D:\x"), + (@"C:\base", "D:x", "D:x", "D:x"), + (@"C:\base", "C:x", @"C:\base\x", "C:x"), + (@"C:\base", "c:x", @"C:\base\x", "c:x"), + (@"C:\base", "", @"C:\base", @"C:\base"), + + ("base", "x", @"base\x", @"base\x"), + ("base", @"\x", @"\x", @"\x"), + ("base", @"D:\x", @"D:\x", @"D:\x"), + ("base", "D:x", "D:x", "D:x"), + ("base", "C:x", "C:x", "C:x"), + ("base", "c:x", "c:x", "c:x"), + ("base", "", "base", "base"), + + (@"\base", "x", @"\base\x", @"\base\x"), + (@"\base", @"\x", @"\x", @"\x"), + (@"\base", @"D:\x", @"D:\x", @"D:\x"), + (@"\base", "D:x", "D:x", "D:x"), + (@"\base", "C:x", "C:x", "C:x"), + (@"\base", "c:x", "c:x", "c:x"), + (@"\base", "", @"\base", @"\base"), + + ("C:base", "x", @"C:base\x", @"C:base\x"), + ("C:base", @"\x", @"C:\x", @"\x"), + ("C:base", @"D:\x", @"D:\x", @"D:\x"), + ("C:base", "D:x", "D:x", "D:x"), + ("C:base", "C:x", @"C:base\x", "C:x"), + ("C:base", "c:x", @"C:base\x", "c:x"), + ("C:base", "", "C:base", "C:base"), + + ("C:", "x", "C:x", @"C:\x"), + ("C:", @"\x", @"C:\x", @"\x"), + ("C:", @"D:\x", @"D:\x", @"D:\x"), + ("C:", "D:x", "D:x", "D:x"), + ("C:", "C:x", "C:x", "C:x"), + ("C:", "c:x", "C:x", "c:x"), + ("C:", "", "C:", "C:"), + + (@"C:\", "x", @"C:\x", @"C:\x"), + (@"C:\", @"\x", @"C:\x", @"\x"), + (@"C:\", @"D:\x", @"D:\x", @"D:\x"), + (@"C:\", "D:x", "D:x", "D:x"), + (@"C:\", "C:x", @"C:\x", "C:x"), + (@"C:\", "c:x", @"C:\x", "c:x"), + (@"C:\", "", @"C:\", @"C:\"), + + (@"\", "x", @"\x", @"\x"), + (@"\", @"\x", @"\x", @"\x"), + (@"\", @"D:\x", @"D:\x", @"D:\x"), + (@"\", "D:x", "D:x", "D:x"), + (@"\", "C:x", "C:x", "C:x"), + (@"\", "c:x", "c:x", "c:x"), + (@"\", "", @"\", @"\"), + + ("", "x", "x", "x"), + ("", @"\x", @"\x", @"\x"), + ("", @"D:\x", @"D:\x", @"D:\x"), + ("", "D:x", "D:x", "D:x"), + ("", "C:x", "C:x", "C:x"), + ("", "c:x", "c:x", "c:x"), + ("", "", "", ""), + ]; + + var failures = new List(); + foreach (var (basePath, appended, expected, expectedPathCombine) in cases) + { + var actual = (new LocalPath(basePath) / appended).Value; + if (actual != expected) + failures.Add($"\"{basePath}\" / \"{appended}\": expected \"{expected}\", got \"{actual}\"."); + + var actualPathCombine = Path.Combine(basePath, appended); + if (actualPathCombine != expectedPathCombine) + failures.Add( + $"Path.Combine(\"{basePath}\", \"{appended}\"): expected \"{expectedPathCombine}\", got \"{actualPathCombine}\"."); + } + + Assert.Empty(failures); + } + + [Fact] + public void AppendMatrixOnUnix() + { + if (OperatingSystem.IsWindows()) return; + + // On Unix, operator / always agrees with Path.Combine, up to normalization. + (string Base, string Appended, string Expected)[] cases = + [ + ("/base", "x", "/base/x"), + ("/base", "/x", "/x"), + ("/base", @"\x", @"/base/\x"), + ("/base", "", "/base"), + ("/", "x", "/x"), + ("base", "C:x", "base/C:x"), + ("C:", "x", "C:/x"), + ("", "x", "x"), + ]; + + var failures = new List(); + foreach (var (basePath, appended, expected) in cases) + { + var actual = (new LocalPath(basePath) / appended).Value; + if (actual != expected) + failures.Add($"\"{basePath}\" / \"{appended}\": expected \"{expected}\", got \"{actual}\"."); + + var actualPathCombine = new LocalPath(Path.Combine(basePath, appended)).Value; + if (actualPathCombine != expected) + failures.Add( + $"Path.Combine(\"{basePath}\", \"{appended}\"): expected \"{expected}\", got \"{actualPathCombine}\"."); + } + + Assert.Empty(failures); + } + [Fact] public void PlatformDefaultPathComparerTest() { diff --git a/TruePath.Tests/PathExtensionsTests.cs b/TruePath.Tests/PathExtensionsTests.cs index b807d88..4ef8a15 100644 --- a/TruePath.Tests/PathExtensionsTests.cs +++ b/TruePath.Tests/PathExtensionsTests.cs @@ -20,7 +20,7 @@ public void GetExtensionWithDotTests(string path, string expected) if (!path.StartsWith('/')) return; - IPath a = new AbsolutePath(path); + IPath a = Utils.SyntheticRoot / path.Substring(1); Assert.Equal(expected, a.GetExtensionWithDot()); } @@ -39,7 +39,7 @@ public void GetExtensionWithoutDotTests(string path, string expected) if (!path.StartsWith('/')) return; - IPath a = new AbsolutePath(path); + IPath a = Utils.SyntheticRoot / path.Substring(1); Assert.Equal(expected, a.GetExtensionWithoutDot()); } @@ -57,7 +57,7 @@ public void GetFilenameWithoutExtensionTests(string path, string expected) if (!path.StartsWith('/')) return; - IPath a = new AbsolutePath(path); + IPath a = Utils.SyntheticRoot / path.Substring(1); Assert.Equal(expected, a.GetFilenameWithoutExtension()); } diff --git a/TruePath.Tests/PathPolyfillTests.cs b/TruePath.Tests/PathPolyfillTests.cs new file mode 100644 index 0000000..f74177c --- /dev/null +++ b/TruePath.Tests/PathPolyfillTests.cs @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2026 TruePath contributors +// +// SPDX-License-Identifier: MIT + +using TruePath.Extensions; + +namespace TruePath.Tests; + +public class PathPolyfillTests +{ + [Theory] + [InlineData("a", "a/b")] + [InlineData("a/b", "a")] + [InlineData("a/b", "a/c")] + [InlineData("a", "a")] + [InlineData("a", "b")] + [InlineData(".", "a")] + [InlineData("a b", "a b/c d")] + [InlineData("a", "a/b#c")] + [InlineData("a", "a/%41")] + [InlineData("a", "a/b/")] + [InlineData("a/", "a")] + [InlineData("Foodie", "Foobar")] + public void GetRelativePathBehavesAsPathGetRelativePath(string relativeTo, string path) + { + AssertSameRelativePath(relativeTo, path); + } + + [Theory] + [InlineData(@"C:\a", @"D:\b")] + [InlineData(@"C:\a", @"C:\a")] + [InlineData(@"c:\a", @"C:\a\b")] + [InlineData(@"\a", @"\a\b")] + [InlineData("A:x", @"A:x\y")] + [InlineData(@"C:\A", @"c:\a\b")] + [InlineData(@"C:\a\", @"C:\a")] + [InlineData(@"C:\Foodie", @"C:\Foobar")] + public void GetRelativePathBehavesAsPathGetRelativePathOnWindows(string relativeTo, string path) + { + if (!OperatingSystem.IsWindows()) return; + AssertSameRelativePath(relativeTo, path); + } + + [Theory] + [InlineData("/a", "/b/c")] + [InlineData("/", "/a")] + [InlineData("/a/b", "/a")] + [InlineData("/a/", "/a/b/")] + [InlineData("/Foodie", "/Foobar")] + public void GetRelativePathBehavesAsPathGetRelativePathOnUnix(string relativeTo, string path) + { + if (OperatingSystem.IsWindows()) return; + AssertSameRelativePath(relativeTo, path); + } + + [Theory] + [InlineData(null, "a")] + [InlineData("a", null)] + [InlineData("", "a")] + [InlineData("a", "")] + public void GetRelativePathThrowsAsPathGetRelativePath(string? relativeTo, string? path) + { + AssertSameException(relativeTo, path); + } + + [Theory] + [InlineData(" ", "a")] + [InlineData("a", " ")] + public void GetRelativePathThrowsAsPathGetRelativePathOnWindows(string relativeTo, string path) + { + if (!OperatingSystem.IsWindows()) return; + AssertSameException(relativeTo, path); + } + + private static void AssertSameRelativePath(string relativeTo, string path) + { + var expected = Path.GetRelativePath(relativeTo, path); + var actual = PathPolyfill.GetRelativePath(relativeTo, path); + Assert.Equal(expected, actual); + } + + private static void AssertSameException(string? relativeTo, string? path) + { + var expected = Assert.ThrowsAny(() => Path.GetRelativePath(relativeTo!, path!)); + var actual = Assert.ThrowsAny(() => PathPolyfill.GetRelativePath(relativeTo!, path!)); + Assert.Equal(expected.GetType(), actual.GetType()); + Assert.Equal(expected.ParamName, actual.ParamName); + } +} diff --git a/TruePath.Tests/PathStringsTests.cs b/TruePath.Tests/PathStringsTests.cs index d2cb083..e16d696 100644 --- a/TruePath.Tests/PathStringsTests.cs +++ b/TruePath.Tests/PathStringsTests.cs @@ -27,7 +27,7 @@ public void TrailingSlashShouldBeRemoved(string input, string expected) [Fact] public void RootPathEndWithSeparator() { - var root = OperatingSystem.IsWindows() ? @"A:\" : "/"; + var root = Utils.SyntheticRootString; Assert.Equal(root, PathStrings.Normalize(root)); var rootWithRepeatedSeparators = OperatingSystem.IsWindows() ? @"A:\\\\" : "//"; diff --git a/TruePath.Tests/PlatformDefaultPathComparerTests.cs b/TruePath.Tests/PlatformDefaultPathComparerTests.cs new file mode 100644 index 0000000..6710d5b --- /dev/null +++ b/TruePath.Tests/PlatformDefaultPathComparerTests.cs @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2026 TruePath contributors +// +// SPDX-License-Identifier: MIT + +using TruePath.Comparers; + +namespace TruePath.Tests; + +public class PlatformDefaultPathComparerTests +{ + [Fact] + public void DefaultStringComparisonMatchesRuntime() + { + var isCaseInsensitive = OperatingSystem.IsWindows() + || OperatingSystem.IsMacOS() + || OperatingSystem.IsIOS() + || OperatingSystem.IsTvOS(); + var expected = isCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + Assert.Equal(expected, PlatformDefaultPathComparer.DefaultStringComparison); + } +} diff --git a/TruePath.Tests/TruePath.Tests.csproj b/TruePath.Tests/TruePath.Tests.csproj index 1b5cf97..695c3b3 100644 --- a/TruePath.Tests/TruePath.Tests.csproj +++ b/TruePath.Tests/TruePath.Tests.csproj @@ -11,6 +11,7 @@ SPDX-License-Identifier: MIT false true false + $(NoWarn);TRUEPATH001 diff --git a/TruePath.Tests/Utils.cs b/TruePath.Tests/Utils.cs index 1535e33..7909529 100644 --- a/TruePath.Tests/Utils.cs +++ b/TruePath.Tests/Utils.cs @@ -23,8 +23,19 @@ internal static string ToNonCanonicalCase(this string path) return nonCanonicalPath; } + /// A root path that exists syntactically on the current platform: A:\ on Windows, / elsewhere. + internal static string SyntheticRootString => OperatingSystem.IsWindows() ? @"A:\" : "/"; + + /// + internal static AbsolutePath SyntheticRoot => new(SyntheticRootString); + + internal static AbsolutePath NonCurrentSyntheticRoot => + AbsolutePath.CurrentWorkingDirectory.PathRoot == SyntheticRoot + ? new AbsolutePath(@"B:\") + : SyntheticRoot; + internal static bool IsPlatformCaseInsensitive() => - OperatingSystem.IsWindows() || OperatingSystem.IsMacOS(); + OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() || OperatingSystem.IsIOS() || OperatingSystem.IsTvOS(); internal static bool RunsOnCi() { diff --git a/TruePath/AbsolutePath.cs b/TruePath/AbsolutePath.cs index 873df39..cbcacb8 100644 --- a/TruePath/AbsolutePath.cs +++ b/TruePath/AbsolutePath.cs @@ -1,9 +1,12 @@ -// SPDX-FileCopyrightText: 2024 TruePath contributors +// SPDX-FileCopyrightText: 2024-2026 TruePath contributors // // SPDX-License-Identifier: MIT using System.Runtime.InteropServices; using TruePath.Comparers; +#if !NET8_0_OR_GREATER +using TruePath.Extensions; +#endif namespace TruePath; @@ -17,8 +20,8 @@ namespace TruePath; /// /// Provides a default comparer for comparing file paths, aware of the current platform. /// - /// On Windows and macOS, this will perform case-insensitive string comparison, since the - /// file systems are case-insensitive on these operating systems by default. + /// On Windows, macOS, iOS and tvOS, this will perform case-insensitive string + /// comparison, since the file systems are case-insensitive on these operating systems by default. /// /// On Linux, the comparison will be case-sensitive. /// @@ -46,7 +49,7 @@ namespace TruePath; /// Path string to normalize. /// Flag indicating whether absoluteness of path should be checked /// Thrown if the passed string does not represent an absolute path.> - private AbsolutePath(string value, bool checkAbsoluteness) + internal AbsolutePath(string value, bool checkAbsoluteness) { Underlying = new LocalPath(value); @@ -74,9 +77,8 @@ public AbsolutePath(LocalPath localPath) : this(localPath.Value, checkAbsolutene /// public AbsolutePath? Parent => Underlying.Parent is { } path ? new(path.Value, checkAbsoluteness: false) : null; - /// Gets the root of this path. - public AbsolutePath PathRoot() => - new(Path.GetPathRoot(Value)!, checkAbsoluteness: false); + /// Gets the root of this path: e.g. C:\ on Windows or / on Unix. + public AbsolutePath PathRoot => new(Path.GetPathRoot(Value)!, checkAbsoluteness: false); /// IPath? IPath.Parent => Parent; @@ -109,28 +111,47 @@ public static AbsolutePath CurrentWorkingDirectory /// Calculates the relative path from a base path to this path. /// /// The base path from which to calculate the relative path. - /// The relative path from the base path to this path. + /// + /// The relative path from the base path to this path, or this path itself if the paths have different roots. + /// + /// + /// If the paths have different roots (on Windows, e.g. paths on different drives), there's no relative path + /// between them, and this path is returned unchanged: D:\x relative to C:\y is D:\x. On + /// Unix, all paths share the same root, so this never happens. + /// #if NET8_0_OR_GREATER public LocalPath RelativeTo(AbsolutePath basePath) => new(Path.GetRelativePath(basePath.Value, Value)); #else - public LocalPath RelativeTo(AbsolutePath basePath) => new(PathEx.GetRelativePath(basePath.Value, Value)); + public LocalPath RelativeTo(AbsolutePath basePath) => new(PathPolyfill.GetRelativePath(basePath.Value, Value)); #endif /// Corrects the file name case on case-insensitive file systems, resolves symlinks. public AbsolutePath Canonicalize() => new(DiskUtils.GetRealPath(Value)); - /// Appends another path to this one. + /// + /// + /// Works the same way as (read its documentation for + /// the details, including the differences from ), except that it throws + /// if the result is not absolute. + /// + /// + /// The result designates the same location as changing the current directory first to + /// , and then to : a / b means the same as + /// cd /d a && cd /d b on Windows, or cd a && cd b on Unix. This is the algorithm + /// of C++'s std::filesystem::path::operator/, except that the drive letters are compared + /// case-insensitively, and that the result is normalized. + /// + /// /// - /// Note that in case path is absolute, it will completely take over and the - /// will be ignored. + /// A result that is not absolute is only possible on Windows, when appending a path relative to the current + /// directory of another drive: e.g. C:\base / D:x would be D:x. On the same drive, such a + /// path is resolved against the base path: C:\base / C:x is C:\base\x. A path rooted without a + /// drive letter keeps the drive of the base path: C:\base / \x is C:\x. /// - public static AbsolutePath operator /(AbsolutePath basePath, LocalPath b) => - new(Path.Combine(basePath.Value, b.Value), false); + /// Thrown if the resulting path is not absolute. + /// C++ standard: path appends (fs.path.append) + public static AbsolutePath operator /(AbsolutePath basePath, LocalPath b) => new(basePath.Underlying / b); - /// Appends another path to this one. - /// - /// Note that in case path is absolute, it will completely take over and the - /// will be ignored. - /// + /// public static AbsolutePath operator /(AbsolutePath basePath, string b) => basePath / new LocalPath(b); /// The normalized path string contained in this object. diff --git a/TruePath/Comparers/PlatformDefaultPathComparer.cs b/TruePath/Comparers/PlatformDefaultPathComparer.cs index d6daafe..9e0f698 100644 --- a/TruePath/Comparers/PlatformDefaultPathComparer.cs +++ b/TruePath/Comparers/PlatformDefaultPathComparer.cs @@ -9,8 +9,8 @@ namespace TruePath.Comparers; /// /// Provides a default comparer for comparing file paths, aware of the current platform. /// -/// On Windows and macOS, this will perform case-insensitive string comparison, since the file -/// systems are case-insensitive on these operating systems by default. +/// On Windows, macOS, iOS and tvOS, this will perform case-insensitive string +/// comparison, since the file systems are case-insensitive on these operating systems by default. /// /// On Linux, the comparison will be case-sensitive. /// @@ -21,14 +21,10 @@ namespace TruePath.Comparers; /// internal class PlatformDefaultPathComparer : IPathComparer where TPath : IPath { - internal static readonly StringComparison DefaultStringComparison = - RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX) - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; - - private readonly StringComparer _stringComparer = DefaultStringComparison == StringComparison.OrdinalIgnoreCase - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; + private readonly StringComparer _stringComparer = + PlatformDefaultPathComparer.DefaultStringComparison == StringComparison.OrdinalIgnoreCase + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; public bool Equals(TPath? x, TPath? y) { @@ -45,3 +41,16 @@ public int Compare(TPath? x, TPath? y) return _stringComparer.Compare(x?.Value, y?.Value); } } + +internal static class PlatformDefaultPathComparer +{ + // Matches the .NET runtime's PathInternal.IsCaseSensitive: + // https://github.com/dotnet/runtime/blob/60629d14374c56f1cb51819049ad1fa529307f8d/src/libraries/Common/src/System/IO/PathInternal.CaseSensitivity.cs#L23-L29 + internal static readonly StringComparison DefaultStringComparison = + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + || RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + || RuntimeInformation.IsOSPlatform(OSPlatform.Create("IOS")) + || RuntimeInformation.IsOSPlatform(OSPlatform.Create("TVOS")) + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; +} diff --git a/TruePath/ExperimentalDiagnostics.cs b/TruePath/ExperimentalDiagnostics.cs new file mode 100644 index 0000000..3bb8604 --- /dev/null +++ b/TruePath/ExperimentalDiagnostics.cs @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2026 TruePath contributors +// +// SPDX-License-Identifier: MIT + +namespace TruePath; + +/// Parameters of the diagnostic reported on usages of the experimental TruePath API. +internal static class ExperimentalDiagnostics +{ + public const string Id = "TRUEPATH001"; + public const string UrlFormat = "https://github.com/ForNeVeR/TruePath#versioning"; +} diff --git a/TruePath/Extensions/PathEx.cs b/TruePath/Extensions/PathEx.cs deleted file mode 100644 index 25c3277..0000000 --- a/TruePath/Extensions/PathEx.cs +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-FileCopyrightText: 2025 TruePath contributors -// -// SPDX-License-Identifier: MIT - -#if !NET8_0_OR_GREATER -// ReSharper disable once CheckNamespace -namespace System.IO; - -/// -/// Class that contains custom implementations methods of class presented in .NET 8 but missing in .NET Standard 2.0. -/// -internal static class PathEx -{ - public static string GetRelativePath(string relativeTo, string path) - { - if (string.IsNullOrEmpty(relativeTo)) throw new ArgumentNullException(nameof(relativeTo)); - if (string.IsNullOrEmpty(path)) throw new ArgumentNullException(nameof(path)); - - var relativeUri = new Uri(Path.GetFullPath(relativeTo) + Path.DirectorySeparatorChar); - var pathUri = new Uri(Path.GetFullPath(path)); - - if (relativeUri.Scheme != pathUri.Scheme) - return path; - - var relativePathUri = relativeUri.MakeRelativeUri(pathUri); - var relativePath = Uri.UnescapeDataString(relativePathUri.ToString()); - - return relativePath.Replace('/', Path.DirectorySeparatorChar); - } -} -#endif diff --git a/TruePath/LocalPath.cs b/TruePath/LocalPath.cs index 5d03cfc..2e31b93 100644 --- a/TruePath/LocalPath.cs +++ b/TruePath/LocalPath.cs @@ -1,8 +1,12 @@ -// SPDX-FileCopyrightText: 2024 TruePath contributors +// SPDX-FileCopyrightText: 2024-2026 TruePath contributors // // SPDX-License-Identifier: MIT +using System.Runtime.InteropServices; using TruePath.Comparers; +#if !NET8_0_OR_GREATER +using TruePath.Extensions; +#endif namespace TruePath; @@ -24,8 +28,8 @@ public readonly struct LocalPath(string value) : IEquatable, ICompara /// /// Provides a default comparer for comparing file paths, aware of the current platform. /// - /// On Windows and macOS, this will perform case-insensitive string comparison, since the - /// file systems are case-insensitive on these operating systems by default. + /// On Windows, macOS, iOS and tvOS, this will perform case-insensitive string + /// comparison, since the file systems are case-insensitive on these operating systems by default. /// /// On Linux, the comparison will be case-sensitive. /// @@ -46,34 +50,89 @@ public readonly struct LocalPath(string value) : IEquatable, ICompara private static char Separator => Path.DirectorySeparatorChar; - private static bool StartsWithParentDirectoryReference(string value) => + private static bool StartsWithParentDirectoryReference(ReadOnlySpan value) => value.Length >= 2 && value[0] == '.' && value[1] == '.' && (value.Length == 2 || value[2] == Separator); /// public string Value { get; } = PathStrings.Normalize(value); + /// Determines the kind of this path. See for the details. + public PathKind Kind + { + get + { + if (Value.Length == 0) return PathKind.Relative; + if (!IsDriveBasedSystem) return Value[0] == Separator ? PathKind.Absolute : PathKind.Relative; + + if (HasDriveLetter) + { + return Value.Length > 2 && Value[2] == Separator + ? PathKind.Absolute + : PathKind.DriveCurrentDirectoryRelative; + } + + return Value[0] == Separator ? PathKind.DriveRootRelative : PathKind.Relative; + } + } + /// - /// Checks whether the path is absolute. /// - /// Currently, any rooted paths are considered absolute, but this is subject to change: on Windows, there - /// will be an additional requirement for a path to be either a DOS device path or start from a disk letter. + /// Checks whether the path is absolute, i.e. it is fully qualified, and doesn't depend on the current directory, + /// or current directory on any drive (Windows-specific). + /// + /// + /// On Windows, this requires a drive letter and a root directory: C:\Windows is absolute, while + /// \Windows and C:Windows are not. /// /// - // 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); + /// Equivalent to checking that is . + public bool IsAbsolute => Kind == PathKind.Absolute; + + private static readonly bool IsDriveBasedSystem = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + private bool HasDriveLetter => IsDriveBasedSystem && PathStrings.SourceContainsDriveLetter(Value.AsSpan()); + + /// + /// Gets the root of this path, if it can be determined without resolving against the current directory. + /// + /// + /// For an absolute path, this is its root: C:\ for C:\foo on Windows, / for + /// /foo on Unix. + /// + /// + /// For a drive-relative path on Windows (e.g. C:foo or C:), this is the root of that drive + /// (C:\). + /// + /// + /// For a relative path, or a path rooted without a drive letter on Windows (e.g. \foo), this is + /// . + /// + /// + /// + public AbsolutePath? PathRoot => Kind switch + { + PathKind.Absolute => new AbsolutePath(Path.GetPathRoot(Value)!, checkAbsoluteness: false), + // The root of a drive-relative path (C:foo) is still the drive root. + PathKind.DriveCurrentDirectoryRelative => new AbsolutePath(Value.Substring(0, 2) + Separator, checkAbsoluteness: false), + // A path rooted without a drive (\foo) has its root depending on the current drive. + _ => null + }; /// /// /// The parent of a relative path consisting of a single segment is the empty path (the current - /// directory), not . + /// directory), not . Similarly, on Windows, the parent of a bare drive (C:, the + /// current directory of that drive) is C:... /// public LocalPath? Parent { get { - if (Value == "" || Value == ".." || Value.EndsWith($"{Separator}..")) return this / ".."; + // 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}..")) + return this / ".."; return Path.GetDirectoryName(Value) is { } parent ? new(parent) : null; } } @@ -140,64 +199,177 @@ public override bool Equals(object? obj) /// 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. + /// Paths of different s are never prefixes of each other: e.g. an absolute path is never a + /// prefix of a relative one, and vice versa. Such a comparison would require resolving a path against the + /// current directory, which this type never does. + /// + /// + /// 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. + /// + /// + /// Note that C:\ will not be considered as a prefix of C:Folder, even though they + /// might be considered as related. /// /// public bool IsPrefixOf(LocalPath other) { - // 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; + var kind = Kind; + if (kind != other.Kind) return false; + + var prefix = Value.AsSpan(); + var path = other.Value.AsSpan(); + if (kind == PathKind.DriveCurrentDirectoryRelative) + { + // The rest of the paths after the drive letter are relative to the drive's current directory, and + // compare the same way the relative paths do. + if (!IsSameDrive(Value, other.Value)) return false; + prefix = prefix.Slice(2); + path = path.Slice(2); + } + + return IsSegmentPrefix(prefix, path); + } + 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 (Value.Length == 0) return !StartsWithParentDirectoryReference(other.Value); + if (prefix.Length == 0) return !StartsWithParentDirectoryReference(path); - if (!(Value.Length <= other.Value.Length && - other.Value.StartsWith(Value, PlatformDefaultPathComparer.DefaultStringComparison))) + if (!path.StartsWith(prefix, PlatformDefaultPathComparer.DefaultStringComparison)) return false; - return other.Value.Length == Value.Length || - Value[Value.Length - 1] == Separator || - other.Value[Value.Length] == Separator; + return path.Length == prefix.Length || + prefix[^1] == Separator || + path[prefix.Length] == Separator; } + private static bool IsSameDrive(string a, string b) => char.ToUpperInvariant(a[0]) == char.ToUpperInvariant(b[0]); + /// /// Calculates the relative path from a base path to this path. /// /// The base path from which to calculate the relative path. - /// The relative path from the base path to this path. + /// + /// The relative path from the base path to this path, or the resolved absolute path to this path if the paths + /// have different roots. + /// + /// + /// + /// If either path is not absolute, it is first resolved 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. + /// + /// + /// If the resolved paths have different roots (on Windows, e.g. paths on different drives), there's no relative + /// path between them, and the resolved absolute path to this path is returned instead: D:\x + /// relative to C:\y is D:\x. On Unix, all paths share the same root, so this never happens. + /// + /// + 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 - public LocalPath RelativeTo(LocalPath basePath) => new(Path.GetRelativePath(basePath.Value, Value)); + return new(Path.GetRelativePath(relativeTo, path)); #else - public LocalPath RelativeTo(LocalPath basePath) => new(PathEx.GetRelativePath(basePath.Value, Value)); + return new(PathPolyfill.GetRelativePath(relativeTo, path)); #endif - /// Appends another path to this one. - /// - /// Note that in case path is absolute, it will completely take over and the - /// will be ignored. - /// - public static LocalPath operator /(LocalPath basePath, LocalPath b) => - new(Path.Combine(basePath.Value, b.Value)); + } /// Appends another path to this one. + /// The path to append to. + /// The path to append. + /// The combined path, normalized. /// - /// Note that in case path is absolute, it will completely take over and the - /// will be ignored. + /// + /// The result designates the same location as changing the current directory first to + /// , and then to : a / b means the same as + /// cd /d a && cd /d b on Windows, or cd a && cd b on Unix. A base path without a + /// drive letter is considered to be on a different drive than any drive letter in . + /// + /// Depending on the of : + /// + /// + /// (D:\x, /x): replaces the base path. + /// + /// + /// (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 . + /// + /// + /// (\x): the drive of the base path is kept, if there is + /// one. C:\base / \x and C:base / \x are both C:\x, while base / \x is + /// \x. + /// + /// + /// (C:x): if the base path is on the same + /// drive, the rest of is appended as a relative path, so C:\base / C:x is + /// C:\base\x. Otherwise, replaces the base path: C:\base / D:x is + /// D:x, and base / C:x is C:x. Drive letters are compared case-insensitively. + /// + /// + /// + /// This is the algorithm also defined in C++'s std::filesystem::path::operator/, and that the result is + /// normalized (e.g. C:\base / "" gets no trailing separator after normalization). + /// + /// + /// Important: this operator differs from , which returns its + /// second argument if that is rooted, and otherwise joins the arguments with a separator: + /// + /// + /// C:\base / \x is C:\x, while Path.Combine returns \x; + /// C:\base / C:x is C:\base\x, while Path.Combine returns C:x; + /// + /// C: / x is C:x, while Path.Combine returns C:\x on .NET 10 (.NET Framework + /// 4.8.1, though, will return C:x); + /// + /// the result is normalized. + /// + /// On Unix, the result is the same as the one of Path.Combine, up to normalization. /// + /// C++ standard: path appends (fs.path.append) + public static LocalPath operator /(LocalPath basePath, LocalPath b) => new(Append(basePath, b)); + + /// public static LocalPath operator /(LocalPath basePath, string b) => basePath / new LocalPath(b); + private static string Append(LocalPath basePath, LocalPath b) + { + switch (b.Kind) + { + case PathKind.Absolute: + return b.Value; + case PathKind.DriveCurrentDirectoryRelative: + return basePath.HasDriveLetter && IsSameDrive(basePath.Value, b.Value) + ? Join(basePath.Value, b.Value.Substring(2)) + : b.Value; + case PathKind.DriveRootRelative: + return basePath.HasDriveLetter ? basePath.Value.Substring(0, 2) + b.Value : b.Value; + default: + return Join(basePath.Value, b.Value); + } + } + + private static string Join(string basePath, string relativePath) + { + if (relativePath.Length == 0) return basePath; + if (basePath.Length == 0) return relativePath; + + // A bare drive (C:) designates the current directory of the drive, and C:x is a path relative to it. + var isBareDrive = basePath.Length == 2 && IsDriveBasedSystem + && PathStrings.SourceContainsDriveLetter(basePath.AsSpan()); + return isBareDrive || basePath[^1] == Separator + ? basePath + relativePath + : basePath + Separator + relativePath; + } + /// /// Implicitly converts an to a . /// @@ -209,10 +381,19 @@ public bool IsPrefixOf(LocalPath other) /// /// An that represents this path resolved against the current working directory. /// + /// /// Note that if this path is already absolute, it will just transform to . The current /// directory won't matter for such a case. + /// + /// + /// On Windows, a path rooted without a drive letter (\x) is resolved against the drive of the current + /// directory, and a path relative to the current directory of a drive (D:x) is resolved against the + /// current directory of that drive, as tracked by the process (see ). + /// /// - public AbsolutePath ResolveToCurrentDirectory() => AbsolutePath.CurrentWorkingDirectory / this; + public AbsolutePath ResolveToCurrentDirectory() => Kind == PathKind.DriveCurrentDirectoryRelative + ? new AbsolutePath(Path.GetFullPath(Value)) + : AbsolutePath.CurrentWorkingDirectory / this; /// Converts an to a . public LocalPath(AbsolutePath path) : this(path.Value) diff --git a/TruePath/PathKind.cs b/TruePath/PathKind.cs new file mode 100644 index 0000000..70a8041 --- /dev/null +++ b/TruePath/PathKind.cs @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2026 TruePath contributors +// +// SPDX-License-Identifier: MIT + +using System.Diagnostics.CodeAnalysis; + +namespace TruePath; + +/// +/// The kind of a : tells how the path is anchored in the file system. +/// +/// On Unix, a path is either or . Windows additionally has two kinds of +/// paths that are only partially anchored: and +/// . +/// +/// +/// +/// This type is experimental: new members may be added, and existing paths may be reclassified, without a major +/// version bump. In particular, UNC and DOS device paths on Windows are not classified yet. +/// +// TODO[#24]: UNC and DOS device paths (\\server\share, \\?\..., \\.\...) are not classified yet; add members for them. +[Experimental(ExperimentalDiagnostics.Id, UrlFormat = ExperimentalDiagnostics.UrlFormat)] +public enum PathKind +{ + /// + /// A fully qualified path, independent of any current directory: e.g. C:\Windows or C:\ on + /// Windows, /usr/bin or / on Unix. + /// + Absolute, + + /// + /// A path relative to the current directory: e.g. Windows, ..\Windows, or the empty path (the + /// current directory itself). + /// + /// + /// On Unix, every path not starting with / is of this kind, including C:foo and \foo. + /// + Relative, + + /// + /// Windows only. A path rooted without a drive letter, relative to the root of the current drive: e.g. + /// \Windows or \. + /// + DriveRootRelative, + + /// + /// Windows only. A path with a drive letter but no root directory, relative to the current directory of + /// that drive: e.g. C:Windows or C:. + /// + DriveCurrentDirectoryRelative +} diff --git a/TruePath/PathStrings.cs b/TruePath/PathStrings.cs index a648076..db20404 100644 --- a/TruePath/PathStrings.cs +++ b/TruePath/PathStrings.cs @@ -193,7 +193,7 @@ block is ".." /// /// true if the source contains a drive letter (e.g., 'C:'); otherwise, false. /// - private static bool SourceContainsDriveLetter(ReadOnlySpan source) + internal static bool SourceContainsDriveLetter(ReadOnlySpan source) { if (source.Length < 2) { diff --git a/TruePath/Extensions/DirectoryEx.cs b/TruePath/Polyfills/DirectoryPolyfills.cs similarity index 82% rename from TruePath/Extensions/DirectoryEx.cs rename to TruePath/Polyfills/DirectoryPolyfills.cs index b9099f9..c347b38 100644 --- a/TruePath/Extensions/DirectoryEx.cs +++ b/TruePath/Polyfills/DirectoryPolyfills.cs @@ -1,15 +1,15 @@ -// SPDX-FileCopyrightText: 2025 TruePath contributors +// SPDX-FileCopyrightText: 2025-2026 TruePath contributors // // SPDX-License-Identifier: MIT #if !NET8_0_OR_GREATER -// ReSharper disable once CheckNamespace -namespace System.IO; + +namespace TruePath.Polyfills; /// /// Class that contains custom implementations methods of class presented in .NET 8 but missing in .NET Standard 2.0. /// -internal static class DirectoryEx +internal static class DirectoryPolyfills { public static DirectoryInfo CreateTempSubdirectory(string? prefix = null) { diff --git a/TruePath/Polyfills/ExperimentalAttribute.cs b/TruePath/Polyfills/ExperimentalAttribute.cs new file mode 100644 index 0000000..eae9b31 --- /dev/null +++ b/TruePath/Polyfills/ExperimentalAttribute.cs @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 TruePath contributors +// +// SPDX-License-Identifier: MIT + +#if !NET8_0_OR_GREATER +// ReSharper disable once CheckNamespace +namespace System.Diagnostics.CodeAnalysis; + +/// +/// A polyfill of the attribute available since .NET 8. The marked API is considered experimental and can be changed in +/// new releases of the library. +/// +[AttributeUsage( + AttributeTargets.Assembly | AttributeTargets.Module | AttributeTargets.Class | AttributeTargets.Struct + | AttributeTargets.Enum | AttributeTargets.Constructor | AttributeTargets.Method | AttributeTargets.Property + | AttributeTargets.Field | AttributeTargets.Event | AttributeTargets.Interface | AttributeTargets.Delegate, + Inherited = false)] +internal sealed class ExperimentalAttribute(string diagnosticId) : Attribute +{ + public string DiagnosticId { get; } = diagnosticId; + public string? UrlFormat { get; set; } +} +#endif diff --git a/TruePath/Polyfills/PathPolyfill.cs b/TruePath/Polyfills/PathPolyfill.cs new file mode 100644 index 0000000..ef5a74f --- /dev/null +++ b/TruePath/Polyfills/PathPolyfill.cs @@ -0,0 +1,362 @@ +// SPDX-FileCopyrightText: 2025-2026 TruePath contributors +// SPDX-FileCopyrightText: .NET Foundation and Contributors +// +// SPDX-License-Identifier: MIT + +// The implementation is ported from dotnet/runtime v10.0.0 (commit 60629d14374c56f1cb51819049ad1fa529307f8d): +// - https://github.com/dotnet/runtime/blob/60629d14374c56f1cb51819049ad1fa529307f8d/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs#L846-L947 +// - https://github.com/dotnet/runtime/blob/60629d14374c56f1cb51819049ad1fa529307f8d/src/libraries/Common/src/System/IO/PathInternal.cs +// - https://github.com/dotnet/runtime/blob/60629d14374c56f1cb51819049ad1fa529307f8d/src/libraries/Common/src/System/IO/PathInternal.Windows.cs +// - https://github.com/dotnet/runtime/blob/60629d14374c56f1cb51819049ad1fa529307f8d/src/libraries/Common/src/System/IO/PathInternal.Unix.cs + +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using TruePath.Comparers; + +namespace TruePath.Extensions; + +/// +/// Class that contains custom implementations methods of class presented in .NET 8 but missing in .NET Standard 2.0. +/// +/// +/// +/// Only used by the .NET Standard 2.0 build, but compiled for every target, so it can be tested against the +/// implementations in . +/// +/// The implementation is ported from the .NET runtime. +/// +internal static class PathPolyfill +{ + private static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + // \\?\, \\.\, \??\ + private const int DevicePrefixLength = 4; + // \\ + private const int UncPrefixLength = 2; + // \\?\UNC\, \\.\UNC\ + private const int UncExtendedPrefixLength = 8; + + /// + /// Create a relative path from one path to another. Paths will be resolved before calculating the difference. + /// Default path comparison for the active platform will be used (OrdinalIgnoreCase for Windows or Mac, Ordinal for Unix). + /// + /// The source path the output should be relative to. This path is always considered to be a directory. + /// The destination path. + /// The relative path or if the paths don't share the same root. + /// Thrown if or is null. + /// Thrown if or is effectively empty. + public static string GetRelativePath(string relativeTo, string path) + { + return GetRelativePath(relativeTo, path, PlatformDefaultPathComparer.DefaultStringComparison); + } + + private static string GetRelativePath(string relativeTo, string path, StringComparison comparisonType) + { + if (relativeTo is null) throw new ArgumentNullException(nameof(relativeTo)); + if (path is null) throw new ArgumentNullException(nameof(path)); + + if (IsEffectivelyEmpty(relativeTo.AsSpan())) + throw new ArgumentException("The path is empty.", nameof(relativeTo)); + if (IsEffectivelyEmpty(path.AsSpan())) + throw new ArgumentException("The path is empty.", nameof(path)); + + Debug.Assert(comparisonType == StringComparison.Ordinal || comparisonType == StringComparison.OrdinalIgnoreCase); + + relativeTo = Path.GetFullPath(relativeTo); + path = Path.GetFullPath(path); + + // Need to check if the roots are different- if they are we need to return the "to" path. + if (!AreRootsEqual(relativeTo, path, comparisonType)) + return path; + + int commonLength = GetCommonPathLength(relativeTo, path, ignoreCase: comparisonType == StringComparison.OrdinalIgnoreCase); + + // If there is nothing in common they can't share the same root, return the "to" path as is. + if (commonLength == 0) + return path; + + // Trailing separators aren't significant for comparison + int relativeToLength = relativeTo.Length; + if (EndsInDirectorySeparator(relativeTo.AsSpan())) + relativeToLength--; + + bool pathEndsInSeparator = EndsInDirectorySeparator(path.AsSpan()); + int pathLength = path.Length; + if (pathEndsInSeparator) + pathLength--; + + // If we have effectively the same path, return "." + if (relativeToLength == pathLength && commonLength >= relativeToLength) return "."; + + // We have the same root, we need to calculate the difference now using the + // common Length and Segment count past the length. + // + // Some examples: + // + // C:\Foo C:\Bar L3, S1 -> ..\Bar + // C:\Foo C:\Foo\Bar L6, S0 -> Bar + // C:\Foo\Bar C:\Bar\Bar L3, S2 -> ..\..\Bar\Bar + // C:\Foo\Foo C:\Foo\Bar L7, S1 -> ..\Bar + + var sb = new StringBuilder(Math.Max(relativeTo.Length, path.Length)); + + // Add parent segments for segments past the common on the "from" path + if (commonLength < relativeToLength) + { + sb.Append(".."); + + for (int i = commonLength + 1; i < relativeToLength; i++) + { + if (IsDirectorySeparator(relativeTo[i])) + { + sb.Append(Path.DirectorySeparatorChar); + sb.Append(".."); + } + } + } + else if (IsDirectorySeparator(path[commonLength])) + { + // No parent segments and we need to eat the initial separator + // (C:\Foo C:\Foo\Bar case) + commonLength++; + } + + // Now add the rest of the "to" path, adding back the trailing separator + int differenceLength = pathLength - commonLength; + if (pathEndsInSeparator) + differenceLength++; + + if (differenceLength > 0) + { + if (sb.Length > 0) + { + sb.Append(Path.DirectorySeparatorChar); + } + + sb.Append(path, commonLength, differenceLength); + } + + return sb.ToString(); + } + + /// + /// Get the common path length from the start of the string. + /// + private static int GetCommonPathLength(string first, string second, bool ignoreCase) + { + int commonChars = EqualStartingCharacterCount(first, second, ignoreCase: ignoreCase); + + // If nothing matches + if (commonChars == 0) + return commonChars; + + // Or we're a full string and equal length or match to a separator + if (commonChars == first.Length + && (commonChars == second.Length || IsDirectorySeparator(second[commonChars]))) + return commonChars; + + if (commonChars == second.Length && IsDirectorySeparator(first[commonChars])) + return commonChars; + + // It's possible we matched somewhere in the middle of a segment e.g. C:\Foodie and C:\Foobar. + while (commonChars > 0 && !IsDirectorySeparator(first[commonChars - 1])) + commonChars--; + + return commonChars; + } + + /// + /// Gets the count of common characters from the left optionally ignoring case + /// + private static int EqualStartingCharacterCount(string? first, string? second, bool ignoreCase) + { + if (string.IsNullOrEmpty(first) || string.IsNullOrEmpty(second)) return 0; + + int commonChars = 0; + int length = Math.Min(first!.Length, second!.Length); + while (commonChars < length + && (first[commonChars] == second[commonChars] + || (ignoreCase && char.ToUpperInvariant(first[commonChars]) == char.ToUpperInvariant(second[commonChars])))) + { + commonChars++; + } + + return commonChars; + } + + /// + /// Returns true if the two paths have the same root + /// + private static bool AreRootsEqual(string? first, string? second, StringComparison comparisonType) + { + int firstRootLength = GetRootLength(first.AsSpan()); + int secondRootLength = GetRootLength(second.AsSpan()); + + return firstRootLength == secondRootLength + && string.Compare( + strA: first, + indexA: 0, + strB: second, + indexB: 0, + length: firstRootLength, + comparisonType: comparisonType) == 0; + } + + /// + /// Returns true if the path ends in a directory separator. + /// + private static bool EndsInDirectorySeparator(ReadOnlySpan path) => + path.Length > 0 && IsDirectorySeparator(path[^1]); + + /// + /// True if the given character is a directory separator. + /// + /// On Unix, both separator characters are /, so this checks only one. + private static bool IsDirectorySeparator(char c) => + c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar; + + /// + /// Returns true if the path is effectively empty for the current OS. + /// For unix, this is empty or null. For Windows, this is empty, null, or + /// just spaces ((char)32). + /// + private static bool IsEffectivelyEmpty(ReadOnlySpan path) + { + if (path.IsEmpty) + return true; + + if (!IsWindows) + return false; + + foreach (char c in path) + { + if (c != ' ') + return false; + } + return true; + } + + /// + /// Gets the length of the root of the path (drive, share, etc.). + /// + private static int GetRootLength(ReadOnlySpan path) => + IsWindows ? GetRootLengthWindows(path) : GetRootLengthUnix(path); + + private static int GetRootLengthUnix(ReadOnlySpan path) + { + return path.Length > 0 && IsDirectorySeparator(path[0]) ? 1 : 0; + } + + private static int GetRootLengthWindows(ReadOnlySpan path) + { + int pathLength = path.Length; + int i = 0; + + bool deviceSyntax = IsDevice(path); + bool deviceUnc = deviceSyntax && IsDeviceUnc(path); + + if ((!deviceSyntax || deviceUnc) && pathLength > 0 && IsDirectorySeparator(path[0])) + { + // UNC or simple rooted path (e.g. "\foo", NOT "\\?\C:\foo") + if (deviceUnc || (pathLength > 1 && IsDirectorySeparator(path[1]))) + { + // UNC (\\?\UNC\ or \\), scan past server\share + + // Start past the prefix ("\\" or "\\?\UNC\") + i = deviceUnc ? UncExtendedPrefixLength : UncPrefixLength; + + // Skip two separators at most + int n = 2; + while (i < pathLength && (!IsDirectorySeparator(path[i]) || --n > 0)) + i++; + } + else + { + // Current drive rooted (e.g. "\foo") + i = 1; + } + } + else if (deviceSyntax) + { + // Device path (e.g. "\\?\.", "\\.\") + // Skip any characters following the prefix that aren't a separator + i = DevicePrefixLength; + while (i < pathLength && !IsDirectorySeparator(path[i])) + i++; + + // If there is another separator take it, as long as we have had at least one + // non-separator after the prefix (e.g. don't take "\\?\\", but take "\\?\a\") + if (i < pathLength && i > DevicePrefixLength && IsDirectorySeparator(path[i])) + i++; + } + else if (pathLength >= 2 + && path[1] == ':' + && IsValidDriveChar(path[0])) + { + // Valid drive specified path ("C:", "D:", etc.) + i = 2; + + // If the colon is followed by a directory separator, move past it (e.g "C:\") + if (pathLength > 2 && IsDirectorySeparator(path[2])) + i++; + } + + return i; + } + + /// + /// Returns true if the given character is a valid drive letter + /// + private static bool IsValidDriveChar(char value) + { + return (uint)((value | 0x20) - 'a') <= 'z' - 'a'; + } + + /// + /// Returns true if the path uses any of the DOS device path syntaxes. ("\\.\", "\\?\", or "\??\") + /// + private static bool IsDevice(ReadOnlySpan path) + { + // If the path begins with any two separators is will be recognized and normalized and prepped with + // "\??\" for internal usage correctly. "\??\" is recognized and handled, "/??/" is not. + return IsExtended(path) + || + ( + path.Length >= DevicePrefixLength + && IsDirectorySeparator(path[0]) + && IsDirectorySeparator(path[1]) + && (path[2] == '.' || path[2] == '?') + && IsDirectorySeparator(path[3]) + ); + } + + /// + /// Returns true if the path is a device UNC (\\?\UNC\, \\.\UNC\) + /// + private static bool IsDeviceUnc(ReadOnlySpan path) + { + return path.Length >= UncExtendedPrefixLength + && IsDevice(path) + && IsDirectorySeparator(path[7]) + && path[4] == 'U' + && path[5] == 'N' + && path[6] == 'C'; + } + + /// + /// Returns true if the path uses the canonical form of extended syntax ("\\?\" or "\??\"). If the + /// path matches exactly (cannot use alternate directory separators) Windows will skip normalization + /// and path length checks. + /// + private static bool IsExtended(ReadOnlySpan path) + { + // While paths like "//?/C:/" will work, they're treated the same as "\\.\" paths. + // Skipping of normalization will *only* occur if back slashes ('\') are used. + return path.Length >= DevicePrefixLength + && path[0] == '\\' + && (path[1] == '\\' || path[1] == '?') + && path[2] == '?' + && path[3] == '\\'; + } +} diff --git a/TruePath/Temporary.cs b/TruePath/Temporary.cs index d6fda3d..1562d99 100644 --- a/TruePath/Temporary.cs +++ b/TruePath/Temporary.cs @@ -1,7 +1,11 @@ -// SPDX-FileCopyrightText: 2024 TruePath contributors +// SPDX-FileCopyrightText: 2024-2026 TruePath contributors // // SPDX-License-Identifier: MIT +#if !NET8_0_OR_GREATER +using TruePath.Polyfills; +#endif + namespace TruePath; /// @@ -40,7 +44,7 @@ public static AbsolutePath CreateTempFolder(string? prefix = null) #if NET8_0_OR_GREATER var tempDirectoryInfo = Directory.CreateTempSubdirectory(prefix); #else - var tempDirectoryInfo = DirectoryEx.CreateTempSubdirectory(prefix); + var tempDirectoryInfo = DirectoryPolyfills.CreateTempSubdirectory(prefix); #endif return AbsolutePath.CurrentWorkingDirectory / tempDirectoryInfo.FullName; } diff --git a/TruePath/TruePath.csproj b/TruePath/TruePath.csproj index 1fb22b8..f0a92d1 100644 --- a/TruePath/TruePath.csproj +++ b/TruePath/TruePath.csproj @@ -11,6 +11,8 @@ SPDX-License-Identifier: MIT true File path abstraction library for .NET. true + + $(NoWarn);TRUEPATH001 diff --git a/docs/index.md b/docs/index.md index 62abaa0..7a8ab68 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,7 +19,9 @@ This library aims to fill this gap by providing a set of types that represent pa Also, the methods in the library provide some qualities that are missing from the `System.IO.Path`: say, we aim to provide several ways of path normalization and comparison, the ones that will and will not perform disk IO to resolve paths on case-insensitive file systems. -The library is inspired by the path libraries used in other ecosystems: in particular, Java's [java.nio.file.Path][java.path] and [Kotlin's extensions][kotlin.path]. +The library is inspired by the path libraries used in other ecosystems, in no particular order: +- Java's [java.nio.file.Path][java.path] and [Kotlin's extensions][kotlin.path] for general API shape; +- [\[fs.path.append\]][cpp.fs.path.append] from the C++ standard for path concatenation algorithms. Project Summary --------------- @@ -89,7 +91,7 @@ TruePath allows the user to control certain aspects of how their paths are prese When comparing the path objects via either `==` operator or the standard `Equals(object)` method, the library uses the `AbsolutePath.PlatformDefaultComparer` or the `LocalPath.PlatformDefaultComparer`, meaning that - paths are compared as strings (no canonicalization performed), -- paths are compared in either case-sensitive (Linux) or case-insensitive/ordinal mode (Windows, macOS). +- paths are compared in either case-insensitive (Windows, macOS, iOS, tvOS) or case-sensitive (other systems, e.g. Linux). For cases when you want to always perform strict case-sensitive comparison (more performant yet not platform-aware), pass the `AbsolutePath.StrictStringComparer` or the `LocalPath.StrictStringComparer` to the overload of the `Equals` method: ```csharp @@ -116,8 +118,8 @@ To convert the path to the canonical form, use `AbsolutePath::Canonicalize`. [api.local-path-pattern]: api/TruePath.LocalPathPattern.yml [api.local-path]: api/TruePath.LocalPath.yml [api.path-io]: api/TruePath.SystemIo.PathIo.yml -[api.reference]: api/TruePath.yml [api.temporary]: api/TruePath.Temporary.yml +[cpp.fs.path.append]: https://eel.is/c++draft/fs.path.append [file-system-globbing.nuget]: https://www.nuget.org/packages/Microsoft.Extensions.FileSystemGlobbing [issue.20]: https://github.com/ForNeVeR/TruePath/issues/20 [java.path]: https://docs.oracle.com/en%2Fjava%2Fjavase%2F21%2Fdocs%2Fapi%2F%2F/java.base/java/nio/file/Path.html diff --git a/scripts/github-actions.fsx b/scripts/github-actions.fsx index f0ec3d6..953dbc7 100644 --- a/scripts/github-actions.fsx +++ b/scripts/github-actions.fsx @@ -108,11 +108,10 @@ let workflows = [ step(name = "Verify encoding", shell = "pwsh", run = "scripts/Test-Encoding.ps1") ] - dotNetJob "nowarn-empty" [ + dotNetJob "xml-doc" [ runsOn ubuntu - checkOut - step(name = "Verify with NoWarn as empty", run = "dotnet build /p:NoWarn='' --no-incremental") + step(name = "Verify XML documentation", run = "dotnet build -p:CheckXmlDoc=true --no-incremental") ] ]