Skip to content

Commit e68cfcf

Browse files
committed
LocalPath: normalize current directory to .
1 parent e999628 commit e68cfcf

10 files changed

Lines changed: 85 additions & 47 deletions

File tree

‎CHANGELOG.md‎

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

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

4142
## [1.12.0] - 2026-03-14

‎TruePath.Tests/LocalPathTests.cs‎

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,36 @@ public void RootedPathAboveRootHasNoParent(string relativePath)
3333
}
3434

3535
[Theory]
36-
[InlineData(".", "")]
36+
[InlineData("", ".")]
37+
[InlineData(".", ".")]
38+
[InlineData("a/..", ".")]
3739
[InlineData("..", "..")]
3840
[InlineData("../..", "../..")]
3941
[InlineData(".../...", ".../...")]
40-
[InlineData(".../..", "")]
42+
[InlineData(".../..", ".")]
4143
public void ConstructionTest(string pathString, string expectedValue)
4244
{
4345
var path = new LocalPath(pathString);
4446
Assert.Equal(expectedValue.Replace('/', Path.DirectorySeparatorChar), path.Value);
4547
}
4648

4749
[Theory]
50+
[InlineData("")]
51+
[InlineData(".")]
52+
[InlineData("./.")]
53+
[InlineData("a/..")]
54+
public void CurrentDirectoryValue(string pathString)
55+
{
56+
var path = new LocalPath(pathString);
57+
58+
Assert.Equal(".", path.Value);
59+
Assert.Equal(".", path.ToString());
60+
Assert.Equal(PathKind.Relative, path.Kind);
61+
Assert.Equal(".", path.FileName);
62+
}
63+
64+
[Theory]
65+
[InlineData("", "..")]
4866
[InlineData(".", "..")]
4967
[InlineData("..", "../..")]
5068
[InlineData("../..", "../../..")]
@@ -187,16 +205,16 @@ public void IsPrefixOfRequiresWholeSegmentRegardlessOfCase()
187205
}
188206

189207
[Fact]
190-
public void EmptyParentIsPrefixOfDescendantPath()
208+
public void CurrentDirectoryParentIsPrefixOfDescendantPath()
191209
{
192210
var parent = new LocalPath("foo").Parent;
193211
Assert.NotNull(parent);
194-
Assert.Equal("", parent.Value.Value);
212+
Assert.Equal(".", parent.Value.Value);
195213
Assert.True(parent.Value.IsPrefixOf(new LocalPath("bar")));
196214
}
197215

198216
[Fact]
199-
public void EmptyParentIsNotPrefixOfPathEscapingUpwards()
217+
public void CurrentDirectoryParentIsNotPrefixOfPathEscapingUpwards()
200218
{
201219
var parent = new LocalPath("foo").Parent;
202220
Assert.NotNull(parent);
@@ -296,7 +314,10 @@ public void ResolveToCurrentDirectoryForDriveRelativePathsOnWindows()
296314
[InlineData("a", "a/b", "b")]
297315
[InlineData("a/b", "a", "..")]
298316
[InlineData("a/c", "a/b", "../b")]
299-
[InlineData("a", "a", "")]
317+
[InlineData("a", "a", ".")]
318+
[InlineData("", "a", "a")]
319+
[InlineData("a", "", "..")]
320+
[InlineData("", "", ".")]
300321
public void RelativeToForRelativePaths(string from, string to, string expected)
301322
{
302323
var relativePath = new LocalPath(to).RelativeTo(new LocalPath(from));
@@ -513,7 +534,10 @@ public void AppendMatrixOnWindows()
513534
("", "D:x", "D:x", "D:x"),
514535
("", "C:x", "C:x", "C:x"),
515536
("", "c:x", "c:x", "c:x"),
516-
("", "", "", ""),
537+
("", "", ".", ""),
538+
(".", "x", "x", @".\x"),
539+
("x", ".", "x", @"x\."),
540+
("C:", ".", "C:", @"C:\."),
517541
];
518542

519543
var failures = new List<string>();
@@ -548,6 +572,9 @@ public void AppendMatrixOnUnix()
548572
("base", "C:x", "base/C:x"),
549573
("C:", "x", "C:/x"),
550574
("", "x", "x"),
575+
("", "", "."),
576+
(".", "x", "x"),
577+
("x", ".", "x"),
551578
];
552579

