diff --git a/Directory.Build.props b/Directory.Build.props index 53695a6..ba95efb 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -25,7 +25,7 @@ all - 3.6.133 + 3.6.139 diff --git a/src/CommonUtilities.DownloadManager/test/CommonUtilities.DownloadManager.Test.csproj b/src/CommonUtilities.DownloadManager/test/CommonUtilities.DownloadManager.Test.csproj index 9ce1493..e884ab0 100644 --- a/src/CommonUtilities.DownloadManager/test/CommonUtilities.DownloadManager.Test.csproj +++ b/src/CommonUtilities.DownloadManager/test/CommonUtilities.DownloadManager.Test.csproj @@ -13,18 +13,18 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/CommonUtilities.FileSystem/src/Extensions/PathExtensions.NetFramework.cs b/src/CommonUtilities.FileSystem/src/Extensions/PathExtensions.NetFramework.cs new file mode 100644 index 0000000..2ae5051 --- /dev/null +++ b/src/CommonUtilities.FileSystem/src/Extensions/PathExtensions.NetFramework.cs @@ -0,0 +1,271 @@ +#if NET48_OR_GREATER || NETSTANDARD2_0 + +using System.IO.Abstractions; +using System; +using System.IO; + +namespace AnakinRaW.CommonUtilities.FileSystem; + +// Mostly copied from https://github.com/dotnet/runtime +public static partial class PathExtensions +{ + // \\?\, \\.\, \??\ + internal const int DevicePrefixLength = 4; + // \\ + internal const int UncPrefixLength = 2; + // \\?\UNC\, \\.\UNC\ + internal const int UncExtendedPrefixLength = 8; + + /// + /// Returns the file name and extension of a file path that is represented by a read-only character span. + /// + /// + /// A read-only span that contains the path from which to obtain the file name and extension. + /// The characters after the last directory separator character in . + /// + /// Under .NET Framework and .NET Standard 2.0 this method behaves like .NET Core for file names with ":", such as + /// "C:\file.txt:stream" --> "file.txt:stream" whereas in .NET Framework the result would be "stream". + /// + public static ReadOnlySpan GetFileName(this IPath _, ReadOnlySpan path) + { + if (IsUnixLikePlatform) + { + // While this case should not get reached, we add a safeguard and fallback to the .NET routine. + return _.GetFileName(path.ToString()).AsSpan(); + } + + var root = _.GetPathRoot(path).Length; + + // We don't want to cut off "C:\file.txt:stream" (i.e. should be "file.txt:stream") + // but we *do* want "C:Foo" => "Foo". This necessitates checking for the root. + + var i = _.DirectorySeparatorChar == _.AltDirectorySeparatorChar + ? path.LastIndexOf(_.DirectorySeparatorChar) + : path.LastIndexOfAny(_.DirectorySeparatorChar, _.AltDirectorySeparatorChar); + + return path.Slice(i < root ? root : i + 1); + } + + /// + /// Returns the characters between the last separator and last (.) in the path. + /// + public static ReadOnlySpan GetFileNameWithoutExtension(this IPath _, ReadOnlySpan path) + { + var fileName = _.GetFileName(path); + var lastPeriod = fileName.LastIndexOf('.'); + return lastPeriod < 0 ? + fileName : // No extension was found + fileName.Slice(0, lastPeriod); + } + + /// + /// Gets the root directory information from the path contained in the specified character span. + /// + /// + /// A read-only span of characters containing the path from which to obtain root directory information. + /// A read-only span of characters containing the root directory of . + public static ReadOnlySpan GetPathRoot(this IPath _, ReadOnlySpan path) + { + if (IsUnixLikePlatform) + { + // While this case should not get reached, we add a safeguard and fallback to the .NET routine. + return _.GetPathRoot(path.ToString()).AsSpan(); + } + + if (IsEffectivelyEmpty(path)) + return ReadOnlySpan.Empty; + + var pathRoot = GetRootLength(path); + return pathRoot <= 0 ? ReadOnlySpan.Empty : path.Slice(0, pathRoot); + } + + /// + /// Returns if the path specified is absolute. This method does no + /// validation of the path. + /// + /// + /// Handles paths that use the alternate directory separator. It is a frequent mistake to + /// assume that rooted paths (Path.IsPathRooted) are not relative. This isn't the case. + /// "C:a" is drive relative-meaning that it will be resolved against the current directory + /// for C: (rooted, but relative). "C:\a" is rooted and not relative (the current directory + /// will not be used to modify the path). + /// + /// + /// A file path. + /// if the path is fixed to a specific drive or UNC path; if the path is relative to the current drive or working directory. + public static bool IsPathFullyQualified(this IPath _, ReadOnlySpan path) + { + if (IsUnixLikePlatform) + { + // While this case should not get reached, we add a safeguard and fallback to the .NET routine. + return _.IsPathRooted(path.ToString()); + } + + if (path.Length < 2) + return false; + + if (IsAnyDirectorySeparator(path[0])) + return path[1] == '?' || IsAnyDirectorySeparator(path[1]); + + return path.Length >= 3 + && path[1] == VolumeSeparatorChar + && IsAnyDirectorySeparator(path[2]) + && IsValidDriveChar(path[0]); + } + + /// + /// Returns if the path specified is absolute. This method does no + /// validation of the path. + /// + /// + /// Handles paths that use the alternate directory separator. It is a frequent mistake to + /// assume that rooted paths (Path.IsPathRooted) are not relative. This isn't the case. + /// "C:a" is drive relative-meaning that it will be resolved against the current directory + /// for C: (rooted, but relative). "C:\a" is rooted and not relative (the current directory + /// will not be used to modify the path). + /// + /// + /// A file path. + /// if the path is fixed to a specific drive or UNC path; if the path is relative to the current drive or working directory. + /// is . + public static bool IsPathFullyQualified(this IPath _, string path) + { + if (path == null) + throw new ArgumentNullException(nameof(path)); + return IsPathFullyQualified(_, path.AsSpan()); + } + + /// + /// Returns a value that indicates whether a file path contains a root. + /// + /// + /// The path to test. + /// if contains a root; otherwise, . + public static bool IsPathRooted(this IPath _, ReadOnlySpan path) + { + var length = path.Length; + return (length >= 1 && IsAnyDirectorySeparator(path[0])) + || (length >= 2 && IsValidDriveChar(path[0]) && path[1] == VolumeSeparatorChar); + } + + internal static bool IsEffectivelyEmpty(ReadOnlySpan path) + { + if (path.IsEmpty) + return true; + + foreach (var c in path) + { + if (c != ' ') + return false; + } + return true; + } + + /// + /// Gets the length of the root of the path (drive, share, etc.). + /// + internal static int GetRootLength(ReadOnlySpan path) + { + var pathLength = path.Length; + var i = 0; + + var deviceSyntax = IsDevice(path); + var deviceUnc = deviceSyntax && IsDeviceUNC(path); + + if ((!deviceSyntax || deviceUnc) && pathLength > 0 && IsAnyDirectorySeparator(path[0])) + { + // UNC or simple rooted path (e.g. "\foo", NOT "\\?\C:\foo") + if (deviceUnc || (pathLength > 1 && IsAnyDirectorySeparator(path[1]))) + { + // UNC (\\?\UNC\ or \\), scan past server\share + + // Start past the prefix ("\\" or "\\?\UNC\") + i = deviceUnc ? UncExtendedPrefixLength : UncPrefixLength; + + // Skip two separators at most + var n = 2; + while (i < pathLength && (!IsAnyDirectorySeparator(path[i]) || --n > 0)) + i++; + } + else + { + // Current drive rooted (e.g. "\foo") + i = 1; + } + } + else if (deviceSyntax) + { + // Device path (e.g. "\\?\.", "\\.\") + // Skip any characters following the prefix that aren't a separator + i = DevicePrefixLength; + while (i < pathLength && !IsAnyDirectorySeparator(path[i])) + i++; + + // If there is another separator take it, as long as we have had at least one + // non-separator after the prefix (e.g. don't take "\\?\\", but take "\\?\a\") + if (i < pathLength && i > DevicePrefixLength && IsAnyDirectorySeparator(path[i])) + i++; + } + else if (pathLength >= 2 + && path[1] == VolumeSeparatorChar + && IsValidDriveChar(path[0])) + { + // Valid drive specified path ("C:", "D:", etc.) + i = 2; + + // If the colon is followed by a directory separator, move past it (e.g "C:\") + if (pathLength > 2 && IsAnyDirectorySeparator(path[2])) + i++; + } + return i; + } + + /// + /// Returns true if the path uses any of the DOS device path syntaxes. ("\\.\", "\\?\", or "\??\") + /// + private static bool IsDevice(ReadOnlySpan path) + { + // If the path begins with any two separators is will be recognized and normalized and prepped with + // "\??\" for internal usage correctly. "\??\" is recognized and handled, "/??/" is not. + return IsExtended(path) + || + ( + path.Length >= DevicePrefixLength + && IsAnyDirectorySeparator(path[0]) + && IsAnyDirectorySeparator(path[1]) + && (path[2] == '.' || path[2] == '?') + && IsAnyDirectorySeparator(path[3]) + ); + } + + /// + /// Returns true if the path is a device UNC (\\?\UNC\, \\.\UNC\) + /// + private static bool IsDeviceUNC(ReadOnlySpan path) + { + return path.Length >= UncExtendedPrefixLength + && IsDevice(path) + && IsAnyDirectorySeparator(path[7]) + && path[4] == 'U' + && path[5] == 'N' + && path[6] == 'C'; + } + + /// + /// Returns true if the path uses the canonical form of extended syntax ("\\?\" or "\??\"). If the + /// path matches exactly (cannot use alternate directory separators) Windows will skip normalization + /// and path length checks. + /// + private static bool IsExtended(ReadOnlySpan path) + { + // While paths like "//?/C:/" will work, they're treated the same as "\\.\" paths. + // Skipping of normalization will *only* occur if back slashes ('\') are used. + return path.Length >= DevicePrefixLength + && path[0] == '\\' + && (path[1] == '\\' || path[1] == '?') + && path[2] == '?' + && path[3] == '\\'; + } +} + +#endif \ No newline at end of file diff --git a/src/CommonUtilities.FileSystem/src/Extensions/PathExtensions.cs b/src/CommonUtilities.FileSystem/src/Extensions/PathExtensions.cs index db1757e..e058430 100644 --- a/src/CommonUtilities.FileSystem/src/Extensions/PathExtensions.cs +++ b/src/CommonUtilities.FileSystem/src/Extensions/PathExtensions.cs @@ -6,16 +6,18 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using AnakinRaW.CommonUtilities.FileSystem.Normalization; +using AnakinRaW.CommonUtilities.FileSystem.Utilities; namespace AnakinRaW.CommonUtilities.FileSystem; +// ReSharper disable once PartialTypeWithSinglePart // Based on https://github.com/dotnet/roslyn/blob/main/src/Compilers/Core/Portable/FileSystem/PathUtilities.cs // and https://github.com/NuGet/NuGet.Client/blob/dev/src/NuGet.Core/NuGet.Common/PathUtil/PathUtility.cs // and https://github.com/dotnet/runtime /// /// Provides extension methods for the class. /// -public static class PathExtensions +public static partial class PathExtensions { private const string ThisDirectory = "."; private const string ParentRelativeDirectory = ".."; @@ -47,7 +49,7 @@ public static bool HasTrailingDirectorySeparator(this IPath _, string path) } /// - /// Determines whether the specified path ends with a directory path separator + /// Determines whether the specified path ends with a directory path separator. /// /// /// The path to check. @@ -71,17 +73,17 @@ private static bool IsAnyDirectorySeparator(char c) } /// - /// Checks whether a path is rooted, but not absolute, to a drive e.g, "C:" or "C:my/path" + /// Checks whether a character span is rooted, but not absolute, to a drive e.g, "C:" or "C:my/path" /// /// /// Only works on Windows. For Linux systems, this method will always return . /// /// The file system's path instance. - /// The path to check. + /// The character span to check. /// If is drive relative the drive's letter will be stored into this variable. /// if is not drive relative. /// Return if is relative, but not absolute to a drive; otherwise, . - public static bool IsDriveRelative(this IPath fsPath, string? path, [NotNullWhen(true)] out char? driveLetter) + public static bool IsDriveRelative(this IPath fsPath, ReadOnlySpan path, [NotNullWhen(true)] out char? driveLetter) { driveLetter = null; @@ -110,14 +112,28 @@ public static bool IsDriveRelative(this IPath fsPath, string? path, [NotNullWhen return false; } - internal static string TrimTrailingSeparators(ReadOnlySpan path, DirectorySeparatorKind separatorKind = DirectorySeparatorKind.System) + /// + /// Checks whether a path is rooted, but not absolute, to a drive e.g, "C:" or "C:my/path" + /// + /// + /// Only works on Windows. For Linux systems, this method will always return . + /// + /// The file system's path instance. + /// The path to check. + /// If is drive relative the drive's letter will be stored into this variable. + /// if is not drive relative. + /// Return if is relative, but not absolute to a drive; otherwise, . + public static bool IsDriveRelative(this IPath fsPath, string? path, [NotNullWhen(true)] out char? driveLetter) + { + return fsPath.IsDriveRelative(path.AsSpan(), out driveLetter); + } + + internal static ReadOnlySpan TrimTrailingSeparators(ReadOnlySpan path, DirectorySeparatorKind separatorKind = DirectorySeparatorKind.System) { var lastSeparator = path.Length; while (lastSeparator > 0 && IsAnyDirectorySeparator(path[lastSeparator - 1], separatorKind)) lastSeparator -= 1; - if (lastSeparator != path.Length) - path = path.Slice(0, lastSeparator); - return path.ToString(); + return lastSeparator != path.Length ? path.Slice(0, lastSeparator) : path; } @@ -149,26 +165,25 @@ private static bool IsAnyDirectorySeparator(char c, DirectorySeparatorKind separ } } - internal static string EnsureTrailingSeparatorInternal(string input) + + internal static void EnsureTrailingSeparatorInternal(ref ValueStringBuilder stringBuilder) { - if (input.Length == 0 || IsAnyDirectorySeparator(input[input.Length - 1])) - return input; + if (stringBuilder.Length == 0 || IsAnyDirectorySeparator(stringBuilder[stringBuilder.Length - 1])) + return; // Use the existing slashes in the path, if they're consistent - var hasPrimarySlash = input.IndexOf(DirectorySeparatorChar) >= 0; - var hasAlternateSlash = input.IndexOf(AltDirectorySeparatorChar) >= 0; - - if (hasPrimarySlash && !hasAlternateSlash) - return input + DirectorySeparatorChar; + var hasPrimarySlash = stringBuilder.RawChars.IndexOf(DirectorySeparatorChar) >= 0; + var hasAlternateSlash = stringBuilder.RawChars.IndexOf(AltDirectorySeparatorChar) >= 0; if (!hasPrimarySlash && hasAlternateSlash) - return input + AltDirectorySeparatorChar; + { + stringBuilder.Append(AltDirectorySeparatorChar); + return; + } - // If there are no slashes, or they are inconsistent, use the current platform's primary slash. - return input + DirectorySeparatorChar; + stringBuilder.Append(DirectorySeparatorChar); } - /// /// Returns a relative path from a path to given root or if is not rooted. /// In contrast to .NET's Path.GetRelativePath(string, string), if is not rooted this method returns . @@ -187,10 +202,10 @@ public static string GetRelativePathEx(this IPath fsPath, string root, string pa // Root should always be absolute root = fsPath.GetFullPath(root); - root = TrimTrailingSeparators(root.AsSpan()); + root = TrimTrailingSeparators(root.AsSpan()).ToString(); path = fsPath.GetFullPath(path); - var trimmedPath = TrimTrailingSeparators(path.AsSpan()); + var trimmedPath = TrimTrailingSeparators(path.AsSpan()).ToString(); var rootParts = GetPathParts(root); var pathParts = GetPathParts(trimmedPath); @@ -213,35 +228,44 @@ public static string GetRelativePathEx(this IPath fsPath, string root, string pa if (index == 0) return path; - var relativePath = string.Empty; + var sb = new ValueStringBuilder(stackalloc char[260]); // add backup notation for remaining base path levels beyond the index var remainingParts = rootParts.Length - index; if (remainingParts > 0) { for (var i = 0; i < remainingParts; i++) - relativePath = relativePath + ParentRelativeDirectory + DirectorySeparatorStr; + { + sb.Append(ParentRelativeDirectory); + sb.Append(DirectorySeparatorChar); + } } if (index < pathParts.Length) { // add the rest of the full path parts for (var i = index; i < pathParts.Length; i++) - relativePath = CombinePathsUnchecked(relativePath, pathParts[i]); + CombinePathsUnchecked(ref sb, pathParts[i]); - if (endsWithTrailingPathSeparator) - relativePath = EnsureTrailingSeparatorInternal(relativePath); + if (endsWithTrailingPathSeparator) + EnsureTrailingSeparatorInternal(ref sb); } else { - if (!string.IsNullOrEmpty(relativePath)) - relativePath = TrimTrailingSeparators(relativePath.AsSpan()); + if (sb.Length > 0) + { + if (HasTrailingDirectorySeparator(sb.AsSpan())) + sb.Length -= 1; + } } - if (relativePath == string.Empty) + if (sb.Length == 0) return ThisDirectory; - return relativePath; + + var result = sb.ToString(); + sb.Dispose(); + return result; } private static string[] GetPathParts(string path) @@ -277,14 +301,17 @@ internal static bool PathsEqual(string path1, string path2) return PathsEqual(path1, path2, Math.Max(path1.Length, path2.Length)); } - private static string CombinePathsUnchecked(string root, string relativePath) + private static void CombinePathsUnchecked(ref ValueStringBuilder sb, string relativePath) { - if (root == string.Empty) - return relativePath; - var c = root[root.Length - 1]; - if (!IsAnyDirectorySeparator(c) && c != VolumeSeparatorChar) - return root + DirectorySeparatorStr + relativePath; - return root + relativePath; + if (sb.Length == 0) + { + sb.Append(relativePath); + return; + } + var c = sb[sb.Length - 1]; + if (!IsAnyDirectorySeparator(c) && c != VolumeSeparatorChar) + sb.Append(DirectorySeparatorChar); + sb.Append(relativePath); } /// @@ -332,67 +359,6 @@ public static bool IsChildOf(this IPath _, string basePath, string candidate) && (IsAnyDirectorySeparator(fullBase[fullBase.Length - 1]) || IsAnyDirectorySeparator(fullCandidate[fullBase.Length])); } -#if !NET && !NETsta - - /// - /// Returns if the path specified is absolute. This method does no - /// validation of the path. - /// - /// - /// Handles paths that use the alternate directory separator. It is a frequent mistake to - /// assume that rooted paths (Path.IsPathRooted) are not relative. This isn't the case. - /// "C:a" is drive relative- meaning that it will be resolved against the current directory - /// for C: (rooted, but relative). "C:\a" is rooted and not relative (the current directory - /// will not be used to modify the path). - /// - /// - /// A file path. - /// if the path is fixed to a specific drive or UNC path; if the path is relative to the current drive or working directory. - public static bool IsPathFullyQualified(this IPath _, ReadOnlySpan path) - { - if (IsUnixLikePlatform) - { - // While this case should not get reached, we add a safeguard and fallback to the .NET routine. - return _.IsPathRooted(path.ToString()); - } - - if (path.Length < 2) - return false; - - if (IsAnyDirectorySeparator(path[0])) - return path[1] == '?' || IsAnyDirectorySeparator(path[1]); - - return path.Length >= 3 - && path[1] == VolumeSeparatorChar - && IsAnyDirectorySeparator(path[2]) - && IsValidDriveChar(path[0]); - } - - /// - /// Returns if the path specified is absolute. This method does no - /// validation of the path. - /// - /// - /// Handles paths that use the alternate directory separator. It is a frequent mistake to - /// assume that rooted paths (Path.IsPathRooted) are not relative. This isn't the case. - /// "C:a" is drive relative-meaning that it will be resolved against the current directory - /// for C: (rooted, but relative). "C:\a" is rooted and not relative (the current directory - /// will not be used to modify the path). - /// - /// - /// A file path. - /// if the path is fixed to a specific drive or UNC path; if the path is relative to the current drive or working directory. - /// is . - public static bool IsPathFullyQualified(this IPath _, string path) - { - if (path == null) - throw new ArgumentNullException(nameof(path)); - return IsPathFullyQualified(_, path.AsSpan()); - } - -#endif - - internal static bool IsValidDriveChar(char value) { return (uint)((value | 0x20) - 'a') <= 'z' - 'a'; diff --git a/src/CommonUtilities.FileSystem/src/Normalization/PathNormalizer.cs b/src/CommonUtilities.FileSystem/src/Normalization/PathNormalizer.cs index 1096c3b..5b9cdd5 100644 --- a/src/CommonUtilities.FileSystem/src/Normalization/PathNormalizer.cs +++ b/src/CommonUtilities.FileSystem/src/Normalization/PathNormalizer.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Runtime.CompilerServices; +using AnakinRaW.CommonUtilities.FileSystem.Utilities; namespace AnakinRaW.CommonUtilities.FileSystem.Normalization; @@ -20,77 +21,121 @@ public static class PathNormalizer /// The normalization failed due to an internal error. public static string Normalize(string path, PathNormalizeOptions options) { - if (string.IsNullOrEmpty(path)) - { - if (path is null) - throw new ArgumentNullException(path); - throw new ArgumentException("The value cannot be an empty string.", path); - } - - path = options.TrailingDirectorySeparatorBehavior switch - { - TrailingDirectorySeparatorBehavior.Trim => PathExtensions.TrimTrailingSeparators(path.AsSpan(), options.UnifySeparatorKind), - TrailingDirectorySeparatorBehavior.Ensure => PathExtensions.EnsureTrailingSeparatorInternal(path), - _ => path - }; + var stringBuilder = new ValueStringBuilder(stackalloc char[260]); + Normalize(path.AsSpan(), ref stringBuilder, options); + var result = stringBuilder.ToString(); + stringBuilder.Dispose(); + return result; + } - // Only do for DirectorySeparatorKind.System, cause for other kinds it will be done at the very end anyway. - if (options.UnifyDirectorySeparators && options.UnifySeparatorKind == DirectorySeparatorKind.System) - path = GetPathWithDirectorySeparator(path, DirectorySeparatorKind.System); + /// + /// Normalizes a given character span that represents a file path to a destination according to given normalization rules. + /// + /// The path to normalize + /// The destination span which contains the normalized path. + /// The options how to normalize. + /// The number of characters written into the destination span. + /// This method populates even if is too small. + /// If the is too small. + public static int Normalize(ReadOnlySpan path, Span destination, PathNormalizeOptions options) + { + var stringBuilder = new ValueStringBuilder(destination); + Normalize(path, ref stringBuilder, options); + if (!stringBuilder.TryCopyTo(destination, out var charsWritten)) + throw new ArgumentException("Cannot copy to destination span", nameof(destination)); + return charsWritten; + } - path = NormalizeCasing(path, options.UnifyCase); + internal static void Normalize(ReadOnlySpan path, ref ValueStringBuilder sb, PathNormalizeOptions options) + { + if (path == null) + throw new ArgumentNullException(nameof(path)); + if (path.Length == 0) + throw new ArgumentException(nameof(path)); + + switch (options.TrailingDirectorySeparatorBehavior) + { + case TrailingDirectorySeparatorBehavior.Trim: + var trimmedPath = PathExtensions.TrimTrailingSeparators(path, options.UnifySeparatorKind); + sb.Append(trimmedPath); + break; + case TrailingDirectorySeparatorBehavior.Ensure: + sb.Append(path); + PathExtensions.EnsureTrailingSeparatorInternal(ref sb); + break; + case TrailingDirectorySeparatorBehavior.None: + sb.Append(path); + break; + default: + throw new IndexOutOfRangeException(); + } - // NB: As previous steps may add new separators (such as GetFullPath) we need to re-apply slash normalization - // if the desired DirectorySeparatorKind is not DirectorySeparatorKind.System - if (options.UnifyDirectorySeparators && options.UnifySeparatorKind != DirectorySeparatorKind.System) - path = GetPathWithDirectorySeparator(path, options.UnifySeparatorKind); + // As the trailing directory normalization might add new separators, this step must come after. + if (options.UnifyDirectorySeparators) + GetPathWithDirectorySeparator(sb.RawChars, options.UnifySeparatorKind); - return path; + NormalizeCasing(sb.RawChars, options.UnifyCase); } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static string NormalizeCasing(string path, UnifyCasingKind casing) + private static bool RequiresCasing(UnifyCasingKind casingOption) { - if (casing == UnifyCasingKind.None) - return path; + if (casingOption == UnifyCasingKind.None) + return false; + if (!PathExtensions.IsFileSystemCaseInsensitive.Value && !casingOption.IsForce()) + return false; + return true; + } - if (!PathExtensions.IsFileSystemCaseInsensitive.Value && !casing.IsForce()) - return path; + private static unsafe void NormalizeCasing(Span path, UnifyCasingKind casing) + { + if (!RequiresCasing(casing)) + return; - if (casing is UnifyCasingKind.LowerCaseForce or UnifyCasingKind.LowerCase) - return path.ToLowerInvariant(); + delegate* transformation; + if (casing is UnifyCasingKind.LowerCase or UnifyCasingKind.LowerCaseForce) + transformation = &ToLower; + else + transformation = &ToUpper; - if (casing is UnifyCasingKind.UpperCase or UnifyCasingKind.UpperCaseForce) - return path.ToUpperInvariant(); + for (var i = 0; i < path.Length; i++) + { + var c = path[i]; + path[i] = transformation(c); + } + } - throw new ArgumentOutOfRangeException(nameof(casing)); + private static char ToLower(char c) + { + return char.ToLowerInvariant(c); } - private static string GetPathWithDirectorySeparator(string path, DirectorySeparatorKind separatorKind) + private static char ToUpper(char c) { - switch (separatorKind) - { - case DirectorySeparatorKind.System: - return PathExtensions.IsUnixLikePlatform ? GetPathWithForwardSlashes(path) : GetPathWithBackSlashes(path); - case DirectorySeparatorKind.Windows: - return GetPathWithBackSlashes(path); - case DirectorySeparatorKind.Linux: - return GetPathWithForwardSlashes(path); - default: - throw new ArgumentOutOfRangeException(nameof(separatorKind)); - } + return char.ToUpperInvariant(c); } - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static string GetPathWithBackSlashes(string path) + private static void GetPathWithDirectorySeparator(Span pathSpan, DirectorySeparatorKind separatorKind) { - return path.Replace('/', '\\'); + var separatorChar = GetSeparatorChar(separatorKind); + + for (var i = 0; i < pathSpan.Length; i++) + { + var c = pathSpan[i]; + if (c is '\\' or '/') + pathSpan[i] = separatorChar; + } } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static string GetPathWithForwardSlashes(string path) + private static char GetSeparatorChar(DirectorySeparatorKind separatorKind) { - return path.Replace('\\', '/'); + return separatorKind switch + { + DirectorySeparatorKind.System => PathExtensions.IsUnixLikePlatform ? '/' : '\\', + DirectorySeparatorKind.Windows => '\\', + DirectorySeparatorKind.Linux => '/', + _ => throw new ArgumentOutOfRangeException(nameof(separatorKind)) + }; } private static bool IsForce(this UnifyCasingKind casing) diff --git a/src/CommonUtilities.FileSystem/src/Properties/AssemblyInfo.cs b/src/CommonUtilities.FileSystem/src/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..5c2a33d --- /dev/null +++ b/src/CommonUtilities.FileSystem/src/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("AnakinRaW.Commonutilities.FileSystem.Test")] \ No newline at end of file diff --git a/src/CommonUtilities.FileSystem/src/Utilities/ValueStringBuilder.cs b/src/CommonUtilities.FileSystem/src/Utilities/ValueStringBuilder.cs new file mode 100644 index 0000000..1e4e1ce --- /dev/null +++ b/src/CommonUtilities.FileSystem/src/Utilities/ValueStringBuilder.cs @@ -0,0 +1,305 @@ +using System; +using System.Buffers; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace AnakinRaW.CommonUtilities.FileSystem.Utilities; + +// Copied from https://github.com/dotnet/runtime +internal ref struct ValueStringBuilder +{ + private char[]? _arrayToReturnToPool; + private Span _chars; + private int _pos; + + public ValueStringBuilder(Span initialBuffer) + { + _arrayToReturnToPool = null; + _chars = initialBuffer; + _pos = 0; + } + + public ValueStringBuilder(int initialCapacity) + { + _arrayToReturnToPool = ArrayPool.Shared.Rent(initialCapacity); + _chars = _arrayToReturnToPool; + _pos = 0; + } + + public int Length + { + get => _pos; + set + { + Debug.Assert(value >= 0); + Debug.Assert(value <= _chars.Length); + _pos = value; + } + } + + public int Capacity => _chars.Length; + + public void EnsureCapacity(int capacity) + { + // This is not expected to be called this with negative capacity + Debug.Assert(capacity >= 0); + + // If the caller has a bug and calls this with negative capacity, make sure to call Grow to throw an exception. + if ((uint)capacity > (uint)_chars.Length) + Grow(capacity - _pos); + } + + /// + /// Get a pinnable reference to the builder. + /// Does not ensure there is a null char after + /// This overload is pattern matched in the C# 7.3+ compiler so you can omit + /// the explicit method call, and write eg "fixed (char* c = builder)" + /// + public ref char GetPinnableReference() + { + return ref MemoryMarshal.GetReference(_chars); + } + + /// + /// Get a pinnable reference to the builder. + /// + /// Ensures that the builder has a null char after + public ref char GetPinnableReference(bool terminate) + { + if (terminate) + { + EnsureCapacity(Length + 1); + _chars[Length] = '\0'; + } + return ref MemoryMarshal.GetReference(_chars); + } + + public ref char this[int index] + { + get + { + Debug.Assert(index < _pos); + return ref _chars[index]; + } + } + + public override string ToString() + { + var s = _chars.Slice(0, _pos).ToString(); + Dispose(); + return s; + } + + /// Returns the underlying storage of the builder. + public Span RawChars => _chars; + + /// + /// Returns a span around the contents of the builder. + /// + /// Ensures that the builder has a null char after + public ReadOnlySpan AsSpan(bool terminate) + { + if (terminate) + { + EnsureCapacity(Length + 1); + _chars[Length] = '\0'; + } + return _chars.Slice(0, _pos); + } + + public ReadOnlySpan AsSpan() => _chars.Slice(0, _pos); + public ReadOnlySpan AsSpan(int start) => _chars.Slice(start, _pos - start); + public ReadOnlySpan AsSpan(int start, int length) => _chars.Slice(start, length); + + public bool TryCopyTo(Span destination, out int charsWritten) + { + if (_chars.Slice(0, _pos).TryCopyTo(destination)) + { + charsWritten = _pos; + Dispose(); + return true; + } + else + { + charsWritten = 0; + Dispose(); + return false; + } + } + + public void Insert(int index, char value, int count) + { + if (_pos > _chars.Length - count) + { + Grow(count); + } + + var remaining = _pos - index; + _chars.Slice(index, remaining).CopyTo(_chars.Slice(index + count)); + _chars.Slice(index, count).Fill(value); + _pos += count; + } + + public void Insert(int index, string? s) + { + if (s == null) + return; + + var count = s.Length; + + if (_pos > _chars.Length - count) + Grow(count); + + var remaining = _pos - index; + _chars.Slice(index, remaining).CopyTo(_chars.Slice(index + count)); + s +#if !NET + .AsSpan() +#endif + .CopyTo(_chars.Slice(index)); + _pos += count; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(char c) + { + var pos = _pos; + var chars = _chars; + if ((uint)pos < (uint)chars.Length) + { + chars[pos] = c; + _pos = pos + 1; + } + else + GrowAndAppend(c); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(string? s) + { + if (s == null) + return; + + var pos = _pos; + if (s.Length == 1 && (uint)pos < (uint)_chars.Length) // very common case, e.g. appending strings from NumberFormatInfo like separators, percent symbols, etc. + { + _chars[pos] = s[0]; + _pos = pos + 1; + } + else + AppendSlow(s); + } + + private void AppendSlow(string s) + { + var pos = _pos; + if (pos > _chars.Length - s.Length) + Grow(s.Length); + + s +#if !NET + .AsSpan() +#endif + .CopyTo(_chars.Slice(pos)); + _pos += s.Length; + } + + public void Append(char c, int count) + { + if (_pos > _chars.Length - count) + Grow(count); + + var dst = _chars.Slice(_pos, count); + for (var i = 0; i < dst.Length; i++) + dst[i] = c; + _pos += count; + } + + public unsafe void Append(char* value, int length) + { + var pos = _pos; + if (pos > _chars.Length - length) + Grow(length); + + var dst = _chars.Slice(_pos, length); + for (var i = 0; i < dst.Length; i++) + dst[i] = *value++; + _pos += length; + } + + public void Append(scoped ReadOnlySpan value) + { + var pos = _pos; + if (pos > _chars.Length - value.Length) + Grow(value.Length); + + value.CopyTo(_chars.Slice(_pos)); + _pos += value.Length; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Span AppendSpan(int length) + { + var origPos = _pos; + if (origPos > _chars.Length - length) + Grow(length); + + _pos = origPos + length; + return _chars.Slice(origPos, length); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void GrowAndAppend(char c) + { + Grow(1); + Append(c); + } + + /// + /// Resize the internal buffer either by doubling current buffer size or + /// by adding to + /// whichever is greater. + /// + /// + /// Number of chars requested beyond current position. + /// + [MethodImpl(MethodImplOptions.NoInlining)] + private void Grow(int additionalCapacityBeyondPos) + { + Debug.Assert(additionalCapacityBeyondPos > 0); + Debug.Assert(_pos > _chars.Length - additionalCapacityBeyondPos, "Grow called incorrectly, no resize is needed."); + + const uint ArrayMaxLength = 0x7FFFFFC7; // same as Array.MaxLength + + // Increase to at least the required size (_pos + additionalCapacityBeyondPos), but try + // to double the size if possible, bounding the doubling to not go beyond the max array length. + var newCapacity = (int)Math.Max( + (uint)(_pos + additionalCapacityBeyondPos), + Math.Min((uint)_chars.Length * 2, ArrayMaxLength)); + + // Make sure to let Rent throw an exception if the caller has a bug and the desired capacity is negative. + // This could also go negative if the actual required length wraps around. + var poolArray = ArrayPool.Shared.Rent(newCapacity); + + _chars.Slice(0, _pos).CopyTo(poolArray); + + var toReturn = _arrayToReturnToPool; + _chars = _arrayToReturnToPool = poolArray; + if (toReturn != null) + { + ArrayPool.Shared.Return(toReturn); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Dispose() + { + var toReturn = _arrayToReturnToPool; + this = default; // for safety, to avoid using pooled array if this instance is erroneously appended to again + if (toReturn != null) + { + ArrayPool.Shared.Return(toReturn); + } + } +} \ No newline at end of file diff --git a/src/CommonUtilities.FileSystem/src/Validation/WindowsFileNameValidator.cs b/src/CommonUtilities.FileSystem/src/Validation/WindowsFileNameValidator.cs index 4997034..89307d9 100644 --- a/src/CommonUtilities.FileSystem/src/Validation/WindowsFileNameValidator.cs +++ b/src/CommonUtilities.FileSystem/src/Validation/WindowsFileNameValidator.cs @@ -27,8 +27,13 @@ public class WindowsFileNameValidator : FileNameValidator //(char)31, ]; - /// - public override FileNameValidationResult IsValidFileName(ReadOnlySpan fileName) + /// + /// Checks whether a string represent a valid file name + /// + /// The string to validate. + /// Determines whether the check shall include Windows reserved file names (e.g, AUX, LPT1, etc.). + /// + public FileNameValidationResult IsValidFileName(ReadOnlySpan fileName, bool checkWindowsReservedNames) { if (fileName.Length == 0) return FileNameValidationResult.NullOrEmpty; @@ -41,17 +46,27 @@ public override FileNameValidationResult IsValidFileName(ReadOnlySpan file if (ContainsInvalidChars(fileName)) return FileNameValidationResult.InvalidCharacter; + if (checkWindowsReservedNames) + { #if NET7_0_OR_GREATER - if (RegexInvalidName.IsMatch(fileName)) - return FileNameValidationResult.SystemReserved; + if (RegexInvalidName.IsMatch(fileName)) + return FileNameValidationResult.SystemReserved; #else - if (RegexInvalidName.IsMatch(fileName.ToString())) - return FileNameValidationResult.SystemReserved; + if (RegexInvalidName.IsMatch(fileName.ToString())) + return FileNameValidationResult.SystemReserved; #endif + } + return FileNameValidationResult.Valid; } + /// + public override FileNameValidationResult IsValidFileName(ReadOnlySpan fileName) + { + return IsValidFileName(fileName, true); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool ContainsInvalidChars(ReadOnlySpan value) { diff --git a/src/CommonUtilities.FileSystem/test/CommonUtilities.FileSystem.Test.csproj b/src/CommonUtilities.FileSystem/test/CommonUtilities.FileSystem.Test.csproj index cce7be3..55a109a 100644 --- a/src/CommonUtilities.FileSystem/test/CommonUtilities.FileSystem.Test.csproj +++ b/src/CommonUtilities.FileSystem/test/CommonUtilities.FileSystem.Test.csproj @@ -11,10 +11,11 @@ AnakinRaW.CommonUtilities.FileSystem.Test AnakinRaW.CommonUtilities.FileSystem.Test + true - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -22,15 +23,15 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/CommonUtilities.FileSystem/test/PathAssert.cs b/src/CommonUtilities.FileSystem/test/PathAssert.cs new file mode 100644 index 0000000..b86642c --- /dev/null +++ b/src/CommonUtilities.FileSystem/test/PathAssert.cs @@ -0,0 +1,18 @@ +using System; + +namespace AnakinRaW.CommonUtilities.FileSystem.Test; + +internal static class PathAssert +{ + public static void Equal(ReadOnlySpan expected, ReadOnlySpan actual) + { + if (!actual.SequenceEqual(expected)) + throw Xunit.Sdk.EqualException.ForMismatchedValues(expected.ToString(), actual.ToString()); + } + + public static void Empty(ReadOnlySpan actual) + { + if (actual.Length > 0) + throw Xunit.Sdk.NotEmptyException.ForNonEmptyCollection(); + } +} \ No newline at end of file diff --git a/src/CommonUtilities.FileSystem/test/PathExtensionsTest.GetFileName.cs b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.GetFileName.cs new file mode 100644 index 0000000..c5cf540 --- /dev/null +++ b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.GetFileName.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using AnakinRaW.CommonUtilities.Testing; +using Xunit; + +namespace AnakinRaW.CommonUtilities.FileSystem.Test; + +public class GetFileNameTest +{ + private readonly IFileSystem _fileSystem = new System.IO.Abstractions.FileSystem(); + + public static TheoryData TestData_GetFileName => new() + { + { ".", "." }, + { "..", ".." }, + { "file", "file" }, + { "file.", "file." }, + { "file.exe", "file.exe" }, + { " . ", " . " }, + { " .. ", " .. " }, + { "fi le", "fi le" }, + { Path.Combine("baz", "file.exe"), "file.exe" }, + { Path.Combine("baz", "file.exe") + Path.AltDirectorySeparatorChar, "" }, + { Path.Combine("bar", "baz", "file.exe"), "file.exe" }, + { Path.Combine("bar", "baz", "file.exe") + Path.DirectorySeparatorChar, "" } + }; + + + [Theory, MemberData(nameof(TestData_GetFileName))] + public void GetFileName_Span(string path, string expected) + { + PathAssert.Equal(expected.AsSpan(), _fileSystem.Path.GetFileName(path.AsSpan())); + Assert.Equal(expected, _fileSystem.Path.GetFileName(path)); + } + + public static IEnumerable TestData_GetFileName_Volume() + { + yield return [":", ":"]; + yield return [".:", ".:"]; + yield return [".:.", ".:."]; // Not a valid drive letter + yield return ["file:", "file:"]; + yield return [":file", ":file"]; + yield return ["file:exe", "file:exe"]; + yield return [Path.Combine("baz", "file:exe"), "file:exe"]; + yield return [Path.Combine("bar", "baz", "file:exe"), "file:exe"]; + } + + [Theory, MemberData(nameof(TestData_GetFileName_Volume))] + public void GetFileName_Volume(string path, string expected) + { + // We used to break on ':' on Windows. This is a valid file name character for alternate data streams. + // Additionally, the character can show up on unix volumes mounted to Windows. +#if !NETFRAMEWORK + Assert.Equal(expected, Path.GetFileName(path)); + Assert.Equal(expected, _fileSystem.Path.GetFileName(path)); +#endif + + PathAssert.Equal(expected.AsSpan(), _fileSystem.Path.GetFileName(path.AsSpan())); + } + + + [PlatformSpecificTheory(TestPlatformIdentifier.Windows)] + [InlineData("B:", "")] + [InlineData("A:.", ".")] + public static void GetFileName_Windows(string path, string expected) + { + // With a valid drive letter followed by a colon, we have a root, but only on Windows. + Assert.Equal(expected, Path.GetFileName(path)); + } + + public static TheoryData TestData_GetFileNameWithoutExtension => new() + { + { "", "" }, + { "file", "file" }, + { "file.exe", "file" }, + { Path.Combine("bar", "baz", "file.exe"), "file" }, + { Path.Combine("bar", "baz") + Path.DirectorySeparatorChar, "" } + }; + + [Theory, MemberData(nameof(TestData_GetFileNameWithoutExtension))] + public void GetFileNameWithoutExtension_Span(string path, string expected) + { + PathAssert.Equal(expected.AsSpan(), _fileSystem.Path.GetFileNameWithoutExtension(path.AsSpan())); + Assert.Equal(expected, _fileSystem.Path.GetFileNameWithoutExtension(path)); + } +} \ No newline at end of file diff --git a/src/CommonUtilities.FileSystem/test/PathExtensionsTest.GetPathRoot.cs b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.GetPathRoot.cs new file mode 100644 index 0000000..f498803 --- /dev/null +++ b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.GetPathRoot.cs @@ -0,0 +1,103 @@ +using System; +using System.IO; +using System.IO.Abstractions; +using AnakinRaW.CommonUtilities.Testing; +using Xunit; + +namespace AnakinRaW.CommonUtilities.FileSystem.Test; + +public class GetPathRootTest +{ + private readonly IFileSystem _fileSystem = new System.IO.Abstractions.FileSystem(); + + [Fact] + public void GetPathRoot_Empty_Span() + { + PathAssert.Empty(_fileSystem.Path.GetPathRoot(ReadOnlySpan.Empty)); + } + + [Fact] + public void GetPathRoot_Basic() + { + var cwd = Directory.GetCurrentDirectory(); + var substring = cwd.Substring(0, cwd.IndexOf(Path.DirectorySeparatorChar) + 1); + + Assert.Equal(substring, _fileSystem.Path.GetPathRoot(cwd)); + PathAssert.Equal(substring.AsSpan(), _fileSystem.Path.GetPathRoot(cwd.AsSpan())); + + Assert.True(_fileSystem.Path.IsPathRooted(cwd)); + + Assert.Equal(string.Empty, _fileSystem.Path.GetPathRoot(@"file.exe")); + Assert.True(_fileSystem.Path.GetPathRoot(@"file.exe".AsSpan()).IsEmpty); + + Assert.False(_fileSystem.Path.IsPathRooted("file.exe")); + } + + [PlatformSpecificTheory(TestPlatformIdentifier.Linux)] + [InlineData(@"/../../.././tmp/..")] + [InlineData(@"/../../../")] + [InlineData(@"/../../../tmp/bar/..")] + [InlineData(@"/../.././././bar/../../../")] + [InlineData(@"/../../././tmp/..")] + [InlineData(@"/../../tmp/../../")] + [InlineData(@"/../../tmp/bar/..")] + [InlineData(@"/../tmp/../..")] + [InlineData(@"/././../../../../")] + [InlineData(@"/././../../../")] + [InlineData(@"/./././bar/../../../")] + [InlineData(@"/")] + [InlineData(@"/bar")] + [InlineData(@"/bar/././././../../..")] + [InlineData(@"/bar/tmp")] + [InlineData(@"/tmp/..")] + [InlineData(@"/tmp/../../../../../bar")] + [InlineData(@"/tmp/../../../bar")] + [InlineData(@"/tmp/../bar/../..")] + [InlineData(@"/tmp/bar")] + [InlineData(@"/tmp/bar/..")] + public void GePathRoot_Unix(string path) + { + var expected = @"/"; + Assert.Equal(expected, _fileSystem.Path.GetPathRoot(path)); + PathAssert.Equal(expected.AsSpan(), _fileSystem.Path.GetPathRoot(path.AsSpan())); + } + + public static TheoryData TestData_GetPathRoot_Windows => new() + { + { @"C:", @"C:" }, + { @"C:\", @"C:\" }, + { @"C:\\", @"C:\" }, + { @"C:\foo1", @"C:\" }, + { @"C:\\foo2", @"C:\" }, + }; + + public static TheoryData TestData_GetPathRoot_Unc => new() + { + { @"\\test\unc\path\to\something", @"\\test\unc" }, + { @"\\a\b\c\d\e", @"\\a\b" }, + { @"\\a\b\", @"\\a\b" }, + { @"\\a\b", @"\\a\b" }, + { @"\\test\unc", @"\\test\unc" }, + }; + + public static TheoryData TestData_GetPathRoot_DevicePaths => new() + { + { @"\\?\UNC\test\unc\path\to\something", @"\\?\UNC\test\unc" }, + { @"\\?\UNC\test\unc", @"\\?\UNC\test\unc" }, + { @"\\?\UNC\a\b1", @"\\?\UNC\a\b1" }, + { @"\\?\UNC\a\b2\", @"\\?\UNC\a\b2" }, + { @"\\?\C:\foo\bar.txt", @"\\?\C:\" } + }; + + [PlatformSpecificTheory(TestPlatformIdentifier.Windows)] + [MemberData(nameof(TestData_GetPathRoot_Windows))] + [MemberData(nameof(TestData_GetPathRoot_Unc))] + [MemberData(nameof(TestData_GetPathRoot_DevicePaths))] + public void GetPathRoot_Span(string value, string expected) + { + Assert.Equal(expected, _fileSystem.Path.GetPathRoot(value)); + + Assert.Equal(expected, _fileSystem.Path.GetPathRoot(value.AsSpan()).ToString()); + Assert.True(_fileSystem.Path.IsPathRooted(value.AsSpan())); + } +} \ No newline at end of file diff --git a/src/CommonUtilities.FileSystem/test/PathExtensionsTest.GetRelativePathEx.cs b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.GetRelativePathEx.cs index 01caf83..098d761 100644 --- a/src/CommonUtilities.FileSystem/test/PathExtensionsTest.GetRelativePathEx.cs +++ b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.GetRelativePathEx.cs @@ -1,4 +1,5 @@ using System; +using System.IO.Abstractions; using AnakinRaW.CommonUtilities.Testing; using Testably.Abstractions.Testing; using Xunit; @@ -7,7 +8,7 @@ namespace AnakinRaW.CommonUtilities.FileSystem.Test; public class GetRelativePathExTest { - private readonly MockFileSystem _fileSystem = new(); + private readonly IFileSystem _fileSystem = new System.IO.Abstractions.FileSystem(); [PlatformSpecificTheory(TestPlatformIdentifier.Windows)] [InlineData(@"C:\", @"C:\", @".")] @@ -75,16 +76,17 @@ public void Test_GetRelativePathEx_FromRelative_Windows(string root, string path [InlineData(@"C:\a", @"X:a", @"X:\a")] public void Test_GetRelativePathEx_FromDriveRelative_Windows(string root, string path, string expected) { - _fileSystem.WithDrive("C:").WithDrive("X:"); - _fileSystem.Initialize().WithSubdirectory("C:\\current"); - _fileSystem.Directory.SetCurrentDirectory("C:\\current"); - var result = _fileSystem.Path.GetRelativePathEx(root, path); + var fileSystem = new MockFileSystem(); + fileSystem.WithDrive("C:").WithDrive("X:"); + fileSystem.Initialize().WithSubdirectory("C:\\current"); + fileSystem.Directory.SetCurrentDirectory("C:\\current"); + var result = fileSystem.Path.GetRelativePathEx(root, path); Assert.Equal(expected, result); Assert.Equal( - _fileSystem.Path.GetFullPath(path).TrimEnd(_fileSystem.Path.DirectorySeparatorChar), - _fileSystem.Path.GetFullPath(_fileSystem.Path.Combine(_fileSystem.Path.GetFullPath(root), result)) - .TrimEnd(_fileSystem.Path.DirectorySeparatorChar), + fileSystem.Path.GetFullPath(path).TrimEnd(fileSystem.Path.DirectorySeparatorChar), + fileSystem.Path.GetFullPath(fileSystem.Path.Combine(fileSystem.Path.GetFullPath(root), result)) + .TrimEnd(fileSystem.Path.DirectorySeparatorChar), StringComparer.OrdinalIgnoreCase); } diff --git a/src/CommonUtilities.FileSystem/test/PathExtensionsTest.IsDriveRelativePath.cs b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.IsDriveRelativePath.cs index 7ad55cd..761ef6d 100644 --- a/src/CommonUtilities.FileSystem/test/PathExtensionsTest.IsDriveRelativePath.cs +++ b/src/CommonUtilities.FileSystem/test/PathExtensionsTest.IsDriveRelativePath.cs @@ -1,4 +1,5 @@ -using System.IO.Abstractions; +using System; +using System.IO.Abstractions; using AnakinRaW.CommonUtilities.Testing; using Testably.Abstractions.Testing; using Xunit; @@ -31,7 +32,9 @@ public class IsDriveRelativePathTest [InlineData(@"C:test/a\a", true, 'C')] public void Test_IsDriveRelative_Windows(string? path, bool expected, char? expectedDriveLetter = null) { - Assert.Equal(expected, _fileSystem.Path.IsDriveRelative(path, out var letter)); + Assert.Equal(expected, _fileSystem.Path.IsDriveRelative(path.AsSpan(), out var letter)); + Assert.Equal(expectedDriveLetter, letter); + Assert.Equal(expected, _fileSystem.Path.IsDriveRelative(path, out letter)); Assert.Equal(expectedDriveLetter, letter); } @@ -58,7 +61,9 @@ public void Test_IsDriveRelative_Windows(string? path, bool expected, char? expe [InlineData(@"C:test/a\a", false)] public void Test_IsDriveRelative_Linux(string path, bool expected) { - Assert.Equal(expected, _fileSystem.Path.IsDriveRelative(path, out var letter)); + Assert.Equal(expected, _fileSystem.Path.IsDriveRelative(path.AsSpan(), out var letter)); + Assert.Null(letter); + Assert.Equal(expected, _fileSystem.Path.IsDriveRelative(path, out letter)); Assert.Null(letter); } } \ No newline at end of file diff --git a/src/CommonUtilities.FileSystem/test/PathNormalizerTest.Normalize.cs b/src/CommonUtilities.FileSystem/test/PathNormalizerTest.Normalize.cs index 988cace..7082e34 100644 --- a/src/CommonUtilities.FileSystem/test/PathNormalizerTest.Normalize.cs +++ b/src/CommonUtilities.FileSystem/test/PathNormalizerTest.Normalize.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Runtime.InteropServices; using AnakinRaW.CommonUtilities.FileSystem.Normalization; using Xunit; @@ -7,6 +8,37 @@ namespace AnakinRaW.CommonUtilities.FileSystem.Test; public class PathNormalizerTest { + [Fact] + public void Test_Normalize_Throws() + { + Assert.Throws(() => + { + PathNormalizer.Normalize(null!, new PathNormalizeOptions()); + }); + + Assert.Throws(() => + { + PathNormalizer.Normalize("", new PathNormalizeOptions()); + }); + } + + + [Fact] + public void Test_Normalize_Span_TooShort() + { + Assert.Throws(() => + { + Span buffer = new char[10]; + return PathNormalizer.Normalize(((string)null!).AsSpan(), buffer, new PathNormalizeOptions()); + }); + + Assert.Throws(() => + { + Span buffer = new char[10]; + return PathNormalizer.Normalize("".AsSpan(), buffer, new PathNormalizeOptions()); + }); + } + [Fact] public void Test_Normalize() { @@ -16,8 +48,16 @@ public void Test_Normalize() Assert.Equal( RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? testData.ExpectedWindows : testData.ExpectedLinux, result); - } + + Span buffer = new char[testData.Input.Length + 10]; + var charsWritten = PathNormalizer.Normalize(testData.Input.AsSpan(), buffer, testData.Options); + + Assert.Equal( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? testData.ExpectedWindows : testData.ExpectedLinux, + buffer.Slice(0, charsWritten).ToString()); + + } } private static IEnumerable NormalizeTestDataSource() @@ -199,7 +239,6 @@ private static IEnumerable NormalizeTestDataSource() UnifySeparatorKind = DirectorySeparatorKind.Linux, } }; - yield return new NormalizeTestData { Input = "a/b\\C", @@ -212,7 +251,6 @@ private static IEnumerable NormalizeTestDataSource() UnifySeparatorKind = DirectorySeparatorKind.Linux // Ensure this option is not altering the result } }; - yield return new NormalizeTestData { Input = "a/b\\C\\", @@ -225,6 +263,46 @@ private static IEnumerable NormalizeTestDataSource() UnifySeparatorKind = DirectorySeparatorKind.Windows // Ensure this option is not altering the result } }; + + + // LongStrings + yield return new NormalizeTestData + { + Input = new string('a', 300), + ExpectedLinux = new string('a', 300), + ExpectedWindows = new string('a', 300), + Options = new PathNormalizeOptions() + }; + yield return new NormalizeTestData + { + Input = new string('a', 300), + ExpectedLinux = new string('a', 300) + "/", + ExpectedWindows = new string('a', 300) + "\\", + Options = new PathNormalizeOptions() + { + TrailingDirectorySeparatorBehavior = TrailingDirectorySeparatorBehavior.Ensure + } + }; + yield return new NormalizeTestData + { + Input = new string('a', 300) + "/", + ExpectedLinux = new string('a', 300), + ExpectedWindows = new string('a', 300), + Options = new PathNormalizeOptions() + { + TrailingDirectorySeparatorBehavior = TrailingDirectorySeparatorBehavior.Trim + } + }; + yield return new NormalizeTestData + { + Input = new string('a', 300) + "/", + ExpectedLinux = new string('A', 300) + "/", + ExpectedWindows = new string('A', 300) + "/", + Options = new PathNormalizeOptions + { + UnifyCase = UnifyCasingKind.UpperCaseForce + } + }; } internal record NormalizeTestData diff --git a/src/CommonUtilities.FileSystem/test/Utilities/ValueStringBuilderTest.cs b/src/CommonUtilities.FileSystem/test/Utilities/ValueStringBuilderTest.cs new file mode 100644 index 0000000..fd1987a --- /dev/null +++ b/src/CommonUtilities.FileSystem/test/Utilities/ValueStringBuilderTest.cs @@ -0,0 +1,318 @@ +using AnakinRaW.CommonUtilities.FileSystem.Utilities; +using System; +using System.Text; +using Xunit; + +namespace AnakinRaW.CommonUtilities.FileSystem.Test.Utilities; + +// Copied from https://github.com/dotnet/runtime +public class ValueStringBuilderTests +{ + [Fact] + public void Ctor_Default_CanAppend() + { + var vsb = default(ValueStringBuilder); + Assert.Equal(0, vsb.Length); + + vsb.Append('a'); + Assert.Equal(1, vsb.Length); + Assert.Equal("a", vsb.ToString()); + } + + [Fact] + public void Ctor_Span_CanAppend() + { + var vsb = new ValueStringBuilder(new char[1]); + Assert.Equal(0, vsb.Length); + + vsb.Append('a'); + Assert.Equal(1, vsb.Length); + Assert.Equal("a", vsb.ToString()); + } + + [Fact] + public void Ctor_InitialCapacity_CanAppend() + { + var vsb = new ValueStringBuilder(1); + Assert.Equal(0, vsb.Length); + + vsb.Append('a'); + Assert.Equal(1, vsb.Length); + Assert.Equal("a", vsb.ToString()); + } + + [Fact] + public void Append_Char_MatchesStringBuilder() + { + var sb = new StringBuilder(); + var vsb = new ValueStringBuilder(); + for (int i = 1; i <= 100; i++) + { + sb.Append((char)i); + vsb.Append((char)i); + } + + Assert.Equal(sb.Length, vsb.Length); + Assert.Equal(sb.ToString(), vsb.ToString()); + } + + [Fact] + public void Append_String_MatchesStringBuilder() + { + var sb = new StringBuilder(); + var vsb = new ValueStringBuilder(); + for (int i = 1; i <= 100; i++) + { + string s = i.ToString(); + sb.Append(s); + vsb.Append(s); + } + + Assert.Equal(sb.Length, vsb.Length); + Assert.Equal(sb.ToString(), vsb.ToString()); + } + + [Theory] + [InlineData(0, 4 * 1024 * 1024)] + [InlineData(1025, 4 * 1024 * 1024)] + [InlineData(3 * 1024 * 1024, 6 * 1024 * 1024)] + public void Append_String_Large_MatchesStringBuilder(int initialLength, int stringLength) + { + var sb = new StringBuilder(initialLength); + var vsb = new ValueStringBuilder(new char[initialLength]); + + string s = new string('a', stringLength); + sb.Append(s); + vsb.Append(s); + + Assert.Equal(sb.Length, vsb.Length); + Assert.Equal(sb.ToString(), vsb.ToString()); + } + + [Fact] + public void Append_CharInt_MatchesStringBuilder() + { + var sb = new StringBuilder(); + var vsb = new ValueStringBuilder(); + for (int i = 1; i <= 100; i++) + { + sb.Append((char)i, i); + vsb.Append((char)i, i); + } + + Assert.Equal(sb.Length, vsb.Length); + Assert.Equal(sb.ToString(), vsb.ToString()); + } + + [Fact] + public unsafe void Append_PtrInt_MatchesStringBuilder() + { + var sb = new StringBuilder(); + var vsb = new ValueStringBuilder(); + for (int i = 1; i <= 100; i++) + { + string s = i.ToString(); + fixed (char* p = s) + { + sb.Append(p, s.Length); + vsb.Append(p, s.Length); + } + } + + Assert.Equal(sb.Length, vsb.Length); + Assert.Equal(sb.ToString(), vsb.ToString()); + } + + [Fact] + public void AppendSpan_DataAppendedCorrectly() + { + var sb = new StringBuilder(); + var vsb = new ValueStringBuilder(); + + for (int i = 1; i <= 1000; i++) + { + string s = i.ToString(); + + sb.Append(s); + + Span span = vsb.AppendSpan(s.Length); + Assert.Equal(sb.Length, vsb.Length); + + s.AsSpan().CopyTo(span); + } + + Assert.Equal(sb.Length, vsb.Length); + Assert.Equal(sb.ToString(), vsb.ToString()); + } + + [Fact] + public void Insert_IntCharInt_MatchesStringBuilder() + { + var sb = new StringBuilder(); + var vsb = new ValueStringBuilder(); + var rand = new Random(42); + + for (int i = 1; i <= 100; i++) + { + int index = rand.Next(sb.Length); + sb.Insert(index, new string((char)i, 1), i); + vsb.Insert(index, (char)i, i); + } + + Assert.Equal(sb.Length, vsb.Length); + Assert.Equal(sb.ToString(), vsb.ToString()); + } + + [Fact] + public void AsSpan_ReturnsCorrectValue_DoesntClearBuilder() + { + var sb = new StringBuilder(); + var vsb = new ValueStringBuilder(); + + for (int i = 1; i <= 100; i++) + { + string s = i.ToString(); + sb.Append(s); + vsb.Append(s); + } + + var resultString = vsb.AsSpan().ToString(); + Assert.Equal(sb.ToString(), resultString); + + Assert.NotEqual(0, sb.Length); + Assert.Equal(sb.Length, vsb.Length); + Assert.Equal(sb.ToString(), vsb.ToString()); + } + + [Fact] + public void ToString_ClearsBuilder_ThenReusable() + { + const string Text1 = "test"; + var vsb = new ValueStringBuilder(); + + vsb.Append(Text1); + Assert.Equal(Text1.Length, vsb.Length); + + string s = vsb.ToString(); + Assert.Equal(Text1, s); + + Assert.Equal(0, vsb.Length); + Assert.Equal(string.Empty, vsb.ToString()); + Assert.True(vsb.TryCopyTo(Span.Empty, out _)); + + const string Text2 = "another test"; + vsb.Append(Text2); + Assert.Equal(Text2.Length, vsb.Length); + Assert.Equal(Text2, vsb.ToString()); + } + + [Fact] + public void TryCopyTo_FailsWhenDestinationIsTooSmall_SucceedsWhenItsLargeEnough() + { + var vsb = new ValueStringBuilder(); + + const string Text = "expected text"; + vsb.Append(Text); + Assert.Equal(Text.Length, vsb.Length); + + Span dst = new char[Text.Length - 1]; + Assert.False(vsb.TryCopyTo(dst, out int charsWritten)); + Assert.Equal(0, charsWritten); + Assert.Equal(0, vsb.Length); + } + + [Fact] + public void TryCopyTo_ClearsBuilder_ThenReusable() + { + const string Text1 = "test"; + var vsb = new ValueStringBuilder(); + + vsb.Append(Text1); + Assert.Equal(Text1.Length, vsb.Length); + + Span dst = new char[Text1.Length]; + Assert.True(vsb.TryCopyTo(dst, out int charsWritten)); + Assert.Equal(Text1.Length, charsWritten); + Assert.Equal(Text1, dst.ToString()); + + Assert.Equal(0, vsb.Length); + Assert.Equal(string.Empty, vsb.ToString()); + Assert.True(vsb.TryCopyTo(Span.Empty, out _)); + + const string Text2 = "another test"; + vsb.Append(Text2); + Assert.Equal(Text2.Length, vsb.Length); + Assert.Equal(Text2, vsb.ToString()); + } + + [Fact] + public void Dispose_ClearsBuilder_ThenReusable() + { + const string Text1 = "test"; + var vsb = new ValueStringBuilder(); + + vsb.Append(Text1); + Assert.Equal(Text1.Length, vsb.Length); + + vsb.Dispose(); + + Assert.Equal(0, vsb.Length); + Assert.Equal(string.Empty, vsb.ToString()); + Assert.True(vsb.TryCopyTo(Span.Empty, out _)); + + const string Text2 = "another test"; + vsb.Append(Text2); + Assert.Equal(Text2.Length, vsb.Length); + Assert.Equal(Text2, vsb.ToString()); + } + + [Fact] + public unsafe void Indexer() + { + const string Text1 = "foobar"; + var vsb = new ValueStringBuilder(); + + vsb.Append(Text1); + + Assert.Equal('b', vsb[3]); + vsb[3] = 'c'; + Assert.Equal('c', vsb[3]); + vsb.Dispose(); + } + + [Fact] + public void EnsureCapacity_IfRequestedCapacityWins() + { + // Note: constants used here may be dependent on minimal buffer size + // the ArrayPool is able to return. + var builder = new ValueStringBuilder(stackalloc char[32]); + + builder.EnsureCapacity(65); + + Assert.Equal(128, builder.Capacity); + } + + [Fact] + public void EnsureCapacity_IfBufferTimesTwoWins() + { + var builder = new ValueStringBuilder(stackalloc char[32]); + + builder.EnsureCapacity(33); + + Assert.Equal(64, builder.Capacity); + builder.Dispose(); + } + + [Fact] + public void EnsureCapacity_NoAllocIfNotNeeded() + { + // Note: constants used here may be dependent on minimal buffer size + // the ArrayPool is able to return. + var builder = new ValueStringBuilder(stackalloc char[64]); + + builder.EnsureCapacity(16); + + Assert.Equal(64, builder.Capacity); + builder.Dispose(); + } +} \ No newline at end of file diff --git a/src/CommonUtilities.FileSystem/test/Validation/WindowsFileNameValidatorTest.cs b/src/CommonUtilities.FileSystem/test/Validation/WindowsFileNameValidatorTest.cs index 7a5312b..5f28ccd 100644 --- a/src/CommonUtilities.FileSystem/test/Validation/WindowsFileNameValidatorTest.cs +++ b/src/CommonUtilities.FileSystem/test/Validation/WindowsFileNameValidatorTest.cs @@ -57,36 +57,40 @@ public void Test_IsValidFileName_ValidNames(string input) [InlineData("test....", FileNameValidationResult.TrailingPeriod)] [InlineData("test..", FileNameValidationResult.TrailingPeriod)] [InlineData("test.", FileNameValidationResult.TrailingPeriod)] - [InlineData("con", FileNameValidationResult.SystemReserved)] - [InlineData("PRN", FileNameValidationResult.SystemReserved)] - [InlineData("AUX", FileNameValidationResult.SystemReserved)] - [InlineData("NUL", FileNameValidationResult.SystemReserved)] - [InlineData("COM0", FileNameValidationResult.SystemReserved)] - [InlineData("COM1", FileNameValidationResult.SystemReserved)] - [InlineData("COM2", FileNameValidationResult.SystemReserved)] - [InlineData("COM3", FileNameValidationResult.SystemReserved)] - [InlineData("COM4", FileNameValidationResult.SystemReserved)] - [InlineData("COM5", FileNameValidationResult.SystemReserved)] - [InlineData("COM6", FileNameValidationResult.SystemReserved)] - [InlineData("COM7", FileNameValidationResult.SystemReserved)] - [InlineData("COM8", FileNameValidationResult.SystemReserved)] - [InlineData("COM9", FileNameValidationResult.SystemReserved)] - [InlineData("lpt0", FileNameValidationResult.SystemReserved)] - [InlineData("lpt1", FileNameValidationResult.SystemReserved)] - [InlineData("lpt2", FileNameValidationResult.SystemReserved)] - [InlineData("lpt3", FileNameValidationResult.SystemReserved)] - [InlineData("lpt4", FileNameValidationResult.SystemReserved)] - [InlineData("lpt5", FileNameValidationResult.SystemReserved)] - [InlineData("lpt6", FileNameValidationResult.SystemReserved)] - [InlineData("lpt7", FileNameValidationResult.SystemReserved)] - [InlineData("lpt8", FileNameValidationResult.SystemReserved)] - [InlineData("lpt9", FileNameValidationResult.SystemReserved)] + [InlineData("con", FileNameValidationResult.SystemReserved, true)] + [InlineData("PRN", FileNameValidationResult.SystemReserved, true)] + [InlineData("AUX", FileNameValidationResult.SystemReserved, true)] + [InlineData("NUL", FileNameValidationResult.SystemReserved, true)] + [InlineData("COM0", FileNameValidationResult.SystemReserved, true)] + [InlineData("COM1", FileNameValidationResult.SystemReserved, true)] + [InlineData("COM2", FileNameValidationResult.SystemReserved, true)] + [InlineData("COM3", FileNameValidationResult.SystemReserved, true)] + [InlineData("COM4", FileNameValidationResult.SystemReserved, true)] + [InlineData("COM5", FileNameValidationResult.SystemReserved, true)] + [InlineData("COM6", FileNameValidationResult.SystemReserved, true)] + [InlineData("COM7", FileNameValidationResult.SystemReserved, true)] + [InlineData("COM8", FileNameValidationResult.SystemReserved, true)] + [InlineData("COM9", FileNameValidationResult.SystemReserved, true)] + [InlineData("lpt0", FileNameValidationResult.SystemReserved, true)] + [InlineData("lpt1", FileNameValidationResult.SystemReserved, true)] + [InlineData("lpt2", FileNameValidationResult.SystemReserved, true)] + [InlineData("lpt3", FileNameValidationResult.SystemReserved, true)] + [InlineData("lpt4", FileNameValidationResult.SystemReserved, true)] + [InlineData("lpt5", FileNameValidationResult.SystemReserved, true)] + [InlineData("lpt6", FileNameValidationResult.SystemReserved, true)] + [InlineData("lpt7", FileNameValidationResult.SystemReserved, true)] + [InlineData("lpt8", FileNameValidationResult.SystemReserved, true)] + [InlineData("lpt9", FileNameValidationResult.SystemReserved, true)] [InlineData("\\file", FileNameValidationResult.InvalidCharacter)] [InlineData("/file", FileNameValidationResult.InvalidCharacter)] [InlineData("|file", FileNameValidationResult.InvalidCharacter)] - public void Test_IsValidFileName_InvalidNames(string input, FileNameValidationResult expected) + public void Test_IsValidFileName_InvalidNames(string input, FileNameValidationResult expected, bool ignoreWhenNoWindowsReserved = false) { Assert.Equal(expected, WindowsFileNameValidator.Instance.IsValidFileName(input)); + Assert.Equal(expected, WindowsFileNameValidator.Instance.IsValidFileName(input.AsSpan(), true)); + Assert.Equal(ignoreWhenNoWindowsReserved ? FileNameValidationResult.Valid : expected, + WindowsFileNameValidator.Instance.IsValidFileName(input.AsSpan(), false)); + Assert.Equal(expected, WindowsFileNameValidator.Instance.IsValidFileName(input.AsSpan())); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) diff --git a/src/CommonUtilities.Registry/test/CommonUtilities.Registry.Test.csproj b/src/CommonUtilities.Registry/test/CommonUtilities.Registry.Test.csproj index 87592ac..b3e7e32 100644 --- a/src/CommonUtilities.Registry/test/CommonUtilities.Registry.Test.csproj +++ b/src/CommonUtilities.Registry/test/CommonUtilities.Registry.Test.csproj @@ -13,13 +13,13 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/CommonUtilities.SimplePipeline/src/Pipelines/ParallelProducerConsumerPipeline.cs b/src/CommonUtilities.SimplePipeline/src/Pipelines/ParallelProducerConsumerPipeline.cs index efd4a49..507a5b6 100644 --- a/src/CommonUtilities.SimplePipeline/src/Pipelines/ParallelProducerConsumerPipeline.cs +++ b/src/CommonUtilities.SimplePipeline/src/Pipelines/ParallelProducerConsumerPipeline.cs @@ -13,13 +13,14 @@ namespace AnakinRaW.CommonUtilities.SimplePipeline; /// Useful, if preparation is work intensive. /// public abstract class ParallelProducerConsumerPipeline : Pipeline -{ - private readonly bool _failFast; - private CancellationTokenSource? _linkedCancellationTokenSource; +{ private readonly ParallelProducerConsumerRunner _runner; private Exception? _preparationException; - + + /// + protected override bool FailFast { get; } + /// /// Initializes a new instance of the class. /// @@ -28,7 +29,7 @@ public abstract class ParallelProducerConsumerPipeline : Pipeline /// A value indicating whether the pipeline should fail fast. protected ParallelProducerConsumerPipeline(int workerCount, bool failFast, IServiceProvider serviceProvider) : base(serviceProvider) { - _failFast = failFast; + FailFast = failFast; _runner = new ParallelProducerConsumerRunner(workerCount, serviceProvider); } @@ -41,8 +42,6 @@ public sealed override async Task RunAsync(CancellationToken token = default) if (PrepareSuccessful is false) return; - _linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); - if (PrepareSuccessful is null) { Task.Run(async () => @@ -53,7 +52,7 @@ public sealed override async Task RunAsync(CancellationToken token = default) if (!result) { PipelineFailed = true; - _linkedCancellationTokenSource?.Cancel(); + Cancel(); } } catch (Exception e) @@ -70,7 +69,7 @@ public sealed override async Task RunAsync(CancellationToken token = default) try { - await RunCoreAsync(_linkedCancellationTokenSource.Token).ConfigureAwait(false); + await RunCoreAsync(token).ConfigureAwait(false); } catch (Exception) { @@ -104,11 +103,6 @@ protected override async Task RunCoreAsync(CancellationToken token) finally { _runner.Error -= OnError; - if (_linkedCancellationTokenSource is not null) - { - _linkedCancellationTokenSource.Dispose(); - _linkedCancellationTokenSource = null; - } } if (!PipelineFailed) @@ -120,18 +114,6 @@ protected override async Task RunCoreAsync(CancellationToken token) ThrowIfAnyStepsFailed(_runner.Steps); } - /// - /// Called when an error occurs within a step. - /// - /// The sender of the event. - /// The event arguments. - protected virtual void OnError(object sender, StepErrorEventArgs e) - { - PipelineFailed = true; - if (_failFast || e.Cancel) - _linkedCancellationTokenSource?.Cancel(); - } - /// protected override void DisposeManagedResources() { diff --git a/src/CommonUtilities.SimplePipeline/src/Pipelines/Pipeline.cs b/src/CommonUtilities.SimplePipeline/src/Pipelines/Pipeline.cs index 78ac591..9bb1c3c 100644 --- a/src/CommonUtilities.SimplePipeline/src/Pipelines/Pipeline.cs +++ b/src/CommonUtilities.SimplePipeline/src/Pipelines/Pipeline.cs @@ -13,6 +13,8 @@ namespace AnakinRaW.CommonUtilities.SimplePipeline; /// public abstract class Pipeline : DisposableObject, IPipeline { + private CancellationTokenSource? _linkedCancellationTokenSource; + /// /// The service provider of the . /// @@ -33,6 +35,11 @@ public abstract class Pipeline : DisposableObject, IPipeline /// public bool PipelineFailed { get; protected set; } + /// + /// Gets a value indicating the pipeline shall abort execution on the first received error. + /// + protected virtual bool FailFast => false; + /// /// /// @@ -62,7 +69,20 @@ public virtual async Task RunAsync(CancellationToken token = default) try { - await RunCoreAsync(token).ConfigureAwait(false); + + try + { + _linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + await RunCoreAsync(_linkedCancellationTokenSource.Token).ConfigureAwait(false); + } + finally + { + if (_linkedCancellationTokenSource is not null) + { + _linkedCancellationTokenSource.Dispose(); + _linkedCancellationTokenSource = null; + } + } } catch (Exception) { @@ -71,6 +91,14 @@ public virtual async Task RunAsync(CancellationToken token = default) } } + /// + /// Cancels the pipeline + /// + public void Cancel() + { + _linkedCancellationTokenSource?.Cancel(); + } + /// public override string ToString() { @@ -104,4 +132,17 @@ protected void ThrowIfAnyStepsFailed(IEnumerable steps) if (failedBuildSteps.Any()) throw new StepFailureException(failedBuildSteps); } + + /// + /// The default event handler that can be used when an error occurs within a step. + /// is set to . When is , the pipeline gets cancelled. + /// + /// The sender of the event. + /// The event arguments. + protected virtual void OnError(object sender, StepErrorEventArgs e) + { + PipelineFailed = true; + if (FailFast || e.Cancel) + Cancel(); + } } \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/src/Pipelines/SimplePipeline.cs b/src/CommonUtilities.SimplePipeline/src/Pipelines/SimplePipeline.cs index 9b1f8a5..59caf22 100644 --- a/src/CommonUtilities.SimplePipeline/src/Pipelines/SimplePipeline.cs +++ b/src/CommonUtilities.SimplePipeline/src/Pipelines/SimplePipeline.cs @@ -11,12 +11,12 @@ namespace AnakinRaW.CommonUtilities.SimplePipeline; /// /// The type of the step runner. public abstract class SimplePipeline : Pipeline where TRunner : StepRunner -{ - private readonly bool _failFast; - - private CancellationTokenSource? _linkedCancellationTokenSource; +{ private IRunner _buildRunner = null!; + /// + protected override bool FailFast { get; } + /// /// Initializes a new instance of the class. /// @@ -27,7 +27,7 @@ public abstract class SimplePipeline : Pipeline where TRunner : StepRun /// protected SimplePipeline(IServiceProvider serviceProvider, bool failFast = true) : base(serviceProvider) { - _failFast = failFast; + FailFast = failFast; } /// @@ -66,19 +66,12 @@ protected override async Task RunCoreAsync(CancellationToken token) { try { - _linkedCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); - _buildRunner.Error += OnError; - await _buildRunner.RunAsync(_linkedCancellationTokenSource.Token).ConfigureAwait(false); + await _buildRunner.RunAsync(token).ConfigureAwait(false); } finally { _buildRunner.Error -= OnError; - if (_linkedCancellationTokenSource is not null) - { - _linkedCancellationTokenSource.Dispose(); - _linkedCancellationTokenSource = null; - } } if (!PipelineFailed) @@ -87,18 +80,6 @@ protected override async Task RunCoreAsync(CancellationToken token) ThrowIfAnyStepsFailed(_buildRunner.Steps); } - /// - /// Called when an error occurs within a step. - /// - /// The sender of the event. - /// The event arguments. - protected virtual void OnError(object sender, StepErrorEventArgs e) - { - PipelineFailed = true; - if (_failFast || e.Cancel) - _linkedCancellationTokenSource?.Cancel(); - } - /// protected override void DisposeManagedResources() { diff --git a/src/CommonUtilities.SimplePipeline/test/CommonUtilities.SimplePipeline.Test.csproj b/src/CommonUtilities.SimplePipeline/test/CommonUtilities.SimplePipeline.Test.csproj index a54128b..a6f2cf3 100644 --- a/src/CommonUtilities.SimplePipeline/test/CommonUtilities.SimplePipeline.Test.csproj +++ b/src/CommonUtilities.SimplePipeline/test/CommonUtilities.SimplePipeline.Test.csproj @@ -13,18 +13,18 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/CommonUtilities.SimplePipeline/test/PipelineTest.cs b/src/CommonUtilities.SimplePipeline/test/Pipelines/PipelineTest.cs similarity index 63% rename from src/CommonUtilities.SimplePipeline/test/PipelineTest.cs rename to src/CommonUtilities.SimplePipeline/test/Pipelines/PipelineTest.cs index ae69cdd..7348528 100644 --- a/src/CommonUtilities.SimplePipeline/test/PipelineTest.cs +++ b/src/CommonUtilities.SimplePipeline/test/Pipelines/PipelineTest.cs @@ -5,7 +5,7 @@ using Moq.Protected; using Xunit; -namespace AnakinRaW.CommonUtilities.SimplePipeline.Test; +namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Pipelines; public class PipelineTest { @@ -34,12 +34,12 @@ public async Task Test_Run() }; pipeline.Protected().Setup>("PrepareCoreAsync").Returns(Task.FromResult(true)); - + await pipeline.Object.RunAsync(); await pipeline.Object.RunAsync(); pipeline.Protected().Verify>("PrepareCoreAsync", Times.Exactly(1)); - pipeline.Protected().Verify("RunCoreAsync", Times.Exactly(2), false, (CancellationToken) default); + pipeline.Protected().Verify("RunCoreAsync", Times.Exactly(2), false, ItExpr.IsAny()); } [Fact] @@ -58,7 +58,7 @@ public async Task Test_Prepare_Run() await pipeline.Object.RunAsync(); pipeline.Protected().Verify>("PrepareCoreAsync", Times.Exactly(1)); - pipeline.Protected().Verify("RunCoreAsync", Times.Exactly(2), false, (CancellationToken)default); + pipeline.Protected().Verify("RunCoreAsync", Times.Exactly(2), false, ItExpr.IsAny()); } [Fact] @@ -89,7 +89,7 @@ public async Task Test_Prepare_Disposed_ThrowsObjectDisposedException() pipeline.Object.Dispose(); pipeline.Object.Dispose(); - await Assert.ThrowsAsync(async() => await pipeline.Object.PrepareAsync()); + await Assert.ThrowsAsync(async () => await pipeline.Object.PrepareAsync()); } [Fact] @@ -106,4 +106,49 @@ public async Task Test_Run_Disposed_ThrowsObjectDisposedException() await Assert.ThrowsAsync(async () => await pipeline.Object.RunAsync()); } + + [Fact] + public async Task Test_Cancel() + { + var sp = new Mock().Object; + var pipeline = new Mock(sp) + { + CallBase = true + }; + + var callbackRunTsc = new TaskCompletionSource(); + var cancelledTsc = new TaskCompletionSource(); + + CancellationToken runToken = default; + + var callbackTask = Task.Run(async () => + { + await callbackRunTsc.Task; + Assert.True(runToken.CanBeCanceled); + + await cancelledTsc.Task; + Assert.True(runToken.IsCancellationRequested); + }); + + pipeline.Protected().Setup>("PrepareCoreAsync").Returns(Task.FromResult(true)); + pipeline.Protected().Setup("RunCoreAsync", ItExpr.IsAny()) + .Callback((CancellationToken token) => + { + runToken = token; + callbackRunTsc.SetResult(0); + }).Returns(callbackTask); + + + var pipelineTask = pipeline.Object.RunAsync(); + + pipeline.Object.Cancel(); + cancelledTsc.SetResult(0); + + await pipelineTask; + + Assert.True(callbackTask.IsCompleted); + + pipeline.Protected().Verify>("PrepareCoreAsync", Times.Exactly(1)); + pipeline.Protected().Verify("RunCoreAsync", Times.Exactly(1), false, ItExpr.IsAny()); + } } \ No newline at end of file diff --git a/src/CommonUtilities.SimplePipeline/test/Runners/ParallelProducerConsumerRunnerTest.cs b/src/CommonUtilities.SimplePipeline/test/Runners/ParallelProducerConsumerRunnerTest.cs index eb800cf..536d4be 100644 --- a/src/CommonUtilities.SimplePipeline/test/Runners/ParallelProducerConsumerRunnerTest.cs +++ b/src/CommonUtilities.SimplePipeline/test/Runners/ParallelProducerConsumerRunnerTest.cs @@ -12,7 +12,7 @@ namespace AnakinRaW.CommonUtilities.SimplePipeline.Test.Runners; public class ParallelProducerConsumerRunnerTest { [Fact] - public void Test_Run_WaitNotFinished() + public async void Test_Run_WaitNotFinished() { var sc = new ServiceCollection(); var runner = new ParallelProducerConsumerRunner(2, sc.BuildServiceProvider()); @@ -23,24 +23,25 @@ public void Test_Run_WaitNotFinished() runner.AddStep(s1.Object); runner.AddStep(s2.Object); - var ran1 = false; + var tsc1 = new TaskCompletionSource(); + var tsc2 = new TaskCompletionSource(); + s1.Setup(t => t.Run(default)).Callback(() => { - ran1 = true; + tsc1.SetResult(1); }); - var ran2 = false; s2.Setup(t => t.Run(default)).Callback(() => { - ran2 = true; + tsc2.SetResult(1); }); - - + + _ = runner.RunAsync(default); - Assert.Throws(() => runner.Wait(TimeSpan.FromSeconds(2))); + await tsc1.Task; + await tsc1.Task; - Assert.True(ran1); - Assert.True(ran2); + Assert.Throws(() => runner.Wait(TimeSpan.FromSeconds(2))); } [Fact] diff --git a/src/CommonUtilities.TestingUtilities/CommonUtilities.TestingUtilities.csproj b/src/CommonUtilities.TestingUtilities/CommonUtilities.TestingUtilities.csproj index c065950..1933daa 100644 --- a/src/CommonUtilities.TestingUtilities/CommonUtilities.TestingUtilities.csproj +++ b/src/CommonUtilities.TestingUtilities/CommonUtilities.TestingUtilities.csproj @@ -21,9 +21,9 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/CommonUtilities/src/Hashing/HashingService.cs b/src/CommonUtilities/src/Hashing/HashingService.cs index 720ad34..a5e680e 100644 --- a/src/CommonUtilities/src/Hashing/HashingService.cs +++ b/src/CommonUtilities/src/Hashing/HashingService.cs @@ -169,20 +169,25 @@ public byte[] GetHash(string stringData, Encoding encoding, HashTypeKey hashType /// public int GetHash(string stringData, Encoding encoding, Span destination, HashTypeKey hashType) + { + return GetHash(stringData.AsSpan(), destination, encoding, hashType); + } + + /// + public int GetHash(ReadOnlySpan stringData, Span destination, Encoding encoding, HashTypeKey hashType) { if (stringData == null) throw new ArgumentNullException(nameof(stringData)); if (encoding == null) throw new ArgumentNullException(nameof(encoding)); - var stringSpan = stringData.AsSpan(); - var maxByteSize = encoding.GetMaxByteCount(stringSpan.Length); + var maxByteSize = encoding.GetMaxByteCount(stringData.Length); byte[]? encodedBytes = null; try { var buffer = maxByteSize > 256 ? encodedBytes = ArrayPool.Shared.Rent(maxByteSize) : stackalloc byte[maxByteSize]; - var bytesToHash = encoding.GetBytesReadOnly(stringSpan, buffer); + var bytesToHash = encoding.GetBytesReadOnly(stringData, buffer); return GetHash(bytesToHash, destination, hashType); } diff --git a/src/CommonUtilities/src/Hashing/IHashingService.cs b/src/CommonUtilities/src/Hashing/IHashingService.cs index f94f462..81cacac 100644 --- a/src/CommonUtilities/src/Hashing/IHashingService.cs +++ b/src/CommonUtilities/src/Hashing/IHashingService.cs @@ -94,6 +94,19 @@ public interface IHashingService /// The buffer in is too small to hold the calculated hash size. int GetHash(string stringData, Encoding encoding, Span destination, HashTypeKey hashType); + /// + /// Computes the hash of a character span using the specified algorithm. + /// + /// The character span to hash. + /// The buffer to receive the hash value. + /// The encoding to interpret the string + /// The hash type data. + /// The total number of bytes written to . + /// The buffer in destination is too small to hold the calculated hash size. + /// is . + /// The buffer in is too small to hold the calculated hash size. + int GetHash(ReadOnlySpan stringData, Span destination, Encoding encoding, HashTypeKey hashType); + /// /// Asynchronously computes the hash of a file using the specified algorithm. /// diff --git a/src/CommonUtilities/test/CommonUtilities.Test.csproj b/src/CommonUtilities/test/CommonUtilities.Test.csproj index e66bc5f..1e07088 100644 --- a/src/CommonUtilities/test/CommonUtilities.Test.csproj +++ b/src/CommonUtilities/test/CommonUtilities.Test.csproj @@ -14,18 +14,18 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/CommonUtilities/test/Hashing/HashingServiceTest.cs b/src/CommonUtilities/test/Hashing/HashingServiceTest.cs index 4dccb91..6ed58ea 100644 --- a/src/CommonUtilities/test/Hashing/HashingServiceTest.cs +++ b/src/CommonUtilities/test/Hashing/HashingServiceTest.cs @@ -66,6 +66,7 @@ public void Test_GetHash_ProviderNotFound_ThrowsHashProviderNotFoundException() Assert.Throws(() => _hashingService.GetHash(someStream, notExistingProvider)); Assert.Throws(() => _hashingService.GetHash("", Encoding.ASCII, notExistingProvider)); Assert.Throws(() => _hashingService.GetHash("", Encoding.ASCII, new Span(someDestination), notExistingProvider)); + Assert.Throws(() => _hashingService.GetHash("".AsSpan(), new Span(someDestination), Encoding.ASCII, notExistingProvider)); Assert.Throws(() => _hashingService.GetHash(someSource, notExistingProvider)); } @@ -100,6 +101,7 @@ public void Test_GetHash_AlwaysOneProvider_DestinationTooShort_ThrowsIndexOutOfR Assert.Throws(() => _hashingService.GetHash(new ReadOnlySpan(someSource), someDestination.AsSpan(), provider)); Assert.Throws(() => _hashingService.GetHash(someStream, someDestination.AsSpan(), provider)); Assert.Throws(() => _hashingService.GetHash("", Encoding.ASCII, new Span(someDestination), provider)); + Assert.Throws(() => _hashingService.GetHash("".AsSpan(), new Span(someDestination), Encoding.ASCII, provider)); } [Fact] @@ -133,6 +135,7 @@ public void Test_GetHash_WrongOutputSizeProvider_HashWrongSize_ThrowsInvalidOper Assert.Throws(() => _hashingService.GetHash(someStream, provider)); Assert.Throws(() => _hashingService.GetHash("", Encoding.ASCII, provider)); Assert.Throws(() => _hashingService.GetHash("", Encoding.ASCII, new Span(someDestination), provider)); + Assert.Throws(() => _hashingService.GetHash("".AsSpan(), new Span(someDestination), Encoding.ASCII, provider)); Assert.Throws(() => _hashingService.GetHash(someSource, provider)); } @@ -185,6 +188,9 @@ public void Test_GetHash_AlwaysOneProvider() Assert.Equal(1, _hashingService.GetHash("", Encoding.ASCII, new Span(destination), provider)); Assert.Equal(expectedHashJoint, destination); + Assert.Equal(1, _hashingService.GetHash("".AsSpan(), new Span(destination), Encoding.ASCII, provider)); + Assert.Equal(expectedHashJoint, destination); + Assert.Equal(expectedHashExact, _hashingService.GetHash(someSource, provider)); } @@ -258,6 +264,9 @@ public void Test_GetHash_DefaultProviders(HashTypeKey hashType, string input, st Assert.Equal(expectedSize, _hashingService.GetHash(input, Encoding.ASCII, new Span(destination), hashType)); Assert.Equal(expectedHash, destination); + Assert.Equal(expectedSize, _hashingService.GetHash(input.AsSpan(), new Span(destination), Encoding.ASCII, hashType)); + Assert.Equal(expectedHash, destination); + Assert.Equal(expectedHash, _hashingService.GetHash(someSource, hashType)); }