Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Declarative RLP Encoding/Decoding #7975

Draft
wants to merge 115 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
115 commits
Select commit Hold shift + click to select a range
d3a3232
Initial Rlp + Writer
emlautarom1 Dec 19, 2024
9a90ccb
Rename `Sequence` -> `List`
emlautarom1 Dec 19, 2024
30dda91
Initial `RlpReader`
emlautarom1 Dec 19, 2024
d3d83f9
Initial `ReadList`
emlautarom1 Dec 19, 2024
65f0891
Multiple `ReadList`
emlautarom1 Dec 19, 2024
b4656cd
Restructure into separate files
emlautarom1 Dec 19, 2024
a63b64f
Use `Rlp.Read` API
emlautarom1 Dec 19, 2024
6735d29
Add `HeterogeneousList` test
emlautarom1 Dec 19, 2024
74cf2fd
Add overload for `Action`
emlautarom1 Dec 19, 2024
2ef4c4d
Test `UnknownLengthList`
emlautarom1 Dec 19, 2024
b3dddcb
Rename converter
emlautarom1 Dec 19, 2024
8b19e01
Use custom Exception
emlautarom1 Dec 19, 2024
2c67165
Add `Action` overload
emlautarom1 Dec 19, 2024
52e1f7b
Test for invalid readings
emlautarom1 Dec 19, 2024
7262407
Support long lists (+55 bytes)
emlautarom1 Dec 19, 2024
e4c2e63
Implement interface on `ReadOnlySpanConverter`
emlautarom1 Dec 20, 2024
35b9203
Make `Rlp[Reader|Writer]` symmetric
emlautarom1 Dec 20, 2024
e0ec1ce
Support ref struct
emlautarom1 Dec 20, 2024
d3a2e5b
Reorder tests
emlautarom1 Dec 20, 2024
67a69df
Annotate as scoped
emlautarom1 Dec 20, 2024
dc3798b
Initial `Choice` implementation
emlautarom1 Dec 20, 2024
ffa62f4
Cleanup test
emlautarom1 Dec 20, 2024
e626fc9
Restructure internals
emlautarom1 Dec 20, 2024
6f3efbc
Test for deep `Choice` (backtracking)
emlautarom1 Dec 20, 2024
2d2eeda
Move `IRlpConverter`
emlautarom1 Dec 20, 2024
67abc2b
Demo user-defined record support
emlautarom1 Dec 20, 2024
8f4b02a
Remove versions
emlautarom1 Dec 20, 2024
9db2ebb
Use `ref struct` over `Interface`
emlautarom1 Dec 20, 2024
2223845
Consistent error
emlautarom1 Dec 20, 2024
51fd038
Split tests from library
emlautarom1 Dec 20, 2024
5fad6ef
Initial source generator for `RlpSerializable`
emlautarom1 Dec 20, 2024
c22f1df
Implement `Write`
emlautarom1 Dec 20, 2024
70c78e2
Test for Source Generated instances
emlautarom1 Dec 20, 2024
f11ff9a
Move record
emlautarom1 Dec 20, 2024
38cbb3d
No need to copy `using` directives
emlautarom1 Dec 20, 2024
f284c28
Remove `Derived` namespace
emlautarom1 Dec 23, 2024
6e91e0d
Update docs
emlautarom1 Dec 23, 2024
ec2fdbc
Rename `List` to `Sequence`
emlautarom1 Dec 23, 2024
f158463
Add support for `List` collection
emlautarom1 Dec 23, 2024
6677222
Test for equivalence in collections with explicit
emlautarom1 Dec 23, 2024
e32a33c
Add explicit converters for IntXX
emlautarom1 Dec 23, 2024
f665779
Reduce duplication
emlautarom1 Dec 23, 2024
2e75a7d
Add `Dictionary` collection
emlautarom1 Dec 23, 2024
aa6b4bd
Extend generator to support Generics (WIP)
emlautarom1 Dec 23, 2024
fcde607
Support multi-param generics
emlautarom1 Dec 23, 2024
416a45b
Add Generic writer Action
emlautarom1 Dec 23, 2024
837c2e4
Rewrite Generics
emlautarom1 Dec 23, 2024
f484b8b
Add recursive record test
emlautarom1 Dec 23, 2024
599da39
Enable `nullable`
emlautarom1 Dec 23, 2024
3eff5ea
Fix `ReadBytes`
emlautarom1 Dec 23, 2024
b7889ed
Test for all base Integer types
emlautarom1 Dec 23, 2024
d41ecd3
Initial README
emlautarom1 Dec 23, 2024
03325a0
Make `Read` calls static
emlautarom1 Dec 24, 2024
914505a
Add `context` overload
emlautarom1 Dec 24, 2024
7dbc2dc
Remove allocations
emlautarom1 Dec 24, 2024
15eb6b6
Use `static` on `Write`
emlautarom1 Dec 24, 2024
c42eb50
Use `static` on `Read`
emlautarom1 Dec 24, 2024
ed0d88b
Return `byte[]` instead of `ReadOnlySpan<byte>` in overload
emlautarom1 Dec 24, 2024
88a8cc4
Use `static` on `Write`
emlautarom1 Dec 24, 2024
a596ab0
Fix typo
emlautarom1 Dec 24, 2024
df3ffc9
Add overloads for `TContext`
emlautarom1 Dec 24, 2024
42b4401
Use `context` overload when possible
emlautarom1 Dec 24, 2024
917a7b8
Remove duplicated overloads
emlautarom1 Dec 24, 2024
88e7bac
Remove "context" TODO
emlautarom1 Dec 24, 2024
1bc2d38
Remove allocating overload
emlautarom1 Dec 24, 2024
fb254e9
More `static` usage
emlautarom1 Dec 24, 2024
21a18b9
Use "context" overload in Generator
emlautarom1 Dec 24, 2024
4740466
Annotate as `scoped`
emlautarom1 Dec 26, 2024
5705da1
Optimize `StringRlpConverter`
emlautarom1 Dec 26, 2024
cc58776
Rename `FastRlp` to `FluentRlp`
emlautarom1 Dec 26, 2024
2456c40
Remove allocations in Dictionary
emlautarom1 Dec 26, 2024
868d272
Remove unused method
emlautarom1 Dec 26, 2024
69c8a92
Introduce "context" overload for `Reader`
emlautarom1 Dec 26, 2024
f0191d1
Remove allocations in `Dictionary`
emlautarom1 Dec 26, 2024
1011df9
Remove allocations in `List`
emlautarom1 Dec 26, 2024
63c3ed9
Use field Encoding
emlautarom1 Dec 26, 2024
0c2807b
Add `ArrayRlpConverter`
emlautarom1 Dec 26, 2024
410dfc7
Merge branch 'master' into feature/declarative-rlp
emlautarom1 Dec 26, 2024
efc25bd
Remove allocations
emlautarom1 Dec 26, 2024
aae0100
Add `TupleRlpConverter`
emlautarom1 Dec 26, 2024
c7a32d1
Support `Tuple` in source generators
emlautarom1 Dec 26, 2024
3dc5b65
Add support for `Representation`
emlautarom1 Dec 26, 2024
fd9fc30
Formatting
emlautarom1 Dec 26, 2024
9f5d236
Merge branch 'master' into feature/declarative-rlp
emlautarom1 Dec 26, 2024
8341a0e
Update docs
emlautarom1 Dec 27, 2024
3e1330d
Throw on trailing bytes
emlautarom1 Dec 30, 2024
4f68cb3
Reduce usage of `StringBuilder`
emlautarom1 Dec 30, 2024
ac98b21
Remove unused code
emlautarom1 Dec 30, 2024
79dc13f
Use format string
emlautarom1 Dec 30, 2024
c9ba2eb
Merge `Array` with generics
emlautarom1 Dec 30, 2024
e43f13c
Merge branch 'master' into feature/declarative-rlp
emlautarom1 Dec 30, 2024
db5408a
Formatting
emlautarom1 Dec 30, 2024
38eb0f0
Merge remote-tracking branch 'origin/feature/declarative-rlp' into fe…
emlautarom1 Dec 30, 2024
31726b6
Fix `0`
emlautarom1 Jan 2, 2025
149145b
Remove signed integer requirement
emlautarom1 Jan 2, 2025
dd57f80
Add `BytesRead`
emlautarom1 Jan 2, 2025
40cfd25
Include user `using` statements
emlautarom1 Jan 2, 2025
f419316
Fix nested Generics support
emlautarom1 Jan 2, 2025
d3592d8
Initial benchmark
emlautarom1 Jan 2, 2025
a52d923
Prefer `sizeof(T)` over `Marshal.SizeOf<T>`
emlautarom1 Jan 2, 2025
606a260
Formatting
emlautarom1 Jan 2, 2025
abf143d
Merge branch 'master' into feature/declarative-rlp
emlautarom1 Jan 2, 2025
2a9bee7
File encoding
emlautarom1 Jan 2, 2025
d0dfb12
Merge remote-tracking branch 'origin/feature/declarative-rlp' into fe…
emlautarom1 Jan 2, 2025
bcaa83f
Remove unused import
emlautarom1 Jan 2, 2025
783acc6
Avoid boxing due to interface default bodies
emlautarom1 Jan 3, 2025
6dc21f4
Add `MemoryDiagnoser`
emlautarom1 Jan 3, 2025
1c8f472
Use `IBufferWriter` instead of `byte[]`
emlautarom1 Jan 6, 2025
b2e5951
Use `IBufferWriter` instead of `byte[]`
emlautarom1 Jan 6, 2025
9622a7b
Merge remote-tracking branch 'origin/feature/declarative-rlp' into fe…
emlautarom1 Jan 7, 2025
11bf0c7
Merge remote-tracking branch 'origin/feature/declarative-rlp' into fe…
emlautarom1 Jan 7, 2025
92bc902
Merge remote-tracking branch 'origin/feature/declarative-rlp' into fe…
emlautarom1 Jan 7, 2025
7e03ac3
Add support for `Optional`
emlautarom1 Jan 10, 2025
04ac6b2
Use `sizeof` instead of `Marshal.SizeOf<T>` in `RlpReader`
emlautarom1 Jan 10, 2025
3250b15
More tests for `Optional`
emlautarom1 Jan 10, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Nethermind/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
<PackageVersion Include="Microsoft.ClearScript.V8.Native.osx-arm64" Version="7.4.5" />
<PackageVersion Include="Microsoft.ClearScript.V8.Native.osx-x64" Version="7.4.5" />
<PackageVersion Include="Microsoft.ClearScript.V8.Native.win-x64" Version="7.4.5" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<Nullable>enable</Nullable>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<RootNamespace>Nethermind.Serialization.FluentRlp.Generator</RootNamespace>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,339 @@
// SPDX-FileCopyrightText: 2024 Demerzel Solutions Limited
// SPDX-License-Identifier: LGPL-3.0-only

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
using System.Text;

