Skip to content

Commit

Permalink
Recognize common keywords for resource type completions
Browse files Browse the repository at this point in the history
  • Loading branch information
Stephen Weatherford authored and StephenWeatherford committed Mar 26, 2024
1 parent 88b03d4 commit 8ff24d3
Show file tree
Hide file tree
Showing 6 changed files with 230 additions and 4 deletions.
95 changes: 95 additions & 0 deletions src/Bicep.LangServer.IntegrationTests/CompletionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2137,6 +2137,101 @@ await RunCompletionScenarioTest(
'|');
}

[TestMethod]
public async Task Resource_type_completions_for_MicrosoftApp_and_subtypes_use_common_keywords_in_filter()
{
var fileWithCursors = """
resource r 'appservice|'
""";

await RunCompletionScenarioTest(TestContext, ServerWithNamespaceProvider, fileWithCursors, completionLists =>
{
var completionList = completionLists.Should().HaveCount(1).And.Subject.First();

var microsoftApp = completionList.Where(completion => completion.Label.StartsWith("'microsoft.app/", StringComparison.InvariantCultureIgnoreCase)).ToArray();
foreach (var completion in microsoftApp)
{
completion.FilterText.Should().NotBeNull();
completion.FilterText.Should().NotBeUpperCased();
var filters = completion.FilterText!.Trim('\'').Split(',');

filters.Where(x => x.StartsWith("microsoft.app")).Should().HaveCount(1);
filters.Should().Contain("containerapp");
}

foreach (var completion in completionList.Except(microsoftApp))
{
if (completion.FilterText is string filterText)
{
filterText.Should().NotBeUpperCased();
var filters = filterText.Trim('\'').Split(',');

filters.Where(x => x.StartsWith("microsoft.app")).Should().HaveCount(0);
filters.Should().NotContain("containerapp");
}
}
});
}

[TestMethod]
public async Task Resource_type_completions_for_MicrosoftWebServerFarms_use_common_keywords_in_filter()
{
var fileWithCursors = """
resource r 'appservice|'
""";

await RunCompletionScenarioTest(TestContext, ServerWithNamespaceProvider, fileWithCursors, completionLists =>
{
var completionList = completionLists.Should().HaveCount(1).And.Subject.First();

// Everything under Microsoft.Web/serverFarms should have keywords "appservice", "webapp", "function" in filter text
var serverFarms = completionList.Where(completion => completion.Label.StartsWith("'microsoft.web/serverfarms", StringComparison.InvariantCultureIgnoreCase)).ToArray();
foreach (var completion in serverFarms)
{
completion.FilterText.Should().NotBeNull();
completion.FilterText.Should().NotBeUpperCased();
var filters = completion.FilterText!.Trim('\'').Split(',');

filters.Where(x => x.StartsWith("microsoft.web/serverfarms")).Should().HaveCount(1);
filters.Should().Contain("appserviceplan");
filters.Should().Contain("asp");
filters.Should().Contain("hostingplan");
}

// Everything else under Microsoft.Web other than serverFarms should not have these keywords
var webButNotServerFarms = completionList.Where(completion => completion.Label.StartsWith("'microsoft.web", StringComparison.InvariantCultureIgnoreCase)
&& !completion.Label.Contains("serverfarms", StringComparison.InvariantCultureIgnoreCase)).ToArray();
foreach (var completion in webButNotServerFarms)
{
if (completion.FilterText is string filterText)
{
filterText.Should().NotBeUpperCased();
var filters = filterText.Trim('\'').Split(',');

filters.Where(x => x.StartsWith("microsoft.web/serverfarms")).Should().HaveCount(0);
filters.Should().NotContain("appserviceplan");
filters.Should().NotContain("asp");
filters.Should().NotContain("hostingplan");
}
}

// Everything not under Microsoft.Web should not have these keywords
foreach (var completion in completionList.Except(serverFarms).Except(webButNotServerFarms))
{
if (completion.FilterText is string filterText)
{
filterText.Should().NotBeUpperCased();
var filters = filterText.Trim('\'').Split(',');

filters.Where(x => x.StartsWith("microsoft.web/serverfarms")).Should().HaveCount(0);
filters.Should().NotContain("appserviceplan");
filters.Should().NotContain("asp");
filters.Should().NotContain("hostingplan");
}
}
});
}

