From f3ce71a2c72e6658cfecec60cc64f09baada8cb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabr=C3=ADcio=20Godoy?= Date: Wed, 13 Dec 2023 21:03:16 -0300 Subject: [PATCH] feat: support string and string builder interpolation (#114) --- api_list.include.md | 4 + readme.md | 13 +- src/Consume/Consume.csproj | 3 + .../ConsumeClassicReferences.csproj | 3 + src/ConsumeIndirect/ConsumeIndirect.csproj | 3 + src/ConsumeNoRefs/ConsumeNoRefs.csproj | 3 + src/NoRefsTests/NoRefsTests.csproj | 3 + src/Polyfill/Polyfill.nuspec | 2 + src/Polyfill/Polyfill.targets | 13 + src/Polyfill/PolyfillExtensions_String.cs | 2 +- .../PolyfillExtensions_StringBuilder.cs | 95 +- .../AppendInterpolatedStringHandler.cs | 348 ++++++++ .../DefaultInterpolatedStringHandler.cs | 814 ++++++++++++++++++ .../StringInterpolation/ISpanFormattable.cs | 32 + ...erpolatedStringHandlerArgumentAttribute.cs | 0 .../InterpolatedStringHandlerAttribute.cs | 0 src/PublicTests/PublicTests.csproj | 3 + src/Tests/SanityChecks.cs | 4 +- src/Tests/StringInterpolationTests.cs | 28 + src/Tests/Tests.csproj | 3 + src/UnsafeTests/UnsafeTests.csproj | 3 + 21 files changed, 1368 insertions(+), 11 deletions(-) create mode 100644 src/Polyfill/StringInterpolation/AppendInterpolatedStringHandler.cs create mode 100644 src/Polyfill/StringInterpolation/DefaultInterpolatedStringHandler.cs create mode 100644 src/Polyfill/StringInterpolation/ISpanFormattable.cs rename src/Polyfill/{ => StringInterpolation}/InterpolatedStringHandlerArgumentAttribute.cs (100%) rename src/Polyfill/{ => StringInterpolation}/InterpolatedStringHandlerAttribute.cs (100%) create mode 100644 src/Tests/StringInterpolationTests.cs diff --git a/api_list.include.md b/api_list.include.md index fa8c9163..7b66f248 100644 --- a/api_list.include.md +++ b/api_list.include.md @@ -209,6 +209,10 @@ ### StringBuilder * `StringBuilder Append(ReadOnlySpan)` [reference](https://learn.microsoft.com/en-us/dotnet/api/system.text.stringbuilder.append#system-text-stringbuilder-append(system-readonlyspan((system-char)))) + * `StringBuilder Append(AppendInterpolatedStringHandler&)` [reference](https://learn.microsoft.com/en-us/dotnet/api/system.text.stringbuilder.append#system-text-stringbuilder-append(system-text-stringbuilder-appendinterpolatedstringhandler@)) + * `StringBuilder Append(IFormatProvider, AppendInterpolatedStringHandler&)` [reference](https://learn.microsoft.com/en-us/dotnet/api/system.text.stringbuilder.append#system-text-stringbuilder-append(system-iformatprovider-system-text-stringbuilder-appendinterpolatedstringhandler@)) + * `StringBuilder AppendLine(AppendInterpolatedStringHandler&)` [reference](https://learn.microsoft.com/en-us/dotnet/api/system.text.stringbuilder.appendline#system-text-stringbuilder-appendline(system-text-stringbuilder-appendinterpolatedstringhandler@)) + * `StringBuilder AppendLine(IFormatProvider, AppendInterpolatedStringHandler&)` [reference](https://learn.microsoft.com/en-us/dotnet/api/system.text.stringbuilder.appendline#system-text-stringbuilder-appendline(system-iformatprovider-system-text-stringbuilder-appendinterpolatedstringhandler@)) * `Void CopyTo(Int32, Span, Int32)` [reference](https://learn.microsoft.com/en-us/dotnet/api/system.text.stringbuilder.copyto#system-text-stringbuilder-copyto(system-int32-system-span((system-char))-system-int32)) * `Boolean Equals(ReadOnlySpan)` [reference](https://learn.microsoft.com/en-us/dotnet/api/system.text.stringbuilder.equals#system-text-stringbuilder-equals(system-readonlyspan((system-char)))) diff --git a/readme.md b/readme.md index 67c28d26..dbd8ec89 100644 --- a/readme.md +++ b/readme.md @@ -67,7 +67,7 @@ Then all consuming projects, like tests, will not need to use the Polyfill nuget If Polyfill is being consumed in a solution that produce a library (and usually a nuget), then the Polyfill nuget can be added to all projects. -If, however, `InternalsVisibileTo` is being used to expose APIs (for example to test projects), then the Polyfill nuget should be added only to the root library project. +If, however, `InternalsVisibleTo` is being used to expose APIs (for example to test projects), then the Polyfill nuget should be added only to the root library project. ## Included polyfills @@ -283,10 +283,13 @@ static class GuardUsage ### InterpolatedStringHandler + * [AppendInterpolatedStringHandler](https://learn.microsoft.com/en-us/dotnet/api/system.text.stringbuilder.appendinterpolatedstringhandler) + * [DefaultInterpolatedStringHandler](https://learn.microsoft.com/en-us/dotnet/api/system.runtime.compilerservices.defaultinterpolatedstringhandler) * [InterpolatedStringHandlerAttribute](https://learn.microsoft.com/en-us/dotnet/api/system.runtime.compilerservices.interpolatedstringhandlerattribute) * [InterpolatedStringHandlerArgumentAttribute](https://learn.microsoft.com/en-us/dotnet/api/system.runtime.compilerservices.interpolatedstringhandlerargumentattribute) + * [ISpanFormattable](https://learn.microsoft.com/en-us/dotnet/api/system.ispanformattable) -Reference: [Write a custom string interpolation handler](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/tutorials/interpolated-string-handler) +References: [String Interpolation in C# 10 and .NET 6](https://devblogs.microsoft.com/dotnet/string-interpolation-in-c-10-and-net-6/), [Write a custom string interpolation handler](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/tutorials/interpolated-string-handler) ### StringSyntaxAttribute @@ -558,9 +561,15 @@ The class `PolyfillExtensions` includes the following extension methods: ### StringBuilder * `StringBuilder Append(ReadOnlySpan)` [reference](https://learn.microsoft.com/en-us/dotnet/api/system.text.stringbuilder.append#system-text-stringbuilder-append(system-readonlyspan((system-char)))) + * `StringBuilder Append(AppendInterpolatedStringHandler&)` [reference](https://learn.microsoft.com/en-us/dotnet/api/system.text.stringbuilder.append#system-text-stringbuilder-append(system-text-stringbuilder-appendinterpolatedstringhandler@)) + * `StringBuilder Append(IFormatProvider, AppendInterpolatedStringHandler&)` [reference](https://learn.microsoft.com/en-us/dotnet/api/system.text.stringbuilder.append#system-text-stringbuilder-append(system-iformatprovider-system-text-stringbuilder-appendinterpolatedstringhandler@)) + * `StringBuilder AppendLine(AppendInterpolatedStringHandler&)` [reference](https://learn.microsoft.com/en-us/dotnet/api/system.text.stringbuilder.appendline#system-text-stringbuilder-appendline(system-text-stringbuilder-appendinterpolatedstringhandler@)) + * `StringBuilder AppendLine(IFormatProvider, AppendInterpolatedStringHandler&)` [reference](https://learn.microsoft.com/en-us/dotnet/api/system.text.stringbuilder.appendline#system-text-stringbuilder-appendline(system-iformatprovider-system-text-stringbuilder-appendinterpolatedstringhandler@)) * `Void CopyTo(Int32, Span, Int32)` [reference](https://learn.microsoft.com/en-us/dotnet/api/system.text.stringbuilder.copyto#system-text-stringbuilder-copyto(system-int32-system-span((system-char))-system-int32)) * `Boolean Equals(ReadOnlySpan)` [reference](https://learn.microsoft.com/en-us/dotnet/api/system.text.stringbuilder.equals#system-text-stringbuilder-equals(system-readonlyspan((system-char)))) +> [!IMPORTANT] +> The methods using `AppendInterpolatedStringHandler` parameter are not extensions because the compiler prefers to use the overload with `string` parameter instead. ### CancellationToken diff --git a/src/Consume/Consume.csproj b/src/Consume/Consume.csproj index 0cc26315..1a628e17 100644 --- a/src/Consume/Consume.csproj +++ b/src/Consume/Consume.csproj @@ -21,6 +21,9 @@ Pollyfill\IndexRange\%(RecursiveDir)%(Filename).cs + + Pollyfill\StringInterpolation\%(RecursiveDir)%(Filename).cs + Pollyfill\Trimming\%(RecursiveDir)%(Filename).cs diff --git a/src/ConsumeClassicReferences/ConsumeClassicReferences.csproj b/src/ConsumeClassicReferences/ConsumeClassicReferences.csproj index 113f07e8..c609c5b0 100644 --- a/src/ConsumeClassicReferences/ConsumeClassicReferences.csproj +++ b/src/ConsumeClassicReferences/ConsumeClassicReferences.csproj @@ -22,6 +22,9 @@ Pollyfill\IndexRange\%(RecursiveDir)%(Filename).cs + + Pollyfill\StringInterpolation\%(RecursiveDir)%(Filename).cs + Pollyfill\Trimming\%(RecursiveDir)%(Filename).cs diff --git a/src/ConsumeIndirect/ConsumeIndirect.csproj b/src/ConsumeIndirect/ConsumeIndirect.csproj index 726b4707..d0bd1013 100644 --- a/src/ConsumeIndirect/ConsumeIndirect.csproj +++ b/src/ConsumeIndirect/ConsumeIndirect.csproj @@ -22,6 +22,9 @@ Pollyfill\IndexRange\%(RecursiveDir)%(Filename).cs + + Pollyfill\StringInterpolation\%(RecursiveDir)%(Filename).cs + Pollyfill\Trimming\%(RecursiveDir)%(Filename).cs diff --git a/src/ConsumeNoRefs/ConsumeNoRefs.csproj b/src/ConsumeNoRefs/ConsumeNoRefs.csproj index f8520598..cbbfe582 100644 --- a/src/ConsumeNoRefs/ConsumeNoRefs.csproj +++ b/src/ConsumeNoRefs/ConsumeNoRefs.csproj @@ -18,6 +18,9 @@ Pollyfill\IndexRange\%(RecursiveDir)%(Filename).cs + + Pollyfill\StringInterpolation\%(RecursiveDir)%(Filename).cs + Pollyfill\Trimming\%(RecursiveDir)%(Filename).cs diff --git a/src/NoRefsTests/NoRefsTests.csproj b/src/NoRefsTests/NoRefsTests.csproj index 3b81a1ce..88354bbf 100644 --- a/src/NoRefsTests/NoRefsTests.csproj +++ b/src/NoRefsTests/NoRefsTests.csproj @@ -16,6 +16,9 @@ Pollyfill\IndexRange\%(RecursiveDir)%(Filename).cs + + Pollyfill\StringInterpolation\%(RecursiveDir)%(Filename).cs + Pollyfill\Trimming\%(RecursiveDir)%(Filename).cs diff --git a/src/Polyfill/Polyfill.nuspec b/src/Polyfill/Polyfill.nuspec index 4dc1228f..9d340323 100644 --- a/src/Polyfill/Polyfill.nuspec +++ b/src/Polyfill/Polyfill.nuspec @@ -23,6 +23,8 @@ target="contentFiles/cs/netstandard2.0/Polyfill/Nullability"/> + $(DefineConstants);HTTPREFERENCED + + $(DefineConstants);HAS_SPAN + $(DefineConstants);HAS_SPAN + $(DefineConstants);HAS_SPAN + $(DefineConstants);HAS_SPAN + $(DefineConstants);HAS_SPAN + $(DefineConstants);HAS_SPAN + $(DefineConstants);HAS_SPAN + $(DefineConstants);HAS_SPAN + $(DefineConstants);HAS_SPAN + $(DefineConstants) + $(DefineConstants) + diff --git a/src/Polyfill/PolyfillExtensions_String.cs b/src/Polyfill/PolyfillExtensions_String.cs index 790dd9c5..2d885e2f 100644 --- a/src/Polyfill/PolyfillExtensions_String.cs +++ b/src/Polyfill/PolyfillExtensions_String.cs @@ -8,7 +8,7 @@ static partial class PolyfillExtensions { -#if (MEMORYREFERENCED && (NETFRAMEWORK || NETSTANDARD || NETCOREAPP)) || NET5_0 +#if HAS_SPAN && !NET6_0_OR_GREATER /// /// Copies the contents of this string into the destination span. diff --git a/src/Polyfill/PolyfillExtensions_StringBuilder.cs b/src/Polyfill/PolyfillExtensions_StringBuilder.cs index 901e13cb..1a64debd 100644 --- a/src/Polyfill/PolyfillExtensions_StringBuilder.cs +++ b/src/Polyfill/PolyfillExtensions_StringBuilder.cs @@ -2,18 +2,19 @@ #pragma warning disable -#if MEMORYREFERENCED && (NETFRAMEWORK || NETSTANDARD2_0 || NETCOREAPP2_0) - using System; using System.IO; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; -using Link = System.ComponentModel.DescriptionAttribute; using System.Threading; using System.Threading.Tasks; +using Link = System.ComponentModel.DescriptionAttribute; static partial class PolyfillExtensions { +#if HAS_SPAN && (!NETSTANDARD2_1_OR_GREATER && !NETCOREAPP2_1_OR_GREATER) + /// /// Copies the characters from a specified segment of this instance to a destination Char span. /// @@ -62,13 +63,13 @@ public static StringBuilder Append(this StringBuilder target, ReadOnlySpan #if AllowUnsafeBlocks unsafe { - fixed (char* valueChars = &MemoryMarshal.GetReference(value)) + fixed (char* valueChars = value) { target.Append(valueChars, value.Length); } } #else - target.Append(value.ToArray()); + target.Append(value.ToString()); #endif return target; } @@ -103,5 +104,85 @@ public static bool Equals(this StringBuilder target, ReadOnlySpan span) return true; } -} -#endif \ No newline at end of file + +#endif +#if HAS_SPAN && !NET6_0_OR_GREATER + /// Appends the specified interpolated string to this instance. + /// The interpolated string to append. + /// A reference to this instance after the append operation has completed. + [Link("https://learn.microsoft.com/en-us/dotnet/api/system.text.stringbuilder.append#system-text-stringbuilder-append(system-text-stringbuilder-appendinterpolatedstringhandler@)")] + public static StringBuilder Append( + StringBuilder target, + [InterpolatedStringHandlerArgument(nameof(target))] ref AppendInterpolatedStringHandler handler) => target; + + /// Appends the specified interpolated string to this instance. + /// An object that supplies culture-specific formatting information. + /// The interpolated string to append. + /// A reference to this instance after the append operation has completed. + [Link("https://learn.microsoft.com/en-us/dotnet/api/system.text.stringbuilder.append#system-text-stringbuilder-append(system-iformatprovider-system-text-stringbuilder-appendinterpolatedstringhandler@)")] + public static StringBuilder Append( + StringBuilder target, + IFormatProvider? provider, + [InterpolatedStringHandlerArgument(nameof(target), nameof(provider))] ref AppendInterpolatedStringHandler handler) => target; + + /// Appends the specified interpolated string followed by the default line terminator to the end of the current StringBuilder object. + /// The interpolated string to append. + /// A reference to this instance after the append operation has completed. + [Link("https://learn.microsoft.com/en-us/dotnet/api/system.text.stringbuilder.appendline#system-text-stringbuilder-appendline(system-text-stringbuilder-appendinterpolatedstringhandler@)")] + public static StringBuilder AppendLine( + StringBuilder target, + [InterpolatedStringHandlerArgument(nameof(target))] ref AppendInterpolatedStringHandler handler) => + target.AppendLine(); + + /// Appends the specified interpolated string followed by the default line terminator to the end of the current StringBuilder object. + /// An object that supplies culture-specific formatting information. + /// The interpolated string to append. + /// A reference to this instance after the append operation has completed. + [Link("https://learn.microsoft.com/en-us/dotnet/api/system.text.stringbuilder.appendline#system-text-stringbuilder-appendline(system-iformatprovider-system-text-stringbuilder-appendinterpolatedstringhandler@)")] + public static StringBuilder AppendLine( + StringBuilder target, + IFormatProvider? provider, + [InterpolatedStringHandlerArgument(nameof(target), nameof(provider))] ref AppendInterpolatedStringHandler handler) => + target.AppendLine(); +#elif NET6_0_OR_GREATER + /// Appends the specified interpolated string to this instance. + /// The interpolated string to append. + /// A reference to this instance after the append operation has completed. + [Link("https://learn.microsoft.com/en-us/dotnet/api/system.text.stringbuilder.append#system-text-stringbuilder-append(system-text-stringbuilder-appendinterpolatedstringhandler@)")] + public static StringBuilder Append( + StringBuilder target, + [InterpolatedStringHandlerArgument(nameof(target))] ref StringBuilder.AppendInterpolatedStringHandler handler) => + target.Append(ref handler); + + /// Appends the specified interpolated string to this instance. + /// An object that supplies culture-specific formatting information. + /// The interpolated string to append. + /// A reference to this instance after the append operation has completed. + [Link("https://learn.microsoft.com/en-us/dotnet/api/system.text.stringbuilder.append#system-text-stringbuilder-append(system-iformatprovider-system-text-stringbuilder-appendinterpolatedstringhandler@)")] + public static StringBuilder Append( + StringBuilder target, + IFormatProvider? provider, + [InterpolatedStringHandlerArgument(nameof(target), nameof(provider))] ref StringBuilder.AppendInterpolatedStringHandler handler) => + target.Append(provider, ref handler); + + /// Appends the specified interpolated string followed by the default line terminator to the end of the current StringBuilder object. + /// The interpolated string to append. + /// A reference to this instance after the append operation has completed. + [Link("https://learn.microsoft.com/en-us/dotnet/api/system.text.stringbuilder.appendline#system-text-stringbuilder-appendline(system-text-stringbuilder-appendinterpolatedstringhandler@)")] + public static StringBuilder AppendLine( + StringBuilder target, + [InterpolatedStringHandlerArgument(nameof(target))] ref StringBuilder.AppendInterpolatedStringHandler handler) => + target.AppendLine(ref handler); + + /// Appends the specified interpolated string followed by the default line terminator to the end of the current StringBuilder object. + /// An object that supplies culture-specific formatting information. + /// The interpolated string to append. + /// A reference to this instance after the append operation has completed. + [Link("https://learn.microsoft.com/en-us/dotnet/api/system.text.stringbuilder.appendline#system-text-stringbuilder-appendline(system-iformatprovider-system-text-stringbuilder-appendinterpolatedstringhandler@)")] + public static StringBuilder AppendLine( + StringBuilder target, + IFormatProvider? provider, + [InterpolatedStringHandlerArgument(nameof(target), nameof(provider))] ref StringBuilder.AppendInterpolatedStringHandler handler) => + target.AppendLine(provider, ref handler); +#endif +} \ No newline at end of file diff --git a/src/Polyfill/StringInterpolation/AppendInterpolatedStringHandler.cs b/src/Polyfill/StringInterpolation/AppendInterpolatedStringHandler.cs new file mode 100644 index 00000000..cc593eb7 --- /dev/null +++ b/src/Polyfill/StringInterpolation/AppendInterpolatedStringHandler.cs @@ -0,0 +1,348 @@ +// + +#if HAS_SPAN && !NET6_0_OR_GREATER + +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.CompilerServices; + +#nullable enable + +namespace System.Text; + +/// Provides a handler used by the language compiler to append interpolated strings into instances. +[EditorBrowsable(EditorBrowsableState.Never)] +[InterpolatedStringHandler] +#if PolyPublic +public +#endif +struct AppendInterpolatedStringHandler +{ + // Implementation note: + // As this type is only intended to be targeted by the compiler, public APIs eschew argument validation logic + // in a variety of places, e.g. allowing a null input when one isn't expected to produce a NullReferenceException rather + // than an ArgumentNullException. + + private const int StackallocCharBufferSizeLimit = 256; + + /// The associated StringBuilder to which to append. + private readonly StringBuilder _stringBuilder; + + /// Optional provider to pass to IFormattable.ToString or ISpanFormattable.TryFormat calls. + private readonly IFormatProvider? _provider; + + /// Whether provides an ICustomFormatter. + /// + /// Custom formatters are very rare. We want to support them, but it's ok if we make them more expensive + /// in order to make them as pay-for-play as possible. So, we avoid adding another reference type field + /// to reduce the size of the handler and to reduce required zero'ing, by only storing whether the provider + /// provides a formatter, rather than actually storing the formatter. This in turn means, if there is a + /// formatter, we pay for the extra interface call on each AppendFormatted that needs it. + /// + private readonly bool _hasCustomFormatter; + + /// Creates a handler used to append an interpolated string into a . + /// The number of constant characters outside of interpolation expressions in the interpolated string. + /// The number of interpolation expressions in the interpolated string. + /// The associated StringBuilder to which to append. + /// This is intended to be called only by compiler-generated code. Arguments are not validated as they'd otherwise be for members intended to be used directly. + public AppendInterpolatedStringHandler(int literalLength, int formattedCount, StringBuilder stringBuilder) + { + _stringBuilder = stringBuilder; + _provider = null; + _hasCustomFormatter = false; + } + + /// Creates a handler used to translate an interpolated string into a . + /// The number of constant characters outside of interpolation expressions in the interpolated string. + /// The number of interpolation expressions in the interpolated string. + /// The associated StringBuilder to which to append. + /// An object that supplies culture-specific formatting information. + /// This is intended to be called only by compiler-generated code. Arguments are not validated as they'd otherwise be for members intended to be used directly. + public AppendInterpolatedStringHandler(int literalLength, int formattedCount, StringBuilder stringBuilder, IFormatProvider? provider) + { + _stringBuilder = stringBuilder; + _provider = provider; + _hasCustomFormatter = provider is not null && DefaultInterpolatedStringHandler.HasCustomFormatter(provider); + } + + /// Writes the specified string to the handler. + /// The string to write. + public void AppendLiteral(string value) => _stringBuilder.Append(value); + + #region AppendFormatted + + // Design note: + // This provides the same set of overloads and semantics as DefaultInterpolatedStringHandler. + + #region AppendFormatted T + + /// Writes the specified value to the handler. + /// The value to write. + /// The type of the value to write. + public void AppendFormatted(T value) + { + // This method could delegate to AppendFormatted with a null format, but explicitly passing + // default as the format to TryFormat helps to improve code quality in some cases when TryFormat is inlined, + // e.g. for Int32 it enables the JIT to eliminate code in the inlined method based on a length check on the format. + + if (_hasCustomFormatter) + { + // If there's a custom formatter, always use it. + AppendCustomFormatter(value, format: null); + } + else if (value is IFormattable fValue) + { + // Check first for IFormattable, even though we'll prefer to use ISpanFormattable, as the latter + // requires the former. For value types, it won't matter as the type checks devolve into + // JIT-time constants. For reference types, they're more likely to implement IFormattable + // than they are to implement ISpanFormattable: if they don't implement either, we save an + // interface check over first checking for ISpanFormattable and then for IFormattable, and + // if it only implements IFormattable, we come out even: only if it implements both do we + // end up paying for an extra interface check. + + if (typeof(T).IsEnum || HasTryFormatExtension(typeof(T)) || fValue is ISpanFormattable) + { + // Formats into temporary space and then copies the result into the StringBuilder. + AppendFormattedWithTempSpace(value, 0, format: null); + } + else + { + // constrained call avoiding boxing for value types + _stringBuilder.Append(fValue.ToString(format: null, _provider)); + } + } + else if (value is not null) + { + _stringBuilder.Append(value.ToString()); + } + } + + /// Writes the specified value to the handler. + /// The value to write. + /// The format string. + /// The type of the value to write. + public void AppendFormatted(T value, string? format) + { + if (_hasCustomFormatter) + { + // If there's a custom formatter, always use it. + AppendCustomFormatter(value, format); + } + else if (value is IFormattable fValue) + { + // Check first for IFormattable, even though we'll prefer to use ISpanFormattable, as the latter + // requires the former. For value types, it won't matter as the type checks devolve into + // JIT-time constants. For reference types, they're more likely to implement IFormattable + // than they are to implement ISpanFormattable: if they don't implement either, we save an + // interface check over first checking for ISpanFormattable and then for IFormattable, and + // if it only implements IFormattable, we come out even: only if it implements both do we + // end up paying for an extra interface check. + + if (typeof(T).IsEnum || HasTryFormatExtension(typeof(T)) || fValue is ISpanFormattable) + { + // Formats into temporary space and then copies the result into the StringBuilder. + AppendFormattedWithTempSpace(value, 0, format); + } + else + { + // constrained call avoiding boxing for value types + _stringBuilder.Append(fValue.ToString(format, _provider)); + } + } + else if (value is not null) + { + _stringBuilder.Append(value.ToString()); + } + } + + /// Writes the specified value to the handler. + /// The value to write. + /// + /// Minimum number of characters that should be written for this value. If the value is negative, it indicates + /// left-aligned and the required minimum is the absolute value. + /// + /// The type of the value to write. + public void AppendFormatted(T value, int alignment) => + AppendFormatted(value, alignment, format: null); + + /// Writes the specified value to the handler. + /// The value to write. + /// The format string. + /// + /// Minimum number of characters that should be written for this value. If the value is negative, it indicates + /// left-aligned and the required minimum is the absolute value. + /// + /// The type of the value to write. + public void AppendFormatted(T value, int alignment, string? format) + { + if (alignment == 0) + { + // This overload is used as a fallback from several disambiguation overloads, so special-case 0. + AppendFormatted(value, format); + } + else if (alignment < 0) + { + // Left aligned: format into the handler, then append any additional padding required. + var start = _stringBuilder.Length; + AppendFormatted(value, format); + var paddingRequired = -alignment - (_stringBuilder.Length - start); + if (paddingRequired > 0) + { + _stringBuilder.Append(' ', paddingRequired); + } + } + else + { + // Right aligned: format into temporary space and then copy that into the handler, appropriately aligned. + AppendFormattedWithTempSpace(value, alignment, format); + } + } + + /// Formats into temporary space and then appends the result into the StringBuilder. + private void AppendFormattedWithTempSpace(T value, int alignment, string? format) + { + // It's expected that either there's not enough space in the current chunk to store this formatted value, + // or we have a non-0 alignment that could require padding inserted. So format into temporary space and + // then append that written span into the StringBuilder: StringBuilder.Append(span) is able to split the + // span across the current chunk and any additional chunks required. + + var handler = new DefaultInterpolatedStringHandler(0, 0, _provider, stackalloc char[StackallocCharBufferSizeLimit]); + handler.AppendFormatted(value, format); + AppendFormatted(handler.Text, alignment); + handler.Clear(); + } + + #endregion + + #region AppendFormatted ReadOnlySpan + + /// Writes the specified character span to the handler. + /// The span to write. + public void AppendFormatted(ReadOnlySpan value) => _stringBuilder.Append(value); + + /// Writes the specified string of chars to the handler. + /// The span to write. + /// + /// Minimum number of characters that should be written for this value. If the value is negative, it indicates + /// left-aligned and the required minimum is the absolute value. + /// + /// The format string. + public void AppendFormatted(ReadOnlySpan value, int alignment = 0, string? format = null) + { + if (alignment == 0) + { + _stringBuilder.Append(value); + } + else + { + var leftAlign = false; + if (alignment < 0) + { + leftAlign = true; + alignment = -alignment; + } + + var paddingRequired = alignment - value.Length; + if (paddingRequired <= 0) + { + _stringBuilder.Append(value); + } + else if (leftAlign) + { + _stringBuilder.Append(value); + _stringBuilder.Append(' ', paddingRequired); + } + else + { + _stringBuilder.Append(' ', paddingRequired); + _stringBuilder.Append(value); + } + } + } + + #endregion + + #region AppendFormatted string + + /// Writes the specified value to the handler. + /// The value to write. + public void AppendFormatted(string? value) + { + if (!_hasCustomFormatter) + { + _stringBuilder.Append(value); + } + else + { + AppendFormatted(value); + } + } + + /// Writes the specified value to the handler. + /// The value to write. + /// + /// Minimum number of characters that should be written for this value. If the value is negative, it indicates + /// left-aligned and the required minimum is the absolute value. + /// + /// The format string. + public void AppendFormatted(string? value, int alignment = 0, string? format = null) => + // Format is meaningless for strings and doesn't make sense for someone to specify. We have the overload + // simply to disambiguate between ROS and object, just in case someone does specify a format, as + // string is implicitly convertible to both. Just delegate to the T-based implementation. + AppendFormatted(value, alignment, format); + + #endregion + + #region AppendFormatted object + + /// Writes the specified value to the handler. + /// The value to write. + /// + /// Minimum number of characters that should be written for this value. If the value is negative, it indicates + /// left-aligned and the required minimum is the absolute value. + /// + /// The format string. + public void AppendFormatted(object? value, int alignment = 0, string? format = null) => + // This overload is expected to be used rarely, only if either a) something strongly typed as object is + // formatted with both an alignment and a format, or b) the compiler is unable to target type to T. It + // exists purely to help make cases from (b) compile. Just delegate to the T-based implementation. + AppendFormatted(value, alignment, format); + + #endregion + + #endregion + + /// Formats the value using the custom formatter from the provider. + /// The value to write. + /// The format string. + /// The type of the value to write. + [MethodImpl(MethodImplOptions.NoInlining)] + private void AppendCustomFormatter(T value, string? format) + { + // This case is very rare, but we need to handle it prior to the other checks in case + // a provider was used that supplied an ICustomFormatter which wanted to intercept the particular value. + // We do the cast here rather than in the ctor, even though this could be executed multiple times per + // formatting, to make the cast pay for play. + Debug.Assert(_hasCustomFormatter); + Debug.Assert(_provider != null); + + var formatter = (ICustomFormatter?)_provider!.GetFormat(typeof(ICustomFormatter)); + Debug.Assert(formatter != null, "An incorrectly written provider said it implemented ICustomFormatter, and then didn't"); + + if (formatter is not null) + { + _stringBuilder.Append(formatter.Format(format, value, _provider)); + } + } + + private static bool HasTryFormatExtension(Type type) + { + return type == typeof(int) || type == typeof(bool) || type == typeof(byte) || type == typeof(float) || + type == typeof(double) || type == typeof(DateTime) || type == typeof(DateTimeOffset) || + type == typeof(decimal) || type == typeof(long) || type == typeof(short) || type == typeof(ushort) || + type == typeof(uint) || type == typeof(ulong) || type == typeof(sbyte); + } +} + +#endif \ No newline at end of file diff --git a/src/Polyfill/StringInterpolation/DefaultInterpolatedStringHandler.cs b/src/Polyfill/StringInterpolation/DefaultInterpolatedStringHandler.cs new file mode 100644 index 00000000..e0fd9580 --- /dev/null +++ b/src/Polyfill/StringInterpolation/DefaultInterpolatedStringHandler.cs @@ -0,0 +1,814 @@ +// + +#if HAS_SPAN && !NET6_0_OR_GREATER + +using System; +using System.Buffers; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Runtime.CompilerServices; +using Link = System.ComponentModel.DescriptionAttribute; + +#nullable enable + +namespace System.Runtime.CompilerServices; + +/// Provides a handler used by the language compiler to process interpolated strings into instances. +[InterpolatedStringHandler] +[ExcludeFromCodeCoverage] +[DebuggerNonUserCode] +[Link("https://learn.microsoft.com/en-us/dotnet/api/system.runtime.compilerservices.defaultinterpolatedstringhandler")] +#if PolyPublic +public +#endif +ref struct DefaultInterpolatedStringHandler +{ + // Implementation note: + // As this type lives in CompilerServices and is only intended to be targeted by the compiler, + // public APIs eschew argument validation logic in a variety of places, e.g. allowing a null input + // when one isn't expected to produce a NullReferenceException rather than an ArgumentNullException. + + /// Expected average length of formatted data used for an individual interpolation expression result. + /// + /// This is inherited from string.Format, and could be changed based on further data. + /// string.Format actually uses `format.Length + args.Length * 8`, but format.Length + /// includes the format items themselves, e.g. "{0}", and since it's rare to have double-digit + /// numbers of items, we bump the 8 up to 11 to account for the three extra characters in "{d}", + /// since the compiler-provided base length won't include the equivalent character count. + /// + private const int GuessedLengthPerHole = 11; + /// Minimum size array to rent from the pool. + /// Same as stack-allocation size used today by string.Format. + private const int MinimumArrayPoolLength = 256; + + /// Maximum length allowed for a string. + /// Keep in sync with AllocateString in gchelpers.cpp. + private const int StringMaxLength = 0x3FFFFFDF; + + /// Optional provider to pass to IFormattable.ToString or ISpanFormattable.TryFormat calls. + private readonly IFormatProvider? _provider; + /// Array rented from the array pool and used to back . + private char[]? _arrayToReturnToPool; + /// The span to write into. + private Span _chars; + /// Position at which to write the next character. + private int _pos; + /// Whether provides an ICustomFormatter. + /// + /// Custom formatters are very rare. We want to support them, but it's ok if we make them more expensive + /// in order to make them as pay-for-play as possible. So, we avoid adding another reference type field + /// to reduce the size of the handler and to reduce required zero'ing, by only storing whether the provider + /// provides a formatter, rather than actually storing the formatter. This in turn means, if there is a + /// formatter, we pay for the extra interface call on each AppendFormatted that needs it. + /// + private readonly bool _hasCustomFormatter; + + /// Creates a handler used to translate an interpolated string into a . + /// The number of constant characters outside of interpolation expressions in the interpolated string. + /// The number of interpolation expressions in the interpolated string. + /// This is intended to be called only by compiler-generated code. Arguments are not validated as they'd otherwise be for members intended to be used directly. + public DefaultInterpolatedStringHandler(int literalLength, int formattedCount) + { + _provider = null; + _chars = _arrayToReturnToPool = ArrayPool.Shared.Rent(GetDefaultLength(literalLength, formattedCount)); + _pos = 0; + _hasCustomFormatter = false; + } + + /// Creates a handler used to translate an interpolated string into a . + /// The number of constant characters outside of interpolation expressions in the interpolated string. + /// The number of interpolation expressions in the interpolated string. + /// An object that supplies culture-specific formatting information. + /// This is intended to be called only by compiler-generated code. Arguments are not validated as they'd otherwise be for members intended to be used directly. + public DefaultInterpolatedStringHandler(int literalLength, int formattedCount, IFormatProvider? provider) + { + _provider = provider; + _chars = _arrayToReturnToPool = ArrayPool.Shared.Rent(GetDefaultLength(literalLength, formattedCount)); + _pos = 0; + _hasCustomFormatter = provider is not null && HasCustomFormatter(provider); + } + + /// Creates a handler used to translate an interpolated string into a . + /// The number of constant characters outside of interpolation expressions in the interpolated string. + /// The number of interpolation expressions in the interpolated string. + /// An object that supplies culture-specific formatting information. + /// A buffer temporarily transferred to the handler for use as part of its formatting. Contents may be overwritten. + /// This is intended to be called only by compiler-generated code. Arguments are not validated as they'd otherwise be for members intended to be used directly. + public DefaultInterpolatedStringHandler(int literalLength, int formattedCount, IFormatProvider? provider, Span initialBuffer) + { + _provider = provider; + _chars = initialBuffer; + _arrayToReturnToPool = null; + _pos = 0; + _hasCustomFormatter = provider is not null && HasCustomFormatter(provider); + } + + /// Derives a default length with which to seed the handler. + /// The number of constant characters outside of interpolation expressions in the interpolated string. + /// The number of interpolation expressions in the interpolated string. + [MethodImpl(MethodImplOptions.AggressiveInlining)] // becomes a constant when inputs are constant + internal static int GetDefaultLength(int literalLength, int formattedCount) => + Math.Max(MinimumArrayPoolLength, literalLength + formattedCount * GuessedLengthPerHole); + + /// Gets the built . + /// The built string. + public override string ToString() => Text.ToString(); + + /// Gets the built and clears the handler. + /// The built string. + /// + /// This releases any resources used by the handler. The method should be invoked only + /// once and as the last thing performed on the handler. Subsequent use is erroneous, ill-defined, + /// and may destabilize the process, as may using any other copies of the handler after ToStringAndClear + /// is called on any one of them. + /// + public string ToStringAndClear() + { + var result = Text.ToString(); + Clear(); + return result; + } + + /// Clears the handler, returning any rented array to the pool. + [MethodImpl(MethodImplOptions.AggressiveInlining)] // used only on a few hot paths + internal void Clear() + { + var toReturn = _arrayToReturnToPool; + this = default; // defensive clear + if (toReturn is not null) + { + ArrayPool.Shared.Return(toReturn); + } + } + + /// Gets a span of the written characters thus far. + internal ReadOnlySpan Text => _chars.Slice(0, _pos); + + /// Writes the specified string to the handler. + /// The string to write. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendLiteral(string value) + { + if (value.TryCopyTo(_chars.Slice(_pos))) + { + _pos += value.Length; + } + else + { + GrowThenCopyString(value); + } + } + + #region AppendFormatted + // Design note: + // The compiler requires a AppendFormatted overload for anything that might be within an interpolation expression; + // if it can't find an appropriate overload, for handlers in general it'll simply fail to compile. + // (For target-typing to string where it uses DefaultInterpolatedStringHandler implicitly, it'll instead fall back to + // its other mechanisms, e.g. using string.Format. This fallback has the benefit that if we miss a case, + // interpolated strings will still work, but it has the downside that a developer generally won't know + // if the fallback is happening and they're paying more.) + // + // At a minimum, then, we would need an overload that accepts: + // (object value, int alignment = 0, string? format = null) + // Such an overload would provide the same expressiveness as string.Format. However, this has several + // shortcomings: + // - Every value type in an interpolation expression would be boxed. + // - ReadOnlySpan could not be used in interpolation expressions. + // - Every AppendFormatted call would have three arguments at the call site, bloating the IL further. + // - Every invocation would be more expensive, due to lack of specialization, every call needing to account + // for alignment and format, etc. + // + // To address that, we could just have overloads for T and ReadOnlySpan: + // (T) + // (T, int alignment) + // (T, string? format) + // (T, int alignment, string? format) + // (ReadOnlySpan) + // (ReadOnlySpan, int alignment) + // (ReadOnlySpan, string? format) + // (ReadOnlySpan, int alignment, string? format) + // but this also has shortcomings: + // - Some expressions that would have worked with an object overload will now force a fallback to string.Format + // (or fail to compile if the handler is used in places where the fallback isn't provided), because the compiler + // can't always target type to T, e.g. `b switch { true => 1, false => null }` where `b` is a bool can successfully + // be passed as an argument of type `object` but not of type `T`. + // - Reference types get no benefit from going through the generic code paths, and actually incur some overheads + // from doing so. + // - Nullable value types also pay a heavy price, in particular around interface checks that would generally evaporate + // at compile time for value types but don't (currently) if the Nullable goes through the same code paths + // (see https://github.com/dotnet/runtime/issues/50915). + // + // We could try to take a more elaborate approach for DefaultInterpolatedStringHandler, since it is the most common + // handler and we want to minimize overheads both at runtime and in IL size, e.g. have a complete set of overloads + // for each of: + // (T, ...) where T : struct + // (T?, ...) where T : struct + // (object, ...) + // (ReadOnlySpan, ...) + // (string, ...) + // but this also has shortcomings, most importantly: + // - If you have an unconstrained T that happens to be a value type, it'll now end up getting boxed to use the object + // overload. This also necessitates the T? overload, since nullable value types don't meet a T : struct constraint, + // so without those they'd all map to the object overloads as well. + // - Any reference type with an implicit cast to ROS will fail to compile due to ambiguities between the + // overloads. string is one such type, hence needing dedicated overloads for it that can be bound to more tightly. + // + // A middle ground we've settled on, which is likely to be the right approach for most other handlers as well, + // would be the set: + // (T, ...) with no constraint + // (ReadOnlySpan) and (ReadOnlySpan, int) + // (object, int alignment = 0, string? format = null) + // (string) and (string, int) + // This would address most of the concerns, at the expense of: + // - Most reference types going through the generic code paths and so being a bit more expensive. + // - Nullable types being more expensive until https://github.com/dotnet/runtime/issues/50915 is addressed. + // We could choose to add a T? where T : struct set of overloads if necessary. + // Strings don't require their own overloads here, but as they're expected to be very common and as we can + // optimize them in several ways (can copy the contents directly, don't need to do any interface checks, don't + // need to pay the shared generic overheads, etc.) we can add overloads specifically to optimize for them. + // + // Hole values are formatted according to the following policy: + // 1. If an IFormatProvider was supplied and it provides an ICustomFormatter, use ICustomFormatter.Format (even if + // the value is null). + // 2. If the type implements ISpanFormattable, use ISpanFormattable.TryFormat. + // 3. If the type implements IFormattable, use IFormattable.ToString. + // 4. Otherwise, use object.ToString. + // This matches the behavior of string.Format, StringBuilder.AppendFormat, etc. The only overloads for which this + // doesn't apply is ReadOnlySpan, which isn't supported by either string.Format nor StringBuilder.AppendFormat, + // but more importantly which can't be boxed to be passed to ICustomFormatter.Format. + + #region AppendFormatted T + /// Writes the specified value to the handler. + /// The value to write. + /// The type of the value to write. + public void AppendFormatted(T value) + { + // This method could delegate to AppendFormatted with a null format, but explicitly passing + // default as the format to TryFormat helps to improve code quality in some cases when TryFormat is inlined, + // e.g. for Int32 it enables the JIT to eliminate code in the inlined method based on a length check on the format. + + // If there's a custom formatter, always use it. + if (_hasCustomFormatter) + { + AppendCustomFormatter(value, format: null); + return; + } + + // Check first for IFormattable, even though we'll prefer to use ISpanFormattable, as the latter + // requires the former. For value types, it won't matter as the type checks devolve into + // JIT-time constants. For reference types, they're more likely to implement IFormattable + // than they are to implement ISpanFormattable: if they don't implement either, we save an + // interface check over first checking for ISpanFormattable and then for IFormattable, and + // if it only implements IFormattable, we come out even: only if it implements both do we + // end up paying for an extra interface check. + string? s; + if (value is IFormattable fValue) + { + // If the value can format itself directly into our buffer, do so. + + if (TryFormatWithExtensions(value, default)) + { + return; + } + else if (fValue is ISpanFormattable sfValue) + { + int charsWritten; + // constrained call avoiding boxing for value types + while (!sfValue.TryFormat(_chars.Slice(_pos), out charsWritten, default, _provider)) + { + Grow(); + } + + _pos += charsWritten; + return; + } + + s = fValue.ToString(format: null, _provider); // constrained call avoiding boxing for value types + } + else + { + s = value?.ToString(); + } + + if (s is not null) + { + AppendLiteral(s); + } + } + + /// Writes the specified value to the handler. + /// The value to write. + /// The format string. + /// The type of the value to write. + public void AppendFormatted(T value, string? format) + { + // If there's a custom formatter, always use it. + if (_hasCustomFormatter) + { + AppendCustomFormatter(value, format); + return; + } + + // Check first for IFormattable, even though we'll prefer to use ISpanFormattable, as the latter + // requires the former. For value types, it won't matter as the type checks devolve into + // JIT-time constants. For reference types, they're more likely to implement IFormattable + // than they are to implement ISpanFormattable: if they don't implement either, we save an + // interface check over first checking for ISpanFormattable and then for IFormattable, and + // if it only implements IFormattable, we come out even: only if it implements both do we + // end up paying for an extra interface check. + string? s; + if (value is IFormattable fValue) + { + // If the value can format itself directly into our buffer, do so. + + if (TryFormatWithExtensions(value, format.AsSpan())) + { + return; + } + else if (fValue is ISpanFormattable sfValue) + { + int charsWritten; + // constrained call avoiding boxing for value types + while (!sfValue.TryFormat(_chars.Slice(_pos), out charsWritten, format.AsSpan(), _provider)) + { + Grow(); + } + + _pos += charsWritten; + return; + } + + s = fValue.ToString(format, _provider); // constrained call avoiding boxing for value types + } + else + { + s = value?.ToString(); + } + + if (s is not null) + { + AppendLiteral(s); + } + } + + /// Writes the specified value to the handler. + /// The value to write. + /// + /// Minimum number of characters that should be written for this value. If the value is negative, it indicates + /// left-aligned and the required minimum is the absolute value. + /// + /// The type of the value to write. + public void AppendFormatted(T value, int alignment) + { + var startingPos = _pos; + AppendFormatted(value); + if (alignment != 0) + { + AppendOrInsertAlignmentIfNeeded(startingPos, alignment); + } + } + + /// Writes the specified value to the handler. + /// The value to write. + /// The format string. + /// + /// Minimum number of characters that should be written for this value. If the value is negative, it indicates + /// left-aligned and the required minimum is the absolute value. + /// + /// The type of the value to write. + public void AppendFormatted(T value, int alignment, string? format) + { + var startingPos = _pos; + AppendFormatted(value, format); + if (alignment != 0) + { + AppendOrInsertAlignmentIfNeeded(startingPos, alignment); + } + } + #endregion + + #region AppendFormatted ReadOnlySpan + /// Writes the specified character span to the handler. + /// The span to write. + public void AppendFormatted(scoped ReadOnlySpan value) + { + // Fast path for when the value fits in the current buffer + if (value.TryCopyTo(_chars.Slice(_pos))) + { + _pos += value.Length; + } + else + { + GrowThenCopySpan(value); + } + } + + /// Writes the specified string of chars to the handler. + /// The span to write. + /// + /// Minimum number of characters that should be written for this value. If the value is negative, it indicates + /// left-aligned and the required minimum is the absolute value. + /// + /// The format string. + public void AppendFormatted(scoped ReadOnlySpan value, int alignment = 0, string? format = null) + { + var leftAlign = false; + if (alignment < 0) + { + leftAlign = true; + alignment = -alignment; + } + + var paddingRequired = alignment - value.Length; + if (paddingRequired <= 0) + { + // The value is as large or larger than the required amount of padding, + // so just write the value. + AppendFormatted(value); + return; + } + + // Write the value along with the appropriate padding. + EnsureCapacityForAdditionalChars(value.Length + paddingRequired); + if (leftAlign) + { + value.CopyTo(_chars.Slice(_pos)); + _pos += value.Length; + _chars.Slice(_pos, paddingRequired).Fill(' '); + _pos += paddingRequired; + } + else + { + _chars.Slice(_pos, paddingRequired).Fill(' '); + _pos += paddingRequired; + value.CopyTo(_chars.Slice(_pos)); + _pos += value.Length; + } + } + #endregion + + #region AppendFormatted string + /// Writes the specified value to the handler. + /// The value to write. + public void AppendFormatted(string? value) + { + // Fast-path for no custom formatter and a non-null string that fits in the current destination buffer. + if (!_hasCustomFormatter && + value is not null && + value.TryCopyTo(_chars.Slice(_pos))) + { + _pos += value.Length; + } + else + { + AppendFormattedSlow(value); + } + } + + /// Writes the specified value to the handler. + /// The value to write. + /// + /// Slow path to handle a custom formatter, potentially null value, + /// or a string that doesn't fit in the current buffer. + /// + [MethodImpl(MethodImplOptions.NoInlining)] + private void AppendFormattedSlow(string? value) + { + if (_hasCustomFormatter) + { + AppendCustomFormatter(value, format: null); + } + else if (value is not null) + { + EnsureCapacityForAdditionalChars(value.Length); + value.CopyTo(_chars.Slice(_pos)); + _pos += value.Length; + } + } + + /// Writes the specified value to the handler. + /// The value to write. + /// + /// Minimum number of characters that should be written for this value. If the value is negative, it indicates + /// left-aligned and the required minimum is the absolute value. + /// + /// The format string. + public void AppendFormatted(string? value, int alignment = 0, string? format = null) => + // Format is meaningless for strings and doesn't make sense for someone to specify. We have the overload + // simply to disambiguate between ROS and object, just in case someone does specify a format, as + // string is implicitly convertible to both. Just delegate to the T-based implementation. + AppendFormatted(value, alignment, format); + #endregion + + #region AppendFormatted object + /// Writes the specified value to the handler. + /// The value to write. + /// + /// Minimum number of characters that should be written for this value. If the value is negative, it indicates + /// left-aligned and the required minimum is the absolute value. + /// + /// The format string. + public void AppendFormatted(object? value, int alignment = 0, string? format = null) => + // This overload is expected to be used rarely, only if either a) something strongly typed as object is + // formatted with both an alignment and a format, or b) the compiler is unable to target type to T. It + // exists purely to help make cases from (b) compile. Just delegate to the T-based implementation. + AppendFormatted(value, alignment, format); + #endregion + #endregion + + /// Gets whether the provider provides a custom formatter. + [MethodImpl(MethodImplOptions.AggressiveInlining)] // only used in a few hot path call sites + internal static bool HasCustomFormatter(IFormatProvider provider) + { + Debug.Assert(provider is not null); + Debug.Assert( + provider is not CultureInfo || provider.GetFormat(typeof(ICustomFormatter)) is null, + "Expected CultureInfo to not provide a custom formatter"); + + return + provider!.GetType() != typeof(CultureInfo) && // optimization to avoid GetFormat in the majority case + provider.GetFormat(typeof(ICustomFormatter)) != null; + } + + /// Formats the value using the custom formatter from the provider. + /// The value to write. + /// The format string. + /// The type of the value to write. + [MethodImpl(MethodImplOptions.NoInlining)] + private void AppendCustomFormatter(T value, string? format) + { + // This case is very rare, but we need to handle it prior to the other checks in case + // a provider was used that supplied an ICustomFormatter which wanted to intercept the particular value. + // We do the cast here rather than in the ctor, even though this could be executed multiple times per + // formatting, to make the cast pay for play. + Debug.Assert(_hasCustomFormatter); + Debug.Assert(_provider != null); + + var formatter = (ICustomFormatter?)_provider!.GetFormat(typeof(ICustomFormatter)); + Debug.Assert( + formatter != null, + "An incorrectly written provider said it implemented ICustomFormatter, and then didn't"); + + if (formatter?.Format(format, value, _provider) is { } customFormatted) + { + AppendLiteral(customFormatted); + } + } + + /// Handles adding any padding required for aligning a formatted value in an interpolation expression. + /// The position at which the written value started. + /// + /// Non-zero minimum number of characters that should be written for this value. If the value is negative, it + /// indicates left-aligned and the required minimum is the absolute value. + /// + private void AppendOrInsertAlignmentIfNeeded(int startingPos, int alignment) + { + Debug.Assert(startingPos >= 0 && startingPos <= _pos); + Debug.Assert(alignment != 0); + + var charsWritten = _pos - startingPos; + + var leftAlign = false; + if (alignment < 0) + { + leftAlign = true; + alignment = -alignment; + } + + var paddingNeeded = alignment - charsWritten; + if (paddingNeeded > 0) + { + EnsureCapacityForAdditionalChars(paddingNeeded); + + if (leftAlign) + { + _chars.Slice(_pos, paddingNeeded).Fill(' '); + } + else + { + _chars.Slice(startingPos, charsWritten).CopyTo(_chars.Slice(startingPos + paddingNeeded)); + _chars.Slice(startingPos, paddingNeeded).Fill(' '); + } + + _pos += paddingNeeded; + } + } + + /// + /// Ensures has the capacity to store beyond . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void EnsureCapacityForAdditionalChars(int additionalChars) + { + if (_chars.Length - _pos < additionalChars) + { + Grow(additionalChars); + } + } + + /// + /// Fallback for fast path in when there's not enough space in the destination. + /// + /// The string to write. + [MethodImpl(MethodImplOptions.NoInlining)] + private void GrowThenCopyString(string value) + { + Grow(value.Length); + value.CopyTo(_chars.Slice(_pos)); + _pos += value.Length; + } + + /// + /// Fallback for for when not enough space exists in the current buffer. + /// + /// The span to write. + [MethodImpl(MethodImplOptions.NoInlining)] + private void GrowThenCopySpan(scoped ReadOnlySpan value) + { + Grow(value.Length); + value.CopyTo(_chars.Slice(_pos)); + _pos += value.Length; + } + + /// + /// Grows to have the capacity to store at least + /// beyond . + /// + [MethodImpl(MethodImplOptions.NoInlining)] // keep consumers as streamlined as possible + private void Grow(int additionalChars) + { + // This method is called when the remaining space (_chars.Length - _pos) is + // insufficient to store a specific number of additional characters. Thus, we + // need to grow to at least that new total. GrowCore will handle growing by more + // than that if possible. + Debug.Assert(additionalChars > _chars.Length - _pos); + GrowCore((uint)_pos + (uint)additionalChars); + } + + /// Grows the size of . + [MethodImpl(MethodImplOptions.NoInlining)] // keep consumers as streamlined as possible + private void Grow() => + // This method is called when the remaining space in _chars isn't sufficient to continue + // the operation. Thus, we need at least one character beyond _chars.Length. GrowCore + // will handle growing by more than that if possible. + GrowCore((uint)_chars.Length + 1); + + /// + /// Grow the size of to at least the specified . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] // but reuse this grow logic directly in both of the above grow routines + private void GrowCore(uint requiredMinCapacity) + { + // We want the max of how much space we actually required and doubling our capacity (without going beyond + // the max allowed length). We also want to avoid asking for small arrays, to reduce the number of times we + // need to grow, and since we're working with unsigned ints that could technically overflow if someone tried + // to, for example, append a huge string to a huge string, we also clamp to int.MaxValue. + // Even if the array creation fails in such a case, we may later fail in ToStringAndClear. + + var newCapacity = Math.Max(requiredMinCapacity, Math.Min((uint)_chars.Length * 2, StringMaxLength)); + var arraySize = (int)InternalMath.Clamp(newCapacity, MinimumArrayPoolLength, int.MaxValue); + + var newArray = ArrayPool.Shared.Rent(arraySize); + _chars.Slice(0, _pos).CopyTo(newArray); + + var toReturn = _arrayToReturnToPool; + _chars = _arrayToReturnToPool = newArray; + + if (toReturn is not null) + { + ArrayPool.Shared.Return(toReturn); + } + } + + private bool TryFormatWithExtensions(T value, ReadOnlySpan format) + { + int charsWritten; + switch (value) + { + case int cval: + while (!cval.TryFormat(_chars.Slice(_pos), out charsWritten, format, _provider)) + { + Grow(); + } + break; + case bool cval: + while (!cval.TryFormat(_chars.Slice(_pos), out charsWritten)) + { + Grow(); + } + break; + case byte cval: + while (!cval.TryFormat(_chars.Slice(_pos), out charsWritten, format, _provider)) + { + Grow(); + } + break; + case float cval: + while (!cval.TryFormat(_chars.Slice(_pos), out charsWritten, format, _provider)) + { + Grow(); + } + break; + case double cval: + while (!cval.TryFormat(_chars.Slice(_pos), out charsWritten, format, _provider)) + { + Grow(); + } + break; + case DateTime cval: + while (!cval.TryFormat(_chars.Slice(_pos), out charsWritten, format, _provider)) + { + Grow(); + } + break; + case DateTimeOffset cval: + while (!cval.TryFormat(_chars.Slice(_pos), out charsWritten, format, _provider)) + { + Grow(); + } + break; + case decimal cval: + while (!cval.TryFormat(_chars.Slice(_pos), out charsWritten, format, _provider)) + { + Grow(); + } + break; + case long cval: + while (!cval.TryFormat(_chars.Slice(_pos), out charsWritten, format, _provider)) + { + Grow(); + } + break; + case short cval: + while (!cval.TryFormat(_chars.Slice(_pos), out charsWritten, format, _provider)) + { + Grow(); + } + break; + case ushort cval: + while (!cval.TryFormat(_chars.Slice(_pos), out charsWritten, format, _provider)) + { + Grow(); + } + break; + case uint cval: + while (!cval.TryFormat(_chars.Slice(_pos), out charsWritten, format, _provider)) + { + Grow(); + } + break; + case ulong cval: + while (!cval.TryFormat(_chars.Slice(_pos), out charsWritten, format, _provider)) + { + Grow(); + } + break; + case sbyte cval: + while (!cval.TryFormat(_chars.Slice(_pos), out charsWritten, format, _provider)) + { + Grow(); + } + break; + default: + return false; + } + + _pos += charsWritten; + return true; + } +} + +static file class InternalMath +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static uint Clamp(uint value, uint min, uint max) + { + if (min > max) + { + ThrowMinMaxException(min, max); + } + + if (value < min) + { + return min; + } + else if (value > max) + { + return max; + } + + return value; + } + + [DoesNotReturn] + private static void ThrowMinMaxException(T min, T max) => + throw new ArgumentException(string.Format(SR.Argument_MinMaxValue, min, max)); +} + +static file class SR +{ + public const string Argument_MinMaxValue = "'{0}' cannot be greater than {1}."; +} + +#endif \ No newline at end of file diff --git a/src/Polyfill/StringInterpolation/ISpanFormattable.cs b/src/Polyfill/StringInterpolation/ISpanFormattable.cs new file mode 100644 index 00000000..329c3c39 --- /dev/null +++ b/src/Polyfill/StringInterpolation/ISpanFormattable.cs @@ -0,0 +1,32 @@ +// + +#if HAS_SPAN && !NET6_0_OR_GREATER + +using Link = System.ComponentModel.DescriptionAttribute; + +#nullable enable + +namespace System; + +/// Provides functionality to format the string representation of an object into a span. +[Link("https://learn.microsoft.com/en-us/dotnet/api/system.ispanformattable")] +#if PolyPublic +public +#endif +interface ISpanFormattable : IFormattable +{ + /// Tries to format the value of the current instance into the provided span of characters. + /// When this method returns, this instance's value formatted as a span of characters. + /// When this method returns, the number of characters that were written in . + /// A span containing the characters that represent a standard or custom format string that defines the acceptable format for . + /// An optional object that supplies culture-specific formatting information for . + /// if the formatting was successful; otherwise, . + /// + /// An implementation of this interface should produce the same string of characters as an implementation of + /// on the same type. + /// TryFormat should return false only if there is not enough space in the destination buffer. Any other failures should throw an exception. + /// + bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider); +} + +#endif \ No newline at end of file diff --git a/src/Polyfill/InterpolatedStringHandlerArgumentAttribute.cs b/src/Polyfill/StringInterpolation/InterpolatedStringHandlerArgumentAttribute.cs similarity index 100% rename from src/Polyfill/InterpolatedStringHandlerArgumentAttribute.cs rename to src/Polyfill/StringInterpolation/InterpolatedStringHandlerArgumentAttribute.cs diff --git a/src/Polyfill/InterpolatedStringHandlerAttribute.cs b/src/Polyfill/StringInterpolation/InterpolatedStringHandlerAttribute.cs similarity index 100% rename from src/Polyfill/InterpolatedStringHandlerAttribute.cs rename to src/Polyfill/StringInterpolation/InterpolatedStringHandlerAttribute.cs diff --git a/src/PublicTests/PublicTests.csproj b/src/PublicTests/PublicTests.csproj index 95e2b340..98325892 100644 --- a/src/PublicTests/PublicTests.csproj +++ b/src/PublicTests/PublicTests.csproj @@ -17,6 +17,9 @@ Pollyfill\IndexRange\%(RecursiveDir)%(Filename).cs + + Pollyfill\StringInterpolation\%(RecursiveDir)%(Filename).cs + Pollyfill\Trimming\%(RecursiveDir)%(Filename).cs diff --git a/src/Tests/SanityChecks.cs b/src/Tests/SanityChecks.cs index 9bc98c87..4bc222a2 100644 --- a/src/Tests/SanityChecks.cs +++ b/src/Tests/SanityChecks.cs @@ -35,7 +35,9 @@ public void Attributes() name == "NullableAttribute" || name == "NullableContextAttribute" || name == "IsReadOnlyAttribute" || - name == "RefSafetyRulesAttribute") + name == "RefSafetyRulesAttribute" || + name == "ScopedRefAttribute" || + name == "IsByRefLikeAttribute") { continue; } diff --git a/src/Tests/StringInterpolationTests.cs b/src/Tests/StringInterpolationTests.cs new file mode 100644 index 00000000..fb4b21d4 --- /dev/null +++ b/src/Tests/StringInterpolationTests.cs @@ -0,0 +1,28 @@ +#if HAS_SPAN + +[TestFixture] +public class StringInterpolationTests +{ + [Test] + public void ShouldInterpolateString() + { + Span buffer = stackalloc char[] { 'H', 'e', 'l', 'l', 'o' }; + var number = 15; + var result = $"{buffer}, you're {number} years old"; + + Assert.AreEqual("Hello, you're 15 years old", result); + } + + [Test] + public void ShouldInterpolateStringBuilder() + { + var sb = new StringBuilder(); + Span buffer = stackalloc char[] { 'H', 'e', 'l', 'l', 'o' }; + var number = 15; + PolyfillExtensions.Append(sb, $"{buffer}, you're {number} years old {sb.ToString()}"); + var result = sb.ToString(); + + Assert.AreEqual("Hello, you're 15 years old Hello, you're 15 years old ", result); + } +} +#endif \ No newline at end of file diff --git a/src/Tests/Tests.csproj b/src/Tests/Tests.csproj index 55bdd613..44f8bcad 100644 --- a/src/Tests/Tests.csproj +++ b/src/Tests/Tests.csproj @@ -16,6 +16,9 @@ Pollyfill\IndexRange\%(RecursiveDir)%(Filename).cs + + Pollyfill\StringInterpolation\%(RecursiveDir)%(Filename).cs + Pollyfill\Trimming\%(RecursiveDir)%(Filename).cs diff --git a/src/UnsafeTests/UnsafeTests.csproj b/src/UnsafeTests/UnsafeTests.csproj index 28f325ce..9b38c707 100644 --- a/src/UnsafeTests/UnsafeTests.csproj +++ b/src/UnsafeTests/UnsafeTests.csproj @@ -17,6 +17,9 @@ Pollyfill\IndexRange\%(RecursiveDir)%(Filename).cs + + Pollyfill\StringInterpolation\%(RecursiveDir)%(Filename).cs + Pollyfill\Trimming\%(RecursiveDir)%(Filename).cs