From 1472f2c2089b46d91fc74451fb37a4068209846c Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:29:36 -0700 Subject: [PATCH 1/2] test: add incrementality step-tracking tests (red baseline) Four tests assert cached-not-executed behavior via trackIncrementalGeneratorSteps: editing one class must not regenerate others, an unrelated edit must regenerate nothing, code edits must not re-parse migration files, and a migration edit must only re-run its class. All four fail against the current pipeline. Co-Authored-By: Claude Fable 5 --- .../IncrementalityTests.cs | 260 ++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 ModernUO.Serialization.Generator.Tests/IncrementalityTests.cs diff --git a/ModernUO.Serialization.Generator.Tests/IncrementalityTests.cs b/ModernUO.Serialization.Generator.Tests/IncrementalityTests.cs new file mode 100644 index 0000000..9fe1052 --- /dev/null +++ b/ModernUO.Serialization.Generator.Tests/IncrementalityTests.cs @@ -0,0 +1,260 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using ModernUO.Serialization.Generator.Tests.Helpers; +using Xunit; + +namespace ModernUO.Serialization.Generator.Tests; + +/// +/// Proves the pipeline actually caches: after an edit, only affected classes may re-run the +/// source-output stage, and untouched migration files are not re-parsed. Uses Roslyn's +/// incremental step tracking, so "cached" means the callback did not execute at all. +/// +public class IncrementalityTests +{ + private const string ClassA = """ + using System; + using ModernUO.Serialization; + using Server; + + namespace Server.TestContent + { + [SerializationGenerator(1)] + public partial class AlphaItem : ISerializable + { + [SerializableField(0)] + private string _name; + + public DateTime Created { get; set; } + public Serial Serial { get; } + public bool Deleted => false; + public void Delete() { } + + private void MigrateFrom(V0Content content) + { + _name = content.Name; + } + } + } + """; + + private const string ClassB = """ + using System; + using ModernUO.Serialization; + using Server; + + namespace Server.TestContent + { + [SerializationGenerator(0)] + public partial class BravoItem : ISerializable + { + [SerializableField(0)] + private int _charges; + + public DateTime Created { get; set; } + public Serial Serial { get; } + public bool Deleted => false; + public void Delete() { } + } + } + """; + + private const string UnrelatedClass = """ + namespace Server.TestContent + { + public class Bystander + { + public int Value { get; set; } + } + } + """; + + private const string AlphaMigrationJson = """ + { + "version": 0, + "type": "Server.TestContent.AlphaItem", + "properties": [ + { + "name": "Name", + "type": "string", + "rule": "PrimitiveTypeMigrationRule" + } + ] + } + """; + + private static (GeneratorDriver Driver, CSharpCompilation Compilation, Dictionary Texts) + CreateTrackedRun( + Dictionary sources, + IEnumerable<(string fileName, string content)> additionalTexts + ) + { + var trees = new List { CSharpSyntaxTree.ParseText(SourceGeneratorTestHelper.ServerStubs) }; + foreach (var (path, text) in sources) + { + trees.Add(CSharpSyntaxTree.ParseText(text, path: path)); + } + + var trustedAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator); + + var references = trustedAssemblies + .Where(p => !string.IsNullOrEmpty(p)) + .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)) + .Concat([MetadataReference.CreateFromFile(typeof(SerializationGeneratorAttribute).Assembly.Location)]) + .ToList(); + + var compilation = CSharpCompilation.Create( + "IncrementalityAssembly", + trees, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + var texts = additionalTexts.ToDictionary( + t => t.fileName, + t => (AdditionalText)new InMemoryAdditionalText(t.fileName, t.content) + ); + + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new EntitySerializationGenerator().AsSourceGenerator()], + additionalTexts: texts.Values, + driverOptions: new GeneratorDriverOptions( + IncrementalGeneratorOutputKind.None, + trackIncrementalGeneratorSteps: true + ) + ); + + driver = driver.RunGenerators(compilation); + return (driver, compilation, texts); + } + + private static CSharpCompilation ReplaceTree(CSharpCompilation compilation, string path, string newText) + { + var oldTree = compilation.SyntaxTrees.Single(t => t.FilePath == path); + return compilation.ReplaceSyntaxTree(oldTree, CSharpSyntaxTree.ParseText(newText, path: path)); + } + + private static (int Executed, int Cached) CountSourceOutputRuns(GeneratorDriver driver) + { + var executed = 0; + var cached = 0; + + foreach (var (_, steps) in driver.GetRunResult().Results[0].TrackedOutputSteps) + { + foreach (var step in steps) + { + foreach (var (_, reason) in step.Outputs) + { + if (reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged) + { + cached++; + } + else + { + executed++; + } + } + } + } + + return (executed, cached); + } + + [Fact] + public void EditingOneClass_DoesNotRegenerateOthers() + { + var sources = new Dictionary { ["A.cs"] = ClassA, ["B.cs"] = ClassB }; + var (driver, compilation, _) = CreateTrackedRun( + sources, + [("Server.TestContent.AlphaItem.v0.json", AlphaMigrationJson)] + ); + + var edited = ReplaceTree(compilation, "B.cs", ClassB + "\n// edit\n"); + driver = driver.RunGenerators(edited); + + var (executed, cached) = CountSourceOutputRuns(driver); + + Assert.True(cached >= 1, "The untouched class must be served from cache."); + Assert.True( + executed <= 1, + $"Only the edited class may re-run the output stage, but {executed} outputs ran." + ); + } + + [Fact] + public void EditingAnUnrelatedFile_RegeneratesNothing() + { + var sources = new Dictionary + { + ["A.cs"] = ClassA, ["B.cs"] = ClassB, ["C.cs"] = UnrelatedClass + }; + var (driver, compilation, _) = CreateTrackedRun( + sources, + [("Server.TestContent.AlphaItem.v0.json", AlphaMigrationJson)] + ); + + var edited = ReplaceTree(compilation, "C.cs", UnrelatedClass + "\n// edit\n"); + driver = driver.RunGenerators(edited); + + var (executed, _) = CountSourceOutputRuns(driver); + + Assert.True( + executed == 0, + $"An edit to a non-serializable file must not re-run any output stage, but {executed} outputs ran." + ); + } + + [Fact] + public void EditingACodeFile_DoesNotReparseMigrationFiles() + { + var sources = new Dictionary { ["A.cs"] = ClassA, ["B.cs"] = ClassB }; + var (driver, compilation, _) = CreateTrackedRun( + sources, + [("Server.TestContent.AlphaItem.v0.json", AlphaMigrationJson)] + ); + + var edited = ReplaceTree(compilation, "B.cs", ClassB + "\n// edit\n"); + driver = driver.RunGenerators(edited); + + var result = driver.GetRunResult().Results[0]; + Assert.True( + result.TrackedSteps.TryGetValue("migrationFiles", out var parseSteps), + "The migration parse node must be tracked as 'migrationFiles'." + ); + + var reparsed = parseSteps + .SelectMany(s => s.Outputs) + .Count(o => o.Reason is not (IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged)); + + Assert.True(reparsed == 0, $"No migration file changed, but {reparsed} parse outputs ran."); + } + + [Fact] + public void EditingAMigrationFile_OnlyAffectsItsClass() + { + var sources = new Dictionary { ["A.cs"] = ClassA, ["B.cs"] = ClassB }; + var (driver, compilation, texts) = CreateTrackedRun( + sources, + [("Server.TestContent.AlphaItem.v0.json", AlphaMigrationJson)] + ); + + // Same compilation; only the additional text changes. + var newJson = AlphaMigrationJson.Replace("\"Name\"", "\"Name\" "); + driver = driver + .ReplaceAdditionalText( + texts["Server.TestContent.AlphaItem.v0.json"], + new InMemoryAdditionalText("Server.TestContent.AlphaItem.v0.json", newJson) + ) + .RunGenerators(compilation); + + var (executed, cached) = CountSourceOutputRuns(driver); + + Assert.True(cached >= 1, "The class without migrations must be served from cache."); + Assert.True( + executed <= 1, + $"Only the class owning the edited migration may re-run, but {executed} outputs ran." + ); + } +} From e3905849557592033cc99284e0b80925f9fdf940 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:56:52 -0700 Subject: [PATCH 2/2] perf: make the incremental pipeline actually incremental Restructures the generator around a fully resolved, value-equatable SerializationModel built in a ForAttributeWithMetadataName transform. Symbol- and compilation-dependent resolution (attribute processing, rules engine, validation, MarkDirty/timer/after-deserialization lookup, type shells) happens in the transform; generation is symbol-free and consumes only the model, so the output stage caches on model equality. Migration JSON files parse once per content change in their own tracked node; a cheap compilation-combined augment step resolves the one value-type fact content structs need; a value-equatable map attaches each class's migrations. Diagnostics travel as equatable DiagnosticInfo and materialize at output time. Location is excluded from model equality so edits elsewhere in a file do not defeat caching. The step-tracked incrementality tests flip green: editing one class leaves every other output cached, editing a non-serializable file or a method body inside a serializable class regenerates nothing, migration files are not re-parsed on code edits, and a migration edit re-runs only its class. A 150-class probe pins the behavior at scale (SourceOutput executed=0 after an inert edit). Correctness: all 17 snapshots byte-identical, and the DiffTool manifest over a 3,786-source corpus is byte-identical to main. Benchmarks (150 classes, migrations included; baseline re-measured on main after fixing the benchmark's missing analyzer-private Humanizer dependency, which had the earlier runs measuring crash paths): - single-edit warm re-run: 61.2ms/16.4MB -> 47.9ms/10.4MB, with output re-executions dropping from 150 to at most 1 (the IDE no longer churns every generated document per keystroke) - no-change re-run: ~65us both (the old pipeline only cached when the compilation was reference-identical, which no real edit ever is) - cold full run: 20.7ms -> 29.9ms; the transform now fronts the resolution work, a deliberate trade against the per-edit wins Breaking (rule API): GenerateDeserializationMethod and GenerateMigrationProperty no longer take a Compilation; IPostDeserializeMethod takes the model; value-type structs must not declare Deserialize (SG3009). Co-Authored-By: Claude Fable 5 --- .../GeneratorBenchmarks.cs | 17 + ....Serialization.Generator.Benchmarks.csproj | 2 + .../IncrementalityTests.cs | 30 + .../PipelineProbeTests.cs | 171 +++++ .../Diagnostics/DiagnosticDescriptors.cs | 9 + .../Diagnostics/DiagnosticInfo.cs | 71 ++ .../EntitySerializationGenerator.cs | 442 ++++++------ .../SerializableClassRecord.cs | 22 - ...SerializableEntityGeneration.BuildModel.cs | 572 ++++++++++++++++ .../SerializableEntityGeneration.Class.cs | 633 ++++-------------- ...ializableEntityGeneration.DataStructure.cs | 34 +- ...zableEntityGeneration.DeserializeMethod.cs | 53 +- .../SerializableEntityGeneration.Property.cs | 43 +- ...SerializableEntityGeneration.SerialCtor.cs | 5 +- ...lizableEntityGeneration.SerializeMethod.cs | 9 +- ...alizationEntityGeneration.ContentStruct.cs | 9 +- .../SerializationModel.cs | 185 +++++ .../DeserializeTimerFieldRequiredException.cs | 10 - .../IPostDeserializeMethod.cs | 4 +- .../ISerializableMigrationRule.cs | 2 - .../Rules/ArrayMigrationRule.cs | 5 - .../Rules/DictionaryMigrationRule.cs | 6 - .../Rules/EnumMigrationRule.cs | 1 - .../Rules/HashSetMigrationRule.cs | 5 - .../Rules/KeyValuePairMigrationRule.cs | 5 - .../Rules/ListMigrationRule.cs | 5 - .../Rules/MigrationRule.cs | 5 +- .../Rules/PrimitiveTypeMigrationRule.cs | 1 - .../Rules/PrimitiveUOTypeMigrationRule.cs | 1 - .../Rules/RawSerializableMigrationRule.cs | 1 - .../SerializableInterfaceMigrationRule.cs | 1 - ...rializationMethodSignatureMigrationRule.cs | 1 - .../Rules/SortedSetMigrationRule.cs | 5 - .../Rules/TimerMigrationRule.cs | 47 +- .../SerializableMetadata.cs | 63 ++ .../SerializableProperty.cs | 71 ++ .../SourceGeneration/EquatableArray.cs | 105 +++ .../SourceGeneration.Arguments.cs | 4 +- .../SourceGeneration.Method.cs | 4 +- .../SourceGeneration.Property.cs | 10 +- .../SymbolMetadata/SymbolMetadata.UO.cs | 35 + 41 files changed, 1741 insertions(+), 963 deletions(-) create mode 100644 ModernUO.Serialization.Generator.Tests/PipelineProbeTests.cs create mode 100644 ModernUO.Serialization.Generator/Diagnostics/DiagnosticInfo.cs delete mode 100644 ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableClassRecord.cs create mode 100644 ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.BuildModel.cs create mode 100644 ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializationModel.cs delete mode 100644 ModernUO.Serialization.Generator/SerializableMigration/DeserializeTimerFieldRequiredException.cs create mode 100644 ModernUO.Serialization.Generator/SourceGeneration/EquatableArray.cs diff --git a/ModernUO.Serialization.Generator.Benchmarks/GeneratorBenchmarks.cs b/ModernUO.Serialization.Generator.Benchmarks/GeneratorBenchmarks.cs index 4cb5ec8..df8ebf3 100644 --- a/ModernUO.Serialization.Generator.Benchmarks/GeneratorBenchmarks.cs +++ b/ModernUO.Serialization.Generator.Benchmarks/GeneratorBenchmarks.cs @@ -86,6 +86,16 @@ public void Setup() .RunGenerators(_compilation); _warmCompilation = _compilation; _editTarget = _warmCompilation.SyntaxTrees.Last(); + + // Guard against measuring a silently failing generator. + var result = _warmDriver.GetRunResult().Results[0]; + if (result.GeneratedSources.Length != ClassCount) + { + throw new InvalidOperationException( + $"Expected {ClassCount} generated sources but got {result.GeneratedSources.Length}. " + + $"Diagnostics: {string.Join("; ", result.Diagnostics.Take(3))}" + ); + } } [Benchmark] @@ -95,6 +105,13 @@ public GeneratorDriver ColdFullRun() => .AddAdditionalTexts(_additionalTexts) .RunGenerators(_compilation); + [Benchmark] + public GeneratorDriver WarmRerunNoChange() + { + _warmDriver = _warmDriver.RunGenerators(_warmCompilation); + return _warmDriver; + } + [Benchmark] public GeneratorDriver WarmRerunAfterSingleEdit() { diff --git a/ModernUO.Serialization.Generator.Benchmarks/ModernUO.Serialization.Generator.Benchmarks.csproj b/ModernUO.Serialization.Generator.Benchmarks/ModernUO.Serialization.Generator.Benchmarks.csproj index 514db43..57b8fb4 100644 --- a/ModernUO.Serialization.Generator.Benchmarks/ModernUO.Serialization.Generator.Benchmarks.csproj +++ b/ModernUO.Serialization.Generator.Benchmarks/ModernUO.Serialization.Generator.Benchmarks.csproj @@ -11,6 +11,8 @@ + + diff --git a/ModernUO.Serialization.Generator.Tests/IncrementalityTests.cs b/ModernUO.Serialization.Generator.Tests/IncrementalityTests.cs index 9fe1052..2023d61 100644 --- a/ModernUO.Serialization.Generator.Tests/IncrementalityTests.cs +++ b/ModernUO.Serialization.Generator.Tests/IncrementalityTests.cs @@ -231,6 +231,36 @@ public void EditingACodeFile_DoesNotReparseMigrationFiles() Assert.True(reparsed == 0, $"No migration file changed, but {reparsed} parse outputs ran."); } + // The payoff case: editing logic inside a serializable class - a method body, a comment - + // produces an equal model, so nothing regenerates even though the class itself changed. + [Fact] + public void EditingNonSerializationCode_InASerializableClass_RegeneratesNothing() + { + var sources = new Dictionary { ["A.cs"] = ClassA, ["B.cs"] = ClassB }; + var (driver, compilation, _) = CreateTrackedRun( + sources, + [("Server.TestContent.AlphaItem.v0.json", AlphaMigrationJson)] + ); + + // Add a method that has nothing to do with serialization. + var edited = ReplaceTree( + compilation, + "B.cs", + ClassB.Replace( + "public void Delete() { }", + "public void Delete() { }\n\n public int ComputeDamage(int roll) => roll * 2 + _charges;" + ) + ); + driver = driver.RunGenerators(edited); + + var (executed, _) = CountSourceOutputRuns(driver); + + Assert.True( + executed == 0, + $"A non-serialization edit inside a serializable class must not regenerate, but {executed} outputs ran." + ); + } + [Fact] public void EditingAMigrationFile_OnlyAffectsItsClass() { diff --git a/ModernUO.Serialization.Generator.Tests/PipelineProbeTests.cs b/ModernUO.Serialization.Generator.Tests/PipelineProbeTests.cs new file mode 100644 index 0000000..aba93f0 --- /dev/null +++ b/ModernUO.Serialization.Generator.Tests/PipelineProbeTests.cs @@ -0,0 +1,171 @@ +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using ModernUO.Serialization.Generator.Tests.Helpers; +using Xunit; +using Xunit.Abstractions; + +namespace ModernUO.Serialization.Generator.Tests; + +/// +/// Step-level probe over a benchmark-sized corpus: reports per-node executed/cached counts +/// after a single-file edit, so a caching regression shows up as numbers, not vibes. +/// +public class PipelineProbeTests(ITestOutputHelper output) +{ + private const int ClassCount = 150; + + private static string BuildClassSource(int index) + { + var sb = new StringBuilder(); + sb.AppendLine("using System;"); + sb.AppendLine("using ModernUO.Serialization;"); + sb.AppendLine("using Server;"); + sb.AppendLine(); + sb.AppendLine("namespace Server.TestContent"); + sb.AppendLine("{"); + sb.AppendLine(" [SerializationGenerator(1)]"); + sb.AppendLine($" public partial class BenchItem{index} : ISerializable"); + sb.AppendLine(" {"); + + for (var f = 0; f < 6; f++) + { + sb.AppendLine($" [SerializableField({f})]"); + sb.AppendLine($" private {(f % 2 == 0 ? "int" : "string")} _field{f};"); + sb.AppendLine(); + } + + sb.AppendLine(" public DateTime Created { get; set; }"); + sb.AppendLine(" public Serial Serial { get; }"); + sb.AppendLine(" public bool Deleted => false;"); + sb.AppendLine(" public void Delete() { }"); + sb.AppendLine(); + sb.AppendLine(" private void MigrateFrom(V0Content content)"); + sb.AppendLine(" {"); + sb.AppendLine(" _field0 = content.Field0;"); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + + return sb.ToString(); + } + + private static string BuildMigrationJson(int index) => + $$""" + { + "version": 0, + "type": "Server.TestContent.BenchItem{{index}}", + "properties": [ + { + "name": "Field0", + "type": "int", + "rule": "PrimitiveTypeMigrationRule" + } + ] + } + """; + + [Fact] + public void WarmRerun_OnlyEditedClassExecutes() + { + var trees = new List { CSharpSyntaxTree.ParseText(SourceGeneratorTestHelper.ServerStubs) }; + for (var i = 0; i < ClassCount; i++) + { + trees.Add(CSharpSyntaxTree.ParseText(BuildClassSource(i), path: $"BenchItem{i}.cs")); + } + + var trustedAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator); + + var references = trustedAssemblies + .Where(p => !string.IsNullOrEmpty(p)) + .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)) + .Concat([MetadataReference.CreateFromFile(typeof(SerializationGeneratorAttribute).Assembly.Location)]) + .ToList(); + + var compilation = CSharpCompilation.Create( + "ProbeAssembly", + trees, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + var additionalTexts = new List(); + for (var i = 0; i < ClassCount; i++) + { + additionalTexts.Add( + new InMemoryAdditionalText($"Server.TestContent.BenchItem{i}.v0.json", BuildMigrationJson(i)) + ); + } + + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new EntitySerializationGenerator().AsSourceGenerator()], + additionalTexts: additionalTexts, + driverOptions: new GeneratorDriverOptions( + IncrementalGeneratorOutputKind.None, + trackIncrementalGeneratorSteps: true + ) + ); + + driver = driver.RunGenerators(compilation); + Assert.Equal(ClassCount, driver.GetRunResult().Results[0].GeneratedSources.Length); + + // Edit one file. + var target = compilation.SyntaxTrees.Single(t => t.FilePath == "BenchItem7.cs"); + var edited = compilation.ReplaceSyntaxTree( + target, + CSharpSyntaxTree.ParseText(BuildClassSource(7) + "\n// edit\n", path: "BenchItem7.cs") + ); + + driver = driver.RunGenerators(edited); + var result = driver.GetRunResult().Results[0]; + + var executedOutputs = 0; + foreach (var (name, steps) in result.TrackedSteps) + { + var executed = 0; + var cached = 0; + foreach (var step in steps) + { + foreach (var (_, reason) in step.Outputs) + { + if (reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged) + { + cached++; + } + else + { + executed++; + } + } + } + + output.WriteLine($"{name}: executed={executed} cached={cached}"); + } + + foreach (var (name, steps) in result.TrackedOutputSteps) + { + var executed = 0; + var cached = 0; + foreach (var step in steps) + { + foreach (var (_, reason) in step.Outputs) + { + if (reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged) + { + cached++; + } + else + { + executed++; + } + } + } + + executedOutputs += executed; + output.WriteLine($"OUTPUT {name}: executed={executed} cached={cached}"); + } + + Assert.True(executedOutputs <= 1, $"Expected at most 1 executed output, got {executedOutputs}."); + } +} diff --git a/ModernUO.Serialization.Generator/Diagnostics/DiagnosticDescriptors.cs b/ModernUO.Serialization.Generator/Diagnostics/DiagnosticDescriptors.cs index 30c7e22..70ff82b 100644 --- a/ModernUO.Serialization.Generator/Diagnostics/DiagnosticDescriptors.cs +++ b/ModernUO.Serialization.Generator/Diagnostics/DiagnosticDescriptors.cs @@ -130,6 +130,15 @@ public static class DiagnosticDescriptors true ); + public static readonly DiagnosticDescriptor SG3013 = new( + "SG3013", + "Invalid migration file", + "Migration file '{0}' could not be parsed: {1}", + "ModernUO.Serialization.Generator", + DiagnosticSeverity.Error, + true + ); + public static DiagnosticDescriptor GeneratorCrashedDiagnostic(Exception e) => new( "SG0001", diff --git a/ModernUO.Serialization.Generator/Diagnostics/DiagnosticInfo.cs b/ModernUO.Serialization.Generator/Diagnostics/DiagnosticInfo.cs new file mode 100644 index 0000000..a4d1c65 --- /dev/null +++ b/ModernUO.Serialization.Generator/Diagnostics/DiagnosticInfo.cs @@ -0,0 +1,71 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: DiagnosticInfo.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace ModernUO.Serialization.Generator; + +/// +/// A value-equatable stand-in for so pipeline models stay cacheable; +/// materialized back into a real diagnostic at output time. +/// +public sealed record DiagnosticInfo( + string Id, + string FilePath, + TextSpan Span, + LinePositionSpan LineSpan, + EquatableArray Args +) +{ + private static readonly Dictionary _descriptors = new() + { + ["SG3001"] = DiagnosticDescriptors.SG3001, + ["SG3002"] = DiagnosticDescriptors.SG3002, + ["SG3003"] = DiagnosticDescriptors.SG3003, + ["SG3004"] = DiagnosticDescriptors.SG3004, + ["SG3005"] = DiagnosticDescriptors.SG3005, + ["SG3006"] = DiagnosticDescriptors.SG3006, + ["SG3007"] = DiagnosticDescriptors.SG3007, + ["SG3008"] = DiagnosticDescriptors.SG3008, + ["SG3009"] = DiagnosticDescriptors.SG3009, + ["SG3010"] = DiagnosticDescriptors.SG3010, + ["SG3011"] = DiagnosticDescriptors.SG3011, + ["SG3012"] = DiagnosticDescriptors.SG3012, + ["SG3013"] = DiagnosticDescriptors.SG3013 + }; + + public static DiagnosticInfo Create(DiagnosticDescriptor descriptor, Location location, params object[] args) + { + var lineSpan = location.GetLineSpan(); + return new DiagnosticInfo( + descriptor.Id, + location.SourceTree?.FilePath ?? lineSpan.Path ?? "", + location.SourceSpan, + lineSpan.Span, + args.Select(a => a?.ToString() ?? "").ToEquatableArray() + ); + } + + public Diagnostic ToDiagnostic() => + Diagnostic.Create( + _descriptors[Id], + Location.Create(FilePath, Span, LineSpan), + Args.Cast().ToArray() + ); +} diff --git a/ModernUO.Serialization.Generator/EntitySerializationGenerator.cs b/ModernUO.Serialization.Generator/EntitySerializationGenerator.cs index 555d805..c4d6204 100755 --- a/ModernUO.Serialization.Generator/EntitySerializationGenerator.cs +++ b/ModernUO.Serialization.Generator/EntitySerializationGenerator.cs @@ -1,6 +1,6 @@ /************************************************************************* * ModernUO * - * Copyright 2019-2023 - ModernUO Development Team * + * Copyright 2019-2026 - ModernUO Development Team * * Email: hi@modernuo.com * * File: EntitySerializationGenerator.cs * * * @@ -8,9 +8,6 @@ * it under the terms of the GNU General Public License as published by * * the Free Software Foundation, either version 3 of the License, or * * (at your option) any later version. * - * * - * You should have received a copy of the GNU General Public License * - * along with this program. If not, see . * *************************************************************************/ using System; @@ -18,7 +15,9 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.IO; +using System.Linq; using System.Text; +using System.Text.Json; using System.Threading; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -29,7 +28,6 @@ namespace ModernUO.Serialization.Generator; [Generator] public class EntitySerializationGenerator(bool generateMigrations = false) : IIncrementalGenerator { - public EntitySerializationGenerator() : this(false) { } @@ -39,310 +37,320 @@ public EntitySerializationGenerator() : this(false) public void Initialize(IncrementalGeneratorInitializationContext context) { - // Gather all classes with [ModernUO.Serialization.SerializationGenerator] attribute + // Fully resolved, value-equatable models. The transform re-runs when the compilation + // changes, but an edit that does not affect the serialization surface produces an + // equal model and everything downstream stays cached. var serializableClasses = context .SyntaxProvider - .CreateSyntaxProvider( - IsSerializationGeneratorSyntaxNode, - GetSerializableClassAndProperties + .ForAttributeWithMetadataName( + SymbolMetadata.SERIALIZABLE_ATTRIBUTE, + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, token) => SerializableEntityGeneration.BuildSerializationModel(ctx, token) ) - .Where(t => t != null && (t.Value.Item1 != null || t.Value.Item2 != null)) .WithTrackingName("serializableClasses"); - // Gather all migration JSON files and organize them by file name (namespace/class) and version. + // Each migration file parses once and re-parses only when its content changes. var migrationFiles = context .AdditionalTextsProvider + .Where(static text => SerializableMigrationSchema.MatchMigrationFilename( + Path.GetFileName(text.Path), out _, out _) + ) + .Select(static (text, token) => ParseMigrationFile(text, token)) + .WithTrackingName("migrationFiles"); + + // Value-type facts need the compilation; this recomputes cheaply per compilation and + // produces equal output while the answers are unchanged, keeping downstream cached. + var augmentedMigrations = migrationFiles + .Combine(context.CompilationProvider) + .Select(static (pair, token) => AugmentMigrationFile(pair.Left, pair.Right, token)) + .WithTrackingName("augmentedMigrations"); + + var migrationMap = augmentedMigrations .Collect() - .Select(ToMigrationFileSet) - .WithTrackingName("migrations"); + .Select(static (files, token) => MigrationFileMap.Create(files, token)) + .WithTrackingName("migrationMap"); - // Combine the classes, migrations, and fields into a single set var classesWithMigrations = serializableClasses - .Combine(migrationFiles) - .Select(TransformToClassMigrationPairs) - .Combine(context.CompilationProvider); + .Combine(migrationMap) + .Select(static (pair, token) => AttachMigrations(pair.Left, pair.Right, token)) + .WithTrackingName("classesWithMigrations"); - // Generate source code context.RegisterSourceOutput(classesWithMigrations, ExecuteIncremental); } - public static bool IsSerializationGeneratorSyntaxNode(SyntaxNode node, CancellationToken token) + private static MigrationFileModel ParseMigrationFile(AdditionalText text, CancellationToken token) { token.ThrowIfCancellationRequested(); - var name = (node as AttributeSyntax)?.Name.ExtractName(); - return name is "SerializationGenerator" or "SerializationGeneratorAttribute"; + + SerializableMigrationSchema.MatchMigrationFilename( + Path.GetFileName(text.Path), out var className, out var version + ); + + var sourceText = text.GetText(token); + if (sourceText == null) + { + return new MigrationFileModel(className, version, text.Path, null, "file could not be read"); + } + + try + { + var metadata = JsonSerializer.Deserialize( + sourceText.ToString(), + SerializableMigrationSchema.GetJsonSerializerOptions() + ); + + return new MigrationFileModel(className, version, text.Path, metadata, null); + } + catch (Exception e) + { + return new MigrationFileModel(className, version, text.Path, null, e.Message); + } } - private static (SerializableClassRecord, Diagnostic[])? GetSerializableClassAndProperties( - GeneratorSyntaxContext ctx, + private static MigrationFileModel AugmentMigrationFile( + MigrationFileModel file, + Compilation compilation, CancellationToken token ) { token.ThrowIfCancellationRequested(); - var compilation = ctx.SemanticModel.Compilation; - - var syntaxNode = (AttributeSyntax)ctx.Node; - - if (syntaxNode.Parent?.Parent is not TypeDeclarationSyntax typeNode) + if (file.Metadata?.Properties is not { } properties) { - return null; + return file; } - if (!typeNode.IsPartial()) + var builder = ImmutableArray.CreateBuilder(properties.Length); + foreach (var property in properties) { - var typeName = compilation - .GetSemanticModel(typeNode.SyntaxTree) - .GetDeclaredSymbol(typeNode)? - .Name; - - var diagnostic = typeNode.GenerateDiagnostic(DiagnosticDescriptors.SG3001, typeName); - return (null, [diagnostic]); + builder.Add( + property with + { + TypeIsValueType = compilation.GetTypeByMetadataName(property.Type)?.IsValueType == true + } + ); } - var node = (TypeDeclarationSyntax)ctx.Node.Parent!.Parent!; - var classSymbol = ctx.SemanticModel.GetDeclaredSymbol(node) as INamedTypeSymbol; - - // This happens when there is no using import - if (!classSymbol.TryGetSerializable(compilation, out var serializationAttribute)) - { - var typeName = compilation - .GetSemanticModel(typeNode.SyntaxTree) - .GetDeclaredSymbol(typeNode)? - .Name; + return file with { Metadata = file.Metadata with { Properties = builder.MoveToImmutable() } }; + } - var diagnostic = typeNode.GenerateDiagnostic(DiagnosticDescriptors.SG3002, typeName); + private static FinalModel AttachMigrations( + SerializationModelResult result, + MigrationFileMap map, + CancellationToken token + ) + { + token.ThrowIfCancellationRequested(); - return (null, [diagnostic]); + var model = result.Model; + if (model == null) + { + return new FinalModel(result, EquatableArray.Empty, EquatableArray.Empty); } - // The generator emits Deserialize for value types; a user-declared one collides with it. - if (classSymbol!.IsValueType && classSymbol.HasDeserializationCapability(compilation, out _)) + var files = map.GetFiles(model.ArityName); + if (files.Count == 0) { - var typeName = classSymbol.Name; - var diagnostic = typeNode.GenerateDiagnostic(DiagnosticDescriptors.SG3009, typeName); - return (null, [diagnostic]); + return new FinalModel(result, EquatableArray.Empty, EquatableArray.Empty); } - var fields = ImmutableArray.CreateBuilder<(ISymbol, AttributeData)>(); - var properties = ImmutableArray.CreateBuilder<(ISymbol, AttributeData)>(); - var saveFlagMethods = ImmutableArray.CreateBuilder<(ISymbol, AttributeData)>(); - var defaultValueMethods = ImmutableArray.CreateBuilder<(ISymbol, AttributeData)>(); - var changedMethods = ImmutableArray.CreateBuilder<(ISymbol, AttributeData)>(); - ISymbol? dirtyTrackingEntity = null; - foreach (var m in node.Members) + var location = model.Location; + var diagnostics = new List(); + var byVersion = new Dictionary(); + + foreach (var file in files) { token.ThrowIfCancellationRequested(); - if (m is PropertyDeclarationSyntax propertyNode) + if (file.Error != null) { - if (ctx.SemanticModel.GetDeclaredSymbol(propertyNode) is IPropertySymbol propertySymbol) - { - if (propertySymbol.TryGetDirtyTrackingEntityField(compilation)) - { - dirtyTrackingEntity = propertySymbol; - } - else if (propertySymbol.TryGetSerializableProperty(compilation, out var attributeData)) - { - properties.Add((propertySymbol, attributeData)); - } - } + diagnostics.Add( + new DiagnosticInfo( + "SG3013", location.FilePath, location.Span, location.LineSpan, + new[] { file.FilePath, file.Error }.ToEquatableArray() + ) + ); + continue; } - else if (m is FieldDeclarationSyntax fieldNode) - { - foreach (var variable in fieldNode.Declaration.Variables) - { - token.ThrowIfCancellationRequested(); - if (ctx.SemanticModel.GetDeclaredSymbol(variable) is IFieldSymbol fieldSymbol) - { - if (fieldSymbol.TryGetDirtyTrackingEntityField(compilation)) - { - dirtyTrackingEntity = fieldSymbol; - } - else if (fieldSymbol.TryGetSerializableField(compilation, out var attributeData)) - { - fields.Add((fieldSymbol, attributeData)); - } - } - } + // Only use the first migration file for each version; the rest are reported (SG3011). + if (byVersion.ContainsKey(file.Version)) + { + diagnostics.Add( + new DiagnosticInfo( + "SG3011", location.FilePath, location.Span, location.LineSpan, + new[] { file.FilePath }.ToEquatableArray() + ) + ); + continue; } - else if (m is MethodDeclarationSyntax methodNode) + + byVersion[file.Version] = file; + + // The file at the current version is the schema record the migration tool + // maintains; anything beyond it is left over from a rolled-back version bump. + if (file.Version > model.Version) { - if (ctx.SemanticModel.GetDeclaredSymbol(methodNode) is IMethodSymbol methodSymbol) - { - if (methodSymbol.TryGetSerializableFieldSaveFlagMethod(compilation, out var attributeData)) - { - saveFlagMethods.Add((methodSymbol, attributeData)); - } - else if (methodSymbol.TryGetSerializableFieldDefaultMethod(compilation, out attributeData)) - { - defaultValueMethods.Add((methodSymbol, attributeData)); - } - else if (methodSymbol.TryGetSerializableFieldChangedMethod(compilation, out attributeData)) - { - changedMethods.Add((methodSymbol, attributeData)); - } - } + diagnostics.Add( + new DiagnosticInfo( + "SG3012", location.FilePath, location.Span, location.LineSpan, + new[] { file.Version.ToString(), model.Version.ToString() }.ToEquatableArray() + ) + ); } } - var record = new SerializableClassRecord( - typeNode, - classSymbol, - serializationAttribute, - fields.ToImmutable(), - properties.ToImmutable(), - saveFlagMethods.ToImmutable(), - defaultValueMethods.ToImmutable(), - changedMethods.ToImmutable(), - dirtyTrackingEntity, - ImmutableDictionary.Empty, - ImmutableArray.Empty - ); + var migrations = new List(); + for (var i = 0; i < model.Version; i++) + { + if (byVersion.TryGetValue(i, out var file) && file.Metadata != null) + { + migrations.Add(file.Metadata); + } + } - return (record, []); + return new FinalModel(result, migrations.ToEquatableArray(), diagnostics.ToEquatableArray()); } - private static (SerializableClassRecord, Diagnostic[]) TransformToClassMigrationPairs( - ((SerializableClassRecord, Diagnostic[])?, ImmutableDictionary) pair, - CancellationToken token - ) + private void ExecuteIncremental(SourceProductionContext context, FinalModel finalModel) { - token.ThrowIfCancellationRequested(); + context.CancellationToken.ThrowIfCancellationRequested(); - var (recordPair, additionalTexts) = pair; - if (!recordPair.HasValue) + foreach (var diagnostic in finalModel.Result.Diagnostics) { - return (null, []); + context.ReportDiagnostic(diagnostic.ToDiagnostic()); } - var (classRecord, diags) = recordPair.Value; + foreach (var diagnostic in finalModel.MigrationDiagnostics) + { + context.ReportDiagnostic(diagnostic.ToDiagnostic()); + } - if (classRecord != null) + var model = finalModel.Result.Model; + if (model == null) { - // Use arity notation for generic types to match migration file naming - var migrationKey = classRecord.ClassSymbol.GetGenericArityName(); - if (additionalTexts.TryGetValue(migrationKey, out var migs)) + return; + } + + try + { + var (classSource, migration) = SerializableEntityGeneration.GenerateFromModel( + model, + finalModel.Migrations.AsImmutableArray(), + generateMigrations, + context.CancellationToken + ); + + // Use arity notation for generic types to avoid invalid characters in filename + context.AddSource( + $"{model.ArityName}.Serialization.g.cs", + SourceText.From(classSource, Encoding.UTF8) + ); + + if (migration != null) { - classRecord = classRecord with - { - Migrations = migs.Files.ToImmutableDictionary(), - DuplicateMigrationFiles = migs.Duplicates?.ToImmutableArray() ?? ImmutableArray.Empty - }; + Migrations[migration.Type] = migration; } } - - return (classRecord, diags); + catch (Exception e) + { + var descriptor = DiagnosticDescriptors.GeneratorCrashedDiagnostic(e); + var diagnostic = Diagnostic.Create( + descriptor, + model.Location.ToLocation(), + e.GetType(), + model.ClassName, + e.Message, + e.StackTrace + ); + + context.ReportDiagnostic(diagnostic); + } } - internal sealed record MigrationFileSet(Dictionary Files) + /// + /// Migration files grouped by class name, with deep value equality so an unchanged set + /// keeps downstream nodes cached. + /// + public sealed class MigrationFileMap : IEquatable { - public List Duplicates { get; set; } - } + private static readonly List _empty = []; - private static ImmutableDictionary ToMigrationFileSet( - ImmutableArray additionalTexts, - CancellationToken token - ) - { - token.ThrowIfCancellationRequested(); + private readonly Dictionary> _files; - var builder = ImmutableDictionary.CreateBuilder(); + private MigrationFileMap(Dictionary> files) => _files = files; - foreach (var additionalText in additionalTexts) + public static MigrationFileMap Create(ImmutableArray files, CancellationToken token) { - token.ThrowIfCancellationRequested(); + var map = new Dictionary>(); - var path = additionalText.Path; - var fileName = Path.GetFileName(path); - if (!SerializableMigrationSchema.MatchMigrationFilename(fileName, out var className, out var version)) + foreach (var file in files) { - continue; - } + token.ThrowIfCancellationRequested(); - if (!builder.TryGetValue(className, out var classMigrationSet)) - { - builder[className] = classMigrationSet = new MigrationFileSet(new Dictionary()); - } + if (!map.TryGetValue(file.ClassName, out var list)) + { + map[file.ClassName] = list = []; + } - // Only use the first migration file for each version; the rest are reported (SG3011). - if (!classMigrationSet.Files.ContainsKey(version)) - { - classMigrationSet.Files[version] = additionalText; - } - else - { - (classMigrationSet.Duplicates ??= []).Add(path); + list.Add(file); } + + return new MigrationFileMap(map); } - return builder.ToImmutable(); - } + public List GetFiles(string className) => + _files.TryGetValue(className, out var list) ? list : _empty; - private void ExecuteIncremental( - SourceProductionContext context, - ((SerializableClassRecord, Diagnostic[]), Compilation) combined - ) - { - context.CancellationToken.ThrowIfCancellationRequested(); - var ((classRecord, prereqFailures), compilation) = combined; - - if (prereqFailures.Length > 0) + public bool Equals(MigrationFileMap other) { - for (var i = 0; i < prereqFailures.Length; i++) + if (other is null) { - context.ReportDiagnostic(prereqFailures[i]); + return false; } - return; - } - - if (classRecord != null) - { - var jsonOptions = SerializableMigrationSchema.GetJsonSerializerOptions(); + if (ReferenceEquals(this, other)) + { + return true; + } - try + if (_files.Count != other._files.Count) { - var (classSource, migration, diags) = compilation.GenerateSerializationPartialClass( - classRecord, - jsonOptions, - generateMigrations, - context.CancellationToken - ); + return false; + } - // Warnings are produced alongside successful generation; report either way. - for (var i = 0; i < (diags?.Length ?? 0); i++) + foreach (var kvp in _files) + { + if (!other._files.TryGetValue(kvp.Key, out var otherList) || kvp.Value.Count != otherList.Count) { - context.ReportDiagnostic(diags[i]); + return false; } - if (classSource != null) + for (var i = 0; i < kvp.Value.Count; i++) { - // Use arity notation for generic types to avoid invalid characters in filename - context.AddSource( - $"{classRecord.ClassSymbol.GetGenericArityName()}.Serialization.g.cs", - SourceText.From(classSource, Encoding.UTF8) - ); - - if (migration != null) + if (!kvp.Value[i].Equals(otherList[i])) { - Migrations[migration.Type] = migration; + return false; } } } - catch (Exception e) + + return true; + } + + public override bool Equals(object obj) => obj is MigrationFileMap other && Equals(other); + + public override int GetHashCode() + { + unchecked { - var descriptor = DiagnosticDescriptors.GeneratorCrashedDiagnostic(e); - var diagnostic = classRecord.TypeNode.GenerateDiagnostic( - descriptor, - e.GetType(), - classRecord.ClassSymbol.Name, - e.Message, - e.StackTrace - ); + var hash = _files.Count; + foreach (var kvp in _files) + { + hash ^= kvp.Key.GetHashCode() * 31 + kvp.Value.Count; + } - context.ReportDiagnostic(diagnostic); + return hash; } } } diff --git a/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableClassRecord.cs b/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableClassRecord.cs deleted file mode 100644 index b7f0c64..0000000 --- a/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableClassRecord.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Collections.Immutable; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace ModernUO.Serialization.Generator; - -public record SerializableClassRecord( - TypeDeclarationSyntax TypeNode, - INamedTypeSymbol ClassSymbol, - AttributeData SerializationAttribute, - ImmutableArray<(ISymbol, AttributeData)> SerializableFields, - ImmutableArray<(ISymbol, AttributeData)> SerializableProperties, - ImmutableArray<(ISymbol, AttributeData)> SerializableFieldSaveFlags, - ImmutableArray<(ISymbol, AttributeData)> SerializableFieldDefault, - ImmutableArray<(ISymbol, AttributeData)> SerializableFieldChanged, - ISymbol? DirtyTrackingEntity, - ImmutableDictionary Migrations, - ImmutableArray DuplicateMigrationFiles -) -{ - public bool IsValueType => ClassSymbol.IsValueType; -}; diff --git a/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.BuildModel.cs b/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.BuildModel.cs new file mode 100644 index 0000000..15c2a22 --- /dev/null +++ b/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.BuildModel.cs @@ -0,0 +1,572 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: SerializableEntityGeneration.BuildModel.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace ModernUO.Serialization.Generator; + +public static partial class SerializableEntityGeneration +{ + /// + /// Resolves every symbol- and compilation-dependent fact code generation needs into a + /// value-equatable . Runs in the incremental transform; + /// an edit that does not change the serialization surface produces an equal model and + /// nothing downstream re-runs. + /// + public static SerializationModelResult BuildSerializationModel( + GeneratorAttributeSyntaxContext ctx, + CancellationToken token + ) + { + token.ThrowIfCancellationRequested(); + + var typeNode = (TypeDeclarationSyntax)ctx.TargetNode; + var classSymbol = (INamedTypeSymbol)ctx.TargetSymbol; + var compilation = ctx.SemanticModel.Compilation; + var serializableAttr = ctx.Attributes[0]; + var location = LocationInfo.Create(typeNode.GetLocation()); + + SerializationModelResult Fail(DiagnosticDescriptor descriptor, params object[] args) => + new(null, new[] { DiagnosticInfo.Create(descriptor, typeNode.GetLocation(), args) }.ToEquatableArray()); + + if (!typeNode.IsPartial()) + { + return Fail(DiagnosticDescriptors.SG3001, classSymbol.Name); + } + + // The generator emits Deserialize for value types; a user-declared one collides with it. + if (classSymbol.IsValueType && classSymbol.HasDeserializationCapability(compilation, out _)) + { + return Fail(DiagnosticDescriptors.SG3009, classSymbol.Name); + } + + // Gather annotated members from the attributed declaration. + var fields = new List<(ISymbol, AttributeData)>(); + var properties = new List<(ISymbol, AttributeData)>(); + var saveFlagMethods = new List<(ISymbol, AttributeData)>(); + var defaultMethods = new List<(ISymbol, AttributeData)>(); + var changedMethods = new List<(ISymbol, AttributeData)>(); + ISymbol? dirtyTrackingEntity = null; + + foreach (var m in typeNode.Members) + { + token.ThrowIfCancellationRequested(); + + if (m is PropertyDeclarationSyntax propertyNode) + { + if (ctx.SemanticModel.GetDeclaredSymbol(propertyNode) is IPropertySymbol propertySymbol) + { + if (propertySymbol.TryGetDirtyTrackingEntityField(compilation)) + { + dirtyTrackingEntity = propertySymbol; + } + else if (propertySymbol.TryGetSerializableProperty(compilation, out var attributeData)) + { + properties.Add((propertySymbol, attributeData)); + } + } + } + else if (m is FieldDeclarationSyntax fieldNode) + { + foreach (var variable in fieldNode.Declaration.Variables) + { + token.ThrowIfCancellationRequested(); + + if (ctx.SemanticModel.GetDeclaredSymbol(variable) is IFieldSymbol fieldSymbol) + { + if (fieldSymbol.TryGetDirtyTrackingEntityField(compilation)) + { + dirtyTrackingEntity = fieldSymbol; + } + else if (fieldSymbol.TryGetSerializableField(compilation, out var attributeData)) + { + fields.Add((fieldSymbol, attributeData)); + } + } + } + } + else if (m is MethodDeclarationSyntax methodNode) + { + if (ctx.SemanticModel.GetDeclaredSymbol(methodNode) is IMethodSymbol methodSymbol) + { + if (methodSymbol.TryGetSerializableFieldSaveFlagMethod(compilation, out var attributeData)) + { + saveFlagMethods.Add((methodSymbol, attributeData)); + } + else if (methodSymbol.TryGetSerializableFieldDefaultMethod(compilation, out attributeData)) + { + defaultMethods.Add((methodSymbol, attributeData)); + } + else if (methodSymbol.TryGetSerializableFieldChangedMethod(compilation, out attributeData)) + { + changedMethods.Add((methodSymbol, attributeData)); + } + } + } + } + + var isValueType = classSymbol.IsValueType; + var isOverride = classSymbol.BaseType.HasSerializableInterface(compilation); + var isSerializable = classSymbol.HasSerializableInterface(compilation); + + var version = (int)serializableAttr.ConstructorArguments[0].Value!; + var encodedVersion = (bool)serializableAttr.ConstructorArguments[1].Value!; + + // Save flag / default value methods, keyed by field order. + var serializableFieldSaveFlags = new SortedDictionary(); + foreach (var (symbol, attrData) in saveFlagMethods) + { + var order = (int)attrData.ConstructorArguments[0].Value!; + + if (order < 0) + { + return Fail(DiagnosticDescriptors.SG3006, SymbolMetadata.SERIALIZABLE_FIELD_SAVE_FLAG_ATTRIBUTE, symbol.Name); + } + + if (serializableFieldSaveFlags.ContainsKey(order)) + { + return Fail(DiagnosticDescriptors.SG3003, SymbolMetadata.SERIALIZABLE_FIELD_SAVE_FLAG_ATTRIBUTE, order); + } + + serializableFieldSaveFlags[order] = new SerializableFieldSaveFlagMethods + { + DetermineFieldShouldSerialize = (IMethodSymbol)symbol + }; + } + + foreach (var (symbol, attrData) in defaultMethods) + { + var order = (int)attrData.ConstructorArguments[0].Value!; + + if (order < 0) + { + return Fail(DiagnosticDescriptors.SG3006, SymbolMetadata.SERIALIZABLE_FIELD_DEFAULT_ATTRIBUTE, symbol.Name); + } + + // No save flag, so we ignore the default value + if (!serializableFieldSaveFlags.TryGetValue(order, out var methods)) + { + continue; + } + + if (methods.GetFieldDefaultValue != null) + { + return Fail(DiagnosticDescriptors.SG3003, SymbolMetadata.SERIALIZABLE_FIELD_DEFAULT_ATTRIBUTE, order); + } + + serializableFieldSaveFlags[order] = methods with { GetFieldDefaultValue = (IMethodSymbol)symbol }; + } + + // Dirty tracking / MarkDirty resolution. + var parentTypeHasEntityTracking = false; + if (!isSerializable && dirtyTrackingEntity == null) + { + dirtyTrackingEntity = classSymbol.BaseType?.HasDirtyTrackingEntity(compilation); + if (dirtyTrackingEntity != null) + { + parentTypeHasEntityTracking = true; + } + } + + var dirtyTrackingEntityNull = + dirtyTrackingEntity?.GetAttributes().Any(a => a.IsCanBeNull(compilation)) ?? false; + + var emitMarkDirtyMethod = false; + string? markDirtyBody = null; + string? markDirtyMethod; + + if (dirtyTrackingEntity != null) + { + if (!parentTypeHasEntityTracking) + { + emitMarkDirtyMethod = true; + + var dirtyTrackingType = (dirtyTrackingEntity as IFieldSymbol)?.Type ?? + (dirtyTrackingEntity as IPropertySymbol)?.Type; + + markDirtyBody = dirtyTrackingType?.GetMarkDirtyMethod( + dirtyTrackingEntity: dirtyTrackingEntity.Name, + isSerializable: dirtyTrackingType?.HasSerializableInterface(compilation) ?? false, + dirtyCanBeNull: dirtyTrackingEntityNull + ); + } + + markDirtyMethod = "MarkDirty()"; + } + else if (isSerializable) + { + markDirtyMethod = classSymbol.GetMarkDirtyMethod(); + } + else + { + markDirtyMethod = null; + } + + // SerializableFieldChanged methods, keyed by order. + var serializableFieldChangedMethods = new Dictionary(); + foreach (var (symbol, attrData) in changedMethods) + { + var order = (int)attrData.ConstructorArguments[0].Value!; + + if (order < 0) + { + return Fail(DiagnosticDescriptors.SG3006, SymbolMetadata.SERIALIZABLE_FIELD_CHANGED_ATTRIBUTE, symbol.Name); + } + + if (serializableFieldChangedMethods.ContainsKey(order)) + { + return Fail(DiagnosticDescriptors.SG3003, SymbolMetadata.SERIALIZABLE_FIELD_CHANGED_ATTRIBUTE, order); + } + + serializableFieldChangedMethods[order] = (IMethodSymbol)symbol; + } + + var serializableFieldSet = new SortedSet(new SerializablePropertyComparer()); + var backingFields = new List(); + + foreach (var (symbol, attributeData) in properties) + { + var attrCtorArgs = attributeData.ConstructorArguments; + + var order = (int)attrCtorArgs[0].Value!; + + if (order < 0) + { + return Fail(DiagnosticDescriptors.SG3006, SymbolMetadata.SERIALIZABLE_PROPERTY_ATTRIBUTE, symbol.Name); + } + + var useField = (string)attrCtorArgs[1].Value!; + + if (symbol is IPropertySymbol propertySymbol) + { + var createField = string.IsNullOrWhiteSpace(useField); + var fieldName = createField ? propertySymbol.Name.GetFieldName() : useField; + var fieldType = propertySymbol.Type; + + // useField was not specified, so we are creating the field + if (createField) + { + backingFields.Add(new BackingFieldModel(fieldType.ToDisplayString(), fieldName)); + } + else + { + if (classSymbol.GetMembers(fieldName) + .FirstOrDefault(member => member is IFieldSymbol) is not IFieldSymbol fieldMember) + { + return Fail(DiagnosticDescriptors.SG3004, fieldName, order); + } + + fieldType = fieldMember.Type; + } + + serializableFieldSaveFlags.TryGetValue(order, out var saveFlagMethodsForOrder); + + try + { + var serializableProperty = SerializableMigrationRulesEngine.GenerateSerializableProperty( + compilation, + propertySymbol.Name, + fieldType, + order, + propertySymbol.GetAttributes(), + classSymbol, + saveFlagMethodsForOrder + ) with { + FieldName = fieldName + }; + + if (!serializableFieldSet.Add(serializableProperty)) + { + return Fail(DiagnosticDescriptors.SG3003, SymbolMetadata.SERIALIZABLE_PROPERTY_ATTRIBUTE, order); + } + } + catch (NoRuleFoundException e) + { + return Fail(DiagnosticDescriptors.SG3007, e.PropertyName, e.PropertyType); + } + } + } + + var fieldEmissions = new List(); + + foreach (var (symbol, attributeData) in fields) + { + token.ThrowIfCancellationRequested(); + + var allAttributes = symbol.GetAttributes(); + + var attributeLines = new List(); + foreach (var attr in allAttributes) + { + token.ThrowIfCancellationRequested(); + + if (attr.AttributeClass == null) + { + continue; + } + + if (!attr.IsSerializedPropertyAttr(compilation, out var serializedPropertyAttrType)) + { + continue; + } + + var attrType = serializedPropertyAttrType.ToDisplayString(); + + if (attr.ConstructorArguments.Length == 0) + { + attributeLines.Add($"[{attrType}]"); + } + else + { + var attrSource = new StringBuilder(); + attrSource.GenerateAttribute("", attrType, attr.ConstructorArguments); + attributeLines.Add(attrSource.ToString().TrimEnd('\r', '\n')); + } + } + + var attrCtorArgs = attributeData.ConstructorArguments; + + var order = (int)attrCtorArgs[0].Value!; + + if (order < 0) + { + return Fail(DiagnosticDescriptors.SG3006, SymbolMetadata.SERIALIZABLE_FIELD_ATTRIBUTE, symbol.Name); + } + + var getterAccessor = Helpers.GetAccessibility(attrCtorArgs[1].Value?.ToString()); + var setterAccessor = Helpers.GetAccessibility(attrCtorArgs[2].Value?.ToString()); + var virtualProperty = (bool)attrCtorArgs[3].Value!; + + if (symbol is IFieldSymbol fieldSymbol) + { + // Readonly fields cannot have setters - force to null + var effectiveSetterAccessor = fieldSymbol.IsReadOnly ? (Accessibility?)null : setterAccessor; + + serializableFieldChangedMethods.TryGetValue(order, out var fieldChangedMethod); + + if (fieldChangedMethod != null) + { + var fieldType = fieldSymbol.Type; + var isValidSignature = fieldChangedMethod.ReturnsVoid && + fieldChangedMethod.Parameters.Length == 2 && + SymbolEqualityComparer.Default.Equals(fieldChangedMethod.Parameters[0].Type, fieldType) && + SymbolEqualityComparer.Default.Equals(fieldChangedMethod.Parameters[1].Type, fieldType); + + if (!isValidSignature) + { + return Fail(DiagnosticDescriptors.SG3010, fieldChangedMethod.Name, fieldType.ToDisplayString()); + } + } + + var invalidateProperties = allAttributes.Any( + attr => attr.AttributeClass?.Equals( + compilation.GetTypeByMetadataName(SymbolMetadata.INVALIDATE_PROPERTIES_ATTRIBUTE), + SymbolEqualityComparer.Default + ) ?? false + ); + + // Data structure method facts. + var propertyType = fieldSymbol.Type; + var namedTypeSymbol = propertyType as INamedTypeSymbol; + var elementType = (propertyType as IArrayTypeSymbol)?.ElementType ?? + (namedTypeSymbol?.TypeArguments.Length > 0 ? namedTypeSymbol.TypeArguments[0] : null); + + var dsIsArray = false; + var dsIsDictionary = false; + var dsIsList = false; + var dsIsCollection = false; + string? dsElementType = null; + string? dsValueType = null; + + if (!fieldSymbol.IsReadOnly && elementType != null) + { + dsIsArray = propertyType is IArrayTypeSymbol; + dsIsDictionary = propertyType.IsDictionaryInterface(compilation); + dsIsList = propertyType.IsListInterface(compilation); + dsIsCollection = propertyType.IsCollection(compilation); + dsElementType = elementType.ToString(); + dsValueType = dsIsDictionary ? namedTypeSymbol!.TypeArguments[1].ToString() : null; + } + + fieldEmissions.Add( + new FieldPropertyModel( + order, + fieldSymbol.Name, + fieldSymbol.Type.ToString(), + fieldSymbol.Name.GetPropertyName(), + getterAccessor, + effectiveSetterAccessor, + fieldSymbol.IsReadOnly, + virtualProperty, + fieldSymbol.Type.HasInequalityOperator(), + invalidateProperties, + fieldChangedMethod?.Name, + attributeLines.ToEquatableArray(), + dsIsArray, + dsIsDictionary, + dsIsList, + dsIsCollection, + dsElementType, + dsValueType + ) + ); + + serializableFieldSaveFlags.TryGetValue(order, out var saveFlagMethodsForOrder); + + try + { + var serializableProperty = SerializableMigrationRulesEngine.GenerateSerializableProperty( + compilation, + fieldSymbol.Name.GetPropertyName(), + fieldSymbol.Type, + order, + allAttributes, + classSymbol, + saveFlagMethodsForOrder + ) with { + FieldName = fieldSymbol.Name, + IsReadOnly = fieldSymbol.IsReadOnly + }; + + if (!serializableFieldSet.Add(serializableProperty)) + { + return Fail(DiagnosticDescriptors.SG3003, SymbolMetadata.SERIALIZABLE_FIELD_ATTRIBUTE, order); + } + } + catch (NoRuleFoundException e) + { + return Fail(DiagnosticDescriptors.SG3007, e.PropertyName, e.PropertyType); + } + } + } + + var serializableFields = serializableFieldSet.ToImmutableArray(); + for (var i = 0; i < serializableFields.Length; i++) + { + token.ThrowIfCancellationRequested(); + + // They are out of order! (missing a number) + var order = serializableFields[i].Order; + if (order != i) + { + return Fail(DiagnosticDescriptors.SG3005, serializableFields[i].Name, i, order); + } + } + + // Timer fields need a [DeserializeTimerField] method to rebuild the timer on load. + var timerFields = new List(); + foreach (var field in serializableFields) + { + if (field.Rule != nameof(TimerMigrationRule)) + { + continue; + } + + var timerMethod = classSymbol.GetDeserializeTimerMethod(compilation, field.Order); + if (timerMethod == null) + { + return Fail(DiagnosticDescriptors.SG3008, field.Name); + } + + timerFields.Add(new TimerFieldModel(field.Order, timerMethod.Name)); + } + + // AfterDeserialization callbacks, in member order. + var afterDeserialization = new List(); + foreach (var member in classSymbol.GetMembers()) + { + if (member is not IMethodSymbol { ReturnsVoid: true, Parameters.Length: 0 } method) + { + continue; + } + + var attr = method.GetAttributes() + .FirstOrDefault( + a => SymbolEqualityComparer.Default.Equals( + a.AttributeClass, + compilation.GetTypeByMetadataName(SymbolMetadata.AFTER_DESERIALIZATION_ATTRIBUTE) + ) + ); + + if (attr != null) + { + afterDeserialization.Add(new AfterDeserializeModel(method.Name, (bool)attr.ConstructorArguments[0].Value!)); + } + } + + // Save flag method names, in order. + var saveFlagModels = serializableFieldSaveFlags + .Select( + kvp => new SaveFlagModel( + kvp.Key, + kvp.Value.DetermineFieldShouldSerialize!.Name, + kvp.Value.GetFieldDefaultValue?.Name + ) + ) + .ToList(); + + // The nesting chain, outermost first. + var shells = new List(); + var containing = classSymbol; + while (containing != null) + { + shells.Add( + new TypeShellModel( + containing.DeclaredAccessibility.ToFriendlyString(), + containing.GetClassNameWithTypeParameters(), + containing.GetWhereClausesForTypeParameters() + ) + ); + containing = containing.ContainingSymbol as INamedTypeSymbol; + } + + shells.Reverse(); + + var model = new SerializationModel( + classSymbol.ContainingNamespace.ToDisplayString(), + classSymbol.Name, + classSymbol.ToDisplayString(), + classSymbol.GetGenericArityName(), + typeNode.GetTypeKeyword(), + isValueType, + version, + encodedVersion, + isOverride, + isOverride || classSymbol.BaseType.IsSerializableRecursive(compilation), + isSerializable, + classSymbol.HasSerialCtor(compilation), + emitMarkDirtyMethod, + markDirtyBody, + markDirtyMethod, + dirtyTrackingEntity?.Name ?? "this", + shells.ToEquatableArray(), + backingFields.ToEquatableArray(), + serializableFields.ToEquatableArray(), + fieldEmissions.ToEquatableArray(), + saveFlagModels.ToEquatableArray(), + afterDeserialization.ToEquatableArray(), + timerFields.ToEquatableArray(), + location + ); + + return new SerializationModelResult(model, EquatableArray.Empty); + } +} diff --git a/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.Class.cs b/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.Class.cs index a4c7e67..2c06ebb 100644 --- a/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.Class.cs +++ b/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.Class.cs @@ -13,14 +13,10 @@ * along with this program. If not, see . * *************************************************************************/ -using System; -using System.Buffers; using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; using System.Reflection; using System.Text; -using System.Text.Json; using System.Threading; using Microsoft.CodeAnalysis; @@ -30,100 +26,20 @@ public static partial class SerializableEntityGeneration { public static readonly string Version = Assembly.GetExecutingAssembly().GetName().Version.ToString(); - public static (string?, SerializableMetadata, Diagnostic[]) GenerateSerializationPartialClass( - this Compilation compilation, - SerializableClassRecord classRecord, - JsonSerializerOptions? jsonSerializerOptions, + /// + /// Emits the serialization partial class from a fully resolved model. Symbol-free by + /// design: everything here must come from the model so the output stage can be cached. + /// + public static (string Source, SerializableMetadata Migration) GenerateFromModel( + SerializationModel model, + ImmutableArray migrations, bool generateMetadata, CancellationToken token ) { token.ThrowIfCancellationRequested(); - var ( - typeNode, - classSymbol, - serializableAttr, - fields, - properties, - saveFlagMethods, - defaultMethods, - changedMethods, - dirtyTrackingEntity, - migrations, - duplicateMigrationFiles - ) = classRecord; - - var typeKeyword = typeNode.GetTypeKeyword(); - var isValueType = classRecord.IsValueType; - - // If we have a parent that is or derives from ISerializable, then we are in override - var isOverride = classSymbol.BaseType.HasSerializableInterface(compilation); - var isSerializable = classSymbol.HasSerializableInterface(compilation); - - var version = (int)serializableAttr.ConstructorArguments[0].Value!; - var encodedVersion = (bool)serializableAttr.ConstructorArguments[1].Value!; - - // Let's find out if we need to do serialization flags - var serializableFieldSaveFlags = new SortedDictionary(); - for (var i = 0; i < saveFlagMethods.Length; i++) - { - var (symbol, attrData) = saveFlagMethods[i]; - var order = (int)attrData.ConstructorArguments[0].Value!; - - if (order < 0) - { - var diag = typeNode.GenerateDiagnostic(DiagnosticDescriptors.SG3006, SymbolMetadata.SERIALIZABLE_FIELD_SAVE_FLAG_ATTRIBUTE, symbol.Name); - return (null, null, [diag]); - } - - // Duplicate found, failure. - if (serializableFieldSaveFlags.TryGetValue(order, out _)) - { - var diag = typeNode.GenerateDiagnostic(DiagnosticDescriptors.SG3003, SymbolMetadata.SERIALIZABLE_FIELD_SAVE_FLAG_ATTRIBUTE, order); - return (null, null, [diag]); - } - - serializableFieldSaveFlags[order] = new SerializableFieldSaveFlagMethods - { - DetermineFieldShouldSerialize = (IMethodSymbol)symbol - }; - } - - for (var i = 0; i < defaultMethods.Length; i++) - { - var (symbol, attrData) = defaultMethods[i]; - var order = (int)attrData.ConstructorArguments[0].Value!; - - if (order < 0) - { - var diag = typeNode.GenerateDiagnostic(DiagnosticDescriptors.SG3006, SymbolMetadata.SERIALIZABLE_FIELD_DEFAULT_ATTRIBUTE, symbol.Name); - return (null, null, [diag]); - } - - // No save flag, so we ignore the default value - if (!serializableFieldSaveFlags.TryGetValue(order, out var serializableFieldSaveFlagMethods)) - { - continue; - } - - // Duplicate found, failure. - if (serializableFieldSaveFlagMethods.GetFieldDefaultValue != null) - { - var diag = typeNode.GenerateDiagnostic(DiagnosticDescriptors.SG3003, SymbolMetadata.SERIALIZABLE_FIELD_DEFAULT_ATTRIBUTE, order); - return (null, null, [diag]); - } - - serializableFieldSaveFlags[order] = serializableFieldSaveFlagMethods with - { - GetFieldDefaultValue = (IMethodSymbol)symbol - }; - } - - var namespaceName = classSymbol.ContainingNamespace.ToDisplayString(); - var className = classSymbol.Name; - - StringBuilder source = new StringBuilder(); + var source = new StringBuilder(); source.AppendLine($""" // @@ -137,11 +53,26 @@ CancellationToken token """); source.AppendLine("#pragma warning disable\n"); - source.GenerateNamespaceStart(namespaceName); + source.GenerateNamespaceStart(model.NamespaceName); var indent = " "; - source.RecursiveGenerateClassStart(classSymbol, ImmutableArray.Empty, ref indent, typeKeyword); + // Class declarations, outermost first; only the innermost gets the attribute and keyword. + for (var i = 0; i < model.TypeShells.Length; i++) + { + var shell = model.TypeShells[i]; + var last = i == model.TypeShells.Length - 1; + + if (last) + { + source.AppendLine($"{indent}[System.CodeDom.Compiler.GeneratedCode(\"ModernUO.Serialization.Generator\", \"{Version}\")]"); + } + + var currentTypeKeyword = last ? model.TypeKeyword : "class"; + source.Append($"{indent}{shell.Accessibility} partial {currentTypeKeyword} {shell.NameWithTypeParameters}"); + source.AppendLine($"{shell.WhereClauses}\n{indent}{{"); + indent += " "; + } source.GenerateField( indent, @@ -149,387 +80,99 @@ CancellationToken token InstanceModifier.Const, "int", "SerializationVersion", - version.ToString() + model.Version.ToString() ); source.AppendLine(); - var parentTypeHasEntityTracking = false; - if (!isSerializable && dirtyTrackingEntity == null) - { - dirtyTrackingEntity = classSymbol.BaseType?.HasDirtyTrackingEntity(compilation); - if (dirtyTrackingEntity != null) - { - parentTypeHasEntityTracking = true; - } - } - - var dirtyTrackingEntityNull = dirtyTrackingEntity?.GetAttributes().Any(a => a.IsCanBeNull(compilation)) ?? false; - string markDirtyMethod; - - - if (dirtyTrackingEntity != null) + if (model.EmitMarkDirtyMethod) { - if (!parentTypeHasEntityTracking) - { - source.GenerateMethodStart( - indent, - "MarkDirty", - Accessibility.Public, - false, - "void", - ImmutableArray<(ITypeSymbol, string)>.Empty - ); - - var dirtyTrackingType = (dirtyTrackingEntity as IFieldSymbol)?.Type ?? - (dirtyTrackingEntity as IPropertySymbol)?.Type; - - var dirtyTrackingDirtyMethod = - dirtyTrackingType?.GetMarkDirtyMethod( - dirtyTrackingEntity: dirtyTrackingEntity.Name, - isSerializable: dirtyTrackingType?.HasSerializableInterface(compilation) ?? false, - dirtyCanBeNull: dirtyTrackingEntityNull - ); - - source.AppendLine($"{indent} {dirtyTrackingDirtyMethod};"); - - source.GenerateMethodEnd(indent); - source.AppendLine(); - } - - markDirtyMethod = "MarkDirty()"; - } - else if (isSerializable) - { - markDirtyMethod = classSymbol.GetMarkDirtyMethod(); - } - else - { - markDirtyMethod = null; - } - - // Process SerializableFieldChanged methods into dictionary by order - var serializableFieldChangedMethods = new Dictionary(); - for (var i = 0; i < changedMethods.Length; i++) - { - var (symbol, attrData) = changedMethods[i]; - var order = (int)attrData.ConstructorArguments[0].Value!; - - if (order < 0) - { - var diag = typeNode.GenerateDiagnostic(DiagnosticDescriptors.SG3006, SymbolMetadata.SERIALIZABLE_FIELD_CHANGED_ATTRIBUTE, symbol.Name); - return (null, null, [diag]); - } + source.GenerateMethodStart( + indent, + "MarkDirty", + Accessibility.Public, + false, + "void", + ImmutableArray<(string, string)>.Empty + ); - // Duplicate found, failure. - if (serializableFieldChangedMethods.ContainsKey(order)) - { - var diag = typeNode.GenerateDiagnostic(DiagnosticDescriptors.SG3003, SymbolMetadata.SERIALIZABLE_FIELD_CHANGED_ATTRIBUTE, order); - return (null, null, [diag]); - } + source.AppendLine($"{indent} {model.MarkDirtyBody};"); - serializableFieldChangedMethods[order] = (IMethodSymbol)symbol; + source.GenerateMethodEnd(indent); + source.AppendLine(); } - var serializableFieldSet = new SortedSet(new SerializablePropertyComparer()); - - foreach (var (symbol, attributeData) in properties) + foreach (var backingField in model.BackingFields) { - var attrCtorArgs = attributeData.ConstructorArguments; - - var order = (int)attrCtorArgs[0].Value!; - - if (order < 0) - { - var diag = typeNode.GenerateDiagnostic(DiagnosticDescriptors.SG3006, SymbolMetadata.SERIALIZABLE_PROPERTY_ATTRIBUTE, symbol.Name); - return (null, null, [diag]); - } - - var useField = (string)attrCtorArgs[1].Value!; - - if (symbol is IPropertySymbol propertySymbol) - { - var createField = string.IsNullOrWhiteSpace(useField); - var fieldName = createField ? propertySymbol.Name.GetFieldName() : useField; - var fieldType = propertySymbol.Type; - - // useField was not specified, so we are creating the field - if (createField) - { - source.GenerateField( - indent, - Accessibility.Private, - InstanceModifier.None, - fieldType.ToDisplayString(), - fieldName - ); - source.AppendLine(); - } - else - { - // find the member - if (classSymbol.GetMembers(fieldName) - .FirstOrDefault(member => member is IFieldSymbol) is not IFieldSymbol fieldMember) - { - var diag = typeNode.GenerateDiagnostic(DiagnosticDescriptors.SG3004, fieldName, order); - return (null, null, [diag]); - } - - fieldType = fieldMember.Type; - } - - serializableFieldSaveFlags.TryGetValue(order, out var serializableFieldSaveFlagMethods); - - try - { - var serializableProperty = SerializableMigrationRulesEngine.GenerateSerializableProperty( - compilation, - propertySymbol.Name, - fieldType, - order, - propertySymbol.GetAttributes(), - classSymbol, - serializableFieldSaveFlagMethods - ) with { - FieldName = fieldName - }; - - // We can't continue if we have duplicates. - if (!serializableFieldSet.Add(serializableProperty)) - { - var diag = typeNode.GenerateDiagnostic(DiagnosticDescriptors.SG3003, SymbolMetadata.SERIALIZABLE_PROPERTY_ATTRIBUTE, order); - return (null, null, [diag]); - } - } - catch (NoRuleFoundException e) - { - var diag = typeNode.GenerateDiagnostic(DiagnosticDescriptors.SG3007, e.PropertyName, e.PropertyType); - return (null, null, [diag]); - } - } + source.GenerateField( + indent, + Accessibility.Private, + InstanceModifier.None, + backingField.TypeDisplay, + backingField.FieldName + ); + source.AppendLine(); } - foreach (var (symbol, attributeData) in fields) + foreach (var field in model.FieldEmissions) { token.ThrowIfCancellationRequested(); - var allAttributes = symbol.GetAttributes(); - - foreach (var attr in allAttributes) - { - token.ThrowIfCancellationRequested(); - - if (attr.AttributeClass == null) - { - continue; - } - - if (!attr.IsSerializedPropertyAttr(compilation, out var serializedPropertyAttrType)) - { - continue; - } - - var attrType = serializedPropertyAttrType.ToDisplayString(); - - if (attr.ConstructorArguments.Length == 0) - { - source.AppendLine($"{indent}[{attrType}]"); - } - else - { - source.GenerateAttribute(indent, attrType, attr.ConstructorArguments); - } - } - - var attrCtorArgs = attributeData.ConstructorArguments; - - var order = (int)attrCtorArgs[0].Value!; - - if (order < 0) + foreach (var attributeLine in field.AttributeLines) { - var diag = typeNode.GenerateDiagnostic(DiagnosticDescriptors.SG3006, SymbolMetadata.SERIALIZABLE_FIELD_ATTRIBUTE, symbol.Name); - return (null, null, [diag]); + source.AppendLine($"{indent}{attributeLine}"); } - var getterAccessor = Helpers.GetAccessibility(attrCtorArgs[1].Value?.ToString()); - var setterAccessor = Helpers.GetAccessibility(attrCtorArgs[2].Value?.ToString()); - var virtualProperty = (bool)attrCtorArgs[3].Value!; + source.GenerateSerializableProperty(indent, field, model.MarkDirtyMethod); + source.AppendLine(); - if (symbol is IFieldSymbol fieldSymbol) + // Skip data structure methods for readonly fields (they cannot be modified) + var generatedDataStructureMethods = false; + if (!field.IsReadOnly) { - // Readonly fields cannot have setters - force to null - var effectiveSetterAccessor = fieldSymbol.IsReadOnly ? (Accessibility?)null : setterAccessor; - - // Get the changed method for this field if any - serializableFieldChangedMethods.TryGetValue(order, out var fieldChangedMethod); - - // Validate changed method signature if present - if (fieldChangedMethod != null) - { - var fieldType = fieldSymbol.Type; - var isValidSignature = fieldChangedMethod.ReturnsVoid && - fieldChangedMethod.Parameters.Length == 2 && - SymbolEqualityComparer.Default.Equals(fieldChangedMethod.Parameters[0].Type, fieldType) && - SymbolEqualityComparer.Default.Equals(fieldChangedMethod.Parameters[1].Type, fieldType); - - if (!isValidSignature) - { - var diag = typeNode.GenerateDiagnostic( - DiagnosticDescriptors.SG3010, - fieldChangedMethod.Name, - fieldType.ToDisplayString() - ); - return (null, null, [diag]); - } - } - - source.GenerateSerializableProperty( - compilation, + var propertyAccessor = field.Setter > field.Getter ? field.Setter.Value : field.Getter; + generatedDataStructureMethods = source.GenerateDataStructureMethods( indent, - fieldSymbol, - getterAccessor, - effectiveSetterAccessor, - virtualProperty, - markDirtyMethod, - fieldChangedMethod + field, + propertyAccessor.ToFriendlyString(), + model.MarkDirtyMethod ); - source.AppendLine(); - - // Skip data structure methods for readonly fields (they cannot be modified) - var generatedDataStructureMethods = false; - if (!fieldSymbol.IsReadOnly) - { - var propertyAccessor = setterAccessor > getterAccessor ? setterAccessor : getterAccessor; - generatedDataStructureMethods = source.GenerateDataStructureMethods( - compilation, - indent, - fieldSymbol, - propertyAccessor.ToFriendlyString(), - markDirtyMethod - ); - } - - if (generatedDataStructureMethods) - { - source.AppendLine(); - } - - serializableFieldSaveFlags.TryGetValue(order, out var serializableFieldSaveFlagMethods); - - try - { - var serializableProperty = SerializableMigrationRulesEngine.GenerateSerializableProperty( - compilation, - fieldSymbol.Name.GetPropertyName(), - fieldSymbol.Type, - order, - allAttributes, - classSymbol, - serializableFieldSaveFlagMethods - ) with { - FieldName = fieldSymbol.Name, - IsReadOnly = fieldSymbol.IsReadOnly - }; - - // We can't continue if we have duplicates. - if (!serializableFieldSet.Add(serializableProperty)) - { - var diag = typeNode.GenerateDiagnostic(DiagnosticDescriptors.SG3003, SymbolMetadata.SERIALIZABLE_FIELD_ATTRIBUTE, order); - return (null, null, [diag]); - } - } - catch (NoRuleFoundException e) - { - var diag = typeNode.GenerateDiagnostic(DiagnosticDescriptors.SG3007, e.PropertyName, e.PropertyType); - return (null, null, [diag]); - } } - } - var serializableFields = serializableFieldSet.ToImmutableArray(); - for (var i = 0; i < serializableFields.Length; i++) - { - token.ThrowIfCancellationRequested(); - - // They are out of order! (missing a number) - var order = serializableFields[i].Order; - if (order != i) + if (generatedDataStructureMethods) { - var diag = typeNode.GenerateDiagnostic(DiagnosticDescriptors.SG3005, serializableFields[i].Name, i, order); - return (null, null, [diag]); + source.AppendLine(); } } - // Skip serial constructor for value types - they use static factory or instance Deserialize - if (!isValueType && isSerializable && !classSymbol.HasSerialCtor(compilation)) + var serializableFields = model.Fields.AsImmutableArray(); + + // Skip serial constructor for value types - they use the generated instance Deserialize + if (!model.IsValueType && model.IsSerializable && !model.HasSerialCtor) { // Serial constructor - source.GenerateSerialCtor(compilation, className, indent, isOverride); + source.GenerateSerialCtor(model.ClassName, indent, model.IsOverride); source.AppendLine(); } - var warnings = new List(); - - if (!duplicateMigrationFiles.IsDefaultOrEmpty) - { - foreach (var path in duplicateMigrationFiles) - { - warnings.Add(typeNode.GenerateDiagnostic(DiagnosticDescriptors.SG3011, path)); - } - } - - // The file at the current version is the schema record the migration tool maintains; - // anything beyond it is left over from a rolled-back version bump. - foreach (var migrationVersion in migrations.Keys) - { - if (migrationVersion > version) - { - warnings.Add(typeNode.GenerateDiagnostic(DiagnosticDescriptors.SG3012, migrationVersion, version)); - } - } - - var migrationsBuilder = ImmutableArray.CreateBuilder(); - - for (var i = 0; i < version; i++) + foreach (var migration in migrations) { token.ThrowIfCancellationRequested(); - if (!migrations.TryGetValue(i, out var additionalText)) - { - continue; - } - - var migrationSource = additionalText.GetText(token); - if (migrationSource == null) - { - continue; - } - - var chrArray = ArrayPool.Shared.Rent(migrationSource.Length); - SerializableMetadata migration; - try - { - migrationSource.CopyTo(0, chrArray, 0, migrationSource.Length); - ReadOnlySpan buffer = chrArray.AsSpan(0, migrationSource.Length); - migration = JsonSerializer.Deserialize(buffer, jsonSerializerOptions); - } - finally - { - ArrayPool.Shared.Return(chrArray); - } - - source.GenerateMigrationContentStruct(compilation, indent, migration, classSymbol); + source.GenerateMigrationContentStruct(indent, migration, model.ClassDisplayString); source.AppendLine(); - - migrationsBuilder.Add(migration); } - var serializeOverride = isOverride || classSymbol.BaseType.IsSerializableRecursive(compilation); - // Compute SaveFlag configuration // Maps field order to (enumIndex, bitIndex) for fields with save flags + var saveFlagsByOrder = new SortedDictionary(); + foreach (var saveFlag in model.SaveFlags) + { + saveFlagsByOrder[saveFlag.Order] = saveFlag; + } + var saveFlagMapping = new Dictionary(); var saveFlagCount = 0; - foreach (var (order, _) in serializableFieldSaveFlags) + foreach (var (order, _) in saveFlagsByOrder) { if (!serializableFields[order].IsReadOnly) { @@ -543,7 +186,7 @@ CancellationToken token var currentBitIndex = 0; var currentEnumIndex = 0; - foreach (var (order, _) in serializableFieldSaveFlags) + foreach (var (order, _) in saveFlagsByOrder) { if (serializableFields[order].IsReadOnly) { @@ -562,45 +205,35 @@ CancellationToken token // Serialize Method source.GenerateSerializeMethod( - compilation, indent, - serializeOverride, - encodedVersion, + model.SerializeOverride, + model.EncodedVersion, serializableFields, - serializableFieldSaveFlags, + saveFlagsByOrder, saveFlagMapping, saveFlagUseUlong, saveFlagEnumCount, - !isValueType + !model.IsValueType ); source.AppendLine(); // Deserialize Method - try - { - source.GenerateDeserializeMethod( - compilation, - classSymbol, - indent, - serializeOverride, - version, - encodedVersion, - migrationsBuilder.ToImmutable(), - serializableFields, - markDirtyMethod, - dirtyTrackingEntity?.Name ?? "this", - serializableFieldSaveFlags, - saveFlagMapping, - saveFlagUseUlong, - saveFlagEnumCount, - !isValueType - ); - } - catch (DeserializeTimerFieldRequiredException e) - { - var diag = typeNode.GenerateDiagnostic(DiagnosticDescriptors.SG3008, e.PropertyName); - return (null, null, [diag]); - } + source.GenerateDeserializeMethod( + model, + indent, + model.SerializeOverride, + model.Version, + model.EncodedVersion, + migrations, + serializableFields, + model.MarkDirtyMethod, + model.ParentReference, + saveFlagsByOrder, + saveFlagMapping, + saveFlagUseUlong, + saveFlagEnumCount, + !model.IsValueType + ); // Serialize SaveFlag enum class(es) if (saveFlagCount > 0) @@ -610,7 +243,7 @@ CancellationToken token currentEnumIndex = 0; var isFirstEnum = true; - foreach (var (order, _) in serializableFieldSaveFlags) + foreach (var (order, _) in saveFlagsByOrder) { // Skip readonly fields if (serializableFields[order].IsReadOnly) @@ -671,67 +304,37 @@ CancellationToken token } } - source.RecursiveGenerateClassEnd(classSymbol, ref indent); + for (var i = 0; i < model.TypeShells.Length; i++) + { + indent = indent.Substring(0, indent.Length - 4); + source.GenerateClassEnd(indent); + } + source.GenerateNamespaceEnd(); // Write the migration file (exclude readonly fields since they aren't serialized) // Use arity notation for generic types to match file naming convention - var serializableFieldsForMigration = serializableFields.Where(f => !f.IsReadOnly).ToImmutableArray(); - var newMigration = generateMetadata ? new SerializableMetadata + SerializableMetadata newMigration = null; + if (generateMetadata) { - Version = version, - Type = classSymbol.GetGenericArityName(), - Properties = serializableFieldsForMigration.Length > 0 ? serializableFieldsForMigration : null - } : null; - - return (source.ToString(), newMigration, warnings.ToArray()); - } - - extension(StringBuilder source) - { - private void RecursiveGenerateClassStart( - INamedTypeSymbol classSymbol, - ImmutableArray interfaces, - ref string indent, - string typeKeyword = "class" - ) - { - var containingSymbolList = new List(); - - do + var builder = ImmutableArray.CreateBuilder(); + foreach (var field in serializableFields) { - containingSymbolList.Add(classSymbol); - classSymbol = classSymbol.ContainingSymbol as INamedTypeSymbol; - } while (classSymbol != null); - - containingSymbolList.Reverse(); - - for (var i = 0; i < containingSymbolList.Count; i++) - { - var symbol = containingSymbolList[i]; - var last = i == containingSymbolList.Count - 1; - - if (last) + if (!field.IsReadOnly) { - source.AppendLine($"{indent}[System.CodeDom.Compiler.GeneratedCode(\"ModernUO.Serialization.Generator\", \"{Version}\")]"); + builder.Add(field); } - - // Only the outermost type uses the specified type keyword - var currentTypeKeyword = last ? typeKeyword : "class"; - source.GenerateClassStart(symbol, indent, last ? interfaces : ImmutableArray.Empty, true, currentTypeKeyword); - indent += " "; } - } - private void RecursiveGenerateClassEnd(INamedTypeSymbol classSymbol, ref string indent) - { - do + var serializableFieldsForMigration = builder.ToImmutable(); + newMigration = new SerializableMetadata { - indent = indent.Substring(0, indent.Length - 4); - source.GenerateClassEnd(indent); - - classSymbol = classSymbol.ContainingSymbol as INamedTypeSymbol; - } while (classSymbol != null); + Version = model.Version, + Type = model.ArityName, + Properties = serializableFieldsForMigration.Length > 0 ? serializableFieldsForMigration : null + }; } + + return (source.ToString(), newMigration); } } diff --git a/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.DataStructure.cs b/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.DataStructure.cs index d7dbea4..b276d1d 100644 --- a/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.DataStructure.cs +++ b/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.DataStructure.cs @@ -1,5 +1,4 @@ using System.Text; -using Microsoft.CodeAnalysis; namespace ModernUO.Serialization.Generator; @@ -7,37 +6,24 @@ public static partial class SerializableEntityGeneration { public static bool GenerateDataStructureMethods( this StringBuilder source, - Compilation compilation, string indent, - IFieldSymbol symbol, + FieldPropertyModel field, string propertyAccessor, string? markDirtyMethod ) { - var propertyName = symbol.Name.GetPropertyName(); - var propertyType = symbol.Type; - var namedTypeSymbol = propertyType as INamedTypeSymbol; - - var elementTypeName = (propertyType as IArrayTypeSymbol)?.ElementType ?? (namedTypeSymbol?.TypeArguments.Length > 0 ? namedTypeSymbol.TypeArguments[0] : null); - if (elementTypeName == null) - { - return false; - } - - var isArray = propertyType is IArrayTypeSymbol; - var isDictionary = propertyType.IsDictionaryInterface(compilation); - var isList = propertyType.IsListInterface(compilation); - var isCollection = propertyType.IsCollection(compilation); - // Non-collection generics (e.g. KeyValuePair) have type arguments but no Add/Clear surface. - if (!isArray && !isDictionary && !isList && !isCollection) + if (!field.HasDataStructureMethods) { return false; } - if (isDictionary) + var propertyName = field.PropertyName; + var elementTypeName = field.DsElementType; + + if (field.DsIsDictionary) { - var valueTypeName = namedTypeSymbol!.TypeArguments[1]; + var valueTypeName = field.DsValueType; // Add source.AppendLine($"{indent}{propertyAccessor} void AddTo{propertyName}({elementTypeName} key, {valueTypeName} value)"); @@ -64,7 +50,7 @@ public static bool GenerateDataStructureMethods( source.AppendLine($"{indent} {markDirtyMethod};"); source.AppendLine($"{indent}}}"); } - else if (isCollection) + else if (field.DsIsCollection) { // Add source.AppendLine($"{indent}{propertyAccessor} void AddTo{propertyName}({elementTypeName} value)"); @@ -85,7 +71,7 @@ public static bool GenerateDataStructureMethods( source.AppendLine(); } - if (isList) + if (field.DsIsList) { // Insert source.AppendLine($"{indent}{propertyAccessor} void InsertInto{propertyName}(int index, {elementTypeName} value)"); @@ -106,7 +92,7 @@ public static bool GenerateDataStructureMethods( source.AppendLine(); - if (isArray) + if (field.DsIsArray) { // Clear source.AppendLine($"{indent}{propertyAccessor} void Clear{propertyName}()"); diff --git a/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.DeserializeMethod.cs b/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.DeserializeMethod.cs index 24892dd..939fb6b 100644 --- a/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.DeserializeMethod.cs +++ b/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.DeserializeMethod.cs @@ -15,7 +15,6 @@ using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; using System.Text; using Microsoft.CodeAnalysis; @@ -26,8 +25,7 @@ public static partial class SerializableEntityGeneration extension(StringBuilder source) { public void GenerateDeserializeMethod( - Compilation compilation, - INamedTypeSymbol classSymbol, + SerializationModel model, string indent, bool isOverride, int version, @@ -36,22 +34,20 @@ public void GenerateDeserializeMethod( ImmutableArray fields, string markDirtyMethod, string parentReference, - SortedDictionary serializableFieldSaveFlagMethodsDictionary, + SortedDictionary serializableFieldSaveFlagMethodsDictionary, Dictionary saveFlagMapping, bool saveFlagUseUlong, int saveFlagEnumCount, bool isVirtual = true ) { - var genericReaderInterface = compilation.GetTypeByMetadataName(SymbolMetadata.GENERIC_READER_INTERFACE); - source.GenerateMethodStart( indent, "Deserialize", Accessibility.Public, isOverride, "void", - ImmutableArray.Create<(ITypeSymbol, string)>((genericReaderInterface, "reader")), + ImmutableArray.Create(("Server.IGenericReader", "reader")), isVirtual ); @@ -64,26 +60,7 @@ public void GenerateDeserializeMethod( source.AppendLine(); } - var afterDeserialization = classSymbol - .GetMembers() - .OfType() - .Select( - m => - { - if (!m.ReturnsVoid || m.Parameters.Length != 0) - { - return (m, null); - } - - return (m, m.GetAttributes() - .FirstOrDefault( - attr => SymbolEqualityComparer.Default.Equals( - attr.AttributeClass, - compilation.GetTypeByMetadataName(SymbolMetadata.AFTER_DESERIALIZATION_ATTRIBUTE) - ) - )); - } - ).Where(m => m.Item2 != null).ToList(); + var afterDeserialization = model.AfterDeserialization; // Version source.AppendLine($"{bodyIndent}var version = reader.{(encodedVersion ? "ReadEncodedInt" : "ReadInt")}();"); @@ -173,7 +150,6 @@ out var serializableFieldSaveFlagMethods rule.GenerateDeserializationMethod( source, innerIndent, - compilation, field, parentReference ); @@ -181,15 +157,14 @@ out var serializableFieldSaveFlagMethods source, innerIndent, field, - compilation, - classSymbol + model ); - if (serializableFieldSaveFlagMethods.GetFieldDefaultValue != null) + if (serializableFieldSaveFlagMethods.DefaultName != null) { source.AppendLine($"{bodyIndent}}}\n{bodyIndent}else\n{bodyIndent}{{"); source.AppendLine( - $"{bodyIndent} {field.FieldName} = {serializableFieldSaveFlagMethods.GetFieldDefaultValue.Name}();" + $"{bodyIndent} {field.FieldName} = {serializableFieldSaveFlagMethods.DefaultName}();" ); } @@ -202,7 +177,6 @@ out var serializableFieldSaveFlagMethods rule.GenerateDeserializationMethod( source, bodyIndent, - compilation, field, parentReference ); @@ -210,8 +184,7 @@ out var serializableFieldSaveFlagMethods source, bodyIndent, field, - compilation, - classSymbol + model ); } } @@ -221,18 +194,18 @@ out var serializableFieldSaveFlagMethods } private void GenerateAfterDeserialization( - string indent, IList<(IMethodSymbol, AttributeData?)> afterDeserialization + string indent, EquatableArray afterDeserialization ) { - foreach (var (method, attr) in afterDeserialization) + foreach (var method in afterDeserialization) { - if ((bool)attr.ConstructorArguments[0].Value!) + if (method.Synchronous) { - source.AppendLine($"{indent}{method.Name}();"); + source.AppendLine($"{indent}{method.MethodName}();"); } else { - source.AppendLine($"{indent}Timer.DelayCall({method.Name});"); + source.AppendLine($"{indent}Timer.DelayCall({method.MethodName});"); } } } diff --git a/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.Property.cs b/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.Property.cs index e0c7efc..3f7a1e8 100644 --- a/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.Property.cs +++ b/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.Property.cs @@ -13,7 +13,6 @@ * along with this program. If not, see . * *************************************************************************/ -using System.Linq; using System.Text; using Microsoft.CodeAnalysis; @@ -23,38 +22,24 @@ public static partial class SerializableEntityGeneration { public static void GenerateSerializableProperty( this StringBuilder source, - Compilation compilation, string indent, - IFieldSymbol fieldSymbol, - Accessibility getter, - Accessibility? setter, - bool isVirtual, - string? markDirtyMethod, - IMethodSymbol? fieldChangedMethod = null + FieldPropertyModel field, + string? markDirtyMethod ) { - var fieldName = fieldSymbol.Name; - - var invalidatePropertiesAttribute = fieldSymbol - .GetAttributes() - .OfType() - .FirstOrDefault( - attr => attr.AttributeClass?.Equals( - compilation.GetTypeByMetadataName(SymbolMetadata.INVALIDATE_PROPERTIES_ATTRIBUTE), - SymbolEqualityComparer.Default - ) ?? false - ); + var fieldName = field.FieldName; var propertyIndent = $"{indent} "; var innerIndent = $"{propertyIndent} "; - var propertyAccessor = setter > getter ? setter : getter; - var getterAccessor = getter == propertyAccessor ? Accessibility.NotApplicable : getter; + var setter = field.Setter; + var propertyAccessor = setter > field.Getter ? setter : field.Getter; + var getterAccessor = field.Getter == propertyAccessor ? Accessibility.NotApplicable : field.Getter; - source.GeneratePropertyStart(indent, propertyAccessor.Value, isVirtual, fieldSymbol); + source.GeneratePropertyStart(indent, propertyAccessor.Value, field.Virtual, field.FieldTypeDisplay, field.PropertyName); // Getter - source.GeneratePropertyGetterReturnsField(propertyIndent, fieldSymbol, getterAccessor); + source.GeneratePropertyGetterReturnsField(propertyIndent, fieldName, getterAccessor); if (setter != null && setter != Accessibility.NotApplicable) { @@ -64,14 +49,14 @@ public static void GenerateSerializableProperty( source.GeneratePropertySetterStart(propertyIndent, false, setterAccessor.Value); // Capture old value before comparison if we have a changed callback - if (fieldChangedMethod != null) + if (field.FieldChangedMethodName != null) { source.AppendLine($"{innerIndent}var oldValue = {fieldName};"); } - var comparison = fieldSymbol.Type.HasInequalityOperator() + var comparison = field.HasInequalityOperator ? $"value != {fieldName}" - : $"!System.Collections.Generic.EqualityComparer<{fieldSymbol.Type.ToDisplayString()}>.Default.Equals(value, {fieldName})"; + : $"!System.Collections.Generic.EqualityComparer<{field.FieldTypeDisplay}>.Default.Equals(value, {fieldName})"; source.AppendLine($"{innerIndent}if ({comparison})"); source.AppendLine($"{innerIndent}{{"); @@ -81,15 +66,15 @@ public static void GenerateSerializableProperty( source.AppendLine($"{innerIndent} {markDirtyMethod};"); } - if (invalidatePropertiesAttribute != null) + if (field.InvalidateProperties) { source.AppendLine($"{innerIndent} InvalidateProperties();"); } // Invoke the changed callback after assignment - if (fieldChangedMethod != null) + if (field.FieldChangedMethodName != null) { - source.AppendLine($"{innerIndent} {fieldChangedMethod.Name}(oldValue, value);"); + source.AppendLine($"{innerIndent} {field.FieldChangedMethodName}(oldValue, value);"); } source.AppendLine($"{innerIndent}}}"); diff --git a/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.SerialCtor.cs b/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.SerialCtor.cs index c0a4a6f..d901e58 100644 --- a/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.SerialCtor.cs +++ b/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.SerialCtor.cs @@ -25,19 +25,16 @@ public static partial class SerializableEntityGeneration public static void GenerateSerialCtor( this StringBuilder source, - Compilation compilation, string className, string indent, bool isOverride ) { - var serialType = (ITypeSymbol)compilation.GetTypeByMetadataName("Server.Serial"); - source.GenerateConstructorStart( indent, className, Accessibility.Public, - new []{ (serialType, "serial") }.ToImmutableArray(), + new[] { ("Server.Serial", "serial") }.ToImmutableArray(), isOverride ? _baseParameters : null ); diff --git a/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.SerializeMethod.cs b/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.SerializeMethod.cs index 74de7a2..5a140ec 100644 --- a/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.SerializeMethod.cs +++ b/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializableEntityGeneration.SerializeMethod.cs @@ -24,27 +24,24 @@ public static partial class SerializableEntityGeneration { public static void GenerateSerializeMethod( this StringBuilder source, - Compilation compilation, string indent, bool isOverride, bool encodedVersion, ImmutableArray fields, - SortedDictionary serializableFieldSaveFlagMethodsDictionary, + SortedDictionary serializableFieldSaveFlagMethodsDictionary, Dictionary saveFlagMapping, bool saveFlagUseUlong, int saveFlagEnumCount, bool isVirtual = true ) { - var genericWriterInterface = compilation.GetTypeByMetadataName(SymbolMetadata.GENERIC_WRITER_INTERFACE); - source.GenerateMethodStart( indent, "Serialize", Accessibility.Public, isOverride, "void", - ImmutableArray.Create<(ITypeSymbol, string)>((genericWriterInterface, "writer")), + ImmutableArray.Create(("Server.IGenericWriter", "writer")), isVirtual ); @@ -79,7 +76,7 @@ public static void GenerateSerializeMethod( continue; } - source.AppendLine($"{bodyIndent}if ({saveFlagMethods.DetermineFieldShouldSerialize!.Name}())\n{bodyIndent}{{"); + source.AppendLine($"{bodyIndent}if ({saveFlagMethods.DetermineName}())\n{bodyIndent}{{"); var propertyName = fields[order].Name; var enumName = mapping.EnumIndex == 0 ? "SaveFlag" : $"SaveFlag{mapping.EnumIndex + 1}"; diff --git a/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializationEntityGeneration.ContentStruct.cs b/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializationEntityGeneration.ContentStruct.cs index f85f3b3..2cb27fb 100644 --- a/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializationEntityGeneration.ContentStruct.cs +++ b/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializationEntityGeneration.ContentStruct.cs @@ -24,10 +24,9 @@ public static partial class SerializableEntityGeneration { public static void GenerateMigrationContentStruct( this StringBuilder source, - Compilation compilation, string indent, SerializableMetadata migration, - INamedTypeSymbol classSymbol + string classDisplayString ) { source.AppendLine($"{indent}ref struct V{migration.Version}Content"); @@ -37,7 +36,7 @@ INamedTypeSymbol classSymbol foreach (var serializableProperty in properties) { SerializableMigrationRulesEngine.Rules[serializableProperty.Rule].GenerateMigrationProperty( - source, compilation, $"{indent} ", serializableProperty + source, $"{indent} ", serializableProperty ); } @@ -68,7 +67,7 @@ INamedTypeSymbol classSymbol source.GenerateEnumEnd($"{indent} "); } - source.AppendLine($"{indent} internal V{migration.Version}Content(Server.IGenericReader reader, {classSymbol.ToDisplayString()} entity)"); + source.AppendLine($"{indent} internal V{migration.Version}Content(Server.IGenericReader reader, {classDisplayString} entity)"); source.AppendLine($"{indent} {{"); if (usesSaveFlags) @@ -93,7 +92,6 @@ INamedTypeSymbol classSymbol SerializableMigrationRulesEngine.Rules[property.Rule].GenerateDeserializationMethod( source, $"{innerIndent} ", - compilation, property, "entity", true @@ -109,7 +107,6 @@ INamedTypeSymbol classSymbol SerializableMigrationRulesEngine.Rules[property.Rule].GenerateDeserializationMethod( source, innerIndent, - compilation, property, "entity", true diff --git a/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializationModel.cs b/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializationModel.cs new file mode 100644 index 0000000..b76efd9 --- /dev/null +++ b/ModernUO.Serialization.Generator/SerializableEntityGeneration/SerializationModel.cs @@ -0,0 +1,185 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: SerializationModel.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace ModernUO.Serialization.Generator; + +/// +/// Everything code generation needs, fully resolved from symbols at transform time and +/// value-equatable so the incremental pipeline can cache on it. Editing anything that does +/// not affect the serialization surface of a class produces an equal model, and nothing +/// downstream re-runs. +/// +public sealed record SerializationModel( + string NamespaceName, + string ClassName, + string ClassDisplayString, + string ArityName, + string TypeKeyword, + bool IsValueType, + int Version, + bool EncodedVersion, + bool IsOverride, + bool SerializeOverride, + bool IsSerializable, + bool HasSerialCtor, + bool EmitMarkDirtyMethod, + string? MarkDirtyBody, + string? MarkDirtyMethod, + string ParentReference, + EquatableArray TypeShells, + EquatableArray BackingFields, + EquatableArray Fields, + EquatableArray FieldEmissions, + EquatableArray SaveFlags, + EquatableArray AfterDeserialization, + EquatableArray TimerFields, + LocationInfo Location +) +{ + // Location is deliberately excluded: an edit elsewhere in the file shifts the class's + // span without changing its serialization surface, and must not defeat caching. The + // trade-off is that a cached model's diagnostic location can lag by a few lines until + // the surface actually changes. + public bool Equals(SerializationModel? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return NamespaceName == other.NamespaceName + && ClassName == other.ClassName + && ClassDisplayString == other.ClassDisplayString + && ArityName == other.ArityName + && TypeKeyword == other.TypeKeyword + && IsValueType == other.IsValueType + && Version == other.Version + && EncodedVersion == other.EncodedVersion + && IsOverride == other.IsOverride + && SerializeOverride == other.SerializeOverride + && IsSerializable == other.IsSerializable + && HasSerialCtor == other.HasSerialCtor + && EmitMarkDirtyMethod == other.EmitMarkDirtyMethod + && MarkDirtyBody == other.MarkDirtyBody + && MarkDirtyMethod == other.MarkDirtyMethod + && ParentReference == other.ParentReference + && TypeShells == other.TypeShells + && BackingFields == other.BackingFields + && Fields == other.Fields + && FieldEmissions == other.FieldEmissions + && SaveFlags == other.SaveFlags + && AfterDeserialization == other.AfterDeserialization + && TimerFields == other.TimerFields; + } + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = hash * 31 + (NamespaceName?.GetHashCode() ?? 0); + hash = hash * 31 + (ClassDisplayString?.GetHashCode() ?? 0); + hash = hash * 31 + Version; + hash = hash * 31 + Fields.GetHashCode(); + hash = hash * 31 + FieldEmissions.GetHashCode(); + return hash; + } + } +} + +/// One containing type in the nesting chain, outermost first. +public sealed record TypeShellModel(string Accessibility, string NameWithTypeParameters, string WhereClauses); + +/// A field generated to back a [SerializableProperty] member. +public sealed record BackingFieldModel(string TypeDisplay, string FieldName); + +/// Emission facts for one [SerializableField] member, in declaration order. +public sealed record FieldPropertyModel( + int Order, + string FieldName, + string FieldTypeDisplay, + string PropertyName, + Accessibility Getter, + Accessibility? Setter, + bool IsReadOnly, + bool Virtual, + bool HasInequalityOperator, + bool InvalidateProperties, + string? FieldChangedMethodName, + EquatableArray AttributeLines, + bool DsIsArray, + bool DsIsDictionary, + bool DsIsList, + bool DsIsCollection, + string? DsElementType, + string? DsValueType +) +{ + public bool HasDataStructureMethods => DsIsArray || DsIsDictionary || DsIsList || DsIsCollection; +} + +/// Save-flag/default method names for one field order. +public sealed record SaveFlagModel(int Order, string DetermineName, string? DefaultName); + +public sealed record AfterDeserializeModel(string MethodName, bool Synchronous); + +/// The [DeserializeTimerField] method for a timer field. +public sealed record TimerFieldModel(int Order, string DeserializeMethodName); + +/// Value-equatable stand-in for . +public sealed record LocationInfo(string FilePath, TextSpan Span, LinePositionSpan LineSpan) +{ + public static LocationInfo Create(Location location) + { + var lineSpan = location.GetLineSpan(); + return new LocationInfo( + location.SourceTree?.FilePath ?? lineSpan.Path ?? "", + location.SourceSpan, + lineSpan.Span + ); + } + + public Location ToLocation() => Location.Create(FilePath, Span, LineSpan); +} + +/// Transform output: a model, or the diagnostics explaining why there is none. +public sealed record SerializationModelResult( + SerializationModel? Model, + EquatableArray Diagnostics +); + +/// A parsed migration file; is null when parsing failed. +public sealed record MigrationFileModel( + string ClassName, + int Version, + string FilePath, + SerializableMetadata? Metadata, + string? Error +); + +/// The model plus its class's migrations, ready for generation. +public sealed record FinalModel( + SerializationModelResult Result, + EquatableArray Migrations, + EquatableArray MigrationDiagnostics +); diff --git a/ModernUO.Serialization.Generator/SerializableMigration/DeserializeTimerFieldRequiredException.cs b/ModernUO.Serialization.Generator/SerializableMigration/DeserializeTimerFieldRequiredException.cs deleted file mode 100644 index 8014efc..0000000 --- a/ModernUO.Serialization.Generator/SerializableMigration/DeserializeTimerFieldRequiredException.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; - -namespace ModernUO.Serialization.Generator; - -public class DeserializeTimerFieldRequiredException : Exception -{ - public string PropertyName { get; } - - public DeserializeTimerFieldRequiredException(string propertyName) => PropertyName = propertyName; -} diff --git a/ModernUO.Serialization.Generator/SerializableMigration/IPostDeserializeMethod.cs b/ModernUO.Serialization.Generator/SerializableMigration/IPostDeserializeMethod.cs index dc08c38..ef6e917 100644 --- a/ModernUO.Serialization.Generator/SerializableMigration/IPostDeserializeMethod.cs +++ b/ModernUO.Serialization.Generator/SerializableMigration/IPostDeserializeMethod.cs @@ -14,7 +14,6 @@ *************************************************************************/ using System.Text; -using Microsoft.CodeAnalysis; namespace ModernUO.Serialization.Generator; @@ -24,7 +23,6 @@ public void PostDeserializeMethod( StringBuilder source, string indent, SerializableProperty property, - Compilation compilation, - INamedTypeSymbol classSymbol + SerializationModel model ); } diff --git a/ModernUO.Serialization.Generator/SerializableMigration/ISerializableMigrationRule.cs b/ModernUO.Serialization.Generator/SerializableMigration/ISerializableMigrationRule.cs index 455f26c..2fffbcf 100644 --- a/ModernUO.Serialization.Generator/SerializableMigration/ISerializableMigrationRule.cs +++ b/ModernUO.Serialization.Generator/SerializableMigration/ISerializableMigrationRule.cs @@ -25,7 +25,6 @@ public interface ISerializableMigrationRule void GenerateMigrationProperty( StringBuilder source, - Compilation compilation, string indent, SerializableProperty property ); @@ -41,7 +40,6 @@ out string[] ruleArguments void GenerateDeserializationMethod( StringBuilder source, string indent, - Compilation compilation, SerializableProperty property, string? parentReference, bool isMigration = false diff --git a/ModernUO.Serialization.Generator/SerializableMigration/Rules/ArrayMigrationRule.cs b/ModernUO.Serialization.Generator/SerializableMigration/Rules/ArrayMigrationRule.cs index 0226ace..87d4272 100644 --- a/ModernUO.Serialization.Generator/SerializableMigration/Rules/ArrayMigrationRule.cs +++ b/ModernUO.Serialization.Generator/SerializableMigration/Rules/ArrayMigrationRule.cs @@ -71,7 +71,6 @@ out string[] ruleArguments public override void GenerateDeserializationMethod( StringBuilder source, string indent, - Compilation compilation, SerializableProperty property, string? parentReference, bool isMigration = false @@ -100,7 +99,6 @@ public override void GenerateDeserializationMethod( source.AppendLine($"{indent}{{"); GenerateDeserialize( source, - compilation, $"{indent} ", propertyName, parentReference, @@ -118,7 +116,6 @@ public override void GenerateDeserializationMethod( { GenerateDeserialize( source, - compilation, indent, propertyName, parentReference, @@ -131,7 +128,6 @@ public override void GenerateDeserializationMethod( private static void GenerateDeserialize( StringBuilder source, - Compilation compilation, string indent, string propertyName, string parentReference, @@ -159,7 +155,6 @@ string[] arrayElementRuleArguments arrayElementRule.GenerateDeserializationMethod( source, $"{indent} ", - compilation, serializableArrayElement, parentReference ); diff --git a/ModernUO.Serialization.Generator/SerializableMigration/Rules/DictionaryMigrationRule.cs b/ModernUO.Serialization.Generator/SerializableMigration/Rules/DictionaryMigrationRule.cs index 96aa7a6..5162f20 100644 --- a/ModernUO.Serialization.Generator/SerializableMigration/Rules/DictionaryMigrationRule.cs +++ b/ModernUO.Serialization.Generator/SerializableMigration/Rules/DictionaryMigrationRule.cs @@ -107,7 +107,6 @@ out string[] ruleArguments public override void GenerateDeserializationMethod( StringBuilder source, string indent, - Compilation compilation, SerializableProperty property, string? parentReference, bool isMigration = false @@ -157,7 +156,6 @@ public override void GenerateDeserializationMethod( source.AppendLine($"{indent}{{"); GenerateDeserialize( source, - compilation, $"{indent} ", propertyName, parentReference, @@ -178,7 +176,6 @@ public override void GenerateDeserializationMethod( { GenerateDeserialize( source, - compilation, indent, propertyName, parentReference, @@ -194,7 +191,6 @@ public override void GenerateDeserializationMethod( private static void GenerateDeserialize( StringBuilder source, - Compilation compilation, string indent, string propertyName, string parentReference, @@ -229,7 +225,6 @@ string[] valueRuleArguments keyElementRule.GenerateDeserializationMethod( source, $"{indent} ", - compilation, serializableKeyElement, parentReference ); @@ -245,7 +240,6 @@ string[] valueRuleArguments valueElementRule.GenerateDeserializationMethod( source, $"{indent} ", - compilation, serializableValueElement, parentReference ); diff --git a/ModernUO.Serialization.Generator/SerializableMigration/Rules/EnumMigrationRule.cs b/ModernUO.Serialization.Generator/SerializableMigration/Rules/EnumMigrationRule.cs index eb4d556..86f7286 100644 --- a/ModernUO.Serialization.Generator/SerializableMigration/Rules/EnumMigrationRule.cs +++ b/ModernUO.Serialization.Generator/SerializableMigration/Rules/EnumMigrationRule.cs @@ -45,7 +45,6 @@ out string[] ruleArguments public override void GenerateDeserializationMethod( StringBuilder source, string indent, - Compilation compilation, SerializableProperty property, string? parentReference, bool isMigration = false diff --git a/ModernUO.Serialization.Generator/SerializableMigration/Rules/HashSetMigrationRule.cs b/ModernUO.Serialization.Generator/SerializableMigration/Rules/HashSetMigrationRule.cs index 2115f2a..c627054 100644 --- a/ModernUO.Serialization.Generator/SerializableMigration/Rules/HashSetMigrationRule.cs +++ b/ModernUO.Serialization.Generator/SerializableMigration/Rules/HashSetMigrationRule.cs @@ -82,7 +82,6 @@ out string[] ruleArguments public override void GenerateDeserializationMethod( StringBuilder source, string indent, - Compilation compilation, SerializableProperty property, string? parentReference, bool isMigration = false @@ -118,7 +117,6 @@ public override void GenerateDeserializationMethod( source.AppendLine($"{indent}{{"); GenerateDeserialize( source, - compilation, $"{indent} ", propertyName, parentReference, @@ -136,7 +134,6 @@ public override void GenerateDeserializationMethod( { GenerateDeserialize( source, - compilation, indent, propertyName, parentReference, @@ -149,7 +146,6 @@ public override void GenerateDeserializationMethod( private static void GenerateDeserialize( StringBuilder source, - Compilation compilation, string indent, string propertyName, string parentReference, @@ -179,7 +175,6 @@ string[] setElementRuleArguments setElementRule.GenerateDeserializationMethod( source, $"{indent} ", - compilation, serializableSetElement, parentReference ); diff --git a/ModernUO.Serialization.Generator/SerializableMigration/Rules/KeyValuePairMigrationRule.cs b/ModernUO.Serialization.Generator/SerializableMigration/Rules/KeyValuePairMigrationRule.cs index 9320db5..47f0b5d 100644 --- a/ModernUO.Serialization.Generator/SerializableMigration/Rules/KeyValuePairMigrationRule.cs +++ b/ModernUO.Serialization.Generator/SerializableMigration/Rules/KeyValuePairMigrationRule.cs @@ -95,7 +95,6 @@ out string[] ruleArguments public override void GenerateDeserializationMethod( StringBuilder source, string indent, - Compilation compilation, SerializableProperty property, string? parentReference, bool isMigration = false @@ -134,7 +133,6 @@ public override void GenerateDeserializationMethod( GenerateDeserialize( source, - compilation, indent, propertyName, parentReference, @@ -149,7 +147,6 @@ public override void GenerateDeserializationMethod( private static void GenerateDeserialize( StringBuilder source, - Compilation compilation, string indent, string propertyName, string parentReference, @@ -180,7 +177,6 @@ string[] valueRuleArguments keyRule.GenerateDeserializationMethod( source, indent, - compilation, serializableKeyProperty, parentReference ); @@ -196,7 +192,6 @@ string[] valueRuleArguments valueRule.GenerateDeserializationMethod( source, indent, - compilation, serializableValueProperty, parentReference ); diff --git a/ModernUO.Serialization.Generator/SerializableMigration/Rules/ListMigrationRule.cs b/ModernUO.Serialization.Generator/SerializableMigration/Rules/ListMigrationRule.cs index 7c23af7..4303fbf 100644 --- a/ModernUO.Serialization.Generator/SerializableMigration/Rules/ListMigrationRule.cs +++ b/ModernUO.Serialization.Generator/SerializableMigration/Rules/ListMigrationRule.cs @@ -82,7 +82,6 @@ out string[] ruleArguments public override void GenerateDeserializationMethod( StringBuilder source, string indent, - Compilation compilation, SerializableProperty property, string? parentReference, bool isMigration = false @@ -117,7 +116,6 @@ public override void GenerateDeserializationMethod( source.AppendLine($"{indent}{{"); GenerateDeserialize( source, - compilation, $"{indent} ", propertyName, parentReference, @@ -135,7 +133,6 @@ public override void GenerateDeserializationMethod( { GenerateDeserialize( source, - compilation, indent, propertyName, parentReference, @@ -148,7 +145,6 @@ public override void GenerateDeserializationMethod( private static void GenerateDeserialize( StringBuilder source, - Compilation compilation, string indent, string propertyName, string parentReference, @@ -178,7 +174,6 @@ string[] listElementRuleArguments listElementRule.GenerateDeserializationMethod( source, $"{indent} ", - compilation, serializableListElement, parentReference ); diff --git a/ModernUO.Serialization.Generator/SerializableMigration/Rules/MigrationRule.cs b/ModernUO.Serialization.Generator/SerializableMigration/Rules/MigrationRule.cs index b1a869b..1c4cf8d 100644 --- a/ModernUO.Serialization.Generator/SerializableMigration/Rules/MigrationRule.cs +++ b/ModernUO.Serialization.Generator/SerializableMigration/Rules/MigrationRule.cs @@ -24,11 +24,11 @@ public abstract class MigrationRule : ISerializableMigrationRule public abstract string RuleName { get; } public virtual void GenerateMigrationProperty( - StringBuilder source, Compilation compilation, string indent, SerializableProperty property + StringBuilder source, string indent, SerializableProperty property ) { var propertyType = property.Type; - var type = compilation.GetTypeByMetadataName(propertyType)?.IsValueType == true + var type = property.TypeIsValueType == true || SymbolMetadata.IsPrimitiveFromTypeDisplayString(propertyType) && propertyType != "bool" ? $"{propertyType}{(property.UsesSaveFlag == true ? "?" : "")}" : propertyType; @@ -43,7 +43,6 @@ public abstract bool GenerateRuleState( public abstract void GenerateDeserializationMethod( StringBuilder source, string indent, - Compilation compilation, SerializableProperty property, string? parentReference, bool isMigration = false diff --git a/ModernUO.Serialization.Generator/SerializableMigration/Rules/PrimitiveTypeMigrationRule.cs b/ModernUO.Serialization.Generator/SerializableMigration/Rules/PrimitiveTypeMigrationRule.cs index 4b89ef1..aec840c 100644 --- a/ModernUO.Serialization.Generator/SerializableMigration/Rules/PrimitiveTypeMigrationRule.cs +++ b/ModernUO.Serialization.Generator/SerializableMigration/Rules/PrimitiveTypeMigrationRule.cs @@ -81,7 +81,6 @@ SpecialType.System_String when attributes.Any(a => a.IsInternString(compilation) public override void GenerateDeserializationMethod( StringBuilder source, string indent, - Compilation compilation, SerializableProperty property, string? parentReference, bool isMigration = false diff --git a/ModernUO.Serialization.Generator/SerializableMigration/Rules/PrimitiveUOTypeMigrationRule.cs b/ModernUO.Serialization.Generator/SerializableMigration/Rules/PrimitiveUOTypeMigrationRule.cs index 5088cc9..ba3803d 100644 --- a/ModernUO.Serialization.Generator/SerializableMigration/Rules/PrimitiveUOTypeMigrationRule.cs +++ b/ModernUO.Serialization.Generator/SerializableMigration/Rules/PrimitiveUOTypeMigrationRule.cs @@ -61,7 +61,6 @@ _ when symbol.IsSerial(compilation) => "Serial", public override void GenerateDeserializationMethod( StringBuilder source, string indent, - Compilation compilation, SerializableProperty property, string? parentReference, bool isMigration = false diff --git a/ModernUO.Serialization.Generator/SerializableMigration/Rules/RawSerializableMigrationRule.cs b/ModernUO.Serialization.Generator/SerializableMigration/Rules/RawSerializableMigrationRule.cs index 693e10d..c343930 100644 --- a/ModernUO.Serialization.Generator/SerializableMigration/Rules/RawSerializableMigrationRule.cs +++ b/ModernUO.Serialization.Generator/SerializableMigration/Rules/RawSerializableMigrationRule.cs @@ -63,7 +63,6 @@ out string[] ruleArguments public override void GenerateDeserializationMethod( StringBuilder source, string indent, - Compilation compilation, SerializableProperty property, string? parentReference, bool isMigration = false diff --git a/ModernUO.Serialization.Generator/SerializableMigration/Rules/SerializableInterfaceMigrationRule.cs b/ModernUO.Serialization.Generator/SerializableMigration/Rules/SerializableInterfaceMigrationRule.cs index 0cf2baa..699daa1 100644 --- a/ModernUO.Serialization.Generator/SerializableMigration/Rules/SerializableInterfaceMigrationRule.cs +++ b/ModernUO.Serialization.Generator/SerializableMigration/Rules/SerializableInterfaceMigrationRule.cs @@ -45,7 +45,6 @@ out string[] ruleArguments public override void GenerateDeserializationMethod( StringBuilder source, string indent, - Compilation compilation, SerializableProperty property, string? parentReference, bool isMigration = false diff --git a/ModernUO.Serialization.Generator/SerializableMigration/Rules/SerializationMethodSignatureMigrationRule.cs b/ModernUO.Serialization.Generator/SerializableMigration/Rules/SerializationMethodSignatureMigrationRule.cs index d2268c4..f2cfed1 100644 --- a/ModernUO.Serialization.Generator/SerializableMigration/Rules/SerializationMethodSignatureMigrationRule.cs +++ b/ModernUO.Serialization.Generator/SerializableMigration/Rules/SerializationMethodSignatureMigrationRule.cs @@ -59,7 +59,6 @@ out string[] ruleArguments public override void GenerateDeserializationMethod( StringBuilder source, string indent, - Compilation compilation, SerializableProperty property, string? parentReference, bool isMigration = false diff --git a/ModernUO.Serialization.Generator/SerializableMigration/Rules/SortedSetMigrationRule.cs b/ModernUO.Serialization.Generator/SerializableMigration/Rules/SortedSetMigrationRule.cs index 830890a..bc63131 100644 --- a/ModernUO.Serialization.Generator/SerializableMigration/Rules/SortedSetMigrationRule.cs +++ b/ModernUO.Serialization.Generator/SerializableMigration/Rules/SortedSetMigrationRule.cs @@ -89,7 +89,6 @@ out string[] ruleArguments public override void GenerateDeserializationMethod( StringBuilder source, string indent, - Compilation compilation, SerializableProperty property, string? parentReference, bool isMigration = false @@ -132,7 +131,6 @@ public override void GenerateDeserializationMethod( source.AppendLine($"{indent}{{"); GenerateDeserialize( source, - compilation, $"{indent} ", propertyName, parentReference, @@ -151,7 +149,6 @@ public override void GenerateDeserializationMethod( { GenerateDeserialize( source, - compilation, indent, propertyName, parentReference, @@ -165,7 +162,6 @@ public override void GenerateDeserializationMethod( private static void GenerateDeserialize( StringBuilder source, - Compilation compilation, string indent, string propertyName, string parentReference, @@ -198,7 +194,6 @@ private static void GenerateDeserialize( setElementRule.GenerateDeserializationMethod( source, $"{indent} ", - compilation, serializableSetElement, parentReference ); diff --git a/ModernUO.Serialization.Generator/SerializableMigration/Rules/TimerMigrationRule.cs b/ModernUO.Serialization.Generator/SerializableMigration/Rules/TimerMigrationRule.cs index f210322..69dc1f8 100644 --- a/ModernUO.Serialization.Generator/SerializableMigration/Rules/TimerMigrationRule.cs +++ b/ModernUO.Serialization.Generator/SerializableMigration/Rules/TimerMigrationRule.cs @@ -47,7 +47,7 @@ out string[] ruleArguments } public override void GenerateMigrationProperty( - StringBuilder source, Compilation compilation, string indent, SerializableProperty property + StringBuilder source, string indent, SerializableProperty property ) { source.AppendLine($"{indent}internal readonly System.DateTime {property.Name}Next;"); @@ -57,7 +57,6 @@ public override void GenerateMigrationProperty( public override void GenerateDeserializationMethod( StringBuilder source, string indent, - Compilation compilation, SerializableProperty property, string? parentReference, bool isMigration = false @@ -98,41 +97,17 @@ public override void GenerateSerializationMethod(StringBuilder source, string in } public void PostDeserializeMethod( - StringBuilder source, string indent, SerializableProperty property, Compilation compilation, INamedTypeSymbol classSymbol + StringBuilder source, string indent, SerializableProperty property, SerializationModel model ) { - var deserializeTimerMethod = classSymbol - .GetMembers() - .OfType() - .FirstOrDefault( - m => - { - if (!m.ReturnsVoid || m.Parameters.Length != 1 || !m.Parameters[0].Type.IsTimeSpan(compilation)) - { - return false; - } - - return m.GetAttributes() - .FirstOrDefault( - attr => - { - if (!SymbolEqualityComparer.Default.Equals( - attr.AttributeClass, - compilation.GetTypeByMetadataName( - SymbolMetadata.DESERIALIZE_TIMER_FIELD_ATTRIBUTE - ) - )) - { - return false; - } - - var order = (int)attr.ConstructorArguments[0].Value!; - return order == property.Order; - } - ) != null; - } - ) ?? throw new DeserializeTimerFieldRequiredException(property.Name); - - source.AppendLine($"{indent}{deserializeTimerMethod.Name}({property.Name}Delay);"); + // Resolved during model building; SG3008 fires there when the method is missing. + foreach (var timerField in model.TimerFields) + { + if (timerField.Order == property.Order) + { + source.AppendLine($"{indent}{timerField.DeserializeMethodName}({property.Name}Delay);"); + return; + } + } } } diff --git a/ModernUO.Serialization.Generator/SerializableMigration/SerializableMetadata.cs b/ModernUO.Serialization.Generator/SerializableMigration/SerializableMetadata.cs index d4945e2..9fd7899 100644 --- a/ModernUO.Serialization.Generator/SerializableMigration/SerializableMetadata.cs +++ b/ModernUO.Serialization.Generator/SerializableMigration/SerializableMetadata.cs @@ -28,4 +28,67 @@ public record SerializableMetadata [JsonPropertyName("properties")] public ImmutableArray? Properties { get; init; } + + // ImmutableArray compares by reference under default record equality; the pipeline caches + // on value equality, so compare the sequence. + public virtual bool Equals(SerializableMetadata? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + if (Version != other.Version || Type != other.Type) + { + return false; + } + + var a = Properties; + var b = other.Properties; + + if (a is null || b is null) + { + return a is null == b is null; + } + + if (a.Value.Length != b.Value.Length) + { + return false; + } + + for (var i = 0; i < a.Value.Length; i++) + { + if (!a.Value[i].Equals(b.Value[i])) + { + return false; + } + } + + return true; + } + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = hash * 31 + Version; + hash = hash * 31 + (Type?.GetHashCode() ?? 0); + + if (Properties is { } properties) + { + for (var i = 0; i < properties.Length; i++) + { + hash = hash * 31 + properties[i].GetHashCode(); + } + } + + return hash; + } + } } diff --git a/ModernUO.Serialization.Generator/SerializableMigration/SerializableProperty.cs b/ModernUO.Serialization.Generator/SerializableMigration/SerializableProperty.cs index db8c04e..3115709 100644 --- a/ModernUO.Serialization.Generator/SerializableMigration/SerializableProperty.cs +++ b/ModernUO.Serialization.Generator/SerializableMigration/SerializableProperty.cs @@ -42,4 +42,75 @@ public record SerializableProperty [JsonIgnore] public bool IsReadOnly { get; init; } + + /// + /// Whether resolves to a value type in the consuming compilation. + /// Filled by the pipeline for migration properties; not part of the schema. + /// + [JsonIgnore] + public bool? TypeIsValueType { get; init; } + + // string[] compares by reference under default record equality; the pipeline caches on + // value equality, so compare the sequence. + public virtual bool Equals(SerializableProperty? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + if (Name != other.Name || Type != other.Type || UsesSaveFlag != other.UsesSaveFlag || + Rule != other.Rule || Order != other.Order || FieldName != other.FieldName || + IsReadOnly != other.IsReadOnly || TypeIsValueType != other.TypeIsValueType) + { + return false; + } + + if (RuleArguments is null) + { + return other.RuleArguments is null; + } + + if (other.RuleArguments is null || RuleArguments.Length != other.RuleArguments.Length) + { + return false; + } + + for (var i = 0; i < RuleArguments.Length; i++) + { + if (RuleArguments[i] != other.RuleArguments[i]) + { + return false; + } + } + + return true; + } + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = hash * 31 + (Name?.GetHashCode() ?? 0); + hash = hash * 31 + (Type?.GetHashCode() ?? 0); + hash = hash * 31 + (Rule?.GetHashCode() ?? 0); + hash = hash * 31 + Order; + + if (RuleArguments != null) + { + for (var i = 0; i < RuleArguments.Length; i++) + { + hash = hash * 31 + (RuleArguments[i]?.GetHashCode() ?? 0); + } + } + + return hash; + } + } } diff --git a/ModernUO.Serialization.Generator/SourceGeneration/EquatableArray.cs b/ModernUO.Serialization.Generator/SourceGeneration/EquatableArray.cs new file mode 100644 index 0000000..e627c14 --- /dev/null +++ b/ModernUO.Serialization.Generator/SourceGeneration/EquatableArray.cs @@ -0,0 +1,105 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2026 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: EquatableArray.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace ModernUO.Serialization.Generator; + +/// +/// An immutable array with sequence-based value equality, so records holding one participate +/// correctly in incremental pipeline caching. +/// +public readonly struct EquatableArray(ImmutableArray array) : IEquatable>, IEnumerable + where T : IEquatable +{ + public static readonly EquatableArray Empty = new(ImmutableArray.Empty); + + private readonly ImmutableArray _array = array; + + public int Length => _array.IsDefault ? 0 : _array.Length; + + public bool IsDefaultOrEmpty => _array.IsDefaultOrEmpty; + + public T this[int index] => _array[index]; + + public ImmutableArray AsImmutableArray() => _array.IsDefault ? ImmutableArray.Empty : _array; + + public bool Equals(EquatableArray other) + { + var a = _array; + var b = other._array; + + if (a.IsDefaultOrEmpty) + { + return b.IsDefaultOrEmpty; + } + + if (b.IsDefaultOrEmpty || a.Length != b.Length) + { + return false; + } + + for (var i = 0; i < a.Length; i++) + { + if (a[i] is null ? b[i] is not null : !a[i].Equals(b[i])) + { + return false; + } + } + + return true; + } + + public override bool Equals(object obj) => obj is EquatableArray other && Equals(other); + + public override int GetHashCode() + { + if (_array.IsDefaultOrEmpty) + { + return 0; + } + + unchecked + { + var hash = 17; + for (var i = 0; i < _array.Length; i++) + { + hash = hash * 31 + (_array[i]?.GetHashCode() ?? 0); + } + + return hash; + } + } + + public static bool operator ==(EquatableArray left, EquatableArray right) => left.Equals(right); + + public static bool operator !=(EquatableArray left, EquatableArray right) => !left.Equals(right); + + public IEnumerator GetEnumerator() => ((IEnumerable)AsImmutableArray()).GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} + +public static class EquatableArray +{ + public static EquatableArray ToEquatableArray(this IEnumerable source) where T : IEquatable => + new(source.ToImmutableArray()); + + public static EquatableArray ToEquatableArray(this ImmutableArray source) where T : IEquatable => + new(source); +} diff --git a/ModernUO.Serialization.Generator/SourceGeneration/SourceGeneration.Arguments.cs b/ModernUO.Serialization.Generator/SourceGeneration/SourceGeneration.Arguments.cs index edb2007..eab203d 100644 --- a/ModernUO.Serialization.Generator/SourceGeneration/SourceGeneration.Arguments.cs +++ b/ModernUO.Serialization.Generator/SourceGeneration/SourceGeneration.Arguments.cs @@ -39,12 +39,12 @@ public static void GetTypesFromTypedConstant(TypedConstant arg, List parameters) + public void GenerateSignatureArguments(ImmutableArray<(string, string)> parameters) { for (var i = 0; i < parameters.Length; i++) { var (t, v) = parameters[i]; - source.AppendFormat("{0} {1}", t.ToDisplayString(), v); + source.AppendFormat("{0} {1}", t, v); if (i < parameters.Length - 1) { source.Append(", "); diff --git a/ModernUO.Serialization.Generator/SourceGeneration/SourceGeneration.Method.cs b/ModernUO.Serialization.Generator/SourceGeneration/SourceGeneration.Method.cs index 91c5387..63e5b33 100644 --- a/ModernUO.Serialization.Generator/SourceGeneration/SourceGeneration.Method.cs +++ b/ModernUO.Serialization.Generator/SourceGeneration/SourceGeneration.Method.cs @@ -25,7 +25,7 @@ public static partial class SourceGeneration { public void GenerateMethodStart( string indent, string methodName, Accessibility accessors, bool isOverride, - string returnType, ImmutableArray<(ITypeSymbol, string)> parameters, bool isVirtual = true + string returnType, ImmutableArray<(string, string)> parameters, bool isVirtual = true ) { var modifier = isOverride ? " override" : isVirtual ? " virtual" : ""; @@ -37,7 +37,7 @@ public void GenerateMethodStart( public void GenerateMethodEnd(string indent) => source.AppendLine($"{indent}}}"); public void GenerateConstructorStart( - string indent, string className, Accessibility accessors, ImmutableArray<(ITypeSymbol, string)> parameters, + string indent, string className, Accessibility accessors, ImmutableArray<(string, string)> parameters, ImmutableArray? baseParameters, bool isOverload = false ) { diff --git a/ModernUO.Serialization.Generator/SourceGeneration/SourceGeneration.Property.cs b/ModernUO.Serialization.Generator/SourceGeneration/SourceGeneration.Property.cs index 968449c..b407fa2 100644 --- a/ModernUO.Serialization.Generator/SourceGeneration/SourceGeneration.Property.cs +++ b/ModernUO.Serialization.Generator/SourceGeneration/SourceGeneration.Property.cs @@ -48,13 +48,13 @@ public void GeneratePropertyStart( string indent, Accessibility accessors, bool isVirtual, - IFieldSymbol fieldSymbol + string typeDisplay, + string propertyName ) { - var propertyName = fieldSymbol.Name.GetPropertyName(); var virt = isVirtual ? "virtual " : ""; - source.AppendLine($"{indent}{accessors.ToFriendlyString()} {virt}{fieldSymbol.Type} {propertyName}"); + source.AppendLine($"{indent}{accessors.ToFriendlyString()} {virt}{typeDisplay} {propertyName}"); source.AppendLine($"{indent}{{"); } @@ -100,12 +100,12 @@ public void GenerateAutoProperty( public void GeneratePropertyGetterReturnsField( string indent, - IFieldSymbol fieldSymbol, + string fieldName, Accessibility Accessibility ) { var accessor = Accessibility != Accessibility.NotApplicable ? $"{Accessibility.ToFriendlyString()} " : ""; - source.AppendLine($"{indent}{accessor}get => {fieldSymbol.Name};"); + source.AppendLine($"{indent}{accessor}get => {fieldName};"); } public void GeneratePropertyGetterStart( diff --git a/ModernUO.Serialization.Generator/SourceGeneration/SymbolMetadata/SymbolMetadata.UO.cs b/ModernUO.Serialization.Generator/SourceGeneration/SymbolMetadata/SymbolMetadata.UO.cs index 410d7b1..5db536e 100644 --- a/ModernUO.Serialization.Generator/SourceGeneration/SymbolMetadata/SymbolMetadata.UO.cs +++ b/ModernUO.Serialization.Generator/SourceGeneration/SymbolMetadata/SymbolMetadata.UO.cs @@ -312,6 +312,41 @@ public bool HasInequalityOperator() return symbol.GetMembers("op_Inequality").Length > 0; } + /// + /// Finds the [DeserializeTimerField] method for the given field order, or null. + /// + public IMethodSymbol GetDeserializeTimerMethod(Compilation compilation, int order) + { + return symbol + .GetMembers() + .OfType() + .FirstOrDefault( + m => + { + if (!m.ReturnsVoid || m.Parameters.Length != 1 || !m.Parameters[0].Type.IsTimeSpan(compilation)) + { + return false; + } + + return m.GetAttributes() + .FirstOrDefault( + attr => + { + if (!SymbolEqualityComparer.Default.Equals( + attr.AttributeClass, + compilation.GetTypeByMetadataName(DESERIALIZE_TIMER_FIELD_ATTRIBUTE) + )) + { + return false; + } + + return (int)attr.ConstructorArguments[0].Value! == order; + } + ) != null; + } + ); + } + public bool HasPublicSerializeMethod(Compilation compilation) { var genericWriterInterface = compilation.GetTypeByMetadataName(GENERIC_WRITER_INTERFACE);