diff --git a/src/Beutl.Engine.SourceGenerators/AnalyzerReleases.Unshipped.md b/src/Beutl.Engine.SourceGenerators/AnalyzerReleases.Unshipped.md index 4a19740f32..aa742fc21a 100644 --- a/src/Beutl.Engine.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/src/Beutl.Engine.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -4,3 +4,7 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- BESG001 | Beutl.Engine.SourceGenerators | Warning | EngineObjectResourceGenerator BESG002 | Beutl.Engine.SourceGenerators | Warning | FallbackTypeGenerator +BESG003 | Beutl.Engine.SourceGenerators | Error | EngineObjectResourceGenerator +BESG004 | Beutl.Engine.SourceGenerators | Error | EngineObjectResourceGenerator +BESG005 | Beutl.Engine.SourceGenerators | Error | EngineObjectResourceGenerator +BESG006 | Beutl.Engine.SourceGenerators | Error | EngineObjectResourceGenerator diff --git a/src/Beutl.Engine.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs b/src/Beutl.Engine.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs index 77b73bba5b..da8b67b46b 100644 --- a/src/Beutl.Engine.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs +++ b/src/Beutl.Engine.SourceGenerators/Diagnostics/DiagnosticDescriptors.cs @@ -19,4 +19,36 @@ public static class DiagnosticDescriptors category: "Beutl.Engine.SourceGenerators", defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor ResourcePropertyMissingInitializer = new( + id: "BESG003", + title: "Stable resource property declaration required", + messageFormat: "Property '{0}.{1}' must expose one stable declaration-time IProperty so detached Resource defaults can be generated; use a declaration initializer or a readonly computed backing field, do not replace it in a constructor, add a valid [ResourceDefaultValuesProvider] factory, or suppress Resource generation and implement Resource/ToResource manually", + category: "Beutl.Engine.SourceGenerators", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor ResourcePrimaryConstructorNotSupported = new( + id: "BESG004", + title: "Primary constructor is incompatible with generated Resource defaults", + messageFormat: "Type '{0}' cannot use a primary constructor with initializer-only detached Resource defaults; add a valid [ResourceDefaultValuesProvider] factory, move to an ordinary constructor, or suppress Resource generation and implement Resource/ToResource manually", + category: "Beutl.Engine.SourceGenerators", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor ResourceDefaultValuesProviderInvalid = new( + id: "BESG005", + title: "Resource defaults provider signature is invalid", + messageFormat: "Type '{0}' must declare exactly one [ResourceDefaultValuesProvider] method that is static, parameterless, non-generic, and returns '{0}'", + category: "Beutl.Engine.SourceGenerators", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor ResourceDefaultValuesProviderRequiredOnDerivedType = new( + id: "BESG006", + title: "Derived resource defaults provider required", + messageFormat: "Type '{0}' derives from a type with an explicit resource defaults provider and must declare its own [ResourceDefaultValuesProvider] factory so inherited detached defaults are not evaluated through the initializer-only path", + category: "Beutl.Engine.SourceGenerators", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); } diff --git a/src/Beutl.Engine.SourceGenerators/Emit/ResourceClassEmitter.cs b/src/Beutl.Engine.SourceGenerators/Emit/ResourceClassEmitter.cs index df1640db05..51311ea5ef 100644 --- a/src/Beutl.Engine.SourceGenerators/Emit/ResourceClassEmitter.cs +++ b/src/Beutl.Engine.SourceGenerators/Emit/ResourceClassEmitter.cs @@ -38,6 +38,7 @@ public static void Emit(StringBuilder sb, string indent, string currentTypeDispl string innerIndent = indent + " "; + EmitConstructors(sb, innerIndent, currentTypeDisplay, info); EmitFields(sb, innerIndent, info); EmitProperties(sb, innerIndent, info); EmitGetOriginal(sb, innerIndent, currentTypeDisplay); @@ -48,6 +49,83 @@ public static void Emit(StringBuilder sb, string indent, string currentTypeDispl sb.Append(indent).AppendLine("}"); } + private static void EmitConstructors( + StringBuilder sb, + string innerIndent, + string currentTypeDisplay, + ClassInfo info) + { + if (!info.Symbol.IsAbstract) + { + sb.Append(innerIndent) + .Append("internal static Resource __CreateAttached") + .Append(info.Symbol.Name) + .AppendLine("()"); + sb.Append(innerIndent).AppendLine(" => new(skipDefaultInitialization: true);"); + sb.AppendLine(); + + sb.Append(innerIndent).AppendLine("public Resource()"); + sb.Append(innerIndent) + .Append(" : this(") + .Append(currentTypeDisplay) + .AppendLine(".__CreateResourceDefaultValues())"); + sb.Append(innerIndent).AppendLine("{"); + sb.Append(innerIndent).AppendLine("}"); + sb.AppendLine(); + } + + sb.Append(innerIndent) + .Append("protected Resource(") + .Append(currentTypeDisplay) + .AppendLine(" defaultValues)"); + sb.Append(innerIndent).AppendLine(" : base(defaultValues)"); + sb.Append(innerIndent).AppendLine("{"); + foreach (ValuePropertyInfo property in info.ValueProperties) + { + if (property.ExcludeFromResource) continue; + + string fieldName = EmitHelpers.ToFieldName(property.Name); + sb.Append(innerIndent) + .Append(" ") + .Append(fieldName) + .Append(" = defaultValues.") + .Append(property.Name) + .AppendLine(".DefaultValue;"); + } + foreach (ObjectPropertyInfo property in info.ObjectProperties) + { + if (property.ExcludeFromResource) continue; + + string fieldName = EmitHelpers.ToFieldName(property.Name); + string resourceType = EmitHelpers.GetResourceTypeName(property.ValueType); + string localName = fieldName + "DefaultValue"; + sb.Append(innerIndent) + .Append(" if (defaultValues.") + .Append(property.Name) + .Append(".DefaultValue is { } ") + .Append(localName) + .AppendLine(")"); + sb.Append(innerIndent).AppendLine(" {"); + sb.Append(innerIndent) + .Append(" ") + .Append(fieldName) + .Append(" = (") + .Append(resourceType) + .Append(")") + .Append(localName) + .AppendLine(".ToResource(global::Beutl.Composition.CompositionContext.Default);"); + sb.Append(innerIndent).AppendLine(" }"); + } + sb.Append(innerIndent).AppendLine("}"); + sb.AppendLine(); + + sb.Append(innerIndent).AppendLine("protected Resource(bool skipDefaultInitialization)"); + sb.Append(innerIndent).AppendLine(" : base(skipDefaultInitialization)"); + sb.Append(innerIndent).AppendLine("{"); + sb.Append(innerIndent).AppendLine("}"); + sb.AppendLine(); + } + private static void EmitFields(StringBuilder sb, string innerIndent, ClassInfo info) { foreach (ValuePropertyInfo property in info.ValueProperties) diff --git a/src/Beutl.Engine.SourceGenerators/Emit/ResourceDefaultValuesEmitter.cs b/src/Beutl.Engine.SourceGenerators/Emit/ResourceDefaultValuesEmitter.cs new file mode 100644 index 0000000000..834a2dfbb2 --- /dev/null +++ b/src/Beutl.Engine.SourceGenerators/Emit/ResourceDefaultValuesEmitter.cs @@ -0,0 +1,93 @@ +using System.Text; + +using Beutl.Engine.SourceGenerators.Models; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Beutl.Engine.SourceGenerators.Emit; + +public static class ResourceDefaultValuesEmitter +{ + public static void Emit( + StringBuilder sb, + string indent, + string currentTypeDisplay, + ClassInfo info, + string? defaultsProviderMethod) + { + if (info.SuppressedResourceGeneration) return; + + if (defaultsProviderMethod is not null) + { + EmitProviderFactory(sb, indent, currentTypeDisplay, defaultsProviderMethod); + return; + } + + string constructorAccessibility = info.Symbol.IsSealed ? "private" : "protected"; + string constructionType = + "global::Beutl.Engine.EngineObject.ResourceDefaultValuesConstruction"; + + IMethodSymbol? implicitParameterlessConstructor = info.Symbol.InstanceConstructors + .FirstOrDefault(static constructor => + constructor.IsImplicitlyDeclared && constructor.Parameters.Length == 0); + if (implicitParameterlessConstructor is not null) + { + sb.Append(indent) + .Append(EmitHelpers.GetAccessibility(implicitParameterlessConstructor.DeclaredAccessibility)) + .Append(' ') + .Append(info.Symbol.Name) + .AppendLine("()"); + sb.Append(indent).AppendLine("{"); + sb.Append(indent).AppendLine("}"); + sb.AppendLine(); + } + + sb.Append(indent) + .AppendLine("[global::System.Diagnostics.CodeAnalysis.SetsRequiredMembers]"); + sb.Append(indent) + .Append(constructorAccessibility) + .Append(' ') + .Append(info.Symbol.Name) + .Append('(') + .Append(constructionType) + .AppendLine(" construction)"); + sb.Append(indent).AppendLine(" : base(construction)"); + sb.Append(indent).AppendLine("{"); + sb.Append(indent).AppendLine("}"); + + if (!info.Symbol.IsAbstract) + { + sb.AppendLine(); + sb.Append(indent) + .Append("private static ") + .Append(currentTypeDisplay) + .AppendLine(" __CreateResourceDefaultValues()"); + sb.Append(indent) + .Append(" => new(default(") + .Append(constructionType) + .AppendLine("));"); + } + } + + private static void EmitProviderFactory( + StringBuilder sb, + string indent, + string currentTypeDisplay, + string defaultsProviderMethod) + { + string escapedMethodName = SyntaxFacts.GetKeywordKind(defaultsProviderMethod) == SyntaxKind.None + ? defaultsProviderMethod + : "@" + defaultsProviderMethod; + sb.Append(indent) + .Append("private static ") + .Append(currentTypeDisplay) + .AppendLine(" __CreateResourceDefaultValues()"); + sb.Append(indent) + .Append(" => ") + .Append(currentTypeDisplay) + .Append('.') + .Append(escapedMethodName) + .AppendLine("();"); + } +} diff --git a/src/Beutl.Engine.SourceGenerators/Emit/ToResourceMethodEmitter.cs b/src/Beutl.Engine.SourceGenerators/Emit/ToResourceMethodEmitter.cs index 34ebb2d1a7..246ce9dd33 100644 --- a/src/Beutl.Engine.SourceGenerators/Emit/ToResourceMethodEmitter.cs +++ b/src/Beutl.Engine.SourceGenerators/Emit/ToResourceMethodEmitter.cs @@ -20,7 +20,12 @@ public static void Emit(StringBuilder sb, string indent, string currentTypeDispl { sb.Append(indent).AppendLine($"public override {currentTypeDisplay}.Resource ToResource({renderContextType} context)"); sb.Append(indent).AppendLine("{"); - sb.Append(indent).AppendLine($" var resource = new {currentTypeDisplay}.Resource();"); + sb.Append(indent) + .Append(" var resource = ") + .Append(currentTypeDisplay) + .Append(".Resource.__CreateAttached") + .Append(info.Symbol.Name) + .AppendLine("();"); sb.Append(indent).AppendLine(" bool updateOnly = true;"); sb.Append(indent).AppendLine(" resource.Update(this, context, ref updateOnly);"); sb.Append(indent).AppendLine($" return resource;"); diff --git a/src/Beutl.Engine.SourceGenerators/EngineObjectResourceGenerator.cs b/src/Beutl.Engine.SourceGenerators/EngineObjectResourceGenerator.cs index bfdd44114c..d857d63ab6 100644 --- a/src/Beutl.Engine.SourceGenerators/EngineObjectResourceGenerator.cs +++ b/src/Beutl.Engine.SourceGenerators/EngineObjectResourceGenerator.cs @@ -7,7 +7,9 @@ using Beutl.Engine.SourceGenerators.Models; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; namespace Beutl.Engine.SourceGenerators; @@ -36,6 +38,8 @@ private static void Execute(SourceProductionContext context, Compilation compila } var processed = new HashSet(SymbolEqualityComparer.Default); + INamedTypeSymbol? defaultsProviderAttribute = compilation.GetTypeByMetadataName( + "Beutl.Engine.ResourceDefaultValuesProviderAttribute"); foreach (ClassInfo info in classes) { @@ -59,13 +63,374 @@ private static void Execute(SourceProductionContext context, Compilation compila continue; } - string source = GenerateSource(info); + IMethodSymbol? defaultsProvider = null; + if (!info.SuppressedResourceGeneration + && (!TryResolveResourceDefaultsProvider( + context, + info, + defaultsProviderAttribute, + out defaultsProvider) + || ReportMissingDerivedResourceDefaultsProvider( + context, + info, + defaultsProviderAttribute, + defaultsProvider) + || (defaultsProvider is null + && (ReportPrimaryConstructor(context, info) + || ReportInvalidResourcePropertyDeclarations(context, compilation, info))))) + { + continue; + } + + string source = GenerateSource(info, defaultsProvider?.Name); string hintName = EmitHelpers.GetHintName(info.Symbol); context.AddSource(hintName, source); } } - private static string GenerateSource(ClassInfo info) + private static bool TryResolveResourceDefaultsProvider( + SourceProductionContext context, + ClassInfo info, + INamedTypeSymbol? defaultsProviderAttribute, + out IMethodSymbol? provider) + { + provider = null; + if (defaultsProviderAttribute is null) + { + return true; + } + + IMethodSymbol[] candidates = info.Symbol.GetMembers() + .OfType() + .Where(method => HasAttribute(method, defaultsProviderAttribute)) + .ToArray(); + if (candidates.Length == 0) + { + return true; + } + + IMethodSymbol? candidate = candidates.Length == 1 ? candidates[0] : null; + if (candidate is null + || !candidate.IsStatic + || candidate.MethodKind != MethodKind.Ordinary + || candidate.Parameters.Length != 0 + || candidate.TypeParameters.Length != 0 + || !SymbolEqualityComparer.Default.Equals(candidate.ReturnType, info.Symbol) + || candidate.ReturnNullableAnnotation == NullableAnnotation.Annotated) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.ResourceDefaultValuesProviderInvalid, + candidate?.Locations.FirstOrDefault() ?? info.Symbol.Locations.FirstOrDefault(), + info.Symbol.ToDisplayString())); + return false; + } + + provider = candidate; + return true; + } + + private static bool ReportMissingDerivedResourceDefaultsProvider( + SourceProductionContext context, + ClassInfo info, + INamedTypeSymbol? defaultsProviderAttribute, + IMethodSymbol? defaultsProvider) + { + if (defaultsProvider is not null + || !HasResourceDefaultsProviderInBaseType(info.Symbol, defaultsProviderAttribute)) + { + return false; + } + + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.ResourceDefaultValuesProviderRequiredOnDerivedType, + info.Symbol.Locations.FirstOrDefault(), + info.Symbol.ToDisplayString())); + return true; + } + + private static bool HasResourceDefaultsProviderInBaseType( + INamedTypeSymbol symbol, + INamedTypeSymbol? defaultsProviderAttribute) + { + if (defaultsProviderAttribute is null) + { + return false; + } + + for (INamedTypeSymbol? current = symbol.BaseType; + current is not null; + current = current.BaseType) + { + if (current.GetMembers() + .OfType() + .Any(method => HasAttribute(method, defaultsProviderAttribute))) + { + return true; + } + } + + return false; + } + + private static bool HasAttribute(ISymbol symbol, INamedTypeSymbol attribute) + => symbol.GetAttributes().Any(item => + SymbolEqualityComparer.Default.Equals(item.AttributeClass, attribute)); + + private static bool ReportPrimaryConstructor( + SourceProductionContext context, + ClassInfo info) + { + if (info.SuppressedResourceGeneration) + { + return false; + } + + ClassDeclarationSyntax? declaration = info.Symbol.DeclaringSyntaxReferences + .Select(static syntaxReference => syntaxReference.GetSyntax()) + .OfType() + .FirstOrDefault(static classDeclaration => classDeclaration.ParameterList is not null); + if (declaration?.ParameterList is null) + { + return false; + } + + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.ResourcePrimaryConstructorNotSupported, + declaration.ParameterList.GetLocation(), + info.Symbol.ToDisplayString())); + return true; + } + + private static bool ReportInvalidResourcePropertyDeclarations( + SourceProductionContext context, + Compilation compilation, + ClassInfo info) + { + if (info.SuppressedResourceGeneration) + { + return false; + } + + var requiredNames = new HashSet( + info.ValueProperties + .Where(static property => !property.ExcludeFromResource) + .Select(static property => property.Name) + .Concat(info.ObjectProperties + .Where(static property => !property.ExcludeFromResource) + .Select(static property => property.Name)), + StringComparer.Ordinal); + bool reported = false; + foreach (IPropertySymbol property in info.Symbol.GetMembers().OfType()) + { + if (!requiredNames.Contains(property.Name)) + { + continue; + } + + bool hasDeclarationTimeStorage = TryGetDeclarationTimeStorage( + compilation, + property, + out ISymbol? storage); + Location? constructorAssignment = storage is null + ? null + : FindInstanceConstructorAssignment(compilation, info.Symbol, storage); + if (hasDeclarationTimeStorage && constructorAssignment is null) + { + continue; + } + + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.ResourcePropertyMissingInitializer, + constructorAssignment ?? property.Locations.FirstOrDefault(), + info.Symbol.ToDisplayString(), + property.Name)); + reported = true; + } + + INamedTypeSymbol? iPropertyDefinition = compilation.GetTypeByMetadataName("Beutl.Engine.IProperty`1"); + INamedTypeSymbol? suppressAttribute = compilation.GetTypeByMetadataName( + "Beutl.Engine.SuppressResourceClassGenerationAttribute"); + if (iPropertyDefinition is not null && suppressAttribute is not null) + { + for (INamedTypeSymbol? current = info.Symbol.BaseType; + current is not null; + current = current.BaseType) + { + foreach (IPropertySymbol property in current.GetMembers().OfType()) + { + if (property.IsStatic + || property.Type is not INamedTypeSymbol { IsGenericType: true } propertyType + || !SymbolEqualityComparer.Default.Equals( + propertyType.ConstructedFrom, + iPropertyDefinition) + || HasAttribute(property, suppressAttribute)) + { + continue; + } + + Location? constructorAssignment = FindInstanceConstructorAssignment( + compilation, + info.Symbol, + property); + if (constructorAssignment is null) + { + continue; + } + + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.ResourcePropertyMissingInitializer, + constructorAssignment, + info.Symbol.ToDisplayString(), + property.Name)); + reported = true; + } + } + } + + return reported; + } + + private static bool TryGetDeclarationTimeStorage( + Compilation compilation, + IPropertySymbol property, + out ISymbol? storage) + { + foreach (SyntaxReference syntaxReference in property.DeclaringSyntaxReferences) + { + if (syntaxReference.GetSyntax() is not PropertyDeclarationSyntax declaration) + { + continue; + } + + if (declaration.Initializer is not null) + { + storage = property; + return true; + } + + ExpressionSyntax? getterExpression = GetDirectGetterExpression(declaration); + if (getterExpression is null) + { + continue; + } + + IOperation? operation = compilation.GetSemanticModel(declaration.SyntaxTree) + .GetOperation(getterExpression); + while (operation is IConversionOperation conversion) + { + operation = conversion.Operand; + } + while (operation is IParenthesizedOperation parenthesized) + { + operation = parenthesized.Operand; + } + + if (operation is not IFieldReferenceOperation + { + Field: { IsReadOnly: true, IsStatic: false } field, + Instance: IInstanceReferenceOperation + { + ReferenceKind: InstanceReferenceKind.ContainingTypeInstance, + }, + } + || !field.DeclaringSyntaxReferences.Any(static fieldReference => + fieldReference.GetSyntax() is VariableDeclaratorSyntax { Initializer: not null })) + { + continue; + } + + storage = field; + return true; + } + + storage = null; + return false; + } + + private static ExpressionSyntax? GetDirectGetterExpression(PropertyDeclarationSyntax declaration) + { + if (declaration.ExpressionBody is not null) + { + return declaration.ExpressionBody.Expression; + } + + AccessorDeclarationSyntax? getter = declaration.AccessorList?.Accessors + .FirstOrDefault(static accessor => accessor.IsKind(SyntaxKind.GetAccessorDeclaration)); + if (getter?.ExpressionBody is not null) + { + return getter.ExpressionBody.Expression; + } + + return getter?.Body?.Statements.Count == 1 + && getter.Body.Statements[0] is ReturnStatementSyntax { Expression: { } expression } + ? expression + : null; + } + + private static Location? FindInstanceConstructorAssignment( + Compilation compilation, + INamedTypeSymbol containingType, + ISymbol storage) + { + foreach (SyntaxReference syntaxReference in containingType.DeclaringSyntaxReferences) + { + if (syntaxReference.GetSyntax() is not ClassDeclarationSyntax declaration) + { + continue; + } + + SemanticModel semanticModel = compilation.GetSemanticModel(declaration.SyntaxTree); + foreach (ConstructorDeclarationSyntax constructor in declaration.Members + .OfType()) + { + foreach (AssignmentExpressionSyntax assignment in constructor + .DescendantNodes(static node => + node is not AnonymousFunctionExpressionSyntax + and not LocalFunctionStatementSyntax) + .OfType()) + { + if (semanticModel.GetOperation(assignment) is not IAssignmentOperation operation) + { + continue; + } + + if (ContainsAssignedStorage(operation.Target, storage)) + { + return assignment.GetLocation(); + } + } + } + } + + return null; + } + + private static bool ContainsAssignedStorage(IOperation target, ISymbol storage) + { + return target switch + { + IConversionOperation conversion => ContainsAssignedStorage(conversion.Operand, storage), + IParenthesizedOperation parenthesized => ContainsAssignedStorage(parenthesized.Operand, storage), + ITupleOperation tuple => tuple.Elements.Any(element => ContainsAssignedStorage(element, storage)), + IPropertyReferenceOperation + { + Instance: IInstanceReferenceOperation + { + ReferenceKind: InstanceReferenceKind.ContainingTypeInstance, + }, + } propertyReference => SymbolEqualityComparer.Default.Equals(propertyReference.Property, storage), + IFieldReferenceOperation + { + Instance: IInstanceReferenceOperation + { + ReferenceKind: InstanceReferenceKind.ContainingTypeInstance, + }, + } fieldReference => SymbolEqualityComparer.Default.Equals(fieldReference.Field, storage), + _ => false, + }; + } + + private static string GenerateSource(ClassInfo info, string? defaultsProviderMethod) { INamedTypeSymbol symbol = info.Symbol; string? namespaceName = symbol.ContainingNamespace is { IsGlobalNamespace: false } ns @@ -75,7 +440,7 @@ private static string GenerateSource(ClassInfo info) var sb = new StringBuilder(); sb.AppendLine("// "); sb.AppendLine("#nullable enable"); - sb.AppendLine("#pragma warning disable CS8631"); + sb.AppendLine("#pragma warning disable CS8631, CS8618, CS9264"); sb.AppendLine(); if (namespaceName is not null) @@ -98,6 +463,13 @@ private static string GenerateSource(ClassInfo info) string indent = " "; string currentTypeDisplay = symbol.ToDisplayString(EmitHelpers.TypeDisplayFormat); + ResourceDefaultValuesEmitter.Emit( + sb, + indent, + currentTypeDisplay, + info, + defaultsProviderMethod); + sb.AppendLine(); ToResourceMethodEmitter.Emit(sb, indent, currentTypeDisplay, info); sb.AppendLine(); ScanPropertiesCoreEmitter.Emit(sb, indent, info); diff --git a/src/Beutl.Engine/Engine/EngineObject.cs b/src/Beutl.Engine/Engine/EngineObject.cs index 32c7dfc836..04818b34b5 100644 --- a/src/Beutl.Engine/Engine/EngineObject.cs +++ b/src/Beutl.Engine/Engine/EngineObject.cs @@ -17,6 +17,25 @@ public sealed partial class FallbackEngineObject : EngineObject, IFallback; [FallbackType(typeof(FallbackEngineObject))] public class EngineObject : Hierarchical, INotifyEdited { + /// + /// Identifies construction performed only to read the declared defaults of generated resource properties. + /// + /// + /// + /// Reserved for code emitted by Beutl.Engine.SourceGenerators. When no + /// is declared, the generated constructor chain runs + /// instance field and property initializers but intentionally skips user constructor bodies. Consequently, + /// every generated value or object must be available from declaration-time state. + /// + /// + /// This path creates only a short-lived defaults source. It does not establish invariants implemented by an + /// ordinary constructor and must not be used to construct an application or plugin object directly. + /// + /// + protected readonly struct ResourceDefaultValuesConstruction + { + } + // これらのプロパティは描画時ではなく編集時に更新されるべき public static readonly CoreProperty IsTimeAnchorProperty; public static readonly CoreProperty IsEnabledProperty; @@ -55,6 +74,23 @@ static EngineObject() AffectsRender(IsEnabledProperty, IsTimeAnchorProperty, ZIndexProperty, TimeRangeProperty); } + public EngineObject() + { + } + + /// + /// Initializes the source object used by generated detached-resource constructors to read property defaults. + /// + /// The generator-only construction marker. + /// + /// The generated public resource constructor uses this overload only when the owner has no explicit + /// . Application and plugin code constructs usable engine + /// objects through their ordinary constructors. + /// + protected EngineObject(ResourceDefaultValuesConstruction construction) + { + } + public virtual IReadOnlyList Properties => _properties; [NotAutoSerialized] @@ -374,6 +410,50 @@ public virtual Resource ToResource(CompositionContext context) public class Resource : IDisposable { + /// + /// Initializes an enabled detached base resource. + /// + public Resource() + { + IsEnabled = true; + } + + /// + /// Initializes generated resource properties from their declared defaults. + /// + /// + /// The provider-created or generator-created source whose properties provide the default values. + /// + /// + /// Generated resource constructors use this overload automatically. A hand-written detached resource may + /// use it when it deliberately supplies an owner whose declared defaults initialize the derived resource. + /// + protected Resource(EngineObject defaultValues) + { + IsEnabled = defaultValues.IsEnabled; + } + + /// + /// Initializes an attached resource without evaluating defaults that replaces. + /// + /// + /// The generator, or a hand-written attached-resource factory, passes when the + /// resource will be populated immediately by . + /// + /// + /// This path intentionally leaves detached defaults uninitialized. Do not expose the resource before its + /// first successful . + /// + protected Resource(bool skipDefaultInitialization) + { + if (!skipDefaultInitialization) + { + throw new ArgumentException( + "Attached-resource construction must explicitly opt out of detached default initialization.", + nameof(skipDefaultInitialization)); + } + } + ~Resource() { Dispose(false); diff --git a/src/Beutl.Engine/Engine/ResourceDefaultValuesProviderAttribute.cs b/src/Beutl.Engine/Engine/ResourceDefaultValuesProviderAttribute.cs new file mode 100644 index 0000000000..067899ef6b --- /dev/null +++ b/src/Beutl.Engine/Engine/ResourceDefaultValuesProviderAttribute.cs @@ -0,0 +1,29 @@ +namespace Beutl.Engine; + +/// +/// Marks the parameterless static factory that supplies the owner whose declared property defaults initialize a +/// generated detached . +/// +/// +/// +/// Use this extension point when the owner uses a primary constructor, initializes a generated +/// from an ordinary constructor, or otherwise cannot expose its defaults through the +/// generator's declaration-time storage rules. +/// +/// +/// Exactly one method on the declaring owner may carry this attribute. The method may be non-public, but it must +/// be static, parameterless, non-generic, and return the declaring owner type. It must return a non-null owner +/// whose generated properties expose the intended detached defaults. The generated public resource constructor +/// invokes the method once for that construction; the attached path does +/// not invoke it. +/// +/// +/// A generated owner derived from a provider-backed owner declares its own provider that constructs the +/// most-derived type. The direct concrete generated resource constructor invokes that provider once and passes +/// the returned owner through the complete base-resource constructor chain; base providers are not invoked +/// separately. Providers are not inherited as defaults factories because doing so would omit the derived owner's +/// defaults and could bypass the base owner's explicit construction contract. +/// +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class ResourceDefaultValuesProviderAttribute : Attribute; diff --git a/src/Beutl.Engine/Graphics/FilterEffects/DelayAnimationEffect.cs b/src/Beutl.Engine/Graphics/FilterEffects/DelayAnimationEffect.cs index b34f370af1..6dc58ed19b 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/DelayAnimationEffect.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/DelayAnimationEffect.cs @@ -117,6 +117,16 @@ public override Resource ToResource(CompositionContext context) public new class Resource : FilterEffect.Resource { + internal Resource() + : this(skipDefaultInitialization: true) + { + } + + protected Resource(bool skipDefaultInitialization) + : base(skipDefaultInitialization) + { + } + private float _delay; private FilterEffect.Resource? _effect; private TimeSpan _globalTime; diff --git a/src/Beutl.Engine/Graphics/FilterEffects/ShakeEffect.cs b/src/Beutl.Engine/Graphics/FilterEffects/ShakeEffect.cs index bb0a67f410..2dc4f688f9 100644 --- a/src/Beutl.Engine/Graphics/FilterEffects/ShakeEffect.cs +++ b/src/Beutl.Engine/Graphics/FilterEffects/ShakeEffect.cs @@ -88,6 +88,16 @@ public override Resource ToResource(CompositionContext context) public new class Resource : FilterEffect.Resource { + internal Resource() + : this(skipDefaultInitialization: true) + { + } + + protected Resource(bool skipDefaultInitialization) + : base(skipDefaultInitialization) + { + } + private float _strengthX; private float _strengthY; private float _speed; diff --git a/src/Beutl.Engine/Graphics/Particles/ParticleEmitter.cs b/src/Beutl.Engine/Graphics/Particles/ParticleEmitter.cs index 802454cb08..e305fabfb9 100644 --- a/src/Beutl.Engine/Graphics/Particles/ParticleEmitter.cs +++ b/src/Beutl.Engine/Graphics/Particles/ParticleEmitter.cs @@ -182,6 +182,16 @@ public override Resource ToResource(CompositionContext context) public new class Resource : Drawable.Resource { + internal Resource() + : this(skipDefaultInitialization: true) + { + } + + protected Resource(bool skipDefaultInitialization) + : base(skipDefaultInitialization) + { + } + private readonly ParticleSimulator _simulator = new(); private int _seed; diff --git a/src/Beutl.NodeGraph/NodeGraphDrawable.cs b/src/Beutl.NodeGraph/NodeGraphDrawable.cs index 26fd14db1b..f08cba8eaa 100644 --- a/src/Beutl.NodeGraph/NodeGraphDrawable.cs +++ b/src/Beutl.NodeGraph/NodeGraphDrawable.cs @@ -50,6 +50,11 @@ public override void Render(GraphicsContext2D context, Drawable.Resource resourc public new sealed class Resource : Drawable.Resource { + internal Resource() + : base(skipDefaultInitialization: true) + { + } + private readonly GraphSnapshot _snapshot = new(); private GraphModel? _model; diff --git a/src/Beutl.NodeGraph/NodeGraphFilterEffect.cs b/src/Beutl.NodeGraph/NodeGraphFilterEffect.cs index 5d7a2b920c..4811f58868 100644 --- a/src/Beutl.NodeGraph/NodeGraphFilterEffect.cs +++ b/src/Beutl.NodeGraph/NodeGraphFilterEffect.cs @@ -40,6 +40,11 @@ public override Resource ToResource(CompositionContext context) public new sealed class Resource : FilterEffect.Resource { + internal Resource() + : base(skipDefaultInitialization: true) + { + } + public GraphSnapshot Snapshot { get; } = new(); public GraphModel? Model { get; private set; } diff --git a/src/Beutl.NodeGraph/Nodes/RenderNodeDrawable.cs b/src/Beutl.NodeGraph/Nodes/RenderNodeDrawable.cs index 00746d2ea5..60a90fe1d5 100644 --- a/src/Beutl.NodeGraph/Nodes/RenderNodeDrawable.cs +++ b/src/Beutl.NodeGraph/Nodes/RenderNodeDrawable.cs @@ -41,6 +41,11 @@ public override void Render(GraphicsContext2D context, Drawable.Resource resourc public new sealed class Resource : Drawable.Resource { + internal Resource() + : base(skipDefaultInitialization: true) + { + } + public RenderNode? GraphNode { get; set; } public override void Update(EngineObject obj, CompositionContext context, ref bool updateOnly) diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RendererExceptionSafetyTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RendererExceptionSafetyTests.cs index 6b6d14edb5..f94d1351e0 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RendererExceptionSafetyTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/RendererExceptionSafetyTests.cs @@ -97,10 +97,17 @@ private static RenderNodeOperation CreateOperation( // Emits a fixed set of ops into the render graph, with Render overridden to bypass the blend/opacity/filter // pushes so they reach the Renderer's pull loop unwrapped. Top-level partial because // EngineObjectResourceGenerator does not support nested types. -internal sealed partial class FaultingDrawable(RenderNodeOperation[] operations) : Drawable +internal sealed partial class FaultingDrawable : Drawable { + private readonly RenderNodeOperation[] _operations; + + public FaultingDrawable(RenderNodeOperation[] operations) + { + _operations = operations; + } + public override void Render(GraphicsContext2D context, Drawable.Resource resource) - => context.DrawNode(new FixedOpsNode(operations)); + => context.DrawNode(new FixedOpsNode(_operations)); protected override Size MeasureCore(Size availableSize, Drawable.Resource resource) => new(4, 4); diff --git a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/SourceEffectiveScaleFlowTests.cs b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/SourceEffectiveScaleFlowTests.cs index 7167513b66..6f79e88f92 100644 --- a/tests/Beutl.UnitTests/Engine/Graphics/Rendering/SourceEffectiveScaleFlowTests.cs +++ b/tests/Beutl.UnitTests/Engine/Graphics/Rendering/SourceEffectiveScaleFlowTests.cs @@ -558,6 +558,11 @@ public override Resource ToResource(CompositionContext context) public new sealed class Resource : FilterEffect.Resource { + public Resource() + : base(skipDefaultInitialization: true) + { + } + public override FilterEffectRenderNode CreateRenderNode() => new ClampToOutputEscapeHatchNode(this); } } diff --git a/tests/Beutl.UnitTests/NodeGraph/NodeGraphFilterEffectRenderNodeTests.cs b/tests/Beutl.UnitTests/NodeGraph/NodeGraphFilterEffectRenderNodeTests.cs index 51b4656b31..9617cec534 100644 --- a/tests/Beutl.UnitTests/NodeGraph/NodeGraphFilterEffectRenderNodeTests.cs +++ b/tests/Beutl.UnitTests/NodeGraph/NodeGraphFilterEffectRenderNodeTests.cs @@ -142,6 +142,11 @@ public override Resource ToResource(CompositionContext context) public new sealed class Resource : FilterEffect.Resource { + public Resource() + : base(skipDefaultInitialization: true) + { + } + public override FilterEffectRenderNode CreateRenderNode() => new ScaleProbeRenderNode(this); } } diff --git a/tests/SourceGeneratorTest/CompositionContext.cs b/tests/SourceGeneratorTest/CompositionContext.cs index c8817a01dd..a2991ce014 100644 --- a/tests/SourceGeneratorTest/CompositionContext.cs +++ b/tests/SourceGeneratorTest/CompositionContext.cs @@ -4,6 +4,8 @@ namespace Beutl.Composition; public class CompositionContext { + public static CompositionContext Default => new(); + public T Get(IProperty property) { throw null!; diff --git a/tests/SourceGeneratorTest/EngineObject.cs b/tests/SourceGeneratorTest/EngineObject.cs index ac94a79e7f..b9c6897284 100644 --- a/tests/SourceGeneratorTest/EngineObject.cs +++ b/tests/SourceGeneratorTest/EngineObject.cs @@ -7,10 +7,24 @@ namespace Beutl.Engine; public class EngineObject { + protected readonly struct ResourceDefaultValuesConstruction + { + } + + public EngineObject() + { + } + + protected EngineObject(ResourceDefaultValuesConstruction construction) + { + } + public virtual IReadOnlyList Properties => throw null!; internal int Version { get; private set; } + public bool IsEnabled { get; set; } = true; + protected virtual IEnumerable ScanPropertiesCore() where T : EngineObject { throw null!; @@ -26,10 +40,32 @@ public virtual Resource ToResource(CompositionContext context) public class Resource : IDisposable { + public Resource() + { + IsEnabled = true; + } + + protected Resource(EngineObject defaultValues) + { + IsEnabled = defaultValues.IsEnabled; + } + + protected Resource(bool skipDefaultInitialization) + { + if (!skipDefaultInitialization) + { + throw new ArgumentException( + "Attached-resource construction must explicitly opt out of detached default initialization.", + nameof(skipDefaultInitialization)); + } + } + private EngineObject? _original; public int Version { get; protected set; } + public bool IsEnabled { get; set; } + public bool IsAttached => _original is not null; public EngineObject? GetOriginal() => _original; diff --git a/tests/SourceGeneratorTest/GeneratorDriverHarness.cs b/tests/SourceGeneratorTest/GeneratorDriverHarness.cs index bdf3b39e22..38bdc402cb 100644 --- a/tests/SourceGeneratorTest/GeneratorDriverHarness.cs +++ b/tests/SourceGeneratorTest/GeneratorDriverHarness.cs @@ -64,6 +64,9 @@ public interface IListProperty : IProperty, System.Collections.Generic.IList< [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Property)] public sealed class SuppressResourceClassGenerationAttribute : System.Attribute { } + + [System.AttributeUsage(System.AttributeTargets.Method, AllowMultiple = false, Inherited = false)] + public sealed class ResourceDefaultValuesProviderAttribute : System.Attribute { } } namespace Beutl.Validation diff --git a/tests/SourceGeneratorTest/ResourceDefaultValuesTests.cs b/tests/SourceGeneratorTest/ResourceDefaultValuesTests.cs new file mode 100644 index 0000000000..1500636cc1 --- /dev/null +++ b/tests/SourceGeneratorTest/ResourceDefaultValuesTests.cs @@ -0,0 +1,188 @@ +using Microsoft.CodeAnalysis; + +namespace SourceGeneratorTest; + +/// +/// Covers the detached-resource defaults contract: the generated constructor chain, the +/// [ResourceDefaultValuesProvider] escape hatch, and the BESG003-BESG006 diagnostics that +/// reject declaration shapes whose defaults cannot be read without running a user constructor. +/// +[TestFixture] +public class ResourceDefaultValuesTests +{ + private static IEnumerable DiagnosticsWithId(GeneratorHarnessResult result, string id) + => result.GeneratorDiagnostics.Where(d => d.Id == id); + + [Test] + public void GeneratedResource_ChainsThroughTheDeclaredDefaults() + { + string source = GeneratorDriverHarness.Run().GetSource("Derived_Resource.g.cs"); + + Assert.Multiple(() => + { + Assert.That( + source, + Does.Contain("public Resource()"), + "A generated concrete Resource keeps a public detached constructor."); + Assert.That( + source, + Does.Contain("__CreateResourceDefaultValues()"), + "The detached constructor reads its defaults from a generated factory."); + Assert.That( + source, + Does.Contain("protected Resource(bool skipDefaultInitialization)"), + "The attached path opts out of default evaluation explicitly."); + Assert.That( + source, + Does.Contain("_x = defaultValues.X.DefaultValue;"), + "Each generated value property starts at its declared default."); + }); + } + + [Test] + public void ToResource_UsesTheAttachedFastPath() + { + string source = GeneratorDriverHarness.Run().GetSource("Derived_Resource.g.cs"); + + Assert.That( + source, + Does.Contain("__CreateAttachedDerived()"), + "ToResource must not evaluate detached defaults that Update immediately replaces."); + } + + [Test] + public void PropertyAssignedInAConstructor_ReportsBESG003() + { + const string Scenario = """ + using Beutl.Engine; + + namespace SourceGeneratorTest.Scenarios; + + public partial class ConstructorAssigned : EngineObject + { + public ConstructorAssigned() + { + Value = Property.Create(1f); + } + + public IProperty Value { get; private set; } + } + """; + + GeneratorHarnessResult result = GeneratorDriverHarness.Run(Scenario); + + Assert.That( + DiagnosticsWithId(result, "BESG003"), + Is.Not.Empty, + "A property whose IProperty is replaced in a constructor has no declaration-time default."); + } + + [Test] + public void PrimaryConstructor_ReportsBESG004() + { + const string Scenario = """ + using Beutl.Engine; + + namespace SourceGeneratorTest.Scenarios; + + public partial class PrimaryCtor(float seed) : EngineObject + { + public IProperty Value { get; } = Property.Create(seed); + } + """; + + GeneratorHarnessResult result = GeneratorDriverHarness.Run(Scenario); + + Assert.That( + DiagnosticsWithId(result, "BESG004"), + Is.Not.Empty, + "A primary constructor cannot run on the initializer-only defaults path."); + } + + [Test] + public void InvalidProviderSignature_ReportsBESG005() + { + const string Scenario = """ + using Beutl.Engine; + + namespace SourceGeneratorTest.Scenarios; + + public partial class BadProvider : EngineObject + { + public IProperty Value { get; } = Property.Create(0f); + + [ResourceDefaultValuesProvider] + private static EngineObject CreateDefaults() => new BadProvider(); + } + """; + + GeneratorHarnessResult result = GeneratorDriverHarness.Run(Scenario); + + Assert.That( + DiagnosticsWithId(result, "BESG005"), + Is.Not.Empty, + "A provider must return the declaring owner type exactly."); + } + + [Test] + public void DerivedTypeWithoutItsOwnProvider_ReportsBESG006() + { + const string Scenario = """ + using Beutl.Engine; + + namespace SourceGeneratorTest.Scenarios; + + public partial class ProviderBase : EngineObject + { + public IProperty BaseValue { get; } = Property.Create(0f); + + [ResourceDefaultValuesProvider] + private static ProviderBase CreateDefaults() => new(); + } + + public partial class ProviderDerived : ProviderBase + { + public IProperty DerivedValue { get; } = Property.Create(0f); + } + """; + + GeneratorHarnessResult result = GeneratorDriverHarness.Run(Scenario); + + Assert.That( + DiagnosticsWithId(result, "BESG006"), + Is.Not.Empty, + "Inheriting a provider would evaluate the base owner's defaults for the derived type."); + } + + [Test] + public void ValidProvider_GeneratesWithoutDiagnostics() + { + const string Scenario = """ + using Beutl.Engine; + + namespace SourceGeneratorTest.Scenarios; + + public partial class GoodProvider(float seed) : EngineObject + { + public IProperty Value { get; } = Property.Create(seed); + + [ResourceDefaultValuesProvider] + private static GoodProvider CreateDefaults() => new(0f); + } + """; + + GeneratorHarnessResult result = GeneratorDriverHarness.Run(Scenario); + + Assert.Multiple(() => + { + Assert.That( + result.GeneratorDiagnostics.Where(d => d.Severity == DiagnosticSeverity.Error), + Is.Empty, + "A valid provider is the documented escape hatch for a primary constructor."); + Assert.That( + result.HasSource("GoodProvider_Resource.g.cs"), + Is.True, + "Generation proceeds once a provider supplies the defaults owner."); + }); + } +}