diff --git a/src/Bicep.Core.Samples/Files/Completions/declarations.json b/src/Bicep.Core.Samples/Files/Completions/declarations.json index 05a65e62d76..e578f39f5e2 100644 --- a/src/Bicep.Core.Samples/Files/Completions/declarations.json +++ b/src/Bicep.Core.Samples/Files/Completions/declarations.json @@ -221,6 +221,24 @@ "newText": "resource ${1:automationAccount} 'Microsoft.Automation/automationAccounts@2019-06-01' = {\n name: ${2:'name'}\n location: resourceGroup().location\n properties: {\n sku: {\n name: ${3|'Free','Basic'|}\n }\n }\n}\n" } }, + { + "label": "res-automation-module", + "kind": "snippet", + "detail": "Automation Module", + "documentation": { + "kind": "markdown", + "value": "```bicep\nresource automationAccount 'Microsoft.Automation/automationAccounts@2015-10-31' = {\n name: 'name'\n}\n\nresource automationAccountVariable 'Microsoft.Automation/automationAccounts/modules@2015-10-31' = {\n parent: automationAccount\n name: 'name'\n properties: {\n contentLink: {\n uri: 'https://content-url.nupkg'\n }\n }\n}\n```" + }, + "deprecated": false, + "preselect": false, + "sortText": "2_res-automation-module", + "insertTextFormat": "snippet", + "insertTextMode": "adjustIndentation", + "textEdit": { + "range": {}, + "newText": "resource automationAccount 'Microsoft.Automation/automationAccounts@2015-10-31' = {\n name: ${1:'name'}\n}\n\nresource ${2:automationAccountVariable} 'Microsoft.Automation/automationAccounts/modules@2015-10-31' = {\n parent: automationAccount\n name: ${3:'name'}\n properties: {\n contentLink: {\n uri: ${4:'https://content-url.nupkg'}\n }\n }\n}" + } + }, { "label": "res-availability-set", "kind": "snippet", diff --git a/src/Bicep.Core.Samples/Files/Completions/object.json b/src/Bicep.Core.Samples/Files/Completions/moduleObject.json similarity index 100% rename from src/Bicep.Core.Samples/Files/Completions/object.json rename to src/Bicep.Core.Samples/Files/Completions/moduleObject.json diff --git a/src/Bicep.Core.Samples/Files/Completions/resourceObject.json b/src/Bicep.Core.Samples/Files/Completions/resourceObject.json new file mode 100644 index 00000000000..680e5679faf --- /dev/null +++ b/src/Bicep.Core.Samples/Files/Completions/resourceObject.json @@ -0,0 +1,88 @@ +[ + { + "label": "for", + "kind": "snippet", + "detail": "for", + "documentation": { + "kind": "markdown", + "value": "```bicep\n[for item in list: {\n\t\n}]\n```" + }, + "deprecated": false, + "preselect": false, + "sortText": "1_for", + "insertTextFormat": "snippet", + "insertTextMode": "adjustIndentation", + "textEdit": { + "range": {}, + "newText": "[for ${2:item} in ${1:list}: {\n\t$0\n}]" + } + }, + { + "label": "for-filtered", + "kind": "snippet", + "detail": "for-filtered", + "documentation": { + "kind": "markdown", + "value": "```bicep\n[for (item, index) in list: if (condition) {\n\t\n}]\n```" + }, + "deprecated": false, + "preselect": false, + "sortText": "1_for-filtered", + "insertTextFormat": "snippet", + "insertTextMode": "adjustIndentation", + "textEdit": { + "range": {}, + "newText": "[for (${2:item}, ${3:index}) in ${1:list}: if (${4:condition}) {\n\t$0\n}]" + } + }, + { + "label": "for-indexed", + "kind": "snippet", + "detail": "for-indexed", + "documentation": { + "kind": "markdown", + "value": "```bicep\n[for (item, index) in list: {\n\t\n}]\n```" + }, + "deprecated": false, + "preselect": false, + "sortText": "1_for-indexed", + "insertTextFormat": "snippet", + "insertTextMode": "adjustIndentation", + "textEdit": { + "range": {}, + "newText": "[for (${2:item}, ${3:index}) in ${1:list}: {\n\t$0\n}]" + } + }, + { + "label": "if", + "kind": "snippet", + "detail": "if", + "deprecated": false, + "preselect": false, + "sortText": "1_if", + "insertTextFormat": "snippet", + "insertTextMode": "adjustIndentation", + "textEdit": { + "range": {}, + "newText": "if (${1:condition}) {\n\t$0\n}" + } + }, + { + "label": "{}", + "kind": "snippet", + "detail": "{}", + "documentation": { + "kind": "markdown", + "value": "```bicep\n{\n\t\n}\n```" + }, + "deprecated": false, + "preselect": true, + "sortText": "2_{}", + "insertTextFormat": "snippet", + "insertTextMode": "adjustIndentation", + "textEdit": { + "range": {}, + "newText": "{\n\t$0\n}" + } + } +] \ No newline at end of file diff --git a/src/Bicep.Core.Samples/Files/InvalidModules_LF/main.bicep b/src/Bicep.Core.Samples/Files/InvalidModules_LF/main.bicep index 0ea3e01b78e..4624c0e156e 100644 --- a/src/Bicep.Core.Samples/Files/InvalidModules_LF/main.bicep +++ b/src/Bicep.Core.Samples/Files/InvalidModules_LF/main.bicep @@ -19,7 +19,7 @@ module moduleWithoutPath = { // missing identifier #completionTest(7) -> empty module -// #completionTest(24,25) -> object +// #completionTest(24,25) -> moduleObject module missingValue '' = var interp = 'hello' diff --git a/src/Bicep.Core.Samples/Files/InvalidModules_LF/main.diagnostics.bicep b/src/Bicep.Core.Samples/Files/InvalidModules_LF/main.diagnostics.bicep index 619fab3ad23..24c5946a3bf 100644 --- a/src/Bicep.Core.Samples/Files/InvalidModules_LF/main.diagnostics.bicep +++ b/src/Bicep.Core.Samples/Files/InvalidModules_LF/main.diagnostics.bicep @@ -28,7 +28,7 @@ module //@[7:7) [BCP096 (Error)] Expected a module identifier at this location. || //@[7:7) [BCP090 (Error)] This module declaration is missing a file path reference. || -// #completionTest(24,25) -> object +// #completionTest(24,25) -> moduleObject module missingValue '' = //@[20:22) [BCP050 (Error)] The specified module path is empty. |''| //@[25:25) [BCP118 (Error)] Expected the "{" character, the "[" character, or the "if" keyword at this location. || diff --git a/src/Bicep.Core.Samples/Files/InvalidModules_LF/main.formatted.bicep b/src/Bicep.Core.Samples/Files/InvalidModules_LF/main.formatted.bicep index 4ec4607829e..fb32d7ac695 100644 --- a/src/Bicep.Core.Samples/Files/InvalidModules_LF/main.formatted.bicep +++ b/src/Bicep.Core.Samples/Files/InvalidModules_LF/main.formatted.bicep @@ -13,7 +13,7 @@ module moduleWithoutPath = { // missing identifier #completionTest(7) -> empty module -// #completionTest(24,25) -> object +// #completionTest(24,25) -> moduleObject module missingValue '' = var interp = 'hello' diff --git a/src/Bicep.Core.Samples/Files/InvalidModules_LF/main.symbols.bicep b/src/Bicep.Core.Samples/Files/InvalidModules_LF/main.symbols.bicep index b08e2412ea4..9b33c7bba58 100644 --- a/src/Bicep.Core.Samples/Files/InvalidModules_LF/main.symbols.bicep +++ b/src/Bicep.Core.Samples/Files/InvalidModules_LF/main.symbols.bicep @@ -24,7 +24,7 @@ module moduleWithoutPath = { module //@[7:7) Module . Type: error. Declaration start char: 0, length: 7 -// #completionTest(24,25) -> object +// #completionTest(24,25) -> moduleObject module missingValue '' = //@[7:19) Module missingValue. Type: error. Declaration start char: 0, length: 25 diff --git a/src/Bicep.Core.Samples/Files/InvalidModules_LF/main.syntax.bicep b/src/Bicep.Core.Samples/Files/InvalidModules_LF/main.syntax.bicep index 57cc34d9f42..fd48c5f68fe 100644 --- a/src/Bicep.Core.Samples/Files/InvalidModules_LF/main.syntax.bicep +++ b/src/Bicep.Core.Samples/Files/InvalidModules_LF/main.syntax.bicep @@ -79,8 +79,8 @@ module //@[7:7) SkippedTriviaSyntax //@[7:9) NewLine |\n\n| -// #completionTest(24,25) -> object -//@[35:36) NewLine |\n| +// #completionTest(24,25) -> moduleObject +//@[41:42) NewLine |\n| module missingValue '' = //@[0:25) ModuleDeclarationSyntax //@[0:6) Identifier |module| diff --git a/src/Bicep.Core.Samples/Files/InvalidModules_LF/main.tokens.bicep b/src/Bicep.Core.Samples/Files/InvalidModules_LF/main.tokens.bicep index bb828a2d3cd..17c5ae8c874 100644 --- a/src/Bicep.Core.Samples/Files/InvalidModules_LF/main.tokens.bicep +++ b/src/Bicep.Core.Samples/Files/InvalidModules_LF/main.tokens.bicep @@ -55,8 +55,8 @@ module //@[0:6) Identifier |module| //@[7:9) NewLine |\n\n| -// #completionTest(24,25) -> object -//@[35:36) NewLine |\n| +// #completionTest(24,25) -> moduleObject +//@[41:42) NewLine |\n| module missingValue '' = //@[0:6) Identifier |module| //@[7:19) Identifier |missingValue| diff --git a/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/Completions/objectPlusFor.json b/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/Completions/objectPlusFor.json index baf740ae54a..542d206b0ce 100644 --- a/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/Completions/objectPlusFor.json +++ b/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/Completions/objectPlusFor.json @@ -67,13 +67,53 @@ "newText": "if (${1:condition}) {\n\t$0\n}" } }, + { + "label": "insert-required", + "kind": "snippet", + "detail": "Required properties", + "documentation": { + "kind": "markdown", + "value": "```bicep\n{\n\tname: \n\tlocation: \n\t\n}\n```" + }, + "deprecated": false, + "preselect": true, + "sortText": "2_insert-required", + "insertTextFormat": "snippet", + "insertTextMode": "adjustIndentation", + "textEdit": { + "range": {}, + "newText": "{\n\tname: $1\n\tlocation: $2\n\t$0\n}" + } + }, + { + "label": "insert-snippet", + "kind": "snippet", + "detail": "DNS Zone", + "documentation": { + "kind": "markdown", + "value": "```bicep\n{\n name: 'name'\n location: 'global'\n}\n\n```" + }, + "deprecated": false, + "preselect": true, + "sortText": "2_insert-snippet", + "insertTextFormat": "snippet", + "insertTextMode": "adjustIndentation", + "textEdit": { + "range": {}, + "newText": "{\n name: ${2:'name'}\n location: 'global'\n}\n" + } + }, { "label": "{}", - "kind": "value", + "kind": "snippet", "detail": "{}", + "documentation": { + "kind": "markdown", + "value": "```bicep\n{\n\t\n}\n```" + }, "deprecated": false, "preselect": true, - "sortText": "1_{}", + "sortText": "2_{}", "insertTextFormat": "snippet", "insertTextMode": "adjustIndentation", "textEdit": { diff --git a/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/main.bicep b/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/main.bicep index 438c081df36..9699476dfc6 100644 --- a/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/main.bicep +++ b/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/main.bicep @@ -11,7 +11,7 @@ resource foo 'ddd' // #completionTest(23) -> resourceTypes resource trailingSpace -// #completionTest(19,20) -> object +// #completionTest(19,20) -> resourceObject resource foo 'ddd'= // wrong resource type diff --git a/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/main.diagnostics.bicep b/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/main.diagnostics.bicep index adb068ab9ae..2dc0b8be1a5 100644 --- a/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/main.diagnostics.bicep +++ b/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/main.diagnostics.bicep @@ -25,7 +25,7 @@ resource trailingSpace //@[24:24) [BCP068 (Error)] Expected a resource type string. Specify a valid resource type of format "/@". || //@[24:24) [BCP029 (Error)] The resource type is not valid. Specify a valid resource type of format "/@". || -// #completionTest(19,20) -> object +// #completionTest(19,20) -> resourceObject resource foo 'ddd'= //@[9:12) [BCP028 (Error)] Identifier "foo" is declared multiple times. Remove or rename the duplicates. |foo| //@[13:18) [BCP029 (Error)] The resource type is not valid. Specify a valid resource type of format "/@". |'ddd'| diff --git a/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/main.formatted.bicep b/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/main.formatted.bicep index 7cafdcf778a..4c04cd4bac2 100644 --- a/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/main.formatted.bicep +++ b/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/main.formatted.bicep @@ -10,7 +10,7 @@ resource foo 'ddd' // #completionTest(23) -> resourceTypes resource trailingSpace -// #completionTest(19,20) -> object +// #completionTest(19,20) -> resourceObject resource foo 'ddd'= // wrong resource type diff --git a/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/main.symbols.bicep b/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/main.symbols.bicep index 985dad14be4..aea04e5afd3 100644 --- a/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/main.symbols.bicep +++ b/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/main.symbols.bicep @@ -16,7 +16,7 @@ resource foo 'ddd' resource trailingSpace //@[9:22) Resource trailingSpace. Type: error. Declaration start char: 0, length: 24 -// #completionTest(19,20) -> object +// #completionTest(19,20) -> resourceObject resource foo 'ddd'= //@[9:12) Resource foo. Type: error. Declaration start char: 0, length: 20 diff --git a/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/main.syntax.bicep b/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/main.syntax.bicep index 8230f534c09..32c2b2bb24e 100644 --- a/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/main.syntax.bicep +++ b/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/main.syntax.bicep @@ -61,8 +61,8 @@ resource trailingSpace //@[24:24) SkippedTriviaSyntax //@[24:28) NewLine |\r\n\r\n| -// #completionTest(19,20) -> object -//@[35:37) NewLine |\r\n| +// #completionTest(19,20) -> resourceObject +//@[43:45) NewLine |\r\n| resource foo 'ddd'= //@[0:20) ResourceDeclarationSyntax //@[0:8) Identifier |resource| diff --git a/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/main.tokens.bicep b/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/main.tokens.bicep index e401c723165..77d01ac8946 100644 --- a/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/main.tokens.bicep +++ b/src/Bicep.Core.Samples/Files/InvalidResources_CRLF/main.tokens.bicep @@ -34,8 +34,8 @@ resource trailingSpace //@[9:22) Identifier |trailingSpace| //@[24:28) NewLine |\r\n\r\n| -// #completionTest(19,20) -> object -//@[35:37) NewLine |\r\n| +// #completionTest(19,20) -> resourceObject +//@[43:45) NewLine |\r\n| resource foo 'ddd'= //@[0:8) Identifier |resource| //@[9:12) Identifier |foo| diff --git a/src/Bicep.LangServer.IntegrationTests/Completions/SnippetTemplates/res-automation-module/main.bicep b/src/Bicep.LangServer.IntegrationTests/Completions/SnippetTemplates/res-automation-module/main.bicep new file mode 100644 index 00000000000..786423efc3d --- /dev/null +++ b/src/Bicep.LangServer.IntegrationTests/Completions/SnippetTemplates/res-automation-module/main.bicep @@ -0,0 +1,6 @@ +// $1 = 'name' +// $2 = automationAccountVariable +// $3 = 'name' +// $4 = 'https://test-content-url.nupkg' + +// Insert snippet here diff --git a/src/Bicep.LangServer.IntegrationTests/Completions/SnippetTemplates/res-automation-module/main.combined.bicep b/src/Bicep.LangServer.IntegrationTests/Completions/SnippetTemplates/res-automation-module/main.combined.bicep new file mode 100644 index 00000000000..4f264b4497c --- /dev/null +++ b/src/Bicep.LangServer.IntegrationTests/Completions/SnippetTemplates/res-automation-module/main.combined.bicep @@ -0,0 +1,13 @@ +resource automationAccount 'Microsoft.Automation/automationAccounts@2015-10-31' = { + name: 'name' +} + +resource automationAccountVariable 'Microsoft.Automation/automationAccounts/modules@2015-10-31' = { + parent: automationAccount + name: 'name' + properties: { + contentLink: { + uri: 'https://test-content-url.nupkg' + } + } +} diff --git a/src/Bicep.LangServer.UnitTests/Snippets/SnippetTests.cs b/src/Bicep.LangServer.UnitTests/Snippets/SnippetTests.cs index cc2751104bb..f42f95e22ec 100644 --- a/src/Bicep.LangServer.UnitTests/Snippets/SnippetTests.cs +++ b/src/Bicep.LangServer.UnitTests/Snippets/SnippetTests.cs @@ -119,6 +119,25 @@ public void SinglePropertySnippetShouldParseCorrectly() snippet.FormatDocumentation().Should().Be("name: ''"); } + [TestMethod] + public void SnippetPlaceholderTextWithUrlShouldParseCorrectly() + { + string text = @"var testIdentifier = '${1:http://test-content-url.nupkg}'"; + + var snippet = new Snippet(text); + snippet.Text.Should().Be(text); + + snippet.Placeholders.Should().SatisfyRespectively( + x => + { + x.Index.Should().Be(1); + x.Name.Should().Be("http://test-content-url.nupkg"); + x.Span.ToString().Should().Be("[22:56]"); + }); + + snippet.FormatDocumentation().Should().Be("var testIdentifier = 'http://test-content-url.nupkg'"); + } + [TestMethod] public void SnippetPlaceholderTextWithMultipleChoicesShouldReturnFirstOneByDefault() { diff --git a/src/Bicep.LangServer.UnitTests/Snippets/SnippetsProviderTests.cs b/src/Bicep.LangServer.UnitTests/Snippets/SnippetsProviderTests.cs index db1c58f8e6e..f68732d1549 100644 --- a/src/Bicep.LangServer.UnitTests/Snippets/SnippetsProviderTests.cs +++ b/src/Bicep.LangServer.UnitTests/Snippets/SnippetsProviderTests.cs @@ -3,8 +3,13 @@ using System.Collections.Generic; using System.Linq; +using Bicep.Core; +using Bicep.Core.Resources; +using Bicep.Core.TypeSystem; +using Bicep.Core.UnitTests.Assertions; using Bicep.LanguageServer.Completions; using Bicep.LanguageServer.Snippets; +using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Bicep.LangServer.UnitTests.Snippets @@ -17,7 +22,7 @@ public void GetDescriptionAndText_WithEmptyInput_ReturnsEmptyDescriptionAndText( { SnippetsProvider snippetsProvider = new SnippetsProvider(); - (string description, string text) = snippetsProvider.GetDescriptionAndText(string.Empty); + (string description, string text) = snippetsProvider.GetDescriptionAndText(string.Empty, @"C:\foo.bicep"); Assert.IsTrue(description.Equals(string.Empty)); Assert.IsTrue(text.Equals(string.Empty)); @@ -28,7 +33,7 @@ public void GetDescriptionAndText_WithNullInput_ReturnsEmptyDescriptionAndText() { SnippetsProvider snippetsProvider = new SnippetsProvider(); - (string description, string text) = snippetsProvider.GetDescriptionAndText(null); + (string description, string text) = snippetsProvider.GetDescriptionAndText(null, @"C:\foo.bicep"); Assert.IsTrue(description.Equals(string.Empty)); Assert.IsTrue(text.Equals(string.Empty)); @@ -39,7 +44,7 @@ public void GetDescriptionAndText_WithOnlyWhitespaceInput_ReturnsEmptyDescriptio { SnippetsProvider snippetsProvider = new SnippetsProvider(); - (string description, string text) = snippetsProvider.GetDescriptionAndText(" "); + (string description, string text) = snippetsProvider.GetDescriptionAndText(" ", @"C:\foo.bicep"); Assert.IsTrue(description.Equals(string.Empty)); Assert.IsTrue(text.Equals(string.Empty)); @@ -59,7 +64,7 @@ public void GetDescriptionAndText_WithValidInput_ReturnsDescriptionAndText() } }"; - (string description, string text) = snippetsProvider.GetDescriptionAndText(template); + (string description, string text) = snippetsProvider.GetDescriptionAndText(template, @"C:\foo.bicep"); string expectedText = @"resource dnsZone 'Microsoft.Network/dnsZones@2018-05-01' = { name: '${1:dnsZone}' @@ -86,7 +91,7 @@ public void GetDescriptionAndText_WithMissingCommentInInput_ReturnsEmptyDescript } }"; - (string description, string text) = snippetsProvider.GetDescriptionAndText(template); + (string description, string text) = snippetsProvider.GetDescriptionAndText(template, @"C:\foo.bicep"); string expectedText = @"resource dnsZone 'Microsoft.Network/dnsZones@2018-05-01' = { name: '${1:dnsZone}' @@ -107,7 +112,7 @@ public void GetDescriptionAndText_WithCommentAndMissingDeclarations_ReturnsEmpty string template = @"// DNS Zone"; - (string description, string text) = snippetsProvider.GetDescriptionAndText(template); + (string description, string text) = snippetsProvider.GetDescriptionAndText(template, @"C:\foo.bicep"); Assert.IsTrue(description.Equals(string.Empty)); Assert.IsTrue(text.Equals(string.Empty)); @@ -132,7 +137,6 @@ public void CompletionPriorityOfResourceSnippets_ShouldBeHigh() public void CompletionPriorityOfNonResourceSnippets_ShouldBeMedium() { SnippetsProvider snippetsProvider = new SnippetsProvider(); - IEnumerable snippets = snippetsProvider.GetTopLevelNamedDeclarationSnippets() .Where(x => !x.Prefix.StartsWith("resource")); @@ -141,5 +145,184 @@ public void CompletionPriorityOfNonResourceSnippets_ShouldBeMedium() Assert.AreEqual(CompletionPriority.Medium, snippet.CompletionPriority); } } + + [TestMethod] + public void GetResourceBodyCompletionSnippets_WithStaticTemplateAndNoResourceDependencies_ShouldReturnSnippets() + { + SnippetsProvider snippetsProvider = new SnippetsProvider(); + TypeSymbol typeSymbol = new ResourceType( + ResourceTypeReference.Parse("Microsoft.Network/dnsZones@2018-05-01"), + ResourceScope.ResourceGroup, + CreateObjectType("Microsoft.Network/dnsZones@2018-05-01", + ("name", LanguageConstants.String, TypePropertyFlags.Required), + ("location", LanguageConstants.String, TypePropertyFlags.Required))); + + IEnumerable snippets = snippetsProvider.GetResourceBodyCompletionSnippets(typeSymbol); + + snippets.Should().SatisfyRespectively( + x => + { + x.Prefix.Should().Be("{}"); + x.Detail.Should().Be("{}"); + x.CompletionPriority.Should().Be(CompletionPriority.Medium); + x.Text.Should().Be("{\n\t$0\n}"); + }, + x => + { + x.Prefix.Should().Be("insert-snippet"); + x.Detail.Should().Be("DNS Zone"); + x.CompletionPriority.Should().Be(CompletionPriority.Medium); + x.Text.Should().BeEquivalentToIgnoringNewlines(@"{ + name: ${2:'name'} + location: 'global' +} +"); + }, + x => + { + x.Prefix.Should().Be("insert-required"); + x.Detail.Should().Be("Required properties"); + x.CompletionPriority.Should().Be(CompletionPriority.Medium); + x.Text.Should().BeEquivalentToIgnoringNewlines(@"{ + name: $1 + location: $2 + $0 +}"); + }); + } + + [TestMethod] + public void GetResourceBodyCompletionSnippets_WithStaticTemplateAndResourceDependencies_ShouldReturnSnippets() + { + SnippetsProvider snippetsProvider = new SnippetsProvider(); + TypeSymbol typeSymbol = new ResourceType( + ResourceTypeReference.Parse("Microsoft.Automation/automationAccounts/modules@2015-10-31"), + ResourceScope.ResourceGroup, + CreateObjectType("Microsoft.Automation/automationAccounts/modules@2015-10-31", + ("name", LanguageConstants.String, TypePropertyFlags.Required), + ("location", LanguageConstants.String, TypePropertyFlags.Required))); + + IEnumerable snippets = snippetsProvider.GetResourceBodyCompletionSnippets(typeSymbol); + + snippets.Should().SatisfyRespectively( + x => + { + x.Prefix.Should().Be("{}"); + x.Detail.Should().Be("{}"); + x.CompletionPriority.Should().Be(CompletionPriority.Medium); + x.Text.Should().Be("{\n\t$0\n}"); + }, + x => + { + x.Prefix.Should().Be("insert-snippet"); + x.Detail.Should().Be("Automation Module"); + x.CompletionPriority.Should().Be(CompletionPriority.Medium); + x.Text.Should().BeEquivalentToIgnoringNewlines(@"{ + parent: automationAccount + name: ${3:'name'} + properties: { + contentLink: { + uri: ${4:'https://content-url.nupkg'} + } + } +} +resource automationAccount 'Microsoft.Automation/automationAccounts@2015-10-31' = { + name: ${1:'name'} +} +"); + }, + x => + { + x.Prefix.Should().Be("insert-required"); + x.Detail.Should().Be("Required properties"); + x.CompletionPriority.Should().Be(CompletionPriority.Medium); + x.Text.Should().BeEquivalentToIgnoringNewlines(@"{ + name: $1 + location: $2 + $0 +}"); + }); + } + + [TestMethod] + public void GetResourceBodyCompletionSnippets_WithNoStaticTemplate_ShouldReturnSnippets() + { + SnippetsProvider snippetsProvider = new SnippetsProvider(); + TypeSymbol typeSymbol = new ResourceType( + ResourceTypeReference.Parse("microsoft.aadiam/azureADMetrics@2020-07-01-preview"), + ResourceScope.ResourceGroup, + CreateObjectType("microsoft.aadiam/azureADMetrics@2020-07-01-preview", + ("name", LanguageConstants.String, TypePropertyFlags.Required), + ("location", LanguageConstants.String, TypePropertyFlags.Required), + ("kind", LanguageConstants.String, TypePropertyFlags.Required), + ("id", LanguageConstants.String, TypePropertyFlags.ReadOnly), + ("hostPoolType", LanguageConstants.String, TypePropertyFlags.Required), + ("sku", CreateObjectType("applicationGroup", + ("friendlyName", LanguageConstants.String, TypePropertyFlags.None), + ("properties", CreateObjectType("properties", + ("loadBalancerType", LanguageConstants.String, TypePropertyFlags.Required), + ("preferredAppGroupType", LanguageConstants.String, TypePropertyFlags.WriteOnly)), + TypePropertyFlags.Required), + ("name", LanguageConstants.String, TypePropertyFlags.Required)), + TypePropertyFlags.Required))); + + IEnumerable snippets = snippetsProvider.GetResourceBodyCompletionSnippets(typeSymbol); + + snippets.Should().SatisfyRespectively( + x => + { + x.Prefix.Should().Be("{}"); + x.Detail.Should().Be("{}"); + x.CompletionPriority.Should().Be(CompletionPriority.Medium); + x.Text.Should().Be("{\n\t$0\n}"); + }, + x => + { + x.Prefix.Should().Be("insert-required"); + x.Detail.Should().Be("Required properties"); + x.CompletionPriority.Should().Be(CompletionPriority.Medium); + x.Text.Should().BeEquivalentToIgnoringNewlines(@"{ + name: $1 + location: $2 + sku: { + name: $3 + properties: { + loadBalancerType: $4 + } + } + kind: $5 + hostPoolType: $6 + $0 +}"); + }); + } + + [TestMethod] + public void GetResourceBodyCompletionSnippets_WithNoRequiredProperties_ShouldReturnEmptySnippet() + { + SnippetsProvider snippetsProvider = new SnippetsProvider(); + TypeSymbol typeSymbol = new ResourceType( + ResourceTypeReference.Parse("microsoft.aadiam/azureADMetrics@2020-07-01-preview"), + ResourceScope.ResourceGroup, + CreateObjectType("microsoft.aadiam/azureADMetrics@2020-07-01-preview")); + + IEnumerable snippets = snippetsProvider.GetResourceBodyCompletionSnippets(typeSymbol); + + snippets.Should().SatisfyRespectively( + x => + { + x.Prefix.Should().Be("{}"); + x.Detail.Should().Be("{}"); + x.CompletionPriority.Should().Be(CompletionPriority.Medium); + x.Text.Should().Be("{\n\t$0\n}"); + }); + } + + private static ObjectType CreateObjectType(string name, params (string name, ITypeReference type, TypePropertyFlags typePropertyFlags)[] properties) + => new( + name, + TypeSymbolValidationFlags.Default, + properties.Select(val => new TypeProperty(val.name, val.type, val.typePropertyFlags)), + null); } } diff --git a/src/Bicep.LangServer/Completions/BicepCompletionProvider.cs b/src/Bicep.LangServer/Completions/BicepCompletionProvider.cs index 35b134ca281..72b1c950b1e 100644 --- a/src/Bicep.LangServer/Completions/BicepCompletionProvider.cs +++ b/src/Bicep.LangServer/Completions/BicepCompletionProvider.cs @@ -15,6 +15,7 @@ using Bicep.Core.Semantics; using Bicep.Core.Syntax; using Bicep.Core.TypeSystem; +using Bicep.Core.TypeSystem.Az; using Bicep.LanguageServer.Extensions; using Bicep.LanguageServer.Snippets; using OmniSharp.Extensions.LanguageServer.Protocol.Models; @@ -58,7 +59,8 @@ public IEnumerable GetFilteredCompletions(Compilation compilatio .Concat(GetResourceTypeCompletions(model, context)) .Concat(GetResourceTypeFollowerCompletions(context)) .Concat(GetModulePathCompletions(model, context)) - .Concat(GetResourceOrModuleBodyCompletions(context)) + .Concat(GetModuleBodyCompletions(context)) + .Concat(GetResourceBodyCompletions(model, context)) .Concat(GetParameterDefaultValueCompletions(model, context)) .Concat(GetVariableValueCompletions(context)) .Concat(GetOutputValueCompletions(model, context)) @@ -367,9 +369,48 @@ private IEnumerable GetOutputValueCompletions(SemanticModel mode return GetValueCompletionsForType(declaredType, context.ReplacementRange, model, context, loopsAllowed: true); } - private IEnumerable GetResourceOrModuleBodyCompletions(BicepCompletionContext context) + private IEnumerable GetResourceBodyCompletions(SemanticModel model, BicepCompletionContext context) { - if (context.Kind.HasFlag(BicepCompletionContextKind.ResourceBody) || context.Kind.HasFlag(BicepCompletionContextKind.ModuleBody)) + if (context.Kind.HasFlag(BicepCompletionContextKind.ResourceBody)) + { + foreach (CompletionItem completionItem in CreateResourceBodyCompletions(model, context)) + { + yield return completionItem; + } + + yield return CreateResourceOrModuleConditionCompletion(context.ReplacementRange); + + // loops are always allowed in a resource/module + foreach (var completion in CreateLoopCompletions(context.ReplacementRange, LanguageConstants.Object, filtersAllowed: true)) + { + yield return completion; + } + } + } + + private IEnumerable CreateResourceBodyCompletions(SemanticModel model, BicepCompletionContext context) + { + if (context.EnclosingDeclaration is ResourceDeclarationSyntax resourceDeclarationSyntax) + { + TypeSymbol typeSymbol = resourceDeclarationSyntax.GetDeclaredType(model.Binder, AzResourceTypeProvider.CreateWithAzTypes()); + + IEnumerable snippets = SnippetsProvider.GetResourceBodyCompletionSnippets(typeSymbol); + + foreach (Snippet snippet in snippets) + { + yield return CreateContextualSnippetCompletion(snippet!.Prefix, + snippet.Detail, + snippet.Text, + context.ReplacementRange, + snippet.CompletionPriority, + preselect: true); + } + } + } + + private IEnumerable GetModuleBodyCompletions(BicepCompletionContext context) + { + if (context.Kind.HasFlag(BicepCompletionContextKind.ModuleBody)) { yield return CreateObjectBodyCompletion(context.ReplacementRange); @@ -905,13 +946,14 @@ private static CompletionItem CreateModulePathCompletion(string name, string pat /// /// Creates a completion with a contextual snippet. This will look like a snippet to the user. /// - private static CompletionItem CreateContextualSnippetCompletion(string label, string detail, string snippet, Range replacementRange, CompletionPriority priority = CompletionPriority.Medium) => + private static CompletionItem CreateContextualSnippetCompletion(string label, string detail, string snippet, Range replacementRange, CompletionPriority priority = CompletionPriority.Medium, bool preselect = false) => CompletionItemBuilder.Create(CompletionItemKind.Snippet) .WithLabel(label) .WithSnippetEdit(replacementRange, snippet) .WithDetail(detail) .WithDocumentation($"```bicep\n{new Snippet(snippet).FormatDocumentation()}\n```") - .WithSortText(GetSortText(label, priority)); + .WithSortText(GetSortText(label, priority)) + .Preselect(preselect); /// /// Creates a completion with a contextual snippet. This will look like a snippet to the user. diff --git a/src/Bicep.LangServer/Completions/CompletionItemBuilder.cs b/src/Bicep.LangServer/Completions/CompletionItemBuilder.cs index 3052aeb08c3..ad8a4ec5187 100644 --- a/src/Bicep.LangServer/Completions/CompletionItemBuilder.cs +++ b/src/Bicep.LangServer/Completions/CompletionItemBuilder.cs @@ -86,9 +86,6 @@ public static CompletionItem WithSnippetEdit(this CompletionItem item, Range ran return item; } - - - public static CompletionItem WithSortText(this CompletionItem item, string sortText) { item.SortText = sortText; @@ -103,7 +100,6 @@ public static CompletionItem Preselect(this CompletionItem item, bool preselect) return item; } - public static CompletionItem WithCommand(this CompletionItem item, Command command) { item.Command = command; diff --git a/src/Bicep.LangServer/Snippets/ISnippetsProvider.cs b/src/Bicep.LangServer/Snippets/ISnippetsProvider.cs index ec36f794d52..dde1605a421 100644 --- a/src/Bicep.LangServer/Snippets/ISnippetsProvider.cs +++ b/src/Bicep.LangServer/Snippets/ISnippetsProvider.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Bicep.Core.TypeSystem; using System.Collections.Generic; namespace Bicep.LanguageServer.Snippets @@ -8,5 +9,7 @@ namespace Bicep.LanguageServer.Snippets public interface ISnippetsProvider { IEnumerable GetTopLevelNamedDeclarationSnippets(); + + IEnumerable GetResourceBodyCompletionSnippets(TypeSymbol typeSymbol); } } diff --git a/src/Bicep.LangServer/Snippets/Snippet.cs b/src/Bicep.LangServer/Snippets/Snippet.cs index 76fe51121f0..21c52878e0c 100644 --- a/src/Bicep.LangServer/Snippets/Snippet.cs +++ b/src/Bicep.LangServer/Snippets/Snippet.cs @@ -32,8 +32,6 @@ public Snippet(string text, CompletionPriority completionPriority = CompletionPr .Select(CreatePlaceholder) .OrderBy(p=>p.Index) .ToImmutableArray(); - - this.Validate(); } public string Prefix { get; } @@ -87,20 +85,5 @@ private static SnippetPlaceholder CreatePlaceholder(Match match) name: name, span: new TextSpan(match.Index, match.Length)); } - - private void Validate() - { - // empty snippet is pointless but still valid - if (this.Placeholders.IsEmpty) - { - return; - } - - var firstPlaceholderIndex = this.Placeholders.First().Index; - if (firstPlaceholderIndex != 0 && firstPlaceholderIndex != 1) - { - throw new ArgumentException($"The first snippet placeholder must have index 0 or 1, but the provided index is {firstPlaceholderIndex}"); - } - } } } diff --git a/src/Bicep.LangServer/Snippets/SnippetsProvider.cs b/src/Bicep.LangServer/Snippets/SnippetsProvider.cs index 2d874d1eb12..057aac843fe 100644 --- a/src/Bicep.LangServer/Snippets/SnippetsProvider.cs +++ b/src/Bicep.LangServer/Snippets/SnippetsProvider.cs @@ -2,21 +2,57 @@ // Licensed under the MIT License. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Immutable; using System.IO; using System.Linq; using System.Reflection; using System.Text; +using Bicep.Core.Diagnostics; +using Bicep.Core.Emit; using Bicep.Core.Parsing; +using Bicep.Core.Semantics; using Bicep.Core.Syntax; +using Bicep.Core.TypeSystem; +using Bicep.Core.TypeSystem.Az; using Bicep.LanguageServer.Completions; namespace Bicep.LanguageServer.Snippets { public class SnippetsProvider : ISnippetsProvider { + // Used to cache resource declarations. Maps resource type to body text and description + private ConcurrentDictionary resourceTypeToBodyMap = new ConcurrentDictionary(); + // Used to cache resource dependencies. Maps resource type to it's dependencies + private ConcurrentDictionary resourceTypeToDependentsMap = new ConcurrentDictionary(); + // Used to cache resource body snippets + private ConcurrentDictionary> resourceBodySnippetsCache = new ConcurrentDictionary>(); + // Used to cache top level declarations private HashSet topLevelNamedDeclarationSnippets = new HashSet(); + // The common properties should be authored consistently to provide for understandability and consumption of the code. + // See https://github.com/Azure/azure-quickstart-templates/blob/master/1-CONTRIBUTION-GUIDE/best-practices.md#resources + // for more information + private List propertiesSortPreferenceList = new List + { + "comments", + "condition", + "scope", + "type", + "apiVersion", + "name", + "location", + "zones", + "sku", + "kind", + "scale", + "plan", + "identity", + "copy", + "dependsOn", + "tags", + "properties" + }; public SnippetsProvider() { @@ -34,7 +70,7 @@ private void Initialize() Stream? stream = assembly.GetManifestResourceStream(manifestResourceName); StreamReader streamReader = new StreamReader(stream ?? throw new ArgumentNullException("Stream is null"), Encoding.Default); - (string description, string snippetText) = GetDescriptionAndText(streamReader.ReadToEnd()); + (string description, string snippetText) = GetDescriptionAndText(streamReader.ReadToEnd(), manifestResourceName); string prefix = Path.GetFileNameWithoutExtension(manifestResourceName); CompletionPriority completionPriority = CompletionPriority.Medium; @@ -49,7 +85,7 @@ private void Initialize() } } - public (string, string) GetDescriptionAndText(string? template) + public (string, string) GetDescriptionAndText(string? template, string manifestResourceName) { string description = string.Empty; string text = string.Empty; @@ -74,6 +110,8 @@ firstToken.LeadingTrivia[0] is SyntaxTrivia syntaxTrivia && { description = syntaxTrivia.Text.Substring("// ".Length); } + + CacheResourceDeclarationAndDependencies(template, manifestResourceName, description); } } @@ -81,5 +119,210 @@ firstToken.LeadingTrivia[0] is SyntaxTrivia syntaxTrivia && } public IEnumerable GetTopLevelNamedDeclarationSnippets() => topLevelNamedDeclarationSnippets; + + private void CacheResourceDeclarationAndDependencies(string template, string manifestResourceName, string description) + { + ImmutableDictionary> dependencies = GetResourceDependencies(template, manifestResourceName); + + foreach (KeyValuePair> kvp in dependencies) + { + DeclaredSymbol declaredSymbol = kvp.Key; + + if (declaredSymbol.DeclaringSyntax is ResourceDeclarationSyntax resourceDeclarationSyntax) + { + string type = declaredSymbol.Type.Name; + + CacheResourceDeclaration(resourceDeclarationSyntax, type, template, description); + CacheResourceDependencies(kvp.Value, template, type); + } + } + } + + private void CacheResourceDeclaration(ResourceDeclarationSyntax resourceDeclarationSyntax, string type, string template, string description) + { + if (!resourceTypeToBodyMap.ContainsKey(type)) + { + TextSpan bodySpan = resourceDeclarationSyntax.Value.Span; + string bodyText = template.Substring(bodySpan.Position, bodySpan.Length); + + resourceTypeToBodyMap.TryAdd(type, (bodyText, description)); + } + } + + private void CacheResourceDependencies(ImmutableHashSet resourceDependencies, string template, string resourceType) + { + if (resourceDependencies.Any()) + { + StringBuilder sb = new StringBuilder(); + foreach (ResourceDependency resourceDependency in resourceDependencies) + { + TextSpan span = resourceDependency.Resource.DeclaringSyntax.Span; + sb.AppendLine(template.Substring(span.Position, span.Length)); + } + + resourceTypeToDependentsMap.TryAdd(resourceType, sb.ToString()); + } + } + + private ImmutableDictionary> GetResourceDependencies(string template, string manifestResourceName) + { + // Snippets with prefix resource will not have valid type, so there can't be any dependencies + if (manifestResourceName.Contains("resource")) + { + return ImmutableDictionary.Create>(); + } + + string path = Path.GetFullPath(manifestResourceName); + SyntaxTree syntaxTree = SyntaxTree.Create(new Uri(path), template); + SyntaxTreeGrouping syntaxTreeGrouping = new SyntaxTreeGrouping( + syntaxTree, + ImmutableHashSet.Create(syntaxTree), + ImmutableDictionary.Create(), + ImmutableDictionary.Create()); + + Compilation compilation = new Compilation(AzResourceTypeProvider.CreateWithAzTypes(), syntaxTreeGrouping); + SemanticModel semanticModel = compilation.GetEntrypointSemanticModel(); + + return ResourceDependencyVisitor.GetResourceDependencies(semanticModel); + } + + public IEnumerable GetResourceBodyCompletionSnippets(TypeSymbol typeSymbol) + { + if (resourceBodySnippetsCache.TryGetValue(typeSymbol.Name, out IEnumerable? cachedSnippets) && cachedSnippets.Any()) + { + return cachedSnippets; + } + + List snippets = new List(); + + snippets.Add(GetEmptySnippet()); + + Snippet? snippetFromExistingTemplate = GetResourceBodyCompletionSnippetFromTemplate(typeSymbol); + if (snippetFromExistingTemplate is not null) + { + snippets.Add(snippetFromExistingTemplate); + } + + Snippet? snippetFromAzTypes = GetResourceBodyCompletionSnippetFromAzTypes(typeSymbol); + if (snippetFromAzTypes is not null) + { + snippets.Add(snippetFromAzTypes); + } + + // Add to cache + resourceBodySnippetsCache.TryAdd(typeSymbol.Name, snippets); + + return snippets; + } + + private Snippet? GetResourceBodyCompletionSnippetFromTemplate(TypeSymbol typeSymbol) + { + string label = "insert-snippet"; + string type = typeSymbol.Name; + + StringBuilder sb = new StringBuilder(); + + // Get resource body completion snippet from checked in static template file, if available + if (resourceTypeToBodyMap.TryGetValue(type, out (string, string) resourceBodyWithDescription)) + { + sb.AppendLine(resourceBodyWithDescription.Item1); + + if (resourceTypeToDependentsMap.TryGetValue(type, out string? resourceDependencies)) + { + sb.Append(resourceDependencies); + } + + return new Snippet(sb.ToString(), CompletionPriority.Medium, label, resourceBodyWithDescription.Item2); + } + + return null; + } + + private Snippet? GetResourceBodyCompletionSnippetFromAzTypes(TypeSymbol typeSymbol) + { + string label = "insert-required"; + string description = "Required properties"; + + if (typeSymbol is ResourceType resourceType && resourceType.Body is ObjectType objectType) + { + int index = 1; + StringBuilder sb = new StringBuilder(); + + IOrderedEnumerable> sortedProperties = objectType.Properties.OrderBy(x => propertiesSortPreferenceList.Exists(y => y == x.Key) ? + propertiesSortPreferenceList.FindIndex(y => y == x.Key) : + propertiesSortPreferenceList.Count - 1); + + foreach (KeyValuePair kvp in sortedProperties) + { + string? snippetText = GetSnippetText(kvp.Value, indentLevel: 1, ref index); + + if (snippetText is not null) + { + sb.Append(snippetText); + } + } + + if (sb.Length > 0) + { + // Insert open curly at the beginning + sb.Insert(0, "{\n"); + + // Append final tab stop + sb.Append("\t$0\n}"); + + return new Snippet(sb.ToString(), CompletionPriority.Medium, label, description); + } + } + + return null; + } + + private string? GetSnippetText(TypeProperty typeProperty, int indentLevel, ref int index) + { + if (typeProperty.Flags.HasFlag(TypePropertyFlags.Required)) + { + StringBuilder sb = new StringBuilder(); + + if (typeProperty.TypeReference.Type is ObjectType objectType) + { + sb.AppendLine(GetIndentString(indentLevel) + typeProperty.Name + ": {"); + + indentLevel++; + + foreach (KeyValuePair kvp in objectType.Properties.OrderBy(x => x.Key)) + { + string? snippetText = GetSnippetText(kvp.Value, indentLevel, ref index); + if (snippetText is not null) + { + sb.Append(snippetText); + } + } + + indentLevel--; + sb.AppendLine(GetIndentString(indentLevel) + "}"); + } + else + { + sb.AppendLine(GetIndentString(indentLevel) + typeProperty.Name + ": $" + (index).ToString()); + index++; + } + + return sb.ToString(); + } + + return null; + } + + private string GetIndentString(int indentLevel) + { + return new string('\t', indentLevel); + } + + private Snippet GetEmptySnippet() + { + string label = "{}"; + + return new Snippet("{\n\t$0\n}", CompletionPriority.Medium, label, label); + } } } diff --git a/src/Bicep.LangServer/Snippets/Templates/res-automation-module.bicep b/src/Bicep.LangServer/Snippets/Templates/res-automation-module.bicep new file mode 100644 index 00000000000..cf05a425a55 --- /dev/null +++ b/src/Bicep.LangServer/Snippets/Templates/res-automation-module.bicep @@ -0,0 +1,14 @@ +// Automation Module +resource automationAccount 'Microsoft.Automation/automationAccounts@2015-10-31' = { + name: ${1:'name'} +} + +resource ${2:automationAccountVariable} 'Microsoft.Automation/automationAccounts/modules@2015-10-31' = { + parent: automationAccount + name: ${3:'name'} + properties: { + contentLink: { + uri: ${4:'https://content-url.nupkg'} + } + } +} \ No newline at end of file