Skip to content
2 changes: 1 addition & 1 deletion samples/ZaString.Demo/ZaString.Demo.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
Expand Down
71 changes: 71 additions & 0 deletions src/ZaString/Core/ZaPooledStringBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Buffers;
using System.Globalization;
using System.Text;
using ZaString.Escaping;

namespace ZaString.Core;

Expand Down Expand Up @@ -94,6 +95,31 @@ public void RemoveLast(int count)
Length -= count;
}

/// <summary>
/// Reserves a writable span of the specified size, growing the rented buffer if needed.
/// Call <see cref="Advance" /> with the number of characters written to commit the append.
/// </summary>
public ZaPooledStringBuilder GetAppendSpan(int size, out Span<char> writeSpan)
{
ThrowIfDisposed();
ArgumentOutOfRangeException.ThrowIfNegative(size);

EnsureCapacity(size);
writeSpan = _buffer.AsSpan(Length, size);
return this;
}

public void Advance(int count)
{
ThrowIfDisposed();
if ((uint)count > (uint)(_buffer.Length - Length))
{
throw new ArgumentOutOfRangeException(nameof(count));
}

Length += count;
}

/// <summary>
/// Gets or sets the character at the specified index.
/// </summary>
Expand Down Expand Up @@ -233,6 +259,51 @@ public ZaPooledStringBuilder Append(bool value)
return Append(value ? "true" : "false");
}

public ZaPooledStringBuilder AppendJsonEscaped(ReadOnlySpan<char> value)
{
var required = JsonEscapeStrategy.GetEscapedLength(value);
GetAppendSpan(required, out var destination);
JsonEscapeStrategy.TryEscape(value, destination, out var written);
Advance(written);
return this;
}
Comment on lines +262 to +269

public ZaPooledStringBuilder AppendHtmlEscaped(ReadOnlySpan<char> value)
{
var required = HtmlEscapeStrategy.GetEscapedLength(value);
GetAppendSpan(required, out var destination);
HtmlEscapeStrategy.TryEscape(value, destination, out var written);
Advance(written);
return this;
}
Comment on lines +271 to +278

public ZaPooledStringBuilder AppendCsvEscaped(ReadOnlySpan<char> value)
{
var required = CsvEscapeStrategy.GetEscapedLength(value);
GetAppendSpan(required, out var destination);
CsvEscapeStrategy.TryEscape(value, destination, out var written);
Advance(written);
return this;
}
Comment on lines +280 to +287

public ZaPooledStringBuilder AppendUrlEncoded(ReadOnlySpan<char> value)
{
var required = UrlEscapeStrategy.GetEscapedLength(value);
GetAppendSpan(required, out var destination);
UrlEscapeStrategy.TryEscape(value, destination, out var written);
Advance(written);
return this;
}
Comment on lines +289 to +296

public ZaPooledStringBuilder AppendFormUrlEncoded(ReadOnlySpan<char> value)
{
var required = FormUrlEscapeStrategy.GetEscapedLength(value);
GetAppendSpan(required, out var destination);
FormUrlEscapeStrategy.TryEscape(value, destination, out var written);
Advance(written);
return this;
}
Comment on lines +298 to +305

public ZaPooledStringBuilder Append<T>(T value, ReadOnlySpan<char> format = default, IFormatProvider? provider = null) where T : ISpanFormattable
{
ThrowIfDisposed();
Expand Down
75 changes: 75 additions & 0 deletions src/ZaString/Escaping/CsvEscapeStrategy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
namespace ZaString.Escaping;

/// <summary>
/// Provides span-to-span CSV field escaping without intermediate string allocation.
/// </summary>
public static class CsvEscapeStrategy
{
public static int GetEscapedLength(ReadOnlySpan<char> value)
{
if (!NeedsQuoting(value))
{
return value.Length;
}

var quoteCount = 0;
foreach (var c in value)
{
if (c == '"')
{
quoteCount++;
}
}

return value.Length + quoteCount + 2;
}

public static bool TryEscape(ReadOnlySpan<char> value, Span<char> destination, out int written)
{
var required = GetEscapedLength(value);
if (required > destination.Length)
{
written = 0;
return false;
}

written = Escape(value, destination);
return true;
}

private static int Escape(ReadOnlySpan<char> value, Span<char> destination)
{
if (!NeedsQuoting(value))
{
value.CopyTo(destination);
return value.Length;
}

var w = 0;
destination[w++] = '"';
foreach (var c in value)
{
destination[w++] = c;
if (c == '"')
{
destination[w++] = '"';
}
}

destination[w++] = '"';
return w;
}

private static bool NeedsQuoting(ReadOnlySpan<char> value)
{
if (value.Length == 0) return true;
if (char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[^1])) return true;

foreach (var c in value)
{
if (c is ',' or '"' or '\n' or '\r') return true;
}

return false;
}
}
100 changes: 100 additions & 0 deletions src/ZaString/Escaping/FormUrlEscapeStrategy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
namespace ZaString.Escaping;

