Skip to content

Commit f1169d0

Browse files
authored
Add module path completions for ARM template files (#3616)
* Add completions for ARM template files * Remove an extra empty line * Fix GetDisplayName * Update test baselines * Address comments and fix a bug * Refactoring
1 parent 6be887d commit f1169d0

File tree

10 files changed

+853
-607
lines changed

10 files changed

+853
-607
lines changed

src/Bicep.Core.Samples/Files/Completions/cwdMCompletions.json

Lines changed: 217 additions & 189 deletions
Large diffs are not rendered by default.

src/Bicep.Core.Samples/Files/InvalidModules_LF/Completions/cwdCompletions.json

Lines changed: 217 additions & 189 deletions
Large diffs are not rendered by default.

src/Bicep.Core.Samples/Files/InvalidModules_LF/Completions/cwdFileCompletions.json

Lines changed: 217 additions & 189 deletions
Large diffs are not rendered by default.

src/Bicep.Core.UnitTests/FileSystem/FileResolverTests.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,5 +181,24 @@ public void DirExists_should_return_expected_results()
181181
Directory.CreateDirectory(tempChildDir);
182182
fileResolver.TryDirExists(PathHelper.FilePathToFileUrl(tempChildDir)).Should().BeTrue();
183183
}
184+
185+
[DataTestMethod]
186+
[DataRow("", 2, true, "")]
187+
[DataRow("a", 2, true, "a")]
188+
[DataRow("aa", 2, true, "aa")]
189+
[DataRow("aaaa\nbbbbb", 2, true, "aa")]
190+
public void TryReadAtMostNCharacters_RegardlessFileContentLength_ReturnsAtMostNCharaters(string fileContents, int n, bool expectedResult, string expectedContents)
191+
{
192+
var fileResolver = new FileResolver();
193+
var tempFile = Path.Combine(Path.GetTempPath(), $"BICEP_TEST_{Guid.NewGuid()}");
194+
var tempFileUri = PathHelper.FilePathToFileUrl(tempFile);
195+
196+
File.WriteAllText(tempFile, fileContents);
197+
198+
var result = fileResolver.TryReadAtMostNCharaters(tempFileUri, Encoding.UTF8, n, out var readContents);
199+
200+
result.Should().Be(expectedResult);
201+
readContents.Should().Be(expectedContents);
202+
}
184203
}
185204
}

src/Bicep.Core/FileSystem/FileResolver.cs

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,6 @@ public bool TryRead(Uri fileUri, [NotNullWhen(true)] out string? fileContents, [
4343
}
4444
}
4545

46-
47-
4846
public bool TryRead(Uri fileUri, [NotNullWhen(true)] out string? fileContents, [NotNullWhen(false)] out DiagnosticBuilder.ErrorBuilderDelegate? failureBuilder, Encoding fileEncoding, int maxCharacters, [NotNullWhen(true)] out Encoding? detectedEncoding)
4947
{
5048
if (!fileUri.IsFile)
@@ -152,6 +150,39 @@ public bool TryReadAsBase64(Uri fileUri, [NotNullWhen(true)] out string? fileBas
152150
}
153151
}
154152

