diff --git a/src/Bicep.Core.IntegrationTests/ExamplesTests.cs b/src/Bicep.Core.IntegrationTests/ExamplesTests.cs index 49822d71d35..35d10b4cb41 100644 --- a/src/Bicep.Core.IntegrationTests/ExamplesTests.cs +++ b/src/Bicep.Core.IntegrationTests/ExamplesTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Diagnostics.CodeAnalysis; +using Bicep.Core.Configuration; using Bicep.Core.Diagnostics; using Bicep.Core.Emit; using Bicep.Core.Extensions; @@ -20,6 +21,10 @@ namespace Bicep.Core.IntegrationTests [TestClass] public class ExamplesTests { + private const string MsGraphSampleExtensionVersion = "0.1.8-preview"; + private const string MsGraphBetaRepository = "bicep/extensions/microsoftgraph/beta"; + private const string MsGraphV1Repository = "bicep/extensions/microsoftgraph/v1.0"; + private static ServiceBuilder Services => new ServiceBuilder().WithDisabledAnalyzersConfiguration(); [NotNull] @@ -33,7 +38,13 @@ public static async Task RunExampleTest(TestContext testContext, EmbeddedFile em var bicepFile = baselineFolder.EntryFile; var jsonFile = baselineFolder.GetFileOrEnsureCheckedIn(Path.ChangeExtension(embeddedBicep.FileName, jsonFileExtension)); - var compiler = Services.WithFeatureOverrides(features).Build().GetCompiler(); + var services = Services.WithFeatureOverrides(features); + if (RequiresMockMsGraphExtensions(embeddedBicep)) + { + services = await AddMockMsGraphExtensions(services); + } + + var compiler = services.Build().GetCompiler(); var compilation = await compiler.CreateCompilation(bicepFile.OutputFileUri.ToIOUri()); var model = compilation.GetEntrypointSemanticModel(); @@ -107,6 +118,29 @@ public void ExampleData_should_return_a_number_of_records() private static IEnumerable GetAllExampleData() => ExampleData.GetAllExampleData().Select(x => new object[] { x.BicepFile }); + private static bool RequiresMockMsGraphExtensions(EmbeddedFile embeddedBicep) + => embeddedBicep.StreamPath.StartsWith("Files/user_submitted/extensibility/microsoftGraph/", StringComparison.Ordinal); + + private static async Task AddMockMsGraphExtensions(ServiceBuilder services) + { + services = services.WithContainerRegistryClientFactory(RegistryHelper.CreateMockRegistryClient( + new RegistryHelper.RepoDescriptor(LanguageConstants.BicepPublicMcrRegistry, MsGraphBetaRepository, [MsGraphSampleExtensionVersion]), + new RegistryHelper.RepoDescriptor(LanguageConstants.BicepPublicMcrRegistry, MsGraphV1Repository, [MsGraphSampleExtensionVersion]))); + + var serviceProvider = services.Build(); + await RegistryHelper.PublishExtensionToRegistryAsync( + serviceProvider, + $"br:{LanguageConstants.BicepPublicMcrRegistry}/{MsGraphBetaRepository}:{MsGraphSampleExtensionVersion}", + ExtensionResourceTypeHelper.GetMockMsGraphTypesTgz("MicrosoftGraphBeta", MsGraphSampleExtensionVersion, "beta")); + + await RegistryHelper.PublishExtensionToRegistryAsync( + serviceProvider, + $"br:{LanguageConstants.BicepPublicMcrRegistry}/{MsGraphV1Repository}:{MsGraphSampleExtensionVersion}", + ExtensionResourceTypeHelper.GetMockMsGraphTypesTgz("MicrosoftGraph", MsGraphSampleExtensionVersion, "v1.0")); + + return services; + } + private static bool IsPermittedMissingTypeDiagnostic(IDiagnostic diagnostic) { if (diagnostic.Code != "BCP081") diff --git a/src/Bicep.Core.UnitTests/Utils/ExtensionResourceTypeHelper.cs b/src/Bicep.Core.UnitTests/Utils/ExtensionResourceTypeHelper.cs index f582e86553d..25555e1037d 100644 --- a/src/Bicep.Core.UnitTests/Utils/ExtensionResourceTypeHelper.cs +++ b/src/Bicep.Core.UnitTests/Utils/ExtensionResourceTypeHelper.cs @@ -340,6 +340,112 @@ public static BinaryData GetMockDesiredStateConfigurationTypesTgz() ("types.json", StreamHelper.GetString(stream => TypeSerializer.Serialize(stream, factory.GetTypes())))); } + public static BinaryData GetMockMsGraphTypesTgz(string extensionName, string extensionVersion, string apiVersion) + { + var factory = new TypeFactory([]); + + var stringType = factory.Create(() => new StringType()); + var booleanType = factory.Create(() => new BooleanType()); + var anyType = factory.Create(() => new AnyType()); + var stringArrayType = factory.Create(() => new ArrayType(factory.GetReference(stringType))); + var anyArrayType = factory.Create(() => new ArrayType(factory.GetReference(anyType))); + + var stringTypeRef = factory.GetReference(stringType); + var booleanTypeRef = factory.GetReference(booleanType); + var anyTypeRef = factory.GetReference(anyType); + var stringArrayTypeRef = factory.GetReference(stringArrayType); + var anyArrayTypeRef = factory.GetReference(anyArrayType); + + ObjectTypeProperty Property(ITypeReference typeRef, ObjectTypePropertyFlags flags = ObjectTypePropertyFlags.None) => new(typeRef, flags, null); + + ResourceType Resource(string name, Dictionary properties) + { + var bodyType = factory.Create(() => new ObjectType(name, properties, null)); + + return factory.Create(() => new ResourceType( + name, + factory.GetReference(bodyType), + null, + writableScopes_in: ScopeType.All, + readableScopes_in: ScopeType.All)); + } + + Dictionary ApplicationProperties() => new() + { + ["uniqueName"] = Property(stringTypeRef, ObjectTypePropertyFlags.Required), + ["displayName"] = Property(stringTypeRef), + ["appId"] = Property(stringTypeRef, ObjectTypePropertyFlags.ReadOnly), + ["id"] = Property(stringTypeRef, ObjectTypePropertyFlags.ReadOnly), + ["appRoles"] = Property(anyArrayTypeRef), + ["api"] = Property(anyTypeRef), + }; + + ResourceType[] resourceTypes = apiVersion switch + { + "beta" => + [ + Resource("Microsoft.Graph/applications@beta", ApplicationProperties()), + Resource("Microsoft.Graph/servicePrincipals@beta", new() + { + ["appId"] = Property(stringTypeRef, ObjectTypePropertyFlags.Required), + ["id"] = Property(stringTypeRef, ObjectTypePropertyFlags.ReadOnly), + }), + Resource("Microsoft.Graph/oauth2PermissionGrants@beta", new() + { + ["clientId"] = Property(stringTypeRef), + ["consentType"] = Property(stringTypeRef), + ["resourceId"] = Property(stringTypeRef), + ["scope"] = Property(stringTypeRef), + ["id"] = Property(stringTypeRef, ObjectTypePropertyFlags.ReadOnly), + }), + Resource("Microsoft.Graph/appRoleAssignedTo@beta", new() + { + ["appRoleId"] = Property(stringTypeRef), + ["principalId"] = Property(stringTypeRef), + ["resourceId"] = Property(stringTypeRef), + ["id"] = Property(stringTypeRef, ObjectTypePropertyFlags.ReadOnly), + }), + Resource("Microsoft.Graph/groups@beta", new() + { + ["uniqueName"] = Property(stringTypeRef, ObjectTypePropertyFlags.Required), + ["displayName"] = Property(stringTypeRef), + ["mailEnabled"] = Property(booleanTypeRef), + ["mailNickname"] = Property(stringTypeRef), + ["securityEnabled"] = Property(booleanTypeRef), + ["groupTypes"] = Property(stringArrayTypeRef), + ["owners"] = Property(stringArrayTypeRef), + ["id"] = Property(stringTypeRef, ObjectTypePropertyFlags.ReadOnly), + }), + ], + "v1.0" => + [ + Resource("Microsoft.Graph/applications@v1.0", ApplicationProperties()), + Resource("Microsoft.Graph/applications/federatedIdentityCredentials@v1.0", new() + { + ["name"] = Property(stringTypeRef, ObjectTypePropertyFlags.Required), + ["audiences"] = Property(stringArrayTypeRef), + ["description"] = Property(stringTypeRef), + ["issuer"] = Property(stringTypeRef), + ["subject"] = Property(stringTypeRef), + ["id"] = Property(stringTypeRef, ObjectTypePropertyFlags.ReadOnly), + }), + ], + _ => throw new ArgumentException($"Unsupported Microsoft Graph API version '{apiVersion}'.", nameof(apiVersion)), + }; + + var settings = new TypeSettings(name: extensionName, version: extensionVersion, isSingleton: false, configurationType: null!); + var index = new TypeIndex( + resourceTypes.ToDictionary(x => x.Name, x => new CrossFileTypeReference("types.json", factory.GetIndex(x))), + new Dictionary>>(), + [], + settings, + null); + + return GetTypesTgzBytesFromFiles( + ("index.json", StreamHelper.GetString(stream => TypeSerializer.SerializeIndex(stream, index))), + ("types.json", StreamHelper.GetString(stream => TypeSerializer.Serialize(stream, factory.GetTypes())))); + } + public static BinaryData GetTypesTgzBytesFromFiles(params (string filePath, string contents)[] files) { var stream = new MemoryStream(); diff --git a/src/Bicep.LangServer.IntegrationTests/CompletionTests.cs b/src/Bicep.LangServer.IntegrationTests/CompletionTests.cs index e3f92f35c0b..510466c2ce3 100644 --- a/src/Bicep.LangServer.IntegrationTests/CompletionTests.cs +++ b/src/Bicep.LangServer.IntegrationTests/CompletionTests.cs @@ -4459,8 +4459,8 @@ public async Task Public_module_version_completions(string inputWithCursors, Bic first.Label.Should().Be("1.0.2"); first.SortText.Should().Be("0000"); first.Kind.Should().Be(CompletionItemKind.Snippet); - first.Detail.Should().Be("d1"); - first.Documentation!.MarkupContent!.Value.Should().Be("[View Documentation](contoso.com/help1)"); + first.Detail.Should().BeNull(); + first.Documentation!.MarkupContent!.Value.Should().Be("### d1 \n**Version:** 1.0.2 \n**Full module path:** app/dapr-containerapp \n**Description:** d1 \n[View Documentation](contoso.com/help1)"); }, second => { @@ -4468,7 +4468,7 @@ public async Task Public_module_version_completions(string inputWithCursors, Bic second.SortText.Should().Be("0001"); second.Kind.Should().Be(CompletionItemKind.Snippet); second.Detail.Should().BeNull(); - second.Documentation.Should().BeNull(); + second.Documentation!.MarkupContent!.Value.Should().Be("**Version:** 1.0.1 \n**Full module path:** app/dapr-containerapp \n**Description:** N/A \n**Documentation:** N/A"); } ); } @@ -4532,16 +4532,16 @@ public async Task Private_module_version_completions(string inputWithCursors, Bi first.Label.Should().Be("v101"); first.SortText.Should().Be("0000"); first.Kind.Should().Be(CompletionItemKind.Snippet); - first.Detail.Should().Be("d101"); - first.Documentation!.MarkupContent!.Value.Should().Be("[View Documentation](contoso.com/help/d101.html)"); + first.Detail.Should().BeNull(); + first.Documentation!.MarkupContent!.Value.Should().Be("### d101 \n**Version:** v101 \n**Full module path:** app/private-app \n**Description:** d101 \n[View Documentation](contoso.com/help/d101.html)"); }, second => { second.Label.Should().Be("v100"); second.SortText.Should().Be("0001"); second.Kind.Should().Be(CompletionItemKind.Snippet); - second.Detail.Should().Be("d100"); - second.Documentation!.MarkupContent!.Value.Should().Be("[View Documentation](contoso.com/help/d100.html)"); + second.Detail.Should().BeNull(); + second.Documentation!.MarkupContent!.Value.Should().Be("### d100 \n**Version:** v100 \n**Full module path:** app/private-app \n**Description:** d100 \n[View Documentation](contoso.com/help/d100.html)"); } ); } @@ -4565,7 +4565,7 @@ public async Task Public_registry_module_completions_support_prefix_matching(str settingsProvider.Setup(x => x.GetSetting(LangServerConstants.GetAllAzureContainerRegistriesForCompletionsSetting)).Returns(false); var publicModuleMetadataProvider = RegistryCatalogMocks.MockPublicMetadataProvider([ - ("bicep/abc/foo/bar", "d1", "contoso.com/help1", []), + ("bicep/abc/foo/bar", "d1", "contoso.com/help1", []), ("bicep/abc/food/bar", "d2", "contoso.com/help2", []), ("bicep/abc/bar/bar", "d3", "contoso.com/help3", []), ]); diff --git a/src/Bicep.LangServer.UnitTests/Completions/AvmModuleDisplayNameProviderTests.cs b/src/Bicep.LangServer.UnitTests/Completions/AvmModuleDisplayNameProviderTests.cs new file mode 100644 index 00000000000..6f06e3ffd5e --- /dev/null +++ b/src/Bicep.LangServer.UnitTests/Completions/AvmModuleDisplayNameProviderTests.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using System.Net.Http; +using System.Reflection; +using Bicep.LanguageServer.Completions; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bicep.LangServer.UnitTests.Completions +{ + [TestClass] + public class AvmModuleDisplayNameProviderTests + { + [TestMethod] + public async Task StartCache_WhenCsvFilesAreAvailable_LoadsDisplayNamesAndStatuses() + { + const string csvContent = """ + ModuleName,ModuleDisplayName,ModuleStatus + bicep/avm/ptn/ai-platform/baseline,AI platform baseline,Available + bicep/avm/res/compute/virtual-machine,Virtual machine,Deprecated + """; + + var client = new TestAvmModuleCsvIndexHttpClient((_, _) => Task.FromResult(csvContent)); + var provider = new AvmModuleDisplayNameProvider(client); + + provider.StartCache(); + await WaitForLoadTaskAsync(provider); + + provider.TryGetModuleDisplayName("bicep/avm/ptn/ai-platform/baseline", out var patternDisplayName).Should().BeTrue(); + patternDisplayName.Should().Be("AI platform baseline"); + + provider.TryGetModuleStatus("bicep/avm/ptn/ai-platform/baseline", out var patternModuleStatus).Should().BeTrue(); + patternModuleStatus.Should().Be("Available"); + + provider.TryGetModuleDisplayName("avm/res/compute/virtual-machine", out var resourceDisplayName).Should().BeTrue(); + resourceDisplayName.Should().Be("Virtual machine"); + + provider.TryGetModuleStatus("avm/res/compute/virtual-machine", out var resourceModuleStatus).Should().BeTrue(); + resourceModuleStatus.Should().Be("Deprecated"); + + client.RequestCount.Should().Be(3); + client.RequestedUris.Select(uri => uri.ToString()).Should().BeEquivalentTo([ + "https://aka.ms/avm/index/bicep/utl/csv", + "https://aka.ms/avm/index/bicep/ptn/csv", + "https://aka.ms/avm/index/bicep/res/csv", + ]); + } + + [TestMethod] + public async Task StartCache_WhenCsvFetchFails_CompletesAndUsesEmptyLookup() + { + var client = new TestAvmModuleCsvIndexHttpClient((_, _) => + Task.FromException(new HttpRequestException("CSV unavailable"))); + var provider = new AvmModuleDisplayNameProvider(client); + + var startCache = provider.StartCache; + startCache.Should().NotThrow(); + + await WaitForLoadTaskAsync(provider); + + provider.TryGetModuleDisplayName("bicep/avm/ptn/ai-platform/baseline", out var displayName).Should().BeFalse(); + displayName.Should().BeNull(); + + provider.TryGetModuleStatus("bicep/avm/ptn/ai-platform/baseline", out var moduleStatus).Should().BeFalse(); + moduleStatus.Should().BeNull(); + + client.RequestCount.Should().Be(3); + } + + private static async Task WaitForLoadTaskAsync(AvmModuleDisplayNameProvider provider) + { + var loadTask = GetLoadTask(provider); + var completedTask = await Task.WhenAny(loadTask, Task.Delay(TimeSpan.FromSeconds(5))); + + completedTask.Should().Be(loadTask); + await loadTask; + } + + private static Task GetLoadTask(AvmModuleDisplayNameProvider provider) + { + var loadTaskField = typeof(AvmModuleDisplayNameProvider).GetField("loadTask", BindingFlags.NonPublic | BindingFlags.Instance); + loadTaskField.Should().NotBeNull(); + + var loadTask = loadTaskField?.GetValue(provider) as Task; + loadTask.Should().NotBeNull(); + + return loadTask ?? Task.CompletedTask; + } + + private sealed class TestAvmModuleCsvIndexHttpClient : IAvmModuleCsvIndexHttpClient + { + private readonly Func> getCsvAsync; + private readonly ConcurrentQueue requestedUris = new(); + private int requestCount; + + public TestAvmModuleCsvIndexHttpClient(Func> getCsvAsync) + { + this.getCsvAsync = getCsvAsync; + } + + public int RequestCount => Volatile.Read(ref requestCount); + + public IEnumerable RequestedUris => requestedUris; + + public async Task GetCsvAsync(Uri csvUri, CancellationToken cancellationToken) + { + requestedUris.Enqueue(csvUri); + + try + { + return await getCsvAsync(csvUri, cancellationToken); + } + finally + { + Interlocked.Increment(ref requestCount); + } + } + } + } +} diff --git a/src/Bicep.LangServer.UnitTests/Completions/ModuleReferenceCompletionProviderTests.cs b/src/Bicep.LangServer.UnitTests/Completions/ModuleReferenceCompletionProviderTests.cs index 8626d1324f3..c3037c5f44d 100644 --- a/src/Bicep.LangServer.UnitTests/Completions/ModuleReferenceCompletionProviderTests.cs +++ b/src/Bicep.LangServer.UnitTests/Completions/ModuleReferenceCompletionProviderTests.cs @@ -536,7 +536,11 @@ public async Task GetFilteredCompletions_WithPublicMcrModuleRegistryCompletionCo c.InsertTextFormat.Should().Be(InsertTextFormat.Snippet); c.InsertText.Should().BeNull(); c.Detail.Should().BeNull(); - c.Documentation.Should().BeNull(); + c.Documentation!.MarkupContent!.Value.Should().NotContain("**Display name:**"); + c.Documentation.MarkupContent.Value.Should().NotContain("### "); + c.Documentation.MarkupContent.Value.Should().Contain("**Full module path:** app/dapr-cntrapp1"); + c.Documentation.MarkupContent.Value.Should().Contain("**Description:** N/A"); + c.Documentation.MarkupContent.Value.Should().Contain("**Documentation:** N/A"); c.TextEdit!.TextEdit!.NewText.Should().Be(expectedCompletionText1); c.TextEdit.TextEdit.Range.Start.Line.Should().Be(0); c.TextEdit.TextEdit.Range.Start.Character.Should().Be(12); @@ -549,8 +553,12 @@ public async Task GetFilteredCompletions_WithPublicMcrModuleRegistryCompletionCo c.Kind.Should().Be(CompletionItemKind.Snippet); c.InsertTextFormat.Should().Be(InsertTextFormat.Snippet); c.InsertText.Should().BeNull(); - c.Detail.Should().Be("description2"); - c.Documentation!.MarkupContent!.Value.Should().Be("[View Documentation](contoso.com/help2)"); + c.Detail.Should().BeNull(); + c.Documentation!.MarkupContent!.Value.Should().NotContain("**Display name:**"); + c.Documentation.MarkupContent.Value.Should().Contain("### description2"); + c.Documentation.MarkupContent.Value.Should().Contain("**Full module path:** app/dapr-cntrapp2"); + c.Documentation.MarkupContent.Value.Should().Contain("**Description:** description2"); + c.Documentation.MarkupContent.Value.Should().Contain("[View Documentation](contoso.com/help2)"); c.TextEdit!.TextEdit!.NewText.Should().Be(expectedCompletionText2); c.TextEdit.TextEdit.Range.Start.Line.Should().Be(0); c.TextEdit.TextEdit.Range.Start.Character.Should().Be(12); @@ -559,6 +567,158 @@ public async Task GetFilteredCompletions_WithPublicMcrModuleRegistryCompletionCo }); } + [TestMethod] + public async Task GetFilteredCompletions_WithPublicAvmModulePathCompletions_UsesModuleDisplayNameForTooltipTitle() + { + var catalog = RegistryCatalogMocks.CreateCatalogWithMocks( + RegistryCatalogMocks.MockPublicMetadataProvider([ + new("bicep/avm/ptn/ai-platform/baseline", "module description", "contoso.com/help", []), + ]) + ); + + var avmDisplayNameProviderMock = StrictMock.Of(); + string? moduleDisplayName = "AI platform baseline"; + avmDisplayNameProviderMock + .Setup(x => x.TryGetModuleDisplayName("bicep/avm/ptn/ai-platform/baseline", out moduleDisplayName)) + .Returns(true); + string? moduleStatus = "Available"; + avmDisplayNameProviderMock + .Setup(x => x.TryGetModuleStatus("bicep/avm/ptn/ai-platform/baseline", out moduleStatus)) + .Returns(true); + + var (completionContext, sourceFile) = GetBicepCompletionContext("module test 'br/public:avm/ptn/ai-platform/|'"); + var moduleReferenceCompletionProvider = new ModuleReferenceCompletionProvider( + azureContainerRegistriesProvider, + catalog, + settingsProvider, + BicepTestConstants.CreateMockTelemetryProvider().Object, + BicepTestConstants.TestRegistryConfiguration, + avmDisplayNameProviderMock.Object); + var completions = await GetAndResolveCompletionItems(sourceFile, completionContext, moduleReferenceCompletionProvider); + + completions.Should().SatisfyRespectively( + x => + { + x.Label.Should().Be("avm/ptn/ai-platform/baseline"); + x.Detail.Should().BeNull(); + x.Documentation!.MarkupContent!.Value.Should().NotContain("**Display name:**"); + x.Documentation.MarkupContent.Value.Should().Contain("### AI platform baseline"); + x.Documentation.MarkupContent.Value.Should().Contain("**Full module path:** avm/ptn/ai-platform/baseline"); + x.Documentation.MarkupContent.Value.Should().Contain("**Status:** \uD83D\uDFE2 Available"); + x.Documentation.MarkupContent.Value.Should().Contain("**Description:** module description"); + }); + + avmDisplayNameProviderMock.VerifyAll(); + } + + [TestMethod] + public async Task GetFilteredCompletions_WithPublicAvmModulePathCompletions_ExcludesProposedModules() + { + var catalog = RegistryCatalogMocks.CreateCatalogWithMocks( + RegistryCatalogMocks.MockPublicMetadataProvider([ + new("bicep/avm/res/compute/virtual-machine", "Virtual machine", "contoso.com/available", []), + new("bicep/avm/res/compute/proposed-service", "Proposed service", "contoso.com/proposed", []), + ]) + ); + + var avmDisplayNameProviderMock = StrictMock.Of(); + string? availableModuleStatus = "Available"; + avmDisplayNameProviderMock + .Setup(provider => provider.TryGetModuleStatus("bicep/avm/res/compute/virtual-machine", out availableModuleStatus)) + .Returns(true); + string? proposedModuleStatus = "Proposed"; + avmDisplayNameProviderMock + .Setup(provider => provider.TryGetModuleStatus("bicep/avm/res/compute/proposed-service", out proposedModuleStatus)) + .Returns(true); + + var (completionContext, sourceFile) = GetBicepCompletionContext("module test 'br/public:avm/res/compute/|'"); + var moduleReferenceCompletionProvider = new ModuleReferenceCompletionProvider( + azureContainerRegistriesProvider, + catalog, + settingsProvider, + BicepTestConstants.CreateMockTelemetryProvider().Object, + BicepTestConstants.TestRegistryConfiguration, + avmDisplayNameProviderMock.Object); + var completions = await moduleReferenceCompletionProvider.GetFilteredCompletions(sourceFile, completionContext, CancellationToken.None); + + completions.Select(completion => completion.Label).Should().Equal("avm/res/compute/virtual-machine"); + + avmDisplayNameProviderMock.VerifyAll(); + } + + [TestMethod] + public async Task GetFilteredCompletions_WithPublicAvmModulePathCompletions_WhenModuleStatusIsUnavailable_IncludesModule() + { + var catalog = RegistryCatalogMocks.CreateCatalogWithMocks( + RegistryCatalogMocks.MockPublicMetadataProvider([ + new("bicep/avm/res/compute/virtual-machine", "Virtual machine", "contoso.com/help", []), + ]) + ); + + var avmDisplayNameProviderMock = StrictMock.Of(); + string? moduleStatus = null; + avmDisplayNameProviderMock + .Setup(provider => provider.TryGetModuleStatus("bicep/avm/res/compute/virtual-machine", out moduleStatus)) + .Returns(false); + + var (completionContext, sourceFile) = GetBicepCompletionContext("module test 'br/public:avm/res/compute/|'"); + var moduleReferenceCompletionProvider = new ModuleReferenceCompletionProvider( + azureContainerRegistriesProvider, + catalog, + settingsProvider, + BicepTestConstants.CreateMockTelemetryProvider().Object, + BicepTestConstants.TestRegistryConfiguration, + avmDisplayNameProviderMock.Object); + var completions = await moduleReferenceCompletionProvider.GetFilteredCompletions(sourceFile, completionContext, CancellationToken.None); + + completions.Select(completion => completion.Label).Should().Equal("avm/res/compute/virtual-machine"); + + avmDisplayNameProviderMock.VerifyAll(); + } + + [TestMethod] + public async Task GetFilteredCompletions_WithPublicAvmModulePathCompletions_WhenDisplayNameCacheIsEmpty_UsesCatalogDescriptionForTooltipTitle() + { + var catalog = RegistryCatalogMocks.CreateCatalogWithMocks( + RegistryCatalogMocks.MockPublicMetadataProvider([ + new("bicep/avm/ptn/ai-platform/baseline", "module description", "contoso.com/help", []), + ]) + ); + + var avmDisplayNameProviderMock = StrictMock.Of(); + string? moduleDisplayName = null; + avmDisplayNameProviderMock + .Setup(provider => provider.TryGetModuleDisplayName("bicep/avm/ptn/ai-platform/baseline", out moduleDisplayName)) + .Returns(false); + string? moduleStatus = null; + avmDisplayNameProviderMock + .Setup(provider => provider.TryGetModuleStatus("bicep/avm/ptn/ai-platform/baseline", out moduleStatus)) + .Returns(false); + + var (completionContext, sourceFile) = GetBicepCompletionContext("module test 'br/public:avm/ptn/ai-platform/|'"); + var moduleReferenceCompletionProvider = new ModuleReferenceCompletionProvider( + azureContainerRegistriesProvider, + catalog, + settingsProvider, + BicepTestConstants.CreateMockTelemetryProvider().Object, + BicepTestConstants.TestRegistryConfiguration, + avmDisplayNameProviderMock.Object); + var completions = await GetAndResolveCompletionItems(sourceFile, completionContext, moduleReferenceCompletionProvider); + + completions.Should().SatisfyRespectively( + completion => + { + completion.Label.Should().Be("avm/ptn/ai-platform/baseline"); + completion.Detail.Should().BeNull(); + completion.Documentation!.MarkupContent!.Value.Should().Contain("### module description"); + completion.Documentation.MarkupContent.Value.Should().Contain("**Full module path:** avm/ptn/ai-platform/baseline"); + completion.Documentation.MarkupContent.Value.Should().NotContain("**Status:**"); + completion.Documentation.MarkupContent.Value.Should().Contain("**Description:** module description"); + }); + + avmDisplayNameProviderMock.VerifyAll(); + } + [DataTestMethod] [DataRow("module test 'br:registry.contoso.io/bicep/|'", "bicep/whatever/abc/foo/bar", "'br:registry.contoso.io/bicep/whatever/abc/foo/bar:$0'")] [DataRow("module test 'br:registry.contoso.io/bicep/|", "bicep/whatever/abc/foo/bar", "'br:registry.contoso.io/bicep/whatever/abc/foo/bar:$0'")] @@ -609,8 +769,12 @@ public async Task GetFilteredCompletions_WithPrivateModulePathCompletions_Return { c.Label.Should().Be(expectedLabel); c.InsertTextFormat.Should().Be(InsertTextFormat.Snippet); - c.Detail.Should().Be("d1"); - c.Documentation!.MarkupContent!.Value.Should().Be("[View Documentation](contoso.com/help1)"); + c.Detail.Should().BeNull(); + c.Documentation!.MarkupContent!.Value.Should().NotContain("**Display name:**"); + c.Documentation.MarkupContent.Value.Should().Contain("### d1"); + c.Documentation.MarkupContent.Value.Should().Contain("**Full module path:** whatever/abc/foo/bar"); + c.Documentation.MarkupContent.Value.Should().Contain("**Description:** d1"); + c.Documentation.MarkupContent.Value.Should().Contain("[View Documentation](contoso.com/help1)"); c.TextEdit!.TextEdit!.NewText.Should().Be(expectedCompletionText); }); } @@ -720,7 +884,12 @@ public async Task GetFilteredCompletions_WithMcrVersionCompletionContext_Returns x.InsertText == null && x.SortText == expectedSortText1 && x.Detail == null && - x.Documentation == null && + !x.Documentation!.MarkupContent!.Value.Contains("**Display name:**") && + !x.Documentation.MarkupContent.Value.Contains("### ") && + x.Documentation.MarkupContent.Value.Contains("**Version:** 1.0.2") && + x.Documentation.MarkupContent.Value.Contains("**Full module path:** app/dapr-containerapp") && + x.Documentation.MarkupContent.Value.Contains("**Description:** N/A") && + x.Documentation.MarkupContent.Value.Contains("**Documentation:** N/A") && x.TextEdit!.TextEdit!.NewText == expectedCompletionText1 && x.TextEdit!.TextEdit!.Range.Start.Line == 0 && x.TextEdit!.TextEdit!.Range.Start.Character == 12 && @@ -732,8 +901,13 @@ public async Task GetFilteredCompletions_WithMcrVersionCompletionContext_Returns x.Kind == CompletionItemKind.Snippet && x.InsertText == null && x.SortText == expectedSortText2 && - x.Detail == "d2" && - x.Documentation!.MarkupContent!.Value == "[View Documentation](contoso.com/help%20page.html)" && + x.Detail == null && + !x.Documentation!.MarkupContent!.Value.Contains("**Display name:**") && + x.Documentation.MarkupContent.Value.Contains("### d2") && + x.Documentation.MarkupContent.Value.Contains("**Version:** 1.0.1") && + x.Documentation.MarkupContent.Value.Contains("**Full module path:** app/dapr-containerapp") && + x.Documentation.MarkupContent.Value.Contains("**Description:** d2") && + x.Documentation.MarkupContent.Value.Contains("[View Documentation](contoso.com/help%20page.html)") && x.TextEdit!.TextEdit!.NewText == expectedCompletionText2 && x.TextEdit!.TextEdit!.Range.Start.Line == 0 && x.TextEdit!.TextEdit!.Range.Start.Character == 12 && @@ -862,8 +1036,12 @@ public async Task GetFilteredCompletions_WithAliasForMCRInBicepConfigAndModulePa x.Label.Should().Be(expectedLabel); x.Kind.Should().Be(CompletionItemKind.Snippet); x.InsertText.Should().BeNull(); - x.Detail.Should().Be("dapr description"); - x.Documentation!.MarkupContent!.Value.Should().Be("[View Documentation](contoso.com/help)"); + x.Detail.Should().BeNull(); + x.Documentation!.MarkupContent!.Value.Should().NotContain("**Display name:**"); + x.Documentation.MarkupContent.Value.Should().Contain("### dapr description"); + x.Documentation.MarkupContent.Value.Should().Contain("**Full module path:** app/dapr-containerapp"); + x.Documentation.MarkupContent.Value.Should().Contain("**Description:** dapr description"); + x.Documentation.MarkupContent.Value.Should().Contain("[View Documentation](contoso.com/help)"); var actualTextEdit = x.TextEdit!.TextEdit; actualTextEdit.Should().NotBeNull(); diff --git a/src/Bicep.LangServer/Completions/AvmModuleDisplayNameProvider.cs b/src/Bicep.LangServer/Completions/AvmModuleDisplayNameProvider.cs new file mode 100644 index 00000000000..ef7866c4c64 --- /dev/null +++ b/src/Bicep.LangServer/Completions/AvmModuleDisplayNameProvider.cs @@ -0,0 +1,281 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using Bicep.Core.Features; +using Microsoft.VisualBasic.FileIO; + +namespace Bicep.LanguageServer.Completions +{ + public interface IAvmModuleDisplayNameProvider + { + void StartCache(); + + bool TryGetModuleDisplayName(string modulePath, [NotNullWhen(true)] out string? displayName); + + bool TryGetModuleStatus(string modulePath, [NotNullWhen(true)] out string? moduleStatus); + } + + public class AvmModuleDisplayNameProvider : IAvmModuleDisplayNameProvider + { + private static readonly Uri UtilityModulesCsvUri = new("https://aka.ms/avm/index/bicep/utl/csv"); + private static readonly Uri PatternModulesCsvUri = new("https://aka.ms/avm/index/bicep/ptn/csv"); + private static readonly Uri ResourceModulesCsvUri = new("https://aka.ms/avm/index/bicep/res/csv"); + + private static readonly AvmModuleInfoLookup EmptyLookup = new([], [], []); + + private readonly IAvmModuleCsvIndexHttpClient client; + private readonly object startLock = new(); + + private volatile AvmModuleInfoLookup lookup = EmptyLookup; + private Task? loadTask; + + public AvmModuleDisplayNameProvider(IAvmModuleCsvIndexHttpClient client) + { + this.client = client; + } + + public void StartCache() + { + lock (startLock) + { + loadTask ??= Task.Run(LoadAsync); + } + } + + public bool TryGetModuleDisplayName(string modulePath, [NotNullWhen(true)] out string? displayName) + { + displayName = null; + + if (TryGetModuleInfo(modulePath) is { } info) + { + displayName = info.DisplayName; + return true; + } + + return false; + } + + public bool TryGetModuleStatus(string modulePath, [NotNullWhen(true)] out string? moduleStatus) + { + moduleStatus = null; + + if (TryGetModuleInfo(modulePath) is { } info && info.ModuleStatus is not null) + { + moduleStatus = info.ModuleStatus; + return true; + } + + return false; + } + + private AvmModuleInfo? TryGetModuleInfo(string modulePath) + { + if (TryGetNormalizedModuleName(modulePath) is not { } normalizedModuleName) + { + return null; + } + + var currentLookup = lookup; + if (normalizedModuleName.StartsWith("avm/utl/", StringComparison.Ordinal)) + { + return currentLookup.Utility.TryGetValue(normalizedModuleName, out var info) ? info : null; + } + + if (normalizedModuleName.StartsWith("avm/ptn/", StringComparison.Ordinal)) + { + return currentLookup.Pattern.TryGetValue(normalizedModuleName, out var info) ? info : null; + } + + if (normalizedModuleName.StartsWith("avm/res/", StringComparison.Ordinal)) + { + return currentLookup.Resource.TryGetValue(normalizedModuleName, out var info) ? info : null; + } + + return null; + } + + private async Task LoadAsync() + { + try + { + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + + var utilityTask = LoadCsvAsync(UtilityModulesCsvUri, timeoutCts.Token); + var patternTask = LoadCsvAsync(PatternModulesCsvUri, timeoutCts.Token); + var resourceTask = LoadCsvAsync(ResourceModulesCsvUri, timeoutCts.Token); + + await Task.WhenAll(utilityTask, patternTask, resourceTask); + + var utility = await utilityTask; + var pattern = await patternTask; + var resource = await resourceTask; + + lookup = new AvmModuleInfoLookup( + Utility: utility, + Pattern: pattern, + Resource: resource); + + Trace.WriteLineIf( + FeatureProvider.TracingEnabled, + string.Format( + "{0}: AVM display-name cache loaded (utility={1}, pattern={2}, resource={3}).", + nameof(AvmModuleDisplayNameProvider), + utility.Count, + pattern.Count, + resource.Count)); + } + catch (Exception exception) + { + lookup = EmptyLookup; + + Trace.WriteLineIf( + FeatureProvider.TracingEnabled, + string.Format( + "{0}: Failed to preload AVM display-name cache. Falling back to empty cache. Error: {1}", + nameof(AvmModuleDisplayNameProvider), + exception.Message)); + } + } + + private async Task> LoadCsvAsync(Uri csvUri, CancellationToken cancellationToken) + { + var csvContent = await client.GetCsvAsync(csvUri, cancellationToken); + using var reader = new StringReader(csvContent); + using var parser = new TextFieldParser(reader) + { + TextFieldType = FieldType.Delimited, + HasFieldsEnclosedInQuotes = true, + TrimWhiteSpace = false, + }; + + parser.SetDelimiters(","); + + if (parser.EndOfData) + { + return []; + } + + var headers = parser.ReadFields(); + if (headers is null) + { + return []; + } + + var moduleNameIndex = Array.FindIndex(headers, x => string.Equals(x, "ModuleName", StringComparison.Ordinal)); + var moduleDisplayNameIndex = Array.FindIndex(headers, x => string.Equals(x, "ModuleDisplayName", StringComparison.Ordinal)); + var moduleStatusIndex = Array.FindIndex(headers, x => string.Equals(x, "ModuleStatus", StringComparison.Ordinal)); + if (moduleNameIndex == -1 || moduleDisplayNameIndex == -1) + { + return []; + } + + var entries = new Dictionary(StringComparer.Ordinal); + while (!parser.EndOfData) + { + var fields = parser.ReadFields(); + if (fields is null || + moduleNameIndex >= fields.Length || + moduleDisplayNameIndex >= fields.Length) + { + continue; + } + + if (TryGetNormalizedModuleName(fields[moduleNameIndex]) is not { } normalizedModuleName) + { + continue; + } + + var moduleDisplayName = fields[moduleDisplayNameIndex]?.Trim(); + if (string.IsNullOrWhiteSpace(moduleDisplayName)) + { + continue; + } + + string? moduleStatus = null; + if (moduleStatusIndex != -1 && moduleStatusIndex < fields.Length) + { + var statusValue = fields[moduleStatusIndex]?.Trim(); + if (!string.IsNullOrWhiteSpace(statusValue)) + { + moduleStatus = statusValue; + } + } + + entries.TryAdd(normalizedModuleName, new AvmModuleInfo(moduleDisplayName, moduleStatus)); + } + + return entries.ToImmutableDictionary(StringComparer.Ordinal); + } + + private static string? TryGetNormalizedModuleName(string? modulePath) + { + if (string.IsNullOrWhiteSpace(modulePath)) + { + return null; + } + + var normalized = modulePath.Trim().Replace('\\', '/'); + if (normalized.StartsWith("bicep/", StringComparison.Ordinal)) + { + normalized = normalized["bicep/".Length..]; + } + + return normalized; + } + + private sealed record AvmModuleInfo(string DisplayName, string? ModuleStatus); + + private sealed record AvmModuleInfoLookup( + ImmutableDictionary Utility, + ImmutableDictionary Pattern, + ImmutableDictionary Resource); + } + + public interface IAvmModuleCsvIndexHttpClient + { + Task GetCsvAsync(Uri csvUri, CancellationToken cancellationToken); + } + + public class AvmModuleCsvIndexHttpClient : IAvmModuleCsvIndexHttpClient + { + private readonly HttpClient client; + + public AvmModuleCsvIndexHttpClient(HttpClient client) + { + this.client = client; + } + + public async Task GetCsvAsync(Uri csvUri, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, csvUri); + using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + + return await response.Content.ReadAsStringAsync(cancellationToken); + } + } + + public class NullAvmModuleDisplayNameProvider : IAvmModuleDisplayNameProvider + { + public static readonly NullAvmModuleDisplayNameProvider Instance = new(); + + public void StartCache() + { + } + + public bool TryGetModuleDisplayName(string modulePath, [NotNullWhen(true)] out string? displayName) + { + displayName = null; + return false; + } + + public bool TryGetModuleStatus(string modulePath, [NotNullWhen(true)] out string? moduleStatus) + { + moduleStatus = null; + return false; + } + } +} diff --git a/src/Bicep.LangServer/Completions/ModuleReferenceCompletionProvider.cs b/src/Bicep.LangServer/Completions/ModuleReferenceCompletionProvider.cs index 525fa4258f7..b160b0bda80 100644 --- a/src/Bicep.LangServer/Completions/ModuleReferenceCompletionProvider.cs +++ b/src/Bicep.LangServer/Completions/ModuleReferenceCompletionProvider.cs @@ -39,6 +39,7 @@ public partial class ModuleReferenceCompletionProvider : IModuleReferenceComplet private readonly ISettingsProvider settingsProvider; private readonly ITelemetryProvider telemetryProvider; private readonly RegistryConfiguration registryConfiguration; + private readonly IAvmModuleDisplayNameProvider avmModuleDisplayNameProvider; private enum ModuleCompletionPriority { @@ -146,13 +147,15 @@ public ModuleReferenceCompletionProvider( IRegistryModuleCatalog registryModuleCatalog, ISettingsProvider settingsProvider, ITelemetryProvider telemetryProvider, - RegistryConfiguration registryConfiguration) + RegistryConfiguration registryConfiguration, + IAvmModuleDisplayNameProvider? avmModuleDisplayNameProvider = null) { this.azureContainerRegistriesProvider = azureContainerRegistriesProvider; this.registryModuleCatalog = registryModuleCatalog; this.settingsProvider = settingsProvider; this.telemetryProvider = telemetryProvider; this.registryConfiguration = registryConfiguration; + this.avmModuleDisplayNameProvider = avmModuleDisplayNameProvider ?? NullAvmModuleDisplayNameProvider.Instance; } public async Task> GetFilteredCompletions(BicepSourceFile sourceFile, BicepCompletionContext context, CancellationToken cancellationToken) @@ -495,6 +498,13 @@ private async Task> GetModuleCompletions(string trim continue; } + if (parts.ResolvedRegistry.Equals(LanguageConstants.BicepPublicMcrRegistry, StringComparison.Ordinal) + && avmModuleDisplayNameProvider.TryGetModuleStatus(moduleName, out var moduleStatus) + && moduleStatus.Equals("Proposed", StringComparison.Ordinal)) + { + continue; + } + string insertText = $"'{parts.WithModulePath(moduleName).ToNotation()}:$0'"; // Remove the base path prefix from the label if we're dealing with a module alias @@ -668,11 +678,11 @@ private async Task ResolveVersionCompletionItem(CompletionItem c telemetryProvider.PostEvent(BicepTelemetryEvent.ModuleRegistryResolution(ModuleRegistryResolutionType.AcrVersion)); } - return (completionItem with - { - Detail = metadata.Details.Description, - }) - .WithDocumentation(MarkdownHelper.GetDocumentationLink(metadata.Details.DocumentationUri)); + var title = GetCompletionTitle(registry, modulePath, metadata.Details.Description); + var status = GetModuleStatus(registry, modulePath); + + return completionItem + .WithDocumentation(GetCompletionDocumentation(title, modulePath, metadata.Details, status, version)); } return completionItem; @@ -691,10 +701,11 @@ private async Task ResolveModuleCompletionItem(CompletionItem co telemetryProvider.PostEvent(BicepTelemetryEvent.ModuleRegistryResolution(ModuleRegistryResolutionType.AcrModulePath)); } - return (completionItem with - { - Detail = details.Description, - }).WithDocumentation(MarkdownHelper.GetDocumentationLink(details.DocumentationUri)); + var title = GetCompletionTitle(registry, modulePath, details.Description); + var status = GetModuleStatus(registry, modulePath); + + return completionItem + .WithDocumentation(GetCompletionDocumentation(title, modulePath, details, status)); } return completionItem; @@ -738,5 +749,84 @@ private static string GetSortText(string label, ModuleCompletionPriority priorit // We want all module completion priorities to come after other completions (e.g. local module paths), so we start with "9" return $"9{(int)priority}_{label}"; } + + private string? GetCompletionTitle(string registry, string modulePath, string? defaultTitle) + { + if (registry.Equals(LanguageConstants.BicepPublicMcrRegistry, StringComparison.Ordinal) + && avmModuleDisplayNameProvider.TryGetModuleDisplayName(modulePath, out var moduleDisplayName)) + { + return moduleDisplayName; + } + + return defaultTitle; + } + + private string? GetModuleStatus(string registry, string modulePath) + { + if (registry.Equals(LanguageConstants.BicepPublicMcrRegistry, StringComparison.Ordinal) + && avmModuleDisplayNameProvider.TryGetModuleStatus(modulePath, out var moduleStatus)) + { + return moduleStatus; + } + + return null; + } + + private static string GetCompletionDocumentation(string? title, string modulePath, RegistryMetadataDetails details, string? status = null, string? version = null) + { + var displayModulePath = GetDisplayModulePath(modulePath); + + var sections = new List(); + + if (title is not null) + { + sections.Add($"### {title}"); + } + + if (version is not null) + { + sections.Add($"**Version:** {version}"); + } + + sections.Add($"**Full module path:** {displayModulePath}"); + + if (status is not null) + { + var emoji = status switch + { + "Proposed" => "⚪", + "Available" => "🟢", + "Orphaned" => "🟡", + "Deprecated" => "🔴", + _ => null, + }; + + sections.Add(emoji is not null + ? $"**Status:** {emoji} {status}" + : $"**Status:** {status}"); + } + + sections.Add($"**Description:** {details.Description ?? "N/A"}"); + + if (MarkdownHelper.GetDocumentationLink(details.DocumentationUri) is { } docLink) + { + sections.Add(docLink); + } + else + { + sections.Add("**Documentation:** N/A"); + } + + return MarkdownHelper.JoinWithNewlines(sections); + } + + private static string GetDisplayModulePath(string modulePath) + { + const string publicRegistryPrefix = LanguageConstants.BicepPublicMcrPathPrefix; + + return modulePath.StartsWith(publicRegistryPrefix, StringComparison.Ordinal) + ? modulePath[publicRegistryPrefix.Length..] + : modulePath; + } } } diff --git a/src/Bicep.LangServer/IServiceCollectionExtensions.cs b/src/Bicep.LangServer/IServiceCollectionExtensions.cs index f73cd521706..2ae9214a217 100644 --- a/src/Bicep.LangServer/IServiceCollectionExtensions.cs +++ b/src/Bicep.LangServer/IServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System.IO.Abstractions; +using System.Net; using Bicep.Core; using Bicep.Core.Analyzers.Interfaces; using Bicep.Core.Analyzers.Linter; @@ -58,9 +59,23 @@ BicepLangServerOptions bicepLangServerOptions .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton(bicepLangServerOptions) - .AddSingleton(); + .AddSingleton() + .AddAvmModuleDisplayNameServices(); + + private static IServiceCollection AddAvmModuleDisplayNameServices(this IServiceCollection services) + { + services + .AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, + }); + + return services; + } } diff --git a/src/Bicep.LangServer/Server.cs b/src/Bicep.LangServer/Server.cs index 4bb26e13f74..aa95053bb00 100644 --- a/src/Bicep.LangServer/Server.cs +++ b/src/Bicep.LangServer/Server.cs @@ -9,6 +9,7 @@ using Bicep.Core.Tracing; using Bicep.Core.Utils; using Bicep.LanguageServer.Features.Custom.Visualization; +using Bicep.LanguageServer.Completions; using Bicep.LanguageServer.Handlers; using Bicep.LanguageServer.Options; using Bicep.LanguageServer.Providers; @@ -97,6 +98,9 @@ public async Task RunAsync(CancellationToken cancellationToken) var scheduler = server.GetRequiredService(); scheduler.Start(); + var avmDisplayNameProvider = server.GetRequiredService(); + avmDisplayNameProvider.StartCache(); + #pragma warning disable VSTHRD003 // Avoid awaiting foreign Tasks await server.WaitForExit; #pragma warning restore VSTHRD003 // Avoid awaiting foreign Tasks