Files
2026-07-13 13:13:17 +08:00

693 lines
22 KiB
C#

#nullable enable
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
// ReSharper disable MemberCanBePrivate.Global
namespace T3.Core.Utils;
[SuppressMessage("ReSharper", "MemberCanBeInternal")]
public static class StringUtils
{
public static unsafe bool Equals(ReadOnlySpan<char> a, ReadOnlySpan<char> b, bool ignoreCase)
{
var aLength = a.Length;
if (aLength != b.Length)
return false;
// this is made unsafe to avoid the overhead of the span bounds check - it's safe because we checked the length already
if (ignoreCase)
{
fixed (char* aPtr = a)
fixed (char* bPtr = b)
{
for (var i = 0; i < aLength; i++)
{
if (char.ToLowerInvariant(aPtr[i]) != char.ToLowerInvariant(bPtr[i]))
return false;
}
}
}
else
{
fixed (char* aPtr = a)
fixed (char* bPtr = b)
{
for (var i = 0; i < aLength; i++)
{
if (aPtr[i] != bPtr[i])
return false;
}
}
}
return true;
}
/// <summary>
/// Splits the provided path by the directory separator character.
/// Warning: not for use with windows-style absolute paths (e.g. C:\foo\bar or C:/foo/bar),
/// though unix-style absolute paths will work (e.g. /foo/bar)
/// </summary>
/// <param name="path">The path to split</param>
/// <param name="ranges">Ranges that can be used to create spans or substrings from the original path</param>
/// <returns></returns>
public static int SplitByDirectory(ReadOnlySpan<char> path, Span<Range> ranges)
{
int count = 0;
int start = 0;
for (int i = 0; i < path.Length; i++)
{
if (path[i] == '\\' || path[i] == '/')
{
if (i > start)
{
ranges[count++] = new Range(start, i);
}
start = i + 1;
}
}
if (path.Length > start)
{
ranges[count++] = new Range(start, path.Length);
}
return count;
}
public enum SearchResultIndex
{
BeforeTerm,
AfterTerm,
FirstIndex,
LastIndex
}
public static bool TryFindIgnoringAllWhitespace(string text, string searchTerm, SearchResultIndex searchResultIndex, out int indexFollowingSearchTerm,
int startIndex = 0)
{
// search the given string for the search term, ignoring all whitespace in both strings. " \ta b" == "ab"
var searchTextLength = text.Length;
// remove all whitespace from searchTerm
searchTerm = RemoveWhitespaceFrom(searchTerm);
var searchTermLength = searchTerm.Length;
int currentSearchIndex = 0;
char currentSearchChar = searchTerm[currentSearchIndex];
int firstIndex = -1;
for (int j = startIndex; j < searchTextLength; j++)
{
var textChar = text[j];
if (char.IsWhiteSpace(textChar))
continue;
if (text[j] != currentSearchChar)
{
currentSearchIndex = 0;
currentSearchChar = searchTerm[0];
firstIndex = -1;
continue;
}
if (firstIndex == -1)
firstIndex = j;
++currentSearchIndex;
if (currentSearchIndex == searchTermLength)
{
indexFollowingSearchTerm = searchResultIndex switch
{
SearchResultIndex.BeforeTerm => firstIndex - 1,
SearchResultIndex.AfterTerm => j + 1,
SearchResultIndex.FirstIndex => firstIndex,
SearchResultIndex.LastIndex => j,
_ => throw new ArgumentOutOfRangeException(nameof(searchResultIndex))
};
indexFollowingSearchTerm = j + 1;
return true;
}
currentSearchChar = searchTerm[currentSearchIndex];
}
indexFollowingSearchTerm = -1;
return false;
}
public static string RemoveWhitespaceFrom(string str)
{
var strLength = str.Length;
for (var i = 0; i < strLength; i++)
{
var c = str[i];
if (char.IsWhiteSpace(c))
{
str = str.Remove(c);
strLength = str.Length;
i = 0;
}
}
return str;
}
public static ReadOnlySpan<char> TrimStringToLineCount(ReadOnlySpan<char> message, int maxLines)
{
var messageLength = message.Length;
if (messageLength == 0)
return message;
int lineCount = 0;
int length = 0;
int nextStartIndex = 0;
while (lineCount < maxLines)
{
lineCount++;
var newlineIndex = message[nextStartIndex..].IndexOf('\n');
nextStartIndex += newlineIndex + 1;
if (newlineIndex == -1 || nextStartIndex == messageLength)
{
length = messageLength;
break;
}
length = nextStartIndex;
}
return message[..length];
}
/// <summary>
/// Returns a list of keys to retrieve Vector components from a dictionary.
///
/// This can ne useful to extract the x,y,z components from OSC addresses with successive names like "Position.X", "Position.Y", ""Position.Z".
/// Giving the key "Position.X" it will return all three keys. This also works for "Position1Key", "Position2Key",...
/// The keys are ordered before search.
/// If the keys differ in more than one character it will return false.
/// </summary>
public static bool TryUpdateVectorKeysRelatedToX(Dictionary<string,float>? dict, string? xKey, ref List<string> vectorKeys, int count)
{
if (dict == null || dict.Count < count || string.IsNullOrEmpty(xKey))
return false;
vectorKeys.Clear();
var justFoundX = false;
var keyCount = dict.Count;
var pool = ArrayPool<string>.Shared;
var keys = pool.Rent(keyCount);
int i = 0;
foreach (var key in dict.Keys)
keys[i++] = key;
Array.Sort(keys, 0, keyCount, StringComparer.Ordinal);
for (i = 0; i < keyCount; i++)
{
var key = keys[i];
if (key == xKey)
justFoundX = true;
if (!justFoundX)
continue;
if (--count >= 0 && CountDifferentChars(key, xKey) <= 1)
{
vectorKeys.Add(key);
}
else
{
break;
}
}
pool.Return(keys, clearArray: false);
return count < 0;
static int CountDifferentChars(string a, string b)
{
if (a.Length != b.Length)
return int.MaxValue;
int diffCount = 0;
for (int i = 0; i < a.Length; i++)
if (a[i] != b[i])
diffCount++;
return diffCount;
}
}
public static int IndexOfNot(this ReadOnlySpan<char> span, char c, bool ignoreCase, out char nextChar)
{
if (ignoreCase)
{
c = char.ToLowerInvariant(c);
for (var i = 0; i < span.Length; i++)
{
nextChar = span[i];
if (char.ToLowerInvariant(nextChar) != c)
{
return i;
}
}
nextChar = default;
return -1;
}
for (var i = 0; i < span.Length; i++)
{
nextChar = span[i];
if (nextChar != c)
return i;
}
nextChar = default;
return -1;
}
public static int LineCount(this string input)
{
if (string.IsNullOrEmpty(input))
return 0;
int count = 1; // At least one line if the string is not empty
for (int i = 0; i < input.Length; i++)
{
if (input[i] == '\n')
count++;
}
return count;
}
public static int IndexOf(this ReadOnlySpan<char> span, char c, bool ignoreCase)
{
if (ignoreCase)
{
c = char.ToLowerInvariant(c);
for (var i = 0; i < span.Length; i++)
{
if (char.ToLowerInvariant(span[i]) == c)
return i;
}
return -1;
}
for (var i = 0; i < span.Length; i++)
{
if (span[i] == c)
return i;
}
return -1;
}
public static int LastIndexOf(this ReadOnlySpan<char> span, char c, bool ignoreCase)
{
if (ignoreCase)
{
c = char.ToLowerInvariant(c);
for (var i = span.Length - 1; i >= 0; i--)
{
if (char.ToLowerInvariant(span[i]) == c)
return i;
}
return -1;
}
for (var i = span.Length - 1; i >= 0; i--)
{
if (span[i] == c)
return i;
}
return -1;
}
public static int LastIndexOfNot(this ReadOnlySpan<char> span, char c, bool ignoreCase, out char precedingChar)
{
if (ignoreCase)
{
c = char.ToLowerInvariant(c);
for (var i = span.Length - 1; i >= 0; i--)
{
precedingChar = span[i];
;
if (char.ToLowerInvariant(precedingChar) != c)
return i;
}
precedingChar = default;
return -1;
}
for (var i = span.Length - 1; i >= 0; i--)
{
precedingChar = span[i];
if (precedingChar != c)
return i;
}
precedingChar = default;
return -1;
}
/// <summary>
/// A naive implementation of a filtering algorithm that supports wildcards ('*').
///
/// It is designed to be highly optimized, but it may not behave how you expect.
/// This will accept an infinite amount of wildcards and treat them all the same -
/// so "a*b**c" will match "a/b/anything/c" (expected) and "a/anything/b/c" (possibly unexpected) -
/// there is no special directory treatment as is standard in most file search implementations.
///
/// Technically, the search begins from the end of the filter and the end of the possible match, and works backwards.
///
/// This is mostly intended for use in file path searches, where the end of the path is the most likely to be the most specific,
/// and the end of the search term is most likely to change with consecutive calls.
/// </summary>
/// <param name="possibleMatch">string you want to check for a match</param>
/// <param name="filter">The filter to match against</param>
/// <param name="ignoreCase"></param>
/// <returns>True if the provided string matches the provided filter</returns>
public static bool MatchesSearchFilter(ReadOnlySpan<char> possibleMatch, ReadOnlySpan<char> filter, bool ignoreCase)
{
while (filter.Length > 0)
{
// the possible match has been exhausted but the filter has not
if (possibleMatch.Length == 0)
return false;
var nextFilterChar = filter[^1];
if (nextFilterChar == '*')
{
var nextNonWildcardIndex = filter.LastIndexOfNot('*', ignoreCase, out nextFilterChar);
if (nextNonWildcardIndex == -1)
return true;
var matchIndex = possibleMatch.LastIndexOf(nextFilterChar, ignoreCase);
if (matchIndex == -1)
return false;
// remove the last character from both strings to continue the search
filter = filter[..nextNonWildcardIndex];
possibleMatch = possibleMatch[..matchIndex];
}
else if (possibleMatch[^1] == nextFilterChar)
{
// remove the last character from both strings to continue the search
possibleMatch = possibleMatch[..^1];
filter = filter[..^1];
}
else
{
// no match /:
return false;
}
}
// we finished the filter - it's a match!
return true;
}
public static unsafe void ReplaceCharUnsafe(this string str, char toReplace, char replacement)
{
fixed (char* strPtr = str)
{
for (int i = 0; i < str.Length; i++)
{
if (strPtr[i] == toReplace)
strPtr[i] = replacement;
}
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void ToForwardSlashesUnsafe(this string str)
{
str.ReplaceCharUnsafe('\\', '/');
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static string ToForwardSlashes(this string str) => str.Replace('\\', '/');
public static string Truncate(this string? input, int maxLength = 10)
{
if (input == null)
return "null";
if (input.Length < maxLength)
{
return input;
}
return input[..Math.Min(input.Length, maxLength)] + "...";
}
public static string HumanReadableDurationFromSeconds(double seconds)
{
return $"{(int)(seconds / 60 / 60):00}:{(seconds / 60) % 60:00}:{seconds % 60:00}";
}
public static string GetReadableRelativeTime(this DateTime? time)
{
if (time == null)
return "Unknown time";
var timeSpan = DateTime.Now - time;
return GetReadableRelativeTime(timeSpan);
}
public static string GetReadableRelativeTime(double time)
{
return GetReadableRelativeTime(TimeSpan.FromSeconds(time));
}
public static string GetReadableFileSize(long size)
{
var gb = 1024 * 1024 * 1024;
var mb = 1024 * 1024;
if (size > gb)
{
// ReSharper disable once PossibleLossOfFraction
return $"{size * 100 / gb * 0.01f:0.00} Gb";
}
if (size > mb)
{
// ReSharper disable once PossibleLossOfFraction
return $"{size * 100 / mb * 0.01f:0.00} Mb";
}
return $"{size} bytes";
}
private static string GetReadableRelativeTime([DisallowNull] TimeSpan? timeSpan)
{
var seconds = timeSpan.Value.TotalSeconds;
if (seconds < 60)
{
return "seconds ago";
}
var minutes = timeSpan.Value.TotalMinutes;
if (minutes < 120)
{
return $"{minutes:0} min ago";
}
var hours = timeSpan.Value.TotalHours;
if (hours < 30)
{
return $"{hours:0.0} h ago";
}
var days = timeSpan.Value.TotalDays;
return $"{days:0.0} days ago";
}
private static readonly Regex _matchFileVersionPattern = new(@"\b(\d{1,4})\b");
public static string AppendOrIncrementVersionNumber(this string original)
{
if (string.IsNullOrEmpty(original))
return original;
var result = _matchFileVersionPattern.Match(original);
if (result.Success)
{
var versionString = result.Groups[1].Value;
if (!int.TryParse(versionString, out var versionNumber))
return original;
var newVersionString = (versionNumber + 1).ToString();
return original.Replace(versionString, newVersionString);
}
return original + " 2";
}
/// <summary>
/// Parse a string like...
///
/// Failed to update shader "PixelShader_PixelShaderFromSource (5e6d5cb9-df8f-494d-b50e-31499b257f34)" in package "Types":
/// Failed to compile shader 'PixelShader_PixelShaderFromSource (5e6d5cb9-df8f-494d-b50e-31499b257f34)'.
/// Line 37,31: error X3000: unrecognized identifier 'x'
/// </summary>
public static List<string> ParseShaderCompilationError(string log)
{
List<string> results = new();
ReadOnlySpan<char> logSpan = log;
ReadOnlySpan<char> separator = ": ";
while (!logSpan.IsEmpty)
{
var newlineIndex = logSpan.IndexOf('\n');
var line = newlineIndex >= 0 ? logSpan[..newlineIndex] : logSpan;
line = line.TrimStart();
if (line.StartsWith("Line "))
{
var commaIndex = line.IndexOf(',');
if (commaIndex > 5 && int.TryParse(line.Slice(5, commaIndex - 5), out var lineNumber))
{
// Find the error message after ": "
var firstColonIndex = line.IndexOf(": ");
if (firstColonIndex >= 0)
{
var t = firstColonIndex + separator.Length;
var secondColonIndex = line[t..].IndexOf(separator) + t;
if (secondColonIndex >= 0 && secondColonIndex + 2 < line.Length)
{
var errorMessage = line.Slice(secondColonIndex + 2);
// Skip the error code (first word)
var spaceIndex = errorMessage.IndexOf(' ');
if (spaceIndex >= 0 && spaceIndex + 1 < errorMessage.Length)
{
var errorString = new string(errorMessage.Slice(spaceIndex + 1)); // Minimal allocation
results.Add($"Line {lineNumber}: {errorString}");
}
}
}
}
}
if (newlineIndex < 0) break;
logSpan = logSpan[(newlineIndex + 1)..]; // Move to next line
}
return results;
}
public static int LevenshteinDistance(ReadOnlySpan<char> s, ReadOnlySpan<char> t)
{
if (s.Length == 0) return t.Length;
if (t.Length == 0) return s.Length;
// Use two rows to save memory
var v0 = new int[t.Length + 1];
var v1 = new int[t.Length + 1];
for (var i = 0; i <= t.Length; i++)
v0[i] = i;
for (var i = 0; i < s.Length; i++)
{
v1[0] = i + 1;
for (var j = 0; j < t.Length; j++)
{
var cost = (s[i] == t[j]) ? 0 : 1;
v1[j + 1] = Math.Min(v1[j] + 1, Math.Min(v0[j + 1] + 1, v0[j] + cost));
}
Array.Copy(v1, v0, v0.Length);
}
return v0[t.Length];
}
public static string ShortenGuid(this Guid guid, int length = 7)
{
if (length < 1 || length > 22)
throw new ArgumentOutOfRangeException(nameof(length), "Length must be between 1 and 22.");
var guidBytes = guid.ToByteArray();
var base64 = Convert.ToBase64String(guidBytes);
var alphanumeric = base64.Replace("+", "").Replace("/", "").Replace("=", "");
return alphanumeric[..length];
}
public static string ToValidClassName(this string input, string fallback = "ClassName")
{
if (string.IsNullOrWhiteSpace(input))
return fallback;
var cleaned = Regex.Replace(input, @"[^a-zA-Z0-9_]", "");
if (char.IsDigit(cleaned, 0))
cleaned = "_" + cleaned;
return string.IsNullOrEmpty(cleaned) ? "ClassName" : cleaned;
}
public static ReadOnlySpan<char> SliceToProgress(string text, float progress)
{
if (string.IsNullOrEmpty(text))
return ReadOnlySpan<char>.Empty;
progress = Math.Clamp(progress, 0f, 1f);
// floor is usually what you want for a typewriter reveal
var len = (int)(text.Length * progress);
if ((uint)len > (uint)text.Length)
len = text.Length;
return text.AsSpan(0, len);
}
/// <summary>
/// </summary>
public static Guid GenerateGuidFromString(this string name)
{
if (string.IsNullOrEmpty(name))
return Guid.Empty;
// Use MD5 to create a deterministic 16-byte hash from the name
using var md5 = System.Security.Cryptography.MD5.Create();
byte[] hash = md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(name));
return new Guid(hash);
}
}