diff --git a/Semantics.Paths/README.md b/Semantics.Paths/README.md index 2fc5a14..dc21125 100644 --- a/Semantics.Paths/README.md +++ b/Semantics.Paths/README.md @@ -162,7 +162,7 @@ The primitive component types `FileName`, `FileExtension`, and `DirectoryName` a | `Exists` | `bool` | True if the path is an existing file or directory. | | `IsFile` / `IsDirectory` | `bool` | Filesystem-backed checks. | | `FileName` | `FileName` | Filename portion (file paths). | -| `FileExtension` / `FullFileExtension` | `FileExtension` | Last extension / everything from the first dot. | +| `FileExtension` / `FullFileExtension` | `FileExtension` | Last extension / everything from the first dot, searching the file name only. A leading dot marks a dotfile, not an extension (`.bashrc` has none). | | `DirectoryPath` | `DirectoryPath` | Directory portion of a file path. | | `Parent` / `Name` / `Depth` / `IsRoot` | directory members | Directory navigation. | | `AsAbsolute()` | typed absolute path | Resolve against the current working directory. | diff --git a/Semantics.Paths/SemanticFilePath.cs b/Semantics.Paths/SemanticFilePath.cs index d2d4a24..9f24d24 100644 --- a/Semantics.Paths/SemanticFilePath.cs +++ b/Semantics.Paths/SemanticFilePath.cs @@ -9,83 +9,64 @@ public abstract record SemanticFilePath : SemanticPath where TDerived : SemanticFilePath { /// - /// Gets the file extension including the leading period, or empty if no extension + /// Gets the file extension including the leading period, or empty if no extension. /// + /// + /// Only the file-name segment is searched, so a dot in a directory name (./notes.txt, + /// /opt/app.v2/README) is never mistaken for an extension. A leading dot in the file name + /// marks a dotfile rather than an extension: .bashrc has no extension, and + /// .config.json has .json. + /// public FileExtension FileExtension { get { -#if NETSTANDARD2_0 - string span = WeakString; + string path = WeakString; + int nameStart = FileNameStart(path); - // Find the last dot - int lastDotIndex = span.LastIndexOf('.'); - if (lastDotIndex == -1 || lastDotIndex == span.Length - 1) + // A dot at the start of the name, or before it, is not an extension; neither is a trailing dot + int lastDotIndex = path.LastIndexOf('.'); + if (lastDotIndex <= nameStart || lastDotIndex == path.Length - 1) { - // No extension or trailing dot return FileExtension.Create(""); } - // Return extension including the dot - string extension = span.Substring(lastDotIndex); - return FileExtension.Create(extension); -#else - ReadOnlySpan span = WeakString.AsSpan(); - - // Find the last dot - int lastDotIndex = span.LastIndexOf('.'); - if (lastDotIndex == -1 || lastDotIndex == span.Length - 1) - { - // No extension or trailing dot - return FileExtension.Create(""); - } - - // Return extension including the dot - ReadOnlySpan extension = span[lastDotIndex..]; - return FileExtension.Create(extension.ToString()); -#endif + return FileExtension.Create(path.AsSpan(lastDotIndex)); } } /// - /// Gets all trailing period-delimited segments including the leading period, or empty if no extensions + /// Gets all trailing period-delimited segments including the leading period, or empty if no extensions. /// + /// + /// Follows the same rules as : only the file-name segment is searched, and + /// a leading dot in the file name is not an extension, so .bashrc has none. + /// public FileExtension FullFileExtension { get { -#if NETSTANDARD2_0 - string span = WeakString; - - // Find the first dot - int firstDotIndex = span.IndexOf('.'); - if (firstDotIndex == -1) - { - // No extension - return FileExtension.Create(""); - } - - // Return everything from the first dot onward - string fullExtension = span.Substring(firstDotIndex); - return FileExtension.Create(fullExtension); -#else - ReadOnlySpan span = WeakString.AsSpan(); + string path = WeakString; + int nameStart = FileNameStart(path); - // Find the first dot - int firstDotIndex = span.IndexOf('.'); + // Skip the name's first character, so a dotfile's leading dot is not taken as an extension + int firstDotIndex = nameStart + 1 < path.Length ? path.IndexOf('.', nameStart + 1) : -1; if (firstDotIndex == -1) { - // No extension return FileExtension.Create(""); } - // Return everything from the first dot onward - ReadOnlySpan fullExtension = span[firstDotIndex..]; - return FileExtension.Create(fullExtension.ToString()); -#endif + return FileExtension.Create(path.AsSpan(firstDotIndex)); } } + /// + /// Returns the index of the first character of the file-name segment of . + /// + /// The path to inspect. + /// 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 filename portion of the path /// diff --git a/Semantics.Test/SemanticPathTests.cs b/Semantics.Test/SemanticPathTests.cs index 3753678..eeec157 100644 --- a/Semantics.Test/SemanticPathTests.cs +++ b/Semantics.Test/SemanticPathTests.cs @@ -94,6 +94,38 @@ public void SemanticFilePath_FullFileExtension_MultipleExtensions_ShouldReturnAl Assert.AreEqual(".tar.gz", fullExtension.ToString()); } + [TestMethod] + [DataRow(new[] { ".", "notes.txt" }, ".txt", ".txt")] + [DataRow(new[] { "..", "data", "archive.tar.gz" }, ".gz", ".tar.gz")] + [DataRow(new[] { "app.v2", "README" }, "", "")] + [DataRow(new[] { "user", ".config", "settings.json" }, ".json", ".json")] + [DataRow(new[] { "user", ".bashrc" }, "", "")] + [DataRow(new[] { ".bashrc" }, "", "")] + [DataRow(new[] { "user", ".config.json" }, ".json", ".json")] + public void SemanticFilePath_Extensions_OnlySearchTheFileName(string[] segments, string expectedExtension, string expectedFullExtension) + { + FilePath filePath = FilePath.Create(TestPaths.Relative(segments)); + + Assert.AreEqual(expectedExtension, filePath.FileExtension.ToString()); + Assert.AreEqual(expectedFullExtension, filePath.FullFileExtension.ToString()); + } + + [TestMethod] + public void SemanticFilePath_Extensions_IgnoreDotsInDirectoriesOfAbsoluteAndRelativePaths() + { + AbsoluteFilePath absolute = AbsoluteFilePath.Create(TestPaths.Absolute("home", "user", ".config", "settings.json")); + Assert.AreEqual(".json", absolute.FileExtension.ToString()); + Assert.AreEqual(".json", absolute.FullFileExtension.ToString()); + + AbsoluteFilePath noExtension = AbsoluteFilePath.Create(TestPaths.Absolute("opt", "app.v2", "README")); + Assert.AreEqual("", noExtension.FileExtension.ToString()); + Assert.AreEqual("", noExtension.FullFileExtension.ToString()); + + RelativeFilePath relative = RelativeFilePath.Create(TestPaths.Relative("..", "data", "archive.tar.gz")); + Assert.AreEqual(".gz", relative.FileExtension.ToString()); + Assert.AreEqual(".tar.gz", relative.FullFileExtension.ToString()); + } + [TestMethod] public void SemanticFilePath_FileName_ShouldReturnCorrectFileName() {