Skip to content

Commit

Permalink
Add support for LS completions
Browse files Browse the repository at this point in the history
  • Loading branch information
jeskew committed Jul 19, 2023
1 parent fc3a66a commit cc2c513
Show file tree
Hide file tree
Showing 5 changed files with 209 additions and 10 deletions.
7 changes: 4 additions & 3 deletions src/Bicep.Core/Parsing/Parser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -304,9 +304,10 @@ TokenType.NewLine or
_ => Skip(reader.Read(), b => b.ExpectedSymbolListOrWildcard()),
};

var fromClause = CompileTimeImportFromClause();

return new(leadingNodes, keyword, importExpression, fromClause);
return new(leadingNodes,
keyword,
importExpression,
WithRecovery(CompileTimeImportFromClause, GetSuppressionFlag(keyword), TokenType.NewLine));
}

private ImportedSymbolsListSyntax ImportedSymbolsList()
Expand Down
90 changes: 90 additions & 0 deletions src/Bicep.LangServer.IntegrationTests/CompletionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4016,5 +4016,95 @@ public async Task GenericArray_Multiline_HasCompletions()
completions.Should().Contain(c => c.Label == "if-else");
}
}

[TestMethod]
public async Task Compile_time_imports_offer_target_path_completions()
{
var mainContent = """
import * as foo from |
""";

var (text, cursors) = ParserHelper.GetFileWithCursors(mainContent, '|');
Uri mainUri = new Uri("file:///path/to/main.bicep");
var files = new Dictionary<Uri, string>
{
[new Uri("file:///path/to/mod.bicep")] = "",
[new Uri("file:///path/to/mod2.bicep")] = "",
[new Uri("file:///path/to/mod2.json")] = @"{ ""schema"": ""https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#"" }",
[mainUri] = text
};

var bicepFile = SourceFileFactory.CreateBicepFile(mainUri, text);
using var helper = await LanguageServerHelper.StartServerWithText(
this.TestContext,
files,
bicepFile.FileUri,
services => services.WithFeatureOverrides(new(CompileTimeImportsEnabled: true)));

var file = new FileRequestHelper(helper.Client, bicepFile);
var completions = await file.RequestCompletion(cursors[0]);

completions.Should().Contain(c => c.Label == "C:/path/to/mod.bicep");
completions.Should().Contain(c => c.Label == "C:/path/to/mod2.bicep");
completions.Should().Contain(c => c.Label == "C:/path/to/mod2.json");
completions.Should().Contain(c => c.Label == "br/");
completions.Should().Contain(c => c.Label == "br:");
completions.Should().Contain(c => c.Label == "ts:");
}

[TestMethod]
public async Task Compile_time_imports_offer_imported_symbol_completions()
{
var modContent = """
@export()
type foo = string

@export()
type bar = int
""";

var mod2Content = """
@export()
type fizz = string

@export()
type buzz = int
""";

var mainContent = """
import {|} from 'mod.bicep'
import {|} from 'mod2.bicep'
""";

var (text, cursors) = ParserHelper.GetFileWithCursors(mainContent, '|');
Uri mainUri = new Uri("file:///main.bicep");
var files = new Dictionary<Uri, string>
{
[new Uri("file:///mod.bicep")] = modContent,
[new Uri("file:///mod2.bicep")] = mod2Content,
[mainUri] = text
};

var bicepFile = SourceFileFactory.CreateBicepFile(mainUri, text);
using var helper = await LanguageServerHelper.StartServerWithText(
this.TestContext,
files,
bicepFile.FileUri,
services => services.WithFeatureOverrides(new(CompileTimeImportsEnabled: true)));

var file = new FileRequestHelper(helper.Client, bicepFile);

var completions = await file.RequestCompletion(cursors[0]);
completions.Should().Contain(c => c.Label == "foo");
completions.Should().Contain(c => c.Label == "bar");
completions.Should().NotContain(c => c.Label == "fizz");
completions.Should().NotContain(c => c.Label == "buzz");

completions = await file.RequestCompletion(cursors[1]);
completions.Should().Contain(c => c.Label == "fizz");
completions.Should().Contain(c => c.Label == "buzz");
completions.Should().NotContain(c => c.Label == "foo");
completions.Should().NotContain(c => c.Label == "bar");
}
}
}
38 changes: 36 additions & 2 deletions src/Bicep.LangServer/Completions/BicepCompletionContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public record FunctionArgumentContext(
FunctionCallSyntaxBase Function,
int ArgumentIndex
);