553580
var failures = new List<string>();

‎TruePath.Tests/NormalizationTests.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public void NormalizedPathDoesNotEndWithDirSeparator(List<string> pathParts)
2020
var normalizedPath = PathStrings.Normalize(sourcePath);
2121

2222
testOutputHelper.WriteLine($"{sourcePath} => {normalizedPath}");
23-
Assert.True(normalizedPath == ""
23+
Assert.True(normalizedPath == "."
2424
|| normalizedPath == Path.DirectorySeparatorChar.ToString()
2525
|| normalizedPath is [_, ':', _]
2626
|| normalizedPath[^1] != Path.DirectorySeparatorChar);

‎TruePath.Tests/PathExtensionsTests.cs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ namespace TruePath.Tests;
77
public class PathExtensionsTests
88
{
99
[Theory]
10+
[InlineData(".", ".")]
1011
[InlineData("..", ".")]
1112
[InlineData("foo/bar.txt", ".txt")]
1213
[InlineData("/foo/bar.txt", ".txt")]
@@ -44,6 +45,7 @@ public void GetExtensionWithoutDotTests(string path, string expected)
4445
}
4546

4647
[Theory]
48+
[InlineData(".", "")]
4749
[InlineData("..", ".")]
4850
[InlineData("foo/bar.txt", "bar")]
4951
[InlineData("/foo/bar.txt", "bar")]
@@ -62,6 +64,7 @@ public void GetFilenameWithoutExtensionTests(string path, string expected)
6264
}
6365

6466
[Theory]
67+
[InlineData(".")]
6568
[InlineData("..")]
6669
[InlineData("file.txt")]
6770
[InlineData("file..txt")]

‎TruePath.Tests/PathStringsTests.cs‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,15 @@ public void SeparatorsAreDeduplicated(string input, string expected)
4545
}
4646

4747
[Theory]
48-
[InlineData(".", "")]
48+
[InlineData("", ".")]
49+
[InlineData(".", ".")]
50+
[InlineData("./.", ".")]
51+
[InlineData("./", ".")]
4952
[InlineData("./foo", "foo")]
5053
[InlineData("..", "..")]
5154
[InlineData("./..", "..")]
52-
[InlineData("a/..", "")]
55+
[InlineData("a/..", ".")]
56+
[InlineData("a/../", ".")]
5357
[InlineData("a/../..", "..")]
5458
[InlineData("a/../../.", "..")]
5559
[InlineData("a/../../..", "../..")]
@@ -68,7 +72,7 @@ public void SeparatorsAreDeduplicated(string input, string expected)
6872
[InlineData("../../../foo", "../../../foo")]
6973
[InlineData("../foo/..", "..")]
7074
[InlineData("...", "...")]
71-
[InlineData(".../..", "")]
75+
[InlineData(".../..", ".")]
7276
[InlineData(".../...", ".../...")]
7377
[InlineData(".../../...", "...")]
7478
[InlineData("foo/bar/../file.ext", "foo/file.ext")]