namespace Nethermind.Serialization.FluentRlp.Generator;

public enum RlpRepresentation : byte
{
/// <summary>
/// The RLP encoding will be a sequence of RLP objects for each property.
/// </summary>
Record = 0,

/// <summary>
/// The RLP encoding will be equivalent to the only underlying property.
/// </summary>
Newtype = 1,
}

[AttributeUsage(AttributeTargets.Class)]
public sealed class RlpSerializable(RlpRepresentation representation = RlpRepresentation.Record) : Attribute
{
public RlpRepresentation Representation { get; } = representation;
}

/// <summary>
/// A source generator that finds all records with [RlpSerializable] attribute and
/// generates an abstract `IRlpConverter` class with `Read` and `Write` methods.
/// </summary>
[Generator]
public sealed class RlpSourceGenerator : IIncrementalGenerator
{
private const string Version = "0.1";
private const string GeneratedCodeAttribute = $"""[GeneratedCode("{nameof(RlpSourceGenerator)}", "{Version}")]""";

public void Initialize(IncrementalGeneratorInitializationContext context)
{
var provider = context.SyntaxProvider.CreateSyntaxProvider(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider attribute based creation using ForAttributeWithMetadataName. It should be much more cheaper than scanning all the records and only then select on attribute basis.

predicate: static (s, _) => s is RecordDeclarationSyntax,
transform: static (ctx, _) => (RecordDeclarationSyntax)ctx.Node
);

var compilation = context.CompilationProvider.Combine(provider.Collect());

context.RegisterSourceOutput(compilation, Execute);
}

private void Execute(
SourceProductionContext context,
(Compilation Compilation, ImmutableArray<RecordDeclarationSyntax> RecordsDeclarationSyntaxes) p)
{
// For each record with the attribute, generate the RlpConverter class
foreach (var recordDecl in p.RecordsDeclarationSyntaxes)
{
// Check if the record has the [RlpSerializable] attribute
SemanticModel semanticModel = p.Compilation.GetSemanticModel(recordDecl.SyntaxTree);
ISymbol? symbol = semanticModel.GetDeclaredSymbol(recordDecl);
if (symbol is null) continue;

AttributeData? rlpSerializableAttribute = symbol
.GetAttributes()
.FirstOrDefault(a =>
a.AttributeClass?.Name == nameof(RlpSerializable) ||
a.AttributeClass?.ToDisplayString() == nameof(RlpSerializable));

// If not, skip the record
if (rlpSerializableAttribute is null) continue;

// Extract the fully qualified record name with its namespace
var recordName = symbol.Name;
var fullTypeName = symbol.ToDisplayString();
// TODO: Deal with missing and nested namespaces
var @namespace = symbol.ContainingNamespace?.ToDisplayString();

// Extract all `using` directives
var usingDirectives = semanticModel.SyntaxTree.GetCompilationUnitRoot()
.Usings
.Select(u => u.ToString())
.ToList();

// Get the `RlpRepresentation` mode
var representation = (RlpRepresentation)(rlpSerializableAttribute.ConstructorArguments[0].Value ?? 0);

// Gather recursively all members that are fields or primary constructor parameters
// so we can read them in the same order they are declared.
var parameters = GetRecordParameters(recordDecl);

// Ensure `Newtype` is only used in single-property records
if (representation == RlpRepresentation.Newtype && parameters.Count != 1)
{
var descriptor = new DiagnosticDescriptor(
"RLP0001",
$"Invalid {nameof(RlpRepresentation)}",
$"'{nameof(RlpRepresentation.Newtype)}' representation is only allowed for records with a single property",
"", DiagnosticSeverity.Error, true);
context.ReportDiagnostic(Diagnostic.Create(descriptor: descriptor, recordDecl.GetLocation()));

return;
}

// Build the converter class source
var generatedCode = GenerateConverterClass(@namespace, usingDirectives, fullTypeName, recordName, parameters, representation);

// Add to the compilation
context.AddSource($"{recordName}RlpConverter.g.cs", SourceText.From(generatedCode, Encoding.UTF8));
}
}

/// <summary>
/// Gathers the record’s primary constructor parameters and public fields/properties
/// in the order they appear in the record declaration.
/// </summary>
private static List<(string Name, TypeSyntax TypeName)> GetRecordParameters(RecordDeclarationSyntax recordDecl)
{
List<(string, TypeSyntax)> parameters = [];

// Primary constructor parameters
if (recordDecl.ParameterList is not null)
{
foreach (var param in recordDecl.ParameterList.Parameters)
{
var paramName = param.Identifier.Text;
var paramType = param.Type!;

parameters.Add((paramName, paramType));
}
}

return parameters;
}

private static string GenerateConverterClass(
string? @namespace,
List<string> usingDirectives,
string fullTypeName,
string recordName,
List<(string Name, TypeSyntax TypeName)> parameters,
RlpRepresentation representation)
{
List<string> defaultUsingDirectives =
[
"using System;",
"using System.CodeDom.Compiler;",
"using Nethermind.Serialization.FluentRlp;",
"using Nethermind.Serialization.FluentRlp.Instances;"
];
IEnumerable<string> directives = defaultUsingDirectives.Concat(usingDirectives).Distinct();
var usingStatements = new StringBuilder();
foreach (var usingDirective in directives)
{
usingStatements.AppendLine(usingDirective);
}

var writeCalls = new StringBuilder();
foreach (var (name, typeName) in parameters)
{
var writeCall = MapTypeToWriteCall(name, typeName);
writeCalls.AppendLine($"w.{writeCall};");
}

var readCalls = new StringBuilder();
foreach (var (name, typeName) in parameters)
{
var readCall = MapTypeToReadCall(typeName);
readCalls.AppendLine($"var {name} = r.{readCall};");
}

var constructorCall = new StringBuilder($"{fullTypeName}(");
for (int i = 0; i < parameters.Count; i++)
{
constructorCall.Append(parameters[i].Name);
if (i < parameters.Count - 1) constructorCall.Append(", ");
}
constructorCall.Append(");");

return
$$"""
// <auto-generated />
#nullable enable
{{usingStatements}}
{{(@namespace is null ? "" : $"namespace {@namespace};")}}

{{GeneratedCodeAttribute}}
public abstract class {{recordName}}RlpConverter : IRlpConverter<{{fullTypeName}}>
{
public static void Write(ref RlpWriter w, {{fullTypeName}} value)
{
{{(representation == RlpRepresentation.Record
? $$"""
w.WriteSequence(value, static (ref RlpWriter w, {{fullTypeName}} value) =>
{
{{writeCalls}}
});
"""
: writeCalls)}}
}

public static {{fullTypeName}} Read(ref RlpReader r)
{
{{(representation == RlpRepresentation.Record
? $$"""
return r.ReadSequence(static (scoped ref RlpReader r) =>
{
{{readCalls}}

return new {{constructorCall}}
});
"""
: $"""
{readCalls}

return new {constructorCall}
""")}}
}
}

{{GeneratedCodeAttribute}}
public static class {{recordName}}Ext
{
public static {{fullTypeName}} Read{{recordName}}(this ref RlpReader reader) => {{recordName}}RlpConverter.Read(ref reader);
public static void Write(this ref RlpWriter writer, {{fullTypeName}} value) => {{recordName}}RlpConverter.Write(ref writer, value);
}
""";
}

/// <summary>
/// Map the type name to the appropriate Read method on the `RlpReader`
/// Extend this mapping for more types as needed.
/// </summary>
private static string MapTypeToReadCall(TypeSyntax syntax)
{
// Hard-coded cases
switch (syntax.ToString())
{
case "byte[]" or "Byte[]" or "System.Byte[]":
return "ReadBytes().ToArray()";
case "Span<byte>" or "System.Span<byte>" or "ReadOnlySpan<byte>" or "System.ReadOnlySpan<byte>":
return "ReadBytes()";
}

// Generics
if (syntax is GenericNameSyntax or TupleTypeSyntax or ArrayTypeSyntax)
{
var typeConstructor = syntax switch
{
GenericNameSyntax generic => generic.Identifier.ToString(),
TupleTypeSyntax _ => "Tuple",
ArrayTypeSyntax _ => "Array",
_ => throw new ArgumentOutOfRangeException(nameof(syntax))
};

var typeParameters = syntax switch
{
GenericNameSyntax generic => generic.TypeArgumentList.Arguments,
TupleTypeSyntax tuple => tuple.Elements.Select(e => e.Type),
ArrayTypeSyntax array => [array.ElementType],
_ => throw new ArgumentOutOfRangeException(nameof(syntax))
};

var sb = new StringBuilder();
sb.AppendLine($"Read{typeConstructor.Capitalize()}(");
foreach (var typeParameter in typeParameters)
{
sb.AppendLine($$"""static (scoped ref RlpReader r) => { return r.{{MapTypeToReadCall(typeParameter)}}; },""");
}
sb.Length -= 2; // Remove the trailing `,\n`
sb.Append(")");

return sb.ToString();
}

// Default
return $"Read{MapTypeAlias(syntax.ToString())}()";
}

/// <summary>
/// Map the type name to the appropriate Write method on the `RlpWriter`
/// Extend this mapping for more types as needed.
/// </summary>
private static string MapTypeToWriteCall(string? propertyName, TypeSyntax syntax)
{
// Hard-coded cases
switch (syntax.ToString())
{
case "byte[]" or "Byte[]" or "System.Byte[]" or "Span<byte>" or "System.Span<byte>" or "ReadOnlySpan<byte>" or "System.ReadOnlySpan<byte>":
return propertyName is null ? "Write(value)" : $"Write(value.{propertyName})";
}

// Generics
if (syntax is GenericNameSyntax or TupleTypeSyntax or ArrayTypeSyntax)
{
var typeParameters = syntax switch
{
GenericNameSyntax generic => generic.TypeArgumentList.Arguments,
TupleTypeSyntax tuple => tuple.Elements.Select(e => e.Type),
ArrayTypeSyntax array => [array.ElementType],
_ => throw new ArgumentOutOfRangeException(nameof(syntax))
};

var sb = new StringBuilder();
sb.AppendLine(propertyName is null ? "Write(value," : $"Write(value.{propertyName},");
foreach (var typeParameter in typeParameters)
{
sb.AppendLine($$"""static (ref RlpWriter w, {{typeParameter}} value) => { w.{{MapTypeToWriteCall(null, typeParameter)}}; },""");
}
sb.Length -= 2; // Remove the trailing `,\n`
sb.Append(")");

return sb.ToString();
}

// Default
return propertyName is not null ? $"Write(value.{propertyName})" : "Write(value)";
}

private static string MapTypeAlias(string alias) =>
alias switch
{
"string" => "String",
"short" => "Int16",
"int" => "Int32",
"long" => "Int64",
_ => alias
};
}

