diff --git a/CHANGELOG.md b/CHANGELOG.md index 14c50e9..52e67f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - **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` now consider paths of different `PathKind` unrelated: an absolute path is never a prefix of a relative one, nor the other way round, and on Windows `\Windows`, `C:Windows` and `Windows` are all unrelated to each other. Previously, the result depended on a string comparison. - **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:** `PathExtensions.WithExtension` now throws an `ArgumentException` unless the path ends with a file name and the result of the change is a file name in its place: an extension change may no longer alter the number or the kind of a path's segments. It used to do so silently — `new LocalPath(@"C:\foo\.gitignore").WithExtension(null)` returned `C:\foo`, `new LocalPath(".gitignore").WithExtension(null)` returned the empty path, and `new LocalPath(@"C:\").WithExtension("bar")` returned `C:\.bar`. - On Windows, `AbsolutePath / LocalPath` now resolves a path relative to the current directory of another drive against the current directory of that drive, so the result is always absolute: e.g. `C:\base / D:x` is `D:\x` if the current directory of drive `D:` is its root. Previously, the result was `D:x`, which is not absolute. - On Windows, `LocalPath.ResolveToCurrentDirectory` now resolves `\x` against the drive of the current directory, and `D:x` against the current directory of drive `D:`. - **Breaking:** `LocalPath.StartsWith` and `AbsolutePath.StartsWith` now compare whole path segments instead of raw strings, which makes them exact inverses of `IsPrefixOf` as originally intended in [#43](https://github.com/ForNeVeR/TruePath/issues/43). For example, `new LocalPath("/foo1").StartsWith(new LocalPath("/foo"))` is now `false`, where it used to be `true`. diff --git a/TruePath.Tests/PathExtensionsTests.cs b/TruePath.Tests/PathExtensionsTests.cs index 4ef8a15..71f349c 100644 --- a/TruePath.Tests/PathExtensionsTests.cs +++ b/TruePath.Tests/PathExtensionsTests.cs @@ -75,9 +75,7 @@ public void FileNameInvariantTests(string inputPath) } [Theory] - [InlineData(@"C:\", "bar", @"C:\.bar")] [InlineData(@"C:\filename.foo", "bar", @"C:\filename.bar")] - [InlineData(@"\", "bar", @"\.bar")] [InlineData(@"\file", "bar", @"\file.bar")] [InlineData(@"\file", ".bar", @"\file.bar")] [InlineData(@"\file.", ".bar", @"\file.bar")] @@ -123,7 +121,6 @@ public void WithExtensionArgumentTests(string inputPath, string? newExtension, s } [Theory] - [InlineData("/", "bar", "/.bar")] [InlineData("/file", "bar", "/file.bar")] [InlineData("/file", ".bar", "/file.bar")] [InlineData("/file.", ".bar", "/file.bar")] @@ -145,4 +142,64 @@ public void WithExtensionTests_Unix(string inputPath, string newExtension, strin // Assert Assert.Equal(expected, newPath.Value); } + + [Theory] + [InlineData(".gitignore", null)] + [InlineData(".gitignore", "")] + [InlineData(".gitignore", ".")] + [InlineData("foo/.gitignore", null)] + [InlineData("..", null)] + [InlineData("..", "bar")] + [InlineData("../..", "txt")] + [InlineData("", "txt")] + [InlineData("a/..", "txt")] + [InlineData("file.txt", "foo/bar")] + public void WithExtensionThrowsIfTheResultIsNotAFileName(string inputPath, string? newExtension) + { + // Arrange + var path = new LocalPath(inputPath); + + // Act & Assert + Assert.Throws(() => path.WithExtension(newExtension)); + } + + [Theory] + [InlineData(@"C:\foo\.gitignore", null)] + [InlineData(@"C:\.gitignore", null)] + [InlineData(@"C:\", "bar")] + public void WithExtensionThrowsIfTheResultIsNotAFileName_Windows(string inputPath, string? newExtension) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + + // Arrange + var local = new LocalPath(inputPath); + var absolute = new AbsolutePath(inputPath); + + // Act & Assert + Assert.Throws(() => local.WithExtension(newExtension)); + Assert.Throws(() => absolute.WithExtension(newExtension)); + } + + [Theory] + [InlineData("/foo/.gitignore", null)] + [InlineData("/.gitignore", null)] + [InlineData("/", "bar")] + public void WithExtensionThrowsIfTheResultIsNotAFileName_Unix(string inputPath, string? newExtension) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux) && !RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return; + } + + // Arrange + var local = new LocalPath(inputPath); + var absolute = new AbsolutePath(inputPath); + + // Act & Assert + Assert.Throws(() => local.WithExtension(newExtension)); + Assert.Throws(() => absolute.WithExtension(newExtension)); + } } diff --git a/TruePath/PathExtensions.cs b/TruePath/PathExtensions.cs index 34b7acc..1392c5c 100644 --- a/TruePath/PathExtensions.cs +++ b/TruePath/PathExtensions.cs @@ -52,8 +52,8 @@ public static string GetFilenameWithoutExtension(this IPath path) => #if NET8_0_OR_GREATER /// - /// Returns a new path of the same type with the extension of its file name component changed, - /// or with a new extension-like component if the original file name was empty. + /// Returns a new path of the same type with the extension of its file name + /// component changed. /// /// The type of the path, which must implement . /// The original path. @@ -66,11 +66,28 @@ public static string GetFilenameWithoutExtension(this IPath path) => /// Pass to remove the extension entirely (file.txt becomes file), or /// an empty string to remove it but keep the trailing dot (file.txt becomes file.). /// + /// + /// The extension may not contain a directory separator, and neither , an empty + /// string nor a lone dot may be passed for a file name that consists entirely of an extension: see the + /// exceptions section below. + /// /// /// /// A new path of type with the modified file name component. /// The original object is not modified. /// + /// + /// + /// Thrown if the has no file name to change: it is empty (which designates the + /// current directory), it consists of a root or a drive designator (Windows) alone, such as C:\, + /// / or C:, or its last segment is a parent directory reference (..). + /// + /// + /// Also thrown if the requested change would leave something other than a file name in the file name's + /// place: e.g. replacing .gitignore with would leave nothing at all, and + /// file.txt with the extension foo/bar would add a whole new segment. + /// + /// /// /// /// Only the last extension is replaced: for archive.tar.gz and the extension zip, the result @@ -81,15 +98,24 @@ public static string GetFilenameWithoutExtension(this IPath path) => /// /// A file name consisting entirely of an extension is treated as an extension, consistently with /// : .gitignore with the extension hgignore becomes - /// .hgignore. + /// .hgignore. Removing the extension of such a name is impossible, though: nothing would + /// be left of the file name. + /// + /// + /// This method changes the extension and nothing else: the path always ends with a file name both before and + /// after the call, and the number and the kind of its segments are always the same. Whenever the requested + /// change would break that, it throws an instead of returning a path of a + /// different shape. /// /// - public static TPath WithExtension(this TPath path, string? extension) where TPath : IPath => - TPath.Create(Path.ChangeExtension(((IPath)path).Value, extension)); + public static TPath WithExtension(this TPath path, string? extension) where TPath : IPath + { + var p = (IPath)path; + return TPath.Create(ChangeExtension(p.Value, p.FileName, extension)); + } #else /// - /// Returns a new path of type with the extension of its file name component changed, - /// or with a new extension-like component if the original file name was empty. + /// Returns a new path of type with the extension of its file name component changed. /// /// The original path. /// @@ -101,11 +127,28 @@ public static TPath WithExtension(this TPath path, string? extension) whe /// Pass to remove the extension entirely (file.txt becomes file), or /// an empty string to remove it but keep the trailing dot (file.txt becomes file.). /// + /// + /// The extension may not contain a directory separator, and neither , an empty + /// string nor a lone dot may be passed for a file name that consists entirely of an extension: see the + /// exception below. + /// /// /// /// A new path of type with the modified file name component. /// The original object is not modified. /// + /// + /// + /// Thrown if the has no file name to change: it is empty (which designates the + /// current directory), it consists of a root or a drive designator alone, such as C:\, / + /// or C:, or its last segment is a parent directory reference (..). + /// + /// + /// Also thrown if the requested change would leave something other than a file name in the file name's + /// place: replacing .gitignore with would leave nothing at all, and + /// file.txt with the extension foo/bar would add a whole new segment. + /// + /// /// /// /// Only the last extension is replaced: for archive.tar.gz and the extension zip, the result @@ -116,15 +159,21 @@ public static TPath WithExtension(this TPath path, string? extension) whe /// /// A file name consisting entirely of an extension is treated as an extension, consistently with /// : .gitignore with the extension hgignore becomes - /// .hgignore. + /// .hgignore. Removing the extension of such a name is impossible, though: nothing would + /// be left of the file name. + /// + /// + /// This method changes the extension and nothing else: the path always ends with a file name both before and + /// after the call, and the number and the kind of its segments are always the same. Whenever the requested + /// change would break that, it throws an instead of returning a path of a + /// different shape. /// /// public static AbsolutePath WithExtension(this AbsolutePath path, string? extension) => - AbsolutePath.Create(Path.ChangeExtension(path.Value, extension)); + AbsolutePath.Create(ChangeExtension(path.Value, path.FileName, extension)); /// - /// Returns a new path of type with the extension of its file name component changed, - /// or with a new extension-like component if the original file name was empty. + /// Returns a new path of type with the extension of its file name component changed. /// /// The original path. /// @@ -136,11 +185,28 @@ public static AbsolutePath WithExtension(this AbsolutePath path, string? extensi /// Pass to remove the extension entirely (file.txt becomes file), or /// an empty string to remove it but keep the trailing dot (file.txt becomes file.). /// + /// + /// The extension may not contain a directory separator, and neither , an empty + /// string nor a lone dot may be passed for a file name that consists entirely of an extension: see the + /// exception below. + /// /// /// /// A new path of type with the modified file name component. /// The original object is not modified. /// + /// + /// + /// Thrown if the has no file name to change: it is empty (which designates the + /// current directory), it consists of a root or a drive designator alone, such as C:\, / + /// or C:, or its last segment is a parent directory reference (..). + /// + /// + /// Also thrown if the requested change would leave something other than a file name in the file name's + /// place: e.g. replacing C:\foo\.gitignore with would leave nothing at all, and + /// file.txt with the extension foo/bar would add a whole new segment. + /// + /// /// /// /// Only the last extension is replaced: for archive.tar.gz and the extension zip, the result @@ -151,10 +217,46 @@ public static AbsolutePath WithExtension(this AbsolutePath path, string? extensi /// /// A file name consisting entirely of an extension is treated as an extension, consistently with /// : .gitignore with the extension hgignore becomes - /// .hgignore. + /// .hgignore. Removing the extension of such a name is impossible, though: nothing would + /// be left of the file name. + /// + /// + /// This method changes the extension and nothing else: the path always ends with a file name both before and + /// after the call, and the number and the kind of its segments are always the same. Whenever the requested + /// change would break that, it throws an instead of returning a path of a + /// different shape. /// /// public static LocalPath WithExtension(this LocalPath path, string? extension) => - LocalPath.Create(Path.ChangeExtension(path.Value, extension)); + LocalPath.Create(ChangeExtension(path.Value, path.FileName, extension)); #endif + + private static bool IsFileName(string segment) => + segment.Length > 0 + && segment is not ("." or "..") + && segment.IndexOf(Path.DirectorySeparatorChar) < 0 + && segment.IndexOf(Path.AltDirectorySeparatorChar) < 0; + + private static string ChangeExtension(string value, string fileName, string? extension) + { + if (!IsFileName(fileName)) + throw new ArgumentException( + $"Path \"{value}\" does not end with a file name, so its extension cannot be changed.", + "path"); + + // A file name consisting entirely of an extension, such as ".gitignore", has nothing left once the extension + // is removed: the new file name would either be empty or a lone dot that the normalization drops, and so the + // path would lose a segment. + var newFileName = Path.ChangeExtension(fileName, extension); + if (!IsFileName(newFileName)) + { + var extensionText = extension is null ? "null" : $"\"{extension}\""; + throw new ArgumentException( + $"Changing the extension of path \"{value}\" to {extensionText} would replace its file name " + + $"\"{fileName}\" with \"{newFileName}\", which is not a file name.", + nameof(extension)); + } + + return Path.ChangeExtension(value, extension); + } }