diff --git a/src/Ramstack.FileSystem.Abstractions/Internal/PathTokenizer.cs b/src/Ramstack.FileSystem.Abstractions/Internal/PathTokenizer.cs index ffb6a5e..8d881c8 100644 --- a/src/Ramstack.FileSystem.Abstractions/Internal/PathTokenizer.cs +++ b/src/Ramstack.FileSystem.Abstractions/Internal/PathTokenizer.cs @@ -18,7 +18,7 @@ internal readonly struct PathTokenizer(string path) /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public Enumerator GetEnumerator() => - new(path); + new Enumerator(path); /// /// Tokenizes the specified path into a collection of the path components. @@ -28,7 +28,7 @@ public Enumerator GetEnumerator() => /// The . /// public static PathTokenizer Tokenize(string path) => - new(path); + new PathTokenizer(path); #region Inner type: Enumerator diff --git a/src/Ramstack.FileSystem.Abstractions/VirtualDirectory.cs b/src/Ramstack.FileSystem.Abstractions/VirtualDirectory.cs index b24cc22..21c50fc 100644 --- a/src/Ramstack.FileSystem.Abstractions/VirtualDirectory.cs +++ b/src/Ramstack.FileSystem.Abstractions/VirtualDirectory.cs @@ -1,7 +1,6 @@ using System.Diagnostics; using System.Runtime.CompilerServices; -using Ramstack.FileSystem.Internal; using Ramstack.Globbing.Traversal; namespace Ramstack.FileSystem; diff --git a/src/Ramstack.FileSystem.Abstractions/Internal/VirtualPath.cs b/src/Ramstack.FileSystem.Abstractions/VirtualPath.cs similarity index 64% rename from src/Ramstack.FileSystem.Abstractions/Internal/VirtualPath.cs rename to src/Ramstack.FileSystem.Abstractions/VirtualPath.cs index a68dcea..cb9ef4f 100644 --- a/src/Ramstack.FileSystem.Abstractions/Internal/VirtualPath.cs +++ b/src/Ramstack.FileSystem.Abstractions/VirtualPath.cs @@ -3,39 +3,88 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -namespace Ramstack.FileSystem.Internal; +using Ramstack.FileSystem.Internal; + +namespace Ramstack.FileSystem; /// -/// Provides path helper methods. +/// Provides utility methods for working with virtual paths. /// -internal static class VirtualPath +/// +/// +/// For compatibility across different implementations of +/// and operating systems, directory separators are unified to use both "/" and "\". +/// This approach will be reviewed once a better solution is found. +/// +/// +/// When normalizing paths (e.g., using the methods and ), +/// "\" separators will be replaced with "/" forcibly. +/// +/// +/// Highly recommended to call or when using virtual paths +/// in the context of to normalize separators and remove relative segments +/// such as "." and "..", since the underlying subsystem for which +/// is implemented may not support these capabilities. +/// +/// +public static class VirtualPath { /// /// The threshold size in characters for using stack allocation. /// - private const int StackallocThreshold = 160; + private const int StackallocThreshold = 256; /// - /// Gets the extension part of the specified path string, including the leading dot . - /// even if it is the entire file name, or an empty string if no extension is present. + /// Returns an extension (including the period ".") of the specified path string. /// /// The path string from which to get the extension. /// - /// The extension of the specified path, including the period ., + /// The extension of the specified path (including the period "."), /// or an empty string if no extension is present. /// + /// + /// returns an empty string ("") + /// if the extension consists solely of a period (e.g., "file."), which differs from + /// , which returns "." in this case. + /// This method follows the behavior of . + /// public static string GetExtension(string path) + { + _ = path.Length; + return GetExtension(path.AsSpan()).ToString(); + } + + /// + /// Returns an extension (including the period ".") of the specified path string. + /// + /// The path string from which to get the extension. + /// + /// The extension of the specified path (including the period "."), + /// or an empty string if no extension is present. + /// + /// + /// returns an empty string ("") + /// if the extension consists solely of a period (e.g., "file."), which differs from + /// , which returns "." in this case. + /// This method follows the behavior of . + /// + public static ReadOnlySpan GetExtension(ReadOnlySpan path) { for (var i = path.Length - 1; i >= 0; i--) { if (path[i] == '.') - return path.AsSpan(i).ToString(); + { + if (i == path.Length - 1) + break; - if (path[i] == '/') + return path.Slice(i); + } + + if (path[i] == '/' || path[i] == '\\') break; } - return ""; + return default; } /// @@ -47,11 +96,28 @@ public static string GetExtension(string path) /// public static string GetFileName(string path) { - var p = path.AsSpan(); + var length = path.Length; + + var fileName = GetFileName(path.AsSpan()); + if (fileName.Length != length) + return fileName.ToString(); - var start = p.LastIndexOf('/'); - return start >= 0 - ? p.Slice(start + 1).ToString() + return path; + } + + /// + /// Returns the file name and extension for the specified path. + /// + /// The path from which to obtain the file name and extension. + /// + /// The file name and extension for the . + /// + public static ReadOnlySpan GetFileName(ReadOnlySpan path) + { + var index = path.LastIndexOfAny('/', '\\'); + + return index >= 0 + ? path.Slice(index + 1) : path; } @@ -64,20 +130,35 @@ public static string GetFileName(string path) /// public static string GetDirectoryName(string path) { - var index = path.AsSpan().LastIndexOf('/'); - if (index < 0) + var offset = GetDirectoryNameOffset(path); + + if (offset < 0) return ""; - var p = index; - while (p - 1 >= 0 && path[p - 1] == '/') - p--; + if (offset == 0) + return "/"; - return p switch - { - 0 when index + 1 == path.Length => "", - 0 => "/", - _ => path[..p] - }; + return path[..offset]; + } + + /// + /// Returns the directory portion for the specified path. + /// + /// The path to retrieve the directory portion from. + /// + /// Directory portion for , or an empty string if path denotes a root directory. + /// + public static ReadOnlySpan GetDirectoryName(ReadOnlySpan path) + { + var offset = GetDirectoryNameOffset(path); + + if (offset < 0) + return ""; + + if (offset == 0) + return "/"; + + return path.Slice(0, offset); } /// @@ -126,7 +207,8 @@ static string NormalizeImpl(string path) index++; } - while (index > 1 && buffer[index - 1] == '/') + // There can be only one trailing slash at most + if (index > 1 && buffer[index - 1] == '/') index--; var result = index > 1 @@ -148,21 +230,21 @@ static string NormalizeImpl(string path) /// if the path in a normalized form; /// otherwise, . /// - public static bool IsNormalized(string path) + public static bool IsNormalized(ReadOnlySpan path) { - if (path.Length == 0 || string.IsNullOrWhiteSpace(path)) + if (path.Length == 0) return false; if (path[0] != '/') return false; - if (path.Length > 1 && path.EndsWith('/')) + if (path.Length > 1 && HasTrailingSlash(path)) return false; - if (path.AsSpan().Contains('\\')) + if (path.Contains('\\')) return false; - return path.AsSpan().IndexOf("//") < 0; + return path.IndexOf("//") < 0; } /// @@ -173,7 +255,7 @@ public static bool IsNormalized(string path) /// if the path in a normalized form; /// otherwise, . /// - public static bool IsFullyNormalized(string path) + public static bool IsFullyNormalized(ReadOnlySpan path) { if (path is ['/', ..]) { @@ -191,7 +273,7 @@ public static bool IsFullyNormalized(string path) return false; var nch = path[j + 1]; - if (nch is '/' or '\\') + if (nch == '/' || nch == '\\') return false; if (nch == '.') @@ -200,7 +282,7 @@ public static bool IsFullyNormalized(string path) return false; var sch = path[j + 2]; - if (sch is '/' or '\\') + if (sch == '/' || sch == '\\') return false; } } @@ -245,7 +327,7 @@ public static string GetFullPath(string path) // Unwind back to the last separator index = buffer[..index].LastIndexOf('/'); - // Path.GetFullPath in this case does not throw an exceptiion, + // Path.GetFullPath in this case does not throw an exception, // it simply clears out the buffer if (index < 0) Error_InvalidPath(); @@ -343,6 +425,22 @@ public static string Join(ReadOnlySpan path1, ReadOnlySpan path2) return string.Concat(path1, "/", path2); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool HasLeadingSlash(string path) => + path.StartsWith('/') || path.StartsWith('\\'); + + /// + /// Determines whether the specified path string ends in a directory separator. + /// + /// The path to test. + /// + /// if the path has a trailing directory separator; + /// otherwise, . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool HasTrailingSlash(string path) => + path.EndsWith('/') || path.EndsWith('\\'); + /// /// Determines whether the specified path string starts with a directory separator. /// @@ -369,27 +467,27 @@ public static bool HasTrailingSlash(ReadOnlySpan path) if (path.Length != 0) { var ch = Unsafe.Add(ref MemoryMarshal.GetReference(path), (nint)(uint)path.Length - 1); - return ch is '/' or '\\'; + return ch == '/' || ch == '\\'; } return false; } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool HasLeadingSlash(string path) => - path.StartsWith('/') || path.StartsWith('\\'); + private static int GetDirectoryNameOffset(ReadOnlySpan path) + { + var lastIndex = path.LastIndexOfAny('/', '\\'); + var index = lastIndex; - /// - /// Determines whether the specified path string ends in a directory separator. - /// - /// The path to test. - /// - /// if the path has a trailing directory separator; - /// otherwise, . - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool HasTrailingSlash(string path) => - path.EndsWith('/') || path.EndsWith('\\'); + // Process consecutive separators + while ((uint)index - 1 < (uint)path.Length && (path[index - 1] == '/' || path[index - 1] == '\\')) + index--; + + // Case where the path consists of separators only + if (index == 0 && lastIndex + 1 == path.Length) + index = -1; + + return index; + } [DoesNotReturn] private static void Error_InvalidPath() => diff --git a/src/Ramstack.FileSystem.Adapters/VirtualFileSystemAdapter.cs b/src/Ramstack.FileSystem.Adapters/VirtualFileSystemAdapter.cs index 982b88f..2e89a44 100644 --- a/src/Ramstack.FileSystem.Adapters/VirtualFileSystemAdapter.cs +++ b/src/Ramstack.FileSystem.Adapters/VirtualFileSystemAdapter.cs @@ -1,7 +1,5 @@ using Microsoft.Extensions.FileProviders; -using Ramstack.FileSystem.Internal; - namespace Ramstack.FileSystem.Adapters; /// diff --git a/src/Ramstack.FileSystem.Amazon/AmazonS3FileSystem.cs b/src/Ramstack.FileSystem.Amazon/AmazonS3FileSystem.cs index 979e280..48082ad 100644 --- a/src/Ramstack.FileSystem.Amazon/AmazonS3FileSystem.cs +++ b/src/Ramstack.FileSystem.Amazon/AmazonS3FileSystem.cs @@ -4,8 +4,6 @@ using Amazon.S3.Model; using Amazon.S3.Util; -using Ramstack.FileSystem.Internal; - namespace Ramstack.FileSystem.Amazon; /// diff --git a/src/Ramstack.FileSystem.Amazon/S3Directory.cs b/src/Ramstack.FileSystem.Amazon/S3Directory.cs index acae6a1..f9e5fa3 100644 --- a/src/Ramstack.FileSystem.Amazon/S3Directory.cs +++ b/src/Ramstack.FileSystem.Amazon/S3Directory.cs @@ -1,9 +1,7 @@ -using System.Runtime.CompilerServices; +using System.Runtime.CompilerServices; using Amazon.S3.Model; -using Ramstack.FileSystem.Internal; - namespace Ramstack.FileSystem.Amazon; /// diff --git a/src/Ramstack.FileSystem.Azure/AzureDirectory.cs b/src/Ramstack.FileSystem.Azure/AzureDirectory.cs index db27fc2..13736bf 100644 --- a/src/Ramstack.FileSystem.Azure/AzureDirectory.cs +++ b/src/Ramstack.FileSystem.Azure/AzureDirectory.cs @@ -5,8 +5,6 @@ using Azure.Storage.Blobs.Models; using Azure.Storage.Blobs.Specialized; -using Ramstack.FileSystem.Internal; - namespace Ramstack.FileSystem.Azure; /// diff --git a/src/Ramstack.FileSystem.Azure/AzureFileSystem.cs b/src/Ramstack.FileSystem.Azure/AzureFileSystem.cs index 9d03a3d..9230e3f 100644 --- a/src/Ramstack.FileSystem.Azure/AzureFileSystem.cs +++ b/src/Ramstack.FileSystem.Azure/AzureFileSystem.cs @@ -5,8 +5,6 @@ using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; -using Ramstack.FileSystem.Internal; - namespace Ramstack.FileSystem.Azure; /// diff --git a/src/Ramstack.FileSystem.Composite/CompositeFileSystem.cs b/src/Ramstack.FileSystem.Composite/CompositeFileSystem.cs index bfbd718..f77ba54 100644 --- a/src/Ramstack.FileSystem.Composite/CompositeFileSystem.cs +++ b/src/Ramstack.FileSystem.Composite/CompositeFileSystem.cs @@ -1,5 +1,3 @@ -using Ramstack.FileSystem.Internal; - namespace Ramstack.FileSystem.Composite; /// diff --git a/src/Ramstack.FileSystem.Globbing/GlobbingFileSystem.cs b/src/Ramstack.FileSystem.Globbing/GlobbingFileSystem.cs index e54856d..e3fe827 100644 --- a/src/Ramstack.FileSystem.Globbing/GlobbingFileSystem.cs +++ b/src/Ramstack.FileSystem.Globbing/GlobbingFileSystem.cs @@ -1,5 +1,4 @@ using Ramstack.Globbing; -using Ramstack.FileSystem.Internal; namespace Ramstack.FileSystem.Globbing; diff --git a/src/Ramstack.FileSystem.Physical/PhysicalFileSystem.cs b/src/Ramstack.FileSystem.Physical/PhysicalFileSystem.cs index 852dd5d..d2a1aa3 100644 --- a/src/Ramstack.FileSystem.Physical/PhysicalFileSystem.cs +++ b/src/Ramstack.FileSystem.Physical/PhysicalFileSystem.cs @@ -1,7 +1,5 @@ using System.Diagnostics; -using Ramstack.FileSystem.Internal; - namespace Ramstack.FileSystem.Physical; /// diff --git a/src/Ramstack.FileSystem.Prefixed/PrefixedDirectory.cs b/src/Ramstack.FileSystem.Prefixed/PrefixedDirectory.cs index 25ca8e3..43708a3 100644 --- a/src/Ramstack.FileSystem.Prefixed/PrefixedDirectory.cs +++ b/src/Ramstack.FileSystem.Prefixed/PrefixedDirectory.cs @@ -1,7 +1,5 @@ using System.Runtime.CompilerServices; -using Ramstack.FileSystem.Internal; - namespace Ramstack.FileSystem.Prefixed; /// diff --git a/src/Ramstack.FileSystem.Sub/SubDirectory.cs b/src/Ramstack.FileSystem.Sub/SubDirectory.cs index 18e446f..bd0821e 100644 --- a/src/Ramstack.FileSystem.Sub/SubDirectory.cs +++ b/src/Ramstack.FileSystem.Sub/SubDirectory.cs @@ -1,7 +1,5 @@ using System.Runtime.CompilerServices; -using Ramstack.FileSystem.Internal; - namespace Ramstack.FileSystem.Sub; /// diff --git a/src/Ramstack.FileSystem.Sub/SubFileSystem.cs b/src/Ramstack.FileSystem.Sub/SubFileSystem.cs index dd1c08e..f159103 100644 --- a/src/Ramstack.FileSystem.Sub/SubFileSystem.cs +++ b/src/Ramstack.FileSystem.Sub/SubFileSystem.cs @@ -1,8 +1,6 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using Ramstack.FileSystem.Internal; - namespace Ramstack.FileSystem.Sub; /// diff --git a/src/Ramstack.FileSystem.Zip/ZipFileSystem.cs b/src/Ramstack.FileSystem.Zip/ZipFileSystem.cs index b58a012..74ae12a 100644 --- a/src/Ramstack.FileSystem.Zip/ZipFileSystem.cs +++ b/src/Ramstack.FileSystem.Zip/ZipFileSystem.cs @@ -1,6 +1,5 @@ using System.IO.Compression; -using Ramstack.FileSystem.Internal; using Ramstack.FileSystem.Null; namespace Ramstack.FileSystem.Zip; diff --git a/tests/Ramstack.FileSystem.Abstractions.Tests/VirtualPathTests.cs b/tests/Ramstack.FileSystem.Abstractions.Tests/VirtualPathTests.cs index dc87318..5039841 100644 --- a/tests/Ramstack.FileSystem.Abstractions.Tests/VirtualPathTests.cs +++ b/tests/Ramstack.FileSystem.Abstractions.Tests/VirtualPathTests.cs @@ -1,63 +1,82 @@ -using Ramstack.FileSystem.Internal; - namespace Ramstack.FileSystem; [TestFixture] public class VirtualPathTests { - [TestCase("", ExpectedResult = "")] - [TestCase(".", ExpectedResult = ".")] - [TestCase("/", ExpectedResult = "")] - [TestCase("/.", ExpectedResult = ".")] - [TestCase("file.txt", ExpectedResult = ".txt")] - [TestCase("/path/to/file.txt", ExpectedResult = ".txt")] - [TestCase("/path/to/.hidden", ExpectedResult = ".hidden")] - [TestCase("/path/to/file", ExpectedResult = "")] - [TestCase("/path.with.dots/to/file.txt", ExpectedResult = ".txt")] - [TestCase("/path/with.dots/file.", ExpectedResult = ".")] - [TestCase("/path.with.dots/to/.hidden.ext", ExpectedResult = ".ext")] - [TestCase("file.with.multiple.dots.ext", ExpectedResult = ".ext")] - [TestCase("/path/to/file.with.multiple.dots.ext", ExpectedResult = ".ext")] - [TestCase("/.hidden", ExpectedResult = ".hidden")] - public string GetExtension(string path) => - VirtualPath.GetExtension(path); + [TestCase("", "")] + [TestCase(".", "")] + [TestCase("/", "")] + [TestCase("/.", "")] + [TestCase("file.txt", ".txt")] + [TestCase("/path/to/file.txt", ".txt")] + [TestCase("/path/to/.hidden", ".hidden")] + [TestCase("/path/to/file", "")] + [TestCase("/path.with.dots/to/file.txt", ".txt")] + [TestCase("/path/with.dots/file.", "")] + [TestCase("/path.with.dots/to/.hidden.ext", ".ext")] + [TestCase("file.with.multiple.dots.ext", ".ext")] + [TestCase("/path/to/file.with.multiple.dots.ext", ".ext")] + [TestCase("/.hidden", ".hidden")] + public void GetExtension(string path, string expected) + { + foreach (var p in GetPathVariations(path)) + { + Assert.That(VirtualPath.GetExtension(p), Is.EqualTo(expected)); + Assert.That(VirtualPath.GetExtension(p.AsSpan()).ToString(), Is.EqualTo(expected)); + } + } + + [TestCase("", "")] + [TestCase(".", ".")] + [TestCase(".hidden", ".hidden")] + [TestCase("file.txt", "file.txt")] + [TestCase("/path/to/file.txt", "file.txt")] + [TestCase("/path/to/.hidden", ".hidden")] + [TestCase("/path/to/file", "file")] + [TestCase("/path/with.dots/file.txt", "file.txt")] + [TestCase("/path/with.dots/file.", "file.")] + [TestCase("/path/to/file.with.multiple.dots.ext", "file.with.multiple.dots.ext")] + [TestCase("/path/to/.hidden.ext", ".hidden.ext")] + [TestCase("/.hidden", ".hidden")] + [TestCase("/path/to/", "")] + [TestCase("/path/to/directory/", "")] + public void GetFileName(string path, string expected) + { + foreach (var p in GetPathVariations(path)) + { + Assert.That(VirtualPath.GetFileName(p), Is.EqualTo(expected)); + Assert.That(VirtualPath.GetFileName(p.AsSpan()).ToString(), Is.EqualTo(expected)); + } + } - [TestCase("", ExpectedResult = "")] - [TestCase(".", ExpectedResult = ".")] - [TestCase(".hidden", ExpectedResult = ".hidden")] - [TestCase("file.txt", ExpectedResult = "file.txt")] - [TestCase("/path/to/file.txt", ExpectedResult = "file.txt")] - [TestCase("/path/to/.hidden", ExpectedResult = ".hidden")] - [TestCase("/path/to/file", ExpectedResult = "file")] - [TestCase("/path/with.dots/file.txt", ExpectedResult = "file.txt")] - [TestCase("/path/with.dots/file.", ExpectedResult = "file.")] - [TestCase("/path/to/file.with.multiple.dots.ext", ExpectedResult = "file.with.multiple.dots.ext")] - [TestCase("/path/to/.hidden.ext", ExpectedResult = ".hidden.ext")] - [TestCase("/.hidden", ExpectedResult = ".hidden")] - [TestCase("/path/to/", ExpectedResult = "")] - [TestCase("/path/to/directory/", ExpectedResult = "")] - public string GetFileName(string path) => - VirtualPath.GetFileName(path); + [TestCase("", "")] + [TestCase("/", "")] + [TestCase("/dir", "/")] + [TestCase("/dir/file", "/dir")] + [TestCase("/dir/dir/", "/dir/dir")] + [TestCase("dir/dir", "dir")] + [TestCase("dir/dir/", "dir/dir")] - [TestCase("", ExpectedResult = "")] - [TestCase("/", ExpectedResult = "")] - [TestCase("/dir", ExpectedResult = "/")] - [TestCase("/dir/file", ExpectedResult = "/dir")] - [TestCase("/dir/dir/", ExpectedResult = "/dir/dir")] - [TestCase("dir/dir", ExpectedResult = "dir")] - [TestCase("dir/dir/", ExpectedResult = "dir/dir")] + [TestCase("//", "")] + [TestCase("///", "")] + [TestCase("//dir", "/")] + [TestCase("///dir", "/")] + [TestCase("////dir", "/")] + [TestCase("/dir///dir", "/dir")] + [TestCase("/dir///dir///", "/dir///dir")] + [TestCase("//dir///dir///", "//dir///dir")] + [TestCase("dir///dir", "dir")] + public void GetDirectoryName(string path, string expected) + { + foreach (var p in GetPathVariations(path)) + { + if (p.Contains('\\') && expected != "/") + expected = expected.Replace("/", "\\"); - [TestCase("//", ExpectedResult = "")] - [TestCase("///", ExpectedResult = "")] - [TestCase("//dir", ExpectedResult = "/")] - [TestCase("///dir", ExpectedResult = "/")] - [TestCase("////dir", ExpectedResult = "/")] - [TestCase("/dir///dir", ExpectedResult = "/dir")] - [TestCase("/dir///dir///", ExpectedResult = "/dir///dir")] - [TestCase("//dir///dir///", ExpectedResult = "//dir///dir")] - [TestCase("dir///dir", ExpectedResult = "dir")] - public string GetDirectoryName(string path) => - VirtualPath.GetDirectoryName(path); + Assert.That(VirtualPath.GetDirectoryName(p), Is.EqualTo(expected)); + Assert.That(VirtualPath.GetDirectoryName(p.AsSpan()).ToString(), Is.EqualTo(expected)); + } + } [TestCase("/", ExpectedResult = true)] [TestCase("/a/b/c", ExpectedResult = true)] @@ -152,4 +171,7 @@ public bool IsNavigatesAboveRoot(string path) => [TestCase("/a/./../b/c/", ExpectedResult = "/a/./../b/c")] public string Normalize(string path) => VirtualPath.Normalize(path); + + private static string[] GetPathVariations(string path) => + [path, path.Replace('/', '\\')]; }