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.
This commit is contained in:
Ryan Nowak 2019-05-09 12:33:09 -07:00 committed by GitHub
parent 4db8260c6c
commit 7642f9d12a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 500 additions and 2 deletions

View File

@ -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<IImmutableSet<string>> DetectFeaturesAsync(
Compilation compilation,
CancellationToken cancellationToken = default)
{
var symbols = new StartupSymbols(compilation);
if (!symbols.HasRequiredSymbols)
{
// Cannot find ASP.NET Core types.
return ImmutableHashSet<string>.Empty;
}
var features = ImmutableHashSet.CreateBuilder<string>();
// 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<IInvocationOperation>()
.Any(op => StartupFacts.IsSignalRConfigureMethodGesture(op.TargetMethod)))
{
features.Add(WellKnownFeatures.SignalR);
}
}
}
return features.ToImmutable();
}
}
}

View File

@ -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<IMethodSymbol> FindConfigureMethods(StartupSymbols symbols, IAssemblySymbol assembly)
{
var visitor = new ConfigureMethodVisitor(symbols);
visitor.Visit(assembly);
return visitor._methods;
}
private readonly StartupSymbols _symbols;
private readonly List<IMethodSymbol> _methods;
private ConfigureMethodVisitor(StartupSymbols symbols)
{
_symbols = symbols;
_methods = new List<IMethodSymbol>();
}
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);
}
}
}
}

View File

@ -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;
}
}
}

View File

@ -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";
}
}
}

View File

@ -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);
}
}

View File

@ -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));
}
}
}

View File

@ -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);
}
}
}

View File

@ -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();
});
}
}
}

View File

@ -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<MyHub>("/test");
});
}
}
public class MyHub : Hub
{
}
}

View File

@ -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");
});
}
}
}

View File

@ -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 =>
{
});
}
}
}

View File

@ -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)
{
}
}
}
}

View File

@ -28,6 +28,16 @@
</ItemGroup>
<ItemGroup>
<Compile Include="../../Analyzers/src/CompilationFeatureDetector.cs">
<Link>%(FileName)%(Extension)</Link>
<Pack>true</Pack>
<PackagePath>$(ContentTargetFolders)\cs\netstandard1.0\shared\</PackagePath>
</Compile>
<Compile Include="../../Analyzers/src/ConfigureMethodVisitor.cs">
<Link>%(FileName)%(Extension)</Link>
<Pack>true</Pack>
<PackagePath>$(ContentTargetFolders)\cs\netstandard1.0\shared\</PackagePath>
</Compile>
<Compile Include="../../Analyzers/src/StartupFacts.cs">
<Link>%(FileName)%(Extension)</Link>
<Pack>true</Pack>
@ -43,6 +53,11 @@
<Pack>true</Pack>
<PackagePath>$(ContentTargetFolders)\cs\netstandard1.0\shared\</PackagePath>
</Compile>
<Compile Include="../../Analyzers/src/WellKnownFeatures.cs">
<Link>%(FileName)%(Extension)</Link>
<Pack>true</Pack>
<PackagePath>$(ContentTargetFolders)\cs\netstandard1.0\shared\</PackagePath>
</Compile>
</ItemGroup>
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />

View File

@ -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<VisualStudioWorkspace> _workspace;
[ImportingConstructor]
public ProjectCompilationFeatureDetector(Lazy<VisualStudioWorkspace> workspace)
{
_workspace = workspace;
}
public async Task<IImmutableSet<string>> 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<string>.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;
}
}
}