[TestMethod]
public async Task Known_list_functions_are_offered()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@

namespace Bicep.LangServer.UnitTests
{
// See also Bicep.LangServer.IntegrationTests/CompletionTests.cs4

[TestClass]
public class BicepCompletionProviderTests
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Bicep.Core.Resources;
using Bicep.LanguageServer.Completions;
using FluentAssertions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using OmniSharp.Extensions.LanguageServer.Protocol.Models;

namespace Bicep.LangServer.UnitTests.Completions
{
[TestClass]
public class ResourceTypeSearchKeywordsTests
{
[DataRow("Microsoft.Web/sites", "'appservice,webapp,function,microsoft.web/sites'")]
[DataRow("microsoft.web/sites", "'appservice,webapp,function,microsoft.web/sites'")]
[DataRow("microsoft.app/abc", "'containerapp,microsoft.app/abc'")]
[DataRow("Microsoft.ContainerService/managedClusters", "'aks,kubernetes,k8s,cluster,microsoft.containerservice/managedclusters'")]
[DataRow("toplevel1", "'top level keyword,toplevel1'")]
[DataRow("toplevel1/secondlevel1", "'top level keyword,toplevel1/secondlevel1'")]
[DataRow("toplevel1/secondlevel2", "'top level keyword,toplevel1/secondlevel2'")]
[DataRow("toplevel1/secondlevel1/thirdlevel", "'top level keyword,toplevel1/secondlevel1/thirdlevel'")]
[DataRow("toplevel2", null)]
[DataRow("toplevel2/secondlevel1", "'second level keyword,toplevel2/secondlevel1'")]
[DataRow("toplevel2/secondlevel2", null)]
[DataRow("toplevel2/secondlevel2/thirdlevel", null)]
[DataRow("toplevel2/secondlevel1/thirdlevel", "'second level keyword,toplevel2/secondlevel1/thirdlevel'")]

[DataTestMethod]
public void TryGetResourceTypeFilterText(string resourceType, string? expectedFilter)
{
var sut = new ResourceTypeSearchKeywords(new Dictionary<string, string[]>
{
["Microsoft.Web/sites"] = ["appservice", "webapp", "function"],
["Microsoft.Web/serverFarms"] = ["asp", "appserviceplan", "hostingplan"],
["MICROSOFT.APP"] = ["containerapp"],
["Microsoft.ContainerService"] = ["aks", "kubernetes", "k8s", "cluster"],
["Microsoft.Authorization/roleAssignments"] = ["rbac"],
["toplevel1"] = ["top level keyword"],
["toplevel2/secondlevel1"] = ["second level keyword"],
});

var result = sut.TryGetResourceTypeFilterText(new ResourceTypeReference(resourceType, "2020-06-01"));

result.Should().Be(expectedFilter);
}
}
}
7 changes: 4 additions & 3 deletions src/Bicep.LangServer/Completions/BicepCompletionProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@ public class BicepCompletionProvider : ICompletionProvider
private static readonly Regex ModuleRegistryWithoutAliasPattern = new(@"'br:(.*?):?'?$", RegexOptions.Compiled | RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase);
private static readonly Regex ModuleRegistryWithAliasPattern = new(@"'br/(.*?):(.*?):?'?$", RegexOptions.Compiled | RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase);

private static readonly ResourceTypeSearchKeywords ResourceTypeSearchKeywords = new();

private readonly IFileResolver FileResolver;
private readonly ISnippetsProvider SnippetsProvider;
public readonly IModuleReferenceCompletionProvider moduleReferenceCompletionProvider;
private readonly ISnippetsProvider SnippetsProvider;

public BicepCompletionProvider(IFileResolver fileResolver, ISnippetsProvider snippetsProvider, IModuleReferenceCompletionProvider moduleReferenceCompletionProvider)
{
Expand Down Expand Up @@ -1865,8 +1867,6 @@ private static CompletionItem CreateResourceTypeCompletion(ResourceTypeReference
{
var insertText = StringUtils.EscapeBicepString(resourceType.Name);
return CompletionItemBuilder.Create(CompletionItemKind.Class, resourceType.ApiVersion)
// Lower-case all resource types in filter text otherwise editor may prefer those with casing that match what the user has already typed (#9168)
.WithFilterText(insertText.ToLowerInvariant())
.WithPlainTextEdit(replacementRange, insertText)
.WithDocumentation(
MarkdownHelper.AppendNewline($"Type: `{resourceType.Type}`") +
Expand All @@ -1883,6 +1883,7 @@ private static CompletionItem CreateResourceTypeCompletion(ResourceTypeReference
MarkdownHelper.AppendNewline($"Type: `{resourceType.Type}`"))
.WithFollowupCompletion("resource type completion")
.WithSortText(index.ToString("x8"))
.WithFilterText(ResourceTypeSearchKeywords.TryGetResourceTypeFilterText(resourceType))
.Build();
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/Bicep.LangServer/Completions/CompletionItemBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ public CompletionItemBuilder WithDocumentation(string? markdown)
return this;
}

public CompletionItemBuilder WithFilterText(string filterText)
public CompletionItemBuilder WithFilterText(string? filterText)
{
this.filterText = filterText;
return this;
Expand Down
74 changes: 74 additions & 0 deletions src/Bicep.LangServer/Completions/ResourceTypeSearchKeywords.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Bicep.Core.Parsing;
using Bicep.Core.Resources;

namespace Bicep.LanguageServer.Completions
{
public class ResourceTypeSearchKeywords
{
private ImmutableDictionary<string, string[]> keywordsMap;

public ResourceTypeSearchKeywords() : this
(new Dictionary<string, string[]>()
{
// Keys must be in the form 'xxx' or 'xxx/yyy' - 'xxx' matches 'xxx' and 'xxx/*', 'xxx/yyy' matches 'xxx/yyy' and 'xxx/yyy/*'
// Key casing doesn't matter
["Microsoft.Web/sites"] = ["appservice", "webapp", "function"],
["Microsoft.Web/serverFarms"] = ["asp", "appserviceplan", "hostingplan"],
["Microsoft.App"] = ["containerapp"],
["Microsoft.ContainerService"] = ["aks", "kubernetes", "k8s", "cluster"],
["Microsoft.Authorization/roleAssignments"] = ["rbac"],
})
{
}

public ResourceTypeSearchKeywords(IDictionary<string, string[]> keywordsMap)
{
this.keywordsMap = keywordsMap.ToImmutableDictionary(x => x.Key.ToLowerInvariant(), x => x.Value, StringComparer.InvariantCultureIgnoreCase);

// Validate
foreach ((var key, var keywords) in keywordsMap)
{
if (key.Split('/').Length > 2)
{
throw new ArgumentException($"Keys in {nameof(keywordsMap)} can have at most one slash: {key}");
}
}
}

public string? TryGetResourceTypeFilterText(ResourceTypeReference resourceType)
{
// Lower-case all resource types in filter text otherwise editor may prefer those with casing that match what the user has already typed (#9168), thus missorting API versions
var filter = resourceType.Type.ToLowerInvariant();
string[]? keywords;

// We want to search using the top-level resource type, including subtypes
// Microsoft.web/serverFarms/xxx -> top-level key is Microsoft.web
// Microsoft.web/serverFarms/xxx -> second-level key is Microsoft.web/serverFarms
var indexFirstSlash = filter.IndexOf('/');
var topLevelKey = indexFirstSlash > 0 ? filter[0..indexFirstSlash] : filter;
if (!keywordsMap.TryGetValue(topLevelKey, out keywords))
{
if (indexFirstSlash > 0)
{
var indexSecondSlash = filter.IndexOf('/', indexFirstSlash + 1);
var secondLevelKey = indexSecondSlash > 0 ? filter[0..indexSecondSlash] : filter;
keywordsMap.TryGetValue(secondLevelKey, out keywords);
}
}

// The filter text, like the insertText, must include the single quotes that surround the resource type in the resource declaration
return keywords is string[]?
StringUtils.EscapeBicepString($"{string.Join(',', keywords)},{filter}") :
null; // null - let vscode use the default (label)
}
}
}

0 comments on commit 8ff24d3

Please sign in to comment.