Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public class DiagnosticProject
private static readonly ICompilationAssemblyResolver _assemblyResolver = new AppBaseCompilationAssemblyResolver();
private static readonly Dictionary<Assembly, Solution> _solutionCache = new Dictionary<Assembly, Solution>();

public static Project Create(Assembly testAssembly, string[] sources, Func<Workspace> workspaceFactory = null, Type analyzerReference = null)
public static Project Create(Assembly testAssembly, string[] sources, Func<Workspace> workspaceFactory = null, Type[] analyzerReferences = null)
{
Solution solution;
lock (_solutionCache)
Expand All @@ -50,11 +50,14 @@ public static Project Create(Assembly testAssembly, string[] sources, Func<Works
}
}

if (analyzerReference != null)
if (analyzerReferences != null)
{
solution = solution.AddAnalyzerReference(
projectId,
new AnalyzerFileReference(analyzerReference.Assembly.Location, AssemblyLoader.Instance));
foreach (var analyzerReference in analyzerReferences)
{
solution = solution.AddAnalyzerReference(
projectId,
new AnalyzerFileReference(analyzerReference.Assembly.Location, AssemblyLoader.Instance));
}
}

_solutionCache.Add(testAssembly, solution);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,7 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context)
}

var wellKnownTypes = WellKnownTypes.GetOrCreate(semanticModel.Compilation);

Dictionary<ThisAndExtensionMethod, PackageSourceAndNamespace> _wellKnownExtensionMethodCache = new()
{
{
new(wellKnownTypes.Get(WellKnownTypeData.WellKnownType.Microsoft_Extensions_DependencyInjection_IServiceCollection), "AddOpenApi"),
new("Microsoft.AspNetCore.OpenApi", "Microsoft.Extensions.DependencyInjection")
},
{
new(wellKnownTypes.Get(WellKnownTypeData.WellKnownType.Microsoft_AspNetCore_Builder_WebApplication), "MapOpenApi"),
new("Microsoft.AspNetCore.OpenApi", "Microsoft.AspNetCore.Builder")
}
};
var wellKnownExtensionMethodCache = ExtensionMethodsClass.ConstructFromWellKnownTypes(wellKnownTypes);

foreach (var diagnostic in context.Diagnostics)
{
Expand Down Expand Up @@ -80,7 +69,7 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context)
}

