Skip to content

Commit

Permalink
Support escaping with quote marks in both ReadOnlySpanExtensions.TryR…
Browse files Browse the repository at this point in the history
…eplaceNonEscaped and TimeSpanHFormat
  • Loading branch information
ScrubN committed Nov 12, 2023
1 parent 412bde5 commit da76fcc
Show file tree
Hide file tree
Showing 2 changed files with 129 additions and 36 deletions.
77 changes: 62 additions & 15 deletions TwitchDownloaderCore/Extensions/ReadOnlySpanExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,44 +4,91 @@ namespace TwitchDownloaderCore.Extensions
{
public static class ReadOnlySpanExtensions
{
/// <summary>Replaces all occurrences of <paramref name="oldChar"/> not prepended by a backslash with <paramref name="newChar"/>.</summary>
public static bool TryReplaceNonEscaped(this ReadOnlySpan<char> str, Span<char> destination, out int charsWritten, char oldChar, char newChar)
/// <summary>Replaces all occurrences of <paramref name="oldChar"/> not prepended by a backslash or contained within quotation marks with <paramref name="newChar"/>.</summary>
public static bool TryReplaceNonEscaped(this ReadOnlySpan<char> str, Span<char> destination, char oldChar, char newChar)
{
const string ESCAPE_CHARS = @"\'""";

if (destination.Length < str.Length)
{
charsWritten = 0;
return false;
}

str.CopyTo(destination);
charsWritten = str.Length;

var firstIndex = destination.IndexOf(oldChar);

if (firstIndex == -1)
{
return true;
}

firstIndex = Math.Min(firstIndex, destination.IndexOf('\\'));
var firstEscapeIndex = destination.IndexOfAny(ESCAPE_CHARS);
if (firstEscapeIndex != -1 && firstEscapeIndex < firstIndex)
firstIndex = firstEscapeIndex;

for (var i = firstIndex; i < str.Length; i++)
var lastIndex = destination.LastIndexOf(oldChar);
var lastEscapeIndex = destination.LastIndexOfAny(ESCAPE_CHARS);
if (lastEscapeIndex != -1 && lastEscapeIndex > lastIndex)
lastIndex = lastEscapeIndex;

lastIndex++;
for (var i = firstIndex; i < lastIndex; i++)
{
var readChar = destination[i];

if (readChar == '\\' && i + 1 < str.Length)
switch (readChar)
{
case '\\':
i++;
break;
case '\'':
case '\"':
{
i = FindCloseQuoteMark(destination, i, lastIndex, readChar);

if (i == -1)
{
destination.Clear();
return false;
}

break;
}
default:
{
if (readChar == oldChar)
{
destination[i] = newChar;
}

break;
}
}
}

return true;
}

private static int FindCloseQuoteMark(ReadOnlySpan<char> destination, int openQuoteIndex, int endIndex, char readChar)
{
var i = openQuoteIndex + 1;
var quoteFound = false;
while (i < endIndex)
{
var readCharQuote = destination[i];
i++;

if (readCharQuote == '\\')
{
i++;
continue;
}

if (readChar == oldChar)
if (readCharQuote == readChar)
{
destination[i] = newChar;
i--;
quoteFound = true;
break;
}
}

return true;
return quoteFound ? i : -1;
}
}
}
88 changes: 67 additions & 21 deletions TwitchDownloaderCore/Tools/TimeSpanHFormat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,76 +49,121 @@ public string Format(string format, TimeSpan timeSpan, IFormatProvider formatPro
if (timeSpan.Days == 0)
{
var newFormat = format.Length <= 256 ? stackalloc char[format.Length] : new char[format.Length];
if (!format.AsSpan().TryReplaceNonEscaped(newFormat, out var charsWritten, 'H', 'h'))
if (!format.AsSpan().TryReplaceNonEscaped(newFormat, 'H', 'h'))
{
throw new Exception("Failed to generate ToString() compatible format. This should not have been possible.");
throw new FormatException($"Invalid character escaping in the format string: {format}");
}

// If the format contains more than 2 sequential unescaped h's, it will throw a format exception. If so, we can fallback to our parser.
if (newFormat.IndexOf("hhh") == -1)
{
return HandleOtherFormats(newFormat[..charsWritten].ToString(), timeSpan, formatProvider);
return HandleOtherFormats(newFormat.ToString(), timeSpan, formatProvider);
}
}

var sb = new StringBuilder(format.Length);
return HandleBigHFormat(format.AsSpan(), timeSpan);
}

