From 7756806ceea67e408246ee302a5ce7855e334769 Mon Sep 17 00:00:00 2001 From: hadashi Date: Wed, 2 Sep 2026 12:33:03 +0900 Subject: [PATCH 1/3] Fix #199: Drop [MRubyObject] runtime gate in GeneratedResolver Unity 6000.5's linker unconditionally strips attribute instances of PreserveAttribute-derived attributes from player builds, so type.GetCustomAttribute() returns null for generated types on IL2CPP builds and no formatter was ever registered. The __RegisterMRubyValueFormatter method lookup alone is a sufficient and stripping-proof signal, since only generated types have that method. Co-Authored-By: Claude Fable 5 --- .../Resolvers/GeneratedResolver.cs | 5 +++-- tests/ChibiRuby.Serializer.Tests/Classes.cs | 21 +++++++++++++++++++ .../GeneratedFormatterTest.cs | 12 +++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/ChibiRuby.Serializer/Resolvers/GeneratedResolver.cs b/src/ChibiRuby.Serializer/Resolvers/GeneratedResolver.cs index a23bb8d2..5da7d254 100644 --- a/src/ChibiRuby.Serializer/Resolvers/GeneratedResolver.cs +++ b/src/ChibiRuby.Serializer/Resolvers/GeneratedResolver.cs @@ -26,8 +26,9 @@ static Cache() static bool TryInvokeRegisterFormatter(Type type) { - if (type.GetCustomAttribute() == null) return false; - + // Do not gate on [MRubyObject] here: Unity 6000.5+'s linker strips instances of + // PreserveAttribute-derived attributes from player builds, so the attribute can be + // absent at runtime even for generated types. The generated method is the reliable signal. var m = type.GetMethod("__RegisterMRubyValueFormatter", BindingFlags.Public | BindingFlags.NonPublic | diff --git a/tests/ChibiRuby.Serializer.Tests/Classes.cs b/tests/ChibiRuby.Serializer.Tests/Classes.cs index 91ec4e64..f29b9524 100644 --- a/tests/ChibiRuby.Serializer.Tests/Classes.cs +++ b/tests/ChibiRuby.Serializer.Tests/Classes.cs @@ -25,3 +25,24 @@ partial struct Struct1 { public long Id { get; set; } } + +// Simulates a source-generated [MRubyObject] type after Unity 6000.5's linker has stripped the +// attribute instance (#199): the generated registration method exists, but the attribute does not. +class AttributeStrippedObject +{ + public int Value { get; set; } + + public static void __RegisterMRubyValueFormatter() + { + GeneratedResolver.Register(new AttributeStrippedObjectFormatter()); + } + + class AttributeStrippedObjectFormatter : IMRubyValueFormatter + { + public MRubyValue Serialize(AttributeStrippedObject? value, MRubyState mrb, MRubyValueSerializerOptions options) + => value is null ? default : new MRubyValue(value.Value); + + public AttributeStrippedObject? Deserialize(MRubyValue value, MRubyState mrb, MRubyValueSerializerOptions options) + => new() { Value = checked((int)value.IntegerValue) }; + } +} diff --git a/tests/ChibiRuby.Serializer.Tests/GeneratedFormatterTest.cs b/tests/ChibiRuby.Serializer.Tests/GeneratedFormatterTest.cs index 56376185..237e317d 100644 --- a/tests/ChibiRuby.Serializer.Tests/GeneratedFormatterTest.cs +++ b/tests/ChibiRuby.Serializer.Tests/GeneratedFormatterTest.cs @@ -95,4 +95,16 @@ public void DeserializeWithCtor() Assert.That(result.Y, Is.EqualTo(456)); Assert.That(result.Hoge, Is.EqualTo("hello hello")); } + + [Test] + public void RegisterFormatterWithoutAttributeInstance() + { + // The [MRubyObject] attribute instance may be stripped by Unity 6000.5+'s linker (#199). + // Registration must work based on the generated method alone. + var result = MRubyValueSerializer.Deserialize(new MRubyValue(42), state)!; + Assert.That(result.Value, Is.EqualTo(42)); + + var serialized = MRubyValueSerializer.Serialize(new AttributeStrippedObject { Value = 43 }, state); + Assert.That(serialized, Is.EqualTo(new MRubyValue(43))); + } } From 27226cc25fcb3ff5b0cc3059fc24940ae1b89e01 Mon Sep 17 00:00:00 2001 From: hadashi Date: Wed, 2 Sep 2026 18:23:05 +0900 Subject: [PATCH 2/3] Add NativeAOT / trimming support to ChibiRuby.Serializer Formatter registration previously relied on runtime reflection (GetMethod + Invoke gated by an attribute lookup) and on MakeGenericType/Activator.CreateInstance for collection, enum and nullable member types. Both paths break under NativeAOT (and the attribute gate already broke under Unity 6000.5's linker, #199). The source generator now makes every registration statically rooted: - Emit a per-assembly [ModuleInitializer] that calls each generated type's __RegisterMRubyValueFormatter on assembly load. A polyfill of ModuleInitializerAttribute is emitted for target frameworks that lack it (netstandard2.1 / Unity). - __RegisterMRubyValueFormatter now also registers closed generic formatter instantiations for every member type reachable from the [MRubyObject] graph (arrays, enums, Nullable, known collections), so AOT builds never need MakeGenericType. A re-entrancy guard makes self/mutually-referencing types cycle-safe. - New [assembly: MRubyFormattable(typeof(...))] declares serializable root types that appear only at call sites (e.g. Deserialize>), including closed generics of [MRubyObject] types. Runtime changes: - BuiltinResolver catches NotSupportedException/MissingMethodException from the dynamic path and returns null so resolution falls through to the source-generated registrations. - EnumAsStringFormatter uses Enum.GetValues()/GetNames() (with a struct, Enum constraint) on modern TFMs to avoid IL3050. - GeneratedResolver's reflection lookup remains as a fallback for assemblies compiled with older generators, with trim-analysis suppressions documenting why it is safe. - IsAotCompatible enabled for net8.0+ targets; publish is warning-free. Verification: sandbox/NativeAotSanity is a PublishAot console app exercising member-graph types, call-site-only roots and enum/nullable round-trips; it runs in a new CI job (fails on any IL2/IL3 warning). Fixes #199 as well: the module initializer sidesteps both the stripped attribute instances and reflection entirely on Unity 6000.5+. Co-Authored-By: Claude Fable 5 --- .github/workflows/test.yaml | 12 ++ ChibiRuby.slnx | 1 + README.md | 29 ++++ .../NativeAotSanity/NativeAotSanity.csproj | 25 +++ sandbox/NativeAotSanity/Program.cs | 98 ++++++++++++ .../AssemblyMeta.cs | 62 ++++++++ .../BuiltinFormatterWalker.cs | 144 ++++++++++++++++++ .../ChibiRubySerializerSourceGenerator.cs | 99 ++++++++++++ .../DiagnosticsDescriptors.cs | 8 + .../MRubyObjectModel.cs | 29 +++- src/ChibiRuby.Serializer/Attributes.cs | 12 ++ .../ChibiRuby.Serializer.csproj | 1 + .../Formatters/EnumAsStringFormatter.cs | 7 +- .../Internal/TrimmingAttributes.cs | 17 +++ .../Resolvers/BuiltinResolver.cs | 39 +++++ .../Resolvers/GeneratedResolver.cs | 7 + tests/ChibiRuby.Serializer.Tests/Classes.cs | 23 +++ .../GeneratedFormatterTest.cs | 22 +++ 18 files changed, 631 insertions(+), 4 deletions(-) create mode 100644 sandbox/NativeAotSanity/NativeAotSanity.csproj create mode 100644 sandbox/NativeAotSanity/Program.cs create mode 100644 src/ChibiRuby.Serializer.SourceGenerator/AssemblyMeta.cs create mode 100644 src/ChibiRuby.Serializer.SourceGenerator/BuiltinFormatterWalker.cs create mode 100644 src/ChibiRuby.Serializer/Internal/TrimmingAttributes.cs diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 69bcb255..150ee2b7 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -22,3 +22,15 @@ jobs: 9.0.x - run: dotnet build -c Debug - run: dotnet test -c Debug --no-build + + test-nativeaot: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-dotnet@v3 + with: + dotnet-version: | + 9.0.x + - run: dotnet publish sandbox/NativeAotSanity/NativeAotSanity.csproj -c Release -r linux-x64 + - run: ./sandbox/NativeAotSanity/bin/Release/net9.0/linux-x64/publish/NativeAotSanity diff --git a/ChibiRuby.slnx b/ChibiRuby.slnx index 69362f64..f1515099 100644 --- a/ChibiRuby.slnx +++ b/ChibiRuby.slnx @@ -1,6 +1,7 @@  + diff --git a/README.md b/README.md index ce631dca..6e8cad45 100644 --- a/README.md +++ b/README.md @@ -1883,6 +1883,35 @@ deserialized.Y //=> 222 deserialized.Z //=> 333 ``` +### NativeAOT / trimming / IL2CPP + +ChibiRuby.Serializer is AOT-safe by default. The source generator emits, per assembly, a +[module initializer](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/attributes/general#moduleinitializer-attribute) +that eagerly registers every generated formatter — including the closed generic instantiations for +collection / enum / nullable member types (e.g. `Dictionary`, `MyEnum`) — when the +assembly is loaded. Because all registrations are rooted statically, they survive: + +- .NET trimming (`PublishTrimmed`) and NativeAOT (`PublishAot`) +- Unity's managed code stripping and IL2CPP (including Unity 6000.5+, whose linker removes + `[MRubyObject]` attribute instances at build time) + +No configuration is required for types that appear as `[MRubyObject]` members. The one case that +needs a declaration is a serializable type used *only* at a call site, since the generator cannot +see `Deserialize` type arguments: + +```cs +// List appears in no [MRubyObject] member, only at call sites: +[assembly: MRubyFormattable(typeof(List))] + +var xs = MRubyValueSerializer.Deserialize>(value, mrb); // OK on NativeAOT/IL2CPP +``` + +`[assembly: MRubyFormattable]` accepts any closed constructed type, including closed generics of +your own `[MRubyObject]` types (e.g. `typeof(MyContainer)`). + +Custom formatters registered through `CompositeResolver` / `MRubyValueSerializerOptions` are plain +statically-referenced code and need no extra care. + ## License MIT diff --git a/sandbox/NativeAotSanity/NativeAotSanity.csproj b/sandbox/NativeAotSanity/NativeAotSanity.csproj new file mode 100644 index 00000000..8ca16c58 --- /dev/null +++ b/sandbox/NativeAotSanity/NativeAotSanity.csproj @@ -0,0 +1,25 @@ + + + + Exe + net9.0 + latest + enable + enable + false + true + true + + $(WarningsAsErrors);IL2026;IL2055;IL2070;IL2071;IL2072;IL2104;IL3050;IL3053 + + + + + + + Analyzer + false + + + + diff --git a/sandbox/NativeAotSanity/Program.cs b/sandbox/NativeAotSanity/Program.cs new file mode 100644 index 00000000..34116c55 --- /dev/null +++ b/sandbox/NativeAotSanity/Program.cs @@ -0,0 +1,98 @@ +// NativeAOT sanity check for ChibiRuby.Serializer. +// Published with PublishAot=true and executed in CI: every check exercises a path that +// used to require runtime reflection or MakeGenericType and now must be satisfied by +// the source generator's eager registrations alone. +using ChibiRuby; +using ChibiRuby.Serializer; + +// Call-site-only root type: appears in no [MRubyObject] member, registered via the +// assembly-level declaration below. +[assembly: MRubyFormattable(typeof(List))] +[assembly: MRubyFormattable(typeof(int[]))] + +var failures = 0; +var state = MRubyState.Create(); + +Check("[MRubyObject] round-trip (collection/enum/nullable members)", () => +{ + var original = new Command + { + Kind = CommandKind.FooBar, + Ids = [1, 2, 3], + Names = ["a", "b"], + Table = new Dictionary { ["x"] = new() { Id = 42 } }, + MaybeCount = 7, + }; + var value = MRubyValueSerializer.Serialize(original, state); + var restored = MRubyValueSerializer.Deserialize(value, state)!; + + Require(restored.Kind == CommandKind.FooBar, "enum member"); + Require(restored.Ids.SequenceEqual([1, 2, 3]), "List member"); + Require(restored.Names.SequenceEqual(["a", "b"]), "string[] member"); + Require(restored.Table["x"].Id == 42, "Dictionary member"); + Require(restored.MaybeCount == 7, "int? member"); +}); + +Check("int[] at a call site", () => +{ + var value = MRubyValueSerializer.Serialize(new[] { 10, 20 }, state); + var restored = MRubyValueSerializer.Deserialize(value, state)!; + Require(restored.SequenceEqual([10, 20]), "int[] round-trip"); +}); + +Check("[assembly: MRubyFormattable] root type List", () => +{ + var value = MRubyValueSerializer.Serialize(new List { 1.5, 2.5 }, state); + var restored = MRubyValueSerializer.Deserialize>(value, state)!; + Require(restored.SequenceEqual([1.5, 2.5]), "List round-trip"); +}); + +if (failures > 0) +{ + Console.WriteLine($"NativeAOT sanity: {failures} check(s) FAILED"); + return 1; +} +Console.WriteLine("NativeAOT sanity: all checks passed"); +return 0; + +void Check(string label, Action action) +{ + try + { + action(); + Console.WriteLine($"PASS {label}"); + } + catch (Exception ex) + { + failures++; + while (ex.InnerException is { } inner) ex = inner; + Console.WriteLine($"FAIL {label}: {ex.GetType().Name}: {ex.Message}"); + } +} + +static void Require(bool condition, string what) +{ + if (!condition) throw new Exception($"assertion failed: {what}"); +} + +enum CommandKind +{ + None, + FooBar, +} + +[MRubyObject] +partial class Command +{ + public CommandKind Kind { get; set; } + public List Ids { get; set; } = []; + public string[] Names { get; set; } = []; + public Dictionary Table { get; set; } = new(); + public int? MaybeCount { get; set; } +} + +[MRubyObject] +partial struct Inner +{ + public long Id { get; set; } +} diff --git a/src/ChibiRuby.Serializer.SourceGenerator/AssemblyMeta.cs b/src/ChibiRuby.Serializer.SourceGenerator/AssemblyMeta.cs new file mode 100644 index 00000000..1caa450d --- /dev/null +++ b/src/ChibiRuby.Serializer.SourceGenerator/AssemblyMeta.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; +using Microsoft.CodeAnalysis; + +namespace ChibiRuby.Serializer.SourceGenerator; + +/// +/// Equatable, assembly-wide facts consumed by the generated module initializer: +/// whether ModuleInitializerAttribute needs a polyfill, and the registration statements +/// derived from [assembly: MRubyFormattable(typeof(...))] root declarations. +/// +sealed record AssemblyMeta( + bool HasModuleInitializerAttribute, + EquatableArray RootStatements, + EquatableArray Diagnostics) : IEquatable +{ + public static AssemblyMeta Create(Compilation compilation, CancellationToken cancellationToken) + { + var hasModuleInitializer = compilation.GetTypeByMetadataName( + "System.Runtime.CompilerServices.ModuleInitializerAttribute") is not null; + + var formattableAttribute = compilation.GetTypeByMetadataName("ChibiRuby.Serializer.MRubyFormattableAttribute"); + var mrubyObjectAttribute = compilation.GetTypeByMetadataName("ChibiRuby.Serializer.MRubyObjectAttribute"); + + var statements = new SortedSet(StringComparer.Ordinal); + var diagnostics = ImmutableArray.CreateBuilder(); + + if (formattableAttribute is not null && mrubyObjectAttribute is not null) + { + foreach (var attribute in compilation.Assembly.GetAttributes()) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, formattableAttribute)) + { + continue; + } + if (attribute.ConstructorArguments.Length != 1 || + attribute.ConstructorArguments[0].Value is not ITypeSymbol rootType) + { + continue; + } + if (rootType is INamedTypeSymbol { IsUnboundGenericType: true } || + rootType.TypeKind == TypeKind.TypeParameter) + { + diagnostics.Add(DiagnosticInfo.Create( + DiagnosticDescriptors.FormattableTypeMustBeClosed, + attribute.ApplicationSyntaxReference?.GetSyntax(cancellationToken).GetLocation(), + rootType.ToDisplayString())); + continue; + } + BuiltinFormatterWalker.Collect(rootType, mrubyObjectAttribute, statements); + } + } + + return new AssemblyMeta( + hasModuleInitializer, + new EquatableArray(statements.ToImmutableArray()), + new EquatableArray(diagnostics.ToImmutable())); + } +} diff --git a/src/ChibiRuby.Serializer.SourceGenerator/BuiltinFormatterWalker.cs b/src/ChibiRuby.Serializer.SourceGenerator/BuiltinFormatterWalker.cs new file mode 100644 index 00000000..769bfaa1 --- /dev/null +++ b/src/ChibiRuby.Serializer.SourceGenerator/BuiltinFormatterWalker.cs @@ -0,0 +1,144 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; + +namespace ChibiRuby.Serializer.SourceGenerator; + +/// +/// Walks a member/root type and collects registration statements for every formatter +/// instantiation the type needs, fully constructed at compile time +/// (e.g. GeneratedResolver.Register(new ListFormatter<int>());). +/// Emitting these keeps trimmed / NativeAOT / IL2CPP builds off the runtime +/// MakeGenericType path in BuiltinResolver, which cannot instantiate value-type +/// generic arguments that were never compiled. +/// +static class BuiltinFormatterWalker +{ + const string Ns = "global::ChibiRuby.Serializer."; + + // Keep in sync with BuiltinResolver.KnownGenericTypes. + static readonly Dictionary KnownGenericFormatters = new() + { + { "System.Nullable`1", "NullableFormatter" }, + { "System.Collections.Generic.KeyValuePair`2", "KeyValuePairFormatter" }, + + { "System.Tuple`1", "TupleFormatter" }, + { "System.Tuple`2", "TupleFormatter" }, + { "System.Tuple`3", "TupleFormatter" }, + { "System.Tuple`4", "TupleFormatter" }, + { "System.Tuple`5", "TupleFormatter" }, + { "System.ValueTuple`1", "ValueTupleFormatter" }, + { "System.ValueTuple`2", "ValueTupleFormatter" }, + { "System.ValueTuple`3", "ValueTupleFormatter" }, + { "System.ValueTuple`4", "ValueTupleFormatter" }, + { "System.ValueTuple`5", "ValueTupleFormatter" }, + + { "System.Collections.Generic.List`1", "ListFormatter" }, + { "System.Collections.Generic.Stack`1", "StackFormatter" }, + { "System.Collections.Generic.Queue`1", "QueueFormatter" }, + { "System.Collections.Generic.LinkedList`1", "LinkedListFormatter" }, + { "System.Collections.Generic.HashSet`1", "HashSetFormatter" }, + { "System.Collections.Generic.SortedSet`1", "SortedSetFormatter" }, + + { "System.Collections.ObjectModel.Collection`1", "CollectionFormatter" }, + { "System.Collections.ObjectModel.ReadOnlyCollection`1", "ReadOnlyCollectionFormatter" }, + { "System.Collections.Concurrent.BlockingCollection`1", "BlockingCollectionFormatter" }, + { "System.Collections.Concurrent.ConcurrentQueue`1", "ConcurrentQueueFormatter" }, + { "System.Collections.Concurrent.ConcurrentStack`1", "ConcurrentStackFormatter" }, + { "System.Collections.Concurrent.ConcurrentBag`1", "ConcurrentBagFormatter" }, + + { "System.Collections.Generic.Dictionary`2", "DictionaryFormatter" }, + { "System.Collections.Generic.SortedDictionary`2", "SortedDictionaryFormatter" }, + { "System.Collections.Concurrent.ConcurrentDictionary`2", "ConcurrentDictionaryFormatter" }, + + { "System.Collections.Generic.IEnumerable`1", "InterfaceEnumerableFormatter" }, + { "System.Collections.Generic.ICollection`1", "InterfaceCollectionFormatter" }, + { "System.Collections.Generic.IReadOnlyCollection`1", "InterfaceReadOnlyCollectionFormatter" }, + { "System.Collections.Generic.IList`1", "InterfaceListFormatter" }, + { "System.Collections.Generic.IReadOnlyList`1", "InterfaceReadOnlyListFormatter" }, + { "System.Collections.Generic.IDictionary`2", "InterfaceDictionaryFormatter" }, + { "System.Collections.Generic.IReadOnlyDictionary`2", "InterfaceReadOnlyDictionaryFormatter" }, + { "System.Collections.Generic.ISet`1", "InterfaceSetFormatter" }, + }; + + /// + /// Collects registration statements for into . + /// Returns true when the walk produced at least one statement or the type is covered by its own + /// generated registration. + /// + public static bool Collect(ITypeSymbol type, INamedTypeSymbol mrubyObjectAttribute, ISet statements) + { + switch (type) + { + case IArrayTypeSymbol array: + { + var formatter = array.Rank switch + { + 1 => "ArrayFormatter", + 2 => "TwoDimensionalArrayFormatter", + 3 => "ThreeDimensionalArrayFormatter", + 4 => "FourDimensionalArrayFormatter", + _ => null, + }; + if (formatter is null) + { + return false; + } + statements.Add(Register($"{Ns}{formatter}<{Display(array.ElementType)}>")); + Collect(array.ElementType, mrubyObjectAttribute, statements); + return true; + } + case INamedTypeSymbol named: + { + if (named.TypeKind == TypeKind.Enum) + { + statements.Add(Register($"{Ns}EnumAsStringFormatter<{Display(named)}>")); + return true; + } + + if (named.GetAttributes().Any(a => + SymbolEqualityComparer.Default.Equals(a.AttributeClass, mrubyObjectAttribute))) + { + // A [MRubyObject] type registers itself (and its member formatters) from its + // generated __RegisterMRubyValueFormatter. Calling it here roots the closed + // instantiation for AOT; the method's re-entrancy guard makes this cycle-safe. + statements.Add($"{DisplayBare(named)}.__RegisterMRubyValueFormatter();"); + foreach (var arg in named.TypeArguments) + { + Collect(arg, mrubyObjectAttribute, statements); + } + return true; + } + + if (named is { IsGenericType: true, IsUnboundGenericType: false }) + { + var handled = false; + var metadataName = $"{named.ConstructedFrom.ContainingNamespace.ToDisplayString()}.{named.ConstructedFrom.MetadataName}"; + if (KnownGenericFormatters.TryGetValue(metadataName, out var formatterName)) + { + var args = string.Join(", ", named.TypeArguments.Select(Display)); + statements.Add(Register($"{Ns}{formatterName}<{args}>")); + handled = true; + } + foreach (var arg in named.TypeArguments) + { + handled |= Collect(arg, mrubyObjectAttribute, statements); + } + return handled; + } + return false; + } + default: + return false; // type parameters etc. resolve at the closed instantiation + } + } + + static string Register(string formatterType) => + $"{Ns}GeneratedResolver.Register(new {formatterType}());"; + + static string Display(ITypeSymbol t) => t.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + // Display without a top-level nullable annotation, for use as a receiver of a static call. + static string DisplayBare(ITypeSymbol t) => + t.WithNullableAnnotation(NullableAnnotation.NotAnnotated).ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); +} diff --git a/src/ChibiRuby.Serializer.SourceGenerator/ChibiRubySerializerSourceGenerator.cs b/src/ChibiRuby.Serializer.SourceGenerator/ChibiRubySerializerSourceGenerator.cs index 2ab69833..1b4c4f5b 100644 --- a/src/ChibiRuby.Serializer.SourceGenerator/ChibiRubySerializerSourceGenerator.cs +++ b/src/ChibiRuby.Serializer.SourceGenerator/ChibiRubySerializerSourceGenerator.cs @@ -1,3 +1,5 @@ +using System.Collections.Immutable; +using System.Linq; using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -36,6 +38,92 @@ public void Initialize(IncrementalGeneratorInitializationContext context) Emit(model, stringBuilder); productionContext.AddSource($"{model.HintName}.g.cs", stringBuilder.ToString()); }); + + // Assembly-wide facts needed by the module initializer. Extracted into an equatable + // model so the aggregate output below still caches when nothing relevant changed. + var assemblyMeta = context.CompilationProvider + .Select(static (compilation, cancellation) => AssemblyMeta.Create(compilation, cancellation)) + .WithTrackingName("ChibiRubyAssemblyMeta"); + + // Aggregate output: one [ModuleInitializer] per assembly that eagerly registers every + // generated formatter. This roots all registrations statically, so trimming, NativeAOT + // and Unity's IL2CPP/linker need no runtime reflection (see issue #199). + context.RegisterSourceOutput(models.Collect().Combine(assemblyMeta), static (productionContext, pair) => + { + var (allModels, meta) = pair; + foreach (var diagnostic in meta.Diagnostics) + { + productionContext.ReportDiagnostic(diagnostic.ToDiagnostic()); + } + EmitModuleInitializer(allModels, meta, productionContext); + }); + } + + static void EmitModuleInitializer( + ImmutableArray models, + AssemblyMeta meta, + SourceProductionContext context) + { + var typeInitializers = models + .Where(x => x is { HasError: false, EmitInInitializer: true }) + .Select(x => x.FullTypeName) + .Distinct() + .OrderBy(x => x, StringComparer.Ordinal) + .ToArray(); + + if (typeInitializers.Length <= 0 && meta.RootStatements.Count <= 0) + { + return; + } + + var stringBuilder = new StringBuilder(); + stringBuilder.AppendLine(""" +// +#nullable enable +"""); + if (!meta.HasModuleInitializerAttribute) + { + stringBuilder.AppendLine(""" +namespace System.Runtime.CompilerServices +{ + // Polyfill for target frameworks (netstandard2.1 / Unity) that do not ship this attribute. + [global::System.AttributeUsage(global::System.AttributeTargets.Method, Inherited = false)] + internal sealed class ModuleInitializerAttribute : global::System.Attribute + { + } +} + +"""); + } + stringBuilder.AppendLine(""" +namespace ChibiRuby.Serializer.Generated +{ + /// + /// Eagerly registers every generated mruby formatter in this assembly on assembly load. + /// Registration is rooted statically here, so it survives trimming, NativeAOT and + /// Unity's IL2CPP/linker without relying on reflection or runtime attribute instances. + /// + internal static class ChibiRubySerializerModuleInitializer + { + [global::System.Runtime.CompilerServices.ModuleInitializer] + [global::ChibiRuby.Serializer.Preserve] + internal static void RegisterGeneratedFormatters() + { +"""); + foreach (var fullTypeName in typeInitializers) + { + stringBuilder.AppendLine($" {fullTypeName}.__RegisterMRubyValueFormatter();"); + } + foreach (var statement in meta.RootStatements) + { + stringBuilder.AppendLine($" {statement}"); + } + stringBuilder.AppendLine(""" + } + } +} +"""); + context.AddSource("ChibiRubySerializerModuleInitializer.g.cs", stringBuilder.ToString()); } static void Emit(MRubyObjectModel model, StringBuilder stringBuilder) @@ -92,10 +180,21 @@ namespace {{ns}} static void EmitRegisterMethod(MRubyObjectModel model, StringBuilder stringBuilder) { stringBuilder.AppendLine($$""" + static bool __mrubyValueFormatterRegistered; + [global::ChibiRuby.Serializer.Preserve] public static void __RegisterMRubyValueFormatter() { + if (__mrubyValueFormatterRegistered) return; + __mrubyValueFormatterRegistered = true; + global::ChibiRuby.Serializer.GeneratedResolver.Register(new {{model.TypeName}}GeneratedFormatter()); +"""); + foreach (var statement in model.EagerRegistrations) + { + stringBuilder.AppendLine($" {statement}"); + } + stringBuilder.AppendLine(""" } """); diff --git a/src/ChibiRuby.Serializer.SourceGenerator/DiagnosticsDescriptors.cs b/src/ChibiRuby.Serializer.SourceGenerator/DiagnosticsDescriptors.cs index 0bdd1fed..a92ecbbb 100644 --- a/src/ChibiRuby.Serializer.SourceGenerator/DiagnosticsDescriptors.cs +++ b/src/ChibiRuby.Serializer.SourceGenerator/DiagnosticsDescriptors.cs @@ -73,6 +73,14 @@ static class DiagnosticDescriptors defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); + public static readonly DiagnosticDescriptor FormattableTypeMustBeClosed = new( + id: "MRBCS010", + title: "[assembly: MRubyFormattable] type must be a closed constructed type", + messageFormat: "The MRubyFormattable type '{0}' must be a closed (fully constructed) type; open generic types cannot be registered ahead of time", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + public static readonly DiagnosticDescriptor ConstructorHasNoMatchedParameter = new( id: "MRBCS009", title: "MRubyObject's constructor has no matched parameter", diff --git a/src/ChibiRuby.Serializer.SourceGenerator/MRubyObjectModel.cs b/src/ChibiRuby.Serializer.SourceGenerator/MRubyObjectModel.cs index a140480b..65d6a87a 100644 --- a/src/ChibiRuby.Serializer.SourceGenerator/MRubyObjectModel.cs +++ b/src/ChibiRuby.Serializer.SourceGenerator/MRubyObjectModel.cs @@ -120,7 +120,13 @@ sealed record MRubyObjectModel( EquatableArray ConstructorParameterNames, EquatableArray Members, EquatableArray Diagnostics, - bool HasError) : IEquatable + bool HasError, + // Registration statements for member formatter instantiations (collections, enums, ...) + // emitted into __RegisterMRubyValueFormatter so AOT builds never hit MakeGenericType. + EquatableArray EagerRegistrations, + // Whether the assembly-level generated module initializer can call this type's + // __RegisterMRubyValueFormatter directly (non-generic and accessible in the assembly). + bool EmitInInitializer) : IEquatable { public static MRubyObjectModel Create(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) { @@ -140,7 +146,9 @@ static MRubyObjectModel ErrorOnly(params DiagnosticInfo[] diagnostics) => EquatableArray.Empty, EquatableArray.Empty, new EquatableArray(diagnostics.ToImmutableArray()), - HasError: true); + HasError: true, + EagerRegistrations: EquatableArray.Empty, + EmitInInitializer: false); static MRubyObjectModel CreateCore(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) { @@ -241,6 +249,19 @@ static MRubyObjectModel CreateCore(GeneratorAttributeSyntaxContext context, Canc (false, false) => "class", }; + var eagerRegistrations = new SortedSet(StringComparer.Ordinal); + foreach (var member in typeMeta.MemberMetas) + { + BuiltinFormatterWalker.Collect(member.MemberType, references.MRubyObjectAttribute, eagerRegistrations); + } + // The registration for the type itself is emitted unconditionally; a statement produced + // for a self-typed member would just be a duplicate of it. + eagerRegistrations.Remove($"{typeMeta.FullTypeName}.__RegisterMRubyValueFormatter();"); + + var compilation = context.SemanticModel.Compilation; + var emitInInitializer = typeMeta.Symbol is { IsGenericType: false } symbol && + compilation.IsSymbolAccessibleWithin(symbol, compilation.Assembly); + var ns = typeMeta.Symbol.ContainingNamespace; var hintName = typeMeta.Symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) .Replace("global::", "") @@ -258,7 +279,9 @@ static MRubyObjectModel CreateCore(GeneratorAttributeSyntaxContext context, Canc ConstructorParameterNames: new EquatableArray(constructedMembers.Select(x => x.Name).ToImmutableArray()), Members: new EquatableArray(memberModels.ToImmutable()), Diagnostics: new EquatableArray(diagnostics.ToImmutable()), - HasError: false); + HasError: false, + EagerRegistrations: new EquatableArray(eagerRegistrations.ToImmutableArray()), + EmitInInitializer: emitInInitializer); } static bool TryGetConstructor( diff --git a/src/ChibiRuby.Serializer/Attributes.cs b/src/ChibiRuby.Serializer/Attributes.cs index 5b254b2a..0043186d 100644 --- a/src/ChibiRuby.Serializer/Attributes.cs +++ b/src/ChibiRuby.Serializer/Attributes.cs @@ -18,3 +18,15 @@ public class MRubyIgnoreAttribute : Attribute; [AttributeUsage(AttributeTargets.Constructor)] public class MRubyConstructorAttribute : Attribute; + +/// +/// Declares a serializable root type that does not appear as a member of any [MRubyObject] type, +/// so the source generator can emit ahead-of-time-safe formatter registrations for it. +/// Use this for types passed only at call sites (e.g. Deserialize<List<int>>) +/// in NativeAOT / IL2CPP builds. +/// +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] +public sealed class MRubyFormattableAttribute(Type type) : Attribute +{ + public Type Type { get; } = type; +} diff --git a/src/ChibiRuby.Serializer/ChibiRuby.Serializer.csproj b/src/ChibiRuby.Serializer/ChibiRuby.Serializer.csproj index cdc4ad2f..5f530124 100644 --- a/src/ChibiRuby.Serializer/ChibiRuby.Serializer.csproj +++ b/src/ChibiRuby.Serializer/ChibiRuby.Serializer.csproj @@ -3,6 +3,7 @@ net8.0;net9.0;net10.0;netstandard2.1 12 enable + true A plugin for ChibiRuby that enables conversion between C# and mruby objects. diff --git a/src/ChibiRuby.Serializer/Formatters/EnumAsStringFormatter.cs b/src/ChibiRuby.Serializer/Formatters/EnumAsStringFormatter.cs index 385f2cc7..62d9528b 100644 --- a/src/ChibiRuby.Serializer/Formatters/EnumAsStringFormatter.cs +++ b/src/ChibiRuby.Serializer/Formatters/EnumAsStringFormatter.cs @@ -3,7 +3,7 @@ namespace ChibiRuby.Serializer; -public class EnumAsStringFormatter : IMRubyValueFormatter where T : Enum +public class EnumAsStringFormatter : IMRubyValueFormatter where T : struct, Enum { class EnumSymbolTable( Dictionary values, @@ -17,8 +17,13 @@ public static EnumSymbolTable Create(MRubyState mrb) var values = new Dictionary(); var symbols = new Dictionary(); +#if NETSTANDARD2_1 var csharpNames = Enum.GetNames(typeof(T)); var csharpValues = (T[])Enum.GetValues(typeof(T)); +#else + var csharpNames = Enum.GetNames(); + var csharpValues = Enum.GetValues(); +#endif var maxNameLength = 0; foreach (var n in csharpNames) { diff --git a/src/ChibiRuby.Serializer/Internal/TrimmingAttributes.cs b/src/ChibiRuby.Serializer/Internal/TrimmingAttributes.cs new file mode 100644 index 00000000..e982b00d --- /dev/null +++ b/src/ChibiRuby.Serializer/Internal/TrimmingAttributes.cs @@ -0,0 +1,17 @@ +#if NETSTANDARD2_1 +// netstandard2.1 does not ship the trimming/AOT analysis attributes. +// Internal polyfills so the shared source compiles; the analyzers only run on the net8.0+ builds. +namespace System.Diagnostics.CodeAnalysis +{ + [AttributeUsage( + AttributeTargets.Class | AttributeTargets.Constructor | AttributeTargets.Event | + AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Struct, + Inherited = false, AllowMultiple = true)] + sealed class UnconditionalSuppressMessageAttribute(string category, string checkId) : Attribute + { + public string Category { get; } = category; + public string CheckId { get; } = checkId; + public string? Justification { get; set; } + } +} +#endif diff --git a/src/ChibiRuby.Serializer/Resolvers/BuiltinResolver.cs b/src/ChibiRuby.Serializer/Resolvers/BuiltinResolver.cs index d3496c79..b5255a0f 100644 --- a/src/ChibiRuby.Serializer/Resolvers/BuiltinResolver.cs +++ b/src/ChibiRuby.Serializer/Resolvers/BuiltinResolver.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; namespace ChibiRuby.Serializer; @@ -143,7 +144,41 @@ static FormatterCache() return FormatterCache.Formatter; } + // On NativeAOT (and IL2CPP without full generic sharing), constructing a generic formatter + // instantiation that was not compiled ahead of time throws. The source generator emits eager, + // statically-typed registrations into GeneratedResolver for every member type it can see, so + // when this dynamic path fails we return null and let resolution fall through to those. static object? TryCreateGenericFormatter(Type type) + { + try + { + return TryCreateGenericFormatterCore(type); + } + catch (NotSupportedException) + { + return null; // NativeAOT: the generic instantiation was not compiled ahead of time + } + catch (MissingMethodException) + { + return null; // NativeAOT: constructor metadata was trimmed + } + catch (MemberAccessException) + { + return null; + } + } + + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "Failures are caught by the caller and resolution falls back to source-generated registrations.")] + [UnconditionalSuppressMessage("Trimming", "IL2055", + Justification = "Only instantiates library formatter types over types already in use; failures fall back to source-generated registrations.")] + [UnconditionalSuppressMessage("Trimming", "IL2072", + Justification = "All mapped formatter types define a public parameterless constructor.")] + [UnconditionalSuppressMessage("Trimming", "IL2070", + Justification = "Only enum types reach the EnumAsStringFormatter instantiation; their default constructor is intrinsic.")] + [UnconditionalSuppressMessage("Trimming", "IL2071", + Justification = "Only enum types reach the EnumAsStringFormatter instantiation; their default constructor is intrinsic.")] + static object? TryCreateGenericFormatterCore(Type type) { Type? formatterType = null; @@ -188,6 +223,10 @@ static FormatterCache() return null; } + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "Failures are caught by the caller and resolution falls back to source-generated registrations.")] + [UnconditionalSuppressMessage("Trimming", "IL2055", + Justification = "Only instantiates library formatter types over types already in use; failures fall back to source-generated registrations.")] static Type? TryCreateGenericFormatterType(Type type, IDictionary knownTypes) { if (type.IsGenericType) diff --git a/src/ChibiRuby.Serializer/Resolvers/GeneratedResolver.cs b/src/ChibiRuby.Serializer/Resolvers/GeneratedResolver.cs index 5da7d254..11e0eaf1 100644 --- a/src/ChibiRuby.Serializer/Resolvers/GeneratedResolver.cs +++ b/src/ChibiRuby.Serializer/Resolvers/GeneratedResolver.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Reflection; namespace ChibiRuby.Serializer; @@ -24,6 +25,12 @@ static Cache() } } + // Fallback for assemblies compiled with a pre-1.7 source generator. Assemblies built with the + // bundled generator register all formatters eagerly from a [ModuleInitializer], which is what + // trimmed/NativeAOT builds rely on; in those builds this reflection lookup may simply find + // nothing (the generated method can be trimmed) and returns false. + [UnconditionalSuppressMessage("Trimming", "IL2070", + Justification = "Reflection fallback only. AOT/trimming-safe registration is done eagerly by generated module initializers.")] static bool TryInvokeRegisterFormatter(Type type) { // Do not gate on [MRubyObject] here: Unity 6000.5+'s linker strips instances of diff --git a/tests/ChibiRuby.Serializer.Tests/Classes.cs b/tests/ChibiRuby.Serializer.Tests/Classes.cs index f29b9524..070a74ce 100644 --- a/tests/ChibiRuby.Serializer.Tests/Classes.cs +++ b/tests/ChibiRuby.Serializer.Tests/Classes.cs @@ -1,3 +1,8 @@ +using ChibiRuby.Serializer; + +// Call-site-only root declaration: exercises the [assembly: MRubyFormattable] AOT registration path. +[assembly: MRubyFormattable(typeof(List))] + namespace ChibiRuby.Serializer.Tests; [MRubyObject] @@ -46,3 +51,21 @@ public MRubyValue Serialize(AttributeStrippedObject? value, MRubyState mrb, MRub => new() { Value = checked((int)value.IntegerValue) }; } } + +enum SampleKind +{ + None, + FooBar, +} + +// Exercises the eager member-formatter registrations (enum / nullable / collections / multi-dim +// array) that the generator emits for AOT builds. +[MRubyObject] +partial class AotMemberObject +{ + public SampleKind Kind { get; set; } + public int? MaybeCount { get; set; } + public List Ids { get; set; } = []; + public Dictionary DictField { get; set; } = new(); + public int[,] Grid { get; set; } = new int[0, 0]; +} diff --git a/tests/ChibiRuby.Serializer.Tests/GeneratedFormatterTest.cs b/tests/ChibiRuby.Serializer.Tests/GeneratedFormatterTest.cs index 237e317d..67493d34 100644 --- a/tests/ChibiRuby.Serializer.Tests/GeneratedFormatterTest.cs +++ b/tests/ChibiRuby.Serializer.Tests/GeneratedFormatterTest.cs @@ -96,6 +96,28 @@ public void DeserializeWithCtor() Assert.That(result.Hoge, Is.EqualTo("hello hello")); } + [Test] + public void EagerMemberFormatterRegistrations() + { + // These closed generic/enum instantiations are registered statically by the generated + // code (module initializer), so they must be resolvable from GeneratedResolver without + // any runtime MakeGenericType — the invariant NativeAOT/IL2CPP builds rely on. + Assert.That(GeneratedResolver.Instance.GetFormatter(), Is.Not.Null); + Assert.That(GeneratedResolver.Instance.GetFormatter(), Is.Not.Null); + Assert.That(GeneratedResolver.Instance.GetFormatter>(), Is.Not.Null); + Assert.That(GeneratedResolver.Instance.GetFormatter>(), Is.Not.Null); + Assert.That(GeneratedResolver.Instance.GetFormatter(), Is.Not.Null); + Assert.That(GeneratedResolver.Instance.GetFormatter(), Is.Not.Null); + } + + [Test] + public void MRubyFormattableRootRegistration() + { + // Registered via [assembly: MRubyFormattable(typeof(List))] in Classes.cs; + // List appears in no [MRubyObject] member. + Assert.That(GeneratedResolver.Instance.GetFormatter>(), Is.Not.Null); + } + [Test] public void RegisterFormatterWithoutAttributeInstance() { From 53fddb40644476f31e4d97b18f8c07dcbd67f7f4 Mon Sep 17 00:00:00 2001 From: hadashi Date: Thu, 3 Sep 2026 08:31:46 +0900 Subject: [PATCH 3/3] Derive the generic formatter map from the serializer assembly by convention Replace BuiltinFormatterWalker's hard-coded KnownGenericFormatters list with discovery over the referenced ChibiRuby.Serializer assembly: a public generic formatter class implementing IMRubyValueFormatter whose Target is constructed exactly from the formatter's own type parameters (ListFormatter : IMRubyValueFormatter?>) maps Target's definition to that formatter. This mirrors what BuiltinResolver instantiates via MakeGenericType, so runtime and generator stay in sync by construction; array and enum formatters keep their dedicated handling, matching BuiltinResolver's own special cases. The map is cached per assembly symbol. Also make the collection/tuple formatter classes public. Most were internal, so the previously emitted registrations would not even have compiled from a user assembly for members like Stack, HashSet, KeyValuePair<,> or tuples; tests and the NativeAOT sanity app now cover those types. Co-Authored-By: Claude Fable 5 --- sandbox/NativeAotSanity/Program.cs | 6 + .../AssemblyMeta.cs | 6 +- .../BuiltinFormatterWalker.cs | 183 ++++++++++++------ .../MRubyObjectModel.cs | 2 +- .../ReferenceSymbols.cs | 2 + .../Formatters/CollectionFormatters.cs | 38 ++-- .../Formatters/TupleFormatter.cs | 22 +-- tests/ChibiRuby.Serializer.Tests/Classes.cs | 4 + .../GeneratedFormatterTest.cs | 4 + 9 files changed, 170 insertions(+), 97 deletions(-) diff --git a/sandbox/NativeAotSanity/Program.cs b/sandbox/NativeAotSanity/Program.cs index 34116c55..9483573c 100644 --- a/sandbox/NativeAotSanity/Program.cs +++ b/sandbox/NativeAotSanity/Program.cs @@ -22,6 +22,8 @@ Names = ["a", "b"], Table = new Dictionary { ["x"] = new() { Id = 42 } }, MaybeCount = 7, + Tags = ["p", "q"], + Tup = (5, "five"), }; var value = MRubyValueSerializer.Serialize(original, state); var restored = MRubyValueSerializer.Deserialize(value, state)!; @@ -31,6 +33,8 @@ Require(restored.Names.SequenceEqual(["a", "b"]), "string[] member"); Require(restored.Table["x"].Id == 42, "Dictionary member"); Require(restored.MaybeCount == 7, "int? member"); + Require(restored.Tags.SetEquals(["p", "q"]), "HashSet member"); + Require(restored.Tup == (5, "five"), "ValueTuple member"); }); Check("int[] at a call site", () => @@ -89,6 +93,8 @@ partial class Command public string[] Names { get; set; } = []; public Dictionary Table { get; set; } = new(); public int? MaybeCount { get; set; } + public HashSet Tags { get; set; } = []; + public (int, string) Tup { get; set; } } [MRubyObject] diff --git a/src/ChibiRuby.Serializer.SourceGenerator/AssemblyMeta.cs b/src/ChibiRuby.Serializer.SourceGenerator/AssemblyMeta.cs index 1caa450d..a685b6ca 100644 --- a/src/ChibiRuby.Serializer.SourceGenerator/AssemblyMeta.cs +++ b/src/ChibiRuby.Serializer.SourceGenerator/AssemblyMeta.cs @@ -22,12 +22,12 @@ public static AssemblyMeta Create(Compilation compilation, CancellationToken can "System.Runtime.CompilerServices.ModuleInitializerAttribute") is not null; var formattableAttribute = compilation.GetTypeByMetadataName("ChibiRuby.Serializer.MRubyFormattableAttribute"); - var mrubyObjectAttribute = compilation.GetTypeByMetadataName("ChibiRuby.Serializer.MRubyObjectAttribute"); + var references = ReferenceSymbols.Create(compilation); var statements = new SortedSet(StringComparer.Ordinal); var diagnostics = ImmutableArray.CreateBuilder(); - if (formattableAttribute is not null && mrubyObjectAttribute is not null) + if (formattableAttribute is not null && references is not null) { foreach (var attribute in compilation.Assembly.GetAttributes()) { @@ -50,7 +50,7 @@ public static AssemblyMeta Create(Compilation compilation, CancellationToken can rootType.ToDisplayString())); continue; } - BuiltinFormatterWalker.Collect(rootType, mrubyObjectAttribute, statements); + BuiltinFormatterWalker.Collect(rootType, references, statements); } } diff --git a/src/ChibiRuby.Serializer.SourceGenerator/BuiltinFormatterWalker.cs b/src/ChibiRuby.Serializer.SourceGenerator/BuiltinFormatterWalker.cs index 769bfaa1..e44628e3 100644 --- a/src/ChibiRuby.Serializer.SourceGenerator/BuiltinFormatterWalker.cs +++ b/src/ChibiRuby.Serializer.SourceGenerator/BuiltinFormatterWalker.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; using Microsoft.CodeAnalysis; namespace ChibiRuby.Serializer.SourceGenerator; @@ -16,57 +17,14 @@ static class BuiltinFormatterWalker { const string Ns = "global::ChibiRuby.Serializer."; - // Keep in sync with BuiltinResolver.KnownGenericTypes. - static readonly Dictionary KnownGenericFormatters = new() - { - { "System.Nullable`1", "NullableFormatter" }, - { "System.Collections.Generic.KeyValuePair`2", "KeyValuePairFormatter" }, - - { "System.Tuple`1", "TupleFormatter" }, - { "System.Tuple`2", "TupleFormatter" }, - { "System.Tuple`3", "TupleFormatter" }, - { "System.Tuple`4", "TupleFormatter" }, - { "System.Tuple`5", "TupleFormatter" }, - { "System.ValueTuple`1", "ValueTupleFormatter" }, - { "System.ValueTuple`2", "ValueTupleFormatter" }, - { "System.ValueTuple`3", "ValueTupleFormatter" }, - { "System.ValueTuple`4", "ValueTupleFormatter" }, - { "System.ValueTuple`5", "ValueTupleFormatter" }, - - { "System.Collections.Generic.List`1", "ListFormatter" }, - { "System.Collections.Generic.Stack`1", "StackFormatter" }, - { "System.Collections.Generic.Queue`1", "QueueFormatter" }, - { "System.Collections.Generic.LinkedList`1", "LinkedListFormatter" }, - { "System.Collections.Generic.HashSet`1", "HashSetFormatter" }, - { "System.Collections.Generic.SortedSet`1", "SortedSetFormatter" }, - - { "System.Collections.ObjectModel.Collection`1", "CollectionFormatter" }, - { "System.Collections.ObjectModel.ReadOnlyCollection`1", "ReadOnlyCollectionFormatter" }, - { "System.Collections.Concurrent.BlockingCollection`1", "BlockingCollectionFormatter" }, - { "System.Collections.Concurrent.ConcurrentQueue`1", "ConcurrentQueueFormatter" }, - { "System.Collections.Concurrent.ConcurrentStack`1", "ConcurrentStackFormatter" }, - { "System.Collections.Concurrent.ConcurrentBag`1", "ConcurrentBagFormatter" }, - - { "System.Collections.Generic.Dictionary`2", "DictionaryFormatter" }, - { "System.Collections.Generic.SortedDictionary`2", "SortedDictionaryFormatter" }, - { "System.Collections.Concurrent.ConcurrentDictionary`2", "ConcurrentDictionaryFormatter" }, - - { "System.Collections.Generic.IEnumerable`1", "InterfaceEnumerableFormatter" }, - { "System.Collections.Generic.ICollection`1", "InterfaceCollectionFormatter" }, - { "System.Collections.Generic.IReadOnlyCollection`1", "InterfaceReadOnlyCollectionFormatter" }, - { "System.Collections.Generic.IList`1", "InterfaceListFormatter" }, - { "System.Collections.Generic.IReadOnlyList`1", "InterfaceReadOnlyListFormatter" }, - { "System.Collections.Generic.IDictionary`2", "InterfaceDictionaryFormatter" }, - { "System.Collections.Generic.IReadOnlyDictionary`2", "InterfaceReadOnlyDictionaryFormatter" }, - { "System.Collections.Generic.ISet`1", "InterfaceSetFormatter" }, - }; + // targetOriginalDefinition (e.g. List`1) -> formatter definition (e.g. ListFormatter`1), + // discovered from the referenced ChibiRuby.Serializer assembly, cached per assembly symbol. + static readonly ConditionalWeakTable> MapCache = new(); /// /// Collects registration statements for into . - /// Returns true when the walk produced at least one statement or the type is covered by its own - /// generated registration. /// - public static bool Collect(ITypeSymbol type, INamedTypeSymbol mrubyObjectAttribute, ISet statements) + public static void Collect(ITypeSymbol type, ReferenceSymbols references, ISet statements) { switch (type) { @@ -82,22 +40,22 @@ public static bool Collect(ITypeSymbol type, INamedTypeSymbol mrubyObjectAttribu }; if (formatter is null) { - return false; + return; } statements.Add(Register($"{Ns}{formatter}<{Display(array.ElementType)}>")); - Collect(array.ElementType, mrubyObjectAttribute, statements); - return true; + Collect(array.ElementType, references, statements); + return; } case INamedTypeSymbol named: { if (named.TypeKind == TypeKind.Enum) { statements.Add(Register($"{Ns}EnumAsStringFormatter<{Display(named)}>")); - return true; + return; } if (named.GetAttributes().Any(a => - SymbolEqualityComparer.Default.Equals(a.AttributeClass, mrubyObjectAttribute))) + SymbolEqualityComparer.Default.Equals(a.AttributeClass, references.MRubyObjectAttribute))) { // A [MRubyObject] type registers itself (and its member formatters) from its // generated __RegisterMRubyValueFormatter. Calling it here roots the closed @@ -105,34 +63,133 @@ public static bool Collect(ITypeSymbol type, INamedTypeSymbol mrubyObjectAttribu statements.Add($"{DisplayBare(named)}.__RegisterMRubyValueFormatter();"); foreach (var arg in named.TypeArguments) { - Collect(arg, mrubyObjectAttribute, statements); + Collect(arg, references, statements); } - return true; + return; } if (named is { IsGenericType: true, IsUnboundGenericType: false }) { - var handled = false; - var metadataName = $"{named.ConstructedFrom.ContainingNamespace.ToDisplayString()}.{named.ConstructedFrom.MetadataName}"; - if (KnownGenericFormatters.TryGetValue(metadataName, out var formatterName)) + if (GetFormatterMap(references).TryGetValue(named.OriginalDefinition, out var formatter)) { var args = string.Join(", ", named.TypeArguments.Select(Display)); - statements.Add(Register($"{Ns}{formatterName}<{args}>")); - handled = true; + statements.Add(Register($"{FormatterTypeName(formatter)}<{args}>")); } foreach (var arg in named.TypeArguments) { - handled |= Collect(arg, mrubyObjectAttribute, statements); + Collect(arg, references, statements); } - return handled; } - return false; + return; } default: - return false; // type parameters etc. resolve at the closed instantiation + return; // type parameters etc. resolve at the closed instantiation } } + static Dictionary GetFormatterMap(ReferenceSymbols references) + { + var formatterInterface = references.MRubyValueFormatterInterface; + return MapCache.GetValue( + formatterInterface.ContainingAssembly, + assembly => CreateFormatterMap(assembly, formatterInterface)); + } + + /// + /// Derives the generic-formatter map from the serializer assembly itself, by convention: + /// a public, non-abstract generic class F<T1..Tn> with a public parameterless + /// constructor that implements IMRubyValueFormatter<Target>, where + /// Target is a generic type constructed exactly from T1..Tn in order + /// (e.g. ListFormatter<T> : IMRubyValueFormatter<List<T>?>), + /// maps Target's definition to F. This is the same shape BuiltinResolver + /// instantiates at runtime via MakeGenericType, so the two stay in sync by construction. + /// Formatters over a bare type parameter (EnumAsStringFormatter, RObjectFormatter) and + /// array formatters do not match and keep their dedicated handling above. + /// + static Dictionary CreateFormatterMap( + IAssemblySymbol serializerAssembly, + INamedTypeSymbol formatterInterface) + { + var map = new Dictionary(SymbolEqualityComparer.Default); + foreach (var formatter in EnumerateTypes(serializerAssembly.GlobalNamespace)) + { + if (formatter is not + { + TypeKind: TypeKind.Class, + IsAbstract: false, + IsGenericType: true, + // generated code in user assemblies must be able to `new` it + DeclaredAccessibility: Accessibility.Public, + }) + { + continue; + } + if (!formatter.InstanceConstructors.Any(x => + x.Parameters.Length == 0 && x.DeclaredAccessibility == Accessibility.Public)) + { + continue; + } + + foreach (var implemented in formatter.AllInterfaces) + { + if (!SymbolEqualityComparer.Default.Equals(implemented.OriginalDefinition, formatterInterface)) + { + continue; + } + if (implemented.TypeArguments[0] is not INamedTypeSymbol { IsGenericType: true } target || + target.TypeArguments.Length != formatter.TypeParameters.Length) + { + continue; + } + + var argumentsMatch = true; + for (var i = 0; i < target.TypeArguments.Length; i++) + { + if (!SymbolEqualityComparer.Default.Equals(target.TypeArguments[i], formatter.TypeParameters[i])) + { + argumentsMatch = false; + break; + } + } + if (!argumentsMatch) + { + continue; + } + + var key = target.OriginalDefinition; + // Deterministic pick if two formatters ever target the same type. + if (!map.TryGetValue(key, out var existing) || + string.CompareOrdinal(FormatterTypeName(formatter), FormatterTypeName(existing)) < 0) + { + map[key] = formatter; + } + } + } + return map; + } + + static IEnumerable EnumerateTypes(INamespaceSymbol ns) + { + foreach (var member in ns.GetMembers()) + { + switch (member) + { + case INamespaceSymbol child: + foreach (var type in EnumerateTypes(child)) + { + yield return type; + } + break; + case INamedTypeSymbol type: + yield return type; // formatters are top-level; no need to walk nested types + break; + } + } + } + + static string FormatterTypeName(INamedTypeSymbol formatter) => + $"global::{formatter.ContainingNamespace.ToDisplayString()}.{formatter.Name}"; + static string Register(string formatterType) => $"{Ns}GeneratedResolver.Register(new {formatterType}());"; diff --git a/src/ChibiRuby.Serializer.SourceGenerator/MRubyObjectModel.cs b/src/ChibiRuby.Serializer.SourceGenerator/MRubyObjectModel.cs index 65d6a87a..b7f93514 100644 --- a/src/ChibiRuby.Serializer.SourceGenerator/MRubyObjectModel.cs +++ b/src/ChibiRuby.Serializer.SourceGenerator/MRubyObjectModel.cs @@ -252,7 +252,7 @@ static MRubyObjectModel CreateCore(GeneratorAttributeSyntaxContext context, Canc var eagerRegistrations = new SortedSet(StringComparer.Ordinal); foreach (var member in typeMeta.MemberMetas) { - BuiltinFormatterWalker.Collect(member.MemberType, references.MRubyObjectAttribute, eagerRegistrations); + BuiltinFormatterWalker.Collect(member.MemberType, references, eagerRegistrations); } // The registration for the type itself is emitted unconditionally; a statement produced // for a self-typed member would just be a duplicate of it. diff --git a/src/ChibiRuby.Serializer.SourceGenerator/ReferenceSymbols.cs b/src/ChibiRuby.Serializer.SourceGenerator/ReferenceSymbols.cs index ccf5490c..c0adce57 100644 --- a/src/ChibiRuby.Serializer.SourceGenerator/ReferenceSymbols.cs +++ b/src/ChibiRuby.Serializer.SourceGenerator/ReferenceSymbols.cs @@ -16,6 +16,7 @@ public class ReferenceSymbols MRubyMemberAttribute = compilation.GetTypeByMetadataName("ChibiRuby.Serializer.MRubyMemberAttribute")!, MRubyIgnoreAttribute = compilation.GetTypeByMetadataName("ChibiRuby.Serializer.MRubyIgnoreAttribute")!, MRubyConstructorAttribute = compilation.GetTypeByMetadataName("ChibiRuby.Serializer.MRubyConstructorAttribute")!, + MRubyValueFormatterInterface = compilation.GetTypeByMetadataName("ChibiRuby.Serializer.IMRubyValueFormatter`1")!, }; } @@ -23,4 +24,5 @@ public class ReferenceSymbols public INamedTypeSymbol MRubyMemberAttribute { get; private set; } = default!; public INamedTypeSymbol MRubyIgnoreAttribute { get; private set; } = default!; public INamedTypeSymbol MRubyConstructorAttribute { get; private set; } = default!; + public INamedTypeSymbol MRubyValueFormatterInterface { get; private set; } = default!; } diff --git a/src/ChibiRuby.Serializer/Formatters/CollectionFormatters.cs b/src/ChibiRuby.Serializer/Formatters/CollectionFormatters.cs index a9a778d5..7790cece 100644 --- a/src/ChibiRuby.Serializer/Formatters/CollectionFormatters.cs +++ b/src/ChibiRuby.Serializer/Formatters/CollectionFormatters.cs @@ -304,7 +304,7 @@ public MRubyValue Serialize(Dictionary? value, MRubyState state, M } } -class SortedDictionaryFormatter : IMRubyValueFormatter?> where TKey : notnull +public class SortedDictionaryFormatter : IMRubyValueFormatter?> where TKey : notnull { public MRubyValue Serialize(SortedDictionary? value, MRubyState state, MRubyValueSerializerOptions options) { @@ -341,7 +341,7 @@ public MRubyValue Serialize(SortedDictionary? value, MRubyState st } } -class ConcurrentDictionaryFormatter : IMRubyValueFormatter?> where TKey : notnull +public class ConcurrentDictionaryFormatter : IMRubyValueFormatter?> where TKey : notnull { public MRubyValue Serialize(ConcurrentDictionary? value, MRubyState state, MRubyValueSerializerOptions options) { @@ -452,7 +452,7 @@ public MRubyValue Serialize(IReadOnlyDictionary? value, MRubyState } } -class InterfaceEnumerableFormatter : IMRubyValueFormatter?> +public class InterfaceEnumerableFormatter : IMRubyValueFormatter?> { public MRubyValue Serialize(IEnumerable? value, MRubyState state, MRubyValueSerializerOptions options) { @@ -486,7 +486,7 @@ public MRubyValue Serialize(IEnumerable? value, MRubyState state, MRubyValueS } } -class InterfaceCollectionFormatter : IMRubyValueFormatter?> +public class InterfaceCollectionFormatter : IMRubyValueFormatter?> { public MRubyValue Serialize(ICollection? value, MRubyState state, MRubyValueSerializerOptions options) { @@ -519,7 +519,7 @@ public MRubyValue Serialize(ICollection? value, MRubyState state, MRubyValueS } } -class InterfaceReadOnlyCollectionFormatter : IMRubyValueFormatter?> +public class InterfaceReadOnlyCollectionFormatter : IMRubyValueFormatter?> { public MRubyValue Serialize(IReadOnlyCollection? value, MRubyState state, MRubyValueSerializerOptions options) { @@ -553,7 +553,7 @@ public MRubyValue Serialize(IReadOnlyCollection? value, MRubyState state, MRu } } -class InterfaceListFormatter : IMRubyValueFormatter?> +public class InterfaceListFormatter : IMRubyValueFormatter?> { public MRubyValue Serialize(IList? value, MRubyState state, MRubyValueSerializerOptions options) { @@ -586,7 +586,7 @@ public MRubyValue Serialize(IList? value, MRubyState state, MRubyValueSeriali } } -class InterfaceReadOnlyListFormatter : IMRubyValueFormatter?> +public class InterfaceReadOnlyListFormatter : IMRubyValueFormatter?> { public MRubyValue Serialize(IReadOnlyList? value, MRubyState state, MRubyValueSerializerOptions options) { @@ -619,7 +619,7 @@ public MRubyValue Serialize(IReadOnlyList? value, MRubyState state, MRubyValu } } -class HashSetFormatter : IMRubyValueFormatter?> +public class HashSetFormatter : IMRubyValueFormatter?> { public MRubyValue Serialize(HashSet? value, MRubyState state, MRubyValueSerializerOptions options) { @@ -652,7 +652,7 @@ public MRubyValue Serialize(HashSet? value, MRubyState state, MRubyValueSeria } } -class SortedSetFormatter : IMRubyValueFormatter?> +public class SortedSetFormatter : IMRubyValueFormatter?> { public MRubyValue Serialize(SortedSet? value, MRubyState state, MRubyValueSerializerOptions options) { @@ -685,7 +685,7 @@ public MRubyValue Serialize(SortedSet? value, MRubyState state, MRubyValueSer } } -class InterfaceSetFormatter : IMRubyValueFormatter?> +public class InterfaceSetFormatter : IMRubyValueFormatter?> { public MRubyValue Serialize(ISet? value, MRubyState state, MRubyValueSerializerOptions options) { @@ -718,7 +718,7 @@ public MRubyValue Serialize(ISet? value, MRubyState state, MRubyValueSerializ } } -class StackFormatter : IMRubyValueFormatter?> +public class StackFormatter : IMRubyValueFormatter?> { public MRubyValue Serialize(Stack? value, MRubyState state, MRubyValueSerializerOptions options) { @@ -761,7 +761,7 @@ public MRubyValue Serialize(Stack? value, MRubyState state, MRubyValueSeriali } } -class QueueFormatter : IMRubyValueFormatter?> +public class QueueFormatter : IMRubyValueFormatter?> { public MRubyValue Serialize(Queue? value, MRubyState state, MRubyValueSerializerOptions options) { @@ -795,7 +795,7 @@ public MRubyValue Serialize(Queue? value, MRubyState state, MRubyValueSeriali } } -class LinkedListFormatter : IMRubyValueFormatter?> +public class LinkedListFormatter : IMRubyValueFormatter?> { public MRubyValue Serialize(LinkedList? value, MRubyState state, MRubyValueSerializerOptions options) { @@ -829,7 +829,7 @@ public MRubyValue Serialize(LinkedList? value, MRubyState state, MRubyValueSe } } -class CollectionFormatter : IMRubyValueFormatter?> +public class CollectionFormatter : IMRubyValueFormatter?> { public MRubyValue Serialize(Collection? value, MRubyState state, MRubyValueSerializerOptions options) { @@ -863,7 +863,7 @@ public MRubyValue Serialize(Collection? value, MRubyState state, MRubyValueSe } } -class ReadOnlyCollectionFormatter : IMRubyValueFormatter?> +public class ReadOnlyCollectionFormatter : IMRubyValueFormatter?> { public MRubyValue Serialize(ReadOnlyCollection? value, MRubyState state, MRubyValueSerializerOptions options) { @@ -897,7 +897,7 @@ public MRubyValue Serialize(ReadOnlyCollection? value, MRubyState state, MRub } } -class BlockingCollectionFormatter : IMRubyValueFormatter?> +public class BlockingCollectionFormatter : IMRubyValueFormatter?> { public MRubyValue Serialize(BlockingCollection? value, MRubyState state, MRubyValueSerializerOptions options) { @@ -931,7 +931,7 @@ public MRubyValue Serialize(BlockingCollection? value, MRubyState state, MRub } } -class ConcurrentQueueFormatter : IMRubyValueFormatter?> +public class ConcurrentQueueFormatter : IMRubyValueFormatter?> { public MRubyValue Serialize(ConcurrentQueue? value, MRubyState state, MRubyValueSerializerOptions options) { @@ -965,7 +965,7 @@ public MRubyValue Serialize(ConcurrentQueue? value, MRubyState state, MRubyVa } } -class ConcurrentStackFormatter : IMRubyValueFormatter?> +public class ConcurrentStackFormatter : IMRubyValueFormatter?> { public MRubyValue Serialize(ConcurrentStack? value, MRubyState state, MRubyValueSerializerOptions options) { @@ -1009,7 +1009,7 @@ public MRubyValue Serialize(ConcurrentStack? value, MRubyState state, MRubyVa } } -class ConcurrentBagFormatter : IMRubyValueFormatter?> +public class ConcurrentBagFormatter : IMRubyValueFormatter?> { public MRubyValue Serialize(ConcurrentBag? value, MRubyState state, MRubyValueSerializerOptions options) { diff --git a/src/ChibiRuby.Serializer/Formatters/TupleFormatter.cs b/src/ChibiRuby.Serializer/Formatters/TupleFormatter.cs index b261a4cc..f79c630c 100644 --- a/src/ChibiRuby.Serializer/Formatters/TupleFormatter.cs +++ b/src/ChibiRuby.Serializer/Formatters/TupleFormatter.cs @@ -3,7 +3,7 @@ namespace ChibiRuby.Serializer; -class KeyValuePairFormatter : IMRubyValueFormatter> +public class KeyValuePairFormatter : IMRubyValueFormatter> { public MRubyValue Serialize(KeyValuePair value, MRubyState state, MRubyValueSerializerOptions options) { @@ -34,7 +34,7 @@ public KeyValuePair Deserialize(MRubyValue value, MRubyState state } } -class TupleFormatter : IMRubyValueFormatter?> +public class TupleFormatter : IMRubyValueFormatter?> { public MRubyValue Serialize(Tuple? value, MRubyState state, MRubyValueSerializerOptions options) { @@ -63,7 +63,7 @@ public MRubyValue Serialize(Tuple? value, MRubyState state, MRubyValueSerial } } -class TupleFormatter : IMRubyValueFormatter?> +public class TupleFormatter : IMRubyValueFormatter?> { public MRubyValue Serialize(Tuple? value, MRubyState state, MRubyValueSerializerOptions options) { @@ -96,7 +96,7 @@ public MRubyValue Serialize(Tuple? value, MRubyState state, MRubyValueSe } } -class TupleFormatter : IMRubyValueFormatter?> +public class TupleFormatter : IMRubyValueFormatter?> { public MRubyValue Serialize(Tuple? value, MRubyState state, MRubyValueSerializerOptions options) { @@ -134,7 +134,7 @@ public MRubyValue Serialize(Tuple? value, MRubyState state, MRubyVal } } -class TupleFormatter : IMRubyValueFormatter?> +public class TupleFormatter : IMRubyValueFormatter?> { public MRubyValue Serialize(Tuple? value, MRubyState state, MRubyValueSerializerOptions options) { @@ -176,7 +176,7 @@ public MRubyValue Serialize(Tuple? value, MRubyState state, MRub } } -class TupleFormatter : IMRubyValueFormatter?> +public class TupleFormatter : IMRubyValueFormatter?> { public MRubyValue Serialize(Tuple? value, MRubyState state, MRubyValueSerializerOptions options) { @@ -222,7 +222,7 @@ public MRubyValue Serialize(Tuple? value, MRubyState state, } } -class ValueTupleFormatter : IMRubyValueFormatter> +public class ValueTupleFormatter : IMRubyValueFormatter> { public MRubyValue Serialize(ValueTuple value, MRubyState state, MRubyValueSerializerOptions options) { @@ -249,7 +249,7 @@ public ValueTuple Deserialize(MRubyValue value, MRubyState state, MRubyValue } } -class ValueTupleFormatter : IMRubyValueFormatter> +public class ValueTupleFormatter : IMRubyValueFormatter> { public MRubyValue Serialize(ValueTuple value, MRubyState state, MRubyValueSerializerOptions options) { @@ -280,7 +280,7 @@ public ValueTuple Deserialize(MRubyValue value, MRubyState state, MRubyV } } -class ValueTupleFormatter : IMRubyValueFormatter> +public class ValueTupleFormatter : IMRubyValueFormatter> { public MRubyValue Serialize(ValueTuple value, MRubyState state, MRubyValueSerializerOptions options) { @@ -315,7 +315,7 @@ public ValueTuple Deserialize(MRubyValue value, MRubyState state, MR } } -class ValueTupleFormatter : IMRubyValueFormatter> +public class ValueTupleFormatter : IMRubyValueFormatter> { public MRubyValue Serialize(ValueTuple value, MRubyState state, MRubyValueSerializerOptions options) { @@ -354,7 +354,7 @@ public ValueTuple Deserialize(MRubyValue value, MRubyState state } } -class ValueTupleFormatter : IMRubyValueFormatter> +public class ValueTupleFormatter : IMRubyValueFormatter> { public MRubyValue Serialize(ValueTuple value, MRubyState state, MRubyValueSerializerOptions options) { diff --git a/tests/ChibiRuby.Serializer.Tests/Classes.cs b/tests/ChibiRuby.Serializer.Tests/Classes.cs index 070a74ce..350eaba1 100644 --- a/tests/ChibiRuby.Serializer.Tests/Classes.cs +++ b/tests/ChibiRuby.Serializer.Tests/Classes.cs @@ -68,4 +68,8 @@ partial class AotMemberObject public List Ids { get; set; } = []; public Dictionary DictField { get; set; } = new(); public int[,] Grid { get; set; } = new int[0, 0]; + public HashSet Tags { get; set; } = []; + public Stack History { get; set; } = new(); + public KeyValuePair Pair { get; set; } + public (int, string) Tup { get; set; } } diff --git a/tests/ChibiRuby.Serializer.Tests/GeneratedFormatterTest.cs b/tests/ChibiRuby.Serializer.Tests/GeneratedFormatterTest.cs index 67493d34..17ebd105 100644 --- a/tests/ChibiRuby.Serializer.Tests/GeneratedFormatterTest.cs +++ b/tests/ChibiRuby.Serializer.Tests/GeneratedFormatterTest.cs @@ -108,6 +108,10 @@ public void EagerMemberFormatterRegistrations() Assert.That(GeneratedResolver.Instance.GetFormatter>(), Is.Not.Null); Assert.That(GeneratedResolver.Instance.GetFormatter(), Is.Not.Null); Assert.That(GeneratedResolver.Instance.GetFormatter(), Is.Not.Null); + Assert.That(GeneratedResolver.Instance.GetFormatter>(), Is.Not.Null); + Assert.That(GeneratedResolver.Instance.GetFormatter>(), Is.Not.Null); + Assert.That(GeneratedResolver.Instance.GetFormatter>(), Is.Not.Null); + Assert.That(GeneratedResolver.Instance.GetFormatter<(int, string)>(), Is.Not.Null); } [Test]