iTranslated by AI
Single-File Source Generators
FGenerator is a framework designed to let AI efficiently generate Roslyn Source Generators. Of course, it is also usable by humans.
The resulting source generator (IIncrementalGenerator) works in Unity 2022.3.12 and later.
The Underlying Problem
AI cannot generate Roslyn generators well.
- The Roslyn API is chaotic due to constant additions, making AI generation results unstable.
- It may or may not support nested types/generic types.
- Numerous other missing considerations.
The bloated Roslyn API is particularly troublesome; even though the tasks aren't inherently difficult, it causes unstable generation results. Additionally, to judge whether the AI's output is correct, the person giving instructions needs knowledge of the chaotic Roslyn API.
Therefore,
- Even though the content is at the same level as Excel macros or batch commands,
the barrier to entry remains high.
The Context Problem
Generative AI itself produces high-quality code as long as you can make it focus on details.
👇 (According to the AI) A method to search for target attributes without unnecessary string allocation:
var lastIdentifier = attribute.Name switch
{
IdentifierNameSyntax id => id.Identifier.Text,
QualifiedNameSyntax q => q.Right.Identifier.Text,
AliasQualifiedNameSyntax a => a.Name.Identifier.Text,
_ => (attribute.Name as SimpleNameSyntax)?.Identifier.Text ?? attribute.Name.ToString(),
};
if (lastIdentifier == _targetAttributeBaseName ||
lastIdentifier == _targetAttributeNameWithSuffix)
{
return true;
}
It feels like, "How am I supposed to know that?" As you can see, the biggest problem with Roslyn is:
- The person giving instructions needs to know where to focus.
I only know this result because when it happened to be output, I asked the AI, and it said, "This is the method with the lowest allocation." (*The tricky part is that even if you ask the same AI to output it, this doesn't always come out.)
Roslyn API is full of these kinds of syntax branches everywhere.
Framework for Generative AI
FGenerator uses a declarative API, which makes it harder for AI to make mistakes. It is also designed to be easy for humans to understand when reviewing the code.
It works simply by doing the following in a single file:
- Reference FGenerator.Sdk
- Add the
Generatorattribute - Inherit from
FGeneratorBase - Implement
Generateand other methods
If TargetAttributeName returns null, it targets "all types in the assembly." This means that if you omit the source code generation part, you could potentially implement an analyzer acting as a code convention checker. (Probably)
#:sdk FGenerator.Sdk@1.2.0
using FGenerator;
using Microsoft.CodeAnalysis;
[Generator]
public class MyGenerator : FGeneratorBase
{
protected override string DiagnosticCategory => "MyGenerator";
protected override string DiagnosticIdPrefix => "MYGEN";
protected override string? TargetAttributeName => "MyAttribute";
protected override string? PostInitializationOutput =>
"internal sealed class MyAttribute : System.Attribute { }";
protected override CodeGeneration? Generate(Target target, out AnalyzeResult? diagnostic)
{
diagnostic = null;
if (!target.IsPartial)
{
// Notify IDE of MYGEN001 error
diagnostic = new("001", "Type Not Partial", DiagnosticSeverity.Error, "Error details");
return null;
}
// Code generation
return new CodeGeneration(target.ToHintName(), "// Built with FGenerator.Sdk");
}
}
Benefits of Declarative API
When using FGenerator with generative AI, a source generator that automatically implements the notoriously tedious INotifyPropertyChanged would look like this:
Generation Result
Since it's source generation, it feels like "don't make unnecessary method calls."
[AutoNotify]
public partial class Person<T>
{
private string _firstName = string.Empty;
private string _lastName = string.Empty;
private int _age;
}
// 👇
partial class Person<T> : INotifyPropertyChanged
{
/// <summary>
/// Occurs when a property value changes.
/// </summary>
public event PropertyChangedEventHandler? PropertyChanged;
public string FirstName
{
get => _firstName;
set => SetField(ref _firstName, value);
}
public string LastName
{
get => _lastName;
set => SetField(ref _lastName, value);
}
public int Age
{
get => _age;
set => SetField(ref _age, value);
}
/// <summary>
/// Raises the PropertyChanged event.
/// </summary>
/// <param name="propertyName">Name of the property that changed.</param>
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
/// <summary>
/// Sets the field value and raises PropertyChanged if the value changed.
/// </summary>
/// <returns>True if the value changed, false otherwise.</returns>
protected bool SetField<A>(ref A field, A value, [CallerMemberName] string? propertyName = null)
{
if (System.Collections.Generic.EqualityComparer<A>.Default.Equals(field, value))
{
return false;
}
field = value;
OnPropertyChanged(propertyName);
return true;
}
}
Here is a slightly more complex StackArray (which is the C# standard InlineArray with IEnumerable added).
Generation Result
A 64-byte struct!
[StackArray(16, typeof(int))]
public partial struct StackArray16
{
}
// 👇
[StructLayout(LayoutKind.Sequential, Pack = 1)]
partial struct StackArray16 : IEnumerable<int>, IEnumerator<int>, IEquatable<global::SampleConsumer.StackArray.StackArray16>
{
private int _value0;
private int _value1;
private int _value2;
private int _value3;
private int _value4;
private int _value5;
private int _value6;
private int _value7;
private int _value8;
private int _value9;
private int _value10;
private int _value11;
private int _value12;
private int _value13;
private int _value14;
private int _value15;
public const int Length = 16;
private int _enumeratorIndex;
public StackArray16(ReadOnlySpan<int> source, bool allowLengthMismatch = false)
: this()
{
int copyLength = source.Length;
if (!allowLengthMismatch && copyLength != Length)
{
throw new ArgumentException("Length mismatch.", nameof(source));
}
if (copyLength > Length)
{
copyLength = Length;
}
var destination = AsSpan();
source.Slice(0, copyLength).CopyTo(destination);
}
public Span<int> AsSpan() => MemoryMarshal.CreateSpan(ref _value0, Length);
public ref int this[int index] => ref AsSpan()[index];
[EditorBrowsable(EditorBrowsableState.Never)]
public global::SampleConsumer.StackArray.StackArray16 GetEnumerator()
{
_enumeratorIndex = -1;
return this;
}
IEnumerator<int> IEnumerable<int>.GetEnumerator() => GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
[EditorBrowsable(EditorBrowsableState.Never)]
public bool MoveNext()
{
if (_enumeratorIndex >= Length - 1)
{
return false;
}
_enumeratorIndex++;
return true;
}
void IEnumerator.Reset() => throw new NotSupportedException();
[EditorBrowsable(EditorBrowsableState.Never)]
public int Current => AsSpan()[_enumeratorIndex];
object IEnumerator.Current => Current!;
[EditorBrowsable(EditorBrowsableState.Never)]
public void Dispose() { }
public bool Equals(global::SampleConsumer.StackArray.StackArray16 other) => AsSpan().SequenceEqual(other.AsSpan());
public override bool Equals(object? obj) => obj is global::SampleConsumer.StackArray.StackArray16 other && Equals(other);
public override int GetHashCode()
{
// Hash combines Length plus up to 7 evenly spaced elements.
return HashCode.Combine(Length, _value0, _value2, _value4, _value6, _value9, _value11, _value13);
}
public static bool operator ==(global::SampleConsumer.StackArray.StackArray16 left, global::SampleConsumer.StackArray.StackArray16 right) => left.Equals(right);
public static bool operator !=(global::SampleConsumer.StackArray.StackArray16 left, global::SampleConsumer.StackArray.StackArray16 right) => !left.Equals(right);
public override string ToString()
{
var span = AsSpan();
var builder = new StringBuilder();
builder.Append('[');
for (int i = 0; i < span.Length; i++)
{
if (i > 0)
{
builder.Append(", ");
}
builder.Append(span[i]);
}
builder.Append(']');
return builder.ToString();
}
}
StackList that stays entirely on the stack (I thought it seemed useful, but the struct size when copying is crazy, so its use cases are limited).
Generation Result
It's so long!
[StackList(9, SwapRemove = true)]
public partial struct StackListSwapRemove<T> where T : unmanaged, IEquatable<T>
{
}
// 👇
[StructLayout(LayoutKind.Sequential, Pack = 1)]
partial struct StackListSwapRemove<T> : IList<T>, IEnumerable<T>, IEnumerator<T>, IEquatable<global::SampleConsumer.StackList.StackListSwapRemove<T>>
{
private T _value0;
private T _value1;
private T _value2;
private T _value3;
private T _value4;
private T _value5;
private T _value6;
private T _value7;
private T _value8;
public const int MaxCount = 9;
private int _count;
private int _enumeratorIndex;
public int Count => _count;
bool ICollection<T>.IsReadOnly => false;
public Span<T> AsSpan() => MemoryMarshal.CreateSpan(ref _value0, _count);
public Span<T> AsFullSpan() => MemoryMarshal.CreateSpan(ref _value0, MaxCount);
public T this[int index]
{
get
{
return AsSpan()[index];
}
set
{
AsSpan()[index] = value;
}
}
public void Add(T item)
{
if (_count >= MaxCount)
{
ThrowArgumentOutOfRange("capacity", "List has reached its maximum capacity.");
}
AsFullSpan()[_count] = item;
_count++;
}
public void Clear()
{
AsSpan().Clear();
_count = 0;
}
public void CopyTo(T[] array, int arrayIndex)
{
if (arrayIndex < 0) ThrowArgumentOutOfRange(nameof(arrayIndex));
if (array == null) throw new ArgumentNullException(nameof(array));
if (array.Length - arrayIndex < _count) throw new ArgumentException("Destination array is not long enough.");
AsSpan().CopyTo(array.AsSpan(arrayIndex));
}
IEnumerator<T> IEnumerable<T>.GetEnumerator() => GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
[EditorBrowsable(EditorBrowsableState.Never)]
public global::SampleConsumer.StackList.StackListSwapRemove<T> GetEnumerator()
{
_enumeratorIndex = -1;
return this;
}
public bool Contains(T item) => IndexOf(item) >= 0;
public int IndexOf(T item)
{
// Use Span<T>.IndexOf for IEquatable<T> to allow vectorized search and avoid comparer allocations.
return AsSpan().IndexOf(item);
}
public void Insert(int index, T item)
{
if (unchecked((uint)index > (uint)_count)) ThrowArgumentOutOfRange(nameof(index));
if (_count >= MaxCount) ThrowArgumentOutOfRange("capacity", "List has reached its maximum capacity.");
var fullSpan = AsFullSpan();
int moveCount = _count - index;
if (moveCount > 0)
{
fullSpan.Slice(index, moveCount).CopyTo(fullSpan.Slice(index + 1));
}
fullSpan[index] = item;
_count++;
}
public void RemoveAt(int index)
{
if (unchecked((uint)index >= (uint)_count)) ThrowArgumentOutOfRange(nameof(index));
var fullSpan = AsFullSpan();
int lastIndex = _count - 1;
// Swap-remove: move last element into the removed slot to keep removal O(1) without preserving order.
if (_count > 1 && index != lastIndex)
{
fullSpan[index] = fullSpan[lastIndex];
}
fullSpan[lastIndex] = default!;
_count--;
}
public bool Remove(T item)
{
int index = IndexOf(item);
if (index < 0)
{
return false;
}
RemoveAt(index);
return true;
}
[EditorBrowsable(EditorBrowsableState.Never)]
public T Current => AsSpan()[_enumeratorIndex];
object IEnumerator.Current => Current!;
[EditorBrowsable(EditorBrowsableState.Never)]
public bool MoveNext()
{
if (_enumeratorIndex >= _count - 1)
{
return false;
}
_enumeratorIndex++;
return true;
}
[EditorBrowsable(EditorBrowsableState.Never)]
public void Dispose() { }
void IEnumerator.Reset() => throw new NotSupportedException();
public bool Equals(global::SampleConsumer.StackList.StackListSwapRemove<T> other)
{
if (_count != other._count)
{
return false;
}
return AsSpan().SequenceEqual(other.AsSpan());
}
public override bool Equals(object? obj) => obj is global::SampleConsumer.StackList.StackListSwapRemove<T> other && Equals(other);
public override int GetHashCode()
{
if (_count == 0) return HashCode.Combine(0);
// Hash combines count plus first/middle/last samples (small counts may reuse the same index) to keep HashCode.Combine arity small.
var span = AsSpan();
return HashCode.Combine(_count, span[0], span[_count >> 1], span[_count - 1]);
}
public static bool operator ==(global::SampleConsumer.StackList.StackListSwapRemove<T> left, global::SampleConsumer.StackList.StackListSwapRemove<T> right) => left.Equals(right);
public static bool operator !=(global::SampleConsumer.StackList.StackListSwapRemove<T> left, global::SampleConsumer.StackList.StackListSwapRemove<T> right) => !left.Equals(right);
public override string ToString()
{
var span = AsSpan();
var builder = new StringBuilder();
builder.Append('[');
for (int i = 0; i < span.Length; i++)
{
if (i > 0)
{
builder.Append(", ");
}
builder.Append(span[i]);
}
builder.Append(']');
return builder.ToString();
}
[DoesNotReturn]
private static void ThrowArgumentOutOfRange(string paramName, string? message = null)
=> throw new ArgumentOutOfRangeException(paramName, message);
public bool AddUnique(T item)
{
if (IndexOf(item) >= 0) return false;
Add(item);
return true;
}
/// <summary>
/// Adds all items from <paramref name="collection"/> and returns the resulting <see cref="Count"/>.
/// </summary>
/// <returns>The new total count after the items are appended.</returns>
public int AddRange(IEnumerable<T> collection)
{
if (collection is null) throw new ArgumentNullException(nameof(collection));
int incomingCount = collection switch
{
ICollection<T> x => x.Count,
IReadOnlyCollection<T> x => x.Count,
_ => -1,
};
if (incomingCount > 0)
{
int newCount = _count + incomingCount;
if (newCount > MaxCount)
{
ThrowArgumentOutOfRange("capacity", "List has reached its maximum capacity.");
}
int writeIndex = _count;
var destination = AsFullSpan();
foreach (var item in collection)
{
destination[writeIndex] = item;
writeIndex++;
}
_count = newCount;
return _count;
}
else if (incomingCount < 0)
{
return AddRangeSlow(collection);
}
else
{
return _count;
}
}
private int AddRangeSlow(IEnumerable<T> collection)
{
foreach (var item in collection)
{
Add(item);
}
return _count;
}
/// <summary>
/// Adds items until capacity is reached; excess items are ignored.
/// </summary>
/// <returns>The new total count after copying up to available capacity.</returns>
public int AddRangeTruncateOverflow(ReadOnlySpan<T> items)
{
int available = MaxCount - _count;
if (available <= 0 || items.IsEmpty)
{
return _count;
}
int copyLength = items.Length;
if (copyLength > available)
{
copyLength = available;
}
items.Slice(0, copyLength).CopyTo(AsFullSpan().Slice(_count, copyLength));
_count += copyLength;
return _count;
}
/// <summary>
/// Adds or replaces items while retaining only the most recent elements; drops oldest existing items first, or if incoming alone exceeds capacity keeps only its last MaxCount items.
/// </summary>
/// <returns>The new total count after the operation (never exceeds capacity).</returns>
public int AddRangeDropOldest(ReadOnlySpan<T> incoming)
{
if (incoming.IsEmpty)
{
return _count;
}
var existing = AsFullSpan();
int total = _count + incoming.Length;
if (total <= MaxCount)
{
incoming.CopyTo(existing.Slice(_count));
return (_count = total);
}
// Incoming alone overflows capacity; keep the most recent portion of incoming.
if (incoming.Length >= MaxCount)
{
incoming.Slice(incoming.Length - MaxCount, MaxCount).CopyTo(existing);
return (_count = MaxCount);
}
else
{
int dropExisting = _count - (MaxCount - incoming.Length);
int existingCount = _count - dropExisting;
existing.Slice(dropExisting, existingCount).CopyTo(existing);
incoming.CopyTo(existing.Slice(existingCount));
return (_count = MaxCount);
}
}
/// <summary>
/// Adds unique items from <paramref name="collection"/>.
/// </summary>
/// <returns>The number of items that were added.</returns>
public int AddRangeUnique(IEnumerable<T> collection)
{
if (collection is null) throw new ArgumentNullException(nameof(collection));
int added = 0;
foreach (var item in collection)
{
if (IndexOf(item) >= 0)
{
continue;
}
Add(item);
added++;
}
return added;
}
}
Since you only need to look at Generate, the frustration of "the AI generated it, but I have no idea what it's doing!" is gone. Generative AI just struggles with "Roslyn's boilerplate"; once you overcome that hurdle, there are no issues. Since the generator is contained in a single file, the AI won't suddenly start implementing weird logic either.
If you say, "I want AddRange, AddUnique, and AddRangeUnique," it will implement them for you; and if you say, "I want to implement IEquatable," it will do that too. It even properly handles logic like "If T is IEquatable<T>, use AsSpan().IndexOf(item) to allow vectorized search" (though you do need to give such instructions!).
There are no restrictions on how you search for target attributes, so in theory, you should be able to implement things like syntax checks just by adding attributes to methods.
Disposable
Since you can generate them so easily, they are no longer "critical source generators referenced by multiple projects." You can quickly add features when needed without worrying about compatibility with other projects.
Git history won't be cluttered with ".csproj files containing mysterious incantations and groups of confusing .cs files," allowing you to treat them at the same level as macros or batch files rather than something special.
It's also a big plus that the source generator itself isn't included in the build, so as long as the generated output is as expected, you don't have to worry about the rest.
Playing with Attribute Parameters
AI is only bad at Roslyn "spells"; it's actually more knowledgeable than humans when it comes to logic like "using the first argument of an attribute as the array element count." It will do it just as you ask.
Restricting to unmanaged or excluding ref structs required specific instructions, but such rigorous checks aren't strictly necessary if you treat it as a "project-specific tool that just needs to work."
// The first RawAttributes entry corresponds to TargetAttributeName.
var attr = target.RawAttributes.FirstOrDefault();
if (attr == null)
{
diagnostic = new AnalyzeResult("004", "Attribute missing", DiagnosticSeverity.Error, "StackArrayGenerator attribute could not be resolved.");
return null;
}
var length = (attr.ConstructorArguments.Length != 0 && attr.ConstructorArguments[0].Value is int LEN) ? LEN : -1;
if (length <= 0)
{
diagnostic = new AnalyzeResult("005", "Length must be positive", DiagnosticSeverity.Error, "Specify a positive length in [StackArrayGenerator].");
return null;
}
In the first place, implementing an analyzer/generator that accounts for every possible syntax is nearly impossible unless you are a Roslyn developer.
(Looking at how the Pure attribute remains mostly a statement of intent, perhaps the syntax flexibility is so high that it's impossible even for the authors?)
Build for Unity
Setting up a source generator for Unity is also somewhat tedious, so I've provided a CLI tool (created by AI) that can generate .meta files and merge DLL files (if necessary).
dnx -y FGenerator.Cli -- build "./**/*.cs" --unity -f -o ".."
Folder structure:
- Assets/
- SourceGenerators/
- src/
-
.asmdefto prevent compilation errors (#Assembly for testing) build.bat- SingleFileSourceGenerator1.cs
- SingleFileSourceGenerator2.cs
- ...
-
- src/
- SourceGenerators/
With this structure, you can use the source generator in a Unity project just by running the batch file.
- By the way, the scope of analyzers/source generators can be defined in
.asmdef.
Conclusion
I thought I decided not to create unnecessary things...!
That's all. Thank you for your hard work.
Discussion