diff --git a/Build.csproj b/Build.csproj
index 3e16e801c..41fb15b0c 100644
--- a/Build.csproj
+++ b/Build.csproj
@@ -1,5 +1,6 @@
+
diff --git a/Directory.Build.props b/Directory.Build.props
index 42de5875c..988cea9b6 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,5 +1,8 @@
+
+ false
+
2.0.0
2014 - $([System.DateTime]::Now.Year) Stack Exchange, Inc.
true
@@ -26,7 +29,15 @@
true
false
true
+ true
+ false
+ true
00240000048000009400000006020000002400005253413100040000010001007791a689e9d8950b44a9a8886baad2ea180e7a8a854f158c9b98345ca5009cdd2362c84f368f1c3658c132b3c0f74e44ff16aeb2e5b353b6e0fe02f923a050470caeac2bde47a2238a9c7125ed7dab14f486a5a64558df96640933b9f2b6db188fc4a820f96dce963b662fa8864adbff38e5b4542343f162ecdc6dad16912fff
+ LatestMajor
+
+
+ preview
+ $(DefineConstants);PREVIEW_LANGVER
true
diff --git a/Directory.Packages.props b/Directory.Packages.props
index df8c078a3..fd9d139a0 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -7,11 +7,16 @@
+
+
+
+
+
-
+
@@ -25,7 +30,6 @@
-
diff --git a/StackExchange.Redis.sln b/StackExchange.Redis.sln
index 2ed4ebfb3..86f751e25 100644
--- a/StackExchange.Redis.sln
+++ b/StackExchange.Redis.sln
@@ -122,9 +122,21 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "docs", "docs\docs.csproj",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StackExchange.Redis.Benchmarks", "tests\StackExchange.Redis.Benchmarks\StackExchange.Redis.Benchmarks.csproj", "{59889284-FFEE-82E7-94CB-3B43E87DA6CF}"
EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "eng", "eng", "{5FA0958E-6EBD-45F4-808E-3447A293F96F}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RESP.Core", "src\RESP.Core\RESP.Core.csproj", "{E50EEB8B-6B3F-4C8C-A5C6-C37FB87C01E2}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StackExchange.Redis.Build", "eng\StackExchange.Redis.Build\StackExchange.Redis.Build.csproj", "{190742E1-FA50-4E36-A8C4-88AE87654340}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RESPite.Tests", "tests\RESPite.Tests\RESPite.Tests.csproj", "{7063E2D3-C591-4604-A5DD-32D4A1678A58}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "eng", "eng", "{C0132984-68D1-4A97-8F8C-AD4E2EECC583}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StackExchange.Redis.Build", "eng\StackExchange.Redis.Build\StackExchange.Redis.Build.csproj", "{B0055B76-4685-4ECF-A904-88EE4E6FC8F0}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RESPite", "src\RESPite\RESPite.csproj", "{F8762EE5-3461-4F6B-8C24-C876B6D9E637}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RESPite.Redis", "src\RESPite.Redis\RESPite.Redis.csproj", "{3A92C2E7-3033-4FDF-8DDC-5DF43D290537}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RESPite.StackExchange.Redis", "src\RESPite.StackExchange.Redis\RESPite.StackExchange.Redis.csproj", "{A5580114-C236-494E-851C-A21E3DB86FC8}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RESPite.Benchmark", "src\RESPite.Benchmark\RESPite.Benchmark.csproj", "{3725A78B-B6B5-4379-9DE0-37A180ADE95A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -184,10 +196,34 @@ Global
{59889284-FFEE-82E7-94CB-3B43E87DA6CF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{59889284-FFEE-82E7-94CB-3B43E87DA6CF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{59889284-FFEE-82E7-94CB-3B43E87DA6CF}.Release|Any CPU.Build.0 = Release|Any CPU
- {190742E1-FA50-4E36-A8C4-88AE87654340}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {190742E1-FA50-4E36-A8C4-88AE87654340}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {190742E1-FA50-4E36-A8C4-88AE87654340}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {190742E1-FA50-4E36-A8C4-88AE87654340}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E50EEB8B-6B3F-4C8C-A5C6-C37FB87C01E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E50EEB8B-6B3F-4C8C-A5C6-C37FB87C01E2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E50EEB8B-6B3F-4C8C-A5C6-C37FB87C01E2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E50EEB8B-6B3F-4C8C-A5C6-C37FB87C01E2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7063E2D3-C591-4604-A5DD-32D4A1678A58}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7063E2D3-C591-4604-A5DD-32D4A1678A58}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7063E2D3-C591-4604-A5DD-32D4A1678A58}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7063E2D3-C591-4604-A5DD-32D4A1678A58}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B0055B76-4685-4ECF-A904-88EE4E6FC8F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B0055B76-4685-4ECF-A904-88EE4E6FC8F0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B0055B76-4685-4ECF-A904-88EE4E6FC8F0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B0055B76-4685-4ECF-A904-88EE4E6FC8F0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F8762EE5-3461-4F6B-8C24-C876B6D9E637}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F8762EE5-3461-4F6B-8C24-C876B6D9E637}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F8762EE5-3461-4F6B-8C24-C876B6D9E637}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F8762EE5-3461-4F6B-8C24-C876B6D9E637}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3A92C2E7-3033-4FDF-8DDC-5DF43D290537}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3A92C2E7-3033-4FDF-8DDC-5DF43D290537}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3A92C2E7-3033-4FDF-8DDC-5DF43D290537}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3A92C2E7-3033-4FDF-8DDC-5DF43D290537}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A5580114-C236-494E-851C-A21E3DB86FC8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A5580114-C236-494E-851C-A21E3DB86FC8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A5580114-C236-494E-851C-A21E3DB86FC8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A5580114-C236-494E-851C-A21E3DB86FC8}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3725A78B-B6B5-4379-9DE0-37A180ADE95A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3725A78B-B6B5-4379-9DE0-37A180ADE95A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3725A78B-B6B5-4379-9DE0-37A180ADE95A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3725A78B-B6B5-4379-9DE0-37A180ADE95A}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -210,7 +246,13 @@ Global
{A0F89B8B-32A3-4C28-8F1B-ADE343F16137} = {73A5C363-CA1F-44C4-9A9B-EF791A76BA6A}
{69A0ACF2-DF1F-4F49-B554-F732DCA938A3} = {73A5C363-CA1F-44C4-9A9B-EF791A76BA6A}
{59889284-FFEE-82E7-94CB-3B43E87DA6CF} = {73A5C363-CA1F-44C4-9A9B-EF791A76BA6A}
- {190742E1-FA50-4E36-A8C4-88AE87654340} = {5FA0958E-6EBD-45F4-808E-3447A293F96F}
+ {E50EEB8B-6B3F-4C8C-A5C6-C37FB87C01E2} = {00CA0876-DA9F-44E8-B0DC-A88716BF347A}
+ {7063E2D3-C591-4604-A5DD-32D4A1678A58} = {73A5C363-CA1F-44C4-9A9B-EF791A76BA6A}
+ {B0055B76-4685-4ECF-A904-88EE4E6FC8F0} = {C0132984-68D1-4A97-8F8C-AD4E2EECC583}
+ {F8762EE5-3461-4F6B-8C24-C876B6D9E637} = {00CA0876-DA9F-44E8-B0DC-A88716BF347A}
+ {3A92C2E7-3033-4FDF-8DDC-5DF43D290537} = {00CA0876-DA9F-44E8-B0DC-A88716BF347A}
+ {A5580114-C236-494E-851C-A21E3DB86FC8} = {00CA0876-DA9F-44E8-B0DC-A88716BF347A}
+ {3725A78B-B6B5-4379-9DE0-37A180ADE95A} = {00CA0876-DA9F-44E8-B0DC-A88716BF347A}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {193AA352-6748-47C1-A5FC-C9AA6B5F000B}
diff --git a/StackExchange.Redis.sln.DotSettings b/StackExchange.Redis.sln.DotSettings
index b72a49d2c..0c18b97d4 100644
--- a/StackExchange.Redis.sln.DotSettings
+++ b/StackExchange.Redis.sln.DotSettings
@@ -1,5 +1,13 @@
OK
PONG
+ RES
+ SE
+ True
+ True
+ True
+ True
+ True
True
- True
\ No newline at end of file
+ True
+
diff --git a/docs/ReleaseNotes.md b/docs/ReleaseNotes.md
index 185a679f4..8b24ea1b8 100644
--- a/docs/ReleaseNotes.md
+++ b/docs/ReleaseNotes.md
@@ -26,7 +26,7 @@ Current package versions:
- Add support for XPENDING Idle time filter ([#2822 by david-brink-talogy](https://github.com/StackExchange/StackExchange.Redis/pull/2822))
- Improve `double` formatting performance on net8+ ([#2928 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2928))
- Add `GetServer(RedisKey, ...)` API ([#2936 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2936))
-- Fix error constructing `StreamAdd` message ([#2941 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2941))
+- Fix error constructing `StreamAdd` message ([#2941 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2941))
## 2.8.58
diff --git a/eng/StackExchange.Redis.Build/EasyArray.cs b/eng/StackExchange.Redis.Build/EasyArray.cs
new file mode 100644
index 000000000..400438e1c
--- /dev/null
+++ b/eng/StackExchange.Redis.Build/EasyArray.cs
@@ -0,0 +1,55 @@
+using System.Collections;
+
+namespace StackExchange.Redis.Build;
+
+///
+/// Think ImmutableArray{T}, but with structural equality.
+///
+/// The data being wrapped.
+internal readonly struct EasyArray(T[]? array) : IEquatable>, IEnumerable
+{
+ public static readonly EasyArray Empty = new([]);
+ private readonly T[]? _array = array ?? [];
+ public int Length => _array?.Length ?? 0;
+ public ref readonly T this[int index] => ref _array![index];
+ public ReadOnlySpan Span => _array.AsSpan();
+ public bool IsEmpty => Length == 0;
+
+ public static bool operator ==(EasyArray x, EasyArray y)
+ => x.Equals(y);
+
+ public static bool operator !=(EasyArray x, EasyArray y)
+ => x.Equals(y);
+
+ public bool Equals(EasyArray other)
+ {
+ T[]? tArr = this._array, oArr = other._array;
+ if (tArr is null) return oArr is null || oArr.Length == 0;
+ if (oArr is null) return tArr.Length == 0;
+
+ if (tArr.Length != oArr.Length) return false;
+ for (int i = 0; i < tArr.Length; i++)
+ {
+ if (ReferenceEquals(tArr[i], oArr[i]))
+ return false;
+ }
+ return true;
+ }
+
+ public IEnumerator GetEnumerator() => ((IEnumerable)(_array ?? [])).GetEnumerator();
+
+ public override bool Equals(object? obj)
+ => obj is EasyArray other && Equals(other);
+
+ public override int GetHashCode()
+ {
+ var arr = _array;
+ if (arr is null) return 0;
+ // use length and first item for a quick hash
+ return arr.Length == 0
+ ? 0
+ : arr.Length ^ EqualityComparer.Default.GetHashCode(arr[0]);
+ }
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+}
diff --git a/eng/StackExchange.Redis.Build/IsExternalInit.cs b/eng/StackExchange.Redis.Build/IsExternalInit.cs
new file mode 100644
index 000000000..64f57fd4a
--- /dev/null
+++ b/eng/StackExchange.Redis.Build/IsExternalInit.cs
@@ -0,0 +1,7 @@
+// ReSharper disable once CheckNamespace
+namespace System.Runtime.CompilerServices;
+#if !NET5_0_OR_GREATER
+internal static class IsExternalInit
+{
+}
+#endif
diff --git a/eng/StackExchange.Redis.Build/RespCommandGenerator.cs b/eng/StackExchange.Redis.Build/RespCommandGenerator.cs
new file mode 100644
index 000000000..3ebef397a
--- /dev/null
+++ b/eng/StackExchange.Redis.Build/RespCommandGenerator.cs
@@ -0,0 +1,1515 @@
+using System.Collections.Immutable;
+using System.Diagnostics;
+using System.Globalization;
+using System.Reflection;
+using System.Text;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace StackExchange.Redis.Build;
+
+[Generator(LanguageNames.CSharp)]
+public class RespCommandGenerator : IIncrementalGenerator
+{
+ [Flags]
+ private enum LiteralFlags
+ {
+ None = 0,
+ Suffix = 1 << 0, // else prefix
+ // optional, etc
+ }
+
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ var literals = context.SyntaxProvider
+ .CreateSyntaxProvider(Predicate, Transform)
+ .Where(pair => pair.MethodName is { Length: > 0 })
+ .Collect();
+
+ context.RegisterSourceOutput(literals, Generate);
+ }
+
+ private bool Predicate(SyntaxNode node, CancellationToken cancellationToken)
+ {
+ // looking for [FastHash] partial static class Foo { }
+ if (node is MethodDeclarationSyntax decl
+ && decl.Modifiers.Any(SyntaxKind.PartialKeyword))
+ {
+ foreach (var attribList in decl.AttributeLists)
+ {
+ foreach (var attrib in attribList.Attributes)
+ {
+ if (attrib.Name.ToString() is "RespCommandAttribute" or "RespCommand") return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private readonly record struct LiteralTuple(string Token, LiteralFlags Flags);
+
+ private readonly record struct ParameterTuple(
+ string Type,
+ string Name,
+ string Modifiers,
+ ParameterFlags Flags,
+ EasyArray Literals,
+ string? ElementType,
+ string? IgnoreExpression,
+ int ArgIndex)
+ {
+ // variable if collection, nullable, or an explicit ignore expression
+ public bool IsVariable => OptionalReasons != 0;
+
+ public ParameterFlags OptionalReasons =>
+ Flags & (ParameterFlags.Collection | ParameterFlags.Nullable | ParameterFlags.IgnoreExpression);
+
+ public bool IsCollection => (Flags & ParameterFlags.Collection) != 0;
+ public bool IsNullable => (Flags & ParameterFlags.Nullable) != 0;
+ }
+
+ private readonly record struct MethodTuple(
+ string Namespace,
+ string TypeName,
+ string ReturnType,
+ string MethodName,
+ string Command,
+ EasyArray Parameters,
+ string TypeModifiers,
+ string MethodModifiers,
+ string Context,
+ string? Formatter,
+ string? Parser,
+ MethodFlags Flags,
+ string DebugNotes)
+ {
+ public bool IsRespOperation => (Flags & MethodFlags.RespOperation) != 0;
+ }
+
+ private static string GetFullName(ITypeSymbol type) =>
+ type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+
+ private enum RESPite
+ {
+ RespContext,
+ RespCommandAttribute,
+ RespKeyAttribute,
+ RespPrefixAttribute,
+ RespSuffixAttribute,
+ RespOperation,
+ RespIgnoreAttribute,
+ }
+
+ private static bool IsRESPite(ITypeSymbol? symbol, RESPite type)
+ {
+ static string NameOf(RESPite type) => type switch
+ {
+ RESPite.RespContext => nameof(RESPite.RespContext),
+ RESPite.RespCommandAttribute => nameof(RESPite.RespCommandAttribute),
+ RESPite.RespKeyAttribute => nameof(RESPite.RespKeyAttribute),
+ RESPite.RespPrefixAttribute => nameof(RESPite.RespPrefixAttribute),
+ RESPite.RespSuffixAttribute => nameof(RESPite.RespSuffixAttribute),
+ RESPite.RespOperation => nameof(RESPite.RespOperation),
+ RESPite.RespIgnoreAttribute => nameof(RESPite.RespIgnoreAttribute),
+ _ => type.ToString(),
+ };
+
+ if (symbol is INamedTypeSymbol named && named.Name == NameOf(type))
+ {
+ // looking likely; check namespace
+ if (named.ContainingNamespace is { Name: "RESPite", ContainingNamespace.IsGlobalNamespace: true })
+ {
+ return true;
+ }
+
+ // if the type doesn't resolve: we're going to need to trust it
+ if (named.TypeKind == TypeKind.Error) return true;
+ }
+
+ return false;
+ }
+
+ private enum SERedis
+ {
+ CommandFlags,
+ RedisValue,
+ RedisKey,
+ }
+
+ private static bool IsSERedis(ITypeSymbol? symbol, SERedis type)
+ {
+ static string NameOf(SERedis type) => type switch
+ {
+ SERedis.CommandFlags => nameof(SERedis.CommandFlags),
+ SERedis.RedisValue => nameof(SERedis.RedisValue),
+ SERedis.RedisKey => nameof(SERedis.RedisKey),
+ _ => type.ToString(),
+ };
+
+ if (symbol is INamedTypeSymbol named && named.Name == NameOf(type))
+ {
+ // looking likely; check namespace
+ if (named.ContainingNamespace is
+ {
+ Name: "Redis", ContainingNamespace:
+ {
+ Name: "StackExchange",
+ ContainingNamespace.IsGlobalNamespace: true,
+ }
+ })
+ {
+ return true;
+ }
+
+ // if the type doesn't resolve: we're going to need to trust it
+ if (named.TypeKind == TypeKind.Error) return true;
+ }
+
+ return false;
+ }
+
+ private static string GetName(ITypeSymbol type)
+ {
+ if (type.ContainingType is null) return type.Name;
+ var stack = new Stack();
+ while (true)
+ {
+ stack.Push(type.Name);
+ if (type.ContainingType is null) break;
+ type = type.ContainingType;
+ }
+
+ var sb = new StringBuilder(stack.Pop());
+ while (stack.Count != 0)
+ {
+ sb.Append('.').Append(stack.Pop());
+ }
+
+ return sb.ToString();
+ }
+
+ [Conditional("DEBUG")]
+ private static void AddNotes(ref string notes, string note)
+ {
+ if (string.IsNullOrWhiteSpace(notes))
+ {
+ notes = note;
+ }
+ else
+ {
+ notes += "; " + note;
+ }
+ }
+
+ private MethodTuple Transform(
+ GeneratorSyntaxContext ctx,
+ CancellationToken cancellationToken)
+ {
+ // extract the name and value (defaults to name, but can be overridden via attribute) and the location
+ if (ctx.SemanticModel.GetDeclaredSymbol(ctx.Node) is not IMethodSymbol method) return default;
+ if (!(method is { IsPartialDefinition: true, PartialImplementationPart: null })) return default;
+
+ MethodFlags methodFlags = 0;
+ string returnType, debugNote = "";
+ if (method.ReturnsVoid)
+ {
+ returnType = "";
+ }
+ // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
+ else if (method.ReturnType is null)
+ {
+ return default;
+ }
+ else
+ {
+ ITypeSymbol? rt = method.ReturnType;
+ if (IsRespOperation(ref rt))
+ {
+ methodFlags |= MethodFlags.RespOperation;
+ }
+ returnType = rt is null ? "" : GetFullName(rt);
+ }
+
+ string ns = "", parentType = "";
+ if (method.ContainingType is { } containingType)
+ {
+ parentType = GetName(containingType);
+ ns = containingType.ContainingNamespace.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat);
+ }
+ else if (method.ContainingNamespace is { } containingNamespace)
+ {
+ ns = containingNamespace.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat);
+ }
+
+ string value = method.Name.ToLowerInvariant();
+ string? formatter = null, parser = null;
+ foreach (var attrib in method.GetAttributes())
+ {
+ if (IsRESPite(attrib.AttributeClass, RESPite.RespCommandAttribute))
+ {
+ if (attrib.ConstructorArguments.Length == 1)
+ {
+ if (attrib.ConstructorArguments[0].Value?.ToString() is { Length: > 0 } val)
+ {
+ value = val;
+ }
+ }
+
+ foreach (var tuple in attrib.NamedArguments)
+ {
+ switch (tuple.Key)
+ {
+ case "Formatter":
+ formatter = tuple.Value.Value?.ToString();
+ AddNotes(ref debugNote, $"custom formatter: '{formatter}'");
+ break;
+ case "Parser":
+ parser = tuple.Value.Value?.ToString();
+ AddNotes(ref debugNote, $"custom parser: '{parser}'");
+ break;
+ }
+ }
+
+ break; // we don't expect another [RespCommand]
+ }
+ }
+
+ var parameters = new List(method.Parameters.Length);
+
+ // get context from the available fields
+ string? context = null;
+ IParameterSymbol? contextParam = null;
+ foreach (var param in method.Parameters)
+ {
+ if (IsRESPite(param.Type, RESPite.RespContext))
+ {
+ contextParam = param;
+ context = param.Name;
+ break;
+ }
+ }
+
+ if (context is null)
+ {
+ AddNotes(ref debugNote, $"checking {method.ContainingType.Name} for fields");
+ foreach (var member in method.ContainingType.GetMembers())
+ {
+ if (member is IFieldSymbol { IsStatic: false } field)
+ {
+ if (IsRESPite(field.Type, RESPite.RespContext))
+ {
+ AddNotes(ref debugNote, $"{field.Name} WAS match - {field.Type.Name}");
+ context = field.Name;
+ break;
+ }
+ }
+ }
+ }
+
+ if (context is null)
+ {
+ // get context from primary constructor (actually, we look at all constructors,
+ // and just hope that the one that matches: works!)
+ foreach (var ctor in method.ContainingType.Constructors)
+ {
+ if (ctor.IsStatic) continue;
+ foreach (var param in ctor.Parameters)
+ {
+ if (IsRESPite(param.Type, RESPite.RespContext))
+ {
+ context = param.Name;
+ break;
+ }
+ }
+
+ if (context is not null) break;
+ }
+ }
+
+ if (context is null)
+ {
+ // look for indirect from parameter
+ foreach (var param in method.Parameters)
+ {
+ if (IsIndirectRespContext(param.Type, out var memberName))
+ {
+ contextParam = param;
+ context = $"{param.Name}.{memberName}";
+ break;
+ }
+ }
+ }
+
+ if (context is null)
+ {
+ // look for indirect from field
+ foreach (var member in method.ContainingType.GetMembers())
+ {
+ if (member is IFieldSymbol { IsStatic: false } field &&
+ IsIndirectRespContext(field.Type, out var memberName))
+ {
+ context = $"{field.Name}.{memberName}";
+ break;
+ }
+ }
+ }
+
+ // See whether instead of x (param, etc.) *being* a RespContext, it could be something that *provides*
+ // a RespContext; this is especially useful for using punned structs (that just wrap a RespContext) to
+ // narrow the methods into logical groups, i.e. "strings", "hashes", etc.
+ static bool IsIndirectRespContext(ITypeSymbol type, out string memberName)
+ {
+ foreach (var member in type.GetMembers())
+ {
+ if (member is IFieldSymbol { IsStatic: false } field
+ && IsRESPite(field.Type, RESPite.RespContext))
+ {
+ memberName = field.Name;
+ return true;
+ }
+ }
+
+ foreach (var member in type.GetMembers())
+ {
+ if (member is IPropertySymbol { IsStatic: false } prop
+ && IsRESPite(prop.Type, RESPite.RespContext) && prop.GetMethod is not null)
+ {
+ memberName = prop.Name;
+ return true;
+ }
+ }
+
+ memberName = "";
+ return false;
+ }
+
+ if (context is null)
+ {
+ // last ditch, get context from properties
+ foreach (var member in method.ContainingType.GetMembers())
+ {
+ if (member is IPropertySymbol { IsStatic: false } prop
+ && IsRESPite(prop.Type, RESPite.RespContext) && prop.GetMethod is not null)
+ {
+ context = prop.Name;
+ break;
+ }
+ }
+ }
+
+ int nextArgIndex = 0;
+ foreach (var param in method.Parameters)
+ {
+ string? ignoreExpression = null;
+ var flags = ParameterFlags.Parameter;
+ if (IsKey(param)) flags |= ParameterFlags.Key;
+ var elementType = param.Type;
+ flags |= GetTypeFlags(ref elementType);
+ string? elementTypeName = ReferenceEquals(elementType, param.Type) ? null : GetFullName(elementType);
+ if (IsSERedis(param.Type, SERedis.CommandFlags))
+ {
+ flags |= ParameterFlags.CommandFlags;
+ // magic pattern; we *demand* a method called Context that takes the flags; if this is an extension
+ // method, assume it is on the first parameter
+ if ((methodFlags & MethodFlags.ExtensionMethod) != 0)
+ {
+ context = $"{method.Parameters[0].Name}.Context({param.Name})";
+ }
+ else
+ {
+ context = $"Context({param.Name})";
+ }
+ }
+ else if (IsRESPite(param.Type, RESPite.RespContext))
+ {
+ // ignore it, but no extra flag
+ }
+ else if (contextParam is not null && SymbolEqualityComparer.Default.Equals(param, contextParam))
+ {
+ // ignore it, but no extra flag
+ }
+ else
+ {
+ flags |= ParameterFlags.Data;
+ }
+
+ string modifiers = param.RefKind switch
+ {
+ RefKind.None => "",
+ RefKind.In => "in ",
+ RefKind.Out => "out ",
+ RefKind.Ref => "ref ",
+ _ => "",
+ };
+
+ if (param.Ordinal == 0 && method.IsExtensionMethod)
+ {
+ methodFlags |= MethodFlags.ExtensionMethod;
+ modifiers = "this " + modifiers;
+ }
+
+ List? literals = null;
+
+ void AddLiteral(string token, LiteralFlags literalFlags)
+ {
+ (literals ??= new()).Add(new(token, literalFlags));
+ }
+
+ AddNotes(ref debugNote, $"checking {param.Name} for literals");
+ foreach (var attrib in param.GetAttributes())
+ {
+ if (attrib.ConstructorArguments.Length == 1)
+ {
+ if (IsRESPite(attrib.AttributeClass, RESPite.RespPrefixAttribute))
+ {
+ if (attrib.ConstructorArguments[0].Value?.ToString() is { } val)
+ {
+ AddNotes(ref debugNote, $"prefix {val}");
+ AddLiteral(val, LiteralFlags.None);
+ }
+ }
+
+ if (IsRESPite(attrib.AttributeClass, RESPite.RespSuffixAttribute))
+ {
+ if (attrib.ConstructorArguments[0].Value?.ToString() is { Length: > 0 } val)
+ {
+ AddNotes(ref debugNote, $"suffix {val}");
+ AddLiteral(val, LiteralFlags.Suffix);
+ }
+ }
+
+ if (IsRESPite(attrib.AttributeClass, RESPite.RespIgnoreAttribute))
+ {
+ var val = attrib.ConstructorArguments[0].Value;
+ var expr = val switch
+ {
+ null when IsSERedis(param.Type, SERedis.RedisValue) | IsSERedis(param.Type, SERedis.RedisKey) => ".IsNull is false",
+ string s => " != " + CodeLiteral(s),
+ bool b => b ? " is false" : " is true", // if we *ignore* true, then "incN = foo is false"
+ long l when attrib.ConstructorArguments[0].Type is INamedTypeSymbol { EnumUnderlyingType: not null } enumType
+ => " != " + GetEnumExpression(enumType, l),
+ long l => " != " + l.ToString(CultureInfo.InvariantCulture),
+ int i when attrib.ConstructorArguments[0].Type is INamedTypeSymbol { EnumUnderlyingType: not null } enumType
+ => " != " + GetEnumExpression(enumType, i),
+ int i => " != " + i.ToString(CultureInfo.InvariantCulture),
+ _ => null,
+ };
+
+ if (expr is not null)
+ {
+ flags |= ParameterFlags.IgnoreExpression;
+ ignoreExpression = expr;
+ }
+
+ static string GetEnumExpression(INamedTypeSymbol enumType, object value)
+ {
+ foreach (var member in enumType.GetMembers())
+ {
+ if (member is IFieldSymbol { IsStatic: true, IsConst: true } field
+ && Equals(field.ConstantValue, value))
+ {
+ return $"{GetFullName(enumType)}.{field.Name}";
+ }
+ }
+
+ return $"({GetFullName(enumType)}){value}";
+ }
+ }
+ }
+ }
+
+ var literalArray = literals is null ? EasyArray.Empty : new(literals.ToArray());
+ var argIndex = (flags & ParameterFlags.Data) != 0 ? nextArgIndex++ : -1;
+
+ parameters.Add(new(GetFullName(param.Type), param.Name, modifiers, flags, literalArray, elementTypeName, ignoreExpression, argIndex));
+ }
+
+ var syntax = (MethodDeclarationSyntax)ctx.Node;
+ return new(
+ ns,
+ parentType,
+ returnType,
+ method.Name,
+ value,
+ new(parameters.ToArray()),
+ TypeModifiers(method.ContainingType),
+ syntax.Modifiers.ToString(),
+ context ?? "",
+ formatter,
+ parser,
+ methodFlags,
+ debugNote);
+
+ static string TypeModifiers(ITypeSymbol type)
+ {
+ foreach (var symbol in type.DeclaringSyntaxReferences)
+ {
+ var syntax = symbol.GetSyntax();
+ if (syntax is TypeDeclarationSyntax typeDeclaration)
+ {
+ var mods = typeDeclaration.Modifiers.ToString();
+ return syntax switch
+ {
+ InterfaceDeclarationSyntax => $"{mods} interface",
+ StructDeclarationSyntax => $"{mods} struct",
+ _ => $"{mods} class",
+ };
+ }
+ }
+
+ return "class"; // wut?
+ }
+ }
+
+ private bool IsRespOperation(ref ITypeSymbol? type) // identify RespOperation[]
+ {
+ if (type is INamedTypeSymbol named && IsRESPite(type, RESPite.RespOperation))
+ {
+ if (named.IsGenericType)
+ {
+ if (named.TypeArguments.Length != 1) return false; // unexpected
+ type = named.TypeArguments[0];
+ }
+ else
+ {
+ type = null;
+ }
+ return true;
+ }
+ return false;
+ }
+
+ private static ParameterFlags GetTypeFlags(ref ITypeSymbol paramType)
+ {
+ var flags = ParameterFlags.None;
+ if (paramType.IsValueType) flags |= ParameterFlags.ValueType;
+ switch (paramType.NullableAnnotation)
+ {
+ case NullableAnnotation.Annotated:
+ flags |= ParameterFlags.Nullable;
+ break;
+ case NullableAnnotation.None:
+ if (paramType.IsReferenceType) flags |= ParameterFlags.Nullable;
+ break;
+ }
+
+ if (paramType is IArrayTypeSymbol arr)
+ {
+ if (arr.Rank == 1 && arr.ElementType.SpecialType != SpecialType.System_Byte)
+ {
+ flags |= ParameterFlags.Collection;
+ paramType = arr.ElementType;
+ }
+ }
+
+ if (paramType is INamedTypeSymbol { IsGenericType: true, Arity: 1 } gen)
+ {
+ switch (gen.ConstructedFrom.SpecialType)
+ {
+ case SpecialType.System_Collections_Generic_ICollection_T:
+ case SpecialType.System_Collections_Generic_IList_T:
+ case SpecialType.System_Collections_Generic_IReadOnlyCollection_T:
+ case SpecialType.System_Collections_Generic_IReadOnlyList_T:
+ flags |= ParameterFlags.Collection | ParameterFlags.CollectionWithCount;
+ paramType = gen.TypeArguments[0];
+ break;
+ default:
+ if (IsSystemCollections(gen.ConstructedFrom, "List"))
+ {
+ flags |= ParameterFlags.Collection;
+ paramType = gen.TypeArguments[0];
+ }
+ if (IsSystemCollections(gen.ConstructedFrom, "ImmutableArray", "Immutable"))
+ {
+ flags |= ParameterFlags.Collection | ParameterFlags.ImmutableArray;
+ paramType = gen.TypeArguments[0];
+ }
+ break;
+ }
+ }
+
+ return flags;
+
+ static bool IsSystemCollections(INamedTypeSymbol type, string name, string ns = "Generic")
+ => type.Name == name && type.ContainingNamespace is { } actualNs && actualNs.Name == ns
+ && actualNs.ContainingNamespace is
+ {
+ Name:
+ "Collections",
+ ContainingNamespace:
+ {
+ Name:
+ "System",
+ ContainingNamespace.IsGlobalNamespace: true,
+ }
+ };
+ }
+
+ private bool IsKey(IParameterSymbol param)
+ {
+ if (param.Name.EndsWith("key", StringComparison.InvariantCultureIgnoreCase))
+ {
+ return true;
+ }
+
+ foreach (var attrib in param.GetAttributes())
+ {
+ if (IsRESPite(attrib.AttributeClass, RESPite.RespKeyAttribute)) return true;
+ }
+
+ return false;
+ }
+
+ private string GetVersion()
+ {
+ var asm = GetType().Assembly;
+ if (asm.GetCustomAttributes(typeof(AssemblyFileVersionAttribute), false).FirstOrDefault() is
+ AssemblyFileVersionAttribute { Version: { Length: > 0 } } version)
+ {
+ return version.Version;
+ }
+
+ return asm.GetName().Version?.ToString() ?? "??";
+ }
+
+ private static string CodeLiteral(string value)
+ => SyntaxFactory
+ .LiteralExpression(SyntaxKind.StringLiteralExpression, SyntaxFactory.Literal(value))
+ .ToFullString();
+
+ private void Generate(
+ SourceProductionContext ctx,
+ ImmutableArray methods)
+ {
+ if (methods.IsDefaultOrEmpty) return;
+
+ var sb = new StringBuilder("// ")
+ .AppendLine().Append("// ").Append(GetType().Name).Append(" v").Append(GetVersion()).AppendLine();
+
+ bool first;
+ int indent = 0;
+
+ // find the unique param types, so we can build helpers
+ Dictionary, (string Name,
+ int ShareCount, string Command)>
+ formatters =
+ new(FormatterComparer.Default);
+
+ foreach (var method in methods.AsSpan())
+ {
+ if (method.Formatter is not null) continue; // using explicit formatter
+ var count = DataParameterCount(method.Parameters);
+ switch (count)
+ {
+ case 0: continue; // no parameter to consider
+ case 1:
+ var p = FirstDataParameter(method.Parameters);
+ if (p.Literals.IsEmpty)
+ {
+ // no literals, and basic write scenario;consumer should add their own extension method
+ continue;
+ }
+
+ break;
+ }
+
+ // add a new formatter, or mark an existing formatter as shared
+ var key = method.Parameters;
+ if (!formatters.TryGetValue(key, out var existing))
+ {
+ formatters.Add(key, ($"__RespFormatter_{formatters.Count}", 1, method.Command));
+ }
+ else
+ {
+ formatters[key] = (existing.Name, existing.ShareCount + 1, ""); // incr share count
+ }
+ }
+
+ StringBuilder NewLine() => sb.AppendLine().Append(' ', Math.Max(indent * 4, 0));
+ NewLine().Append("using global::RESPite;");
+ foreach (var method in methods.AsSpan())
+ {
+ if (HasAnyFlag(method.Parameters, ParameterFlags.CommandFlags))
+ {
+ NewLine().Append("using global::RESPite.StackExchange.Redis;");
+ break;
+ }
+ }
+
+ NewLine().Append("using global::System;");
+ NewLine().Append("using global::System.Threading.Tasks;");
+
+ foreach (var grp in methods.GroupBy(l => (l.Namespace, l.TypeName, l.TypeModifiers)))
+ {
+ NewLine();
+ int braces = 0;
+ if (!string.IsNullOrWhiteSpace(grp.Key.Namespace))
+ {
+ NewLine().Append("namespace ").Append(grp.Key.Namespace);
+ NewLine().Append("{");
+ indent++;
+ braces++;
+ }
+
+ if (!string.IsNullOrWhiteSpace(grp.Key.TypeName))
+ {
+ if (grp.Key.TypeName.Contains('.')) // nested types
+ {
+ var tokens = grp.Key.TypeName.Split('.');
+ for (var i = 0; i < tokens.Length; i++)
+ {
+ var part = tokens[i];
+ if (i == tokens.Length - 1)
+ {
+ NewLine().Append(grp.Key.TypeModifiers).Append(' ').Append(part);
+ }
+ else
+ {
+ NewLine().Append("partial class ").Append(part);
+ }
+
+ NewLine().Append("{");
+ indent++;
+ braces++;
+ }
+ }
+ else
+ {
+ NewLine().Append(grp.Key.TypeModifiers).Append(' ').Append(grp.Key.TypeName);
+ NewLine().Append("{");
+ indent++;
+ braces++;
+ }
+ }
+
+ foreach (var method in grp)
+ {
+ if (method.DebugNotes is { Length: > 0 })
+ {
+ NewLine().Append("/* ").Append(method.MethodName).Append(": ")
+ .Append(method.DebugNotes).Append(" */");
+ }
+
+ bool isSharedFormatter = false;
+ string? formatter = method.Formatter
+ ?? InbuiltFormatter(method.Parameters);
+ if (formatter is null && formatters.TryGetValue(method.Parameters, out var tmp))
+ {
+ formatter = $"{tmp.Name}.Default";
+ isSharedFormatter = tmp.ShareCount > 1;
+ }
+
+ // perform string escaping on the generated value (this includes the quotes, note)
+ var csValue = CodeLiteral(method.Command);
+
+ WriteMethod(false);
+ if ((method.Flags & MethodFlags.RespOperation) == 0)
+ {
+ WriteMethod(true); // also write async half
+ }
+
+ void WriteMethod(bool asAsync)
+ {
+ sb = NewLine().Append(asAsync ? RemovePartial(method.MethodModifiers) : method.MethodModifiers)
+ .Append(' ');
+ if (asAsync)
+ {
+ sb.Append(HasAnyFlag(method.Parameters, ParameterFlags.CommandFlags) ? "Task" : "ValueTask");
+ if (!string.IsNullOrWhiteSpace(method.ReturnType))
+ {
+ sb.Append('<').Append(method.ReturnType).Append('>');
+ }
+ }
+ else if (method.IsRespOperation)
+ {
+ sb.Append("global::RESPite.RespOperation");
+ if (!string.IsNullOrWhiteSpace(method.ReturnType))
+ {
+ sb.Append('<').Append(method.ReturnType).Append('>');
+ }
+ }
+ else
+ {
+ sb.Append(string.IsNullOrEmpty(method.ReturnType) ? "void" : method.ReturnType);
+ }
+
+ sb.Append(' ').Append(method.MethodName).Append(asAsync ? "Async" : "").Append("(");
+ first = true;
+ foreach (var param in method.Parameters)
+ {
+ if ((param.Flags & ParameterFlags.Parameter) == 0) continue;
+ if (!first) sb.Append(", ");
+ first = false;
+
+ sb.Append(param.Modifiers).Append(param.Type).Append(' ').Append(param.Name);
+ }
+
+ var dataParameters = DataParameterCount(method.Parameters);
+ sb.Append(")");
+ indent++;
+
+ var parser = method.Parser ?? InbuiltParser(method.ReturnType, explicitSuccess: true);
+ bool useDirectCall = method.Context is { Length: > 0 } & formatter is { Length: > 0 } &
+ parser is { Length: > 0 };
+
+ if (string.IsNullOrWhiteSpace(method.Context))
+ {
+ NewLine().Append("=> throw new NotSupportedException(\"No RespContext available\");");
+ useDirectCall = false;
+ }
+ else if (!(useDirectCall & asAsync))
+ {
+ sb = NewLine();
+ if (useDirectCall) sb.Append("// ");
+ sb.Append("=> ").Append(method.Context).Append(".Command(").Append(csValue).Append("u8");
+ if (dataParameters != 0)
+ {
+ sb.Append(", ");
+ WriteTuple(method.Parameters, sb, TupleMode.Values);
+
+ if (!string.IsNullOrWhiteSpace(formatter))
+ {
+ sb.Append(", ").Append(formatter);
+ }
+ }
+ sb.Append(asAsync | method.IsRespOperation ? ").Send" : ").Wait");
+ if (!string.IsNullOrWhiteSpace(method.ReturnType))
+ {
+ sb.Append('<').Append(method.ReturnType).Append('>');
+ }
+
+ sb.Append("(").Append(parser).Append(")");
+ if (asAsync && HasAnyFlag(method.Parameters, ParameterFlags.CommandFlags))
+ {
+ sb.Append(".AsTask()");
+ }
+
+ sb.Append(';');
+ }
+
+ if (useDirectCall) // avoid the intermediate step when possible
+ {
+ sb = NewLine().Append("=> ").Append(method.Context).Append(".Send")
+ .Append('<');
+ WriteTuple(
+ method.Parameters,
+ sb,
+ isSharedFormatter ? TupleMode.SyntheticNames : TupleMode.NamedTuple);
+ if (!string.IsNullOrWhiteSpace(method.ReturnType))
+ {
+ sb.Append(", ").Append(method.ReturnType);
+ }
+
+ sb.Append(">(").Append(csValue).Append("u8").Append(", ");
+ WriteTuple(method.Parameters, sb, TupleMode.Values);
+ sb.Append(", ").Append(formatter).Append(", ").Append(parser).Append(")");
+ if (asAsync)
+ {
+ sb.Append(HasAnyFlag(method.Parameters, ParameterFlags.CommandFlags)
+ ? ".AsTask()"
+ : ".AsValueTask()");
+ }
+ else if (method.IsRespOperation)
+ {
+ // nothing to do
+ }
+ else
+ {
+ sb.Append(".Wait(");
+ if (HasAnyFlag(method.Parameters, ParameterFlags.CommandFlags))
+ {
+ // to avoid calling Context(flags) twice, we assume that this member will exist
+ sb.Append("SyncTimeout");
+ }
+ else
+ {
+ sb.Append(method.Context).Append(".SyncTimeout");
+ }
+
+ sb.Append(")");
+ }
+
+ sb.Append(";");
+ }
+
+ indent--;
+ NewLine();
+ }
+ }
+
+ // handle any closing braces
+ while (braces-- > 0)
+ {
+ indent--;
+ NewLine().Append("}");
+ }
+
+ NewLine();
+ }
+
+ foreach (var tuple in formatters)
+ {
+ var parameters = tuple.Key;
+ var name = tuple.Value.Name;
+ var names = tuple.Value.ShareCount > 1 ? TupleMode.SyntheticNames : TupleMode.NamedTuple;
+
+ NewLine();
+ if (tuple.Value.ShareCount > 1)
+ {
+ NewLine().Append("// shared by ").Append(tuple.Value.ShareCount).Append(" methods");
+ }
+ else if (tuple.Value.Command is { Length: > 0 })
+ {
+ NewLine().Append("// for command: ").Append(tuple.Value.Command);
+ }
+
+ sb = NewLine().Append("sealed file class ").Append(name)
+ .Append(" : global::RESPite.Messages.IRespFormatter<");
+ WriteTuple(parameters, sb, names);
+ sb.Append('>');
+ NewLine().Append("{");
+ indent++;
+ NewLine().Append("public static readonly ").Append(name).Append(" Default = new();");
+ NewLine();
+
+ sb = NewLine()
+ .Append(
+ "public void Format(scoped ReadOnlySpan command, ref global::RESPite.Messages.RespWriter writer, in ");
+ WriteTuple(parameters, sb, names);
+ sb.Append(" request)");
+ NewLine().Append("{");
+ indent++;
+ var argCount = DataParameterCount(parameters, out int constantCount, out bool isVariable);
+
+ void WriteParameterName(in ParameterTuple p, StringBuilder? target = null)
+ {
+ target ??= sb;
+ if (argCount == 1)
+ {
+ target.Append("request");
+ }
+ else
+ {
+ target.Append("request.");
+ if (names == TupleMode.SyntheticNames)
+ {
+ target.Append("Arg").Append(p.ArgIndex);
+ }
+ else
+ {
+ target.Append(p.Name);
+ }
+ }
+ }
+
+ int index;
+ if (isVariable)
+ {
+ foreach (var parameter in parameters.Span)
+ {
+ if (parameter.IsVariable)
+ {
+ sb = NewLine().Append("bool __inc").Append(parameter.ArgIndex).Append(" = ");
+ WriteParameterName(parameter);
+ switch (parameter.OptionalReasons)
+ {
+ case ParameterFlags.Nullable:
+ sb.Append(" is not null");
+ break;
+ case ParameterFlags.Nullable | ParameterFlags.IgnoreExpression:
+ sb.Append(" is { } __val").Append(parameter.ArgIndex)
+ .Append(" && __val").Append(parameter.ArgIndex)
+ .Append(parameter.IgnoreExpression);
+ break;
+ case ParameterFlags.IgnoreExpression:
+ sb.Append(parameter.IgnoreExpression);
+ break;
+ case ParameterFlags.Collection:
+ // non-nullable collection; literals already handled
+ switch (parameter.Flags & (ParameterFlags.CollectionWithCount | ParameterFlags.ImmutableArray))
+ {
+ case ParameterFlags.CollectionWithCount:
+ sb.Append(".Count != 0");
+ break;
+ case ParameterFlags.ImmutableArray: // needs special care because of default (breaks .Length)
+ sb.Append(".IsDefaultOrEmpty == false");
+ break;
+ default:
+ sb.Append(".Length != 0");
+ break;
+ }
+ break;
+ case ParameterFlags.Collection | ParameterFlags.Nullable:
+ sb.Append(" is { ");
+ switch (parameter.Flags & (ParameterFlags.CollectionWithCount | ParameterFlags.ImmutableArray))
+ {
+ case ParameterFlags.CollectionWithCount:
+ sb.Append("Count: > 0");
+ break;
+ case ParameterFlags.ImmutableArray: // needs special care because of default (breaks .Length)
+ sb.Append("IsDefaultOrEmpty: false");
+ break;
+ default:
+ sb.Append("Length: > 0");
+ break;
+ }
+ sb.Append("}");
+ break;
+ default:
+ sb.Append($" false /* unhandled combination! */");
+ break;
+ }
+ sb.Append("; // ").Append(parameter.OptionalReasons);
+ }
+ }
+
+ sb = NewLine().Append("writer.WriteCommand(command,");
+ bool firstVariableItem = true;
+ if (constantCount != 0)
+ {
+ sb.Append(" ").Append(constantCount).Append(" // constant args");
+ firstVariableItem = false;
+ }
+ indent++;
+ index = 0;
+ foreach (var parameter in parameters.Span)
+ {
+ if (parameter.IsVariable)
+ {
+ sb = NewLine();
+ if (firstVariableItem)
+ {
+ firstVariableItem = false;
+ }
+ else
+ {
+ sb.Append("+ ");
+ }
+ sb.Append("(__inc").Append(parameter.ArgIndex).Append(" ? ");
+ var literalCount = parameter.Literals.Length;
+ if (!parameter.IsCollection)
+ {
+ sb.Append(1 + literalCount);
+ }
+ else
+ {
+ if (literalCount != 0) sb.Append("(");
+ WriteParameterName(parameter);
+ if (parameter.IsNullable) sb.Append("!");
+ sb.Append((parameter.Flags & ParameterFlags.CollectionWithCount) == 0 ? ".Length" : ".Count");
+ if (literalCount != 0) sb.Append(" + ").Append(literalCount).Append(")");
+ }
+
+ sb.Append(" : 0)");
+ if (!parameter.IsCollection)
+ {
+ // help identify what this is (not needed for collections, since foo.Count etc)
+ sb.Append(" // ");
+ WriteParameterName(parameter);
+ if (tuple.Value.ShareCount != 1) sb.Append(" (").Append(parameter.Name).Append(")"); // give an example
+ }
+
+ if (literalCount != 0)
+ {
+ if (parameter.IsCollection) sb.Append(" //");
+ sb.Append(" with");
+ foreach (var literal in parameter.Literals.Span)
+ {
+ sb.Append(" ").Append(string.IsNullOrEmpty(literal.Token) ? "(count)" : literal.Token);
+ }
+ }
+ }
+ index++;
+ }
+ NewLine().Append(");");
+ indent--;
+ }
+ else if (tuple.Value.Command is { Length: > 0 } cmd
+ && Encoding.UTF8.GetByteCount(cmd) == cmd.Length) // check pure ASCII
+ {
+ // only used by one command; allow optimization
+ NewLine().Append("if(writer.CommandMap is null)");
+ NewLine().Append("{");
+ indent++;
+ string raw = $"*{constantCount + 1}\r\n${cmd.Length}\r\n{tuple.Value.Command}\r\n";
+ sb = NewLine().Append("writer.WriteRaw(").Append(CodeLiteral(raw)).Append("u8); // ")
+ .Append(cmd).Append(" with ").Append(constantCount).Append(" args");
+ indent--;
+ NewLine().Append("}");
+ NewLine().Append("else");
+ NewLine().Append("{");
+ indent++;
+ NewLine().Append("writer.WriteCommand(command, ").Append(constantCount).Append(");");
+ indent--;
+ NewLine().Append("}");
+ }
+ else
+ {
+ NewLine().Append("writer.WriteCommand(command, ").Append(constantCount).Append(");");
+ }
+
+ void WritePrefix(in ParameterTuple p) => WriteLiteral(p, false);
+ void WriteSuffix(in ParameterTuple p) => WriteLiteral(p, true);
+
+ void WriteLiteral(in ParameterTuple p, bool suffix)
+ {
+ LiteralFlags match = suffix ? LiteralFlags.Suffix : LiteralFlags.None;
+ foreach (var literal in p.Literals.Span)
+ {
+ if ((literal.Flags & LiteralFlags.Suffix) == match)
+ {
+ if (string.IsNullOrEmpty(literal.Token))
+ {
+ if (p.IsCollection)
+ {
+ sb = NewLine().Append("writer.WriteBulkString(");
+ WriteParameterName(p);
+ if (p.IsNullable) sb.Append("!");
+ sb.Append((p.Flags & ParameterFlags.CollectionWithCount) == 0 ? ".Length" : ".Count")
+ .Append(");");
+ }
+ else
+ {
+ NewLine().Append("#error empty literal for ").Append(p.Name).AppendLine();
+ }
+ }
+ else
+ {
+ var len = Encoding.UTF8.GetByteCount(literal.Token);
+ var resp = $"${len}\r\n{literal.Token}\r\n";
+ NewLine().Append("writer.WriteRaw(").Append(CodeLiteral(resp)).Append("u8); // ")
+ .Append(literal.Token);
+ }
+ }
+ }
+ }
+
+ index = 0;
+ foreach (var parameter in parameters.Span)
+ {
+ if ((parameter.Flags & ParameterFlags.DataParameter) == ParameterFlags.DataParameter)
+ {
+ if (parameter.IsVariable)
+ {
+ sb = NewLine().Append("if (__inc").Append(parameter.ArgIndex).Append(")");
+ NewLine().Append("{");
+ indent++;
+ }
+
+ WritePrefix(parameter);
+ var elementType = parameter.ElementType ?? parameter.Type;
+ if (parameter.IsCollection)
+ {
+ sb = NewLine().Append("foreach (").Append(elementType).Append(" val in ");
+ WriteParameterName(parameter);
+ if (parameter.IsNullable) sb.Append("!");
+ sb.Append(")");
+ NewLine().Append("{");
+ indent++;
+ }
+
+ sb = NewLine().Append("writer.");
+ if (elementType is "global::StackExchange.Redis.RedisValue"
+ or "global::StackExchange.Redis.RedisKey")
+ {
+ sb.Append("Write");
+ }
+ else
+ {
+ sb.Append((parameter.Flags & ParameterFlags.Key) == 0 ? "WriteBulkString" : "WriteKey");
+ }
+
+ sb.Append("(");
+ if (parameter.IsCollection)
+ {
+ sb.Append("val");
+ }
+ else
+ {
+ WriteParameterName(parameter);
+ }
+ sb.Append(");");
+
+ if (parameter.IsCollection)
+ {
+ indent--;
+ NewLine().Append("}");
+ }
+
+ WriteSuffix(parameter);
+ if (parameter.IsVariable)
+ {
+ indent--;
+ NewLine().Append("}");
+ }
+ index++;
+ }
+ }
+
+ Debug.Assert(index == argCount, "wrote all parameters");
+
+ indent--;
+ NewLine().Append("}");
+ indent--;
+ NewLine().Append("}");
+ }
+
+ NewLine();
+ ctx.AddSource(GetType().Name + ".generated.cs", sb.ToString());
+
+ static void WriteTuple(
+ EasyArray parameters,
+ StringBuilder sb,
+ TupleMode mode)
+ {
+ var count = DataParameterCount(parameters);
+ if (count == 0) return;
+ if (count < 2)
+ {
+ var p = FirstDataParameter(parameters);
+ sb.Append(mode == TupleMode.Values ? p.Name : p.Type);
+ return;
+ }
+
+ sb.Append('(');
+ int index = 0;
+ foreach (var param in parameters.Span)
+ {
+ if ((param.Flags & ParameterFlags.DataParameter) != ParameterFlags.DataParameter)
+ {
+ continue; // note don't increase index
+ }
+
+ if (index != 0) sb.Append(", ");
+
+ switch (mode)
+ {
+ case TupleMode.Values:
+ sb.Append(param.Name);
+ break;
+ case TupleMode.AnonTuple:
+ sb.Append(param.Type);
+ break;
+ case TupleMode.NamedTuple:
+ sb.Append(param.Type).Append(' ').Append(param.Name);
+ break;
+ case TupleMode.SyntheticNames:
+ sb.Append(param.Type).Append(" Arg").Append(index);
+ break;
+ }
+
+ index++;
+ }
+
+ sb.Append(')');
+ }
+ }
+
+ private static bool HasAnyFlag(
+ EasyArray parameters,
+ ParameterFlags any)
+ {
+ foreach (var p in parameters.Span)
+ {
+ if ((p.Flags & any) != 0) return true;
+ }
+
+ return false;
+ }
+
+ private static string? InbuiltFormatter(
+ EasyArray parameters)
+ {
+ if (DataParameterCount(parameters) == 1)
+ {
+ var p = FirstDataParameter(parameters);
+ if (p.Literals.IsEmpty)
+ {
+ // can only use the inbuilt formatter if there are no literals
+ return InbuiltFormatter(p.Type, (p.Flags & ParameterFlags.Key) != 0);
+ }
+ }
+
+ return null;
+ }
+
+ private static ParameterTuple FirstDataParameter(
+ EasyArray parameters)
+ {
+ if (!parameters.IsEmpty)
+ {
+ foreach (var parameter in parameters.Span)
+ {
+ if ((parameter.Flags & ParameterFlags.DataParameter) == ParameterFlags.DataParameter)
+ {
+ return parameter;
+ }
+ }
+ }
+
+ return Array.Empty().First();
+ }
+
+ private static int DataParameterCount(
+ EasyArray parameters)
+ => DataParameterCount(parameters, out _, out _);
+
+ private static int DataParameterCount(
+ EasyArray parameters, out int constantCount, out bool isVariable)
+ {
+ // note: constantCount includes literals
+ constantCount = 0;
+ isVariable = false;
+ if (parameters.IsEmpty) return 0;
+ int count = 0;
+ foreach (var parameter in parameters.Span)
+ {
+ if ((parameter.Flags & ParameterFlags.DataParameter) == ParameterFlags.DataParameter)
+ {
+ bool thisParamIsVariable = false;
+ count++;
+ if (parameter.IsVariable)
+ {
+ isVariable = thisParamIsVariable = true;
+ }
+ else
+ {
+ constantCount++;
+ }
+
+ if (!(thisParamIsVariable | parameter.Literals.IsEmpty))
+ {
+ constantCount += parameter.Literals.Length; // we include literals if not variable
+ }
+ }
+ }
+
+ return count;
+ }
+
+ private const string RespFormattersPrefix = "global::RESPite.RespFormatters.";
+
+ private static string? InbuiltFormatter(string type, bool isKey) => type switch
+ {
+ "string" => isKey ? (RespFormattersPrefix + "Key.String") : (RespFormattersPrefix + "Value.String"),
+ "byte[]" => isKey ? (RespFormattersPrefix + "Key.ByteArray") : (RespFormattersPrefix + "Value.ByteArray"),
+ "int" => RespFormattersPrefix + "Int32",
+ "long" => RespFormattersPrefix + "Int64",
+ "float" => RespFormattersPrefix + "Single",
+ "double" => RespFormattersPrefix + "Double",
+ "" => RespFormattersPrefix + "Empty",
+ "global::StackExchange.Redis.RedisKey" => "global::RESPite.StackExchange.Redis.RespFormatters.RedisKey",
+ "global::StackExchange.Redis.RedisKey[]" => "global::RESPite.StackExchange.Redis.RespFormatters.RedisKeyArray",
+ "global::StackExchange.Redis.RedisValue" => "global::RESPite.StackExchange.Redis.RespFormatters.RedisValue",
+ _ => null,
+ };
+
+ private const string RespParsersPrefix = "global::RESPite.RespParsers.";
+
+ private static string? InbuiltParser(string type, bool explicitSuccess = false) => type switch
+ {
+ "" when explicitSuccess => RespParsersPrefix + "Success",
+ "bool" => RespParsersPrefix + "Success",
+ "string" => RespParsersPrefix + "String",
+ "int" => RespParsersPrefix + "Int32",
+ "long" => RespParsersPrefix + "Int64",
+ "float" => RespParsersPrefix + "Single",
+ "double" => RespParsersPrefix + "Double",
+ "int?" => RespParsersPrefix + "NullableInt32",
+ "long?" => RespParsersPrefix + "NullableInt64",
+ "float?" => RespParsersPrefix + "NullableSingle",
+ "double?" => RespParsersPrefix + "NullableDouble",
+ "global::RESPite.RespParsers.ResponseSummary" => RespParsersPrefix + "ResponseSummary.Parser",
+ "global::StackExchange.Redis.RedisKey" => "global::RESPite.StackExchange.Redis.RespParsers.RedisKey",
+ "global::StackExchange.Redis.RedisValue" => "global::RESPite.StackExchange.Redis.RespParsers.RedisValue",
+ "global::StackExchange.Redis.RedisValue[]" => "global::RESPite.StackExchange.Redis.RespParsers.RedisValueArray",
+ "global::StackExchange.Redis.HashEntry[]" => "global::RESPite.StackExchange.Redis.RespParsers.HashEntryArray",
+ "global::StackExchange.Redis.SortedSetEntry[]" => "global::RESPite.StackExchange.Redis.RespParsers.SortedSetEntryArray",
+ "global::StackExchange.Redis.SortedSetEntry?" => "global::RESPite.StackExchange.Redis.RespParsers.SortedSetEntry",
+ "global::StackExchange.Redis.Lease" => "global::RESPite.StackExchange.Redis.RespParsers.BytesLease",
+ _ => null,
+ };
+
+ private enum TupleMode
+ {
+ AnonTuple,
+ NamedTuple,
+ Values,
+ SyntheticNames,
+ }
+
+ private static string RemovePartial(string modifiers)
+ {
+ if (string.IsNullOrWhiteSpace(modifiers) || !modifiers.Contains("partial")) return modifiers;
+ if (modifiers == "partial") return "";
+ if (modifiers.StartsWith("partial ")) return modifiers.Substring(8);
+ if (modifiers.EndsWith(" partial")) return modifiers.Substring(0, modifiers.Length - 8);
+ return modifiers.Replace(" partial ", " ");
+ }
+
+ [Flags]
+ private enum MethodFlags
+ {
+ None = 0,
+ RespOperation = 1 << 0,
+ ExtensionMethod = 1 << 1,
+ }
+
+ [Flags]
+ private enum ParameterFlags
+ {
+ // ReSharper disable once UnusedMember.Local
+ None = 0,
+ Parameter = 1 << 0,
+ Data = 1 << 1,
+ DataParameter = Data | Parameter,
+ Key = 1 << 2,
+ CommandFlags = 1 << 3,
+ ValueType = 1 << 4,
+ Nullable = 1 << 5,
+ Collection = 1 << 6,
+ CollectionWithCount = 1 << 7, // has .Count, otherwise assumed to have .Length
+ ImmutableArray = 1 << 8,
+ IgnoreExpression = 1 << 9,
+ }
+
+ // compares whether a formatter can be shared, which depends on the key index and types (not names)
+ private sealed class
+ FormatterComparer
+ : IEqualityComparer>
+ {
+ private FormatterComparer() { }
+ public static readonly FormatterComparer Default = new();
+
+ public bool Equals(
+ EasyArray x,
+ EasyArray y)
+ {
+ if (x.Length != y.Length) return false;
+ for (int i = 0; i < x.Length; i++)
+ {
+ var px = x[i];
+ var py = y[i];
+ if (px.Type != py.Type || px.Flags != py.Flags) return false;
+ // literals need to match by name too
+ if (!px.Literals.SequenceEqual(py.Literals)) return false;
+ }
+
+ return true;
+ }
+
+ public int GetHashCode(
+ EasyArray obj)
+ {
+ var hash = obj.Length;
+ foreach (var p in obj.Span)
+ {
+ hash ^= p.Type.GetHashCode() ^ (int)p.Flags ^ p.Literals.Length;
+ }
+
+ return hash;
+ }
+ }
+}
diff --git a/eng/StackExchange.Redis.Build/RespCommandGenerator.md b/eng/StackExchange.Redis.Build/RespCommandGenerator.md
new file mode 100644
index 000000000..1a3ec5fbc
--- /dev/null
+++ b/eng/StackExchange.Redis.Build/RespCommandGenerator.md
@@ -0,0 +1,18 @@
+# RespCommandGenerator
+
+Emit basic RESP command bodies.
+
+The purpose of this generator is to interpret inputs like:
+
+``` c#
+[RespCommand] // optional: include explicit command text
+public int void Foo(string key, int delta, double x);
+```
+
+and implement the relevant sync and async core logic, including
+implementing a custom `IRespFormatter<(string, int, double)>`. Note that
+the formatter can be reused between commands, so the names are not used internally.
+
+Note that parameters named `key` are detected automatically for sharding purposes;
+when this is not suitable,`[Key]` can be used instead to denote a parameter to use
+for sharding - for example `partial void Rename([Key] string fromKey, string toKey)`.
\ No newline at end of file
diff --git a/eng/StackExchange.Redis.Build/StackExchange.Redis.Build.csproj b/eng/StackExchange.Redis.Build/StackExchange.Redis.Build.csproj
index f875133ba..f57005154 100644
--- a/eng/StackExchange.Redis.Build/StackExchange.Redis.Build.csproj
+++ b/eng/StackExchange.Redis.Build/StackExchange.Redis.Build.csproj
@@ -5,6 +5,7 @@
enable
enable
true
+ true
@@ -16,5 +17,4 @@
FastHash.cs
-
diff --git a/global.json b/global.json
index 35e954767..f00fd8fcc 100644
--- a/global.json
+++ b/global.json
@@ -1,6 +1,6 @@
{
"sdk": {
- "allowPrerelease": false
+ "allowPrerelease": true
}
}
\ No newline at end of file
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 06e403ebb..3d2acbaaf 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -6,11 +6,16 @@
false
-
+
+
+
+
+ $(MSBuildWarningsAsMessages);MSB3277
+
diff --git a/src/RESP.Core/AmbientBufferWriter.cs b/src/RESP.Core/AmbientBufferWriter.cs
new file mode 100644
index 000000000..bc64abc75
--- /dev/null
+++ b/src/RESP.Core/AmbientBufferWriter.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Buffers;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+namespace Resp;
+
+internal sealed class AmbientBufferWriter : IBufferWriter
+{
+ [ThreadStatic]
+ private static AmbientBufferWriter? _threadStaticInstance;
+
+ public static AmbientBufferWriter Get(int estimatedSize)
+ {
+ var obj = _threadStaticInstance ??= new AmbientBufferWriter();
+ obj.Init(estimatedSize);
+ return obj;
+ }
+
+ private byte[] _buffer = [];
+ private int _committed;
+
+ private void Init(int size)
+ {
+ _committed = 0;
+ if (size < 0) size = 0;
+ if (_buffer.Length < size)
+ {
+ DemandCapacity(size);
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private void DemandCapacity(int size)
+ {
+ const int MIN_BUFFER = 32;
+ size = Math.Max(size, MIN_BUFFER);
+
+ if (_committed + size > _buffer.Length)
+ {
+ GrowBy(size);
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ private void GrowBy(int length)
+ {
+ var newSize = Math.Max(_committed + length, checked((_buffer.Length * 3) / 2));
+ byte[] newBuffer = ArrayPool.Shared.Rent(newSize), oldBuffer = _buffer;
+ if (_committed != 0)
+ {
+ new ReadOnlySpan(oldBuffer, 0, _committed).CopyTo(newBuffer);
+ }
+
+ _buffer = newBuffer;
+ ArrayPool.Shared.Return(oldBuffer);
+ }
+
+ internal byte[] Detach(out int length)
+ {
+ length = _committed;
+ if (length == 0) return [];
+ var result = _buffer;
+ _buffer = [];
+ _committed = 0;
+ return result;
+ }
+
+ public void Advance(int count)
+ {
+ var capacity = _buffer.Length - _committed;
+ if (count < 0 || count > capacity) Throw();
+ {
+ _committed += count;
+ }
+
+ static void Throw() => throw new ArgumentOutOfRangeException(nameof(count));
+ }
+
+ public Memory GetMemory(int sizeHint = 0)
+ {
+ DemandCapacity(sizeHint);
+ return new(_buffer, _committed, _buffer.Length - _committed);
+ }
+
+ public Span GetSpan(int sizeHint = 0)
+ {
+ DemandCapacity(sizeHint);
+ return new(_buffer, _committed, _buffer.Length - _committed);
+ }
+
+ internal void Reset() => _committed = 0;
+}
diff --git a/src/RESP.Core/BatchConnection.cs b/src/RESP.Core/BatchConnection.cs
new file mode 100644
index 000000000..29ce1dfef
--- /dev/null
+++ b/src/RESP.Core/BatchConnection.cs
@@ -0,0 +1,229 @@
+using System;
+using System.Buffers;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+
+namespace Resp;
+
+public interface IBatchConnection : IRespConnection
+{
+ Task FlushAsync();
+ void Flush();
+}
+
+internal sealed class BatchConnection : IBatchConnection
+{
+ private bool _isDisposed;
+ private readonly List _unsent;
+ private readonly IRespConnection _tail;
+ private readonly RespContext _context;
+
+ public BatchConnection(in RespContext context, int sizeHint)
+ {
+ // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract - an abundance of caution
+ var tail = context.Connection;
+ if (tail is not { CanWrite: true }) ThrowNonWritable();
+ if (tail is BatchConnection) ThrowBatch();
+
+ _unsent = sizeHint <= 0 ? [] : new List(sizeHint);
+ _tail = tail!;
+ _context = context.WithConnection(this);
+ static void ThrowBatch() => throw new ArgumentException("Nested batches are not supported", nameof(tail));
+
+ static void ThrowNonWritable() =>
+ throw new ArgumentException("A writable connection is required", nameof(tail));
+ }
+
+ public void Dispose()
+ {
+ lock (_unsent)
+ {
+ /* everyone else checks disposal inside the lock, so:
+ once we've set this, we can be sure that no more
+ items will be added */
+ _isDisposed = true;
+ }
+#if NET5_0_OR_GREATER
+ var span = CollectionsMarshal.AsSpan(_unsent);
+ foreach (var message in span)
+ {
+ message.TrySetException(new ObjectDisposedException(ToString()));
+ }
+#else
+ foreach (var message in _unsent)
+ {
+ message.TrySetException(new ObjectDisposedException(ToString()));
+ }
+#endif
+ _unsent.Clear();
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ Dispose();
+ return default;
+ }
+
+ public RespConfiguration Configuration => _tail.Configuration;
+ public bool CanWrite => _tail.CanWrite;
+
+ public int Outstanding
+ {
+ get
+ {
+ lock (_unsent)
+ {
+ return _unsent.Count;
+ }
+ }
+ }
+
+ public ref readonly RespContext Context => ref _context;
+
+ private const string SyncMessage = "Batch connections do not support synchronous sends";
+ public void Send(IRespMessage message) => throw new NotSupportedException(SyncMessage);
+
+ public void Send(ReadOnlySpan messages) => throw new NotSupportedException(SyncMessage);
+
+ private void ThrowIfDisposed()
+ {
+ if (_isDisposed) Throw();
+ static void Throw() => throw new ObjectDisposedException(nameof(BatchConnection));
+ }
+
+ public Task SendAsync(IRespMessage message)
+ {
+ lock (_unsent)
+ {
+ ThrowIfDisposed();
+ _unsent.Add(message);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ public Task SendAsync(ReadOnlyMemory messages)
+ {
+ if (messages.Length != 0)
+ {
+ lock (_unsent)
+ {
+ ThrowIfDisposed();
+#if NET8_0_OR_GREATER
+ _unsent.AddRange(messages.Span); // internally optimized
+#else
+ // two-step; first ensure capacity, then add in loop
+#if NET6_0_OR_GREATER
+ _unsent.EnsureCapacity(_unsent.Count + messages.Length);
+#else
+ var required = _unsent.Count + messages.Length;
+ if (_unsent.Capacity < required)
+ {
+ const int maxLength = 0X7FFFFFC7; // not directly available on down-level runtimes :(
+ var newCapacity = _unsent.Capacity * 2; // try doubling
+ if ((uint)newCapacity > maxLength) newCapacity = maxLength; // account for max
+ if (newCapacity < required) newCapacity = required; // in case doubling wasn't enough
+ _unsent.Capacity = newCapacity;
+ }
+#endif
+ foreach (var message in messages.Span)
+ {
+ _unsent.Add(message);
+ }
+#endif
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private int Flush(out IRespMessage[] oversized, out IRespMessage? single)
+ {
+ lock (_unsent)
+ {
+ var count = _unsent.Count;
+ switch (count)
+ {
+ case 0:
+ oversized = [];
+ single = null;
+ break;
+ case 1:
+ oversized = [];
+ single = _unsent[0];
+ break;
+ default:
+ oversized = ArrayPool.Shared.Rent(count);
+ single = null;
+ _unsent.CopyTo(oversized);
+ break;
+ }
+
+ _unsent.Clear();
+ return count;
+ }
+ }
+
+ public Task FlushAsync()
+ {
+ var count = Flush(out var oversized, out var single);
+ return count switch
+ {
+ 0 => Task.CompletedTask,
+ 1 => _tail.SendAsync(single!),
+ _ => SendAndRecycleAsync(_tail, oversized, count),
+ };
+
+ static async Task SendAndRecycleAsync(IRespConnection tail, IRespMessage[] oversized, int count)
+ {
+ try
+ {
+ await tail.SendAsync(oversized.AsMemory(0, count)).ConfigureAwait(false);
+ ArrayPool.Shared.Return(oversized); // only on success, in case captured
+ }
+ catch (Exception ex)
+ {
+ foreach (var message in oversized.AsSpan(0, count))
+ {
+ message.TrySetException(ex);
+ }
+
+ throw;
+ }
+ }
+ }
+
+ public void Flush()
+ {
+ var count = Flush(out var oversized, out var single);
+ switch (count)
+ {
+ case 0:
+ return;
+ case 1:
+ _tail.Send(single!);
+ return;
+ }
+
+ try
+ {
+ _tail.Send(oversized.AsSpan(0, count));
+ }
+ catch (Exception ex)
+ {
+ foreach (var message in oversized.AsSpan(0, count))
+ {
+ message.TrySetException(ex);
+ }
+
+ throw;
+ }
+ finally
+ {
+ // in the sync case, Send takes a span - hence can't have been captured anywhere; always recycle
+ ArrayPool.Shared.Return(oversized);
+ }
+ }
+}
diff --git a/src/RESP.Core/Builder.cs b/src/RESP.Core/Builder.cs
new file mode 100644
index 000000000..0615bd3f9
--- /dev/null
+++ b/src/RESP.Core/Builder.cs
@@ -0,0 +1,33 @@
+using System;
+using System.Threading.Tasks;
+
+namespace Resp;
+
+public readonly ref struct RespMessageBuilder(RespContext context, ReadOnlySpan command, TRequest value, IRespFormatter formatter)
+#if NET9_0_OR_GREATER
+ where TRequest : allows ref struct
+#endif
+{
+ private readonly ReadOnlySpan _command = command;
+ private readonly TRequest _value = value; // cannot inline to .ctor because of "allows ref struct"
+
+ public TResponse Wait()
+ => Message.Send(context, _command, _value, formatter, RespParsers.Get());
+ public TResponse Wait(IRespParser parser)
+ => Message.Send(context, _command, _value, formatter, parser);
+
+ public void Wait()
+ => Message.Send(context, _command, _value, formatter, RespParsers.Success);
+ public void Wait(IRespParser parser)
+ => Message.Send(context, _command, _value, formatter, parser);
+
+ public ValueTask AsValueTask()
+ => Message.SendAsync(context, _command, _value, formatter, RespParsers.Get());
+ public ValueTask AsValueTask(IRespParser parser)
+ => Message.SendAsync(context, _command, _value, formatter, parser);
+
+ public ValueTask AsValueTask()
+ => Message.SendAsync(context, _command, _value, formatter, RespParsers.Success);
+ public ValueTask AsValueTask(IRespParser parser)
+ => Message.SendAsync(context, _command, _value, formatter, parser);
+}
diff --git a/src/RESP.Core/CustomNetworkStream.cs b/src/RESP.Core/CustomNetworkStream.cs
new file mode 100644
index 000000000..4673ea4ed
--- /dev/null
+++ b/src/RESP.Core/CustomNetworkStream.cs
@@ -0,0 +1,297 @@
+#if NETCOREAPP3_0_OR_GREATER
+
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Net.Sockets;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Threading.Tasks.Sources;
+
+namespace Resp;
+
+internal sealed class CustomNetworkStream(Socket socket) : Stream
+{
+ private SocketAwaitableEventArgs _readArgs = new(), _writeArgs = new();
+ private SocketAwaitableEventArgs ReadArgs() => _readArgs.Next();
+ private SocketAwaitableEventArgs WriteArgs() => _writeArgs.Next();
+
+ public override void Close()
+ {
+ socket.Close();
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ socket.Dispose();
+ _readArgs.Dispose();
+ _writeArgs.Dispose();
+ }
+
+ base.Dispose(disposing);
+ }
+
+ public override void Flush() { }
+
+ public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+
+ public override void SetLength(long value) => throw new NotSupportedException();
+
+ public override int Read(byte[] buffer, int offset, int count) =>
+ socket.Receive(buffer, offset, count, SocketFlags.None);
+
+ public override void Write(byte[] buffer, int offset, int count) =>
+ socket.Send(buffer, offset, count, SocketFlags.None);
+
+ public override int Read(Span buffer) => socket.Receive(buffer);
+
+ public override void Write(ReadOnlySpan buffer) => socket.Send(buffer);
+
+ private static void ThrowCancellable() => throw new NotSupportedException(
+ "Cancellable operations are not supported on this stream; cancellation should be handled at the message level, not the IO level.");
+
+ public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+ {
+ if (cancellationToken.CanBeCanceled) ThrowCancellable();
+ var args = ReadArgs();
+ args.SetBuffer(buffer, offset, count);
+ if (socket.ReceiveAsync(args)) return args.Pending().AsTask();
+ return Task.FromResult(args.GetInlineResult());
+ }
+
+ public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
+ {
+ if (cancellationToken.CanBeCanceled) ThrowCancellable();
+ var args = WriteArgs();
+ args.SetBuffer(buffer, offset, count);
+ if (socket.SendAsync(args)) return args.Pending().AsTask();
+ args.GetInlineResult(); // check for socket errors
+ return Task.CompletedTask;
+ }
+
+ public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default)
+ {
+ if (cancellationToken.CanBeCanceled) ThrowCancellable();
+ var args = ReadArgs();
+ args.SetBuffer(buffer);
+ if (socket.ReceiveAsync(args)) return args.Pending();
+ return new(args.GetInlineResult());
+ }
+
+ public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default)
+ {
+ if (cancellationToken.CanBeCanceled) ThrowCancellable();
+ var args = WriteArgs();
+ args.SetBuffer(MemoryMarshal.AsMemory(buffer));
+ if (socket.SendAsync(args)) return args.PendingNoValue();
+ args.GetInlineResult(); // check for socket errors
+ return default;
+ }
+
+ public override int ReadByte()
+ {
+ Span buffer = stackalloc byte[1];
+ int count = socket.Receive(buffer);
+ return count <= 0 ? -1 : buffer[0];
+ }
+
+ public override void WriteByte(byte value)
+ {
+ ReadOnlySpan buffer = [value];
+ socket.Send(buffer);
+ }
+
+ public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state)
+ {
+ var args = ReadArgs();
+ args.SetBuffer(buffer, offset, count);
+ args.CompletedSynchronously = false;
+ if (socket.SendAsync(args))
+ {
+ args.OnCompleted(callback, state);
+ }
+ else
+ {
+ args.CompletedSynchronously = true;
+ callback?.Invoke(args);
+ }
+
+ return args;
+ }
+
+ public override int EndRead(IAsyncResult asyncResult) => ((SocketAwaitableEventArgs)asyncResult).GetInlineResult();
+
+ public override IAsyncResult BeginWrite(
+ byte[] buffer,
+ int offset,
+ int count,
+ AsyncCallback? callback,
+ object? state)
+ {
+ var args = WriteArgs();
+ args.SetBuffer(buffer, offset, count);
+ args.CompletedSynchronously = false;
+ if (socket.SendAsync(args))
+ {
+ args.OnCompleted(callback, state);
+ }
+ else
+ {
+ args.CompletedSynchronously = true;
+ callback?.Invoke(args);
+ }
+
+ return args;
+ }
+
+ public override void EndWrite(IAsyncResult asyncResult) =>
+ ((SocketAwaitableEventArgs)asyncResult).GetInlineResult();
+
+ public override bool CanRead => true;
+ public override bool CanSeek => false;
+ public override bool CanWrite => true;
+ public override long Length => throw new NotSupportedException();
+
+ public override long Position
+ {
+ get => throw new NotSupportedException();
+ set => throw new NotSupportedException();
+ }
+
+ public override bool CanTimeout => socket.ReceiveTimeout != 0 || socket.SendTimeout != 0;
+
+ public override int ReadTimeout
+ {
+ get => socket.ReceiveTimeout;
+ set => socket.ReceiveTimeout = value;
+ }
+
+ public override int WriteTimeout
+ {
+ get => socket.SendTimeout;
+ set => socket.SendTimeout = value;
+ }
+
+ // inspired from Pipelines.Sockets.Unofficial and Kestrel's SocketAwaitableEventArgs; extended to support more scenarios
+ private sealed class SocketAwaitableEventArgs : SocketAsyncEventArgs,
+ IValueTaskSource, IValueTaskSource, IAsyncResult
+ {
+#if NET5_0_OR_GREATER
+ public SocketAwaitableEventArgs() : base(unsafeSuppressExecutionContextFlow: true) { }
+#else
+ public SocketAwaitableEventArgs() { }
+#endif
+ private static readonly Action