Skip to content

Commit b4c10e0

Browse files
feat: add source generator for inline array attributes and refactor array structures (#7)
* feat: add source generator for inline array attributes and refactor array structures * chore: update packages; replace FluentAssertions with AwesomeAssertions * chore: add .NET 9 setup step to CI and release workflows * fix: ensure that a zero length triggers diagnostic; correct syntax for AutoInlineArray attribute in tests
1 parent 7bcac5f commit b4c10e0

File tree

49 files changed

+2394
-228
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+2394
-228
lines changed

.github/workflows/ci.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ jobs:
1111
runs-on: ubuntu-latest
1212
timeout-minutes: 15
1313
steps:
14+
- name: Setup .NET 9
15+
uses: actions/setup-dotnet@v4
16+
with:
17+
dotnet-version: '9.0.x'
1418
- name: Checkout
1519
uses: actions/checkout@v4
1620
- name: Build

.github/workflows/release.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ jobs:
99
runs-on: ubuntu-latest
1010
timeout-minutes: 15
1111
steps:
12+
- name: Setup .NET 9
13+
uses: actions/setup-dotnet@v4
14+
with:
15+
dotnet-version: '9.0.x'
1216
- name: Checkout
1317
uses: actions/checkout@v4
1418
- name: Verify commit exists in origin/master

.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,4 +360,8 @@ MigrationBackup/
360360
.ionide/
361361

362362
# Fody - auto-generated XML schema
363-
FodyWeavers.xsd
363+
FodyWeavers.xsd
364+
365+
# Verify
366+
*.received.*
367+
*.received/

F1Game.UDP.Benchamrks/F1Game.UDP.Benchmarks.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
</PropertyGroup>
1010

1111
<ItemGroup>
12-
<PackageReference Include="BenchmarkDotNet" Version="0.13.12" />
12+
<PackageReference Include="BenchmarkDotNet" Version="0.15.0" />
1313
<PackageReference Include="F1Sharp" Version="1.3.0" />
1414
<PackageReference Include="SimRacing.Telemetry.Receiver.F1.23" Version="1.0.1" />
1515
</ItemGroup>

F1Game.UDP.Benchamrks/ThirdPartyComparisonBenchmark.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ static void SetupCarTelemetryPacket(byte[] data, Random random)
8383
CarTelemetryData = packet.CarTelemetryData.AsEnumerable().Select(x => x with
8484
{
8585
Gear = random.GetItems(new sbyte[] { 1, 2, 3, 4, 5, 6, 7, 8 }, 1)[0],
86-
}).ToArray22(),
86+
}).ToArray(),
8787
SuggestedGear = random.GetItems(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }, 1)[0]
8888
};
8989

@@ -99,7 +99,7 @@ static void SetupCarStatusPacket(byte[] data, Random random)
9999
CarStatusData = packet.CarStatusData.AsEnumerable().Select(x => x with
100100
{
101101
VehicleFiaFlags = random.GetItems([FiaFlag.Green, FiaFlag.Yellow, FiaFlag.None, FiaFlag.Blue], 1)[0]
102-
}).ToArray22()
102+
}).ToArray()
103103
};
104104

105105
var writer = new BytesWriter(data);
@@ -122,14 +122,14 @@ static void SetupSessionPacket(byte[] data, Random random)
122122
MarshalZones = packet.MarshalZones.AsEnumerable().Select(x => x with
123123
{
124124
ZoneFlag = random.GetItems([FiaFlag.Green, FiaFlag.Yellow, FiaFlag.Blue, FiaFlag.None], 1)[0]
125-
}).ToArray21(),
125+
}).ToArray(),
126126
WeatherForecastSamples = packet.WeatherForecastSamples.AsEnumerable().Select(x => x with
127127
{
128128
AirTemperature = (sbyte)random.Next(127),
129129
TrackTemperature = (sbyte)random.Next(127),
130130
AirTemperatureChange = (sbyte)random.Next(127),
131131
TrackTemperatureChange = (sbyte)random.Next(127),
132-
}).ToArray64()
132+
}).ToArray()
133133
};
134134

