From 6f380fe78a2a4faaf354e3cb82864a1618f92a79 Mon Sep 17 00:00:00 2001 From: Anthony Martin <38542602+anthony-c-martin@users.noreply.github.com> Date: Wed, 30 Nov 2022 19:03:55 -0500 Subject: [PATCH] Fix for partially-typed resource type completions (#9158) --- .../CompletionTests.cs | 56 +++++++++++++++++++ .../Helpers/ServerRequestHelper.cs | 2 + .../Completions/BicepCompletionContext.cs | 2 +- .../Completions/BicepCompletionProvider.cs | 49 ++++++++++++++-- 4 files changed, 103 insertions(+), 6 deletions(-) diff --git a/src/Bicep.LangServer.IntegrationTests/CompletionTests.cs b/src/Bicep.LangServer.IntegrationTests/CompletionTests.cs index abd49314daf..e20d5526109 100644 --- a/src/Bicep.LangServer.IntegrationTests/CompletionTests.cs +++ b/src/Bicep.LangServer.IntegrationTests/CompletionTests.cs @@ -678,6 +678,62 @@ await RunCompletionScenarioTest(this.TestContext, '|'); } + [TestMethod] + public async Task Partial_identifier_resource_type_completions_work() + { + { + var fileWithCursors = @" +resource myRes Te|st +"; + + var (text, cursor) = ParserHelper.GetFileWithSingleCursor(fileWithCursors, '|'); + var file = await new ServerRequestHelper(TestContext, ServerWithBuiltInTypes).OpenFile(text); + + var completions = await file.RequestCompletion(cursor); + + var updatedFile = file.ApplyCompletion(completions, "'Test.Rp/basicTests'"); + updatedFile.Should().HaveSourceText(@" +resource myRes 'Test.Rp/basicTests@|' +"); + } + } + + [TestMethod] + public async Task Partial_string_resource_type_completions_work() + { + { + var fileWithCursors = @" +resource myRes 'Test.Rp/ba|si +"; + + var (text, cursor) = ParserHelper.GetFileWithSingleCursor(fileWithCursors, '|'); + var file = await new ServerRequestHelper(TestContext, ServerWithBuiltInTypes).OpenFile(text); + + var completions = await file.RequestCompletion(cursor); + + var updatedFile = file.ApplyCompletion(completions, "'Test.Rp/basicTests'"); + updatedFile.Should().HaveSourceText(@" +resource myRes 'Test.Rp/basicTests@|' +"); + } + + { + var fileWithCursors = @" +resource myRes 'Test.Rp/basicTests@| +"; + + var (text, cursor) = ParserHelper.GetFileWithSingleCursor(fileWithCursors, '|'); + var file = await new ServerRequestHelper(TestContext, ServerWithBuiltInTypes).OpenFile(text); + + var completions = await file.RequestCompletion(cursor); + + var updatedFile = file.ApplyCompletion(completions, "2020-01-01"); + updatedFile.Should().HaveSourceText(@" +resource myRes 'Test.Rp/basicTests@2020-01-01'| +"); + } + } + [TestMethod] public async Task ResourceTypeFollowerWithoCompletionsOffersEqualsAndExisting() { diff --git a/src/Bicep.LangServer.IntegrationTests/Helpers/ServerRequestHelper.cs b/src/Bicep.LangServer.IntegrationTests/Helpers/ServerRequestHelper.cs index ae169510416..e529e954b06 100644 --- a/src/Bicep.LangServer.IntegrationTests/Helpers/ServerRequestHelper.cs +++ b/src/Bicep.LangServer.IntegrationTests/Helpers/ServerRequestHelper.cs @@ -55,6 +55,8 @@ public async Task RequestCompletion(int cursor) public BicepFile ApplyCompletion(CompletionList completions, string label, params string[] tabStops) { + // Should().Contain is superfluous here, but it gives a better assertion message when it fails + completions.Should().Contain(x => x.Label == label); completions.Should().ContainSingle(x => x.Label == label); return ApplyCompletion(completions.Single(x => x.Label == label), tabStops); diff --git a/src/Bicep.LangServer/Completions/BicepCompletionContext.cs b/src/Bicep.LangServer/Completions/BicepCompletionContext.cs index b2b15405d09..8551952eecf 100644 --- a/src/Bicep.LangServer/Completions/BicepCompletionContext.cs +++ b/src/Bicep.LangServer/Completions/BicepCompletionContext.cs @@ -356,7 +356,7 @@ output.Type is ResourceTypeSyntax type && if (SyntaxMatcher.IsTailMatch(matchingNodes, resource => CheckTypeIsExpected(resource.Name, resource.Type)) || SyntaxMatcher.IsTailMatch(matchingNodes, (_, _, token) => token.Type == TokenType.StringComplete) || - SyntaxMatcher.IsTailMatch(matchingNodes, (resource, skipped, token) => resource.Type == skipped && token.Type == TokenType.Identifier)) + SyntaxMatcher.IsTailMatch(matchingNodes, (resource, skipped, token) => resource.Type == skipped)) { // the most specific matching node is a resource declaration // the declaration syntax is "resource '' ..." diff --git a/src/Bicep.LangServer/Completions/BicepCompletionProvider.cs b/src/Bicep.LangServer/Completions/BicepCompletionProvider.cs index 7d75e57750d..f1f299cf2b5 100644 --- a/src/Bicep.LangServer/Completions/BicepCompletionProvider.cs +++ b/src/Bicep.LangServer/Completions/BicepCompletionProvider.cs @@ -360,6 +360,43 @@ private static bool IsTypeLiteralSyntax(SyntaxBase syntax) => syntax is BooleanL null => null, }; + private static string? TryGetSkippedTokenText(SkippedTriviaSyntax skippedTrivia) + { + // This method attempts to obtain text from a skipped token - in cases where the user has partially-typed syntax + // but may be looking for completions. + if (skippedTrivia.Elements.Length != 1 || + skippedTrivia.Elements[0] is not Token token) + { + return null; + } + + switch (token.Type) + { + case TokenType.Identifier: + return token.Text; + + case TokenType.StringComplete: + if (!token.Text.EndsWith("'", StringComparison.Ordinal)) + { + // An unterminated string will result in skipped trivia containing an unterminated token. + // Compensate here by building the expected token before lexing it. + token = SyntaxFactory.CreateToken(token.Type, $"{token.Text}'"); + } + + return Lexer.TryGetStringValue(token); + + default: + return null; + } + } + + private static string? TryGetEnteredTextFromStringOrSkipped(SyntaxBase syntax) + => syntax switch { + StringSyntax s => s.TryGetLiteralValue(), + SkippedTriviaSyntax s => TryGetSkippedTokenText(s), + _ => null, + }; + private IEnumerable GetResourceTypeCompletions(SemanticModel model, BicepCompletionContext context) { if (!context.Kind.HasFlag(BicepCompletionContextKind.ResourceType)) @@ -398,9 +435,11 @@ private IEnumerable GetResourceTypeCompletions(SemanticModel mod return items; } - static string? TryGetFullyQualifiedType(StringSyntax? stringSyntax) + static string? TryGetFullyQualifiedType(SyntaxBase? syntax) { - if (stringSyntax?.TryGetLiteralValue() is string entered && ResourceTypeReference.HasResourceTypePrefix(entered)) + if (syntax is not null && + TryGetEnteredTextFromStringOrSkipped(syntax) is {} entered && + ResourceTypeReference.HasResourceTypePrefix(entered)) { return entered; } @@ -412,9 +451,9 @@ private IEnumerable GetResourceTypeCompletions(SemanticModel mod { return enclosingDeclaration switch { - ResourceDeclarationSyntax resourceSyntax => TryGetFullyQualifiedType(resourceSyntax.TypeString), - ParameterDeclarationSyntax parameterSyntax when parameterSyntax.Type is ResourceTypeSyntax resourceType => TryGetFullyQualifiedType(resourceType.TypeString), - OutputDeclarationSyntax outputSyntax when outputSyntax.Type is ResourceTypeSyntax resourceType => TryGetFullyQualifiedType(resourceType.TypeString), + ResourceDeclarationSyntax resourceSyntax => TryGetFullyQualifiedType(resourceSyntax.Type), + ParameterDeclarationSyntax parameterSyntax when parameterSyntax.Type is ResourceTypeSyntax resourceType => TryGetFullyQualifiedType(resourceType.Type), + OutputDeclarationSyntax outputSyntax when outputSyntax.Type is ResourceTypeSyntax resourceType => TryGetFullyQualifiedType(resourceType.Type), _ => null, }; }