/// <summary>
/// Provides span-to-span form URL encoding without intermediate string allocation.
/// </summary>
public static class FormUrlEscapeStrategy
{
public static int GetEscapedLength(ReadOnlySpan<char> value)
{
var length = 0;
for (var i = 0; i < value.Length; i++)
{
var c = value[i];
if (c == ' ')
{
length += 1;
}
else if (c <= 0x7F)
{
length += UrlEscapeStrategy.IsUnreservedAscii(c) ? 1 : 3;
}
else if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1]))
{
length += 4 * 3;
i++;
}
else
{
length += 9;
}
}

return length;
}

public static bool TryEscape(ReadOnlySpan<char> value, Span<char> destination, out int written)
{
var required = GetEscapedLength(value);
if (required > destination.Length)
{
written = 0;
return false;
}

written = Escape(value, destination);
return true;
}

private static int Escape(ReadOnlySpan<char> value, Span<char> destination)
{
var w = 0;
for (var i = 0; i < value.Length; i++)
{
var c = value[i];
if (c == ' ')
{
destination[w++] = '+';
}
else if (c <= 0x7F)
{
if (UrlEscapeStrategy.IsUnreservedAscii(c))
{
destination[w++] = c;
}
else
{
destination[w++] = '%';
UrlEscapeStrategy.WriteHexByte((byte)c, destination.Slice(w, 2));
w += 2;
}
}
else if (char.IsHighSurrogate(c) && i + 1 < value.Length && char.IsLowSurrogate(value[i + 1]))
{
var low = value[++i];
var codePoint = 0x10000 + (c - 0xD800 << 10 | low - 0xDC00);
w += UrlEscapeStrategy.PercentEncodeUtf8FromCodePoint(codePoint, destination[w..]);
}
else
{
w += WriteReplacementChar(destination[w..]);
}
}

return w;
}

private static int WriteReplacementChar(Span<char> destination)
{
destination[0] = '%';
destination[1] = 'E';
destination[2] = 'F';
destination[3] = '%';
destination[4] = 'B';
destination[5] = 'F';
destination[6] = '%';
destination[7] = 'B';
destination[8] = 'D';
return 9;
}
}
88 changes: 88 additions & 0 deletions src/ZaString/Escaping/HtmlEscapeStrategy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
namespace ZaString.Escaping;

/// <summary>
/// Provides span-to-span HTML escaping without intermediate string allocation.
/// </summary>
public static class HtmlEscapeStrategy
{
public static int GetEscapedLength(ReadOnlySpan<char> value)
{
var extra = 0;
foreach (var c in value)
{
switch (c)
{
case '&': extra += 4; break;
case '<':
case '>': extra += 3; break;
case '"': extra += 5; break;
case '\'': extra += 4; break;
}
}

return value.Length + extra;
}

public static bool TryEscape(ReadOnlySpan<char> value, Span<char> destination, out int written)
{
var required = GetEscapedLength(value);
if (required > destination.Length)
{
written = 0;
return false;
}

written = Escape(value, destination);
return true;
}

private static int Escape(ReadOnlySpan<char> value, Span<char> destination)
{
var w = 0;
foreach (var c in value)
{
switch (c)
{
case '&':
destination[w++] = '&';
destination[w++] = 'a';
destination[w++] = 'm';
destination[w++] = 'p';
destination[w++] = ';';
break;
case '<':
destination[w++] = '&';
destination[w++] = 'l';
destination[w++] = 't';
destination[w++] = ';';
break;
case '>':
destination[w++] = '&';
destination[w++] = 'g';
destination[w++] = 't';
destination[w++] = ';';
break;
case '"':
destination[w++] = '&';
destination[w++] = 'q';
destination[w++] = 'u';
destination[w++] = 'o';
destination[w++] = 't';
destination[w++] = ';';
break;
case '\'':
destination[w++] = '&';
destination[w++] = '#';
destination[w++] = '3';
destination[w++] = '9';
destination[w++] = ';';
break;
default:
destination[w++] = c;
break;
}
}

return w;
}
}
Loading