diff --git a/samples/ReadmeSample/ReadmeSample.csproj b/samples/ReadmeSample/ReadmeSample.csproj index b1dc659..72664f6 100644 --- a/samples/ReadmeSample/ReadmeSample.csproj +++ b/samples/ReadmeSample/ReadmeSample.csproj @@ -13,6 +13,7 @@ + diff --git a/src/EntityFrameworkCore.Projectables.CodeFixes/FactoryMethodToConstructorCodeRefactoringProvider.cs b/src/EntityFrameworkCore.Projectables.CodeFixes/FactoryMethodToConstructorCodeRefactoringProvider.cs new file mode 100644 index 0000000..4a8fb20 --- /dev/null +++ b/src/EntityFrameworkCore.Projectables.CodeFixes/FactoryMethodToConstructorCodeRefactoringProvider.cs @@ -0,0 +1,66 @@ +using System.Composition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeRefactorings; + +namespace EntityFrameworkCore.Projectables.CodeFixes; + +/// +/// Code refactoring provider that converts a [Projectable] factory method whose body is +/// an object-initializer expression (=> new T { … }) into a [Projectable] +/// constructor of the same class. +/// +/// Two refactoring actions are offered: +/// +/// Convert the factory method to a constructor (current document only). +/// Convert the factory method to a constructor and replace all +/// callers throughout the solution with new T(…) invocations. +/// +/// +/// +/// This provider is complementary to , +/// which fixes the EFP0012 diagnostic. The refactoring provider remains useful when +/// the diagnostic is suppressed. +/// +/// +/// A public parameterless constructor is automatically inserted when the class does not already +/// have one, preserving the implicit default constructor that would otherwise be lost. +/// +/// +[ExportCodeRefactoringProvider(LanguageNames.CSharp, Name = nameof(FactoryMethodToConstructorCodeRefactoringProvider))] +[Shared] +public sealed class FactoryMethodToConstructorCodeRefactoringProvider : CodeRefactoringProvider +{ + /// + public override async Task ComputeRefactoringsAsync(CodeRefactoringContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + { + return; + } + + var node = root.FindNode(context.Span); + + if (!ProjectableCodeFixHelper.TryGetFixableFactoryMethodPattern(node, out var containingType, out var method)) + { + return; + } + + context.RegisterRefactoring( + CodeAction.Create( + title: "Convert [Projectable] factory method to constructor", + createChangedDocument: ct => + FactoryMethodTransformationHelper.ConvertToConstructorAsync( + context.Document, method!, containingType!, ct), + equivalenceKey: "EFP_FactoryToConstructor")); + + context.RegisterRefactoring( + CodeAction.Create( + title: "Convert [Projectable] factory method to constructor (and update callers)", + createChangedSolution: ct => + FactoryMethodTransformationHelper.ConvertToConstructorAndUpdateCallersAsync( + context.Document, method!, containingType!, ct), + equivalenceKey: "EFP_FactoryToConstructorWithCallers")); + } +} diff --git a/src/EntityFrameworkCore.Projectables.CodeFixes/FactoryMethodToCtorCodeFixProvider.cs b/src/EntityFrameworkCore.Projectables.CodeFixes/FactoryMethodToCtorCodeFixProvider.cs new file mode 100644 index 0000000..8136254 --- /dev/null +++ b/src/EntityFrameworkCore.Projectables.CodeFixes/FactoryMethodToCtorCodeFixProvider.cs @@ -0,0 +1,63 @@ +using System.Collections.Immutable; +using System.Composition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; + +namespace EntityFrameworkCore.Projectables.CodeFixes; + +/// +/// Code fix provider for EFP0012. +/// Offers two fixes on a [Projectable] factory method that can be a constructor: +/// +/// Convert the factory method to a constructor (current document). +/// Convert the factory method to a constructor and update all +/// callers throughout the solution. +/// +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(FactoryMethodToCtorCodeFixProvider))] +[Shared] +public sealed class FactoryMethodToCtorCodeFixProvider : CodeFixProvider +{ + /// + public override ImmutableArray FixableDiagnosticIds => ["EFP0012"]; + + /// + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + { + return; + } + + var node = root.FindNode(context.Span); + + if (!ProjectableCodeFixHelper.TryGetFixableFactoryMethodPattern(node, out var containingType, out var method)) + { + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + title: "Convert [Projectable] factory method to constructor", + createChangedDocument: ct => + FactoryMethodTransformationHelper.ConvertToConstructorAsync( + context.Document, method!, containingType!, ct), + equivalenceKey: "EFP0012_FactoryToConstructor"), + context.Diagnostics[0]); + + context.RegisterCodeFix( + CodeAction.Create( + title: "Convert [Projectable] factory method to constructor (and update callers)", + createChangedSolution: ct => + FactoryMethodTransformationHelper.ConvertToConstructorAndUpdateCallersAsync( + context.Document, method!, containingType!, ct), + equivalenceKey: "EFP0012_FactoryToConstructorWithCallers"), + context.Diagnostics[0]); + } +} + diff --git a/src/EntityFrameworkCore.Projectables.CodeFixes/FactoryMethodTransformationHelper.cs b/src/EntityFrameworkCore.Projectables.CodeFixes/FactoryMethodTransformationHelper.cs new file mode 100644 index 0000000..f8f560f --- /dev/null +++ b/src/EntityFrameworkCore.Projectables.CodeFixes/FactoryMethodTransformationHelper.cs @@ -0,0 +1,508 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.FindSymbols; +using Microsoft.CodeAnalysis.Formatting; +using Microsoft.CodeAnalysis.Simplification; + +namespace EntityFrameworkCore.Projectables.CodeFixes; + +/// +/// Shared helpers for the factory-method → projectable-constructor transformation. +/// Used by both and +/// . +/// +static internal class FactoryMethodTransformationHelper +{ + /// + /// Fetches a fresh root, applies the factory → constructor transformation, and + /// returns an updated document. + /// + async static internal Task ConvertToConstructorAsync( + Document document, + MethodDeclarationSyntax method, + TypeDeclarationSyntax containingType, + CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) + { + return document; + } + + return document.WithSyntaxRoot(BuildRootWithConstructor(root, method, containingType)); + } + + /// + /// Applies the factory → constructor transformation on the declaring document and + /// replaces all instance.FactoryMethod(args) call sites in the solution with + /// new ReturnType(args). + /// + async static internal Task ConvertToConstructorAndUpdateCallersAsync( + Document document, + MethodDeclarationSyntax method, + TypeDeclarationSyntax containingType, + CancellationToken cancellationToken) + { + var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + if (semanticModel is null) + { + return document.Project.Solution; + } + + var methodSymbol = semanticModel.GetDeclaredSymbol(method, cancellationToken); + if (methodSymbol is null) + { + return document.Project.Solution; + } + + var solution = document.Project.Solution; + var methodParamNames = methodSymbol.Parameters.Select(p => p.Name).ToArray(); + var returnType = methodSymbol.ReturnType; + var returnTypeSyntax = SyntaxFactory + .ParseTypeName(returnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) + .WithAdditionalAnnotations(Simplifier.Annotation); + + // Find all callers BEFORE modifying the solution so that spans are still valid. + var references = await SymbolFinder + .FindReferencesAsync(methodSymbol, solution, cancellationToken) + .ConfigureAwait(false); + + // Group locations by document (including the declaring document). + var locationsByDoc = new Dictionary>(); + foreach (var referencedSymbol in references) + { + foreach (var refLocation in referencedSymbol.Locations) + { + if (!locationsByDoc.TryGetValue(refLocation.Document.Id, out var list)) + { + list = []; + locationsByDoc[refLocation.Document.Id] = list; + } + + list.Add(refLocation); + } + } + + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) + { + return solution; + } + + // Map annotation → data needed to build the replacement node. + var invocationAnnotations = new Dictionary(); + var methodGroupAnnotations = new Dictionary(); + + var workingRoot = root; + + if (locationsByDoc.TryGetValue(document.Id, out var declaringDocLocations)) + { + // Use Dictionaries keyed by node to deduplicate: multiple reference spans + // can resolve to the same syntax node. + var annotationByInvocation = new Dictionary(); + var annotationByMethodGroup = new Dictionary(); + + foreach (var refLocation in declaringDocLocations) + { + var refNode = root.FindNode(refLocation.Location.SourceSpan); + + // Determine the method-reference expression: qualified (Class.Method) or simple (Method). + var methodRefExpr = refNode.Parent is MemberAccessExpressionSyntax maExpr && maExpr.Name == refNode + ? maExpr + : (ExpressionSyntax)refNode; + + if (methodRefExpr.Parent is InvocationExpressionSyntax invocation + && invocation.Expression == methodRefExpr) + { + // Direct invocation: Class.Method(args) → new ReturnType(args) + if (invocation.Parent is ConditionalAccessExpressionSyntax) + { + continue; + } + + if (annotationByInvocation.ContainsKey(invocation)) + { + // Same invocation reached via a different reference span — skip. + continue; + } + + var ann = new SyntaxAnnotation(); + invocationAnnotations[ann] = ( + invocation.ArgumentList, + invocation.GetLeadingTrivia(), + invocation.GetTrailingTrivia()); + annotationByInvocation[invocation] = ann; + } + else + { + // Method group: Class.Method → p1 => new ReturnType(p1) + // Skip nameof(Class.Method): SymbolFinder returns these locations but + // replacing them with a lambda would produce invalid C#. + if (IsInsideNameOf(methodRefExpr)) + { + continue; + } + + if (annotationByMethodGroup.ContainsKey(methodRefExpr)) + { + continue; + } + + var ann = new SyntaxAnnotation(); + methodGroupAnnotations[ann] = ( + methodRefExpr.GetLeadingTrivia(), + methodRefExpr.GetTrailingTrivia()); + annotationByMethodGroup[methodRefExpr] = ann; + } + } + + // Merge all nodes-to-annotate into one ReplaceNodes pass (does NOT shift spans). + var nodesToAnnotate = new Dictionary( + annotationByInvocation.Count + annotationByMethodGroup.Count); + foreach (var kvp in annotationByInvocation) + { + nodesToAnnotate[kvp.Key] = kvp.Value; + } + + foreach (var kvp in annotationByMethodGroup) + { + nodesToAnnotate[kvp.Key] = kvp.Value; + } + + if (nodesToAnnotate.Count > 0) + { + workingRoot = root.ReplaceNodes( + nodesToAnnotate.Keys, + (original, _) => original.WithAdditionalAnnotations(nodesToAnnotate[original])); + } + } + + // Re-find method and containingType in workingRoot by their original spans + // (safe because adding annotations does not shift spans). + var currentMethod = workingRoot.FindNode(method.Span) as MethodDeclarationSyntax ?? method; + var currentContainingType = workingRoot.FindNode(containingType.Span) as TypeDeclarationSyntax ?? containingType; + + // Apply the factory → constructor transformation. + // Annotated call-site nodes that live OUTSIDE the transformed type survive + // untouched (annotations are preserved by ReplaceNode). + var transformedRoot = BuildRootWithConstructor(workingRoot, currentMethod, currentContainingType); + + // Replace annotated invocations — found by annotation, not by span. + var finalDeclaringRoot = transformedRoot; + foreach (var annEntry in invocationAnnotations) + { + var ann = annEntry.Key; + var argList = annEntry.Value.ArgList; + var leading = annEntry.Value.Leading; + var trailing = annEntry.Value.Trailing; + + var annotatedInvocation = finalDeclaringRoot + .GetAnnotatedNodes(ann) + .OfType() + .FirstOrDefault(); + + if (annotatedInvocation is null) + { + continue; + } + + // Rewrite: instance.FactoryMethod(args) → new ReturnType(args) + var newCreation = SyntaxFactory + .ObjectCreationExpression( + SyntaxFactory.Token(SyntaxKind.NewKeyword) + .WithTrailingTrivia(SyntaxFactory.Space), + returnTypeSyntax, + argList, + initializer: null) + .WithLeadingTrivia(leading) + .WithTrailingTrivia(trailing); + + finalDeclaringRoot = finalDeclaringRoot.ReplaceNode(annotatedInvocation, newCreation); + } + + // Replace annotated method groups with lambdas: Class.Method → p => new ReturnType(p) + foreach (var annEntry in methodGroupAnnotations) + { + var ann = annEntry.Key; + var leading = annEntry.Value.Leading; + var trailing = annEntry.Value.Trailing; + + var annotatedMethodGroup = finalDeclaringRoot + .GetAnnotatedNodes(ann) + .FirstOrDefault(); + + if (annotatedMethodGroup is null) + { + continue; + } + + var lambda = BuildMethodGroupLambda(methodParamNames, returnTypeSyntax) + .WithLeadingTrivia(leading) + .WithTrailingTrivia(trailing); + + finalDeclaringRoot = finalDeclaringRoot.ReplaceNode(annotatedMethodGroup, lambda); + } + + solution = solution.WithDocumentSyntaxRoot(document.Id, finalDeclaringRoot); + + // ----------------------------------------------------------------------- + // Other caller documents — spans in these roots are still the original + // unmodified spans, so the existing end-to-start approach is correct. + // ----------------------------------------------------------------------- + foreach (var kvp in locationsByDoc) + { + var docId = kvp.Key; + if (docId == document.Id) + { + // Already handled above via annotations. + continue; + } + + var locations = kvp.Value; + + var callerDoc = solution.GetDocument(docId); + if (callerDoc is null) + { + continue; + } + + var callerRoot = await callerDoc.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (callerRoot is null) + { + continue; + } + + // Process end-to-start so that earlier spans remain valid. + var newCallerRoot = callerRoot; + foreach (var refLocation in locations.OrderByDescending(l => l.Location.SourceSpan.Start)) + { + var refNode = newCallerRoot.FindNode(refLocation.Location.SourceSpan); + + // Determine the method-reference expression: qualified (Class.Method) or simple (Method). + var methodRefExpr = refNode.Parent is MemberAccessExpressionSyntax maExpr && maExpr.Name == refNode + ? maExpr + : (ExpressionSyntax)refNode; + + if (methodRefExpr.Parent is InvocationExpressionSyntax invocation + && invocation.Expression == methodRefExpr) + { + // Skip conditional-access invocations like x?.FactoryMethod(...) + // to avoid producing invalid syntax such as x?.new ReturnType(...). + if (invocation.Parent is ConditionalAccessExpressionSyntax) + { + continue; + } + + // Rewrite: Class.Method(args) → new ReturnType(args) + var newCreation = SyntaxFactory + .ObjectCreationExpression( + SyntaxFactory.Token(SyntaxKind.NewKeyword) + .WithTrailingTrivia(SyntaxFactory.Space), + returnTypeSyntax, + invocation.ArgumentList, + initializer: null) + .WithLeadingTrivia(invocation.GetLeadingTrivia()) + .WithTrailingTrivia(invocation.GetTrailingTrivia()); + + newCallerRoot = newCallerRoot.ReplaceNode(invocation, newCreation); + } + else + { + // Method group: Class.Method → p => new ReturnType(p) + // Skip nameof(Class.Method): SymbolFinder returns these locations but + // replacing them with a lambda would produce invalid C#. + if (IsInsideNameOf(methodRefExpr)) + { + continue; + } + + var lambda = BuildMethodGroupLambda(methodParamNames, returnTypeSyntax) + .WithLeadingTrivia(methodRefExpr.GetLeadingTrivia()) + .WithTrailingTrivia(methodRefExpr.GetTrailingTrivia()); + + newCallerRoot = newCallerRoot.ReplaceNode(methodRefExpr, lambda); + } + } + + solution = solution.WithDocumentSyntaxRoot(docId, newCallerRoot); + } + + return solution; + } + + /// + /// Core transformation: removes the factory method, inserts an equivalent + /// [Projectable] constructor at the same position, and prepends a public + /// parameterless constructor when the class does not already have one. + /// + private static SyntaxNode BuildRootWithConstructor( + SyntaxNode root, + MethodDeclarationSyntax method, + TypeDeclarationSyntax containingType) + { + var creation = (BaseObjectCreationExpressionSyntax)method.ExpressionBody!.Expression; + var initializer = creation.Initializer!; + + // Only support simple object-initializer assignments (Prop = value). If there are + // other initializer forms (e.g., collection initializers) or assignments whose RHS + // is a nested initializer (e.g. Items = { 1, 2 }), bail out to avoid + // producing a constructor that does not preserve behavior. + if (initializer.Expressions.Any( + e => e is not AssignmentExpressionSyntax { Right: not InitializerExpressionSyntax })) + { + return root; + } + + // Convert each object-initializer assignment (Prop = value) to a statement (Prop = value;). + // Two trivia sources must be preserved: + // 1. The expression's own trailing trivia (e.g. an inline "// comment") must move onto + // the semicolon token so it stays on the same line as the statement terminator. + // 2. The separator comma's trailing trivia (e.g. "\r\n ") carries the newline and + // indentation that separates adjacent items in the initializer list. That trivia + // is lost when we extract only the expressions, so we append it to the semicolon as + // well so the next statement starts on its own correctly-indented line. + static StatementSyntax ToStatement(AssignmentExpressionSyntax a, SyntaxTriviaList separatorTrailing) + { + var exprTrailing = a.GetTrailingTrivia(); + var semicolonTrailing = exprTrailing.AddRange(separatorTrailing); + return SyntaxFactory.ExpressionStatement( + a.WithoutTrailingTrivia(), + SyntaxFactory.Token(SyntaxKind.SemicolonToken).WithTrailingTrivia(semicolonTrailing)); + } + + var exprs = initializer.Expressions; + var statements = new StatementSyntax[exprs.Count]; + for (var i = 0; i < exprs.Count; i++) + { + var a = (AssignmentExpressionSyntax)exprs[i]; + var sepTrivia = i < exprs.SeparatorCount + ? exprs.GetSeparator(i).TrailingTrivia + : default; + statements[i] = ToStatement(a, sepTrivia); + } + + var ctorModifiers = GetConstructorModifiers(method); + + var ctor = SyntaxFactory + .ConstructorDeclaration(containingType.Identifier.WithoutTrivia()) + .WithAttributeLists(method.AttributeLists) + .WithModifiers(ctorModifiers) + .WithParameterList(method.ParameterList) + .WithBody(SyntaxFactory.Block(statements)) + .WithAdditionalAnnotations(Formatter.Annotation) + .WithLeadingTrivia(method.GetLeadingTrivia()); + + var methodIndex = containingType.Members.IndexOf(method); + var newMembers = containingType.Members.RemoveAt(methodIndex); + newMembers = newMembers.Insert(Math.Min(methodIndex, newMembers.Count), ctor); + + // Add an explicit parameterless constructor only when the class originally had NO + // explicit constructors at all. The C# compiler emits an implicit parameterless + // constructor solely in that case (C# spec §10.11.4), and it is always declared public. + // If the class already had other user-declared constructors the implicit default was + // already suppressed, so we must not introduce a new public overload. + var existingCtors = containingType.Members.OfType().ToArray(); + var hasParamlessCtor = existingCtors.Any(c => c.ParameterList.Parameters.Count == 0); + var hadNoExplicitCtors = existingCtors.Length == 0; + + if (!hasParamlessCtor && hadNoExplicitCtors) + { + // The implicit default ctor is always public (C# spec §10.11.4) regardless of the + // factory method's own accessibility, so hard-code public here. + var paramlessCtor = SyntaxFactory + .ConstructorDeclaration(containingType.Identifier.WithoutTrivia()) + .WithModifiers(SyntaxFactory.TokenList( + SyntaxFactory.Token(SyntaxKind.PublicKeyword) + .WithTrailingTrivia(SyntaxFactory.Space))) + .WithParameterList(SyntaxFactory.ParameterList()) + .WithBody(SyntaxFactory.Block()) + .WithAdditionalAnnotations(Formatter.Annotation) + .WithLeadingTrivia(SyntaxFactory.ElasticCarriageReturnLineFeed); + + newMembers = newMembers.Insert(0, paramlessCtor); + } + + return root.ReplaceNode(containingType, containingType.WithMembers(newMembers)); + } + + private static SyntaxTokenList GetConstructorModifiers(MethodDeclarationSyntax method) + { + // Derive constructor modifiers from the factory method, dropping modifiers that are + // invalid or meaningless for instance constructors (e.g., static, async, extern, unsafe). + var filteredModifiers = method.Modifiers + .Where(m => + !m.IsKind(SyntaxKind.StaticKeyword) && + !m.IsKind(SyntaxKind.AsyncKeyword) && + !m.IsKind(SyntaxKind.ExternKeyword) && + !m.IsKind(SyntaxKind.UnsafeKeyword)); + + return SyntaxFactory.TokenList(filteredModifiers); + } + + /// + /// Returns when is the argument of a + /// nameof(…) expression. + /// + /// Roslyn parses nameof(X.Y) as an whose + /// callee is an with the text nameof. + /// still returns such + /// locations, but replacing them with a lambda or object-creation expression would produce + /// invalid C# — they must be skipped. + /// + /// + private static bool IsInsideNameOf(ExpressionSyntax expr) => + expr.Parent is ArgumentSyntax + { + Parent: ArgumentListSyntax + { + Parent: InvocationExpressionSyntax + { + Expression: IdentifierNameSyntax { Identifier.Text: "nameof" } + } + } + }; + + /// + /// Builds a lambda expression that wraps a constructor call, for use when a factory + /// method is referenced as a method group (e.g. Select(MyType.Create)). + /// + /// Single parameter → simple lambda: p => new ReturnType(p) + /// Multiple parameters → parenthesised lambda: (p1, p2) => new ReturnType(p1, p2) + /// + /// + private static LambdaExpressionSyntax BuildMethodGroupLambda( + string[] paramNames, + TypeSyntax returnTypeSyntax) + { + var parameters = paramNames + .Select(name => SyntaxFactory.Parameter(SyntaxFactory.Identifier(name))) + .ToArray(); + + var arguments = paramNames + .Select(name => SyntaxFactory.Argument(SyntaxFactory.IdentifierName(name))) + .ToArray(); + + var objectCreation = SyntaxFactory.ObjectCreationExpression( + SyntaxFactory.Token(SyntaxKind.NewKeyword).WithTrailingTrivia(SyntaxFactory.Space), + returnTypeSyntax, + SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(arguments)), + initializer: null); + + if (parameters.Length == 1) + { + return SyntaxFactory + .SimpleLambdaExpression(parameters[0], objectCreation) + .WithAdditionalAnnotations(Formatter.Annotation); + } + + return SyntaxFactory + .ParenthesizedLambdaExpression( + SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(parameters)), + objectCreation) + .WithAdditionalAnnotations(Formatter.Annotation); + } +} + diff --git a/src/EntityFrameworkCore.Projectables.CodeFixes/ProjectableCodeFixHelper.cs b/src/EntityFrameworkCore.Projectables.CodeFixes/ProjectableCodeFixHelper.cs index 0821e64..c06cd6c 100644 --- a/src/EntityFrameworkCore.Projectables.CodeFixes/ProjectableCodeFixHelper.cs +++ b/src/EntityFrameworkCore.Projectables.CodeFixes/ProjectableCodeFixHelper.cs @@ -21,6 +21,30 @@ static internal bool TryFindProjectableAttribute(MemberDeclarationSyntax member, return attribute is not null; } + + static internal bool TryGetFixableFactoryMethodPattern( + SyntaxNode methodNode, + out TypeDeclarationSyntax? containingType, + out MethodDeclarationSyntax? method) + { + containingType = null; + method = null; + + var localMethod = methodNode.AncestorsAndSelf().OfType().FirstOrDefault(); + if (localMethod is null) + { + return false; + } + + if (!SyntaxHelpers.TryGetFactoryMethodPattern(localMethod, out containingType)) + { + return false; + } + + method = localMethod; + + return TryFindProjectableAttribute(localMethod, out _); + } /// /// Adds or replaces a named argument on the [Projectable] attribute of . diff --git a/src/EntityFrameworkCore.Projectables.CodeFixes/SyntaxHelpers.cs b/src/EntityFrameworkCore.Projectables.CodeFixes/SyntaxHelpers.cs new file mode 100644 index 0000000..5c4a82f --- /dev/null +++ b/src/EntityFrameworkCore.Projectables.CodeFixes/SyntaxHelpers.cs @@ -0,0 +1,113 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace EntityFrameworkCore.Projectables.CodeFixes; + +static internal class SyntaxHelpers +{ + /// + /// Returns when matches the + /// factory-method pattern: + /// + /// Expression body of the form => new ContainingType { … } + /// (object initializer only, no constructor arguments in the new + /// expression). + /// Static method. + /// Return type simple name equals the containing class name. + /// For explicit new T { … }, T must be an unqualified + /// identifier that matches the containing class name. Qualified names such as + /// new Other.MyObj { … } or new global::Other.MyObj { … } are + /// rejected because they cannot be confirmed as the same type without a semantic + /// model. + /// + /// + static internal bool TryGetFactoryMethodPattern( + MethodDeclarationSyntax method, + out TypeDeclarationSyntax? containingType) + { + containingType = null; + + if (method.Parent is not TypeDeclarationSyntax parentType) + { + return false; + } + + if (method.ExpressionBody is null) + { + return false; + } + + if (method.ExpressionBody.Expression is not BaseObjectCreationExpressionSyntax creation) + { + return false; + } + + // Only pure object-initializer bodies — no constructor arguments on the new expression. + if (creation.ArgumentList?.Arguments.Count > 0) + { + return false; + } + + if (creation.Initializer is null) + { + return false; + } + + // Only pure simple-assignment initializers (Prop = value) — no bare collection + // elements (which are not AssignmentExpressionSyntax) and no nested initializer + // assignments (Items = { 1, 2 }) whose RHS is an InitializerExpressionSyntax. + // Converting such entries to statements would produce invalid C#, so we must not + // offer the refactoring at all for these patterns. + if (creation.Initializer.Expressions.Any( + e => e is not AssignmentExpressionSyntax { Right: not InitializerExpressionSyntax })) + { + return false; + } + + // Only allow static factory methods, to keep the code fix simpler + if (!method.Modifiers.Any(m => m.IsKind(SyntaxKind.StaticKeyword))) + { + return false; + } + + // For explicit new T { … }, verify the type is an unqualified name matching the + // containing class. Qualified names (new Other.MyObj { }, new global::Other.MyObj { }) + // are rejected: without a semantic model we cannot confirm they resolve to the same + // type, so this is the conservative safe choice. + // For implicit new() { }, the compiler infers the type from the method's return type, + // which is already validated below, so no additional check is needed here. + if (creation is ObjectCreationExpressionSyntax { Type: var createdType }) + { + var createdTypeName = createdType switch + { + IdentifierNameSyntax id => id.Identifier.Text, + GenericNameSyntax generic => generic.Identifier.Text, + _ => null // QualifiedNameSyntax, AliasQualifiedNameSyntax, etc. — reject + }; + + if (createdTypeName is null || createdTypeName != parentType.Identifier.Text) + { + return false; + } + } + + // The method's return type must match the containing type (syntax-level name comparison). + var returnTypeName = method.ReturnType switch + { + IdentifierNameSyntax id => id.Identifier.Text, + QualifiedNameSyntax { Right: IdentifierNameSyntax right } => right.Identifier.Text, + GenericNameSyntax generic => generic.Identifier.Text, + _ => null + }; + + if (returnTypeName is null || returnTypeName != parentType.Identifier.Text) + { + return false; + } + + containingType = parentType; + + return true; + } +} \ No newline at end of file diff --git a/src/EntityFrameworkCore.Projectables.Generator/AnalyzerReleases.Shipped.md b/src/EntityFrameworkCore.Projectables.Generator/AnalyzerReleases.Shipped.md index c148638..5c81204 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/AnalyzerReleases.Shipped.md +++ b/src/EntityFrameworkCore.Projectables.Generator/AnalyzerReleases.Shipped.md @@ -13,6 +13,7 @@ EFP0008 | Design | Error | Target class is missing a parameterless construc EFP0009 | Design | Error | Delegated constructor cannot be analyzed for projection EFP0010 | Design | Error | UseMemberBody target member not found EFP0011 | Design | Error | UseMemberBody target member is incompatible +EFP0012 | Design | Info | [Projectable] factory method can be converted to a constructor ### Changed Rules diff --git a/src/EntityFrameworkCore.Projectables.Generator/EntityFrameworkCore.Projectables.Generator.csproj b/src/EntityFrameworkCore.Projectables.Generator/EntityFrameworkCore.Projectables.Generator.csproj index af7739e..d3a9683 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/EntityFrameworkCore.Projectables.Generator.csproj +++ b/src/EntityFrameworkCore.Projectables.Generator/EntityFrameworkCore.Projectables.Generator.csproj @@ -11,6 +11,7 @@ + diff --git a/src/EntityFrameworkCore.Projectables.Generator/Infrastructure/Diagnostics.cs b/src/EntityFrameworkCore.Projectables.Generator/Infrastructure/Diagnostics.cs index 17bd189..9fab2f2 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/Infrastructure/Diagnostics.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/Infrastructure/Diagnostics.cs @@ -91,4 +91,12 @@ static internal class Diagnostics category: "Design", DiagnosticSeverity.Error, isEnabledByDefault: true); + + public readonly static DiagnosticDescriptor FactoryMethodShouldBeConstructor = new DiagnosticDescriptor( + id: "EFP0012", + title: "[Projectable] factory method can be converted to a constructor", + messageFormat: "Factory method '{0}' creates and returns an instance of the containing class via object initializer. Consider converting it to a [Projectable] constructor.", + category: "Design", + DiagnosticSeverity.Info, + isEnabledByDefault: true); } \ No newline at end of file diff --git a/src/EntityFrameworkCore.Projectables.Generator/ProjectionExpressionGenerator.cs b/src/EntityFrameworkCore.Projectables.Generator/ProjectionExpressionGenerator.cs index a408787..38821b1 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/ProjectionExpressionGenerator.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/ProjectionExpressionGenerator.cs @@ -5,6 +5,7 @@ using Microsoft.CodeAnalysis.Text; using System.Collections.Immutable; using System.Text; +using EntityFrameworkCore.Projectables.CodeFixes; using EntityFrameworkCore.Projectables.Generator.Comparers; using EntityFrameworkCore.Projectables.Generator.Interpretation; using EntityFrameworkCore.Projectables.Generator.Models; @@ -184,6 +185,15 @@ private static void Execute( { throw new InvalidOperationException("Expected a memberName here"); } + + // Report EFP0012 when a [Projectable] method is a factory that could be a constructor. + if (member is MethodDeclarationSyntax factoryCandidate && SyntaxHelpers.TryGetFactoryMethodPattern(factoryCandidate, out _)) + { + context.ReportDiagnostic(Diagnostic.Create( + Infrastructure.Diagnostics.FactoryMethodShouldBeConstructor, + factoryCandidate.Identifier.GetLocation(), + factoryCandidate.Identifier.Text)); + } var generatedClassName = ProjectionExpressionClassNameGenerator.GenerateName(projectable.ClassNamespace, projectable.NestedInClassNames, projectable.MemberName, projectable.ParameterTypeNames); var generatedFileName = projectable.ClassTypeParameterList is not null ? $"{generatedClassName}-{projectable.ClassTypeParameterList.ChildNodes().Count()}.g.cs" : $"{generatedClassName}.g.cs"; diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/CodeFixTestBase.cs b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/CodeFixTestBase.cs index 9bc523b..e0ab23c 100644 --- a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/CodeFixTestBase.cs +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/CodeFixTestBase.cs @@ -15,7 +15,7 @@ namespace EntityFrameworkCore.Projectables.CodeFixes.Tests; /// public abstract class CodeFixTestBase { - private static Document CreateDocument(string source) + protected static Document CreateDocument([StringSyntax("csharp")] string source) { var workspace = new AdhocWorkspace(); var projectId = ProjectId.CreateNewId(); @@ -31,8 +31,37 @@ private static Document CreateDocument(string source) return solution.GetDocument(documentId)!; } + /// + /// Like , but also adds all trusted platform assemblies + /// so that the workspace's semantic model can resolve symbols fully. + /// Required by refactoring actions that call + /// SymbolFinder.FindReferencesAsync (e.g. "update callers"). + /// + protected static Document CreateDocumentWithReferences([StringSyntax("csharp")] string source) + { + var workspace = new AdhocWorkspace(); + var projectId = ProjectId.CreateNewId(); + var documentId = DocumentId.CreateNewId(projectId); + + var trustedAssemblies = ((string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") ?? string.Empty) + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Select(p => MetadataReference.CreateFromFile(p)) + .Cast() + .ToList(); + + var solution = workspace.CurrentSolution + .AddProject(projectId, "TestProject", "TestProject", LanguageNames.CSharp) + .WithProjectCompilationOptions( + projectId, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) + .WithProjectMetadataReferences(projectId, trustedAssemblies) + .AddDocument(documentId, "Test.cs", SourceText.From(source)); + + return solution.GetDocument(documentId)!; + } + private async static Task<(Document Document, IReadOnlyList Actions)> CollectActionsAsync( - string source, + [StringSyntax("csharp")] string source, string diagnosticId, Func locateDiagnosticSpan, CodeFixProvider provider) @@ -70,7 +99,7 @@ private static Document CreateDocument(string source) /// returned by . /// protected async static Task> GetCodeFixActionsAsync( - string source, + [StringSyntax("csharp")] string source, string diagnosticId, Func locateDiagnosticSpan, CodeFixProvider provider) @@ -84,8 +113,7 @@ protected async static Task> GetCodeFixActionsAsync( /// source text of the resulting document. /// protected async static Task ApplyCodeFixAsync( - [StringSyntax("csharp")] - string source, + [StringSyntax("csharp")] string source, string diagnosticId, Func locateDiagnosticSpan, CodeFixProvider provider, diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_AddsParameterlessConstructor_WhenNoneExists.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_AddsParameterlessConstructor_WhenNoneExists.verified.txt new file mode 100644 index 0000000..57a096b --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_AddsParameterlessConstructor_WhenNoneExists.verified.txt @@ -0,0 +1,14 @@ + +namespace Foo { + class Input { } + class Output { + public Output() + { + } + + [Projectable] + public Output(Input i) + { + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_DoesNotAddParamLessCtor_WhenAlreadyPresent.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_DoesNotAddParamLessCtor_WhenAlreadyPresent.verified.txt new file mode 100644 index 0000000..d460104 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_DoesNotAddParamLessCtor_WhenAlreadyPresent.verified.txt @@ -0,0 +1,11 @@ + +namespace Foo { + class Input { } + class Output { + public Output() { } + [Projectable] + public Output(Input i) + { + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_DoesNotAddParamLessCtor_WhenOtherExplicitCtorExists.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_DoesNotAddParamLessCtor_WhenOtherExplicitCtorExists.verified.txt new file mode 100644 index 0000000..ad2d0cc --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_DoesNotAddParamLessCtor_WhenOtherExplicitCtorExists.verified.txt @@ -0,0 +1,13 @@ + +namespace Foo { + class Input { public int Value { get; set; } } + class Output { + public int Value { get; set; } + public Output(string name) { } + [Projectable] + public Output(Input i) + { + Value = i.Value; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_ImplicitObjectCreation.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_ImplicitObjectCreation.verified.txt new file mode 100644 index 0000000..96789f6 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_ImplicitObjectCreation.verified.txt @@ -0,0 +1,16 @@ + +namespace Foo { + class OtherObj { public string Prop1 { get; set; } } + class MyObj { + public MyObj() + { + } + + public string Prop1 { get; set; } + [Projectable] + public MyObj(OtherObj obj) + { + Prop1 = obj.Prop1; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_InsertedParameterlessCtorIsAlwaysPublic.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_InsertedParameterlessCtorIsAlwaysPublic.verified.txt new file mode 100644 index 0000000..4850265 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_InsertedParameterlessCtorIsAlwaysPublic.verified.txt @@ -0,0 +1,15 @@ + +namespace Foo { + class Input { } + class Output { + public Output() + { + } + + public int Value { get; set; } + [Projectable] + internal Output(Input i) + { + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_PreservesProjectableOptions.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_PreservesProjectableOptions.verified.txt new file mode 100644 index 0000000..c6e87af --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_PreservesProjectableOptions.verified.txt @@ -0,0 +1,14 @@ + +namespace Foo { + class OtherObj { } + class MyObj { + public MyObj() + { + } + + [Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore)] + public MyObj(OtherObj obj) + { + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_SimpleStaticFactoryMethod.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_SimpleStaticFactoryMethod.verified.txt new file mode 100644 index 0000000..96789f6 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.CodeFix_SimpleStaticFactoryMethod.verified.txt @@ -0,0 +1,16 @@ + +namespace Foo { + class OtherObj { public string Prop1 { get; set; } } + class MyObj { + public MyObj() + { + } + + public string Prop1 { get; set; } + [Projectable] + public MyObj(OtherObj obj) + { + Prop1 = obj.Prop1; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.cs b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.cs new file mode 100644 index 0000000..f5a5744 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeFixProviderTests.cs @@ -0,0 +1,185 @@ +namespace EntityFrameworkCore.Projectables.CodeFixes.Tests; + +/// +/// Tests for (EFP0012). +/// Verifies the code fix output via Verify.Xunit snapshots. +/// +[UsesVerify] +public class FactoryMethodToCtorCodeFixProviderTests : CodeFixTestBase +{ + private readonly static FactoryMethodToCtorCodeFixProvider _provider = new(); + + [Fact] + public void FixableDiagnosticIds_ContainsEFP0012() => + Assert.Contains("EFP0012", _provider.FixableDiagnosticIds); + + [Fact] + public Task CodeFix_SimpleStaticFactoryMethod() => + Verifier.Verify( + ApplyCodeFixAsync( + FactoryMethodToCtorSources.SimpleStaticFactoryMethod, + "EFP0012", + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider, + actionIndex: 0)); + + [Fact] + public Task CodeFix_PreservesProjectableOptions() => + Verifier.Verify( + ApplyCodeFixAsync( + FactoryMethodToCtorSources.PreservesProjectableOptions, + "EFP0012", + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider, + actionIndex: 0)); + + [Fact] + public Task CodeFix_AddsParameterlessConstructor_WhenNoneExists() => + Verifier.Verify( + ApplyCodeFixAsync( + FactoryMethodToCtorSources.AddsParameterlessConstructor, + "EFP0012", + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider, + actionIndex: 0)); + + [Fact] + public Task CodeFix_DoesNotAddParamLessCtor_WhenAlreadyPresent() => + Verifier.Verify( + ApplyCodeFixAsync( + FactoryMethodToCtorSources.ParameterlessConstructorAlreadyPresent, + "EFP0012", + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider, + actionIndex: 0)); + + /// + /// When the class already has at least one explicit constructor (but no parameterless one), + /// the C# compiler did NOT generate an implicit default constructor — so the transformation + /// must NOT insert one, which would unintentionally widen the public surface area. + /// + [Fact] + public Task CodeFix_DoesNotAddParamLessCtor_WhenOtherExplicitCtorExists() => + Verifier.Verify( + ApplyCodeFixAsync( + FactoryMethodToCtorSources.OtherExplicitCtorExists, + "EFP0012", + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider, + actionIndex: 0)); + + /// + /// The implicit default constructor is always public (C# spec §10.11.4) regardless of the + /// factory method's accessibility. The inserted explicit parameterless ctor must therefore + /// be public too, even when the factory method is internal or protected. + /// + [Fact] + public Task CodeFix_InsertedParameterlessCtorIsAlwaysPublic() => + Verifier.Verify( + ApplyCodeFixAsync( + FactoryMethodToCtorSources.InsertedParameterlessCtorIsAlwaysPublic, + "EFP0012", + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider, + actionIndex: 0)); + + /// + /// Regression test: implicit object creation (new() { … }) must not throw + /// an + /// must treat it as + /// rather than casting to the explicit ObjectCreationExpressionSyntax. + /// + [Fact] + public Task CodeFix_ImplicitObjectCreation() => + Verifier.Verify( + ApplyCodeFixAsync( + FactoryMethodToCtorSources.ImplicitObjectCreation, + "EFP0012", + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider, + actionIndex: 0)); + + [Fact] + public async Task TwoCodeFixActionsAreOffered() + { + var actions = await GetCodeFixActionsAsync( + FactoryMethodToCtorSources.TwoActionsSource, + "EFP0012", + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider); + + Assert.Equal(2, actions.Count); + Assert.Contains("constructor", actions[0].Title, StringComparison.OrdinalIgnoreCase); + Assert.Contains("callers", actions[1].Title, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task NoCodeFix_WhenPatternDoesNotMatch() + { + // Without [Projectable] the pattern check returns false → no fix registered. + var actions = await GetCodeFixActionsAsync( + @" +namespace Foo { + class MyObj { + public static MyObj Create() => new MyObj { }; + } +}", + "EFP0012", + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider); + + Assert.Empty(actions); + } + + /// + /// Items = { 1, 2 } in an object initializer is an + /// + /// whose RHS is an . + /// Converting it to a statement produces invalid C# (Items = { 1, 2 };), + /// so the code fix must not be offered for this pattern. + /// + [Fact] + public async Task NoCodeFix_WhenInitializerHasNestedCollectionInitializer() + { + var actions = await GetCodeFixActionsAsync( + @" +using System.Collections.Generic; +namespace Foo { + class MyObj { + public List Items { get; set; } + [Projectable] + public static MyObj Create() => new MyObj { Items = { 1, 2 } }; + } +}", + "EFP0012", + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider); + + Assert.Empty(actions); + } + + /// + /// A mixed initializer that combines a simple assignment with a nested collection + /// initializer (Items = { 1, 2 }) must also be rejected. + /// + [Fact] + public async Task NoCodeFix_WhenMixedSimpleAndNestedCollectionInitializer() + { + var actions = await GetCodeFixActionsAsync( + @" +using System.Collections.Generic; +namespace Foo { + class MyObj { + public int Value { get; set; } + public List Items { get; set; } + [Projectable] + public static MyObj Create(int v) => new MyObj { Value = v, Items = { 1, 2 } }; + } +}", + "EFP0012", + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider); + + Assert.Empty(actions); + } +} diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_AddsParamLessCtor_WhenNoneExists.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_AddsParamLessCtor_WhenNoneExists.verified.txt new file mode 100644 index 0000000..57a096b --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_AddsParamLessCtor_WhenNoneExists.verified.txt @@ -0,0 +1,14 @@ + +namespace Foo { + class Input { } + class Output { + public Output() + { + } + + [Projectable] + public Output(Input i) + { + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_ComplexPropertyExpressions_ArePreserved.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_ComplexPropertyExpressions_ArePreserved.verified.txt new file mode 100644 index 0000000..603d167 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_ComplexPropertyExpressions_ArePreserved.verified.txt @@ -0,0 +1,25 @@ + +namespace Foo { + class Src { + public int X { get; set; } + public int Y { get; set; } + public bool IsActive { get; set; } + public string Name { get; set; } + } + class Dest { + public Dest() + { + } + + public int Sum { get; set; } + public int Toggle { get; set; } + public string Label { get; set; } + [Projectable] + public Dest(Src src) + { + Sum = src.X + src.Y; + Toggle = src.IsActive ? 1 : 0; + Label = src.Name ?? "unknown"; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_DoesNotAddParamLessCtor_WhenAlreadyPresent.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_DoesNotAddParamLessCtor_WhenAlreadyPresent.verified.txt new file mode 100644 index 0000000..d460104 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_DoesNotAddParamLessCtor_WhenAlreadyPresent.verified.txt @@ -0,0 +1,11 @@ + +namespace Foo { + class Input { } + class Output { + public Output() { } + [Projectable] + public Output(Input i) + { + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_DoesNotAddParamLessCtor_WhenOtherExplicitCtorExists.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_DoesNotAddParamLessCtor_WhenOtherExplicitCtorExists.verified.txt new file mode 100644 index 0000000..ad2d0cc --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_DoesNotAddParamLessCtor_WhenOtherExplicitCtorExists.verified.txt @@ -0,0 +1,13 @@ + +namespace Foo { + class Input { public int Value { get; set; } } + class Output { + public int Value { get; set; } + public Output(string name) { } + [Projectable] + public Output(Input i) + { + Value = i.Value; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_ImplicitObjectCreation.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_ImplicitObjectCreation.verified.txt new file mode 100644 index 0000000..96789f6 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_ImplicitObjectCreation.verified.txt @@ -0,0 +1,16 @@ + +namespace Foo { + class OtherObj { public string Prop1 { get; set; } } + class MyObj { + public MyObj() + { + } + + public string Prop1 { get; set; } + [Projectable] + public MyObj(OtherObj obj) + { + Prop1 = obj.Prop1; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_InsertedParameterlessCtorIsAlwaysPublic.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_InsertedParameterlessCtorIsAlwaysPublic.verified.txt new file mode 100644 index 0000000..4850265 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_InsertedParameterlessCtorIsAlwaysPublic.verified.txt @@ -0,0 +1,15 @@ + +namespace Foo { + class Input { } + class Output { + public Output() + { + } + + public int Value { get; set; } + [Projectable] + internal Output(Input i) + { + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_MultipleInitializerAssignments.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_MultipleInitializerAssignments.verified.txt new file mode 100644 index 0000000..28bfeea --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_MultipleInitializerAssignments.verified.txt @@ -0,0 +1,17 @@ + +namespace Foo { + class Src { public int A { get; set; } public int B { get; set; } } + class Dest { + public Dest() + { + } + + public int A { get; set; } + public int B { get; set; } + [Projectable] + public Dest(Src src) + { + A = src.A; B = src.B; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_PreservesInitializerInlineComments.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_PreservesInitializerInlineComments.verified.txt new file mode 100644 index 0000000..6a2348c --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_PreservesInitializerInlineComments.verified.txt @@ -0,0 +1,19 @@ + +namespace Foo { + class Src { public int A { get; set; } public int B { get; set; } } + class Dest { + public Dest() + { + } + + public int A { get; set; } + public int B { get; set; } + [Projectable] + public Dest(Src src) + { + // primary field + A = src.A; + B = src.B; // secondary field + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_PreservesLeadingXmlDocComment.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_PreservesLeadingXmlDocComment.verified.txt new file mode 100644 index 0000000..b3b81b9 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_PreservesLeadingXmlDocComment.verified.txt @@ -0,0 +1,18 @@ + +namespace Foo { + class Src { public int A { get; set; } } + class Dest { + public Dest() + { + } + + public int A { get; set; } + /// Creates a new from a . + /// The source object. + [Projectable] + public Dest(Src src) + { + A = src.A; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_PreservesProjectableOptions.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_PreservesProjectableOptions.verified.txt new file mode 100644 index 0000000..c6e87af --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_PreservesProjectableOptions.verified.txt @@ -0,0 +1,14 @@ + +namespace Foo { + class OtherObj { } + class MyObj { + public MyObj() + { + } + + [Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore)] + public MyObj(OtherObj obj) + { + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_SimpleFactoryMethod.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_SimpleFactoryMethod.verified.txt new file mode 100644 index 0000000..96789f6 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_SimpleFactoryMethod.verified.txt @@ -0,0 +1,16 @@ + +namespace Foo { + class OtherObj { public string Prop1 { get; set; } } + class MyObj { + public MyObj() + { + } + + public string Prop1 { get; set; } + [Projectable] + public MyObj(OtherObj obj) + { + Prop1 = obj.Prop1; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_WithExistingMembers_PreservesMemberOrder.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_WithExistingMembers_PreservesMemberOrder.verified.txt new file mode 100644 index 0000000..82d32e6 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.ConvertToConstructor_WithExistingMembers_PreservesMemberOrder.verified.txt @@ -0,0 +1,17 @@ + +namespace Foo { + class Input { public int Value { get; set; } } + class Output { + public Output() + { + } + + public int Value { get; set; } + public string Name { get; set; } + [Projectable] + public Output(Input i) + { + Value = i.Value; + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_MethodGroup_MultipleParameters.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_MethodGroup_MultipleParameters.verified.txt new file mode 100644 index 0000000..c504378 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_MethodGroup_MultipleParameters.verified.txt @@ -0,0 +1,19 @@ + +using System; +namespace Foo { + class Src { public int A { get; set; } } + class Dest { + public Dest() { } + public int A { get; set; } + public int Offset { get; set; } + [Projectable] + public Dest(Src src, int offset) + { + A = src.A; Offset = offset; + } + } + class Consumer { + void Register(Func factory) { } + void Setup() => Register((src, offset) => new Dest(src, offset)); + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_MethodGroup_SingleParameter.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_MethodGroup_SingleParameter.verified.txt new file mode 100644 index 0000000..5d3fa21 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_MethodGroup_SingleParameter.verified.txt @@ -0,0 +1,18 @@ + +using System.Collections.Generic; +using System.Linq; +namespace Foo { + class Src { public int A { get; set; } } + class Dest { + public Dest() { } + public int A { get; set; } + [Projectable] + public Dest(Src src) + { + A = src.A; + } + } + class Consumer { + Dest[] Use(IEnumerable items) => items.Select(src => new Dest(src)).ToArray(); + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_MixedDirectInvocationAndMethodGroup.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_MixedDirectInvocationAndMethodGroup.verified.txt new file mode 100644 index 0000000..f6a1c1d --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_MixedDirectInvocationAndMethodGroup.verified.txt @@ -0,0 +1,21 @@ + +using System.Collections.Generic; +using System.Linq; +namespace Foo { + class Src { public int A { get; set; } } + class Dest { + public Dest() { } + public int A { get; set; } + [Projectable] + public Dest(Src src) + { + A = src.A; + } + } + class Consumer { + void Setup(IEnumerable items) { + var d = new Dest(new Src { A = 1 }); // direct invocation + var all = items.Select(src => new Dest(src)).ToArray(); // method group + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_NameOfReference_IsNotRewritten.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_NameOfReference_IsNotRewritten.verified.txt new file mode 100644 index 0000000..6acb34a --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_NameOfReference_IsNotRewritten.verified.txt @@ -0,0 +1,16 @@ + +namespace Foo { + class Src { public int A { get; set; } } + class Dest { + public Dest() { } + public int A { get; set; } + [Projectable] + public Dest(Src src) + { + A = src.A; + } + } + class Consumer { + string GetMethodName() => nameof(Dest.Map); + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_PreservesCallSiteTrivia.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_PreservesCallSiteTrivia.verified.txt new file mode 100644 index 0000000..397a09f --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_PreservesCallSiteTrivia.verified.txt @@ -0,0 +1,19 @@ + +namespace Foo { + class Src { public int A { get; set; } } + class Dest { + public Dest() { } + public int A { get; set; } + [Projectable] + public Dest(Src src) + { + A = src.A; + } + } + class Consumer { + Dest Use(Src src) { + // map the source + return new Dest(src); // inline comment + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_SameDocument_OnlyReplacesFactoryCallSite.verified.txt b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_SameDocument_OnlyReplacesFactoryCallSite.verified.txt new file mode 100644 index 0000000..0f7363c --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.UpdateCallers_SameDocument_OnlyReplacesFactoryCallSite.verified.txt @@ -0,0 +1,23 @@ + +namespace Foo { + class Src { public int A { get; set; } public int B { get; set; } } + class Dest { + public Dest() { } + public int A { get; set; } + public int B { get; set; } + [Projectable] + public Dest(Src src) + { + A = src.A; B = src.B; + } + } + class Other { + public static int Compute() => 42; + } + class Consumer { + void Setup() { + var d = new Dest(new Src { A = 1, B = 2 }); + var x = Other.Compute(); + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.cs b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.cs new file mode 100644 index 0000000..78ee2ad --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorCodeRefProviderTests.cs @@ -0,0 +1,587 @@ +using VerifyXunit; +using Xunit; + +namespace EntityFrameworkCore.Projectables.CodeFixes.Tests; + +/// +/// Tests for . +/// Each test verifies via Verify.Xunit snapshots (.verified.txt files). +/// +[UsesVerify] +public class FactoryMethodToCtorCodeRefProviderTests : RefactoringTestBase +{ + private readonly static FactoryMethodToConstructorCodeRefactoringProvider _provider = new(); + + // ──────────────────────────────────────────────────────────────────────────── + // Action 0 — convert factory method to constructor (document only) + // ──────────────────────────────────────────────────────────────────────────── + + [Fact] + public Task ConvertToConstructor_SimpleFactoryMethod() => + Verifier.Verify( + ApplyRefactoringAsync( + FactoryMethodToCtorSources.SimpleStaticFactoryMethod, + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider, + actionIndex: 0)); + + [Fact] + public Task ConvertToConstructor_PreservesProjectableOptions() => + Verifier.Verify( + ApplyRefactoringAsync( + FactoryMethodToCtorSources.PreservesProjectableOptions, + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider, + actionIndex: 0)); + + [Fact] + public Task ConvertToConstructor_MultipleInitializerAssignments() => + Verifier.Verify( + ApplyRefactoringAsync( + @" +namespace Foo { + class Src { public int A { get; set; } public int B { get; set; } } + class Dest { + public int A { get; set; } + public int B { get; set; } + [Projectable] + public static Dest Map(Src src) => new Dest { A = src.A, B = src.B }; + } +}", + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider, + actionIndex: 0)); + + [Fact] + public Task ConvertToConstructor_AddsParamLessCtor_WhenNoneExists() => + Verifier.Verify( + ApplyRefactoringAsync( + FactoryMethodToCtorSources.AddsParameterlessConstructor, + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider, + actionIndex: 0)); + + [Fact] + public Task ConvertToConstructor_DoesNotAddParamLessCtor_WhenAlreadyPresent() => + Verifier.Verify( + ApplyRefactoringAsync( + FactoryMethodToCtorSources.ParameterlessConstructorAlreadyPresent, + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider, + actionIndex: 0)); + + /// + /// When the class already has at least one explicit constructor (but no parameterless one), + /// the C# compiler did NOT generate an implicit default constructor — so the transformation + /// must NOT insert one, which would unintentionally widen the public surface area. + /// + [Fact] + public Task ConvertToConstructor_DoesNotAddParamLessCtor_WhenOtherExplicitCtorExists() => + Verifier.Verify( + ApplyRefactoringAsync( + FactoryMethodToCtorSources.OtherExplicitCtorExists, + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider, + actionIndex: 0)); + + /// + /// The implicit default constructor is always public (C# spec §10.11.4) regardless of the + /// factory method's accessibility. The inserted explicit parameterless ctor must therefore + /// be public too, even when the factory method is internal or protected. + /// + [Fact] + public Task ConvertToConstructor_InsertedParameterlessCtorIsAlwaysPublic() => + Verifier.Verify( + ApplyRefactoringAsync( + FactoryMethodToCtorSources.InsertedParameterlessCtorIsAlwaysPublic, + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider, + actionIndex: 0)); + + [Fact] + public Task ConvertToConstructor_WithExistingMembers_PreservesMemberOrder() => + Verifier.Verify( + ApplyRefactoringAsync( + @" +namespace Foo { + class Input { public int Value { get; set; } } + class Output { + public int Value { get; set; } + public string Name { get; set; } + [Projectable] + public static Output From(Input i) => new Output { Value = i.Value }; + } +}", + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider, + actionIndex: 0)); + + // ──────────────────────────────────────────────────────────────────────────── + // Guard: no refactoring should be offered in inapplicable situations + // ──────────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task NoRefactoring_WhenMethodHasNoProjectableAttribute() + { + var actions = await GetRefactoringActionsAsync( + @" +namespace Foo { + class MyObj { + public MyObj Create() => new MyObj { }; + } +}", + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider); + + Assert.Empty(actions); + } + + [Fact] + public async Task NoRefactoring_WhenReturnTypeDoesNotMatchContainingClass() + { + var actions = await GetRefactoringActionsAsync( + @" +namespace Foo { + class Other { } + class MyObj { + [Projectable] + public Other Create() => new Other { }; + } +}", + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider); + + Assert.Empty(actions); + } + + [Fact] + public async Task NoRefactoring_WhenBodyHasConstructorArguments() + { + var actions = await GetRefactoringActionsAsync( + @" +namespace Foo { + class MyObj { + [Projectable] + public MyObj Create(int x) => new MyObj(x) { }; + } +}", + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider); + + Assert.Empty(actions); + } + + [Fact] + public async Task NoRefactoring_WhenBodyIsNotObjectCreation() + { + var actions = await GetRefactoringActionsAsync( + @" +namespace Foo { + class MyObj { + [Projectable] + public MyObj Create() => default; + } +}", + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider); + + Assert.Empty(actions); + } + + [Fact] + public async Task NoRefactoring_WhenBodyHasNoInitializer() + { + var actions = await GetRefactoringActionsAsync( + @" +namespace Foo { + class MyObj { + [Projectable] + public MyObj Create() => new MyObj(); + } +}", + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider); + + Assert.Empty(actions); + } + + /// + /// Items = { 1, 2 } in an object initializer is an + /// + /// whose RHS is an . + /// Converting it to a statement produces invalid C# (Items = { 1, 2 };), + /// so the refactoring must not be offered for this pattern. + /// + [Fact] + public async Task NoRefactoring_WhenInitializerHasNestedCollectionInitializer() + { + var actions = await GetRefactoringActionsAsync( + @" +using System.Collections.Generic; +namespace Foo { + class MyObj { + public List Items { get; set; } + [Projectable] + public static MyObj Create() => new MyObj { Items = { 1, 2 } }; + } +}", + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider); + + Assert.Empty(actions); + } + + /// + /// A mixed initializer that combines a simple assignment with a nested collection + /// initializer (Items = { 1, 2 }) must also be rejected. + /// + [Fact] + public async Task NoRefactoring_WhenMixedSimpleAndNestedCollectionInitializer() + { + var actions = await GetRefactoringActionsAsync( + @" +using System.Collections.Generic; +namespace Foo { + class MyObj { + public int Value { get; set; } + public List Items { get; set; } + [Projectable] + public static MyObj Create(int v) => new MyObj { Value = v, Items = { 1, 2 } }; + } +}", + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider); + + Assert.Empty(actions); + } + + /// + /// Regression: new Other.MyObj { } has a qualified type name that cannot be + /// confirmed as the containing type without a semantic model. The pattern must reject + /// it to avoid a false-positive transformation that would corrupt the class. + /// + [Fact] + public async Task NoRefactoring_WhenCreatedTypeIsQualifiedName() + { + var actions = await GetRefactoringActionsAsync( + @" +namespace Other { class MyObj { } } +namespace Foo { + class MyObj { + [Projectable] + public static MyObj Create() => new Other.MyObj { }; + } +}", + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider); + + Assert.Empty(actions); + } + + /// + /// Regression: new global::Other.MyObj { } uses an alias-qualified name that + /// cannot be confirmed as the containing type without a semantic model. + /// + [Fact] + public async Task NoRefactoring_WhenCreatedTypeIsAliasQualifiedName() + { + var actions = await GetRefactoringActionsAsync( + @" +namespace Other { class MyObj { } } +namespace Foo { + class MyObj { + [Projectable] + public static MyObj Create() => new global::Other.MyObj { }; + } +}", + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider); + + Assert.Empty(actions); + } + + // ──────────────────────────────────────────────────────────────────────────── + // Action titles + // ──────────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task TwoActionsAreOffered_WithCorrectTitles() + { + var actions = await GetRefactoringActionsAsync( + FactoryMethodToCtorSources.TwoActionsSource, + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider); + + Assert.Equal(2, actions.Count); + Assert.Contains("constructor", actions[0].Title, StringComparison.OrdinalIgnoreCase); + Assert.Contains("callers", actions[1].Title, StringComparison.OrdinalIgnoreCase); + } + + // ──────────────────────────────────────────────────────────────────────────── + // Action 1 — convert factory method to constructor AND update callers + // ──────────────────────────────────────────────────────────────────────────── + + /// + /// Regression test: when the declaring document also contains call sites, + /// BuildRootWithConstructor shifts all spans (it removes the factory method and + /// inserts a constructor). Only Dest.Map(…) must be rewritten — + /// unrelated invocations such as Other.Compute() must be left intact. + /// + [Fact] + public Task UpdateCallers_SameDocument_OnlyReplacesFactoryCallSite() => + Verifier.Verify( + ApplyRefactoringAsync( + CreateDocumentWithReferences(@" +namespace Foo { + class Src { public int A { get; set; } public int B { get; set; } } + class Dest { + public Dest() { } + public int A { get; set; } + public int B { get; set; } + [Projectable] + public static Dest Map(Src src) => new Dest { A = src.A, B = src.B }; + } + class Other { + public static int Compute() => 42; + } + class Consumer { + void Setup() { + var d = Dest.Map(new Src { A = 1, B = 2 }); + var x = Other.Compute(); + } + } +}"), + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider, + actionIndex: 1)); + + [Fact] + public Task UpdateCallers_PreservesCallSiteTrivia() => + Verifier.Verify( + ApplyRefactoringAsync( + CreateDocumentWithReferences(@" +namespace Foo { + class Src { public int A { get; set; } } + class Dest { + public Dest() { } + public int A { get; set; } + [Projectable] + public static Dest Map(Src src) => new Dest { A = src.A }; + } + class Consumer { + Dest Use(Src src) { + // map the source + return Dest.Map(src); // inline comment + } + } +}"), + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider, + actionIndex: 1)); + + // ──────────────────────────────────────────────────────────────────────────── + // Action 1 — method group callers (e.g. .Select(Dest.Map)) + // ──────────────────────────────────────────────────────────────────────────── + + /// + /// A single-parameter method group passed to Select must be rewritten to a + /// simple lambda: Dest.Mapsrc => new Dest(src). + /// + [Fact] + public Task UpdateCallers_MethodGroup_SingleParameter() => + Verifier.Verify( + ApplyRefactoringAsync( + CreateDocumentWithReferences(@" +using System.Collections.Generic; +using System.Linq; +namespace Foo { + class Src { public int A { get; set; } } + class Dest { + public Dest() { } + public int A { get; set; } + [Projectable] + public static Dest Map(Src src) => new Dest { A = src.A }; + } + class Consumer { + Dest[] Use(IEnumerable items) => items.Select(Dest.Map).ToArray(); + } +}"), + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider, + actionIndex: 1)); + + /// + /// A multi-parameter method group assigned to a delegate variable must be rewritten + /// to a parenthesised lambda: Dest.Map(src, offset) => new Dest(src, offset). + /// + [Fact] + public Task UpdateCallers_MethodGroup_MultipleParameters() => + Verifier.Verify( + ApplyRefactoringAsync( + CreateDocumentWithReferences(@" +using System; +namespace Foo { + class Src { public int A { get; set; } } + class Dest { + public Dest() { } + public int A { get; set; } + public int Offset { get; set; } + [Projectable] + public static Dest Map(Src src, int offset) => new Dest { A = src.A, Offset = offset }; + } + class Consumer { + void Register(Func factory) { } + void Setup() => Register(Dest.Map); + } +}"), + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider, + actionIndex: 1)); + + /// + /// When the same document has both a direct invocation and a method-group + /// reference to the same factory method, both must be rewritten correctly and + /// independently. + /// + [Fact] + public Task UpdateCallers_MixedDirectInvocationAndMethodGroup() => + Verifier.Verify( + ApplyRefactoringAsync( + CreateDocumentWithReferences(@" +using System.Collections.Generic; +using System.Linq; +namespace Foo { + class Src { public int A { get; set; } } + class Dest { + public Dest() { } + public int A { get; set; } + [Projectable] + public static Dest Map(Src src) => new Dest { A = src.A }; + } + class Consumer { + void Setup(IEnumerable items) { + var d = Dest.Map(new Src { A = 1 }); // direct invocation + var all = items.Select(Dest.Map).ToArray(); // method group + } + } +}"), + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider, + actionIndex: 1)); + + /// + /// nameof(Dest.Map) is returned by SymbolFinder.FindReferencesAsync as a + /// reference location. It must NOT be rewritten to a lambda — it should be left unchanged + /// (producing a compile-time error that the user can fix manually), rather than generating + /// invalid C# like p => new Dest(p) inside a nameof argument. + /// + [Fact] + public Task UpdateCallers_NameOfReference_IsNotRewritten() => + Verifier.Verify( + ApplyRefactoringAsync( + CreateDocumentWithReferences(@" +namespace Foo { + class Src { public int A { get; set; } } + class Dest { + public Dest() { } + public int A { get; set; } + [Projectable] + public static Dest Map(Src src) => new Dest { A = src.A }; + } + class Consumer { + string GetMethodName() => nameof(Dest.Map); + } +}"), + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider, + actionIndex: 1)); + + // ──────────────────────────────────────────────────────────────────────────── + // Complex initializer expressions and trivia preservation (action 0) + // ──────────────────────────────────────────────────────────────────────────── + + [Fact] + public Task ConvertToConstructor_ComplexPropertyExpressions_ArePreserved() => + Verifier.Verify( + ApplyRefactoringAsync( + @" +namespace Foo { + class Src { + public int X { get; set; } + public int Y { get; set; } + public bool IsActive { get; set; } + public string Name { get; set; } + } + class Dest { + public int Sum { get; set; } + public int Toggle { get; set; } + public string Label { get; set; } + [Projectable] + public static Dest Map(Src src) => new Dest { + Sum = src.X + src.Y, + Toggle = src.IsActive ? 1 : 0, + Label = src.Name ?? ""unknown"" + }; + } +}", + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider, + actionIndex: 0)); + + [Fact] + public Task ConvertToConstructor_PreservesLeadingXmlDocComment() => + Verifier.Verify( + ApplyRefactoringAsync( + @" +namespace Foo { + class Src { public int A { get; set; } } + class Dest { + public int A { get; set; } + /// Creates a new from a . + /// The source object. + [Projectable] + public static Dest Map(Src src) => new Dest { A = src.A }; + } +}", + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider, + actionIndex: 0)); + + /// + /// Regression test: implicit object creation (new() { … }) must not throw + /// an + /// must treat it as + /// rather than casting to the explicit ObjectCreationExpressionSyntax. + /// + [Fact] + public Task ConvertToConstructor_ImplicitObjectCreation() => + Verifier.Verify( + ApplyRefactoringAsync( + FactoryMethodToCtorSources.ImplicitObjectCreation, + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider, + actionIndex: 0)); + + [Fact] + public Task ConvertToConstructor_PreservesInitializerInlineComments() => + Verifier.Verify( + ApplyRefactoringAsync( + @" +namespace Foo { + class Src { public int A { get; set; } public int B { get; set; } } + class Dest { + public int A { get; set; } + public int B { get; set; } + [Projectable] + public static Dest Map(Src src) => new Dest { + // primary field + A = src.A, + B = src.B // secondary field + }; + } +}", + FactoryMethodToCtorSources.FirstMethodIdentifierSpan, + _provider, + actionIndex: 0)); +} + diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorSources.cs b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorSources.cs new file mode 100644 index 0000000..904414d --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/FactoryMethodToCtorSources.cs @@ -0,0 +1,105 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace EntityFrameworkCore.Projectables.CodeFixes.Tests; + +/// +/// Source code snippets shared between +/// and . +/// +static internal class FactoryMethodToCtorSources +{ + static internal TextSpan FirstMethodIdentifierSpan(SyntaxNode root) => + root.DescendantNodes() + .OfType() + .First() + .Identifier + .Span; + + [StringSyntax("csharp")] + internal const string SimpleStaticFactoryMethod = @" +namespace Foo { + class OtherObj { public string Prop1 { get; set; } } + class MyObj { + public string Prop1 { get; set; } + [Projectable] + public static MyObj Create(OtherObj obj) => new MyObj { Prop1 = obj.Prop1 }; + } +}"; + + [StringSyntax("csharp")] + internal const string PreservesProjectableOptions = @" +namespace Foo { + class OtherObj { } + class MyObj { + [Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore)] + public static MyObj Create(OtherObj obj) => new MyObj { }; + } +}"; + + [StringSyntax("csharp")] + internal const string AddsParameterlessConstructor = @" +namespace Foo { + class Input { } + class Output { + [Projectable] + public static Output Create(Input i) => new Output { }; + } +}"; + + [StringSyntax("csharp")] + internal const string ParameterlessConstructorAlreadyPresent = @" +namespace Foo { + class Input { } + class Output { + public Output() { } + [Projectable] + public static Output Create(Input i) => new Output { }; + } +}"; + + [StringSyntax("csharp")] + internal const string OtherExplicitCtorExists = @" +namespace Foo { + class Input { public int Value { get; set; } } + class Output { + public int Value { get; set; } + public Output(string name) { } + [Projectable] + public static Output Create(Input i) => new Output { Value = i.Value }; + } +}"; + + [StringSyntax("csharp")] + internal const string InsertedParameterlessCtorIsAlwaysPublic = @" +namespace Foo { + class Input { } + class Output { + public int Value { get; set; } + [Projectable] + internal static Output Create(Input i) => new Output { }; + } +}"; + + [StringSyntax("csharp")] + internal const string ImplicitObjectCreation = @" +namespace Foo { + class OtherObj { public string Prop1 { get; set; } } + class MyObj { + public string Prop1 { get; set; } + [Projectable] + public static MyObj Create(OtherObj obj) => new() { Prop1 = obj.Prop1 }; + } +}"; + + [StringSyntax("csharp")] + internal const string TwoActionsSource = @" +namespace Foo { + class MyObj { + [Projectable] + public static MyObj Create() => new MyObj { }; + } +}"; +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/RefactoringTestBase.cs b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/RefactoringTestBase.cs new file mode 100644 index 0000000..f2d0756 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.CodeFixes.Tests/RefactoringTestBase.cs @@ -0,0 +1,113 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeRefactorings; +using Microsoft.CodeAnalysis.Text; +using Xunit; + +namespace EntityFrameworkCore.Projectables.CodeFixes.Tests; + +/// +/// Base class providing helpers for tests. +/// Builds an in-memory document, invokes the provider at the supplied span, and +/// optionally applies one of the offered refactoring actions. +/// +public abstract class RefactoringTestBase : CodeFixTestBase +{ + private async static Task<(Document Document, IReadOnlyList Actions)> CollectRefactoringActionsAsync( + [StringSyntax("csharp")] + string source, + Func locateSpan, + CodeRefactoringProvider provider) + { + var document = CreateDocument(source); + var root = await document.GetSyntaxRootAsync(); + var span = locateSpan(root!); + + var actions = new List(); + var context = new CodeRefactoringContext( + document, + span, + action => actions.Add(action), + CancellationToken.None); + + await provider.ComputeRefactoringsAsync(context); + return (document, actions); + } + + /// + /// Returns all instances offered by + /// for the span returned by . + /// + protected async static Task> GetRefactoringActionsAsync( + [StringSyntax("csharp")] + string source, + Func locateSpan, + CodeRefactoringProvider provider) + { + var (_, actions) = await CollectRefactoringActionsAsync(source, locateSpan, provider); + return actions; + } + + /// + /// Applies the refactoring action at and returns the full + /// source text of the primary (originating) document after the change. + /// + protected async static Task ApplyRefactoringAsync( + [StringSyntax("csharp")] + string source, + Func locateSpan, + CodeRefactoringProvider provider, + int actionIndex = 0) + { + var (document, actions) = await CollectRefactoringActionsAsync(source, locateSpan, provider); + + Assert.True( + actions.Count > actionIndex, + $"Expected at least {actionIndex + 1} refactoring action(s) but only {actions.Count} were registered."); + + var action = actions[actionIndex]; + var operations = await action.GetOperationsAsync(CancellationToken.None); + var applyOp = operations.OfType().Single(); + + var newDocument = applyOp.ChangedSolution.GetDocument(document.Id)!; + var newRoot = await newDocument.GetSyntaxRootAsync(); + return newRoot!.ToFullString(); + } + + /// + /// Overload that accepts an already-created . + /// Use this when the document must carry metadata references (e.g. for + /// "update callers" actions that rely on SymbolFinder.FindReferencesAsync). + /// + protected async static Task ApplyRefactoringAsync( + Document document, + Func locateSpan, + CodeRefactoringProvider provider, + int actionIndex = 0) + { + var root = await document.GetSyntaxRootAsync(); + var span = locateSpan(root!); + + var actions = new List(); + var context = new CodeRefactoringContext( + document, + span, + action => actions.Add(action), + CancellationToken.None); + + await provider.ComputeRefactoringsAsync(context); + + Assert.True( + actions.Count > actionIndex, + $"Expected at least {actionIndex + 1} refactoring action(s) but only {actions.Count} were registered."); + + var action = actions[actionIndex]; + var operations = await action.GetOperationsAsync(CancellationToken.None); + var applyOp = operations.OfType().Single(); + + var newDocument = applyOp.ChangedSolution.GetDocument(document.Id)!; + var newRoot = await newDocument.GetSyntaxRootAsync(); + return newRoot!.ToFullString(); + } +} diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/FactoryMethodDiagnosticTests.cs b/tests/EntityFrameworkCore.Projectables.Generator.Tests/FactoryMethodDiagnosticTests.cs new file mode 100644 index 0000000..da2c2bf --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/FactoryMethodDiagnosticTests.cs @@ -0,0 +1,202 @@ +using System.Linq; +using Microsoft.CodeAnalysis; +using Xunit; +using Xunit.Abstractions; + +namespace EntityFrameworkCore.Projectables.Generator.Tests; + +/// +/// Tests that the generator reports EFP0012 (Info) when a [Projectable] method +/// matches the factory-method pattern (expression body => new ContainingType { … }), +/// and that it does NOT report the diagnostic for methods that do not match. +/// +/// Unlike the previous standalone DiagnosticAnalyzer, the diagnostic is now emitted +/// directly in so that it is part of the +/// same incremental pipeline that produces the expression tree — no separate analysis pass needed. +/// +/// +public class FactoryMethodDiagnosticTests : ProjectionExpressionGeneratorTestsBase +{ + public FactoryMethodDiagnosticTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { } + + // ──────────────────────────────────────────────────────────────────────── + // EFP0012 is reported + // ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void ReportsEFP0012_OnStaticFactoryMethod() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; +namespace Foo { + class OtherObj { } + class MyObj { + [Projectable] + public static MyObj Create(OtherObj o) => new MyObj { }; + } +}"); + var result = RunGenerator(compilation); + + var diag = Assert.Single(result.Diagnostics); + Assert.Equal("EFP0012", diag.Id); + Assert.Equal(DiagnosticSeverity.Info, diag.Severity); + Assert.Equal("Create", diag.Location.SourceTree! + .GetRoot().FindToken(diag.Location.SourceSpan.Start).ValueText); + } + + [Fact] + public void ReportsEFP0012_WithMultipleInitializerAssignments() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; +namespace Foo { + class Src { public int A { get; set; } public int B { get; set; } } + class Dest { + public int A { get; set; } + public int B { get; set; } + [Projectable] + public static Dest Map(Src s) => new Dest { A = s.A, B = s.B }; + } +}"); + var result = RunGenerator(compilation); + + var diag = Assert.Single(result.Diagnostics); + Assert.Equal("EFP0012", diag.Id); + } + + [Fact] + public void ReportsEFP0012_AndStillGeneratesExpressionTree() + { + // EFP0012 is Info — generation must not be blocked. + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; +namespace Foo { + class Input { public int Value { get; set; } } + class Output { + public int Value { get; set; } + [Projectable] + public static Output From(Input i) => new Output { Value = i.Value }; + } +}"); + var result = RunGenerator(compilation); + + Assert.Single(result.Diagnostics.Where(d => d.Id == "EFP0012")); + Assert.Single(result.GeneratedTrees); // expression tree is still generated + } + + [Fact] + public void ReportsEFP0012_WithProjectableOptions_Preserved() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; +namespace Foo { + class OtherObj { } + class MyObj { + [Projectable(NullConditionalRewriteSupport = NullConditionalRewriteSupport.Ignore)] + public static MyObj Create(OtherObj o) => new MyObj { }; + } +}"); + var result = RunGenerator(compilation); + + var diag = Assert.Single(result.Diagnostics); + Assert.Equal("EFP0012", diag.Id); + } + + // ──────────────────────────────────────────────────────────────────────── + // EFP0012 is NOT reported + // ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void NoEFP0012_WhenBodyHasConstructorArguments() + { + // new MyObj(x) { } — has constructor args, not a pure initializer + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; +namespace Foo { + class MyObj { + public MyObj() { } + public MyObj(int x) { } + [Projectable] + public static MyObj Create(int x) => new MyObj(x) { }; + } +}"); + var result = RunGenerator(compilation); + + Assert.DoesNotContain(result.Diagnostics, d => d.Id == "EFP0012"); + } + + [Fact] + public void NoEFP0012_WhenBodyHasNoInitializer() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; +namespace Foo { + class MyObj { + public MyObj() { } + [Projectable] + public static MyObj Create() => new MyObj(); + } +}"); + var result = RunGenerator(compilation); + + Assert.DoesNotContain(result.Diagnostics, d => d.Id == "EFP0012"); + } + + [Fact] + public void NoEFP0012_WhenReturnTypeDoesNotMatchContainingClass() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; +namespace Foo { + class Other { } + class MyObj { + [Projectable] + public static Other Create() => new Other { }; + } +}"); + var result = RunGenerator(compilation); + + Assert.DoesNotContain(result.Diagnostics, d => d.Id == "EFP0012"); + } + + [Fact] + public void NoEFP0012_WhenBodyIsNotObjectCreation() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; +namespace Foo { + class MyObj { + public int Value { get; set; } + [Projectable] + public int Computed => Value * 2; + } +}"); + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + } + + [Fact] + public void NoEFP0012_ForProjectableConstructor() + { + var compilation = CreateCompilation(@" +using EntityFrameworkCore.Projectables; +namespace Foo { + class OtherObj { public int X { get; set; } } + class MyObj { + public int X { get; set; } + public MyObj() { } + [Projectable] + public MyObj(OtherObj o) { + X = o.X; + } + } +}"); + var result = RunGenerator(compilation); + + // A [Projectable] constructor is not a factory method — EFP0012 must not be reported. + Assert.DoesNotContain(result.Diagnostics, d => d.Id == "EFP0012"); + } +} + +