diff --git a/Semantics.Paths/Implementations/AbsoluteFilePath.cs b/Semantics.Paths/Implementations/AbsoluteFilePath.cs index 5584c967..1ceda918 100644 --- a/Semantics.Paths/Implementations/AbsoluteFilePath.cs +++ b/Semantics.Paths/Implementations/AbsoluteFilePath.cs @@ -52,7 +52,7 @@ public FileName FileNameWithoutExtension get { return _cachedFileNameWithoutExtension ??= FileName.Create( - Path.GetFileNameWithoutExtension(WeakString) ?? ""); + Path.GetFileName(WeakStringWithoutExtension)); } } @@ -65,19 +65,14 @@ public AbsoluteFilePath ChangeExtension(FileExtension newExtension) { Ensure.NotNull(newExtension); - string newPath = Path.ChangeExtension(WeakString, newExtension.WeakString); - return Create(newPath); + return Create(WeakStringWithExtension(newExtension)); } /// /// Removes the file extension from this path. /// /// A new without an extension. - public AbsoluteFilePath RemoveExtension() - { - string pathWithoutExtension = Path.ChangeExtension(WeakString, null) ?? ""; - return Create(pathWithoutExtension); - } + public AbsoluteFilePath RemoveExtension() => Create(WeakStringWithoutExtension); /// /// Determines whether this file is inside the specified parent directory. diff --git a/Semantics.Paths/Implementations/RelativeFilePath.cs b/Semantics.Paths/Implementations/RelativeFilePath.cs index d972f8be..85f68e23 100644 --- a/Semantics.Paths/Implementations/RelativeFilePath.cs +++ b/Semantics.Paths/Implementations/RelativeFilePath.cs @@ -52,7 +52,7 @@ public FileName FileNameWithoutExtension get { return _cachedFileNameWithoutExtension ??= FileName.Create( - Path.GetFileNameWithoutExtension(WeakString) ?? ""); + Path.GetFileName(WeakStringWithoutExtension)); } } @@ -65,19 +65,14 @@ public RelativeFilePath ChangeExtension(FileExtension newExtension) { Ensure.NotNull(newExtension); - string newPath = Path.ChangeExtension(WeakString, newExtension.WeakString); - return Create(newPath); + return Create(WeakStringWithExtension(newExtension)); } /// /// Removes the file extension from this path. /// /// A new without an extension. - public RelativeFilePath RemoveExtension() - { - string pathWithoutExtension = Path.ChangeExtension(WeakString, null) ?? ""; - return Create(pathWithoutExtension); - } + public RelativeFilePath RemoveExtension() => Create(WeakStringWithoutExtension); /// /// Converts this relative file path to an absolute file path representation. diff --git a/Semantics.Paths/SemanticFilePath.cs b/Semantics.Paths/SemanticFilePath.cs index 9f24d24a..a60bf135 100644 --- a/Semantics.Paths/SemanticFilePath.cs +++ b/Semantics.Paths/SemanticFilePath.cs @@ -67,6 +67,42 @@ public FileExtension FullFileExtension /// The index just past the last directory separator, or 0 if there is none. private static int FileNameStart(string path) => path.LastIndexOfAny(['/', '\\']) + 1; + /// + /// Gets the path with its removed, so a dotfile such as .bashrc, + /// which has no extension, keeps its whole name. A trailing dot after the name (file.) is + /// removed too, as removes it. + /// + private protected string WeakStringWithoutExtension + { + get + { + string path = WeakString; + int length = path.Length - FileExtension.WeakString.Length; + if (length == path.Length && length - 1 > FileNameStart(path) && path[length - 1] == '.') + { + length--; + } + + return path.Substring(0, length); + } + } + + /// + /// Gets the path with its replaced by , or + /// with appended when the path has none, as a dotfile does. + /// + /// The new extension, with or without its leading period. + /// The path with the new extension. + private protected string WeakStringWithExtension(FileExtension newExtension) + { + Ensure.NotNull(newExtension); + + string extension = newExtension.WeakString; + return extension.Length > 0 && extension[0] == '.' + ? WeakStringWithoutExtension + extension + : $"{WeakStringWithoutExtension}.{extension}"; + } + /// /// Gets the filename portion of the path /// diff --git a/Semantics.Test/Paths/DotfileExtensionTests.cs b/Semantics.Test/Paths/DotfileExtensionTests.cs new file mode 100644 index 00000000..d5a34a02 --- /dev/null +++ b/Semantics.Test/Paths/DotfileExtensionTests.cs @@ -0,0 +1,92 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Test.Paths; + +using ktsu.Semantics.Paths; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Issue #325: a dotfile (.bashrc) has no extension, so RemoveExtension leaves it alone +/// and ChangeExtension appends to it, instead of treating the whole name as an extension. +/// +[TestClass] +public sealed class DotfileExtensionTests +{ + private static FileExtension Bak => FileExtension.Create(".bak"); + + // Absolute and relative dotfiles from the issue, iterated inside each test rather than passed + // through [DataRow]: a public test method taking a string trips CA1062. + private static readonly string[] AbsoluteDotfiles = + [ + TestPaths.Absolute("home", "user", ".bashrc"), + TestPaths.Absolute(".profile"), + ]; + + private static readonly string[] RelativeDotfiles = + [ + TestPaths.Relative(".gitignore"), + TestPaths.Relative("src", ".editorconfig"), + ]; + + [TestMethod] + public void AbsoluteFilePath_Dotfile_RemoveExtension_KeepsName() + { + foreach (string path in AbsoluteDotfiles) + { + Assert.AreEqual(path, AbsoluteFilePath.Create(path).RemoveExtension().WeakString); + } + } + + [TestMethod] + public void AbsoluteFilePath_Dotfile_ChangeExtension_Appends() + { + foreach (string path in AbsoluteDotfiles) + { + Assert.AreEqual(path + ".bak", AbsoluteFilePath.Create(path).ChangeExtension(Bak).WeakString); + } + } + + [TestMethod] + public void RelativeFilePath_Dotfile_RemoveExtension_KeepsName() + { + foreach (string path in RelativeDotfiles) + { + Assert.AreEqual(path, RelativeFilePath.Create(path).RemoveExtension().WeakString); + } + } + + [TestMethod] + public void RelativeFilePath_Dotfile_ChangeExtension_Appends() + { + foreach (string path in RelativeDotfiles) + { + Assert.AreEqual(path + ".bak", RelativeFilePath.Create(path).ChangeExtension(Bak).WeakString); + } + } + + [TestMethod] + public void Dotfile_FileNameWithoutExtension_IsWholeName() + { + Assert.AreEqual(".bashrc", AbsoluteFilePath.Create(TestPaths.Absolute("home", "user", ".bashrc")).FileNameWithoutExtension.WeakString); + Assert.AreEqual(".gitignore", RelativeFilePath.Create(".gitignore").FileNameWithoutExtension.WeakString); + } + + [TestMethod] + public void DotfileWithExtension_RemoveAndChangeExtension_TouchOnlyTheExtension() + { + RelativeFilePath path = RelativeFilePath.Create(TestPaths.Relative("src", ".config.json")); + + Assert.AreEqual(TestPaths.Relative("src", ".config"), path.RemoveExtension().WeakString); + Assert.AreEqual(TestPaths.Relative("src", ".config.bak"), path.ChangeExtension(Bak).WeakString); + Assert.AreEqual(".config", path.FileNameWithoutExtension.WeakString); + } + + [TestMethod] + public void DotInDirectoryName_IsNotAnExtension() + { + RelativeFilePath path = RelativeFilePath.Create(TestPaths.Relative("app.v2", "README")); + + Assert.AreEqual(path.WeakString, path.RemoveExtension().WeakString); + Assert.AreEqual(TestPaths.Relative("app.v2", "README.bak"), path.ChangeExtension(Bak).WeakString); + } +}