‎TruePath/LocalPath.cs‎

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ namespace TruePath;
1313
/// <para>A path pointing to a place in the local file system.</para>
1414
/// <para>It may be either absolute or relative.</para>
1515
/// <para>
16-
/// An <b>empty</b> path designates the <b>current directory</b>. Normalizing an empty string, <c>.</c> or
16+
/// The path <c>.</c> designates the <b>current directory</b>. Normalizing an empty string, <c>.</c> or
1717
/// <c>a/..</c> produces it, and so does <see cref="Parent"/> of a relative path consisting of a single segment
1818
/// (e.g. the parent of <c>foo</c>).
1919
/// </para>
@@ -80,7 +80,6 @@ public PathKind Kind
8080
{
8181
get
8282
{
83-
if (Value.Length == 0) return PathKind.Relative;
8483
if (!PathStrings.IsDriveBasedSystem) return Value[0] == Separator ? PathKind.Absolute : PathKind.Relative;
8584

8685
if (HasDriveLetter)
@@ -140,8 +139,8 @@ public PathKind Kind
140139

141140
/// <inheritdoc cref="IPath.Parent"/>
142141
/// <remarks>
143-
/// The parent of a relative path consisting of a single segment is the <b>empty</b> path (the current
144-
/// directory), not <see langword="null"/>. Similarly, on Windows, the parent of a bare drive (<c>C:</c>, the
142+
/// The parent of a relative path consisting of a single segment is <c>.</c> (the current directory), not
143+
/// <see langword="null"/>. Similarly, on Windows, the parent of a bare drive (<c>C:</c>, the
145144
/// current directory of that drive) is <c>C:..</c>.
146145
/// </remarks>
147146
public LocalPath? Parent
@@ -154,7 +153,9 @@ public LocalPath? Parent
154153
{
155154
// For C:foo, the rest after the drive letter is a relative path, and follows the same rules.
156155
var relativePart = kind == PathKind.DriveCurrentDirectoryRelative ? Value[2..] : Value;
157-
if (relativePart == "" || relativePart == ".." || relativePart.EndsWith($"{Separator}.."))
156+
if (IsCurrentDirectory(relativePart.AsSpan())
157+
|| relativePart == ".."
158+
|| relativePart.EndsWith($"{Separator}.."))
158159
return this / "..";
159160
}
160161

@@ -219,7 +220,7 @@ public override bool Equals(object? obj)
219220
/// <inheritdoc cref="IPath{TPath}.IsPrefixOf(TPath)"/>
220221
/// <remarks>
221222
/// <para>
222-
/// An <b>empty</b> path designates the current directory. It is a prefix of every relative path that stays
223+
/// The path <c>.</c> designates the current directory. It is a prefix of every relative path that stays
223224
/// at or below that directory, which means every relative path that does not begin with a <c>..</c>
224225
/// reference.
225226
/// </para>
@@ -237,7 +238,7 @@ public override bool Equals(object? obj)
237238
/// <para>
238239
/// On Windows, paths relative to the current directory of a drive (such as <c>C:Windows</c>) are only related
239240
/// if they have the same drive letter. A bare drive (<c>C:</c>) designates that drive's current directory, and
240-
/// behaves the same way as the empty path does for relative paths.
241+
/// behaves the same way as <c>.</c> does for relative paths.
241242
/// </para>
242243
/// <para>
243244
/// Note that <c>C:\</c> will <i>not</i> be considered as a prefix of <c>C:Folder</c>, even though they
@@ -280,10 +281,7 @@ public bool IsPrefixOf(LocalPath other)
280281

281282
private static bool IsSegmentPrefix(ReadOnlySpan<char> prefix, ReadOnlySpan<char> path)
282283
{
283-
// The empty path is the current directory, so every path at or below it has it as a prefix - but one
284-
// starting with a ".." reference points outside it. Normalization only ever keeps such references at the
285-
// very start of a path, so testing the first segment is enough.
286-
if (prefix.Length == 0) return !StartsWithParentDirectoryReference(path);
284+
if (IsCurrentDirectory(prefix)) return !StartsWithParentDirectoryReference(path);
287285

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

293+
/// <summary>
294+
/// Whether a normalized relative path (or its part left after stripping a drive letter or the leading <c>..</c>
295+
/// references) designates the directory it is relative to.
296+
/// </summary>
297+
private static bool IsCurrentDirectory(ReadOnlySpan<char> value) =>
298+
value.Length == 0 || value is ".";
299+
295300
private static bool IsSameDrive(string a, string b) => char.ToUpperInvariant(a[0]) == char.ToUpperInvariant(b[0]);
296301

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

@@ -346,8 +348,8 @@ public LocalPath RelativeTo(LocalPath basePath)
346348
/// <item>
347349
/// <see cref="PathKind.Relative"/> (<c>x</c>): <paramref name="b"/> is appended after a separator, so
348350
/// <c>C:\base / x</c> is <c>C:\base\x</c>. No separator is added after a bare drive: <c>C: / x</c> is
349-
/// <c>C:x</c>. Appending an empty path returns the base path unchanged, and appending to an empty path
350-
/// returns <paramref name="b"/>.
351+
/// <c>C:x</c>. Appending <c>.</c> (the current directory) returns the base path unchanged, and appending
352+
/// to <c>.</c> returns <paramref name="b"/>.
351353
/// </item>
352354
/// <item>
353355
/// <see cref="PathKind.DriveRootRelative"/> (<c>\x</c>): the drive of the base path is kept, if there is
@@ -408,8 +410,8 @@ private static string Append(LocalPath basePath, LocalPath b)
408410
private static string Join(LocalPath basePath, string relativePath)
409411
{
410412
var baseValue = basePath.Value;
411-
if (relativePath.Length == 0) return baseValue;
412-
if (baseValue.Length == 0) return relativePath;
413+
if (IsCurrentDirectory(relativePath.AsSpan())) return baseValue;
414+
if (baseValue == ".") return relativePath;
413415

414416
// A bare drive (C:) designates the current directory of the drive, and C:x is a path relative to it.
415417
var isBareDrive = baseValue.Length == 2 && basePath.HasDriveLetter;

‎TruePath/PathExtensions.cs‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,8 @@ public static string GetFilenameWithoutExtension(this IPath path) =>
7878
/// </returns>
7979
/// <exception cref="ArgumentException">
8080
/// <para>
81-
/// Thrown if the <paramref name="path"/> has no file name to change: it is empty (which designates the
82-
/// current directory), it consists of a root or a drive designator (Windows) alone, such as <c>C:\</c>,
81+
/// Thrown if the <paramref name="path"/> has no file name to change: it designates the current directory
82+
/// (<c>.</c>), it consists of a root or a drive designator (Windows) alone, such as <c>C:\</c>,
8383
/// <c>/</c> or <c>C:</c>, or its last segment is a parent directory reference (<c>..</c>).
8484
/// </para>
8585
/// <para>
@@ -139,8 +139,8 @@ public static TPath WithExtension<TPath>(this TPath path, string? extension) whe
139139
/// </returns>
140140
/// <exception cref="ArgumentException">
141141
/// <para>
142-
/// Thrown if the <paramref name="path"/> has no file name to change: it is empty (which designates the
143-
/// current directory), it consists of a root or a drive designator alone, such as <c>C:\</c>, <c>/</c>
142+
/// Thrown if the <paramref name="path"/> has no file name to change: it designates the current directory
143+
/// (<c>.</c>), it consists of a root or a drive designator alone, such as <c>C:\</c>, <c>/</c>
144144
/// or <c>C:</c>, or its last segment is a parent directory reference (<c>..</c>).
145145
/// </para>
146146
/// <para>
@@ -197,8 +197,8 @@ public static AbsolutePath WithExtension(this AbsolutePath path, string? extensi
197197
/// </returns>
198198
/// <exception cref="ArgumentException">
199199
/// <para>
200-
/// Thrown if the <paramref name="path"/> has no file name to change: it is empty (which designates the
201-
/// current directory), it consists of a root or a drive designator alone, such as <c>C:\</c>, <c>/</c>
200+
/// Thrown if the <paramref name="path"/> has no file name to change: it designates the current directory
201+
/// (<c>.</c>), it consists of a root or a drive designator alone, such as <c>C:\</c>, <c>/</c>
202202
/// or <c>C:</c>, or its last segment is a parent directory reference (<c>..</c>).
203203
/// </para>
204204
/// <para>

‎TruePath/PathKind.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ public enum PathKind
2929
Absolute,
3030

3131
/// <summary>
32-
/// A path relative to the current directory: e.g. <c>Windows</c>, <c>..\Windows</c>, or the empty path (the
33-
/// current directory itself).
32+
/// A path relative to the current directory: e.g. <c>Windows</c>, <c>..\Windows</c>, or <c>.</c> (the current
33+
/// directory itself).
3434
/// </summary>
3535
/// <remarks>
3636
/// On Unix, every path not starting with <c>/</c> is of this kind, including <c>C:foo</c> and <c>\foo</c>.

0 commit comments

Comments
 (0)