diff --git a/src/ChibiRuby.Serializer.SourceGenerator/ChibiRubySerializerSourceGenerator.cs b/src/ChibiRuby.Serializer.SourceGenerator/ChibiRubySerializerSourceGenerator.cs index ea5613fc..2ab69833 100644 --- a/src/ChibiRuby.Serializer.SourceGenerator/ChibiRubySerializerSourceGenerator.cs +++ b/src/ChibiRuby.Serializer.SourceGenerator/ChibiRubySerializerSourceGenerator.cs @@ -1,4 +1,4 @@ -using System.Text; +using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -9,96 +9,38 @@ public class ChibiRubySerializerSourceGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { - var serializableProvider = context.SyntaxProvider + // Extract a fully value-equatable model in the transform stage. This keeps the + // pipeline free of Compilation/symbol/syntax references, so the incremental driver + // can cache per type and skip generation when nothing relevant changed. + var models = context.SyntaxProvider .ForAttributeWithMetadataName( "ChibiRuby.Serializer.MRubyObjectAttribute", - static (node, cancellation) => + static (node, _) => node is ClassDeclarationSyntax or StructDeclarationSyntax or RecordDeclarationSyntax, - static (context, cancellation) => context); - // .Combine(context.CompilationProvider); - //.WithComparer(Comparer.Instance) + static (context, cancellation) => MRubyObjectModel.Create(context, cancellation)) + .WithTrackingName("MRubyObjectModels"); - context.RegisterSourceOutput( - context.CompilationProvider.Combine(serializableProvider.Collect()), - (productionContext, t) => - { - var (compilation, list) = t; - var references = ReferenceSymbols.Create(compilation); - if (references is null) - { - return; - } - - var stringBuilder = new StringBuilder(); - - foreach (var x in list) - { - var typeMeta = new MRubyObjectTypeMeta( - (TypeDeclarationSyntax)x.TargetNode, - (INamedTypeSymbol)x.TargetSymbol, - x.Attributes.First(), - references); - - if (TryEmitMRubyObjectType(typeMeta, stringBuilder, references, productionContext)) - { - var fullType = typeMeta.Symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) - .Replace("global::", "") - .Replace("<", "_") - .Replace(">", "_"); - - productionContext.AddSource($"{fullType}.g.cs", stringBuilder.ToString()); - } - stringBuilder.Clear(); - } - }); - } - - static bool TryEmitMRubyObjectType( - MRubyObjectTypeMeta typeMeta, - StringBuilder stringBuilder, - ReferenceSymbols references, - in SourceProductionContext context) - { - try + context.RegisterSourceOutput(models, static (productionContext, model) => { - var error = false; - - // verify is partial - if (!typeMeta.IsPartial()) - { - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.MustBePartial, - typeMeta.Syntax.Identifier.GetLocation(), - typeMeta.Symbol.Name)); - error = true; - } - - // nested is not allowed - if (typeMeta.IsNested()) + foreach (var diagnostic in model.Diagnostics) { - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.NestedNotAllow, - typeMeta.Syntax.Identifier.GetLocation(), - typeMeta.Symbol.Name)); - error = true; + productionContext.ReportDiagnostic(diagnostic.ToDiagnostic()); } - // verify abstract/interface - if (typeMeta.Symbol.IsAbstract) + if (model.HasError) { - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.AbstractNotAllow, - typeMeta.Syntax.Identifier.GetLocation(), - typeMeta.TypeName)); - error = true; + return; } - if (error) - { - return false; - } + var stringBuilder = new StringBuilder(); + Emit(model, stringBuilder); + productionContext.AddSource($"{model.HintName}.g.cs", stringBuilder.ToString()); + }); + } - stringBuilder.AppendLine($$""" + static void Emit(MRubyObjectModel model, StringBuilder stringBuilder) + { + stringBuilder.AppendLine($$""" // #nullable enable #pragma warning disable CS0162 // Unreachable code @@ -115,44 +57,28 @@ static bool TryEmitMRubyObjectType( using ChibiRuby.Serializer; """); - var ns = typeMeta.Symbol.ContainingNamespace; - if (!ns.IsGlobalNamespace) - { - stringBuilder.AppendLine($$""" + if (model.Namespace is { } ns) + { + stringBuilder.AppendLine($$""" namespace {{ns}} { """); - } - - var typeDeclarationKeyword = (typeMeta.Symbol.IsRecord, typeMeta.Symbol.IsValueType) switch - { - (true, true) => "record struct", - (true, false) => "record", - (false, true) => "struct", - (false, false) => "class", - }; + } - stringBuilder.AppendLine($$""" -partial {{typeDeclarationKeyword}} {{typeMeta.TypeName}} + stringBuilder.AppendLine($$""" +partial {{model.TypeDeclarationKeyword}} {{model.TypeName}} { """); - // EmitCCtor(typeMeta, codeWriter, in context); - if (!TryEmitRegisterMethod(typeMeta, stringBuilder, in context)) - { - return false; - } - if (!TryEmitFormatter(typeMeta, stringBuilder, references, in context)) - { - return false; - } + EmitRegisterMethod(model, stringBuilder); + EmitFormatter(model, stringBuilder); + stringBuilder.AppendLine("}"); + if (model.Namespace is not null) + { stringBuilder.AppendLine("}"); - if (!ns.IsGlobalNamespace) - { - stringBuilder.AppendLine("}"); - } + } - stringBuilder.AppendLine($$""" + stringBuilder.AppendLine($$""" #pragma warning restore CS0162 // Unreachable code #pragma warning restore CS0219 // Variable assigned but never used #pragma warning restore CS8600 // Converting null literal or possible null value to non-nullable type. @@ -161,106 +87,80 @@ namespace {{ns}} #pragma warning restore CS8604 // Possible null reference argument for parameter #pragma warning restore CS8631 // The type cannot be used as type parameter in the generic type or method """); - return true; - } - catch (Exception ex) - { - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.UnexpectedErrorDescriptor, - Location.None, - ex.ToString())); - return false; - } } - static bool TryEmitFormatter( - MRubyObjectTypeMeta typeMeta, - StringBuilder stringBuilder, - ReferenceSymbols references, - in SourceProductionContext context) + static void EmitRegisterMethod(MRubyObjectModel model, StringBuilder stringBuilder) { - var returnType = typeMeta.Symbol.IsValueType - ? typeMeta.FullTypeName - : $"{typeMeta.FullTypeName}?"; + stringBuilder.AppendLine($$""" + [global::ChibiRuby.Serializer.Preserve] + public static void __RegisterMRubyValueFormatter() + { + global::ChibiRuby.Serializer.GeneratedResolver.Register(new {{model.TypeName}}GeneratedFormatter()); + } + +"""); + } + + static void EmitFormatter(MRubyObjectModel model, StringBuilder stringBuilder) + { + var returnType = model.IsValueType + ? model.FullTypeName + : $"{model.FullTypeName}?"; stringBuilder.AppendLine($$""" [global::ChibiRuby.Serializer.Preserve] - public class {{typeMeta.TypeName}}GeneratedFormatter : IMRubyValueFormatter<{{returnType}}> + public class {{model.TypeName}}GeneratedFormatter : IMRubyValueFormatter<{{returnType}}> { """); // Default - foreach (var memberMeta in typeMeta.MemberMetas) + foreach (var member in model.Members) { stringBuilder.Append($$""" - static readonly byte[] {{memberMeta.Name}}KeyUtf8 = + static readonly byte[] {{member.Name}}KeyUtf8 = """); - stringBuilder.AppendByteArrayString(memberMeta.KeyNameUtf8Bytes); - stringBuilder.AppendLine($"; // {memberMeta.KeyName}"); + stringBuilder.AppendByteArrayString(System.Text.Encoding.UTF8.GetBytes(member.KeyName)); + stringBuilder.AppendLine($"; // {member.KeyName}"); } stringBuilder.AppendLine(); - if (!TryEmitSerializeMethod(typeMeta, stringBuilder, references, in context)) - { - return false; - } - - if (!TryEmitDeserializeMethod(typeMeta, stringBuilder, references, in context)) - { - return false; - } - stringBuilder.AppendLine($$""" - } -"""); - return true; - } + EmitSerializeMethod(model, stringBuilder); + EmitDeserializeMethod(model, stringBuilder); - static bool TryEmitRegisterMethod(MRubyObjectTypeMeta typeMeta, StringBuilder stringBuilder, in SourceProductionContext context) - { stringBuilder.AppendLine($$""" - [global::ChibiRuby.Serializer.Preserve] - public static void __RegisterMRubyValueFormatter() - { - global::ChibiRuby.Serializer.GeneratedResolver.Register(new {{typeMeta.TypeName}}GeneratedFormatter()); } - """); - return true; } - static bool TryEmitSerializeMethod( - MRubyObjectTypeMeta typeMeta, - StringBuilder stringBuilder, - ReferenceSymbols references, - in SourceProductionContext context) + static void EmitSerializeMethod(MRubyObjectModel model, StringBuilder stringBuilder) { - var inputType = typeMeta.Symbol.IsValueType - ? typeMeta.FullTypeName - : $"{typeMeta.FullTypeName}?"; + var inputType = model.IsValueType + ? model.FullTypeName + : $"{model.FullTypeName}?"; stringBuilder.AppendLine($$""" [global::ChibiRuby.Serializer.Preserve] public MRubyValue Serialize({{inputType}} value, MRubyState state, MRubyValueSerializerOptions options) { """); - if (!typeMeta.Symbol.IsValueType) + if (!model.IsValueType) { stringBuilder.AppendLine($$""" if (value is null) return default; """); } stringBuilder.AppendLine($$""" - var props = state.NewHash({{typeMeta.MemberMetas.Count}}); + var props = state.NewHash({{model.Members.Count}}); """); - foreach (var memberMeta in typeMeta.MemberMetas) + foreach (var member in model.Members) { stringBuilder.AppendLine($$""" - var key{{memberMeta.Name}} = new MRubyValue(state.Intern({{memberMeta.Name}}KeyUtf8)); - var value{{memberMeta.Name}} = options.Resolver.GetFormatterWithVerify<{{memberMeta.FullTypeName}}>() - .Serialize(value.{{memberMeta.Name}}, state, options); - props.Add(key{{memberMeta.Name}}, value{{memberMeta.Name}}); + var key{{member.Name}} = new MRubyValue(state.Intern({{member.Name}}KeyUtf8)); + var value{{member.Name}} = options.Resolver.GetFormatterWithVerify<{{member.FullTypeName}}>() + .Serialize(value.{{member.Name}}, state, options); + props.Add(key{{member.Name}}, value{{member.Name}}); """); } @@ -270,61 +170,19 @@ public MRubyValue Serialize({{inputType}} value, MRubyState state, MRubyValueSer } """); - return true; } - static bool TryEmitDeserializeMethod( - MRubyObjectTypeMeta typeMeta, - StringBuilder stringBuilder, - ReferenceSymbols references, - in SourceProductionContext context) + static void EmitDeserializeMethod(MRubyObjectModel model, StringBuilder stringBuilder) { - if (!TryGetConstructor(typeMeta, references, in context, - out var selectedConstructor, - out var constructedMembers)) - { - return false; - } - - var setterMembers = typeMeta.MemberMetas - .Where(x => - { - return constructedMembers.All(constructedMember => !SymbolEqualityComparer.Default.Equals(x.Symbol, constructedMember.Symbol)); - }) - .ToArray(); - - foreach (var setterMember in setterMembers) - { - switch (setterMember) - { - case { IsProperty: true, IsSettable: false }: - { - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.MRubyObjectPropertyMustHaveSetter, - setterMember.GetLocation(typeMeta.Syntax), - typeMeta.TypeName, - setterMember.Name)); - return false; - } - case { IsField: true, IsSettable: false }: - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.MRubyObjectFieldCannotBeReadonly, - setterMember.GetLocation(typeMeta.Syntax), - typeMeta.TypeName, - setterMember.Name)); - return false; - } - } - - var returnType = typeMeta.Symbol.IsValueType - ? typeMeta.FullTypeName - : $"{typeMeta.FullTypeName}?"; + var returnType = model.IsValueType + ? model.FullTypeName + : $"{model.FullTypeName}?"; stringBuilder.AppendLine($$""" [global::ChibiRuby.Serializer.Preserve] public {{returnType}} Deserialize(MRubyValue value, MRubyState state, MRubyValueSerializerOptions options) { """); - if (!typeMeta.Symbol.IsValueType) + if (!model.IsValueType) { stringBuilder.AppendLine($$""" if (value.IsNil) return default; @@ -332,24 +190,24 @@ static bool TryEmitDeserializeMethod( """); } - if (typeMeta.MemberMetas.Count <= 0) + if (model.Members.Count <= 0) { stringBuilder.AppendLine($$""" - return new {{typeMeta.TypeName}}(); + return new {{model.TypeName}}(); } """); - return true; + return; } stringBuilder.AppendLine($$""" - MRubySerializationException.ThrowIfTypeMismatch(value, MRubyVType.Hash, "{{typeMeta.TypeName}}"); + MRubySerializationException.ThrowIfTypeMismatch(value, MRubyVType.Hash, "{{model.TypeName}}"); """); - foreach (var memberMeta in typeMeta.MemberMetas) + foreach (var member in model.Members) { stringBuilder.AppendLine($$""" - var __{{memberMeta.Name}}__ = {{memberMeta.EmitDefaultValue()}}; - var __{{memberMeta.Name}}NameSymbol__ = state.Intern({{memberMeta.Name}}KeyUtf8); + var __{{member.Name}}__ = {{member.DefaultValueExpr}}; + var __{{member.Name}}NameSymbol__ = state.Intern({{member.Name}}KeyUtf8); """); } @@ -359,13 +217,13 @@ static bool TryEmitDeserializeMethod( { """); var branching = "if"; - foreach (var memberMeta in typeMeta.MemberMetas) + foreach (var member in model.Members) { stringBuilder.AppendLine($$""" - {{branching}} (entry.Key.SymbolValue == __{{memberMeta.Name}}NameSymbol__) + {{branching}} (entry.Key.SymbolValue == __{{member.Name}}NameSymbol__) { - __{{memberMeta.Name}}__ = options.Resolver - .GetFormatterWithVerify<{{memberMeta.FullTypeName}}>() + __{{member.Name}}__ = options.Resolver + .GetFormatterWithVerify<{{member.FullTypeName}}>() .Deserialize(entry.Value, state, options); continue; } @@ -377,31 +235,29 @@ static bool TryEmitDeserializeMethod( """); stringBuilder.Append($$""" - return new {{typeMeta.TypeName}} + return new {{model.TypeName}} """); - if (selectedConstructor != null) + if (model.HasSelectedConstructor) { - var parameters = string.Join(",", constructedMembers.Select(x => $"__{x.Name}__")); + var parameters = string.Join(",", model.ConstructorParameterNames.Select(x => $"__{x}__")); stringBuilder.AppendLine($"({parameters})"); } else { stringBuilder.AppendLine("()"); } + + var setterMembers = model.Members.Where(x => !x.IsConstructorParameter).ToArray(); if (setterMembers.Length > 0) { stringBuilder.AppendLine($$""" { """); - foreach (var setterMember in setterMembers) { - if (!constructedMembers.Contains(setterMember)) - { - stringBuilder.AppendLine($$""" + stringBuilder.AppendLine($$""" {{setterMember.Name}} = __{{setterMember.Name}}__, """); - } } stringBuilder.AppendLine($$""" } @@ -411,88 +267,5 @@ static bool TryEmitDeserializeMethod( ; } """); - return true; - } - - static bool TryGetConstructor( - MRubyObjectTypeMeta typeMeta, - ReferenceSymbols reference, - in SourceProductionContext context, - out IMethodSymbol? selectedConstructor, - out IReadOnlyList constructedMembers) - { - if (typeMeta.Constructors.Count <= 0) - { - selectedConstructor = null; - constructedMembers = []; - return true; - } - - if (typeMeta.Constructors.Count == 1) - { - selectedConstructor = typeMeta.Constructors[0]; - } - else - { - var ctorWithAttrs = typeMeta.Constructors - .Where(x => x.ContainsAttribute(reference.MRubyConstructorAttribute)) - .ToArray(); - - switch (ctorWithAttrs.Length) - { - case 1: - selectedConstructor = ctorWithAttrs[0]; - break; - case > 1: - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.MultipleConstructorAttribute, - typeMeta.Syntax.Identifier.GetLocation(), - typeMeta.Symbol.Name)); - selectedConstructor = null; - constructedMembers = []; - return false; - - default: - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.MultipleConstructorWithoutAttribute, - typeMeta.Syntax.Identifier.GetLocation(), - typeMeta.Symbol.Name)); - selectedConstructor = null; - constructedMembers = []; - return false; - } - } - - var parameterMembers = new List(); - var error = false; - foreach (var parameter in selectedConstructor.Parameters) - { - var matchedMember = typeMeta.MemberMetas - .FirstOrDefault(member => parameter.Name.Equals(member.Name, StringComparison.OrdinalIgnoreCase)); - if (matchedMember != null) - { - matchedMember.IsConstructorParameter = true; - if (parameter.HasExplicitDefaultValue) - { - matchedMember.HasExplicitDefaultValueFromConstructor = true; - matchedMember.ExplicitDefaultValueFromConstructor = parameter.ExplicitDefaultValue; - } - parameterMembers.Add(matchedMember); - } - else - { - var location = selectedConstructor.Locations.FirstOrDefault() ?? - typeMeta.Syntax.Identifier.GetLocation(); - context.ReportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.ConstructorHasNoMatchedParameter, - location, - typeMeta.Symbol.Name, - parameter.Name)); - constructedMembers = []; - error = true; - } - } - constructedMembers = parameterMembers; - return !error; } } diff --git a/src/ChibiRuby.Serializer.SourceGenerator/MRubyObjectModel.cs b/src/ChibiRuby.Serializer.SourceGenerator/MRubyObjectModel.cs new file mode 100644 index 00000000..a140480b --- /dev/null +++ b/src/ChibiRuby.Serializer.SourceGenerator/MRubyObjectModel.cs @@ -0,0 +1,345 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace ChibiRuby.Serializer.SourceGenerator; + +/// +/// Value-equatable wrapper around so that records +/// containing it participate correctly in the incremental generator cache. +/// +readonly struct EquatableArray : IEquatable>, IEnumerable + where T : IEquatable +{ + public static readonly EquatableArray Empty = new(ImmutableArray.Empty); + + readonly ImmutableArray array; + + public EquatableArray(ImmutableArray array) => this.array = array; + + public int Count => array.IsDefault ? 0 : array.Length; + public T this[int index] => array[index]; + + public bool Equals(EquatableArray other) + { + if (array.IsDefault) return other.array.IsDefault; + if (other.array.IsDefault) return false; + if (array.Length != other.array.Length) return false; + for (var i = 0; i < array.Length; i++) + { + if (!array[i].Equals(other.array[i])) return false; + } + return true; + } + + public override bool Equals(object? obj) => obj is EquatableArray other && Equals(other); + + public override int GetHashCode() + { + if (array.IsDefault) return 0; + var hash = 17; + foreach (var item in array) + { + hash = unchecked(hash * 31 + (item?.GetHashCode() ?? 0)); + } + return hash; + } + + public IEnumerator GetEnumerator() => + (array.IsDefault ? Enumerable.Empty() : array).GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} + +/// +/// Equatable, compilation-independent representation of a diagnostic source location, +/// so diagnostics can be carried through the incremental cache and re-materialized later. +/// +sealed record LocationInfo(string FilePath, TextSpan TextSpan, LinePositionSpan LineSpan) +{ + public Location ToLocation() => Location.Create(FilePath, TextSpan, LineSpan); + + public static LocationInfo? CreateFrom(Location location) + { + if (location.Kind != LocationKind.SourceFile || location.SourceTree is null) + { + return null; + } + return new LocationInfo( + location.SourceTree.FilePath, + location.SourceSpan, + location.GetLineSpan().Span); + } +} + +/// Equatable diagnostic descriptor + args that can be cached and reported at output time. +sealed record DiagnosticInfo( + DiagnosticDescriptor Descriptor, + LocationInfo? Location, + EquatableArray MessageArgs) : IEquatable +{ + public Diagnostic ToDiagnostic() + { + var location = Location?.ToLocation() ?? Microsoft.CodeAnalysis.Location.None; + return Diagnostic.Create(Descriptor, location, MessageArgs.Cast().ToArray()); + } + + public static DiagnosticInfo Create(DiagnosticDescriptor descriptor, Location? location, params string[] messageArgs) => + new(descriptor, + location is null ? null : LocationInfo.CreateFrom(location), + new EquatableArray(messageArgs.ToImmutableArray())); +} + +/// Equatable member info used to emit the formatter for a single serializable member. +sealed record MRubyMemberModel( + string Name, + string FullTypeName, + string KeyName, + string DefaultValueExpr, + bool IsConstructorParameter) : IEquatable; + +/// +/// Fully value-equatable model for a single [MRubyObject] type. Carries everything the +/// emitter needs, so -based caching +/// in the incremental pipeline can skip code generation when nothing relevant changed. +/// +sealed record MRubyObjectModel( + string HintName, + string TypeName, + string FullTypeName, + string? Namespace, + string TypeDeclarationKeyword, + bool IsValueType, + bool HasSelectedConstructor, + EquatableArray ConstructorParameterNames, + EquatableArray Members, + EquatableArray Diagnostics, + bool HasError) : IEquatable +{ + public static MRubyObjectModel Create(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) + { + try + { + return CreateCore(context, cancellationToken); + } + catch (Exception ex) + { + return ErrorOnly(DiagnosticInfo.Create( + DiagnosticDescriptors.UnexpectedErrorDescriptor, null, ex.ToString())); + } + } + + static MRubyObjectModel ErrorOnly(params DiagnosticInfo[] diagnostics) => + new(string.Empty, string.Empty, string.Empty, null, "class", false, false, + EquatableArray.Empty, + EquatableArray.Empty, + new EquatableArray(diagnostics.ToImmutableArray()), + HasError: true); + + static MRubyObjectModel CreateCore(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) + { + var references = ReferenceSymbols.Create(context.SemanticModel.Compilation); + if (references is null) + { + return ErrorOnly(); + } + + var typeMeta = new MRubyObjectTypeMeta( + (TypeDeclarationSyntax)context.TargetNode, + (INamedTypeSymbol)context.TargetSymbol, + context.Attributes.First(), + references); + + var diagnostics = ImmutableArray.CreateBuilder(); + + // --- structural validation (mirrors original TryEmitMRubyObjectType) --- + var structuralError = false; + if (!typeMeta.IsPartial()) + { + diagnostics.Add(DiagnosticInfo.Create( + DiagnosticDescriptors.MustBePartial, + typeMeta.Syntax.Identifier.GetLocation(), + typeMeta.Symbol.Name)); + structuralError = true; + } + if (typeMeta.IsNested()) + { + diagnostics.Add(DiagnosticInfo.Create( + DiagnosticDescriptors.NestedNotAllow, + typeMeta.Syntax.Identifier.GetLocation(), + typeMeta.Symbol.Name)); + structuralError = true; + } + if (typeMeta.Symbol.IsAbstract) + { + diagnostics.Add(DiagnosticInfo.Create( + DiagnosticDescriptors.AbstractNotAllow, + typeMeta.Syntax.Identifier.GetLocation(), + typeMeta.TypeName)); + structuralError = true; + } + + if (structuralError) + { + return ErrorOnly(diagnostics.ToArray()); + } + + cancellationToken.ThrowIfCancellationRequested(); + + // --- constructor selection (mirrors TryGetConstructor) --- + if (!TryGetConstructor(typeMeta, references, diagnostics, + out var hasSelectedConstructor, out var constructedMembers)) + { + return ErrorOnly(diagnostics.ToArray()); + } + + // --- setter member validation (mirrors TryEmitDeserializeMethod) --- + var constructedSet = new HashSet(constructedMembers.Select(x => x.Symbol), SymbolEqualityComparer.Default); + foreach (var member in typeMeta.MemberMetas) + { + if (constructedSet.Contains(member.Symbol)) continue; + switch (member) + { + case { IsProperty: true, IsSettable: false }: + diagnostics.Add(DiagnosticInfo.Create( + DiagnosticDescriptors.MRubyObjectPropertyMustHaveSetter, + member.GetLocation(typeMeta.Syntax), + typeMeta.TypeName, member.Name)); + return ErrorOnly(diagnostics.ToArray()); + case { IsField: true, IsSettable: false }: + diagnostics.Add(DiagnosticInfo.Create( + DiagnosticDescriptors.MRubyObjectFieldCannotBeReadonly, + member.GetLocation(typeMeta.Syntax), + typeMeta.TypeName, member.Name)); + return ErrorOnly(diagnostics.ToArray()); + } + } + + // --- build the equatable member models (order preserved) --- + var memberModels = ImmutableArray.CreateBuilder(typeMeta.MemberMetas.Count); + foreach (var member in typeMeta.MemberMetas) + { + memberModels.Add(new MRubyMemberModel( + member.Name, + member.FullTypeName, + member.KeyName, + member.EmitDefaultValue(), + IsConstructorParameter: constructedSet.Contains(member.Symbol))); + } + + var typeDeclarationKeyword = (typeMeta.Symbol.IsRecord, typeMeta.Symbol.IsValueType) switch + { + (true, true) => "record struct", + (true, false) => "record", + (false, true) => "struct", + (false, false) => "class", + }; + + var ns = typeMeta.Symbol.ContainingNamespace; + var hintName = typeMeta.Symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + .Replace("global::", "") + .Replace("<", "_") + .Replace(">", "_"); + + return new MRubyObjectModel( + HintName: hintName, + TypeName: typeMeta.TypeName, + FullTypeName: typeMeta.FullTypeName, + Namespace: ns.IsGlobalNamespace ? null : ns.ToDisplayString(), + TypeDeclarationKeyword: typeDeclarationKeyword, + IsValueType: typeMeta.Symbol.IsValueType, + HasSelectedConstructor: hasSelectedConstructor, + ConstructorParameterNames: new EquatableArray(constructedMembers.Select(x => x.Name).ToImmutableArray()), + Members: new EquatableArray(memberModels.ToImmutable()), + Diagnostics: new EquatableArray(diagnostics.ToImmutable()), + HasError: false); + } + + static bool TryGetConstructor( + MRubyObjectTypeMeta typeMeta, + ReferenceSymbols reference, + ImmutableArray.Builder diagnostics, + out bool hasSelectedConstructor, + out IReadOnlyList constructedMembers) + { + IMethodSymbol? selectedConstructor; + if (typeMeta.Constructors.Count <= 0) + { + hasSelectedConstructor = false; + constructedMembers = []; + return true; + } + + if (typeMeta.Constructors.Count == 1) + { + selectedConstructor = typeMeta.Constructors[0]; + } + else + { + var ctorWithAttrs = typeMeta.Constructors + .Where(x => x.ContainsAttribute(reference.MRubyConstructorAttribute)) + .ToArray(); + + switch (ctorWithAttrs.Length) + { + case 1: + selectedConstructor = ctorWithAttrs[0]; + break; + case > 1: + diagnostics.Add(DiagnosticInfo.Create( + DiagnosticDescriptors.MultipleConstructorAttribute, + typeMeta.Syntax.Identifier.GetLocation(), + typeMeta.Symbol.Name)); + hasSelectedConstructor = false; + constructedMembers = []; + return false; + default: + diagnostics.Add(DiagnosticInfo.Create( + DiagnosticDescriptors.MultipleConstructorWithoutAttribute, + typeMeta.Syntax.Identifier.GetLocation(), + typeMeta.Symbol.Name)); + hasSelectedConstructor = false; + constructedMembers = []; + return false; + } + } + + var parameterMembers = new List(); + var error = false; + foreach (var parameter in selectedConstructor.Parameters) + { + var matchedMember = typeMeta.MemberMetas + .FirstOrDefault(member => parameter.Name.Equals(member.Name, StringComparison.OrdinalIgnoreCase)); + if (matchedMember != null) + { + matchedMember.IsConstructorParameter = true; + if (parameter.HasExplicitDefaultValue) + { + matchedMember.HasExplicitDefaultValueFromConstructor = true; + matchedMember.ExplicitDefaultValueFromConstructor = parameter.ExplicitDefaultValue; + } + parameterMembers.Add(matchedMember); + } + else + { + var location = selectedConstructor.Locations.FirstOrDefault() ?? + typeMeta.Syntax.Identifier.GetLocation(); + diagnostics.Add(DiagnosticInfo.Create( + DiagnosticDescriptors.ConstructorHasNoMatchedParameter, + location, + typeMeta.Symbol.Name, parameter.Name)); + error = true; + } + } + + hasSelectedConstructor = true; + constructedMembers = parameterMembers; + return !error; + } +} diff --git a/src/ChibiRuby.Serializer.SourceGenerator/Polyfills.cs b/src/ChibiRuby.Serializer.SourceGenerator/Polyfills.cs new file mode 100644 index 00000000..c7a41eb1 --- /dev/null +++ b/src/ChibiRuby.Serializer.SourceGenerator/Polyfills.cs @@ -0,0 +1,4 @@ +// netstandard2.0 does not ship IsExternalInit, which `record` init-only setters require. +namespace System.Runtime.CompilerServices; + +internal static class IsExternalInit { } diff --git a/tests/ChibiRuby.Serializer.Tests/ChibiRuby.Serializer.Tests.csproj b/tests/ChibiRuby.Serializer.Tests/ChibiRuby.Serializer.Tests.csproj index 6e5e5980..0f055097 100644 --- a/tests/ChibiRuby.Serializer.Tests/ChibiRuby.Serializer.Tests.csproj +++ b/tests/ChibiRuby.Serializer.Tests/ChibiRuby.Serializer.Tests.csproj @@ -14,6 +14,8 @@ + + @@ -25,7 +27,8 @@ Analyzer - false + + true diff --git a/tests/ChibiRuby.Serializer.Tests/IncrementalGeneratorTest.cs b/tests/ChibiRuby.Serializer.Tests/IncrementalGeneratorTest.cs new file mode 100644 index 00000000..45676fdc --- /dev/null +++ b/tests/ChibiRuby.Serializer.Tests/IncrementalGeneratorTest.cs @@ -0,0 +1,107 @@ +using System.Collections.Immutable; +using ChibiRuby.Serializer.SourceGenerator; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace ChibiRuby.Serializer.Tests; + +[TestFixture] +public class IncrementalGeneratorTest +{ + // A `[MRubyObject]` target plus an unrelated method, all in one syntax tree. + const string BaseSource = """ +using ChibiRuby.Serializer; + +namespace TestApp; + +[MRubyObject] +public partial class Person +{ + public int Age { get; set; } + public string Name { get; set; } = ""; +} + +public static class Unrelated +{ + public static int Compute() => 1; +} +"""; + + // Identical `[MRubyObject]` target; only the unrelated method body changed. + const string ModifiedUnrelatedSource = """ +using ChibiRuby.Serializer; + +namespace TestApp; + +[MRubyObject] +public partial class Person +{ + public int Age { get; set; } + public string Name { get; set; } = ""; +} + +public static class Unrelated +{ + public static int Compute() => 2 + 3 + 4; +} +"""; + + static CSharpCompilation CreateCompilation(string source) + { + var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator) + .Where(p => !string.IsNullOrEmpty(p)) + .Select(p => MetadataReference.CreateFromFile(p)) + .Append(MetadataReference.CreateFromFile(typeof(MRubyObjectAttribute).Assembly.Location)) + .Append(MetadataReference.CreateFromFile(typeof(MRubyState).Assembly.Location)); + + return CSharpCompilation.Create( + "IncrementalTestAssembly", + new[] { CSharpSyntaxTree.ParseText(source) }, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } + + [Test] + public void Regeneration_IsSkipped_WhenUnrelatedCodeChanges() + { + var generator = new ChibiRubySerializerSourceGenerator().AsSourceGenerator(); + var driver = CSharpGeneratorDriver.Create( + new[] { generator }, + driverOptions: new GeneratorDriverOptions(default, trackIncrementalGeneratorSteps: true)); + + // First run establishes the cache. + var compilation1 = CreateCompilation(BaseSource); + GeneratorDriver runDriver = driver.RunGenerators(compilation1); + + var firstResult = runDriver.GetRunResult().Results.Single(); + Assert.That(firstResult.TrackedSteps["MRubyObjectModels"] + .SelectMany(s => s.Outputs) + .Select(o => o.Reason), Is.All.EqualTo(IncrementalStepRunReason.New), + "first run should produce a fresh model"); + + // Second run: only the unrelated method body changed. The `[MRubyObject]` model is + // value-equal, so the transform output is Unchanged and the source output is skipped. + var compilation2 = CreateCompilation(ModifiedUnrelatedSource); + runDriver = runDriver.RunGenerators(compilation2); + + var secondResult = runDriver.GetRunResult().Results.Single(); + + var modelReasons = secondResult.TrackedSteps["MRubyObjectModels"] + .SelectMany(s => s.Outputs) + .Select(o => o.Reason) + .ToArray(); + Assert.That(modelReasons, Is.All.EqualTo(IncrementalStepRunReason.Unchanged), + "the equatable model must compare equal so downstream steps can be skipped"); + + // The actual code-generation output step must be skipped (Cached), not re-run. + var outputReasons = secondResult.TrackedOutputSteps + .SelectMany(kv => kv.Value) + .SelectMany(s => s.Outputs) + .Select(o => o.Reason) + .ToArray(); + Assert.That(outputReasons, Is.Not.Empty); + Assert.That(outputReasons, Is.All.EqualTo(IncrementalStepRunReason.Cached), + "source output should be served from cache when the model is unchanged"); + } +}