private static string HandleBigHFormat(ReadOnlySpan<char> format, TimeSpan timeSpan)
{
var formatLength = format.Length;
var sb = new StringBuilder(formatLength);
var regularFormatCharStart = -1;
var bigHStart = -1;
var formatSpan = format.AsSpan();
for (var i = 0; i < formatSpan.Length; i++)
for (var i = 0; i < formatLength; i++)
{
var readChar = formatSpan[i];
var readChar = format[i];

if (readChar == 'H')
{
if (bigHStart == -1)
{
bigHStart = i;
}

if (regularFormatCharStart != -1)
{
AppendRegularFormat(sb, timeSpan, format, regularFormatCharStart, i - regularFormatCharStart);
var formatEnd = i - regularFormatCharStart;
AppendRegularFormat(sb, timeSpan, format.Slice(regularFormatCharStart, formatEnd));
regularFormatCharStart = -1;
}
}
else
{
if (regularFormatCharStart == -1)
{
regularFormatCharStart = i;
}

if (bigHStart != -1)
{
AppendBigHFormat(sb, timeSpan, i - bigHStart);
var bigHCount = i - bigHStart;
AppendBigHFormat(sb, timeSpan, bigHCount);
bigHStart = -1;
}

// If the current char is an escape we can skip the next char
if (readChar == '\\' && i + 1 < formatSpan.Length)
switch (readChar)
{
i++;
// If the current char is an escape we can skip the next char
case '\\' when i + 1 < formatLength:
i++;
continue;
// If the current char is a quote we can skip the next quote, if it exists
case '\'' when i + 1 < formatLength:
case '\"' when i + 1 < formatLength:
{
i = FindCloseQuoteMark(format, i, formatLength, readChar);

if (i == -1)
{
throw new FormatException($"Invalid character escaping in the format string: {format}");
}

continue;
}
}
}
}

if (regularFormatCharStart != -1)
{
AppendRegularFormat(sb, timeSpan, format, regularFormatCharStart, formatSpan.Length - regularFormatCharStart);
var formatEnd = format.Length - regularFormatCharStart;
AppendRegularFormat(sb, timeSpan, format.Slice(regularFormatCharStart, formatEnd));
}
else if (bigHStart != -1)
{
AppendBigHFormat(sb, timeSpan, formatSpan.Length - bigHStart);
var bigHCount = format.Length - bigHStart;
AppendBigHFormat(sb, timeSpan, bigHCount);
}

return sb.ToString();
}

private static void AppendRegularFormat(StringBuilder sb, TimeSpan timeSpan, string formatString, int start, int length)
private static int FindCloseQuoteMark(ReadOnlySpan<char> format, int openQuoteIndex, int endIndex, char readChar)
{
var i = openQuoteIndex + 1;
var quoteFound = false;
while (i < endIndex)
{
var readCharQuote = format[i];
i++;

if (readCharQuote == '\\')
{
i++;
continue;
}

if (readCharQuote == readChar)
{
i--;
quoteFound = true;
break;
}
}

return quoteFound ? i : -1;
}

private static void AppendRegularFormat(StringBuilder sb, TimeSpan timeSpan, ReadOnlySpan<char> format)
{
Span<char> destination = stackalloc char[256];
var format = formatString.AsSpan(start, length);

if (timeSpan.TryFormat(destination, out var charsWritten, format))
{
Expand All @@ -132,7 +177,8 @@ private static void AppendRegularFormat(StringBuilder sb, TimeSpan timeSpan, str

private static void AppendBigHFormat(StringBuilder sb, TimeSpan timeSpan, int count)
{
Span<char> destination = stackalloc char[8];
const int TIMESPAN_MAX_HOURS_LENGTH = 9; // The maximum integer hours a TimeSpan can hold is 256204778.
Span<char> destination = stackalloc char[TIMESPAN_MAX_HOURS_LENGTH];
Span<char> format = stackalloc char[count];
format.Fill('0');

Expand Down

0 comments on commit da76fcc

Please sign in to comment.