private static readonly CompositeSyntaxPattern ExpectingImportSpecification = CompositeSyntaxPattern.Create(
cursor: '|',
"import |",
Expand Down Expand Up @@ -209,6 +210,14 @@ public static BicepCompletionContext Create(IFeatureProvider featureProvider, Co
ConvertFlag(ExpectingImportAsKeyword.TailMatch(pattern), BicepCompletionContextKind.ExpectingImportAsKeyword);
}

if (featureProvider.CompileTimeImportsEnabled)
{
kind |= ConvertFlag(IsImportIdentifierContext(matchingNodes, offset), BicepCompletionContextKind.ImportIdentifier) |
ConvertFlag(IsImportedSymbolListItemContext(matchingNodes, offset), BicepCompletionContextKind.ImportedSymbolIdentifier) |
ConvertFlag(ExpectingContextualFromKeyword(matchingNodes, offset), BicepCompletionContextKind.ExpectingImportFromKeyword) |
ConvertFlag(IsImportTargetContext(matchingNodes, offset), BicepCompletionContextKind.ModulePath);
}

if (featureProvider.AssertsEnabled)
{
kind |= ConvertFlag(IsAssertValueContext(matchingNodes, offset), BicepCompletionContextKind.AssertValue | BicepCompletionContextKind.Expression);
Expand Down Expand Up @@ -398,7 +407,7 @@ output.Type is ResourceTypeSyntax type &&
// we are in a token that is inside a StringSyntax node, which is inside a module declaration
return BicepCompletionContextKind.ModulePath;
}

if (SyntaxMatcher.IsTailMatch<TestDeclarationSyntax>(matchingNodes, test => CheckTypeIsExpected(test.Name, test.Path)) ||
SyntaxMatcher.IsTailMatch<TestDeclarationSyntax, StringSyntax, Token>(matchingNodes, (_, _, token) => token.Type == TokenType.StringComplete) ||
SyntaxMatcher.IsTailMatch<TestDeclarationSyntax, SkippedTriviaSyntax, Token>(matchingNodes, (test, skipped, _) => test.Path == skipped))
Expand All @@ -425,7 +434,7 @@ private static bool IsResourceTypeFollowerContext(List<SyntaxBase> matchingNodes
SyntaxMatcher.IsTailMatch<ResourceDeclarationSyntax, SkippedTriviaSyntax, Token>(matchingNodes, (resource, skipped, token) => resource.Assignment == skipped && token.Type == TokenType.Identifier) ||
// resource foo '...' |=
SyntaxMatcher.IsTailMatch<ResourceDeclarationSyntax, Token>(matchingNodes, (resource, token) => resource.Assignment == token && token.Type == TokenType.Assignment && offset == token.Span.Position);

private static bool IsTargetScopeContext(List<SyntaxBase> matchingNodes, int offset) =>
SyntaxMatcher.IsTailMatch<TargetScopeSyntax>(matchingNodes, targetScope =>
!targetScope.Assignment.Span.ContainsInclusive(offset) &&
Expand Down Expand Up @@ -770,6 +779,31 @@ private static bool IsAssertValueContext(List<SyntaxBase> matchingNodes, int off
// assert foo = a|
SyntaxMatcher.IsTailMatch<AssertDeclarationSyntax, VariableAccessSyntax, IdentifierSyntax, Token>(matchingNodes, (assert, _, _, token) => token.Type == TokenType.Identifier && assert.Assignment is Token assignmentToken && offset > assignmentToken.GetEndPosition());

private static bool IsImportIdentifierContext(List<SyntaxBase> matchingNodes, int offset) =>
// import |
// because extensibility and compile-time imports share a keyword at present, an incomplete statement will be parsed as a ProviderDeclarationSyntax node instead of a CompileTimeImportDeclarationSyntax node
SyntaxMatcher.IsTailMatch<ProviderDeclarationSyntax>(matchingNodes, declaration => declaration.SpecificationString is SkippedTriviaSyntax &&
declaration.SpecificationString.Span.ContainsInclusive(offset) &&
declaration.WithClause is SkippedTriviaSyntax &&
declaration.WithClause.Span.Length == 0 &&
declaration.AsClause is SkippedTriviaSyntax &&
declaration.AsClause.Span.Length == 0);

private static bool IsImportedSymbolListItemContext(List<SyntaxBase> matchingNodes, int offset) =>
SyntaxMatcher.IsTailMatch<ImportedSymbolsListItemSyntax, IdentifierSyntax, Token>(matchingNodes, (_, _, token) => token.Type == TokenType.Identifier) ||
SyntaxMatcher.IsTailMatch<ImportedSymbolsListSyntax, Token>(matchingNodes);

private static bool ExpectingContextualFromKeyword(List<SyntaxBase> matchingNodes, int offset) =>
// import {} | or import * as foo |
SyntaxMatcher.IsTailMatch<CompileTimeImportDeclarationSyntax>(matchingNodes, statement => statement.ImportExpression is not SkippedTriviaSyntax &&
statement.FromClause is SkippedTriviaSyntax &&
statement.FromClause.Span.ContainsInclusive(offset));

private static bool IsImportTargetContext(List<SyntaxBase> matchingNodes, int offset) =>
// import {} | or import * as foo |
SyntaxMatcher.IsTailMatch<CompileTimeImportFromClauseSyntax>(matchingNodes, (fromClause) => offset > fromClause.Keyword.GetEndPosition()) ||
SyntaxMatcher.IsTailMatch<CompileTimeImportFromClauseSyntax, StringSyntax>(matchingNodes);

private static bool IsResourceBodyContext(List<SyntaxBase> matchingNodes, int offset) =>
// resources only allow {} as the body so we don't need to worry about
// providing completions for a partially-typed identifier
Expand Down
17 changes: 16 additions & 1 deletion src/Bicep.LangServer/Completions/BicepCompletionContextKind.cs
Original file line number Diff line number Diff line change
Expand Up @@ -220,10 +220,25 @@ public enum BicepCompletionContextKind : ulong
/// The current location needs a module path (local or remote)
/// </summary>
TestPath = 1UL << 40,

/// <summary>
/// The current location needs an assert value.
/// </summary>
AssertValue = 1UL << 41,

/// <summary>
/// The current location will accept an import identifier ('{}' or '* as foo')
/// </summary>
ImportIdentifier = 1UL << 42,

/// <summary>
/// The current location in an import statement can be completed with a symbol that can be imported from the statement target.
/// </summary>
ImportedSymbolIdentifier = 1UL << 43,

/// <summary>
/// The current location in an import statement requires the <code>from</code> contextual keyword
/// </summary>
ExpectingImportFromKeyword = 1UL << 44,
}
}
67 changes: 63 additions & 4 deletions src/Bicep.LangServer/Completions/BicepCompletionProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ public async Task<IEnumerable<CompletionItem>> GetFilteredCompletions(Compilatio
.Concat(GetOutputValueCompletions(model, context))
.Concat(GetOutputTypeFollowerCompletions(context))
.Concat(GetTargetScopeCompletions(model, context))
.Concat(GetImportCompletions(model, context))
.Concat(GetProviderImportCompletions(model, context))
.Concat(GetCompileTimeImportCompletions(model, context))
.Concat(GetFunctionParamCompletions(model, context))
.Concat(GetExpressionCompletions(model, context))
.Concat(GetDisableNextLineDiagnosticsDirectiveCompletion(context))
Expand Down Expand Up @@ -171,7 +172,7 @@ private IEnumerable<CompletionItem> GetDeclarationCompletions(SemanticModel mode
yield return CreateKeywordCompletion(LanguageConstants.ModuleKeyword, "Module keyword", context.ReplacementRange);
yield return CreateKeywordCompletion(LanguageConstants.TargetScopeKeyword, "Target Scope keyword", context.ReplacementRange);

if (model.Features.ExtensibilityEnabled)
if (model.Features.ExtensibilityEnabled || model.Features.CompileTimeImportsEnabled)
{
yield return CreateKeywordCompletion(LanguageConstants.ImportKeyword, "Import keyword", context.ReplacementRange);
}
Expand All @@ -195,6 +196,11 @@ private IEnumerable<CompletionItem> GetDeclarationCompletions(SemanticModel mode
yield return CreateKeywordCompletion(LanguageConstants.AssertKeyword, "Assert keyword", context.ReplacementRange);
}

if (model.Features.UserDefinedTypesEnabled)
{
yield return CreateKeywordCompletion(LanguageConstants.TypeKeyword, "Type keyword", context.ReplacementRange);
}

foreach (Snippet resourceSnippet in SnippetsProvider.GetTopLevelNamedDeclarationSnippets())
{
string prefix = resourceSnippet.Prefix;
Expand Down Expand Up @@ -748,7 +754,7 @@ private IEnumerable<CompletionItem> GetLocalTestPathCompletions(SemanticModel mo

// Local functions.

bool IsBicepFile(Uri fileUri) => PathHelper.HasBicepExtension(fileUri);
bool IsBicepFile(Uri fileUri) => PathHelper.HasBicepExtension(fileUri);
}

private bool IsOciModuleRegistryReference(BicepCompletionContext context)
Expand Down Expand Up @@ -1864,7 +1870,7 @@ private static CompletionItem CreateSymbolCompletion(Symbol symbol, Range replac
.Build();
}

private IEnumerable<CompletionItem> GetImportCompletions(SemanticModel model, BicepCompletionContext context)
private IEnumerable<CompletionItem> GetProviderImportCompletions(SemanticModel model, BicepCompletionContext context)
{
if (context.Kind.HasFlag(BicepCompletionContextKind.ExpectingImportSpecification))
{
Expand Down Expand Up @@ -1913,6 +1919,59 @@ private IEnumerable<CompletionItem> GetImportCompletions(SemanticModel model, Bi
}
}

private IEnumerable<CompletionItem> GetCompileTimeImportCompletions(SemanticModel model, BicepCompletionContext context)
{
if (context.Kind.HasFlag(BicepCompletionContextKind.ImportIdentifier))
{
yield return CompletionItemBuilder.Create(CompletionItemKind.Value, "{}")
.WithSortText(GetSortText("{}", CompletionPriority.High))
.WithDetail("Import symbols individually from another template")
.WithPlainTextEdit(context.ReplacementRange, "{}")
.Build();

yield return CompletionItemBuilder.Create(CompletionItemKind.Value, "* as")
.WithSortText(GetSortText("* as", CompletionPriority.High))
.WithDetail("Import all symbols from another template under a new namespace")
.WithPlainTextEdit(context.ReplacementRange, "* as")
.Build();
}

if (context.Kind.HasFlag(BicepCompletionContextKind.ExpectingImportFromKeyword))
{
yield return CreateKeywordCompletion(LanguageConstants.FromKeyword, "From keyword", context.ReplacementRange);
}

if (context.Kind.HasFlag(BicepCompletionContextKind.ImportedSymbolIdentifier))
{
if (context.EnclosingDeclaration is CompileTimeImportDeclarationSyntax compileTimeImportDeclaration &&
compileTimeImportDeclaration.ImportExpression.Span.ContainsInclusive(context.ReplacementTarget.Span.Position))
{
if (SemanticModelHelper.TryGetSemanticModelForForeignTemplateReference(model.Compilation.SourceFileGrouping,
compileTimeImportDeclaration,
b => b.CompileTimeImportDeclarationMustReferenceTemplate(),
model.Compilation,
out var importedModel,
out _))
{
var claimedNames = model.Root.Declarations.Select(d => d.Name).ToImmutableHashSet();

foreach (var exported in importedModel.ExportedTypes)
{
var edit = claimedNames.Contains(exported.Key)
? $"{exported.Key} as "
: exported.Key;

yield return CompletionItemBuilder.Create(CompletionItemKind.Variable, exported.Key)
.WithSortText(GetSortText(exported.Key, CompletionPriority.High))
.WithDetail(exported.Value.Description)
.WithPlainTextEdit(context.ReplacementRange, edit)
.Build();
}
}
}
}
}

// the priority must be a number in the sort text
private static string GetSortText(string label, CompletionPriority priority) => $"{(int)priority}_{label}";

Expand Down

0 comments on commit cc2c513

Please sign in to comment.