public static class StringExt
{
public static string Capitalize(this string str) => str[0].ToString().ToUpper() + str[1..];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited
// SPDX-License-Identifier: LGPL-3.0-only

using FluentAssertions;
using FluentAssertions.Collections;

namespace Nethermind.Serialization.FluentRlp.Test;

// NOTE: `FluentAssertions` currently does not support `(ReadOnly)Span<T>` or `(ReadOnly)Memory<T>` assertions.
public static class Extensions
{
public static GenericCollectionAssertions<T> Should<T>(this ReadOnlySpan<T> span) => span.ToArray().Should();
public static GenericCollectionAssertions<T> Should<T>(this ReadOnlyMemory<T> memory) => memory.ToArray().Should();

public static AndConstraint<GenericCollectionAssertions<TExpectation>> BeEquivalentTo<TExpectation>(
this GenericCollectionAssertions<TExpectation> @this,
ReadOnlySpan<TExpectation> expectation,
string because = "",
params object[] becauseArgs)
{
return @this.BeEquivalentTo(expectation.ToArray(), config => config, because, becauseArgs);
}

public static AndConstraint<GenericCollectionAssertions<TExpectation>> BeEquivalentTo<TExpectation>(
this GenericCollectionAssertions<TExpectation> @this,
ReadOnlyMemory<TExpectation> expectation,
string because = "",
params object[] becauseArgs)
{
return @this.BeEquivalentTo(expectation.ToArray(), config => config, because, becauseArgs);
}
}
Loading