diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..fb6ac7d
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,48 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - 'Alchemy.SourceGenerator/**'
+ - 'Alchemy.SourceGenerator.Tests/**'
+ - '.github/workflows/ci.yml'
+ pull_request:
+ paths:
+ - 'Alchemy.SourceGenerator/**'
+ - 'Alchemy.SourceGenerator.Tests/**'
+ - '.github/workflows/ci.yml'
+ workflow_dispatch:
+
+jobs:
+ source-generator-tests:
+ name: Source generator tests
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Restore
+ run: dotnet restore Alchemy.SourceGenerator/Alchemy.SourceGenerator.sln
+
+ - name: Build
+ run: >
+ dotnet build Alchemy.SourceGenerator/Alchemy.SourceGenerator.sln
+ --configuration Release
+ --no-restore
+
+ - name: Test
+ run: >
+ dotnet run
+ --project Alchemy.SourceGenerator.Tests/Alchemy.SourceGenerator.Tests.csproj
+ --configuration Release
+ --no-build
+ --
+ --treenode-filter "/*/Alchemy.SourceGenerator.Tests/*/*"
diff --git a/Alchemy.SourceGenerator.Tests/.gitignore b/Alchemy.SourceGenerator.Tests/.gitignore
new file mode 100644
index 0000000..62d1e79
--- /dev/null
+++ b/Alchemy.SourceGenerator.Tests/.gitignore
@@ -0,0 +1,3 @@
+bin/
+obj/
+TestResults/
diff --git a/Alchemy.SourceGenerator.Tests/Alchemy.SourceGenerator.Tests.csproj b/Alchemy.SourceGenerator.Tests/Alchemy.SourceGenerator.Tests.csproj
new file mode 100644
index 0000000..215b5a9
--- /dev/null
+++ b/Alchemy.SourceGenerator.Tests/Alchemy.SourceGenerator.Tests.csproj
@@ -0,0 +1,30 @@
+
+
+
+
+ Exe
+ net10.0
+ latest
+ enable
+ enable
+ false
+ Alchemy.SourceGenerator.Tests
+
+ $(NoWarn);NU1608;NU1701
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Alchemy.SourceGenerator.Tests/DiagnosticTests.cs b/Alchemy.SourceGenerator.Tests/DiagnosticTests.cs
new file mode 100644
index 0000000..2e418a7
--- /dev/null
+++ b/Alchemy.SourceGenerator.Tests/DiagnosticTests.cs
@@ -0,0 +1,198 @@
+using Microsoft.CodeAnalysis;
+
+namespace Alchemy.SourceGenerator.Tests;
+
+///
+/// The three diagnostics declared in .
+///
+public class DiagnosticTests
+{
+ [Test]
+ public async Task ALCHEMY001_is_reported_for_a_non_partial_type()
+ {
+ var result = GeneratorUtils.Run("""
+ using System;
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace Demo
+ {
+ [AlchemySerialize]
+ public class NotPartial
+ {
+ [AlchemySerializeField, NonSerialized]
+ public Dictionary map = new();
+ }
+ }
+ """);
+
+ await Assert.That(result.HasDiagnostic("ALCHEMY001")).IsTrue();
+
+ var diagnostic = result.GeneratorDiagnostics.Single(d => d.Id == "ALCHEMY001");
+ await Assert.That(diagnostic.Severity).IsEqualTo(DiagnosticSeverity.Error);
+ await Assert.That(diagnostic.GetMessage()).Contains("NotPartial");
+ }
+
+ [Test]
+ public async Task ALCHEMY001_suppresses_generation_for_that_type()
+ {
+ var result = GeneratorUtils.Run("""
+ using Alchemy.Serialization;
+
+ namespace Demo
+ {
+ [AlchemySerialize]
+ public class NotPartial { }
+ }
+ """);
+
+ await Assert.That(result.GeneratedSources).IsEmpty();
+ }
+
+ [Test]
+ public async Task ALCHEMY002_is_reported_for_a_nested_type()
+ {
+ var result = GeneratorUtils.Run("""
+ using System;
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace Demo
+ {
+ public partial class Outer
+ {
+ [AlchemySerialize]
+ public partial class Inner
+ {
+ [AlchemySerializeField, NonSerialized]
+ public Dictionary map = new();
+ }
+ }
+ }
+ """);
+
+ await Assert.That(result.HasDiagnostic("ALCHEMY002")).IsTrue();
+ await Assert.That(result.GeneratedSources).IsEmpty();
+
+ var diagnostic = result.GeneratorDiagnostics.Single(d => d.Id == "ALCHEMY002");
+ await Assert.That(diagnostic.Severity).IsEqualTo(DiagnosticSeverity.Error);
+ await Assert.That(diagnostic.GetMessage()).Contains("Inner");
+ }
+
+ [Test]
+ public async Task ALCHEMY003_warns_when_the_field_is_not_marked_NonSerialized()
+ {
+ // Without [NonSerialized] Unity serializes the field itself as well as the
+ // JSON payload, so the value is stored twice and can diverge.
+ var result = GeneratorUtils.Run("""
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace Demo
+ {
+ [AlchemySerialize]
+ public partial class Warned
+ {
+ [AlchemySerializeField]
+ public Dictionary map = new();
+ }
+ }
+ """);
+
+ await Assert.That(result.HasDiagnostic("ALCHEMY003")).IsTrue();
+
+ var diagnostic = result.GeneratorDiagnostics.Single(d => d.Id == "ALCHEMY003");
+ await Assert.That(diagnostic.Severity).IsEqualTo(DiagnosticSeverity.Warning);
+ await Assert.That(diagnostic.GetMessage()).Contains("map");
+ }
+
+ [Test]
+ public async Task ALCHEMY003_still_generates_the_code()
+ {
+ // It is a warning, not an error — generation must continue.
+ var result = GeneratorUtils.Run("""
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace Demo
+ {
+ [AlchemySerialize]
+ public partial class Warned
+ {
+ [AlchemySerializeField]
+ public Dictionary map = new();
+ }
+ }
+ """);
+
+ await Assert.That(result.GeneratedSources.Length).IsEqualTo(1);
+ await Assert.That(result.DescribeCompilationErrors()).IsEqualTo("");
+ }
+
+ [Test]
+ public async Task No_diagnostic_when_NonSerialized_is_present()
+ {
+ var result = GeneratorUtils.Run("""
+ using System;
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace Demo
+ {
+ [AlchemySerialize]
+ public partial class Clean
+ {
+ [AlchemySerializeField, NonSerialized]
+ public Dictionary map = new();
+ }
+ }
+ """);
+
+ await Assert.That(result.GeneratorDiagnostics).IsEmpty();
+ }
+
+ [Test]
+ public async Task Namespace_qualified_NonSerialized_is_recognised()
+ {
+ // The bare spelling is covered by No_diagnostic_when_NonSerialized_is_present.
+ var result = GeneratorUtils.Run("""
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace Demo
+ {
+ [AlchemySerialize]
+ public partial class Spelled
+ {
+ [AlchemySerializeField, System.NonSerialized]
+ public Dictionary map = new();
+ }
+ }
+ """);
+
+ await Assert.That(result.HasDiagnostic("ALCHEMY003")).IsFalse();
+ }
+
+ [Test]
+ public async Task The_generator_never_crashes_on_well_formed_input()
+ {
+ // A generator crash surfaces as the catch-all "AlchemySerializeGeneratorError".
+ var result = GeneratorUtils.Run("""
+ using System;
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace Demo
+ {
+ [AlchemySerialize]
+ public partial class Fine
+ {
+ [AlchemySerializeField, NonSerialized]
+ public Dictionary map = new();
+ }
+ }
+ """);
+
+ await Assert.That(result.HasDiagnostic("AlchemySerializeGeneratorError")).IsFalse();
+ }
+}
diff --git a/Alchemy.SourceGenerator.Tests/GenerationTests.cs b/Alchemy.SourceGenerator.Tests/GenerationTests.cs
new file mode 100644
index 0000000..219f254
--- /dev/null
+++ b/Alchemy.SourceGenerator.Tests/GenerationTests.cs
@@ -0,0 +1,259 @@
+namespace Alchemy.SourceGenerator.Tests;
+
+///
+/// Core generation behaviour: what the generator emits for well-formed input,
+/// and that the emitted code actually compiles.
+///
+public class GenerationTests
+{
+ const string Simple = """
+ using System;
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace Demo
+ {
+ [AlchemySerialize]
+ public partial class Sample
+ {
+ [AlchemySerializeField, NonSerialized]
+ public Dictionary map = new();
+ }
+ }
+ """;
+
+ [Test]
+ public async Task Emits_exactly_one_source_file_per_attributed_type()
+ {
+ var result = GeneratorUtils.Run(Simple);
+ await Assert.That(result.GeneratedSources.Length).IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task Generated_code_compiles_cleanly()
+ {
+ var result = GeneratorUtils.Run(Simple);
+ await Assert.That(result.DescribeCompilationErrors()).IsEqualTo("");
+ }
+
+ [Test]
+ public async Task Hint_name_is_the_fully_qualified_type_name()
+ {
+ var result = GeneratorUtils.Run(Simple);
+ await Assert.That(result.GeneratedSources[0].HintName)
+ .IsEqualTo("Demo.Sample.AlchemySerializeGenerator.g.cs");
+ }
+
+ [Test]
+ public async Task Implements_ISerializationCallbackReceiver_explicitly()
+ {
+ var text = GeneratorUtils.Run(Simple).AllGeneratedText;
+
+ await Assert.That(text).Contains("partial class Sample : global::UnityEngine.ISerializationCallbackReceiver");
+ await Assert.That(text).Contains("void global::UnityEngine.ISerializationCallbackReceiver.OnBeforeSerialize()");
+ await Assert.That(text).Contains("void global::UnityEngine.ISerializationCallbackReceiver.OnAfterDeserialize()");
+ }
+
+ [Test]
+ public async Task Round_trips_each_field_through_SerializationHelper()
+ {
+ var text = GeneratorUtils.Run(Simple).AllGeneratedText;
+
+ await Assert.That(text).Contains("SerializationHelper.ToJson(this.map");
+ await Assert.That(text).Contains("SerializationHelper.FromJson<");
+ await Assert.That(text).Contains(".map.isCreated = true;");
+ }
+
+ [Test]
+ public async Task Clears_the_UnityObject_reference_table_before_each_serialize()
+ {
+ // The reference table is index-based; failing to clear it would make indices
+ // drift on every re-serialize.
+ var text = GeneratorUtils.Run(Simple).AllGeneratedText;
+ await Assert.That(text).Contains("UnityObjectReferences.Clear();");
+ }
+
+ [Test]
+ public async Task Wraps_every_field_in_try_catch_so_one_bad_field_cannot_abort_the_rest()
+ {
+ var text = GeneratorUtils.Run(Simple).AllGeneratedText;
+ await Assert.That(text).Contains("catch (global::System.Exception ex)");
+ await Assert.That(text).Contains("global::UnityEngine.Debug.LogException(ex);");
+ }
+
+ [Test]
+ public async Task Backing_store_is_hidden_from_the_inspector_by_default()
+ {
+ var text = GeneratorUtils.Run(Simple).AllGeneratedText;
+ await Assert.That(text).Contains("[global::UnityEngine.HideInInspector, global::UnityEngine.SerializeField]");
+ }
+
+ [Test]
+ public async Task ShowAlchemySerializationData_replaces_HideInInspector_with_a_visible_readonly_field()
+ {
+ var result = GeneratorUtils.Run("""
+ using System;
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace Demo
+ {
+ [AlchemySerialize, ShowAlchemySerializationData]
+ public partial class Shown
+ {
+ [AlchemySerializeField, NonSerialized]
+ public Dictionary map = new();
+ }
+ }
+ """);
+
+ var text = result.AllGeneratedText;
+ await Assert.That(text).Contains("global::Alchemy.Inspector.LabelText(\"Alchemy Serialization Data (Demo.Shown)\")");
+ await Assert.That(text).Contains("global::Alchemy.Inspector.ReadOnly");
+ await Assert.That(text).DoesNotContain("[global::UnityEngine.HideInInspector, global::UnityEngine.SerializeField] private AlchemySerializationData");
+ await Assert.That(result.DescribeCompilationErrors()).IsEqualTo("");
+ }
+
+ [Test]
+ public async Task Multiple_fields_each_get_their_own_serialization_slot()
+ {
+ var result = GeneratorUtils.Run("""
+ using System;
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace Demo
+ {
+ [AlchemySerialize]
+ public partial class Many
+ {
+ [AlchemySerializeField, NonSerialized] public Dictionary a = new();
+ [AlchemySerializeField, NonSerialized] public HashSet b = new();
+ [AlchemySerializeField, NonSerialized] public (int, int) c;
+ }
+ }
+ """);
+
+ var text = result.AllGeneratedText;
+ await Assert.That(text).Contains("public Item a = new();");
+ await Assert.That(text).Contains("public Item b = new();");
+ await Assert.That(text).Contains("public Item c = new();");
+ await Assert.That(result.DescribeCompilationErrors()).IsEqualTo("");
+ }
+
+ [Test]
+ public async Task Multiple_declarators_on_one_field_line_are_all_captured()
+ {
+ var result = GeneratorUtils.Run("""
+ using System;
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace Demo
+ {
+ [AlchemySerialize]
+ public partial class Decl
+ {
+ [AlchemySerializeField, NonSerialized]
+ public Dictionary x = new(), y = new();
+ }
+ }
+ """);
+
+ var text = result.AllGeneratedText;
+ await Assert.That(text).Contains("public Item x = new();");
+ await Assert.That(text).Contains("public Item y = new();");
+ await Assert.That(result.DescribeCompilationErrors()).IsEqualTo("");
+ }
+
+ [Test]
+ public async Task Fields_without_the_attribute_are_ignored()
+ {
+ var text = GeneratorUtils.Run("""
+ using System;
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace Demo
+ {
+ [AlchemySerialize]
+ public partial class Mixed
+ {
+ [AlchemySerializeField, NonSerialized] public Dictionary tracked = new();
+ public int untracked;
+ }
+ }
+ """).AllGeneratedText;
+
+ await Assert.That(text).Contains("public Item tracked = new();");
+ await Assert.That(text).DoesNotContain("untracked");
+ }
+
+ [Test]
+ public async Task A_type_with_no_attributed_fields_still_produces_valid_code()
+ {
+ var result = GeneratorUtils.Run("""
+ using Alchemy.Serialization;
+
+ namespace Demo
+ {
+ [AlchemySerialize]
+ public partial class Empty { }
+ }
+ """);
+
+ await Assert.That(result.GeneratedSources.Length).IsEqualTo(1);
+ await Assert.That(result.DescribeCompilationErrors()).IsEqualTo("");
+ }
+
+ [Test]
+ public async Task Source_without_the_attribute_generates_nothing()
+ {
+ var result = GeneratorUtils.Run("""
+ namespace Demo
+ {
+ public partial class Untouched
+ {
+ public int value;
+ }
+ }
+ """);
+
+ await Assert.That(result.GeneratedSources).IsEmpty();
+ await Assert.That(result.GeneratorDiagnostics).IsEmpty();
+ }
+
+ [Test]
+ public async Task Private_and_protected_fields_are_supported()
+ {
+ var result = GeneratorUtils.Run("""
+ using System;
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace Demo
+ {
+ [AlchemySerialize]
+ public partial class Access
+ {
+ [AlchemySerializeField, NonSerialized] private Dictionary priv = new();
+ [AlchemySerializeField, NonSerialized] protected HashSet prot = new();
+ }
+ }
+ """);
+
+ var text = result.AllGeneratedText;
+ await Assert.That(text).Contains("public Item priv = new();");
+ await Assert.That(text).Contains("public Item prot = new();");
+ await Assert.That(result.DescribeCompilationErrors()).IsEqualTo("");
+ }
+
+ [Test]
+ public async Task Generation_is_deterministic_across_runs()
+ {
+ var first = GeneratorUtils.Run(Simple).AllGeneratedText;
+ var second = GeneratorUtils.Run(Simple).AllGeneratedText;
+ await Assert.That(first).IsEqualTo(second);
+ }
+
+}
diff --git a/Alchemy.SourceGenerator.Tests/GeneratorUtils.cs b/Alchemy.SourceGenerator.Tests/GeneratorUtils.cs
new file mode 100644
index 0000000..e6c920d
--- /dev/null
+++ b/Alchemy.SourceGenerator.Tests/GeneratorUtils.cs
@@ -0,0 +1,177 @@
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+
+namespace Alchemy.SourceGenerator.Tests;
+
+///
+/// The outcome of running over a piece of source text.
+///
+public sealed record GeneratorResult(
+ ImmutableArray GeneratorDiagnostics,
+ ImmutableArray GeneratedSources,
+ Compilation OutputCompilation)
+{
+ /// Diagnostics the generator itself reported (ALCHEMY001-003, crash reports).
+ public IEnumerable Errors =>
+ GeneratorDiagnostics.Where(d => d.Severity == DiagnosticSeverity.Error);
+
+ public IEnumerable Warnings =>
+ GeneratorDiagnostics.Where(d => d.Severity == DiagnosticSeverity.Warning);
+
+ public bool HasDiagnostic(string id) => GeneratorDiagnostics.Any(d => d.Id == id);
+
+ /// All generated code concatenated, convenient for substring assertions.
+ public string AllGeneratedText =>
+ string.Join("\n", GeneratedSources.Select(s => s.Text));
+
+ ///
+ /// Compile errors produced by the *final* compilation (original source + generated code).
+ /// This is what proves the generator emitted valid C#.
+ ///
+ public ImmutableArray CompilationErrors =>
+ OutputCompilation.GetDiagnostics()
+ .Where(d => d.Severity == DiagnosticSeverity.Error)
+ .ToImmutableArray();
+
+ public string DescribeCompilationErrors() =>
+ CompilationErrors.Length == 0
+ ? ""
+ : string.Join("\n", CompilationErrors.Select(d => $"{d.Id}: {d.GetMessage()} @ {d.Location.GetLineSpan()}"));
+}
+
+public sealed record GeneratedSource(string HintName, string Text);
+
+///
+/// Compiles source text and runs the Alchemy source generator over it.
+///
+public static class GeneratorUtils
+{
+ static readonly ImmutableArray FrameworkReferences = LoadFrameworkReferences();
+
+ static ImmutableArray LoadFrameworkReferences()
+ {
+ // Reference every assembly in the running framework so the test source can use
+ // anything from the BCL without pulling in a reference-assembly package.
+ var tpa = (string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") ?? "";
+ return tpa.Split(Path.PathSeparator)
+ .Where(p => p.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) && File.Exists(p))
+ .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p))
+ .ToImmutableArray();
+ }
+
+ ///
+ /// Minimal stand-ins for the Unity and Alchemy types the generated code references.
+ /// Prepended to every test compilation so that "does the generated code compile?"
+ /// is a meaningful question without a Unity install.
+ ///
+ public const string Stubs = """
+ namespace UnityEngine
+ {
+ public class Object { }
+ public interface ISerializationCallbackReceiver
+ {
+ void OnBeforeSerialize();
+ void OnAfterDeserialize();
+ }
+ [System.AttributeUsage(System.AttributeTargets.Field)]
+ public sealed class SerializeField : System.Attribute { }
+ [System.AttributeUsage(System.AttributeTargets.Field)]
+ public sealed class HideInInspector : System.Attribute { }
+ [System.AttributeUsage(System.AttributeTargets.Field)]
+ public sealed class TextArea : System.Attribute
+ {
+ public TextArea() { }
+ public TextArea(int minLines, int maxLines) { }
+ }
+ public static class Debug
+ {
+ public static void LogException(System.Exception ex) { }
+ }
+ }
+
+ namespace Alchemy.Serialization
+ {
+ [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Struct)]
+ public sealed class AlchemySerializeAttribute : System.Attribute { }
+
+ [System.AttributeUsage(System.AttributeTargets.Field)]
+ public sealed class AlchemySerializeFieldAttribute : System.Attribute { }
+
+ [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Struct)]
+ public sealed class ShowAlchemySerializationDataAttribute : System.Attribute { }
+
+ public interface IAlchemySerializationCallbackReceiver
+ {
+ void OnBeforeSerialize();
+ void OnAfterDeserialize();
+ }
+ }
+
+ namespace Alchemy.Serialization.Internal
+ {
+ public static class SerializationHelper
+ {
+ public static string ToJson(T target, System.Collections.Generic.IList unityObjectReferences) => "";
+ public static T FromJson(string json, System.Collections.Generic.IList unityObjectReferences) => default!;
+ }
+ }
+
+ namespace Alchemy.Inspector
+ {
+ [System.AttributeUsage(System.AttributeTargets.All)]
+ public sealed class LabelTextAttribute : System.Attribute
+ {
+ public LabelTextAttribute(string text) { }
+ }
+ [System.AttributeUsage(System.AttributeTargets.All)]
+ public sealed class ReadOnlyAttribute : System.Attribute { }
+ }
+ """;
+
+ /// Runs the generator over , with the Unity/Alchemy stubs included.
+ public static GeneratorResult Run(params string[] sources) =>
+ RunCore(new AlchemySerializeGenerator(), includeStubs: true, sources);
+
+ /// Runs the generator over only, without the stub types.
+ public static GeneratorResult RunWithoutStubs(params string[] sources) =>
+ RunCore(new AlchemySerializeGenerator(), includeStubs: false, sources);
+
+ static GeneratorResult RunCore(ISourceGenerator generator, bool includeStubs, string[] sources)
+ {
+ var parseOptions = new CSharpParseOptions(LanguageVersion.Latest);
+
+ var trees = new List();
+ if (includeStubs) trees.Add(CSharpSyntaxTree.ParseText(Stubs, parseOptions, path: "Stubs.cs"));
+ for (var i = 0; i < sources.Length; i++)
+ {
+ trees.Add(CSharpSyntaxTree.ParseText(sources[i], parseOptions, path: $"Source{i}.cs"));
+ }
+
+ var compilation = CSharpCompilation.Create(
+ assemblyName: "AlchemyGeneratorTestAssembly",
+ syntaxTrees: trees,
+ references: FrameworkReferences,
+ options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+
+ // AlchemySerializeGenerator implements the v1 ISourceGenerator interface, so it is
+ // passed straight through (AsSourceGenerator() is the IIncrementalGenerator adapter).
+ var driver = CSharpGeneratorDriver.Create(
+ generators: new[] { generator },
+ additionalTexts: null,
+ parseOptions: parseOptions,
+ optionsProvider: null);
+
+ driver = (CSharpGeneratorDriver)driver.RunGeneratorsAndUpdateCompilation(
+ compilation, out var outputCompilation, out var diagnostics);
+
+ var runResult = driver.GetRunResult();
+
+ var generated = runResult.Results
+ .SelectMany(r => r.GeneratedSources)
+ .Select(s => new GeneratedSource(s.HintName, s.SourceText.ToString()))
+ .ToImmutableArray();
+
+ return new GeneratorResult(diagnostics, generated, outputCompilation);
+ }
+}
diff --git a/Alchemy.SourceGenerator.Tests/GenericTypeTests.cs b/Alchemy.SourceGenerator.Tests/GenericTypeTests.cs
new file mode 100644
index 0000000..fd01305
--- /dev/null
+++ b/Alchemy.SourceGenerator.Tests/GenericTypeTests.cs
@@ -0,0 +1,166 @@
+namespace Alchemy.SourceGenerator.Tests;
+
+///
+/// Generic targets, and the naming contract that ties the generator to the editor.
+///
+public class GenericTypeTests
+{
+ ///
+ /// Reproduces the backing-field name the editor computes at runtime in
+ /// InspectorHelper.CreateMemberElement:
+ ///
+ /// var declaredType = fieldInfo.DeclaringType;
+ /// if (declaredType.IsConstructedGenericType) declaredType = declaredType.GetGenericTypeDefinition();
+ /// var dataName = "__alchemySerializationData_" + declaredType.FullName.Replace("`", "").Replace(".", "_");
+ ///
+ /// The generator builds the same name from Roslyn symbols by a completely different
+ /// route, so the two must be pinned together or the inspector silently fails to find
+ /// the serialized data.
+ ///
+ static string ExpectedNameFromEditorSide(string reflectionFullName) =>
+ "__alchemySerializationData_" + reflectionFullName.Replace("`", "").Replace(".", "_");
+
+ [Test]
+ public async Task Single_type_parameter_generates_and_compiles()
+ {
+ var result = GeneratorUtils.Run("""
+ using System;
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace Demo.Deep
+ {
+ [AlchemySerialize]
+ public partial class Gen
+ {
+ [AlchemySerializeField, NonSerialized]
+ public List items = new();
+ }
+ }
+ """);
+
+ await Assert.That(result.AllGeneratedText).Contains("partial class Gen");
+ await Assert.That(result.DescribeCompilationErrors()).IsEqualTo("");
+ }
+
+ [Test]
+ public async Task Backing_field_name_matches_what_the_editor_looks_up_for_one_type_parameter()
+ {
+ var result = GeneratorUtils.Run("""
+ using System;
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace Demo.Deep
+ {
+ [AlchemySerialize]
+ public partial class Gen
+ {
+ [AlchemySerializeField, NonSerialized]
+ public List items = new();
+ }
+ }
+ """);
+
+ // typeof(Gen<>).FullName == "Demo.Deep.Gen`1"
+ var expected = ExpectedNameFromEditorSide("Demo.Deep.Gen`1");
+ await Assert.That(expected).IsEqualTo("__alchemySerializationData_Demo_Deep_Gen1");
+ await Assert.That(result.AllGeneratedText).Contains(expected);
+ }
+
+ [Test]
+ public async Task Backing_field_name_matches_what_the_editor_looks_up_for_two_type_parameters()
+ {
+ var result = GeneratorUtils.Run("""
+ using System;
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace Demo
+ {
+ [AlchemySerialize]
+ public partial class Pair
+ {
+ [AlchemySerializeField, NonSerialized]
+ public Dictionary map = new();
+ }
+ }
+ """);
+
+ // typeof(Pair<,>).FullName == "Demo.Pair`2"
+ var expected = ExpectedNameFromEditorSide("Demo.Pair`2");
+ await Assert.That(expected).IsEqualTo("__alchemySerializationData_Demo_Pair2");
+ await Assert.That(result.AllGeneratedText).Contains(expected);
+ }
+
+ [Test]
+ public async Task Backing_field_name_matches_for_a_non_generic_type()
+ {
+ var result = GeneratorUtils.Run("""
+ using System;
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace Demo
+ {
+ [AlchemySerialize]
+ public partial class Plain
+ {
+ [AlchemySerializeField, NonSerialized]
+ public Dictionary map = new();
+ }
+ }
+ """);
+
+ var expected = ExpectedNameFromEditorSide("Demo.Plain");
+ await Assert.That(expected).IsEqualTo("__alchemySerializationData_Demo_Plain");
+ await Assert.That(result.AllGeneratedText).Contains(expected);
+ }
+
+ [Test]
+ public async Task Type_parameters_are_carried_onto_the_partial_declaration()
+ {
+ var result = GeneratorUtils.Run("""
+ using System;
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace Demo
+ {
+ [AlchemySerialize]
+ public partial class Pair
+ {
+ [AlchemySerializeField, NonSerialized]
+ public Dictionary map = new();
+ }
+ }
+ """);
+
+ await Assert.That(result.AllGeneratedText).Contains("partial class Pair");
+ await Assert.That(result.DescribeCompilationErrors()).IsEqualTo("");
+ }
+
+ [Test]
+ public async Task Generic_type_with_a_constraint_compiles()
+ {
+ // The generated partial deliberately omits the where-clause, which is legal C#
+ // as long as at least one declaration states it.
+ var result = GeneratorUtils.Run("""
+ using System;
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace Demo
+ {
+ [AlchemySerialize]
+ public partial class Constrained where T : class, new()
+ {
+ [AlchemySerializeField, NonSerialized]
+ public List items = new();
+ }
+ }
+ """);
+
+ await Assert.That(result.DescribeCompilationErrors()).IsEqualTo("");
+ }
+}
diff --git a/Alchemy.SourceGenerator.Tests/InheritanceTests.cs b/Alchemy.SourceGenerator.Tests/InheritanceTests.cs
new file mode 100644
index 0000000..a4ce753
--- /dev/null
+++ b/Alchemy.SourceGenerator.Tests/InheritanceTests.cs
@@ -0,0 +1,175 @@
+namespace Alchemy.SourceGenerator.Tests;
+
+///
+/// Inheritance chaining. When a base class is also [AlchemySerialize], the derived
+/// class must hide the base helpers with new and forward to them with
+/// base. — otherwise the base class's fields silently stop round-tripping.
+///
+public class InheritanceTests
+{
+ const string BaseAndDerived = """
+ using System;
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace Demo
+ {
+ [AlchemySerialize]
+ public partial class BaseType
+ {
+ [AlchemySerializeField, NonSerialized]
+ public Dictionary fromBase = new();
+ }
+
+ [AlchemySerialize]
+ public partial class DerivedType : BaseType
+ {
+ [AlchemySerializeField, NonSerialized]
+ public HashSet fromDerived = new();
+ }
+ }
+ """;
+
+ [Test]
+ public async Task Both_types_are_generated()
+ {
+ var result = GeneratorUtils.Run(BaseAndDerived);
+ await Assert.That(result.GeneratedSources.Length).IsEqualTo(2);
+ await Assert.That(result.DescribeCompilationErrors()).IsEqualTo("");
+ }
+
+ [Test]
+ public async Task Derived_type_forwards_to_the_base_implementation()
+ {
+ var derived = GeneratorUtils.Run(BaseAndDerived)
+ .GeneratedSources.Single(s => s.HintName.Contains("DerivedType")).Text;
+
+ await Assert.That(derived).Contains("base.__AlchemyOnBeforeSerialize();");
+ await Assert.That(derived).Contains("base.__AlchemyOnAfterDeserialize();");
+ }
+
+ [Test]
+ public async Task Derived_type_hides_the_base_helpers_with_new()
+ {
+ var derived = GeneratorUtils.Run(BaseAndDerived)
+ .GeneratedSources.Single(s => s.HintName.Contains("DerivedType")).Text;
+
+ await Assert.That(derived).Contains("protected new void __AlchemyOnBeforeSerialize()");
+ await Assert.That(derived).Contains("protected new void __AlchemyOnAfterDeserialize()");
+ }
+
+ [Test]
+ public async Task Base_type_does_not_forward_to_anything()
+ {
+ var baseText = GeneratorUtils.Run(BaseAndDerived)
+ .GeneratedSources.Single(s => s.HintName.Contains("BaseType")).Text;
+
+ await Assert.That(baseText).DoesNotContain("base.__Alchemy");
+ await Assert.That(baseText).DoesNotContain("protected new void");
+ }
+
+ [Test]
+ public async Task Each_level_keeps_its_own_backing_store()
+ {
+ // Base and derived must not share one AlchemySerializationData instance,
+ // or clearing the reference table in one would corrupt the other.
+ var result = GeneratorUtils.Run(BaseAndDerived);
+
+ await Assert.That(result.AllGeneratedText).Contains("__alchemySerializationData_Demo_BaseType");
+ await Assert.That(result.AllGeneratedText).Contains("__alchemySerializationData_Demo_DerivedType");
+ }
+
+ [Test]
+ public async Task A_derived_type_whose_base_is_not_attributed_does_not_forward()
+ {
+ var result = GeneratorUtils.Run("""
+ using System;
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace Demo
+ {
+ public partial class PlainBase { }
+
+ [AlchemySerialize]
+ public partial class OnlyDerived : PlainBase
+ {
+ [AlchemySerializeField, NonSerialized]
+ public Dictionary map = new();
+ }
+ }
+ """);
+
+ await Assert.That(result.AllGeneratedText).DoesNotContain("base.__Alchemy");
+ await Assert.That(result.DescribeCompilationErrors()).IsEqualTo("");
+ }
+
+ [Test]
+ public async Task Attribution_is_detected_through_an_unattributed_intermediate_class()
+ {
+ // Base -> Middle (not attributed) -> Leaf. The generator walks the whole
+ // base chain, so Leaf must still chain to Base's implementation.
+ var result = GeneratorUtils.Run("""
+ using System;
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace Demo
+ {
+ [AlchemySerialize]
+ public partial class Root
+ {
+ [AlchemySerializeField, NonSerialized]
+ public Dictionary a = new();
+ }
+
+ public partial class Middle : Root { }
+
+ [AlchemySerialize]
+ public partial class Leaf : Middle
+ {
+ [AlchemySerializeField, NonSerialized]
+ public HashSet b = new();
+ }
+ }
+ """);
+
+ var leaf = result.GeneratedSources.Single(s => s.HintName.Contains("Leaf")).Text;
+ await Assert.That(leaf).Contains("base.__AlchemyOnBeforeSerialize();");
+ await Assert.That(result.DescribeCompilationErrors()).IsEqualTo("");
+ }
+
+ [Test]
+ public async Task Three_level_chain_compiles()
+ {
+ var result = GeneratorUtils.Run("""
+ using System;
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace Demo
+ {
+ [AlchemySerialize]
+ public partial class L1
+ {
+ [AlchemySerializeField, NonSerialized] public Dictionary a = new();
+ }
+
+ [AlchemySerialize]
+ public partial class L2 : L1
+ {
+ [AlchemySerializeField, NonSerialized] public HashSet b = new();
+ }
+
+ [AlchemySerialize]
+ public partial class L3 : L2
+ {
+ [AlchemySerializeField, NonSerialized] public System.Collections.Generic.List c = new();
+ }
+ }
+ """);
+
+ await Assert.That(result.GeneratedSources.Length).IsEqualTo(3);
+ await Assert.That(result.DescribeCompilationErrors()).IsEqualTo("");
+ }
+}
diff --git a/Alchemy.SourceGenerator.Tests/NamespaceTests.cs b/Alchemy.SourceGenerator.Tests/NamespaceTests.cs
new file mode 100644
index 0000000..e44e6d2
--- /dev/null
+++ b/Alchemy.SourceGenerator.Tests/NamespaceTests.cs
@@ -0,0 +1,137 @@
+namespace Alchemy.SourceGenerator.Tests;
+
+///
+/// Namespace emission — the generator reconstructs the containing namespace by hand,
+/// so every namespace shape needs covering.
+///
+public class NamespaceTests
+{
+ [Test]
+ public async Task Block_scoped_namespace_is_reproduced()
+ {
+ var result = GeneratorUtils.Run("""
+ using System;
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace Outer.Inner.Deep
+ {
+ [AlchemySerialize]
+ public partial class Deeply
+ {
+ [AlchemySerializeField, NonSerialized]
+ public Dictionary map = new();
+ }
+ }
+ """);
+
+ await Assert.That(result.AllGeneratedText).Contains("namespace Outer.Inner.Deep {");
+ await Assert.That(result.DescribeCompilationErrors()).IsEqualTo("");
+ }
+
+ [Test]
+ public async Task File_scoped_namespace_is_supported()
+ {
+ var result = GeneratorUtils.Run("""
+ using System;
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace FileScoped;
+
+ [AlchemySerialize]
+ public partial class Sample
+ {
+ [AlchemySerializeField, NonSerialized]
+ public Dictionary map = new();
+ }
+ """);
+
+ await Assert.That(result.AllGeneratedText).Contains("namespace FileScoped {");
+ await Assert.That(result.DescribeCompilationErrors()).IsEqualTo("");
+ }
+
+ [Test]
+ public async Task Global_namespace_emits_no_namespace_block()
+ {
+ var result = GeneratorUtils.Run("""
+ using System;
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ [AlchemySerialize]
+ public partial class NoNamespace
+ {
+ [AlchemySerializeField, NonSerialized]
+ public Dictionary map = new();
+ }
+ """);
+
+ await Assert.That(result.AllGeneratedText).DoesNotContain("namespace ");
+ await Assert.That(result.GeneratedSources[0].HintName)
+ .IsEqualTo("NoNamespace.AlchemySerializeGenerator.g.cs");
+ await Assert.That(result.DescribeCompilationErrors()).IsEqualTo("");
+ }
+
+ [Test]
+ public async Task Two_same_named_types_in_different_namespaces_do_not_collide()
+ {
+ // Both produce a type called "Sample"; the hint name must disambiguate them
+ // or AddSource throws on the duplicate.
+ var result = GeneratorUtils.Run("""
+ using System;
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace First
+ {
+ [AlchemySerialize]
+ public partial class Sample
+ {
+ [AlchemySerializeField, NonSerialized]
+ public Dictionary map = new();
+ }
+ }
+
+ namespace Second
+ {
+ [AlchemySerialize]
+ public partial class Sample
+ {
+ [AlchemySerializeField, NonSerialized]
+ public HashSet set = new();
+ }
+ }
+ """);
+
+ await Assert.That(result.GeneratedSources.Length).IsEqualTo(2);
+ await Assert.That(result.HasDiagnostic("AlchemySerializeGeneratorError")).IsFalse();
+
+ var hintNames = result.GeneratedSources.Select(s => s.HintName).OrderBy(x => x).ToArray();
+ await Assert.That(hintNames[0]).IsEqualTo("First.Sample.AlchemySerializeGenerator.g.cs");
+ await Assert.That(hintNames[1]).IsEqualTo("Second.Sample.AlchemySerializeGenerator.g.cs");
+ await Assert.That(result.DescribeCompilationErrors()).IsEqualTo("");
+ }
+
+ [Test]
+ public async Task Backing_field_name_is_namespace_qualified_so_it_cannot_clash()
+ {
+ var result = GeneratorUtils.Run("""
+ using System;
+ using System.Collections.Generic;
+ using Alchemy.Serialization;
+
+ namespace My.Game
+ {
+ [AlchemySerialize]
+ public partial class Player
+ {
+ [AlchemySerializeField, NonSerialized]
+ public Dictionary map = new();
+ }
+ }
+ """);
+
+ await Assert.That(result.AllGeneratedText).Contains("__alchemySerializationData_My_Game_Player");
+ }
+}
diff --git a/Alchemy.SourceGenerator.Tests/README.md b/Alchemy.SourceGenerator.Tests/README.md
new file mode 100644
index 0000000..ad1cef2
--- /dev/null
+++ b/Alchemy.SourceGenerator.Tests/README.md
@@ -0,0 +1,17 @@
+# Alchemy.SourceGenerator.Tests
+
+Tests for `AlchemySerializeGenerator`, using [TUnit](https://tunit.dev/).
+
+```bash
+cd Alchemy.SourceGenerator.Tests
+dotnet run
+```
+
+TUnit runs on `Microsoft.Testing.Platform`, so this project is an executable. Use `dotnet run`,
+not `dotnet test`.
+
+```bash
+dotnet run --treenode-filter "/*/*/GenerationTests/*" # a single class
+dotnet run --coverage
+dotnet run --report-trx
+```
diff --git a/Alchemy.SourceGenerator/Alchemy.SourceGenerator.sln b/Alchemy.SourceGenerator/Alchemy.SourceGenerator.sln
index 5e0d4e4..983c9f1 100644
--- a/Alchemy.SourceGenerator/Alchemy.SourceGenerator.sln
+++ b/Alchemy.SourceGenerator/Alchemy.SourceGenerator.sln
@@ -5,6 +5,8 @@ VisualStudioVersion = 25.0.1706.1
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Alchemy.SourceGenerator", "Alchemy.SourceGenerator.csproj", "{073160C2-08C9-4905-A4D0-7897AADB4E6C}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Alchemy.SourceGenerator.Tests", "..\Alchemy.SourceGenerator.Tests\Alchemy.SourceGenerator.Tests.csproj", "{74109E75-6475-4613-BFC1-2296669ADC59}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -15,6 +17,10 @@ Global
{073160C2-08C9-4905-A4D0-7897AADB4E6C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{073160C2-08C9-4905-A4D0-7897AADB4E6C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{073160C2-08C9-4905-A4D0-7897AADB4E6C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {74109E75-6475-4613-BFC1-2296669ADC59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {74109E75-6475-4613-BFC1-2296669ADC59}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {74109E75-6475-4613-BFC1-2296669ADC59}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {74109E75-6475-4613-BFC1-2296669ADC59}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/Alchemy.SourceGenerator/README.md b/Alchemy.SourceGenerator/README.md
new file mode 100644
index 0000000..31854c5
--- /dev/null
+++ b/Alchemy.SourceGenerator/README.md
@@ -0,0 +1,12 @@
+# Alchemy.SourceGenerator
+
+Source generator for Alchemy.
+
+# How to update the shipped DLL
+
+Unity consumes the compiled generator at
+`Alchemy/Assets/Alchemy/Generator/Alchemy.SourceGenerator.dll`. After changing the generator, rebuild it with:
+
+```bash
+dotnet run scripts/generator.cs
+```
diff --git a/scripts/generator.cs b/scripts/generator.cs
new file mode 100644
index 0000000..7437ab6
--- /dev/null
+++ b/scripts/generator.cs
@@ -0,0 +1,103 @@
+#!/usr/bin/env dotnet
+//
+// Rebuilds Alchemy.SourceGenerator and refreshes the DLL shipped inside the Unity package.
+//
+// dotnet run scripts/generator.cs
+//
+// Unity loads the compiled generator from Alchemy/Assets/Alchemy/Generator/, so that copy has to
+// be rebuilt and committed whenever the generator source changes.
+
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+
+if (args.Length > 0)
+{
+ Console.Error.WriteLine($"error: unexpected argument '{args[0]}'");
+ Console.Error.WriteLine("usage: dotnet run scripts/generator.cs");
+ return 2;
+}
+
+var repoRoot = FindRepositoryRoot();
+var projectPath = Path.Combine(repoRoot, "Alchemy.SourceGenerator", "Alchemy.SourceGenerator.csproj");
+var shippedDll = Path.Combine(repoRoot, "Alchemy", "Assets", "Alchemy", "Generator", "Alchemy.SourceGenerator.dll");
+
+if (!File.Exists(shippedDll))
+{
+ Console.Error.WriteLine($"error: shipped DLL not found at {Relative(shippedDll)}");
+ return 1;
+}
+
+var buildDir = Directory.CreateTempSubdirectory("alchemy-generator-").FullName;
+
+try
+{
+ Console.WriteLine("Building Alchemy.SourceGenerator...");
+
+ var exitCode = Run("dotnet", [
+ "build", projectPath,
+ "--configuration", "Release",
+ "--output", buildDir,
+ "--nologo",
+ "--verbosity", "quiet"
+ ]);
+
+ if (exitCode != 0)
+ {
+ Console.Error.WriteLine("error: build failed");
+ return 1;
+ }
+
+ var builtDll = Path.Combine(buildDir, "Alchemy.SourceGenerator.dll");
+
+ if (ContentEquals(builtDll, shippedDll))
+ {
+ Console.WriteLine($"Unchanged: {Relative(shippedDll)} is already current.");
+ return 0;
+ }
+
+ File.Copy(builtDll, shippedDll, overwrite: true);
+ Console.WriteLine($"Updated: {Relative(shippedDll)}");
+ return 0;
+}
+finally
+{
+ try { Directory.Delete(buildDir, recursive: true); } catch { /* best effort */ }
+}
+
+static bool ContentEquals(string left, string right) =>
+ File.ReadAllBytes(left).AsSpan().SequenceEqual(File.ReadAllBytes(right));
+
+static int Run(string fileName, string[] arguments)
+{
+ var startInfo = new ProcessStartInfo(fileName) { UseShellExecute = false };
+ foreach (var argument in arguments) startInfo.ArgumentList.Add(argument);
+
+ using var process = Process.Start(startInfo)
+ ?? throw new InvalidOperationException($"could not start {fileName}");
+
+ process.WaitForExit();
+ return process.ExitCode;
+}
+
+string Relative(string path) => Path.GetRelativePath(repoRoot, path).Replace('\\', '/');
+
+// The script is compiled in place, so the source path locates the repository without
+// depending on the working directory. Falls back to walking up from the caller's directory.
+static string FindRepositoryRoot([CallerFilePath] string scriptPath = "")
+{
+ foreach (var start in new[] { Path.GetDirectoryName(scriptPath), Environment.CurrentDirectory })
+ {
+ if (string.IsNullOrEmpty(start)) continue;
+
+ for (var directory = new DirectoryInfo(start); directory != null; directory = directory.Parent)
+ {
+ if (Directory.Exists(Path.Combine(directory.FullName, "Alchemy.SourceGenerator")) &&
+ Directory.Exists(Path.Combine(directory.FullName, "Alchemy")))
+ {
+ return directory.FullName;
+ }
+ }
+ }
+
+ throw new InvalidOperationException("could not locate the repository root");
+}