135135
var writer = new BytesWriter(data);
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
using Microsoft.CodeAnalysis.Text;
2+
using Microsoft.CodeAnalysis;
3+
using System.Text;
4+
5+
namespace F1Game.UDP.SourceGenerator;
6+
7+
static class AutoInlineArrayAttributeSource
8+
{
9+
public const string FullyQualifiedAutoInlineArrayAttribute = $"{AutoInlineArrayAttributeNamespace}.{AutoInlineArrayAttributeFullName}";
10+
11+
const string AutoInlineArrayAttributeNamespace = "F1Game.UDP.SourceGenerator";
12+
const string AutoInlineArrayAttributeFullName = "AutoInlineArrayAttribute";
13+
14+
const string SourceCode = $$"""
15+
// <auto-generated/>
16+
#nullable enable
17+
18+
using System;
19+
20+
namespace {{AutoInlineArrayAttributeNamespace}};
21+
22+
/// <summary>
23+
/// Source Generator helper attribute. Apply to a partial struct definition
24+
/// to generate an inline array implementation with the specified length.
25+
/// The generator adds the <see cref="System.Runtime.CompilerServices.InlineArrayAttribute"/>,
26+
/// implements <see cref="IEquatable{T}"/>, and provides common boilerplate code.
27+
/// </summary>
28+
/// <remarks>
29+
/// <para>The struct must be declared as partial.</para>
30+
/// <para>The first argument is the desired length (number of elements), must be greater than 0.</para>
31+
/// <para>The second argument is type of instance field. Could be omitted if struct is generic or contains instance field.</para>
32+
/// <para>
33+
/// Field and Type Inference Rules:
34+
/// <list type="number">
35+
/// <item><description>If the partial struct does not contain an instance field, element type should be provided.</description></item>
36+
/// <item><description>If the partial struct contains exactly one instance field (e.g., <c>private byte _element0;</c>), its type is used as the element type, and the generator DOES NOT add a field.</description></item>
37+
/// <item><description>If the partial struct contains zero instance fields AND is generic (e.g., <c>partial struct MyArray&lt;T&gt;</c>), the first type parameter (<c>T</c>) is used as the element type, and the generator ADDS a field (e.g., <c>private T _element0;</c>).</description></item>
38+
/// <item><description>If the partial struct contains zero instance fields AND is NOT generic, it's a compile-time error (element type cannot be inferred).</description></item>
39+
/// <item><description>If the partial struct contains more than one instance field, it's a compile-time error.</description></item>
40+
/// </list>
41+
/// </para>
42+
/// </remarks>
43+
[AttributeUsage(AttributeTargets.Struct, AllowMultiple = false, Inherited = false)]
44+
internal sealed class {{AutoInlineArrayAttributeFullName}} : Attribute
45+
{
46+
/// <summary>Initializes a new instance of the <see cref="{{AutoInlineArrayAttributeFullName}}"/> class.</summary>
47+
/// <param name="length">The number of elements in the inline array. Must be greater than 0.</param>
48+
public {{AutoInlineArrayAttributeFullName}}(int length, Type? elementType = null)
49+
{
50+
if (length <= 0)
51+
throw new ArgumentOutOfRangeException(nameof(length), "Length must be greater than 0.");
52+
Length = length;
53+
ElementType = elementType;
54+
}
55+
56+
/// <summary>Gets the number of elements intended for the inline array.</summary>
57+
public int Length { get; }
58+
59+
/// <summary>Gets the type of the element in the inline array.</summary>
60+
public Type? ElementType { get; init; } = null;
61+
}
62+
""";
63+
64+
public static void AddAttributeSource(IncrementalGeneratorPostInitializationContext context)
65+
=> context.AddSource($"{FullyQualifiedAutoInlineArrayAttribute}.Generated.cs", SourceText.From(SourceCode, Encoding.UTF8));
66+
}
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
using System.Text;
2+
3+
using Microsoft.CodeAnalysis;
4+
using Microsoft.CodeAnalysis.CSharp;
5+
using Microsoft.CodeAnalysis.CSharp.Syntax;
6+
using Microsoft.CodeAnalysis.Text;
7+
8+
namespace F1Game.UDP.SourceGenerator;
9+
10+
[Generator]
11+
sealed class AutoInlineArrayGenerator : IIncrementalGenerator
12+
{
13+
public void Initialize(IncrementalGeneratorInitializationContext context)
14+
{
15+
context.RegisterPostInitializationOutput(AutoInlineArrayAttributeSource.AddAttributeSource);
16+
17+
IncrementalValuesProvider<BuildTarget> buildTargets = context.SyntaxProvider.ForAttributeWithMetadataName(
18+
AutoInlineArrayAttributeSource.FullyQualifiedAutoInlineArrayAttribute,
19+
predicate: static (node, _) => NodePredicate(node),
20+
transform: static (ctx, ct) => GetSemanticTargetForGeneration(ctx, ct))
21+
.Where(static m => m is not null)!;
22+
23+
context.RegisterSourceOutput(buildTargets, static (context, source) => ExecuteGeneration(context, source));
24+
}
25+
26+
static bool NodePredicate(SyntaxNode node)
27+
{
28+
return node is TypeDeclarationSyntax { Keyword.RawKind: (int)SyntaxKind.StructKeyword }
29+
|| node is RecordDeclarationSyntax { ClassOrStructKeyword.RawKind: (int)SyntaxKind.StructKeyword };
30+
}
31+
32+
static BuildTarget? GetSemanticTargetForGeneration(GeneratorAttributeSyntaxContext context, CancellationToken _)
33+
{
34+
if (context.TargetSymbol is not INamedTypeSymbol type)
35+
return null;
36+
37+
if (!type.DeclaringSyntaxReferences.Select(x => x.GetSyntax()).Any(x => x is TypeDeclarationSyntax typeSyntax && typeSyntax.Modifiers.Any(SyntaxKind.PartialKeyword)))
38+
return Diagnostic.Create(DiagnosticDescriptors.TypeMustBePartial, type.Locations[0], type.Name);
39+
40+
if (context.Attributes.Length > 1)
41+
return Diagnostic.Create(DiagnosticDescriptors.NoMoreThanOneAttribute, type.Locations[0], type.Name);
42+
43+
if (context.Attributes[0] is not { } attributeData)
44+
return null;
45+
46+
if (attributeData.ConstructorArguments[0].Value is not int length || length <= 0)
47+
return Diagnostic.Create(DiagnosticDescriptors.LengthShouldBePositiveNumber, type.Locations[0], type.Name);
48+
49+
var elementTypeFromAttribute = attributeData.ConstructorArguments.Length > 1
50+
? attributeData.ConstructorArguments[1].Value as ITypeSymbol
51+
: null;
52+
53+
var instanceFields = type.GetMembers()
54+
.OfType<IFieldSymbol>()
55+
.Where(f => !f.IsStatic && !f.IsConst)
56+
.ToArray();
57+
58+
if (instanceFields.Length > 1)
59+
return Diagnostic.Create(DiagnosticDescriptors.TooManyInstanceFields, type.Locations[0], type.Name);
60+
61+
return (instanceFields.FirstOrDefault(), elementTypeFromAttribute) switch
62+
{
63+
(null, null) when type.IsGenericType && type.TypeParameters.Length > 0 => new GenerationData(type, length, type.TypeParameters[0], true),
64+
(null, { } elementType) => new GenerationData(type, length, elementType, true),
65+
({ } field, null) => new GenerationData(type, length, field.Type, false),
66+
({ }, { }) => Diagnostic.Create(DiagnosticDescriptors.InstanceFieldAndElementTypeArePresent, type.Locations[0], type.Name),
67+
_ => Diagnostic.Create(DiagnosticDescriptors.NoTypeForInstanceField, type.Locations[0], type.Name),
68+
};
69+
}
70+
71+
static void ExecuteGeneration(SourceProductionContext context, BuildTarget target)
72+
{
73+
context.CancellationToken.ThrowIfCancellationRequested();
74+
75+
if (target.Diagnostic is not null)
76+
{
77+
context.ReportDiagnostic(target.Diagnostic);
78+
return;
79+
}
80+
81+
if (target.GenerationData is not GenerationData generationData)
82+
return;
83+
84+
var (typeData, length, elementType, generateField) = generationData;
85+
var (namespaceName, typeName, typeDeclaration, typeMetadataName) = typeData;
86+
var namespaceDeclaration = namespaceName is null ? "" : $"namespace {namespaceName};";
87+
88+
string fieldGenerationCode = generateField
89+
? $$"""
90+
[SuppressMessage("Style", "IDE0044:Add readonly modifier", Justification = "Inline array element field must be mutable.")]
91+
[SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "Field is used by the runtime for InlineArray layout and access.")]
92+
private {{elementType}} _element0;
93+
"""
94+
: "";
95+
96+
string source = $$"""
97+
// <auto-generated/>
98+
#nullable enable
99+
100+
using System;
101+
using System.Collections.Generic;
102+
using System.Diagnostics.CodeAnalysis;
103+
using System.Runtime.CompilerServices;
104+
105+
{{namespaceDeclaration}}
106+
107+
/// <summary>
108+
/// Represents an inline array {{typeName}} with {{length}} elements of type <see cref="{{elementType}}"/>.
109+
/// Provides basic equality comparison and hashing. Access elements using the indexer (e.g., myArray[0]).
110+
/// </summary>
111+
[InlineArray({{length}})]
112+
partial {{typeDeclaration}} {{typeName}}
113+
: IEquatable<{{typeName}}>
114+
{
115+
{{fieldGenerationCode}}
116+
117+
/// <summary>Gets the fixed length of the inline array: {{length}}.</summary>
118+
public int Length => {{length}};
119+
120+
/// <summary>
121+
/// Returns a <see cref="Span{T}"/> that represents the elements of this inline array.
122+
/// </summary>
123+
/// <returns>
124+
/// A <see cref="Span{T}"/> of length <c>{{length}}</c> that provides mutable access to the elements of the inline array.
125+
/// </returns>
126+
public Span<{{elementType}}> AsSpan()
127+
=> MemoryMarshal.CreateSpan(ref Unsafe.As<{{typeName}}, {{elementType}}>(ref this), {{length}});
128+
129+
/// <summary>
130+
/// Returns a <see cref="ReadOnlySpan{T}"/> that represents the elements of this inline array.
131+
/// </summary>
132+
/// <returns>
133+
/// A <see cref="ReadOnlySpan{T}"/> of length <c>{{length}}</c> that provides read-only access to the elements of the inline array.
134+
/// </returns>
135+
public ReadOnlySpan<{{elementType}}> AsReadOnlySpan()
136+
=> MemoryMarshal.CreateReadOnlySpan(ref Unsafe.As<{{typeName}}, {{elementType}}>(ref this), {{length}});
137+
138+
/// <summary>
139+
/// Returns an <see cref="IEnumerable{T}"/> that enumerates the elements of this inline array.
140+
/// </summary>
141+
/// <returns>
142+
/// An <see cref="IEnumerable{T}"/> that iterates over the elements of the inline array in order.
143+
/// </returns>
144+
public IEnumerable<{{elementType}}> AsEnumerable()
145+
{
146+
foreach (var item in this)
147+
yield return item;
148+
}
149+
150+
/// <inheritdoc/>
151+
public override bool Equals([NotNullWhen(true)] object? obj)
152+
=> obj is {{typeName}} other && Equals(other);
153+
154+
/// <summary>Indicates whether the current inline array is equal to another inline array of the same type by comparing their elements sequentially.</summary>
155+
/// <param name="other">An inline array to compare with this instance.</param>
156+
/// <returns><c>true</c> if the current array's elements are equal to the <paramref name="other"/> array's elements; otherwise, <c>false</c>.</returns>
157+
public bool Equals({{typeName}} other)
158+
=> ((ReadOnlySpan<{{elementType}}>)this).SequenceEqual((ReadOnlySpan<{{elementType}}>)other);
159+
160+
/// <inheritdoc/>
161+
public override int GetHashCode()
162+
{
163+
HashCode hashCode = default;
164+
ReadOnlySpan<{{elementType}}> span = this;
165+
var comparer = EqualityComparer<{{elementType}}>.Default;
166+
167+
foreach (ref readonly {{elementType}} item in span)
168+
hashCode.Add(item, comparer);
169+
170+
return hashCode.ToHashCode();
171+
}
172+
173+
/// <summary>
174+
/// Creates a new <see cref="{{typeName}}"/> instance and populates it with elements from the specified <see cref="ReadOnlySpan{T}"/> source.
175+
/// </summary>
176+
/// <param name="source">
177+
/// The sequence of elements to copy into the inline array. If the sequence contains fewer elements than the array's length, the remaining elements are left at their default value.
178+
/// If the sequence contains more elements than the array's length, the extra elements are ignored.
179+
/// </param>
180+
/// <returns>
181+
/// A new <see cref="{{typeName}}"/> instance containing elements from <paramref name="source"/>.
182+
/// </returns>
183+
public static {{typeName}} Create(ReadOnlySpan<{{elementType}}> source)
184+
{
185+
var array = new {{typeName}}();
186+
for (var i = 0; i < array.Length && i < source.Length; i++)
187+
array[i] = source[i];
188+
return array;
189+
}
190+
191+
/// <summary>Determines whether two specified instances of <see cref="{{typeName}}"/> are equal by comparing their elements sequence.</summary>
192+
/// <param name="left">The first inline array to compare.</param>
193+
/// <param name="right">The second inline array to compare.</param>
194+
/// <returns><c>true</c> if the arrays are equal; otherwise, <c>false</c>.</returns>
195+
public static bool operator ==({{typeName}} left, {{typeName}} right)
196+
=> left.Equals(right);
197+
198+
/// <summary>Determines whether two specified instances of <see cref="{{typeName}}"/> are not equal by comparing their elements sequence.</summary>
199+
/// <param name="left">The first inline array to compare.</param>
200+
/// <param name="right">The second inline array to compare.</param>
201+
/// <returns><c>true</c> if the arrays are not equal; otherwise, <c>false</c>.</returns>
202+
public static bool operator !=({{typeName}} left, {{typeName}} right)
203+
=> !(left == right);
204+
205+
/// <summary>
206+
/// Implicitly converts an array of <see cref="{{elementType}}"/> to a <see cref="{{typeName}}"/>.
207+
/// Copies up to <c>{{length}}</c> elements from the source array; extra elements are ignored, and missing elements are default-initialized.
208+
/// </summary>
209+
/// <param name="source">
210+
/// The source array to copy elements from. If <paramref name="source"/> is <c>null</c>, a default-initialized <see cref="{{typeName}}"/> is returned.
211+
/// </param>
212+
/// <returns>
213+
/// A <see cref="{{typeName}}"/> containing elements from <paramref name="source"/>.
214+
/// </returns>
215+
public static implicit operator {{typeName}}({{elementType}}[] source)
216+
=> source is null ? new() : Create(source);
217+
218+
/// <summary>
219+
/// Implicitly converts a <see cref="ReadOnlySpan{T}"/> of <see cref="{{elementType}}"/> to a <see cref="{{typeName}}"/>.
220+
/// Copies up to <c>{{length}}</c> elements from the source span; extra elements are ignored, and missing elements are default-initialized.
221+
/// </summary>
222+
/// <param name="source">
223+
/// The source span to copy elements from.
224+
/// </param>
225+
/// <returns>
226+
/// A <see cref="{{typeName}}"/> containing elements from <paramref name="source"/>.
227+
/// </returns>
228+
public static implicit operator {{typeName}}(ReadOnlySpan<{{elementType}}> source)
229+
=> Create(source);
230+
231+
/// <summary>
232+
/// Implicitly converts a <see cref="Span{T}"/> of <see cref="{{elementType}}"/> to a <see cref="{{typeName}}"/>.
233+
/// Copies up to <c>{{length}}</c> elements from the source span; extra elements are ignored, and missing elements are default-initialized.
234+
/// </summary>
235+
/// <param name="source">
236+
/// The source span to copy elements from.
237+
/// </param>
238+
/// <returns>
239+
/// A <see cref="{{typeName}}"/> containing elements from <paramref name="source"/>.
240+
/// </returns>
241+
public static implicit operator {{typeName}}(Span<{{elementType}}> source)
242+
=> Create(source);
243+
}
244+
""";
245+
246+
context.AddSource($"{typeMetadataName}.Generated.cs", SourceText.From(source, Encoding.UTF8));
247+
}
248+
}

0 commit comments

Comments
 (0)