From fafb89e28d2baeafa04c4fe675a40348beecc2e3 Mon Sep 17 00:00:00 2001 From: Brennan Date: Mon, 11 Aug 2025 13:51:55 -0700 Subject: [PATCH 1/4] WIP --- .../src/Analyzers/DiagnosticDescriptors.cs | 9 + .../src/Analyzers/Resources.resx | 6 + ...erInsteadOfCreateWebHostBuilderAnalyzer.cs | 109 +++++ ...ilderInsteadOfCreateWebHostBuilderFixer.cs | 434 ++++++++++++++++++ ...uilderInsteadOfCreateWebHostBuilderTest.cs | 400 ++++++++++++++++ 5 files changed, 958 insertions(+) create mode 100644 src/Framework/AspNetCoreAnalyzers/src/Analyzers/WebApplicationBuilder/UseCreateHostBuilderInsteadOfCreateWebHostBuilderAnalyzer.cs create mode 100644 src/Framework/AspNetCoreAnalyzers/src/CodeFixes/UseCreateHostBuilderInsteadOfCreateWebHostBuilderFixer.cs create mode 100644 src/Framework/AspNetCoreAnalyzers/test/WebApplicationBuilder/UseCreateHostBuilderInsteadOfCreateWebHostBuilderTest.cs diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DiagnosticDescriptors.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DiagnosticDescriptors.cs index e48dd9445bfc..6269b8a9dfba 100644 --- a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DiagnosticDescriptors.cs +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DiagnosticDescriptors.cs @@ -248,4 +248,13 @@ internal static class DiagnosticDescriptors DiagnosticSeverity.Info, isEnabledByDefault: true, helpLinkUri: AnalyzersLink); + + internal static readonly DiagnosticDescriptor UseCreateHostBuilderInsteadOfCreateWebHostBuilder = new( + "ASP0029", + CreateLocalizableResourceString(nameof(Resources.Analyzer_UseCreateHostBuilderInsteadOfCreateWebHostBuilder_Title)), + CreateLocalizableResourceString(nameof(Resources.Analyzer_UseCreateHostBuilderInsteadOfCreateWebHostBuilder_Message)), + Usage, + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + helpLinkUri: AnalyzersLink); } diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Resources.resx b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Resources.resx index 8c9397f5be64..fea73d77333e 100644 --- a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Resources.resx +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Resources.resx @@ -333,4 +333,10 @@ If the server does not specifically reject IPv6, IPAddress.IPv6Any is preferred over IPAddress.Any usage for safety and performance reasons. See https://aka.ms/aspnetcore-warnings/ASP0028 for more details. + + Use Host.CreateDefaultBuilder instead of WebHost.CreateDefaultBuilder + + + WebHost is deprecated. Use Host.CreateDefaultBuilder and ConfigureWebHostDefaults instead. + diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/WebApplicationBuilder/UseCreateHostBuilderInsteadOfCreateWebHostBuilderAnalyzer.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/WebApplicationBuilder/UseCreateHostBuilderInsteadOfCreateWebHostBuilderAnalyzer.cs new file mode 100644 index 000000000000..a8a04c07369e --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/WebApplicationBuilder/UseCreateHostBuilderInsteadOfCreateWebHostBuilderAnalyzer.cs @@ -0,0 +1,109 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Immutable; +using System.Linq; +using Microsoft.AspNetCore.App.Analyzers.Infrastructure; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Microsoft.AspNetCore.Analyzers.WebApplicationBuilder; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class UseCreateHostBuilderInsteadOfCreateWebHostBuilderAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create( + DiagnosticDescriptors.UseCreateHostBuilderInsteadOfCreateWebHostBuilder + ); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationStartAction(context => + { + var compilation = context.Compilation; + var wellKnownTypes = WellKnownTypes.GetOrCreate(compilation); + + context.RegisterOperationAction(context => + { + var invocation = (IInvocationOperation)context.Operation; + var targetMethod = invocation.TargetMethod; + + // Check if this is WebHost.CreateDefaultBuilder + if (IsWebHostCreateDefaultBuilderCall(targetMethod)) + { + var diagnostic = Diagnostic.Create( + DiagnosticDescriptors.UseCreateHostBuilderInsteadOfCreateWebHostBuilder, + invocation.Syntax.GetLocation() + ); + context.ReportDiagnostic(diagnostic); + } + }, OperationKind.Invocation); + + context.RegisterSyntaxNodeAction(context => + { + var methodDeclaration = (MethodDeclarationSyntax)context.Node; + var semantic = context.SemanticModel; + var symbol = semantic.GetDeclaredSymbol(methodDeclaration); + + // Check if this method returns IWebHostBuilder + if (symbol != null && IsWebHostBuilderReturnType(symbol)) + { + // Check if the method body contains WebHost.CreateDefaultBuilder + if (ContainsWebHostCreateDefaultBuilder(methodDeclaration)) + { + var diagnostic = Diagnostic.Create( + DiagnosticDescriptors.UseCreateHostBuilderInsteadOfCreateWebHostBuilder, + methodDeclaration.ReturnType.GetLocation() + ); + context.ReportDiagnostic(diagnostic); + } + } + }, SyntaxKind.MethodDeclaration); + }); + } + + private static bool IsWebHostCreateDefaultBuilderCall(IMethodSymbol method) + { + // Check if this is WebHost.CreateDefaultBuilder (not other WebHost methods) + if (method.Name == "CreateDefaultBuilder" && + method.ContainingType?.Name == "WebHost" && + method.ContainingType?.ContainingNamespace?.ToDisplayString() == "Microsoft.AspNetCore") + { + return true; + } + + return false; + } + + private static bool IsWebHostBuilderReturnType(IMethodSymbol method) + { + // Check if the return type is IWebHostBuilder + var returnType = method.ReturnType; + return returnType.Name == "IWebHostBuilder" && + returnType.ContainingNamespace?.ToDisplayString() == "Microsoft.AspNetCore.Hosting"; + } + + private static bool ContainsWebHostCreateDefaultBuilder(MethodDeclarationSyntax methodDeclaration) + { + // Check if the method contains WebHost.CreateDefaultBuilder calls + var descendants = methodDeclaration.DescendantNodes().OfType(); + + foreach (var invocation in descendants) + { + if (invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Expression is IdentifierNameSyntax identifier && + identifier.Identifier.ValueText == "WebHost" && + memberAccess.Name.Identifier.ValueText == "CreateDefaultBuilder") + { + return true; + } + } + + return false; + } +} diff --git a/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/UseCreateHostBuilderInsteadOfCreateWebHostBuilderFixer.cs b/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/UseCreateHostBuilderInsteadOfCreateWebHostBuilderFixer.cs new file mode 100644 index 000000000000..666c5ad8029b --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/UseCreateHostBuilderInsteadOfCreateWebHostBuilderFixer.cs @@ -0,0 +1,434 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Microsoft.AspNetCore.Analyzers.WebApplicationBuilder.Fixers; + +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(UseCreateHostBuilderInsteadOfCreateWebHostBuilderFixer)), Shared] +public sealed class UseCreateHostBuilderInsteadOfCreateWebHostBuilderFixer : CodeFixProvider +{ + public override ImmutableArray FixableDiagnosticIds { get; } = ImmutableArray.Create( + DiagnosticDescriptors.UseCreateHostBuilderInsteadOfCreateWebHostBuilder.Id + ); + + public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root == null) + { + return; + } + + foreach (var diagnostic in context.Diagnostics) + { + var node = root.FindNode(diagnostic.Location.SourceSpan); + + // Handle method return type case (IWebHostBuilder return type) + if (node is TypeSyntax returnType && + returnType.Parent is MethodDeclarationSyntax methodDeclaration) + { + context.RegisterCodeFix( + CodeAction.Create( + title: "Convert to IHostBuilder and use Host.CreateDefaultBuilder", + createChangedDocument: c => ConvertWebHostBuilderMethod(context.Document, methodDeclaration, c), + equivalenceKey: "ConvertWebHostBuilderMethod"), + diagnostic); + } + + // Handle invocation case (WebHost.CreateDefaultBuilder) + else if (node.FirstAncestorOrSelf() is InvocationExpressionSyntax invocation) + { + if (IsWebHostCreateDefaultBuilderInvocation(invocation)) + { + context.RegisterCodeFix( + CodeAction.Create( + title: "Replace with Host.CreateDefaultBuilder", + createChangedDocument: c => ConvertWebHostCreateDefaultBuilder(context.Document, invocation, c), + equivalenceKey: "ConvertWebHostCreateDefaultBuilder"), + diagnostic); + } + } + } + } + + private static bool IsWebHostCreateDefaultBuilderInvocation(InvocationExpressionSyntax invocation) + { + if (invocation.Expression is MemberAccessExpressionSyntax memberAccess) + { + return memberAccess.Expression is IdentifierNameSyntax { Identifier.ValueText: "WebHost" } && + memberAccess.Name.Identifier.ValueText == "CreateDefaultBuilder"; + } + return false; + } + + private static async Task ConvertWebHostBuilderMethod(Document document, MethodDeclarationSyntax methodDeclaration, CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root == null) + { + return document; + } + + var newMethodDeclaration = methodDeclaration; + + // Change return type from IWebHostBuilder to IHostBuilder + if (IsWebHostBuilderType(methodDeclaration.ReturnType)) + { + var newReturnType = SyntaxFactory.IdentifierName("IHostBuilder"); + newMethodDeclaration = newMethodDeclaration.WithReturnType(newReturnType); + } + + // Transform the method body to use Host.CreateDefaultBuilder and ConfigureWebHostDefaults + if (methodDeclaration.Body != null) + { + var transformedBody = TransformMethodBody(methodDeclaration.Body); + newMethodDeclaration = newMethodDeclaration.WithBody(transformedBody); + } + else if (methodDeclaration.ExpressionBody != null) + { + var transformedExpressionBody = TransformExpressionBody(methodDeclaration.ExpressionBody); + newMethodDeclaration = newMethodDeclaration.WithExpressionBody(transformedExpressionBody); + } + + var newRoot = root.ReplaceNode(methodDeclaration, newMethodDeclaration.WithLeadingTrivia(methodDeclaration.GetLeadingTrivia())); + + // Add the required using statement if not already present + if (root is CompilationUnitSyntax compilationUnit) + { + var hasHostingUsing = compilationUnit.Usings.Any(u => + u.Name?.ToString() == "Microsoft.Extensions.Hosting"); + + if (!hasHostingUsing) + { + var hostingUsing = SyntaxFactory.UsingDirective( + SyntaxFactory.QualifiedName( + SyntaxFactory.QualifiedName( + SyntaxFactory.IdentifierName("Microsoft"), + SyntaxFactory.IdentifierName("Extensions")), + SyntaxFactory.IdentifierName("Hosting"))); + + newRoot = ((CompilationUnitSyntax)newRoot).AddUsings(hostingUsing); + } + } + + return document.WithSyntaxRoot(newRoot); + } + + private static bool IsWebHostBuilderType(TypeSyntax typeSyntax) + { + return typeSyntax.ToString().Contains("IWebHostBuilder"); + } + + private static async Task ConvertWebHostCreateDefaultBuilder(Document document, InvocationExpressionSyntax invocation, CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root == null) + { + return document; + } + + // Find the full expression chain that contains this invocation + var fullExpression = GetFullExpressionChain(invocation); + + // Transform the entire expression + var transformedExpression = TransformExpression(fullExpression); + + var newRoot = root.ReplaceNode(fullExpression, transformedExpression.WithLeadingTrivia(fullExpression.GetLeadingTrivia())); + + // Add the required using statement if not already present + if (root is CompilationUnitSyntax compilationUnit) + { + var hasHostingUsing = compilationUnit.Usings.Any(u => + u.Name?.ToString() == "Microsoft.Extensions.Hosting"); + + if (!hasHostingUsing) + { + var hostingUsing = SyntaxFactory.UsingDirective( + SyntaxFactory.QualifiedName( + SyntaxFactory.QualifiedName( + SyntaxFactory.IdentifierName("Microsoft"), + SyntaxFactory.IdentifierName("Extensions")), + SyntaxFactory.IdentifierName("Hosting"))); + + newRoot = ((CompilationUnitSyntax)newRoot).AddUsings(hostingUsing); + } + } + + return document.WithSyntaxRoot(newRoot); + } + + private static ExpressionSyntax GetFullExpressionChain(ExpressionSyntax expression) + { + // Walk up the tree to find the root of the method call chain + // The input expression is WebHost.CreateDefaultBuilder(), but we need to find the full chain + var current = expression; + + // Walk up the parent hierarchy to find the outermost invocation in the chain + while (current.Parent != null) + { + if (current.Parent is MemberAccessExpressionSyntax memberAccess && memberAccess.Expression == current) + { + // This current expression is the left side of a member access, so there's more to the chain + if (memberAccess.Parent is InvocationExpressionSyntax parentInvocation) + { + current = parentInvocation; + } + else + { + break; + } + } + else + { + // We've reached the end of the chain + break; + } + } + + return current; + } + + private static BlockSyntax TransformMethodBody(BlockSyntax body) + { + var transformedStatements = body.Statements.Select(TransformStatement).ToArray(); + return body.WithStatements(SyntaxFactory.List(transformedStatements)); + } + + private static ArrowExpressionClauseSyntax TransformExpressionBody(ArrowExpressionClauseSyntax expressionBody) + { + var transformedExpression = TransformExpression(expressionBody.Expression); + return expressionBody.WithExpression(transformedExpression); + } + + private static StatementSyntax TransformStatement(StatementSyntax statement) + { + if (statement is ReturnStatementSyntax returnStatement && returnStatement.Expression != null) + { + var transformedExpression = TransformExpression(returnStatement.Expression); + return returnStatement.WithExpression(transformedExpression); + } + return statement; + } + + private static ExpressionSyntax TransformExpression(ExpressionSyntax expression) + { + // Transform WebHost.CreateDefaultBuilder(args).ConfigureServices(...).UseStartup() + // to Host.CreateDefaultBuilder(args).ConfigureWebHostDefaults(webBuilder => webBuilder.ConfigureServices(...).UseStartup()) + + // Find the WebHost.CreateDefaultBuilder call and extract everything after it + var (webHostCreateCall, chainedCalls, remainingChain) = ExtractWebHostBuilderChain(expression); + + if (webHostCreateCall != null && chainedCalls.Count > 0) + { + // Create Host.CreateDefaultBuilder + var hostCreateCall = SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("Host"), + SyntaxFactory.IdentifierName("CreateDefaultBuilder"))) + .WithArgumentList(webHostCreateCall.ArgumentList); + + // Create the webBuilder expression chain + var webBuilderChain = CreateWebBuilderChain(chainedCalls); + + // Create the ConfigureWebHostDefaults lambda with proper formatting + var lambda = SyntaxFactory.SimpleLambdaExpression( + SyntaxFactory.Parameter(SyntaxFactory.Identifier("webBuilder")), + webBuilderChain) + .WithArrowToken(SyntaxFactory.Token(SyntaxKind.EqualsGreaterThanToken)); + + // Create Host.CreateDefaultBuilder().ConfigureWebHostDefaults(...) + var configureCall = SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + hostCreateCall.WithTrailingTrivia(), + SyntaxFactory.IdentifierName("ConfigureWebHostDefaults")) + .WithOperatorToken(SyntaxFactory.Token(SyntaxKind.DotToken) + .WithLeadingTrivia(SyntaxFactory.EndOfLine("\r\n")) + .WithTrailingTrivia())) + .WithArgumentList(SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.Argument(lambda)))); + + // If there's a remaining chain (like .Build()), append it + ExpressionSyntax result = configureCall; + if (remainingChain != null) + { + // Replace the placeholder with the actual configure call + result = remainingChain.ReplaceNode( + remainingChain.DescendantNodes().OfType() + .First(n => n.Identifier.ValueText == "HOST_PLACEHOLDER"), + configureCall); + } + + return result.WithTrailingTrivia(expression.GetTrailingTrivia()) + .WithLeadingTrivia(expression.GetLeadingTrivia()); + } + + // Handle standalone WebHost.CreateDefaultBuilder without chaining + if (expression is InvocationExpressionSyntax invocation && + invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Expression is IdentifierNameSyntax { Identifier.ValueText: "WebHost" } && + memberAccess.Name.Identifier.ValueText == "CreateDefaultBuilder") + { + return SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("Host"), + SyntaxFactory.IdentifierName("CreateDefaultBuilder"))) + .WithArgumentList(invocation.ArgumentList) + .WithTrailingTrivia(invocation.GetTrailingTrivia()) + .WithLeadingTrivia(invocation.GetLeadingTrivia()); + } + + return expression; + } + + private static readonly HashSet WebHostBuilderMethods = new HashSet + { + "UseStartup", + "ConfigureServices", + "ConfigureKestrel", + "Configure", + "UseUrls", + "UseContentRoot", + "UseEnvironment", + "UseWebRoot", + "ConfigureLogging", + "ConfigureAppConfiguration", + "UseIISIntegration", + "UseIIS", + "UseKestrel", + "UseSockets", + "UseQuic", + "UseHttpSys", + "UseDefaultServiceProvider" + }; + + private static (InvocationExpressionSyntax? webHostCreateCall, List<(SimpleNameSyntax methodName, ArgumentListSyntax arguments)> chainedCalls, ExpressionSyntax? remainingChain) ExtractWebHostBuilderChain(ExpressionSyntax expression) + { + var chainedCalls = new List<(SimpleNameSyntax methodName, ArgumentListSyntax arguments)>(); + InvocationExpressionSyntax? webHostCreateCall = null; + ExpressionSyntax? remainingChain = null; + + // Walk the expression chain from the top level down to find all method calls + var currentExpr = expression; + var methodCalls = new Stack<(SimpleNameSyntax methodName, ArgumentListSyntax arguments, InvocationExpressionSyntax invocation)>(); + + // Traverse the chain by following invocation expressions + while (currentExpr is InvocationExpressionSyntax invocation) + { + if (invocation.Expression is MemberAccessExpressionSyntax memberAccess) + { + var methodName = memberAccess.Name; // This preserves generic arguments + methodCalls.Push((methodName, invocation.ArgumentList, invocation)); + + // Move to the next expression in the chain + currentExpr = memberAccess.Expression; + } + else + { + // This could be the WebHost.CreateDefaultBuilder call itself + break; + } + } + + // Now process the stack to find WebHost.CreateDefaultBuilder and everything after it + bool foundWebHostCall = false; + var nonWebHostMethods = new List<(SimpleNameSyntax methodName, ArgumentListSyntax arguments, InvocationExpressionSyntax invocation)>(); + + while (methodCalls.Count > 0) + { + var (methodName, arguments, invocationExpr) = methodCalls.Pop(); + + if (!foundWebHostCall && methodName.Identifier.ValueText == "CreateDefaultBuilder") + { + // Check if this is WebHost.CreateDefaultBuilder + if (invocationExpr.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Expression is IdentifierNameSyntax identifier && + identifier.Identifier.ValueText == "WebHost") + { + webHostCreateCall = invocationExpr; + foundWebHostCall = true; + } + } + else if (foundWebHostCall) + { + // This is a method chained after WebHost.CreateDefaultBuilder + // Check if it's a WebHostBuilder method that should go inside ConfigureWebHostDefaults + if (WebHostBuilderMethods.Contains(methodName.Identifier.ValueText)) + { + chainedCalls.Add((methodName, arguments)); + } + else + { + // This method should remain outside the lambda (like Build(), Run(), etc.) + nonWebHostMethods.Add((methodName, arguments, invocationExpr)); + break; // Stop processing once we hit a non-WebHostBuilder method + } + } + } + + // Build the remaining chain from non-WebHostBuilder methods + if (nonWebHostMethods.Count > 0) + { + // Create a placeholder for the Host.CreateDefaultBuilder().ConfigureWebHostDefaults(...) call + // This will be replaced later, but we need something to chain the remaining methods to + var placeholder = SyntaxFactory.IdentifierName("HOST_PLACEHOLDER"); + + // Chain the remaining methods + ExpressionSyntax current = placeholder; + foreach (var (methodName, arguments, _) in nonWebHostMethods) + { + var memberAccess = SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + current, + methodName); + current = SyntaxFactory.InvocationExpression(memberAccess, arguments); + } + + remainingChain = current; + } + + return (webHostCreateCall, chainedCalls, remainingChain); + } + + private static ExpressionSyntax CreateWebBuilderChain(List<(SimpleNameSyntax methodName, ArgumentListSyntax arguments)> chainedCalls) + { + if (chainedCalls.Count == 0) + { + return SyntaxFactory.IdentifierName("webBuilder"); + } + + // Start with webBuilder + ExpressionSyntax current = SyntaxFactory.IdentifierName("webBuilder"); + + // Chain all the method calls with proper formatting + for (int i = 0; i < chainedCalls.Count; i++) + { + var (methodName, arguments) = chainedCalls[i]; + + var memberAccess = SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + current, + methodName); // Use the SimpleNameSyntax directly to preserve generics + + current = SyntaxFactory.InvocationExpression(memberAccess, arguments); + } + + return current; + } +} diff --git a/src/Framework/AspNetCoreAnalyzers/test/WebApplicationBuilder/UseCreateHostBuilderInsteadOfCreateWebHostBuilderTest.cs b/src/Framework/AspNetCoreAnalyzers/test/WebApplicationBuilder/UseCreateHostBuilderInsteadOfCreateWebHostBuilderTest.cs new file mode 100644 index 000000000000..a2f2518e1716 --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/test/WebApplicationBuilder/UseCreateHostBuilderInsteadOfCreateWebHostBuilderTest.cs @@ -0,0 +1,400 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.CodeAnalysis.Testing; +using VerifyAnalyzer = Microsoft.AspNetCore.Analyzers.Verifiers.CSharpAnalyzerVerifier< + Microsoft.AspNetCore.Analyzers.WebApplicationBuilder.UseCreateHostBuilderInsteadOfCreateWebHostBuilderAnalyzer>; +using VerifyCS = Microsoft.AspNetCore.Analyzers.Verifiers.CSharpCodeFixVerifier< + Microsoft.AspNetCore.Analyzers.WebApplicationBuilder.UseCreateHostBuilderInsteadOfCreateWebHostBuilderAnalyzer, + Microsoft.AspNetCore.Analyzers.WebApplicationBuilder.Fixers.UseCreateHostBuilderInsteadOfCreateWebHostBuilderFixer>; + +namespace Microsoft.AspNetCore.Analyzers.WebApplicationBuilder; + +public class UseCreateHostBuilderInsteadOfCreateWebHostBuilderTest +{ + [Fact] + public async Task DoesNotWarnWhenUsingHostCreateDefaultBuilder() + { + // Arrange + var source = @" +using Microsoft.Extensions.Hosting; +using Microsoft.AspNetCore.Builder; +public static class Program +{ + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => { }); +} +"; + // Assert + await VerifyCS.VerifyCodeFixAsync(source, source); + } + + [Fact] + public async Task WarnsWhenUsingWebHostCreateDefaultBuilder() + { + // Arrange + var source = @" +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore; +public static class Program +{ + public static void Main(string[] args) + { + CreateWebHostBuilder(args).Build().Run(); + } + + public static {|#0:IWebHostBuilder|} CreateWebHostBuilder(string[] args) => + {|#1:WebHost.CreateDefaultBuilder(args)|} + .UseStartup(); +} +public class Startup { } +"; + + var fixedSource = @" +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore; +using Microsoft.Extensions.Hosting; + +public static class Program +{ + public static void Main(string[] args) + { + CreateWebHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateWebHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) +.ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup()); +} +public class Startup { } +"; + + var diagnostic = new DiagnosticResult(DiagnosticDescriptors.UseCreateHostBuilderInsteadOfCreateWebHostBuilder) + .WithMessage(Resources.Analyzer_UseCreateHostBuilderInsteadOfCreateWebHostBuilder_Message); + + var expectedDiagnostics = new[] + { + diagnostic.WithLocation(0), + diagnostic.WithLocation(1), + }; + + // Assert + await VerifyCS.VerifyCodeFixAsync(source, expectedDiagnostics, fixedSource); + } + + [Fact] + public async Task WarnsWhenUsingCreateWebHostBuilderWithBlockBody() + { + // Arrange + var source = @" +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore; +public static class Program +{ + public static void Main(string[] args) + { + CreateWebHostBuilder(args).Build().Run(); + } + + public static {|#0:IWebHostBuilder|} CreateWebHostBuilder(string[] args) + { + return {|#1:WebHost.CreateDefaultBuilder(args)|}.UseStartup(); + } +} +public class Startup { } +"; + + var fixedSource = @" +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore; +using Microsoft.Extensions.Hosting; + +public static class Program +{ + public static void Main(string[] args) + { + CreateWebHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateWebHostBuilder(string[] args) + { + return Host.CreateDefaultBuilder(args) +.ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup()); + } +} +public class Startup { } +"; + + var diagnostic = new DiagnosticResult(DiagnosticDescriptors.UseCreateHostBuilderInsteadOfCreateWebHostBuilder) + .WithMessage(Resources.Analyzer_UseCreateHostBuilderInsteadOfCreateWebHostBuilder_Message); + + var expectedDiagnostics = new[] + { + diagnostic.WithLocation(0), + diagnostic.WithLocation(1), + }; + + // Assert + await VerifyCS.VerifyCodeFixAsync(source, expectedDiagnostics, fixedSource); + } + + [Fact] + public async Task WarnsOnlyForWebHostCreateDefaultBuilder() + { + // Arrange + var source = @" +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore; +public static class Program +{ + public static void Main(string[] args) + { + CreateWebHostBuilder(args).Build().Run(); + } + + public static {|#0:IWebHostBuilder|} CreateWebHostBuilder(string[] args) => + {|#1:WebHost.CreateDefaultBuilder(args)|}.UseStartup(); + + public static void StartWeb() => + WebHost.Start(""http://localhost:5000"", (c) => { }); +} +public class Startup { } +"; + + var expectedDiagnostics = new[] + { + VerifyAnalyzer.Diagnostic().WithLocation(0), + VerifyAnalyzer.Diagnostic().WithLocation(1) + }; + + await VerifyAnalyzer.VerifyAnalyzerAsync(source, expectedDiagnostics); + } + + [Fact] + public async Task WarnsForMultipleMethods() + { + // Arrange + var source = @" +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore; +public static class Program +{ + public static void Main(string[] args) + { + CreateWebHostBuilder(args).Build().Run(); + } + + public static {|#0:IWebHostBuilder|} CreateWebHostBuilder(string[] args) => + {|#1:WebHost.CreateDefaultBuilder(args)|}.UseStartup(); + + public static {|#2:IWebHostBuilder|} CreateWebHostBuilderForTesting(string[] args) => + {|#3:WebHost.CreateDefaultBuilder(args)|}.UseStartup(); +} +public class Startup { } +"; + + var expectedDiagnostics = new[] + { + VerifyAnalyzer.Diagnostic().WithLocation(0), + VerifyAnalyzer.Diagnostic().WithLocation(1), + VerifyAnalyzer.Diagnostic().WithLocation(2), + VerifyAnalyzer.Diagnostic().WithLocation(3) + }; + + await VerifyAnalyzer.VerifyAnalyzerAsync(source, expectedDiagnostics); + } + + [Fact] + public async Task WarnsForInstanceMethod() + { + // Arrange + var source = @" +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore; +public class Program +{ + public static void Main(string[] args) + { + } + + public {|#0:IWebHostBuilder|} CreateWebHostBuilder(string[] args) => + {|#1:WebHost.CreateDefaultBuilder(args)|}.UseStartup(); +} +public class Startup { } +"; + + var expectedDiagnostics = new[] + { + VerifyAnalyzer.Diagnostic().WithLocation(0), + VerifyAnalyzer.Diagnostic().WithLocation(1) + }; + + // No diagnostics expected + await VerifyAnalyzer.VerifyAnalyzerAsync(source, expectedDiagnostics); + } + + [Fact] + public async Task WarnsForPrivateMethods() + { + // Arrange + var source = @" +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore; +public static class Program +{ + public static void Main(string[] args) + { + CreateWebHostBuilder(args).Build().Run(); + } + + private static {|#0:IWebHostBuilder|} CreateWebHostBuilder(string[] args) => + {|#1:WebHost.CreateDefaultBuilder(args)|}.UseStartup(); +} +public class Startup { } +"; + + var expectedDiagnostics = new[] + { + VerifyAnalyzer.Diagnostic().WithLocation(0), + VerifyAnalyzer.Diagnostic().WithLocation(1) + }; + + // No diagnostics expected + await VerifyAnalyzer.VerifyAnalyzerAsync(source, expectedDiagnostics); + } + + [Fact] + public async Task CodeFixWorksInsideUsingStatement() + { + // Arrange + var source = @" +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore; +public static class Program +{ + public static void Main(string[] args) + { + using (var host = {|#0:WebHost.CreateDefaultBuilder(args)|} + .UseStartup() + .Build()) + { + host.Run(); + } + } +} +public class Startup { } +"; + + var fixedSource = @" +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore; +using Microsoft.Extensions.Hosting; + +public static class Program +{ + public static void Main(string[] args) + { + using (var host = Host.CreateDefaultBuilder(args) +.ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup() +).Build()) + { + host.Run(); + } + } +} +public class Startup { } +"; + + var diagnostic = new DiagnosticResult(DiagnosticDescriptors.UseCreateHostBuilderInsteadOfCreateWebHostBuilder) + .WithMessage(Resources.Analyzer_UseCreateHostBuilderInsteadOfCreateWebHostBuilder_Message); + + var expectedDiagnostics = new[] + { + diagnostic.WithLocation(0), + }; + + // Assert + await VerifyCS.VerifyCodeFixAsync(source, expectedDiagnostics, fixedSource); + } + + [Fact] + public async Task CodeFixWorksWithManyChainedCalls() + { + // Arrange + var source = @" +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore; +public static class Program +{ + public static void Main(string[] args) + { + {|#0:WebHost.CreateDefaultBuilder(new[] { ""--cliKey"", ""cliValue"" })|} + .ConfigureServices((context, service) => { }) + .ConfigureKestrel(options => + options.Configure(options.ConfigurationLoader.Configuration)) + .Configure(app => + { + }); + } +} +"; + + var fixedSource = @" +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore; +using Microsoft.Extensions.Hosting; + +public static class Program +{ + public static void Main(string[] args) + { + Host.CreateDefaultBuilder(new[] { ""--cliKey"", ""cliValue"" }) +.ConfigureWebHostDefaults(webBuilder => webBuilder.ConfigureServices((context, service) => { }) +.ConfigureKestrel(options => + options.Configure(options.ConfigurationLoader.Configuration)) +.Configure(app => + { + })); + } +} +"; + + var diagnostic = new DiagnosticResult(DiagnosticDescriptors.UseCreateHostBuilderInsteadOfCreateWebHostBuilder) + .WithMessage(Resources.Analyzer_UseCreateHostBuilderInsteadOfCreateWebHostBuilder_Message); + + var expectedDiagnostics = new[] + { + diagnostic.WithLocation(0), + }; + + // Assert + await VerifyCS.VerifyCodeFixAsync(source, expectedDiagnostics, fixedSource); + } + + [Fact] + public async Task DoesNotWarnForIWebHostBuilderMethodWithoutWebHostUsage() + { + // Arrange + var source = @" +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Builder; +public static class Program +{ + public static void Main(string[] args) + { + CreateWebHostBuilder(args).Build().Run(); + } + + public static IWebHostBuilder CreateWebHostBuilder(string[] args) => null!; +} +"; + // Assert + await VerifyCS.VerifyCodeFixAsync(source, source); + } +} From 903a3843e623654a4494c30b513fe63c43991fb7 Mon Sep 17 00:00:00 2001 From: Brennan Date: Thu, 14 Aug 2025 12:40:40 -0700 Subject: [PATCH 2/4] fb --- docs/list-of-diagnostics.md | 1 + .../src/Analyzers/DiagnosticDescriptors.cs | 2 +- ...derInsteadOfCreateWebHostBuilderAnalyzer.cs | 18 +++++++++--------- ...uilderInsteadOfCreateWebHostBuilderFixer.cs | 16 +++++++++------- 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index e6195634513e..34dacca5a243 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -35,6 +35,7 @@ | __`ASP0027`__ | Unnecessary public Program class declaration | | __`ASP0028`__ | Consider using ListenAnyIP() instead of Listen(IPAddress.Any) | | __`ASP0029`__ | Experimental warning for validations resolver APIs | +| __`ASP0030`__ | Use Host.CreateDefaultBuilder instead of WebHost.CreateDefaultBuilder | ### API (`API1000-API1003`) diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DiagnosticDescriptors.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DiagnosticDescriptors.cs index 6269b8a9dfba..c2cd9b184e79 100644 --- a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DiagnosticDescriptors.cs +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DiagnosticDescriptors.cs @@ -250,7 +250,7 @@ internal static class DiagnosticDescriptors helpLinkUri: AnalyzersLink); internal static readonly DiagnosticDescriptor UseCreateHostBuilderInsteadOfCreateWebHostBuilder = new( - "ASP0029", + "ASP0030", CreateLocalizableResourceString(nameof(Resources.Analyzer_UseCreateHostBuilderInsteadOfCreateWebHostBuilder_Title)), CreateLocalizableResourceString(nameof(Resources.Analyzer_UseCreateHostBuilderInsteadOfCreateWebHostBuilder_Message)), Usage, diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/WebApplicationBuilder/UseCreateHostBuilderInsteadOfCreateWebHostBuilderAnalyzer.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/WebApplicationBuilder/UseCreateHostBuilderInsteadOfCreateWebHostBuilderAnalyzer.cs index a8a04c07369e..9e0fea9013c2 100644 --- a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/WebApplicationBuilder/UseCreateHostBuilderInsteadOfCreateWebHostBuilderAnalyzer.cs +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/WebApplicationBuilder/UseCreateHostBuilderInsteadOfCreateWebHostBuilderAnalyzer.cs @@ -10,6 +10,8 @@ using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Operations; +using WellKnownType = Microsoft.AspNetCore.App.Analyzers.Infrastructure.WellKnownTypeData.WellKnownType; + namespace Microsoft.AspNetCore.Analyzers.WebApplicationBuilder; [DiagnosticAnalyzer(LanguageNames.CSharp)] @@ -34,7 +36,7 @@ public override void Initialize(AnalysisContext context) var targetMethod = invocation.TargetMethod; // Check if this is WebHost.CreateDefaultBuilder - if (IsWebHostCreateDefaultBuilderCall(targetMethod)) + if (IsWebHostCreateDefaultBuilderCall(targetMethod, wellKnownTypes)) { var diagnostic = Diagnostic.Create( DiagnosticDescriptors.UseCreateHostBuilderInsteadOfCreateWebHostBuilder, @@ -51,7 +53,7 @@ public override void Initialize(AnalysisContext context) var symbol = semantic.GetDeclaredSymbol(methodDeclaration); // Check if this method returns IWebHostBuilder - if (symbol != null && IsWebHostBuilderReturnType(symbol)) + if (symbol != null && IsWebHostBuilderReturnType(symbol, wellKnownTypes)) { // Check if the method body contains WebHost.CreateDefaultBuilder if (ContainsWebHostCreateDefaultBuilder(methodDeclaration)) @@ -67,12 +69,11 @@ public override void Initialize(AnalysisContext context) }); } - private static bool IsWebHostCreateDefaultBuilderCall(IMethodSymbol method) + private static bool IsWebHostCreateDefaultBuilderCall(IMethodSymbol method, WellKnownTypes wellKnownTypes) { // Check if this is WebHost.CreateDefaultBuilder (not other WebHost methods) - if (method.Name == "CreateDefaultBuilder" && - method.ContainingType?.Name == "WebHost" && - method.ContainingType?.ContainingNamespace?.ToDisplayString() == "Microsoft.AspNetCore") + if (method.Name == "CreateDefaultBuilder" && + SymbolEqualityComparer.Default.Equals(method.ContainingType, wellKnownTypes.Get(WellKnownType.Microsoft_AspNetCore_WebHost))) { return true; } @@ -80,12 +81,11 @@ private static bool IsWebHostCreateDefaultBuilderCall(IMethodSymbol method) return false; } - private static bool IsWebHostBuilderReturnType(IMethodSymbol method) + private static bool IsWebHostBuilderReturnType(IMethodSymbol method, WellKnownTypes wellKnownTypes) { // Check if the return type is IWebHostBuilder var returnType = method.ReturnType; - return returnType.Name == "IWebHostBuilder" && - returnType.ContainingNamespace?.ToDisplayString() == "Microsoft.AspNetCore.Hosting"; + return SymbolEqualityComparer.Default.Equals(returnType, wellKnownTypes.Get(WellKnownType.Microsoft_AspNetCore_Hosting_IWebHostBuilder)); } private static bool ContainsWebHostCreateDefaultBuilder(MethodDeclarationSyntax methodDeclaration) diff --git a/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/UseCreateHostBuilderInsteadOfCreateWebHostBuilderFixer.cs b/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/UseCreateHostBuilderInsteadOfCreateWebHostBuilderFixer.cs index 666c5ad8029b..b08d55cd4727 100644 --- a/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/UseCreateHostBuilderInsteadOfCreateWebHostBuilderFixer.cs +++ b/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/UseCreateHostBuilderInsteadOfCreateWebHostBuilderFixer.cs @@ -129,7 +129,7 @@ private static async Task ConvertWebHostBuilderMethod(Document documen private static bool IsWebHostBuilderType(TypeSyntax typeSyntax) { - return typeSyntax.ToString().Contains("IWebHostBuilder"); + return typeSyntax.ToString().Equals("IWebHostBuilder"); } private static async Task ConvertWebHostCreateDefaultBuilder(Document document, InvocationExpressionSyntax invocation, CancellationToken cancellationToken) @@ -257,7 +257,7 @@ private static ExpressionSyntax TransformExpression(ExpressionSyntax expression) hostCreateCall.WithTrailingTrivia(), SyntaxFactory.IdentifierName("ConfigureWebHostDefaults")) .WithOperatorToken(SyntaxFactory.Token(SyntaxKind.DotToken) - .WithLeadingTrivia(SyntaxFactory.EndOfLine("\r\n")) + .WithLeadingTrivia(SyntaxFactory.ElasticCarriageReturnLineFeed) .WithTrailingTrivia())) .WithArgumentList(SyntaxFactory.ArgumentList( SyntaxFactory.SingletonSeparatedList( @@ -267,11 +267,13 @@ private static ExpressionSyntax TransformExpression(ExpressionSyntax expression) ExpressionSyntax result = configureCall; if (remainingChain != null) { - // Replace the placeholder with the actual configure call - result = remainingChain.ReplaceNode( - remainingChain.DescendantNodes().OfType() - .First(n => n.Identifier.ValueText == "HOST_PLACEHOLDER"), - configureCall); + var placeHolder = remainingChain.DescendantNodes().OfType() + .FirstOrDefault(n => n.Identifier.ValueText == "HOST_PLACEHOLDER"); + if (placeHolder != null) + { + // Replace the placeholder with the actual configure call + result = remainingChain.ReplaceNode(placeHolder, configureCall); + } } return result.WithTrailingTrivia(expression.GetTrailingTrivia()) From 01c8e5d09cc1f16ca1fde3b8e82ac99f1dff387b Mon Sep 17 00:00:00 2001 From: Brennan Date: Thu, 14 Aug 2025 12:50:42 -0700 Subject: [PATCH 3/4] missed file --- src/Shared/RoslynUtils/WellKnownTypeData.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Shared/RoslynUtils/WellKnownTypeData.cs b/src/Shared/RoslynUtils/WellKnownTypeData.cs index 1afb045fc713..c2fa022bc37f 100644 --- a/src/Shared/RoslynUtils/WellKnownTypeData.cs +++ b/src/Shared/RoslynUtils/WellKnownTypeData.cs @@ -124,6 +124,8 @@ public enum WellKnownType System_ComponentModel_DataAnnotations_RequiredAttribute, System_ComponentModel_DataAnnotations_CustomValidationAttribute, System_Type, + Microsoft_AspNetCore_Hosting_IWebHostBuilder, + Microsoft_AspNetCore_WebHost, } public static string[] WellKnownTypeNames = @@ -245,5 +247,7 @@ public enum WellKnownType "System.ComponentModel.DataAnnotations.RequiredAttribute", "System.ComponentModel.DataAnnotations.CustomValidationAttribute", "System.Type", + "Microsoft.AspNetCore.Hosting.IWebHostBuilder", + "Microsoft.AspNetCore.WebHost", ]; } From 44d5e8c07e2a5589fffdad3e3522f0dc88f910f8 Mon Sep 17 00:00:00 2001 From: Brennan Date: Wed, 20 Aug 2025 13:26:34 -0700 Subject: [PATCH 4/4] better formatting --- ...ilderInsteadOfCreateWebHostBuilderFixer.cs | 94 +++++++++++++------ ...uilderInsteadOfCreateWebHostBuilderTest.cs | 64 +++++++++++-- 2 files changed, 122 insertions(+), 36 deletions(-) diff --git a/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/UseCreateHostBuilderInsteadOfCreateWebHostBuilderFixer.cs b/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/UseCreateHostBuilderInsteadOfCreateWebHostBuilderFixer.cs index b08d55cd4727..96e6c40d7b6d 100644 --- a/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/UseCreateHostBuilderInsteadOfCreateWebHostBuilderFixer.cs +++ b/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/UseCreateHostBuilderInsteadOfCreateWebHostBuilderFixer.cs @@ -142,11 +142,24 @@ private static async Task ConvertWebHostCreateDefaultBuilder(Document // Find the full expression chain that contains this invocation var fullExpression = GetFullExpressionChain(invocation); - + + SyntaxTriviaList leadingTrivia = fullExpression.GetLeadingTrivia(); + if (!fullExpression.HasLeadingTrivia) + { + // Try to find some leading trivia from a parent + // e.g. using (var host = WebHost.CreateDefaultBuilder(args)) would not have leading trivia on the WebHost call + var parent = fullExpression.Parent; + while (parent != null && !parent.HasLeadingTrivia) + { + parent = parent.Parent; + } + leadingTrivia = parent?.GetLeadingTrivia() ?? SyntaxFactory.TriviaList(); + } + // Transform the entire expression - var transformedExpression = TransformExpression(fullExpression); + var transformedExpression = TransformExpression(fullExpression, leadingTrivia); - var newRoot = root.ReplaceNode(fullExpression, transformedExpression.WithLeadingTrivia(fullExpression.GetLeadingTrivia())); + var newRoot = root.ReplaceNode(fullExpression, transformedExpression); // Add the required using statement if not already present if (root is CompilationUnitSyntax compilationUnit) @@ -209,7 +222,7 @@ private static BlockSyntax TransformMethodBody(BlockSyntax body) private static ArrowExpressionClauseSyntax TransformExpressionBody(ArrowExpressionClauseSyntax expressionBody) { - var transformedExpression = TransformExpression(expressionBody.Expression); + var transformedExpression = TransformExpression(expressionBody.Expression, expressionBody.Expression.GetLeadingTrivia()); return expressionBody.WithExpression(transformedExpression); } @@ -217,13 +230,14 @@ private static StatementSyntax TransformStatement(StatementSyntax statement) { if (statement is ReturnStatementSyntax returnStatement && returnStatement.Expression != null) { - var transformedExpression = TransformExpression(returnStatement.Expression); + var transformedExpression = TransformExpression(returnStatement.Expression, + new SyntaxTriviaList(SyntaxFactory.ElasticCarriageReturnLineFeed).AddRange(returnStatement.GetLeadingTrivia()).Add(SyntaxFactory.ElasticTab)); return returnStatement.WithExpression(transformedExpression); } return statement; } - private static ExpressionSyntax TransformExpression(ExpressionSyntax expression) + private static ExpressionSyntax TransformExpression(ExpressionSyntax expression, SyntaxTriviaList leadingTrivia) { // Transform WebHost.CreateDefaultBuilder(args).ConfigureServices(...).UseStartup() // to Host.CreateDefaultBuilder(args).ConfigureWebHostDefaults(webBuilder => webBuilder.ConfigureServices(...).UseStartup()) @@ -242,26 +256,27 @@ private static ExpressionSyntax TransformExpression(ExpressionSyntax expression) .WithArgumentList(webHostCreateCall.ArgumentList); // Create the webBuilder expression chain - var webBuilderChain = CreateWebBuilderChain(chainedCalls); + var webBuilderChain = CreateWebBuilderChain(chainedCalls, leadingTrivia); // Create the ConfigureWebHostDefaults lambda with proper formatting var lambda = SyntaxFactory.SimpleLambdaExpression( SyntaxFactory.Parameter(SyntaxFactory.Identifier("webBuilder")), - webBuilderChain) - .WithArrowToken(SyntaxFactory.Token(SyntaxKind.EqualsGreaterThanToken)); + webBuilderChain); // Create Host.CreateDefaultBuilder().ConfigureWebHostDefaults(...) var configureCall = SyntaxFactory.InvocationExpression( SyntaxFactory.MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, - hostCreateCall.WithTrailingTrivia(), + hostCreateCall.WithLeadingTrivia(leadingTrivia), SyntaxFactory.IdentifierName("ConfigureWebHostDefaults")) .WithOperatorToken(SyntaxFactory.Token(SyntaxKind.DotToken) - .WithLeadingTrivia(SyntaxFactory.ElasticCarriageReturnLineFeed) - .WithTrailingTrivia())) + .WithLeadingTrivia(leadingTrivia) + )) .WithArgumentList(SyntaxFactory.ArgumentList( SyntaxFactory.SingletonSeparatedList( - SyntaxFactory.Argument(lambda)))); + SyntaxFactory.Argument(lambda)))) + // Adds new line and indentation for remaining chain calls e.g. Build(), Run(), etc. + .WithTrailingTrivia(new SyntaxTriviaList(SyntaxFactory.ElasticCarriageReturnLineFeed).AddRange(leadingTrivia)); // If there's a remaining chain (like .Build()), append it ExpressionSyntax result = configureCall; @@ -276,8 +291,7 @@ private static ExpressionSyntax TransformExpression(ExpressionSyntax expression) } } - return result.WithTrailingTrivia(expression.GetTrailingTrivia()) - .WithLeadingTrivia(expression.GetLeadingTrivia()); + return result; } // Handle standalone WebHost.CreateDefaultBuilder without chaining @@ -320,9 +334,9 @@ invocation.Expression is MemberAccessExpressionSyntax memberAccess && "UseDefaultServiceProvider" }; - private static (InvocationExpressionSyntax? webHostCreateCall, List<(SimpleNameSyntax methodName, ArgumentListSyntax arguments)> chainedCalls, ExpressionSyntax? remainingChain) ExtractWebHostBuilderChain(ExpressionSyntax expression) + private static (InvocationExpressionSyntax? webHostCreateCall, List<(SimpleNameSyntax methodName, ArgumentListSyntax arguments, SyntaxTriviaList leadingTrivia)> chainedCalls, ExpressionSyntax? remainingChain) ExtractWebHostBuilderChain(ExpressionSyntax expression) { - var chainedCalls = new List<(SimpleNameSyntax methodName, ArgumentListSyntax arguments)>(); + var chainedCalls = new List<(SimpleNameSyntax methodName, ArgumentListSyntax arguments, SyntaxTriviaList leadingTrivia)>(); InvocationExpressionSyntax? webHostCreateCall = null; ExpressionSyntax? remainingChain = null; @@ -373,42 +387,55 @@ memberAccess.Expression is IdentifierNameSyntax identifier && // Check if it's a WebHostBuilder method that should go inside ConfigureWebHostDefaults if (WebHostBuilderMethods.Contains(methodName.Identifier.ValueText)) { - chainedCalls.Add((methodName, arguments)); + chainedCalls.Add((methodName, arguments, invocationExpr.GetLeadingTrivia())); } else { // This method should remain outside the lambda (like Build(), Run(), etc.) nonWebHostMethods.Add((methodName, arguments, invocationExpr)); - break; // Stop processing once we hit a non-WebHostBuilder method + // Add any remaining methods to the non-WebHostBuilder list + nonWebHostMethods.AddRange(methodCalls); + methodCalls.Clear(); + // Stop processing once we hit a non-WebHostBuilder method + break; } } } - + // Build the remaining chain from non-WebHostBuilder methods if (nonWebHostMethods.Count > 0) { // Create a placeholder for the Host.CreateDefaultBuilder().ConfigureWebHostDefaults(...) call // This will be replaced later, but we need something to chain the remaining methods to var placeholder = SyntaxFactory.IdentifierName("HOST_PLACEHOLDER"); - + // Chain the remaining methods ExpressionSyntax current = placeholder; - foreach (var (methodName, arguments, _) in nonWebHostMethods) + foreach (var (methodName, arguments, invocation) in nonWebHostMethods) { + SyntaxTriviaList leadingTrivia = default; + if (invocation.Expression is MemberAccessExpressionSyntax memberAccessExpr) + { + leadingTrivia = memberAccessExpr.Expression.GetLeadingTrivia(); + } + var memberAccess = SyntaxFactory.MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, - current, + // Since we're appending method calls, + // we need to add trailing trivia after each one to affect the new method calls formatting + current.WithTrailingTrivia(current.GetTrailingTrivia().AddRange(leadingTrivia)), methodName); + current = SyntaxFactory.InvocationExpression(memberAccess, arguments); } - + remainingChain = current; } - + return (webHostCreateCall, chainedCalls, remainingChain); } - private static ExpressionSyntax CreateWebBuilderChain(List<(SimpleNameSyntax methodName, ArgumentListSyntax arguments)> chainedCalls) + private static ExpressionSyntax CreateWebBuilderChain(List<(SimpleNameSyntax methodName, ArgumentListSyntax arguments, SyntaxTriviaList leadingTrivia)> chainedCalls, SyntaxTriviaList leadingTrivia2) { if (chainedCalls.Count == 0) { @@ -421,16 +448,25 @@ private static ExpressionSyntax CreateWebBuilderChain(List<(SimpleNameSyntax met // Chain all the method calls with proper formatting for (int i = 0; i < chainedCalls.Count; i++) { - var (methodName, arguments) = chainedCalls[i]; - + var (methodName, arguments, leadingTrivia) = chainedCalls[i]; + var memberAccess = SyntaxFactory.MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, current, methodName); // Use the SimpleNameSyntax directly to preserve generics current = SyntaxFactory.InvocationExpression(memberAccess, arguments); + current = current.WithTrailingTrivia(); + + if (i < chainedCalls.Count - 1) + { + // Add a line break and indentation for all but the last method call + var triviaList = new SyntaxTriviaList(SyntaxFactory.ElasticCarriageReturnLineFeed).AddRange(leadingTrivia); + triviaList = triviaList.Add(SyntaxFactory.ElasticTab); + current = current.WithTrailingTrivia(triviaList); + } } - + return current; } } diff --git a/src/Framework/AspNetCoreAnalyzers/test/WebApplicationBuilder/UseCreateHostBuilderInsteadOfCreateWebHostBuilderTest.cs b/src/Framework/AspNetCoreAnalyzers/test/WebApplicationBuilder/UseCreateHostBuilderInsteadOfCreateWebHostBuilderTest.cs index a2f2518e1716..49166404d1cf 100644 --- a/src/Framework/AspNetCoreAnalyzers/test/WebApplicationBuilder/UseCreateHostBuilderInsteadOfCreateWebHostBuilderTest.cs +++ b/src/Framework/AspNetCoreAnalyzers/test/WebApplicationBuilder/UseCreateHostBuilderInsteadOfCreateWebHostBuilderTest.cs @@ -70,7 +70,7 @@ public static void Main(string[] args) public static IHostBuilder CreateWebHostBuilder(string[] args) => Host.CreateDefaultBuilder(args) -.ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup()); + .ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup()); } public class Startup { } "; @@ -125,7 +125,7 @@ public static void Main(string[] args) public static IHostBuilder CreateWebHostBuilder(string[] args) { return Host.CreateDefaultBuilder(args) -.ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup()); + .ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup()); } } public class Startup { } @@ -301,8 +301,8 @@ public static class Program public static void Main(string[] args) { using (var host = Host.CreateDefaultBuilder(args) -.ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup() -).Build()) + .ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup()) + .Build()) { host.Run(); } @@ -323,6 +323,56 @@ public class Startup { } await VerifyCS.VerifyCodeFixAsync(source, expectedDiagnostics, fixedSource); } + [Fact] + public async Task FixerWorksWithMultipleNonWebHostChainedMethods() + { + // Arrange + var source = @" +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore; +public static class Program +{ + public static void Main(string[] args) + { + {|#0:WebHost.CreateDefaultBuilder(args)|} + .UseStartup() + .Build() + .RunAsync(default); + } +} +public class Startup { } +"; + + var fixedSource = @" +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore; +using Microsoft.Extensions.Hosting; + +public static class Program +{ + public static void Main(string[] args) + { + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup()) + .Build() + .RunAsync(default); + } +} +public class Startup { } +"; + + var diagnostic = new DiagnosticResult(DiagnosticDescriptors.UseCreateHostBuilderInsteadOfCreateWebHostBuilder) + .WithMessage(Resources.Analyzer_UseCreateHostBuilderInsteadOfCreateWebHostBuilder_Message); + + var expectedDiagnostics = new[] + { + diagnostic.WithLocation(0), + }; + + // Assert + await VerifyCS.VerifyCodeFixAsync(source, expectedDiagnostics, fixedSource); + } + [Fact] public async Task CodeFixWorksWithManyChainedCalls() { @@ -355,10 +405,10 @@ public static class Program public static void Main(string[] args) { Host.CreateDefaultBuilder(new[] { ""--cliKey"", ""cliValue"" }) -.ConfigureWebHostDefaults(webBuilder => webBuilder.ConfigureServices((context, service) => { }) -.ConfigureKestrel(options => + .ConfigureWebHostDefaults(webBuilder => webBuilder.ConfigureServices((context, service) => { }) + .ConfigureKestrel(options => options.Configure(options.ConfigurationLoader.Configuration)) -.Configure(app => + .Configure(app => { })); }