diff --git a/src/PatternKit.Generators/MementoGenerator.cs b/src/PatternKit.Generators/MementoGenerator.cs
index f8db7105..a9a611be 100644
--- a/src/PatternKit.Generators/MementoGenerator.cs
+++ b/src/PatternKit.Generators/MementoGenerator.cs
@@ -348,17 +348,20 @@ private string GenerateMementoStruct(TypeInfo typeInfo, SourceProductionContext
}
sb.AppendLine();
- // Constructor
- sb.Append($" private {typeInfo.TypeName}Memento(");
- sb.Append(string.Join(", ", typeInfo.Members.Select(m => $"{m.Type} {ToCamelCase(m.Name)}")));
- sb.AppendLine(")");
- sb.AppendLine(" {");
- foreach (var member in typeInfo.Members)
+ if (typeInfo.Members.Count > 0)
{
- sb.AppendLine($" {member.Name} = {ToCamelCase(member.Name)};");
+ // Constructor
+ sb.Append($" private {typeInfo.TypeName}Memento(");
+ sb.Append(string.Join(", ", typeInfo.Members.Select(m => $"{m.Type} {ToCamelCase(m.Name)}")));
+ sb.AppendLine(")");
+ sb.AppendLine(" {");
+ foreach (var member in typeInfo.Members)
+ {
+ sb.AppendLine($" {member.Name} = {ToCamelCase(member.Name)};");
+ }
+ sb.AppendLine(" }");
+ sb.AppendLine();
}
- sb.AppendLine(" }");
- sb.AppendLine();
// Capture method
GenerateCaptureMethod(sb, typeInfo);
@@ -449,7 +452,7 @@ private void GenerateRestoreNewMethod(StringBuilder sb, TypeInfo typeInfo, Sourc
else
{
// Fall back to object initializer
- sb.AppendLine("()");
+ AppendRecordFallbackConstructor(sb, typeInfo);
sb.AppendLine(" {");
foreach (var member in typeInfo.Members)
{
@@ -487,6 +490,24 @@ private void GenerateRestoreNewMethod(StringBuilder sb, TypeInfo typeInfo, Sourc
sb.AppendLine(" }");
}
+ private static void AppendRecordFallbackConstructor(StringBuilder sb, TypeInfo typeInfo)
+ {
+ var fallbackCtor = typeInfo.TypeSymbol.Constructors
+ .Where(c => !c.IsStatic && c.DeclaredAccessibility == Accessibility.Public)
+ .OrderBy(c => c.Parameters.Length)
+ .FirstOrDefault();
+
+ if (fallbackCtor is null || fallbackCtor.Parameters.Length == 0)
+ {
+ sb.AppendLine("()");
+ return;
+ }
+
+ sb.Append("(");
+ sb.Append(string.Join(", ", fallbackCtor.Parameters.Select(_ => "default!")));
+ sb.AppendLine(")");
+ }
+
private void GenerateInPlaceRestoreMethod(StringBuilder sb, TypeInfo typeInfo)
{
sb.AppendLine($" /// Restores the memento state to an existing originator instance (in-place).");
diff --git a/test/PatternKit.Generators.Tests/DecoratorGeneratorTests.cs b/test/PatternKit.Generators.Tests/DecoratorGeneratorTests.cs
index d1c3f68c..373d16d0 100644
--- a/test/PatternKit.Generators.Tests/DecoratorGeneratorTests.cs
+++ b/test/PatternKit.Generators.Tests/DecoratorGeneratorTests.cs
@@ -1206,4 +1206,39 @@ string Format(
var emit = updated.Emit(Stream.Null);
ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics));
}
+
+ [Scenario("Diagnostic PKDEC004 InaccessiblePropertyDeclaration")]
+ [Fact]
+ public void Diagnostic_PKDEC004_InaccessiblePropertyDeclaration()
+ {
+ const string source = """
+ using PatternKit.Generators.Decorator;
+
+ namespace TestNamespace;
+
+ [GenerateDecorator]
+ public abstract class SecretRepository
+ {
+ private protected abstract string Confidential { get; }
+ public abstract string Visible { get; }
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(Diagnostic_PKDEC004_InaccessiblePropertyDeclaration));
+ var gen = new DecoratorGenerator();
+ _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated);
+
+ ScenarioExpect.Contains(result.Results.SelectMany(r => r.Diagnostics), d => d.Id == "PKDEC004" && d.GetMessage().Contains("Confidential"));
+
+ var generatedSource = result.Results
+ .SelectMany(r => r.GeneratedSources)
+ .Single(gs => gs.HintName == "TestNamespace_SecretRepository.Decorator.g.cs")
+ .SourceText.ToString();
+
+ ScenarioExpect.Contains("Visible", generatedSource);
+ ScenarioExpect.DoesNotContain("Confidential", generatedSource);
+
+ var emit = updated.Emit(Stream.Null);
+ ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics));
+ }
}
diff --git a/test/PatternKit.Generators.Tests/MementoGeneratorTests.cs b/test/PatternKit.Generators.Tests/MementoGeneratorTests.cs
index da174744..94faaa7a 100644
--- a/test/PatternKit.Generators.Tests/MementoGeneratorTests.cs
+++ b/test/PatternKit.Generators.Tests/MementoGeneratorTests.cs
@@ -399,4 +399,192 @@ public partial record class EditorState(string Text, int Cursor);
ScenarioExpect.Matches(@"private\s+const\s+int\s+MaxCapacity\s*=\s*50", caretakerSource);
ScenarioExpect.Matches(@"if\s*\(\s*_states\.Count\s*>\s*MaxCapacity\s*\)", caretakerSource);
}
+
+ [Scenario("ExplicitCaptureStrategies AreAcceptedWithoutReferenceWarnings")]
+ [Fact]
+ public void ExplicitCaptureStrategies_AreAcceptedWithoutReferenceWarnings()
+ {
+ const string source = """
+ using PatternKit.Generators;
+ using System.Collections.Generic;
+
+ namespace TestNamespace;
+
+ [Memento]
+ public partial class Document
+ {
+ [MementoStrategy(MementoCaptureStrategy.ByReference)]
+ public List Tags { get; set; } = new();
+
+ [MementoStrategy(MementoCaptureStrategy.Clone)]
+ public CloneableBuffer Cloneable { get; set; } = new();
+
+ [MementoStrategy(MementoCaptureStrategy.DeepCopy)]
+ public CloneableBuffer Deep { get; set; } = new();
+
+ [MementoStrategy(MementoCaptureStrategy.Custom)]
+ public CloneableBuffer Custom { get; set; } = new();
+ }
+
+ public sealed class CloneableBuffer
+ {
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ExplicitCaptureStrategies_AreAcceptedWithoutReferenceWarnings));
+ var gen = new MementoGenerator();
+ _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated);
+
+ ScenarioExpect.DoesNotContain(result.Results.SelectMany(r => r.Diagnostics), d => d.Id == "PKMEM003");
+
+ var mementoSource = result.Results
+ .SelectMany(r => r.GeneratedSources)
+ .Single(gs => gs.HintName == "Document.Memento.g.cs")
+ .SourceText.ToString();
+
+ ScenarioExpect.Contains("global::System.Collections.Generic.List Tags", mementoSource);
+ ScenarioExpect.Contains("global::TestNamespace.CloneableBuffer Cloneable", mementoSource);
+ ScenarioExpect.Contains("global::TestNamespace.CloneableBuffer Deep", mementoSource);
+ ScenarioExpect.Contains("global::TestNamespace.CloneableBuffer Custom", mementoSource);
+
+ var emit = updated.Emit(Stream.Null);
+ ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics));
+ }
+
+ [Scenario("RecordWithoutPrimaryConstructor RestoresWithObjectInitializer")]
+ [Fact]
+ public void RecordWithoutPrimaryConstructor_RestoresWithObjectInitializer()
+ {
+ const string source = """
+ using PatternKit.Generators;
+
+ namespace TestNamespace;
+
+ [Memento]
+ public partial record class EditorState
+ {
+ public string Text { get; init; } = "";
+ public int Cursor { get; init; }
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(RecordWithoutPrimaryConstructor_RestoresWithObjectInitializer));
+ var gen = new MementoGenerator();
+ _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated);
+
+ ScenarioExpect.All(result.Results, r => ScenarioExpect.Empty(r.Diagnostics));
+
+ var mementoSource = result.Results
+ .SelectMany(r => r.GeneratedSources)
+ .Single(gs => gs.HintName == "EditorState.Memento.g.cs")
+ .SourceText.ToString();
+
+ ScenarioExpect.Contains("return new global::TestNamespace.EditorState()", mementoSource);
+ ScenarioExpect.Contains("Text = this.Text,", mementoSource);
+ ScenarioExpect.Contains("Cursor = this.Cursor,", mementoSource);
+
+ var emit = updated.Emit(Stream.Null);
+ ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics));
+ }
+
+ [Scenario("ClassWithOnlyReadonlyMembers RestoresWithDefaultConstructor")]
+ [Fact]
+ public void ClassWithOnlyReadonlyMembers_RestoresWithDefaultConstructor()
+ {
+ const string source = """
+ using PatternKit.Generators;
+
+ namespace TestNamespace;
+
+ [Memento]
+ public partial class ReadOnlyState
+ {
+ public readonly int Version;
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(ClassWithOnlyReadonlyMembers_RestoresWithDefaultConstructor));
+ var gen = new MementoGenerator();
+ _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated);
+
+ ScenarioExpect.All(result.Results, r => ScenarioExpect.Empty(r.Diagnostics));
+
+ var mementoSource = result.Results
+ .SelectMany(r => r.GeneratedSources)
+ .Single(gs => gs.HintName == "ReadOnlyState.Memento.g.cs")
+ .SourceText.ToString();
+
+ ScenarioExpect.Contains("public int Version { get; }", mementoSource);
+ ScenarioExpect.Contains("return new global::TestNamespace.ReadOnlyState();", mementoSource);
+ ScenarioExpect.DoesNotContain("originator.Version = this.Version;", mementoSource);
+
+ var emit = updated.Emit(Stream.Null);
+ ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics));
+ }
+
+ [Scenario("RecordWithNoCapturedMembers RestoresWithDefaultConstructor")]
+ [Fact]
+ public void RecordWithNoCapturedMembers_RestoresWithDefaultConstructor()
+ {
+ const string source = """
+ using PatternKit.Generators;
+
+ namespace TestNamespace;
+
+ [Memento]
+ public partial record class EmptyState;
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(RecordWithNoCapturedMembers_RestoresWithDefaultConstructor));
+ var gen = new MementoGenerator();
+ _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated);
+
+ ScenarioExpect.All(result.Results, r => ScenarioExpect.Empty(r.Diagnostics));
+
+ var mementoSource = result.Results
+ .SelectMany(r => r.GeneratedSources)
+ .Single(gs => gs.HintName == "EmptyState.Memento.g.cs")
+ .SourceText.ToString();
+
+ ScenarioExpect.DoesNotContain("private EmptyStateMemento()", mementoSource);
+ ScenarioExpect.Contains("return new global::TestNamespace.EmptyState();", mementoSource);
+
+ var emit = updated.Emit(Stream.Null);
+ ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics));
+ }
+
+ [Scenario("RecordWithIgnoredPrimaryConstructorMember FallsBackToObjectInitializer")]
+ [Fact]
+ public void RecordWithIgnoredPrimaryConstructorMember_FallsBackToObjectInitializer()
+ {
+ const string source = """
+ using PatternKit.Generators;
+
+ namespace TestNamespace;
+
+ [Memento]
+ public partial record class FilteredState([property: MementoIgnore] string Raw)
+ {
+ public string Name { get; init; } = Raw;
+ }
+ """;
+
+ var comp = RoslynTestHelpers.CreateCompilation(source, nameof(RecordWithIgnoredPrimaryConstructorMember_FallsBackToObjectInitializer));
+ var gen = new MementoGenerator();
+ _ = RoslynTestHelpers.Run(comp, gen, out var result, out var updated);
+
+ ScenarioExpect.All(result.Results, r => ScenarioExpect.Empty(r.Diagnostics));
+
+ var mementoSource = result.Results
+ .SelectMany(r => r.GeneratedSources)
+ .Single(gs => gs.HintName == "FilteredState.Memento.g.cs")
+ .SourceText.ToString();
+
+ ScenarioExpect.Contains("return new global::TestNamespace.FilteredState(default!)", mementoSource);
+ ScenarioExpect.Contains("Name = this.Name,", mementoSource);
+ ScenarioExpect.DoesNotContain("Raw = this.Raw", mementoSource);
+
+ var emit = updated.Emit(Stream.Null);
+ ScenarioExpect.True(emit.Success, string.Join("\n", emit.Diagnostics));
+ }
}