Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Semantics.Paths/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
79 changes: 30 additions & 49 deletions Semantics.Paths/SemanticFilePath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,83 +9,64 @@ public abstract record SemanticFilePath<TDerived> : SemanticPath<TDerived>
where TDerived : SemanticFilePath<TDerived>
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Only the file-name segment is searched, so a dot in a directory name (<c>./notes.txt</c>,
/// <c>/opt/app.v2/README</c>) is never mistaken for an extension. A leading dot in the file name
/// marks a dotfile rather than an extension: <c>.bashrc</c> has no extension, and
/// <c>.config.json</c> has <c>.json</c>.
/// </remarks>
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<FileExtension>("");
}

// Return extension including the dot
string extension = span.Substring(lastDotIndex);
return FileExtension.Create<FileExtension>(extension);
#else
ReadOnlySpan<char> 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<FileExtension>("");
}

// Return extension including the dot
ReadOnlySpan<char> extension = span[lastDotIndex..];
return FileExtension.Create<FileExtension>(extension.ToString());
#endif
return FileExtension.Create<FileExtension>(path.AsSpan(lastDotIndex));
}
}

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Follows the same rules as <see cref="FileExtension"/>: only the file-name segment is searched, and
/// a leading dot in the file name is not an extension, so <c>.bashrc</c> has none.
/// </remarks>
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<FileExtension>("");
}

// Return everything from the first dot onward
string fullExtension = span.Substring(firstDotIndex);
return FileExtension.Create<FileExtension>(fullExtension);
#else
ReadOnlySpan<char> 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<FileExtension>("");
}

// Return everything from the first dot onward
ReadOnlySpan<char> fullExtension = span[firstDotIndex..];
return FileExtension.Create<FileExtension>(fullExtension.ToString());
#endif
return FileExtension.Create<FileExtension>(path.AsSpan(firstDotIndex));
}
}

/// <summary>
/// Returns the index of the first character of the file-name segment of <paramref name="path"/>.
/// </summary>
/// <param name="path">The path to inspect.</param>
/// <returns>The index just past the last directory separator, or 0 if there is none.</returns>
private static int FileNameStart(string path) => path.LastIndexOfAny(['/', '\\']) + 1;

/// <summary>
/// Gets the filename portion of the path
/// </summary>
Expand Down
32 changes: 32 additions & 0 deletions Semantics.Test/SemanticPathTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<FilePath>(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<AbsoluteFilePath>(TestPaths.Absolute("home", "user", ".config", "settings.json"));
Assert.AreEqual(".json", absolute.FileExtension.ToString());
Assert.AreEqual(".json", absolute.FullFileExtension.ToString());

AbsoluteFilePath noExtension = AbsoluteFilePath.Create<AbsoluteFilePath>(TestPaths.Absolute("opt", "app.v2", "README"));
Assert.AreEqual("", noExtension.FileExtension.ToString());
Assert.AreEqual("", noExtension.FullFileExtension.ToString());

RelativeFilePath relative = RelativeFilePath.Create<RelativeFilePath>(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()
{
Expand Down
Loading