Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions ChibiRuby.slnx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<Solution>
<Folder Name="/sandbox/">
<Project Path="sandbox\ChibiRuby.Benchmark\ChibiRuby.Benchmark.csproj" Type="Classic C#" />
<Project Path="sandbox\NativeAotSanity\NativeAotSanity.csproj" Type="Classic C#" />
<Project Path="sandbox\SampleConsoleApp\SampleConsoleApp.csproj" Type="Classic C#" />
<Project Path="sandbox\SampleDebuggerEmbedded\SampleDebuggerEmbedded.csproj" Type="Classic C#" />
</Folder>
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, MyStruct>`, `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<T>` type arguments:

```cs
// List<double> appears in no [MRubyObject] member, only at call sites:
[assembly: MRubyFormattable(typeof(List<double>))]

var xs = MRubyValueSerializer.Deserialize<List<double>>(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<int>)`).

Custom formatters registered through `CompositeResolver` / `MRubyValueSerializerOptions` are plain
statically-referenced code and need no extra care.

## License

MIT
25 changes: 25 additions & 0 deletions sandbox/NativeAotSanity/NativeAotSanity.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<!-- Fail the publish if any trimming/AOT analysis warning regresses. -->
<WarningsAsErrors>$(WarningsAsErrors);IL2026;IL2055;IL2070;IL2071;IL2072;IL2104;IL3050;IL3053</WarningsAsErrors>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\ChibiRuby\ChibiRuby.csproj" />
<ProjectReference Include="..\..\src\ChibiRuby.Serializer\ChibiRuby.Serializer.csproj" />
<ProjectReference Include="..\..\src\ChibiRuby.Serializer.SourceGenerator\ChibiRuby.Serializer.SourceGenerator.csproj">
<OutputItemType>Analyzer</OutputItemType>
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
</ProjectReference>
</ItemGroup>

</Project>
104 changes: 104 additions & 0 deletions sandbox/NativeAotSanity/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// 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<double>))]
[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<string, Inner> { ["x"] = new() { Id = 42 } },
MaybeCount = 7,
Tags = ["p", "q"],
Tup = (5, "five"),
};
var value = MRubyValueSerializer.Serialize(original, state);
var restored = MRubyValueSerializer.Deserialize<Command>(value, state)!;

Require(restored.Kind == CommandKind.FooBar, "enum member");
Require(restored.Ids.SequenceEqual([1, 2, 3]), "List<int> member");
Require(restored.Names.SequenceEqual(["a", "b"]), "string[] member");
Require(restored.Table["x"].Id == 42, "Dictionary<string, struct> member");
Require(restored.MaybeCount == 7, "int? member");
Require(restored.Tags.SetEquals(["p", "q"]), "HashSet<string> member");
Require(restored.Tup == (5, "five"), "ValueTuple member");
});

Check("int[] at a call site", () =>
{
var value = MRubyValueSerializer.Serialize(new[] { 10, 20 }, state);
var restored = MRubyValueSerializer.Deserialize<int[]>(value, state)!;
Require(restored.SequenceEqual([10, 20]), "int[] round-trip");
});

Check("[assembly: MRubyFormattable] root type List<double>", () =>
{
var value = MRubyValueSerializer.Serialize(new List<double> { 1.5, 2.5 }, state);
var restored = MRubyValueSerializer.Deserialize<List<double>>(value, state)!;
Require(restored.SequenceEqual([1.5, 2.5]), "List<double> 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<int> Ids { get; set; } = [];
public string[] Names { get; set; } = [];
public Dictionary<string, Inner> Table { get; set; } = new();
public int? MaybeCount { get; set; }
public HashSet<string> Tags { get; set; } = [];
public (int, string) Tup { get; set; }
}

[MRubyObject]
partial struct Inner
{
public long Id { get; set; }
}
62 changes: 62 additions & 0 deletions src/ChibiRuby.Serializer.SourceGenerator/AssemblyMeta.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Threading;
using Microsoft.CodeAnalysis;

namespace ChibiRuby.Serializer.SourceGenerator;

/// <summary>
/// Equatable, assembly-wide facts consumed by the generated module initializer:
/// whether ModuleInitializerAttribute needs a polyfill, and the registration statements
/// derived from <c>[assembly: MRubyFormattable(typeof(...))]</c> root declarations.
/// </summary>
sealed record AssemblyMeta(
bool HasModuleInitializerAttribute,
EquatableArray<string> RootStatements,
EquatableArray<DiagnosticInfo> Diagnostics) : IEquatable<AssemblyMeta>
{
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 references = ReferenceSymbols.Create(compilation);

var statements = new SortedSet<string>(StringComparer.Ordinal);
var diagnostics = ImmutableArray.CreateBuilder<DiagnosticInfo>();

if (formattableAttribute is not null && references 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, references, statements);
}
}

return new AssemblyMeta(
hasModuleInitializer,
new EquatableArray<string>(statements.ToImmutableArray()),
new EquatableArray<DiagnosticInfo>(diagnostics.ToImmutable()));
}
}
Loading
Loading