From 7642f9d12a63c077c32d7908cccdec098d7162d5 Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Thu, 9 May 2019 12:33:09 -0700 Subject: [PATCH] Fixes: #9281 - implement SignalR detection (#10065) This change adds detection of various SignalR configure-related calls to the startup analysis infrastructure. Also adds a shim that VS is going to call into to analyze the project pre-publish. --- .../src/CompilationFeatureDetector.cs | 58 ++++++++++++++ .../Analyzers/src/ConfigureMethodVisitor.cs | 61 ++++++++++++++ src/Analyzers/Analyzers/src/StartupFacts.cs | 25 ++++++ src/Analyzers/Analyzers/src/SymbolNames.cs | 23 +++++- .../Analyzers/src/WellKnownFeatures.cs | 10 +++ .../test/CompilationFeatureDetectorTest.cs | 51 ++++++++++++ .../test/ConfigureMethodVisitorTest.cs | 41 ++++++++++ .../StartupWithMapBlazorHub.cs | 20 +++++ .../StartupWithMapHub.cs | 25 ++++++ .../StartupWithNoFeatures.cs | 20 +++++ .../StartupWithUseSignalR.cs | 18 +++++ .../ConfigureMethodVisitorTest/Startup.cs | 55 +++++++++++++ ....Analyzers.FeatureDetection.Sources.csproj | 15 ++++ .../ProjectCompilationFeatureDetector.cs | 80 +++++++++++++++++++ 14 files changed, 500 insertions(+), 2 deletions(-) create mode 100644 src/Analyzers/Analyzers/src/CompilationFeatureDetector.cs create mode 100644 src/Analyzers/Analyzers/src/ConfigureMethodVisitor.cs create mode 100644 src/Analyzers/Analyzers/src/WellKnownFeatures.cs create mode 100644 src/Analyzers/Analyzers/test/CompilationFeatureDetectorTest.cs create mode 100644 src/Analyzers/Analyzers/test/ConfigureMethodVisitorTest.cs create mode 100644 src/Analyzers/Analyzers/test/TestFiles/CompilationFeatureDetectorTest/StartupWithMapBlazorHub.cs create mode 100644 src/Analyzers/Analyzers/test/TestFiles/CompilationFeatureDetectorTest/StartupWithMapHub.cs create mode 100644 src/Analyzers/Analyzers/test/TestFiles/CompilationFeatureDetectorTest/StartupWithNoFeatures.cs create mode 100644 src/Analyzers/Analyzers/test/TestFiles/CompilationFeatureDetectorTest/StartupWithUseSignalR.cs create mode 100644 src/Analyzers/Analyzers/test/TestFiles/ConfigureMethodVisitorTest/Startup.cs create mode 100644 src/Analyzers/shared/FeatureDetection/ProjectCompilationFeatureDetector.cs diff --git a/src/Analyzers/Analyzers/src/CompilationFeatureDetector.cs b/src/Analyzers/Analyzers/src/CompilationFeatureDetector.cs new file mode 100644 index 0000000000..1557d63a5a --- /dev/null +++ b/src/Analyzers/Analyzers/src/CompilationFeatureDetector.cs @@ -0,0 +1,58 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Operations; + +namespace Microsoft.AspNetCore.Analyzers +{ + internal static class CompilationFeatureDetector + { + public static async Task> DetectFeaturesAsync( + Compilation compilation, + CancellationToken cancellationToken = default) + { + var symbols = new StartupSymbols(compilation); + if (!symbols.HasRequiredSymbols) + { + // Cannot find ASP.NET Core types. + return ImmutableHashSet.Empty; + } + + var features = ImmutableHashSet.CreateBuilder(); + + // Find configure methods in the project's assembly + var configureMethods = ConfigureMethodVisitor.FindConfigureMethods(symbols, compilation.Assembly); + for (var i = 0; i < configureMethods.Count; i++) + { + var configureMethod = configureMethods[i]; + + // Handles the case where a method is using partial definitions. We don't expect this to occur, but still handle it correctly. + var syntaxReferences = configureMethod.DeclaringSyntaxReferences; + for (var j = 0; j < syntaxReferences.Length; j++) + { + var semanticModel = compilation.GetSemanticModel(syntaxReferences[j].SyntaxTree); + + var syntax = await syntaxReferences[j].GetSyntaxAsync(cancellationToken).ConfigureAwait(false); + var operation = semanticModel.GetOperation(syntax); + + // Look for a call to one of the SignalR gestures that applies to the Configure method. + if (operation + .Descendants() + .OfType() + .Any(op => StartupFacts.IsSignalRConfigureMethodGesture(op.TargetMethod))) + { + features.Add(WellKnownFeatures.SignalR); + } + } + } + + return features.ToImmutable(); + } + } +} diff --git a/src/Analyzers/Analyzers/src/ConfigureMethodVisitor.cs b/src/Analyzers/Analyzers/src/ConfigureMethodVisitor.cs new file mode 100644 index 0000000000..db0e40d0ba --- /dev/null +++ b/src/Analyzers/Analyzers/src/ConfigureMethodVisitor.cs @@ -0,0 +1,61 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.CodeAnalysis; + +namespace Microsoft.AspNetCore.Analyzers +{ + internal class ConfigureMethodVisitor : SymbolVisitor + { + public static List FindConfigureMethods(StartupSymbols symbols, IAssemblySymbol assembly) + { + var visitor = new ConfigureMethodVisitor(symbols); + visitor.Visit(assembly); + return visitor._methods; + } + + private readonly StartupSymbols _symbols; + private readonly List _methods; + + private ConfigureMethodVisitor(StartupSymbols symbols) + { + _symbols = symbols; + _methods = new List(); + } + + public override void VisitAssembly(IAssemblySymbol symbol) + { + Visit(symbol.GlobalNamespace); + } + + public override void VisitNamespace(INamespaceSymbol symbol) + { + foreach (var type in symbol.GetTypeMembers()) + { + Visit(type); + } + + foreach (var @namespace in symbol.GetNamespaceMembers()) + { + Visit(@namespace); + } + } + + public override void VisitNamedType(INamedTypeSymbol symbol) + { + foreach (var member in symbol.GetMembers()) + { + Visit(member); + } + } + + public override void VisitMethod(IMethodSymbol symbol) + { + if (StartupFacts.IsConfigure(_symbols, symbol)) + { + _methods.Add(symbol); + } + } + } +} diff --git a/src/Analyzers/Analyzers/src/StartupFacts.cs b/src/Analyzers/Analyzers/src/StartupFacts.cs index a267024a05..221e3ff4c4 100644 --- a/src/Analyzers/Analyzers/src/StartupFacts.cs +++ b/src/Analyzers/Analyzers/src/StartupFacts.cs @@ -122,5 +122,30 @@ namespace Microsoft.AspNetCore.Analyzers return false; } + + // Based on the three known gestures for including SignalR in a ConfigureMethod: + // UseSignalR() // middleware + // MapHub<>() // endpoint routing + // MapBlazorHub() // server-side blazor + // + // To be slightly less brittle, we don't look at the exact symbols and instead just look + // at method names in here. We're NOT worried about false negatives, because all of these + // cases contain words like SignalR or Hub. + public static bool IsSignalRConfigureMethodGesture(IMethodSymbol symbol) + { + if (symbol == null) + { + throw new ArgumentNullException(nameof(symbol)); + } + + if (string.Equals(symbol.Name, SymbolNames.SignalRAppBuilderExtensions.UseSignalRMethodName, StringComparison.Ordinal) || + string.Equals(symbol.Name, SymbolNames.HubEndpointRouteBuilderExtensions.MapHubMethodName, StringComparison.Ordinal) || + string.Equals(symbol.Name, SymbolNames.ComponentEndpointRouteBuilderExtensions.MapBlazorHubMethodName, StringComparison.Ordinal)) + { + return true; + } + + return false; + } } } diff --git a/src/Analyzers/Analyzers/src/SymbolNames.cs b/src/Analyzers/Analyzers/src/SymbolNames.cs index 0aff8e0738..0442d96c75 100644 --- a/src/Analyzers/Analyzers/src/SymbolNames.cs +++ b/src/Analyzers/Analyzers/src/SymbolNames.cs @@ -1,8 +1,6 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using Microsoft.CodeAnalysis; - namespace Microsoft.AspNetCore.Analyzers { internal static class SymbolNames @@ -22,5 +20,26 @@ namespace Microsoft.AspNetCore.Analyzers { public const string MetadataName = "Microsoft.Extensions.DependencyInjection.IServiceCollection"; } + + public static class ComponentEndpointRouteBuilderExtensions + { + public const string MetadataName = "Microsoft.AspNetCore.Builder.ComponentEndpointRouteBuilderExtensions"; + + public const string MapBlazorHubMethodName = "MapBlazorHub"; + } + + public static class HubEndpointRouteBuilderExtensions + { + public const string MetadataName = "Microsoft.AspNetCore.Builder.HubEndpointRouteBuilderExtensions"; + + public const string MapHubMethodName = "MapHub"; + } + + public static class SignalRAppBuilderExtensions + { + public const string MetadataName = "Microsoft.AspNetCore.Builder.SignalRAppBuilderExtensions"; + + public const string UseSignalRMethodName = "UseSignalR"; + } } } diff --git a/src/Analyzers/Analyzers/src/WellKnownFeatures.cs b/src/Analyzers/Analyzers/src/WellKnownFeatures.cs new file mode 100644 index 0000000000..7c6e820232 --- /dev/null +++ b/src/Analyzers/Analyzers/src/WellKnownFeatures.cs @@ -0,0 +1,10 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Analyzers +{ + internal static class WellKnownFeatures + { + public static readonly string SignalR = nameof(SignalR); + } +} diff --git a/src/Analyzers/Analyzers/test/CompilationFeatureDetectorTest.cs b/src/Analyzers/Analyzers/test/CompilationFeatureDetectorTest.cs new file mode 100644 index 0000000000..0669c76464 --- /dev/null +++ b/src/Analyzers/Analyzers/test/CompilationFeatureDetectorTest.cs @@ -0,0 +1,51 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Analyzers.TestFiles.CompilationFeatureDetectorTest; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace Microsoft.AspNetCore.Analyzers +{ + public class CompilationFeatureDetectorTest : AnalyzerTestBase + { + [Fact] + public async Task DetectFeaturesAsync_FindsNoFeatures() + { + // Arrange + var compilation = await CreateCompilationAsync(nameof(StartupWithNoFeatures)); + var symbols = new StartupSymbols(compilation); + + var type = (INamedTypeSymbol)compilation.GetSymbolsWithName(nameof(StartupWithNoFeatures)).Single(); + Assert.True(StartupFacts.IsStartupClass(symbols, type)); + + // Act + var features = await CompilationFeatureDetector.DetectFeaturesAsync(compilation); + + // Assert + Assert.Empty(features); + } + + [Theory] + [InlineData(nameof(StartupWithUseSignalR))] + [InlineData(nameof(StartupWithMapHub))] + [InlineData(nameof(StartupWithMapBlazorHub))] + public async Task DetectFeaturesAsync_FindsSignalR(string source) + { + // Arrange + var compilation = await CreateCompilationAsync(source); + var symbols = new StartupSymbols(compilation); + + var type = (INamedTypeSymbol)compilation.GetSymbolsWithName(source).Single(); + Assert.True(StartupFacts.IsStartupClass(symbols, type)); + + // Act + var features = await CompilationFeatureDetector.DetectFeaturesAsync(compilation); + + // Assert + Assert.Collection(features, f => Assert.Equal(WellKnownFeatures.SignalR, f)); + } + } +} diff --git a/src/Analyzers/Analyzers/test/ConfigureMethodVisitorTest.cs b/src/Analyzers/Analyzers/test/ConfigureMethodVisitorTest.cs new file mode 100644 index 0000000000..9cf769861b --- /dev/null +++ b/src/Analyzers/Analyzers/test/ConfigureMethodVisitorTest.cs @@ -0,0 +1,41 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace Microsoft.AspNetCore.Analyzers +{ + public class ConfigureMethodVisitorTest : AnalyzerTestBase + { + [Fact] + public async Task FindConfigureMethods_AtDifferentScopes() + { + // Arrange + var expected = new string[] + { + "global::ANamespace.Nested.Startup.Configure", + "global::ANamespace.Nested.Startup.NestedStartup.Configure", + "global::ANamespace.Startup.ConfigureDevelopment", + "global::ANamespace.Startup.NestedStartup.ConfigureTest", + "global::Another.AnotherStartup.Configure", + "global::GlobalStartup.Configure", + }; + + var compilation = await CreateCompilationAsync("Startup"); + var symbols = new StartupSymbols(compilation); + + // Act + var results = ConfigureMethodVisitor.FindConfigureMethods(symbols, compilation.Assembly); + + // Assert + var actual = results + .Select(m => m.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + "." + m.Name) + .OrderBy(s => s) + .ToArray(); + Assert.Equal(expected, actual); + } + } +} diff --git a/src/Analyzers/Analyzers/test/TestFiles/CompilationFeatureDetectorTest/StartupWithMapBlazorHub.cs b/src/Analyzers/Analyzers/test/TestFiles/CompilationFeatureDetectorTest/StartupWithMapBlazorHub.cs new file mode 100644 index 0000000000..685483a0b1 --- /dev/null +++ b/src/Analyzers/Analyzers/test/TestFiles/CompilationFeatureDetectorTest/StartupWithMapBlazorHub.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Builder; + +namespace Microsoft.AspNetCore.Analyzers.TestFiles.CompilationFeatureDetectorTest +{ + public class StartupWithMapBlazorHub + { + public void Configure(IApplicationBuilder app) + { + app.UseRouting(); + + app.UseEndpoints(endpoints => + { + endpoints.MapBlazorHub(); + }); + } + } +} diff --git a/src/Analyzers/Analyzers/test/TestFiles/CompilationFeatureDetectorTest/StartupWithMapHub.cs b/src/Analyzers/Analyzers/test/TestFiles/CompilationFeatureDetectorTest/StartupWithMapHub.cs new file mode 100644 index 0000000000..43ea578d88 --- /dev/null +++ b/src/Analyzers/Analyzers/test/TestFiles/CompilationFeatureDetectorTest/StartupWithMapHub.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.SignalR; + +namespace Microsoft.AspNetCore.Analyzers.TestFiles.CompilationFeatureDetectorTest +{ + public class StartupWithMapHub + { + public void Configure(IApplicationBuilder app) + { + app.UseRouting(); + + app.UseEndpoints(endpoints => + { + endpoints.MapHub("/test"); + }); + } + } + + public class MyHub : Hub + { + } +} diff --git a/src/Analyzers/Analyzers/test/TestFiles/CompilationFeatureDetectorTest/StartupWithNoFeatures.cs b/src/Analyzers/Analyzers/test/TestFiles/CompilationFeatureDetectorTest/StartupWithNoFeatures.cs new file mode 100644 index 0000000000..24426fc681 --- /dev/null +++ b/src/Analyzers/Analyzers/test/TestFiles/CompilationFeatureDetectorTest/StartupWithNoFeatures.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Builder; + +namespace Microsoft.AspNetCore.Analyzers.TestFiles.CompilationFeatureDetectorTest +{ + public class StartupWithNoFeatures + { + public void Configure(IApplicationBuilder app) + { + app.UseRouting(); + + app.UseEndpoints(endpoints => + { + endpoints.MapFallbackToFile("index.html"); + }); + } + } +} diff --git a/src/Analyzers/Analyzers/test/TestFiles/CompilationFeatureDetectorTest/StartupWithUseSignalR.cs b/src/Analyzers/Analyzers/test/TestFiles/CompilationFeatureDetectorTest/StartupWithUseSignalR.cs new file mode 100644 index 0000000000..17159a411c --- /dev/null +++ b/src/Analyzers/Analyzers/test/TestFiles/CompilationFeatureDetectorTest/StartupWithUseSignalR.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Builder; + +namespace Microsoft.AspNetCore.Analyzers.TestFiles.CompilationFeatureDetectorTest +{ + public class StartupWithUseSignalR + { + public void Configure(IApplicationBuilder app) + { + app.UseSignalR(routes => + { + + }); + } + } +} diff --git a/src/Analyzers/Analyzers/test/TestFiles/ConfigureMethodVisitorTest/Startup.cs b/src/Analyzers/Analyzers/test/TestFiles/ConfigureMethodVisitorTest/Startup.cs new file mode 100644 index 0000000000..0e196654dc --- /dev/null +++ b/src/Analyzers/Analyzers/test/TestFiles/ConfigureMethodVisitorTest/Startup.cs @@ -0,0 +1,55 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Builder; + +public class GlobalStartup +{ + public void Configure(IApplicationBuilder app) + { + } +} + +namespace Another +{ + public class AnotherStartup + { + public void Configure(IApplicationBuilder app) + { + } + } +} + +namespace ANamespace +{ + public class Startup + { + public void ConfigureDevelopment(IApplicationBuilder app) + { + } + + public class NestedStartup + { + public void ConfigureTest(IApplicationBuilder app) + { + } + } + } +} + +namespace ANamespace.Nested +{ + public class Startup + { + public void Configure(IApplicationBuilder app) + { + } + + public class NestedStartup + { + public void Configure(IApplicationBuilder app) + { + } + } + } +} diff --git a/src/Analyzers/shared/FeatureDetection/Microsoft.AspNetCore.Analyzers.FeatureDetection.Sources.csproj b/src/Analyzers/shared/FeatureDetection/Microsoft.AspNetCore.Analyzers.FeatureDetection.Sources.csproj index 81c027292b..0edebfb6d2 100644 --- a/src/Analyzers/shared/FeatureDetection/Microsoft.AspNetCore.Analyzers.FeatureDetection.Sources.csproj +++ b/src/Analyzers/shared/FeatureDetection/Microsoft.AspNetCore.Analyzers.FeatureDetection.Sources.csproj @@ -28,6 +28,16 @@ + + %(FileName)%(Extension) + true + $(ContentTargetFolders)\cs\netstandard1.0\shared\ + + + %(FileName)%(Extension) + true + $(ContentTargetFolders)\cs\netstandard1.0\shared\ + %(FileName)%(Extension) true @@ -43,6 +53,11 @@ true $(ContentTargetFolders)\cs\netstandard1.0\shared\ + + %(FileName)%(Extension) + true + $(ContentTargetFolders)\cs\netstandard1.0\shared\ + diff --git a/src/Analyzers/shared/FeatureDetection/ProjectCompilationFeatureDetector.cs b/src/Analyzers/shared/FeatureDetection/ProjectCompilationFeatureDetector.cs new file mode 100644 index 0000000000..9a92559a61 --- /dev/null +++ b/src/Analyzers/shared/FeatureDetection/ProjectCompilationFeatureDetector.cs @@ -0,0 +1,80 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Immutable; +using System.ComponentModel.Composition; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.VisualStudio.LanguageServices; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Threading; +using Task = System.Threading.Tasks.Task; + +namespace Microsoft.AspNetCore.Analyzers.FeatureDetection +{ + // Be very careful making changes to this file. No project in this repo builds it. + // + // If you need to verify a change, make a local project (net472) and copy in everything included by this project. + // + // You'll also need some nuget packages like: + // - Microsoft.VisualStudio.LanguageServices + // - Microsoft.VisualStudio.Shell.15.0 + // - Microsoft.VisualStudio.Threading + [Export(typeof(ProjectCompilationFeatureDetector))] + internal class ProjectCompilationFeatureDetector + { + private readonly Lazy _workspace; + + [ImportingConstructor] + public ProjectCompilationFeatureDetector(Lazy workspace) + { + _workspace = workspace; + } + + public async Task> DetectFeaturesAsync(string projectFullPath, CancellationToken cancellationToken = default) + { + if (projectFullPath == null) + { + throw new ArgumentNullException(nameof(projectFullPath)); + } + + // If the workspace is uninitialized, we need to do the first access on the UI thread. + // + // This is very unlikely to occur, but doing it here for completeness. + if (!_workspace.IsValueCreated) + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); + GC.KeepAlive(_workspace.Value); + await TaskScheduler.Default; + } + + var workspace = _workspace.Value; + var solution = workspace.CurrentSolution; + + var project = GetProject(solution, projectFullPath); + if (project == null) + { + // Cannot find matching project. + return ImmutableHashSet.Empty; + } + + var compilation = await project.GetCompilationAsync(cancellationToken).ConfigureAwait(false); + return await CompilationFeatureDetector.DetectFeaturesAsync(compilation, cancellationToken); + } + + private static Project GetProject(Solution solution, string projectFilePath) + { + foreach (var project in solution.Projects) + { + if (string.Equals(projectFilePath, project.FilePath, StringComparison.OrdinalIgnoreCase)) + { + return project; + } + } + + return null; + } + } +}