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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
15 changes: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!--
SPDX-FileCopyrightText: 2024-2025 TruePath contributors <https://github.com/ForNeVeR/TruePath>
SPDX-FileCopyrightText: 2024-2026 TruePath contributors <https://github.com/ForNeVeR/TruePath>

SPDX-License-Identifier: MIT
-->
Expand All @@ -8,7 +8,7 @@ SPDX-License-Identifier: MIT
<PropertyGroup Label="Packaging">
<Version>1.12.0</Version>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<Copyright>2024-2026 TruePath contributors &lt;https://github.com/ForNeVeR/TruePath&gt;</Copyright>
<Copyright>2024-2026 TruePath contributors &lt;https://github.com/ForNeVeR/TruePath&gt;; .NET Foundation and Contributors</Copyright>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<IncludeSymbols>true</IncludeSymbols>
Expand All @@ -20,7 +20,8 @@ SPDX-License-Identifier: MIT
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<NoWarn>CS0419;CS1570;CS1571;CS1572;CS1573;CS1574;CS1580;CS1581;CS1584;CS1587;CS1589;CS1590;CS1591;CS1592;CS1598;CS1710;CS1711;CS1712;$(NoWarn)</NoWarn>
<!-- XML documentation warnings are suppressed for local development; CI verifies them with -p:CheckXmlDoc=true. -->
<NoWarn Condition="'$(CheckXmlDoc)' != 'true'">CS0419;CS1570;CS1571;CS1572;CS1573;CS1574;CS1580;CS1581;CS1584;CS1587;CS1589;CS1590;CS1591;CS1592;CS1598;CS1710;CS1711;CS1712;$(NoWarn)</NoWarn>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
</PropertyGroup>
Expand Down
1 change: 1 addition & 0 deletions LICENSE.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
MIT License

Copyright (c) 2024-2026 TruePath contributors <https://github.com/ForNeVeR/TruePath>
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:

Expand Down
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!--
SPDX-FileCopyrightText: 2024-2025 Friedrich von Never <friedrich@fornever.me>
SPDX-FileCopyrightText: 2024-2026 Friedrich von Never <friedrich@fornever.me>

SPDX-License-Identifier: MIT
-->
Expand All @@ -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].

Expand All @@ -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>$(NoWarn);TRUEPATH001</NoWarn>` into your project file, or with `#pragma warning disable TRUEPATH001`) to acknowledge that.

Documentation
-------------
- [Project Documentation Site][docs]
Expand All @@ -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
Expand All @@ -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
88 changes: 71 additions & 17 deletions TruePath.Tests/AbsolutePathTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,26 @@ 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);
}

[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]
Expand Down Expand Up @@ -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));
Expand All @@ -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";

Expand All @@ -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";

Expand All @@ -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";

Expand All @@ -199,15 +199,15 @@ 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));
}

[Fact]
public void IsPrefixOfTreatsRootAsPrefixOfDescendants()
{
var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/");
var root = Utils.SyntheticRoot;

Assert.True(root.IsPrefixOf(root / "sub" / "a.txt"));
}
Expand Down Expand Up @@ -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<ArgumentException>(() => new AbsolutePath(path));
Assert.Equal(expectedMessage, ex.Message);

ex = Assert.Throws<ArgumentException>(() => 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<ArgumentException>(() => 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", "..")]
Expand All @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions TruePath.Tests/GenericInterfaceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down
Loading
Loading