var targetThisAndExtensionMethod = new ThisAndExtensionMethod(symbolType, methodName);
if (_wellKnownExtensionMethodCache.TryGetValue(targetThisAndExtensionMethod, out var packageSourceAndNamespace))
if (wellKnownExtensionMethodCache.TryGetValue(targetThisAndExtensionMethod, out var packageSourceAndNamespace))
{
var position = diagnostic.Location.SourceSpan.Start;
var packageInstallData = new AspNetCoreInstallPackageData(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// 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 Microsoft.AspNetCore.Analyzers;
using Microsoft.AspNetCore.App.Analyzers.Infrastructure;

internal static class ExtensionMethodsCache
{
public static Dictionary<ThisAndExtensionMethod, PackageSourceAndNamespace> ConstructFromWellKnownTypes(WellKnownTypes wellKnownTypes)
{
return new()
{
{
new(wellKnownTypes.Get(WellKnownTypeData.WellKnownType.Microsoft_Extensions_DependencyInjection_IServiceCollection), "AddOpenApi"),
new("Microsoft.AspNetCore.OpenApi", "Microsoft.Extensions.DependencyInjection")
},
{
new(wellKnownTypes.Get(WellKnownTypeData.WellKnownType.Microsoft_AspNetCore_Builder_WebApplication), "MapOpenApi"),
new("Microsoft.AspNetCore.OpenApi", "Microsoft.AspNetCore.Builder")
}
};
}
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JamesNK Can I get your review on this given your experience implementing completion providers in route tooling?

The goal is to add completions for extension methods defined in a separate package then provide an installation codefix for them.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's been a minute since my head was in The Roslyn Zone. I'll do my best.

Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// 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.Composition;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.App.Analyzers.Infrastructure;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Completion;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage;

[ExportCompletionProvider(nameof(ExtensionMethodsCompletionProvider), LanguageNames.CSharp)]
[Shared]
public sealed class ExtensionMethodsCompletionProvider : CompletionProvider
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a comment summarizing what the provider's deal is.

Does it replace completion for known types or are values from the completion provider added to the auto-complete? Replacing auto-complete for some types (if that's how it works) feels pretty invasive.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The completion provider is additive (assuming I understand the intent of the context.AddItem method 😅 ) and is intended to augment the completions list with methods that we think might be helpful. I'll add a comment to this effect.

Copy link
Member

@JamesNK JamesNK Jun 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if the value is already present in auto-complete? For example, someone might already have a reference to the OpenAPI package. Do they see the AddOpenApi option from built-in autocomplete and a item from this provider?

Do items added by this provider look the same or different to other items? Could I see a screenshot?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So excited! 🥳

{
public override async Task ProvideCompletionsAsync(CompletionContext context)
{
if (!context.Document.SupportsSemanticModel)
{
return;
}

var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
if (root == null)
{
return;
}

var span = context.CompletionListSpan;
var token = root.FindToken(span.Start);
if (token.Parent == null)
{
return;
}

var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false);
if (semanticModel == null)
{
return;
}

var wellKnownTypes = WellKnownTypes.GetOrCreate(semanticModel.Compilation);
var wellKnownExtensionMethodCache = ExtensionMethodsClass.ConstructFromWellKnownTypes(wellKnownTypes);
var nearestMemberAccessExpression = FindNearestMemberAccessExpression(token.Parent);

if (nearestMemberAccessExpression is not null && nearestMemberAccessExpression is MemberAccessExpressionSyntax memberAccess)
{
var symbol = semanticModel.GetSymbolInfo(memberAccess.Expression);
var symbolType = symbol.Symbol switch
{
IMethodSymbol methodSymbol => methodSymbol.ReturnType,
IPropertySymbol propertySymbol => propertySymbol.Type,
ILocalSymbol localSymbol => localSymbol.Type,
_ => null
};

var matchingExtensionMethods = wellKnownExtensionMethodCache.Where(pair => IsMatchingExtensionMethod(pair, symbolType, token));
foreach (var item in matchingExtensionMethods)
{
context.CompletionListSpan = span;
context.AddItem(CompletionItem.Create(
displayText: item.Key.ExtensionMethod,
sortText: item.Key.ExtensionMethod,
filterText: item.Key.ExtensionMethod
));
}
}
}

private static SyntaxNode? FindNearestMemberAccessExpression(SyntaxNode? node)
{
var current = node;
while (current != null)
{
if (current?.IsKind(SyntaxKind.SimpleMemberAccessExpression) ?? false)
{
return current;
}

current = current?.Parent;
}

return null;
}

private static bool IsMatchingExtensionMethod(
KeyValuePair<ThisAndExtensionMethod, PackageSourceAndNamespace> pair,
ISymbol? symbolType,
SyntaxToken token)
{
if (symbolType is null)
{
return false;
}

var isIdentifierToken = token.IsKind(SyntaxKind.IdentifierName) || token.IsKind(SyntaxKind.IdentifierToken);
return SymbolEqualityComparer.Default.Equals(pair.Key.ThisType, symbolType) &&
(!isIdentifierToken || pair.Key.ExtensionMethod.Contains(token.ValueText));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage.Infrastructure;
using Microsoft.AspNetCore.Analyzers.WebApplicationBuilder;
using Microsoft.CodeAnalysis.Completion;

namespace Microsoft.AspNetCore.Analyzers.Dependencies;

public partial class ExtensionMethodsCompletionProviderTests
{
private TestDiagnosticAnalyzerRunner Runner { get; } = new(new WebApplicationBuilderAnalyzer());

public static object[][] CompletionTriggers =>
[
[CompletionTrigger.Invoke],
[null]
];

[Theory]
[MemberData(nameof(CompletionTriggers))]
public async Task ProvidesAddOpenApiCompletion(CompletionTrigger trigger)
{
// Arrange & Act
var result = await GetCompletionsAndServiceAsync(@"
using Microsoft.AspNetCore.Builder;

class Program
{
static void Main()
{
var builder = WebApplication.CreateBuilder();
builder.Services.$$
}
}
", trigger);

// Assert
Assert.True(result.ShouldTriggerCompletion);
Assert.Contains(result.Completions.ItemsList, item => item.DisplayText == "AddOpenApi");
}

[Theory]
[MemberData(nameof(CompletionTriggers))]
public async Task ProvidesAddOpenApiCompletionWithPartialToken(CompletionTrigger trigger)
{
// Arrange & Act
var result = await GetCompletionsAndServiceAsync(@"
using Microsoft.AspNetCore.Builder;

class Program
{
static void Main()
{
var builder = WebApplication.CreateBuilder();
builder.Services.[|Ad$$|]
}
}
", trigger);

// Assert
Assert.True(result.ShouldTriggerCompletion);
Assert.Contains(result.Completions.ItemsList, item => item.DisplayText == "AddOpenApi");
}

[Theory]
[MemberData(nameof(CompletionTriggers))]
public async Task DoesNotProvideCompletionIfNoStringMatchForServices(CompletionTrigger trigger)
{
// Arrange & Act
var result = await GetCompletionsAndServiceAsync(@"
using Microsoft.AspNetCore.Builder;

class Program
{
static void Main()
{
var builder = WebApplication.CreateBuilder();
builder.Services.[|Confi$$|]
}
}
", trigger);

// Assert
Assert.True(result.ShouldTriggerCompletion);
Assert.DoesNotContain(result.Completions.ItemsList, item => item.DisplayText == "AddOpenApi");
}

[Theory]
[MemberData(nameof(CompletionTriggers))]
public async Task ProvidesMapOpenApiCompletion(CompletionTrigger trigger)
{
// Arrange & Act
var result = await GetCompletionsAndServiceAsync(@"
using Microsoft.AspNetCore.Builder;

class Program
{
static void Main()
{
var app = WebApplication.Create();
app.$$
}
}
", trigger);

// Assert
Assert.True(result.ShouldTriggerCompletion);
Assert.Contains(result.Completions.ItemsList, item => item.DisplayText == "MapOpenApi");
}

[Theory]
[MemberData(nameof(CompletionTriggers))]
public async Task ProvidesMapOpenApiCompletionWithPartialToken(CompletionTrigger trigger)
{
// Arrange & Act
var result = await GetCompletionsAndServiceAsync(@"
using Microsoft.AspNetCore.Builder;

class Program
{
static void Main()
{
var app = WebApplication.Create();
app.[|Ma$$|]
}
}
", trigger);

// Assert
Assert.True(result.ShouldTriggerCompletion);
Assert.Contains(result.Completions.ItemsList, item => item.DisplayText == "MapOpenApi");
}

[Theory]
[MemberData(nameof(CompletionTriggers))]
public async Task DoesNotProvideCompletionIfNoStringMatchForWebApplication(CompletionTrigger trigger)
{
// Arrange & Act
var result = await GetCompletionsAndServiceAsync(@"
using Microsoft.AspNetCore.Builder;

class Program
{
static void Main()
{
var app = WebApplication.Create();
app.[|Use$$|]
}
}
", trigger);

// Assert
Assert.True(result.ShouldTriggerCompletion);
Assert.DoesNotContain(result.Completions.ItemsList, item => item.DisplayText == "MapOpenApi");
}

private Task<CompletionResult> GetCompletionsAndServiceAsync(string source, CompletionTrigger? completionTrigger = null)
{
return CompletionTestHelpers.GetCompletionsAndServiceAsync(Runner, source, completionTrigger);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
<Reference Include="Microsoft.AspNetCore.Mvc" />
<Reference Include="Microsoft.AspNetCore.Http.Results" />
<Reference Include="Microsoft.AspNetCore.RateLimiting" />
<Reference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" PrivateAssets="All" />
<Reference Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing.XUnit" />
<Reference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ public static Project CreateProjectWithReferencesInBinDir(Assembly testAssembly,

Func<Workspace> createWorkspace = CreateWorkspace;

var project = DiagnosticProject.Create(testAssembly, source, createWorkspace, typeof(RoutePatternClassifier));
var project = DiagnosticProject.Create(testAssembly, source, createWorkspace, [typeof(RoutePatternClassifier), typeof(ExtensionMethodsCompletionProvider)]);
foreach (var assembly in Directory.EnumerateFiles(AppContext.BaseDirectory, "*.dll"))
{
if (!project.MetadataReferences.Any(c => string.Equals(Path.GetFileNameWithoutExtension(c.Display), Path.GetFileNameWithoutExtension(assembly), StringComparison.OrdinalIgnoreCase)))
Expand Down