153+
public bool TryReadAtMostNCharaters(Uri fileUri, Encoding fileEncoding, int n, [NotNullWhen(true)] out string? fileContents)
154+
{
155+
if (!fileUri.IsFile || n <= 0)
156+
{
157+
fileContents = null;
158+
return false;
159+
}
160+
161+
try
162+
{
163+
if (Directory.Exists(fileUri.LocalPath))
164+
{
165+
// Docs suggest this is the error to throw when we give a directory.
166+
// A trailing backslash causes windows not to throw this exception.
167+
throw new UnauthorizedAccessException($"Access to the path '{fileUri.LocalPath}' is denied.");
168+
}
169+
170+
using var fileStream = File.OpenRead(fileUri.LocalPath);
171+
using var sr = new StreamReader(fileStream, fileEncoding, true);
172+
173+
var buffer = new char[n];
174+
n = sr.ReadBlock(buffer, 0, n);
175+
176+
fileContents = new string(buffer.Take(n).ToArray());
177+
return true;
178+
}
179+
catch (Exception)
180+
{
181+
fileContents = null;
182+
return false;
183+
}
184+
}
185+
155186
public Uri? TryResolveFilePath(Uri parentFileUri, string childFilePath)
156187
{
157188
if (!Uri.TryCreate(parentFileUri, childFilePath, out var relativeUri))

src/Bicep.Core/FileSystem/IFileResolver.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,16 @@ public interface IFileResolver
2121
bool TryRead(Uri fileUri, [NotNullWhen(true)] out string? fileContents, [NotNullWhen(false)] out DiagnosticBuilder.ErrorBuilderDelegate? failureBuilder);
2222

2323
bool TryRead(Uri fileUri, [NotNullWhen(true)] out string? fileContents, [NotNullWhen(false)] out DiagnosticBuilder.ErrorBuilderDelegate? failureBuilder, Encoding fileEncoding, int maxCharacters, [NotNullWhen(true)] out Encoding? detectedEncoding);
24+
25+
bool TryReadAtMostNCharaters(Uri fileUri, Encoding fileEncoding, int n, [NotNullWhen(true)] out string? fileContents);
26+
2427
/// <summary>
2528
/// Tries to resolve a child file path relative to a parent module file path.
2629
/// </summary>
2730
/// <param name="parentFileUri">The file URI of the parent.</param>
2831
/// <param name="childFilePath">The file path of the child.</param>
2932
Uri? TryResolveFilePath(Uri parentFileUri, string childFilePath);
3033

31-
3234
/// <summary>
3335
/// Tries to get Directories given a uri and pattern. Both argument and returned URIs MUST have a trailing '/'
3436
/// </summary>

src/Bicep.Core/FileSystem/InMemoryFileResolver.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,18 @@ public bool TryRead(Uri fileUri, [NotNullWhen(true)] out string? fileContents, [
5757
return true;
5858
}
5959

60+
public bool TryReadAtMostNCharaters(Uri fileUri, Encoding fileEncoding, int n, [NotNullWhen(true)] out string? fileContents)
61+
{
62+
if (!fileLookup.TryGetValue(fileUri, out fileContents))
63+
{
64+
fileContents = null;
65+
return false;
66+
}
67+
68+
fileContents = new string(fileContents.Take(n).ToArray());
69+
return true;
70+
}
71+
6072
public Uri? TryResolveFilePath(Uri parentFileUri, string childFilePath)
6173
{
6274
if (!Uri.TryCreate(parentFileUri, childFilePath, out var relativeUri))

src/Bicep.Core/LanguageConstants.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Collections.Immutable;
66
using System.Linq;
77
using System.Text;
8+
using System.Text.RegularExpressions;
89
using Bicep.Core.Parsing;
910
using Bicep.Core.Resources;
1011
using Bicep.Core.TypeSystem;
@@ -48,9 +49,11 @@ public static class LanguageConstants
4849
public const string TargetScopeTypeSubscription = "subscription";
4950
public const string TargetScopeTypeResourceGroup = "resourceGroup";
5051

51-
public static ImmutableSortedSet<string> DeclarationKeywords = new[] { ParameterKeyword, VariableKeyword, ResourceKeyword, OutputKeyword, ModuleKeyword }.ToImmutableSortedSet(StringComparer.Ordinal);
52+
public static readonly Regex ArmTemplateSchemaRegex = new(@"https?:\/\/schema\.management\.azure\.com\/schemas\/([^""\/]+\/[a-zA-Z]*[dD]eploymentTemplate\.json)#?");
5253

53-
public static ImmutableSortedSet<string> ContextualKeywords = DeclarationKeywords
54+
public static readonly ImmutableSortedSet<string> DeclarationKeywords = new[] { ParameterKeyword, VariableKeyword, ResourceKeyword, OutputKeyword, ModuleKeyword }.ToImmutableSortedSet(StringComparer.Ordinal);
55+
56+
public static readonly ImmutableSortedSet<string> ContextualKeywords = DeclarationKeywords
5457
.Add(TargetScopeKeyword)
5558
.Add(IfKeyword)
5659
.Add(ForKeyword)

src/Bicep.LangServer.IntegrationTests/CompletionTests.cs

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ public class CompletionTests
4545
[NotNull]
4646
public TestContext? TestContext { get; set; }
4747

48+
public static string GetDisplayName(MethodInfo info, object[] row)
49+
{
50+
row.Should().HaveCount(3);
51+
row[0].Should().BeOfType<DataSet>();
52+
row[1].Should().BeOfType<string>();
53+
row[2].Should().BeAssignableTo<IList<Position>>();
54+
55+
return $"{info.Name}_{((DataSet)row[0]).Name}_{row[1]}";
56+
}
57+
4858
[TestMethod]
4959
public async Task EmptyFileShouldProduceDeclarationCompletions()
5060
{
@@ -786,14 +796,64 @@ public async Task RequestCompletions_MatchingNodeIsBooleanOrIntegerOrNullLiteral
786796
await RunCompletionScenarioTest(this.TestContext, fileWithCursors, AssertAllCompletionsEmpty);
787797
}
788798

789-
private static async Task RunCompletionScenarioTest(TestContext testContext, string fileWithCursors, Action<IEnumerable<CompletionList?>> assertAction)
799+
[TestMethod]
800+
public async Task RequestModulePathCompletions_ArmTemplateFilesInDir_ReturnsCompletionsIncludingArmTemplatePaths()
790801
{
791-
var (file, cursors) = ParserHelper.GetFileWithCursors(fileWithCursors);
792-
var bicepFile = SourceFileFactory.CreateBicepFile(new Uri("file:///path/to/main.bicep"), file);
793-
var client = await IntegrationTestHelper.StartServerWithTextAsync(testContext, file, bicepFile.FileUri, resourceTypeProvider: BuiltInTestTypes.Create());
794-
var completions = await RequestCompletions(client, bicepFile, cursors);
802+
var mainUri = DocumentUri.FromFileSystemPath("/path/to/main.bicep");
803+
var armTemplateUri1 = DocumentUri.FromFileSystemPath("/path/to/template1.arm");
804+
var armTemplateUri2 = DocumentUri.FromFileSystemPath("/path/to/template2.json");
805+
var armTemplateUri3 = DocumentUri.FromFileSystemPath("/path/to/template3.jsonc");
806+
var armTemplateUri4 = DocumentUri.FromFileSystemPath("/path/to/template4.json");
807+
var armTemplateUri5 = DocumentUri.FromFileSystemPath("/path/to/template5.json");
808+
var jsonUri1 = DocumentUri.FromFileSystemPath("/path/to/json1.json");
809+
var jsonUri2 = DocumentUri.FromFileSystemPath("/path/to/json2.json");
810+
var bicepModuleUri1 = DocumentUri.FromFileSystemPath("/path/to/module1.txt");
811+
var bicepModuleUri2 = DocumentUri.FromFileSystemPath("/path/to/module2.bicep");
812+
var bicepModuleUri3 = DocumentUri.FromFileSystemPath("/path/to/module3.bicep");
813+
814+
var (mainFileText, cursors) = ParserHelper.GetFileWithCursors(@"
815+
module mod1 './module1.txt' = {}
816+
module mod2 './template3.jsonc' = {}
817+
module mod2 './|' = {}
818+
");
819+
var mainFile = SourceFileFactory.CreateBicepFile(mainUri.ToUri(), mainFileText);
820+
var schema = "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#";
821+
822+
var fileTextsByUri = new Dictionary<Uri, string>
823+
{
824+
[mainUri.ToUri()] = mainFileText,
825+
[armTemplateUri1.ToUri()] = "",
826+
[armTemplateUri2.ToUri()] = @$"{{ ""schema"": ""{schema}"" }}",
827+
[armTemplateUri3.ToUri()] = @"{}",
828+
[armTemplateUri4.ToUri()] = new string('x', 2000 - schema.Length) + schema,
829+
[armTemplateUri5.ToUri()] = new string('x', 2002 - schema.Length) + schema,
830+
[jsonUri1.ToUri()] = "{}",
831+
[jsonUri2.ToUri()] = @"[{ ""name"": ""value"" }]",
832+
[bicepModuleUri1.ToUri()] = "param foo string",
833+
[bicepModuleUri2.ToUri()] = "param bar bool",
834+
[bicepModuleUri3.ToUri()] = "",
835+
};
795836

796-
assertAction(completions);
837+
var fileResolver = new InMemoryFileResolver(fileTextsByUri);
838+
839+
var client = await IntegrationTestHelper.StartServerWithTextAsync(
840+
TestContext,
841+
mainFileText,
842+
mainUri,
843+
resourceTypeProvider: BuiltInTestTypes.Create(),
844+
fileResolver: fileResolver);
845+
846+
var completionLists = await RequestCompletions(client, mainFile, cursors);
847+
completionLists.Should().HaveCount(1);
848+
849+
var completionItems = completionLists.Single()!.Items;
850+
completionItems.Should().SatisfyRespectively(
851+
x => x.Label.Should().Be("module2.bicep"),
852+
x => x.Label.Should().Be("module3.bicep"),
853+
x => x.Label.Should().Be("template1.arm"),
854+
x => x.Label.Should().Be("template2.json"),
855+
x => x.Label.Should().Be("template3.jsonc"),
856+
x => x.Label.Should().Be("template4.json"));
797857
}
798858

799859
[TestMethod]
@@ -970,14 +1030,14 @@ private void ValidateCompletions(DataSet dataSet, string setName, List<(Position
9701030

9711031
private static string GetGlobalCompletionSetPath(string setName) => Path.Combine("src", "Bicep.Core.Samples", "Files", DataSet.TestCompletionsDirectory, GetFullSetName(setName));
9721032

973-
public static string GetDisplayName(MethodInfo info, object[] row)
1033+
private static async Task RunCompletionScenarioTest(TestContext testContext, string fileWithCursors, Action<IEnumerable<CompletionList?>> assertAction)
9741034
{
975-
row.Should().HaveCount(3);
976-
row[0].Should().BeOfType<DataSet>();
977-
row[1].Should().BeOfType<string>();
978-
row[2].Should().BeAssignableTo<IList<Position>>();
1035+
var (file, cursors) = ParserHelper.GetFileWithCursors(fileWithCursors);
1036+
var bicepFile = SourceFileFactory.CreateBicepFile(new Uri("file:///path/to/main.bicep"), file);
1037+
var client = await IntegrationTestHelper.StartServerWithTextAsync(testContext, file, bicepFile.FileUri, resourceTypeProvider: BuiltInTestTypes.Create());
1038+
var completions = await RequestCompletions(client, bicepFile, cursors);
9791039

980-
return $"{info.Name}_{((DataSet)row[0]).Name}_{row[1]}";
1040+
assertAction(completions);
9811041
}
9821042

9831043
private static string FormatPosition(Position position) => $"({position.Line}, {position.Character})";

src/Bicep.LangServer/Completions/BicepCompletionProvider.cs

Lines changed: 58 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -291,31 +291,66 @@ private IEnumerable<CompletionItem> GetModulePathCompletions(SemanticModel model
291291
files = FileResolver.GetFiles(queryParent, "");
292292
dirs = FileResolver.GetDirectories(queryParent, "");
293293
}
294-
// "./" will not be preserved when making relative Uris. We have to go and manually add it.
295-
// Prioritize .bicep files higher than other files.
296-
var fileItems = files
297-
.Where(file => file != model.SourceFile.FileUri)
298-
.Where(file => file.Segments.Last().EndsWith(LanguageConstants.LanguageFileExtension))
299-
.Select(file => CreateModulePathCompletionBuilder(
300-
file.Segments.Last(),
301-
(entered.StartsWith("./") ? "./" : "") + cwdUri.MakeRelativeUri(file).ToString(),
302-
context.ReplacementRange,
303-
CompletionItemKind.File,
304-
file.Segments.Last().EndsWith(LanguageConstants.LanguageId) ? CompletionPriority.High : CompletionPriority.Medium)
305-
.Build())
306-
.ToList();
307294

295+
// Prioritize .bicep files higher than other files.
296+
var bicepFileItems = CreateFileCompletionItems(files, cwdUri, IsBicepFile, CompletionPriority.High);
297+
var armTemplateFileItems = CreateFileCompletionItems(files, cwdUri, IsArmTemplateFileLike, CompletionPriority.Medium);
308298
var dirItems = dirs
309-
.Select(dir => CreateModulePathCompletionBuilder(
310-
dir.Segments.Last(),
311-
(entered.StartsWith("./") ? "./" : "") + cwdUri.MakeRelativeUri(dir).ToString(),
312-
context.ReplacementRange,
313-
CompletionItemKind.Folder,
314-
CompletionPriority.Medium)
315-
.WithCommand(new Command { Name = EditorCommands.RequestCompletions })
316-
.Build())
317-
.ToList();
318-
return fileItems.Concat(dirItems);
299+
.Select(dir =>
300+
CreateModulePathCompletionBuilder(
301+
dir.Segments.Last(),
302+
// "./" will not be preserved when making relative Uris. We have to go and manually add it.
303+
(entered.StartsWith("./") ? "./" : "") + cwdUri.MakeRelativeUri(dir).ToString(),
304+
context.ReplacementRange,
305+
CompletionItemKind.Folder,
306+
CompletionPriority.Low)
307+
.WithCommand(new Command { Name = EditorCommands.RequestCompletions })
308+
.Build());
309+
310+
return bicepFileItems.Concat(armTemplateFileItems).Concat(dirItems);
311+
312+
// Local functions.
313+
IEnumerable<CompletionItem> CreateFileCompletionItems(IEnumerable<Uri> fileUris, Uri cwdUri, Predicate<Uri> predicate, CompletionPriority priority) => fileUris
314+
.Where(fileUri => fileUri != model.SourceFile.FileUri && predicate(fileUri))
315+
.Select(fileUri =>
316+
CreateModulePathCompletionBuilder(
317+
fileUri.Segments.Last(),
318+
(entered.StartsWith("./") ? "./" : "") + cwdUri.MakeRelativeUri(fileUri).ToString(),
319+
context.ReplacementRange,
320+
CompletionItemKind.File,
321+
priority)
322+
.Build());
323+
324+
bool IsBicepFile(Uri fileUri) => PathHelper.HasBicepExtension(fileUri);
325+
326+
bool IsArmTemplateFileLike(Uri fileUri)
327+
{
328+
if (PathHelper.HasExtension(fileUri, LanguageConstants.ArmTemplateFileExtension))
329+
{
330+
return true;
331+
}
332+
333+
if (model.Compilation.SourceFileGrouping.SourceFiles.Any(sourceFile =>
334+
sourceFile is ArmTemplateFile &&
335+
sourceFile.FileUri.LocalPath.Equals(fileUri.LocalPath, PathHelper.PathComparison)))
336+
{
337+
return true;
338+
}
339+
340+
if (!PathHelper.HasExtension(fileUri, LanguageConstants.JsonFileExtension) &&
341+
!PathHelper.HasExtension(fileUri, LanguageConstants.JsoncFileExtension))
342+
{
343+
return false;
344+
}
345+
346+
if (FileResolver.TryReadAtMostNCharaters(fileUri, Encoding.UTF8, 2000, out var fileContents) &&
347+
LanguageConstants.ArmTemplateSchemaRegex.IsMatch(fileContents))
348+
{
349+
return true;
350+
}
351+
352+
return false;
353+
}
319354
}
320355

321356
private static IEnumerable<CompletionItem> GetParameterTypeSnippets(Compilation compitation, BicepCompletionContext context)

0 commit comments

Comments
 (0)