Skip to content

Commit f1c7212

Browse files
Escape strings correctly in snippets (#14582)
Use the syntax factory + pretty printer to emit snippets rather than ad-hoc string formatting. This avoids having to deal with edge cases like escaping. Closes #14578 ###### Microsoft Reviewers: [Open in CodeFlow](https://microsoft.github.io/open-pr/?codeflow=https://github.com/Azure/bicep/pull/14582)
1 parent d5c6d14 commit f1c7212

File tree

3 files changed

+151
-78
lines changed

3 files changed

+151
-78
lines changed

src/Bicep.Core.Samples/Files/baselines/InvalidModules_LF/Completions/moduleBodyCompletions.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@
7373
"detail": "Required properties",
7474
"documentation": {
7575
"kind": "markdown",
76-
"value": "```bicep\n{\n\tname: \n\tparams: {\n\t\tarrayParam: \n\t\tobjParam: {\n\t\t}\n\t\tstringParamB: \n\t}\n}\n``` \n"
76+
"value": "```bicep\n{\n\tname: \n\tparams: {\n\t\tarrayParam: \n\t\tobjParam: {}\n\t\tstringParamB: \n\t}\n}\n``` \n"
7777
},
7878
"deprecated": false,
7979
"preselect": true,
@@ -82,7 +82,7 @@
8282
"insertTextMode": "adjustIndentation",
8383
"textEdit": {
8484
"range": {},
85-
"newText": "{\n\tname: $1\n\tparams: {\n\t\tarrayParam: $2\n\t\tobjParam: {\n\t\t}\n\t\tstringParamB: $3\n\t}\n}$0"
85+
"newText": "{\n\tname: $1\n\tparams: {\n\t\tarrayParam: $2\n\t\tobjParam: {}\n\t\tstringParamB: $3\n\t}\n}$0"
8686
},
8787
"command": {
8888
"title": "module body completion snippet",

src/Bicep.LangServer.IntegrationTests/CompletionTests.cs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4899,5 +4899,102 @@ await RunCompletionScenarioTest(TestContext, ServerWithNamespaceProvider, fileWi
48994899
completionList.Should().SatisfyRespectively(i => i.Label.Should().Be("*"));
49004900
});
49014901
}
4902+
4903+
[TestMethod]
4904+
public async Task Strings_in_required_property_completions_are_correctly_escaped()
4905+
{
4906+
var fileWithCursors = """
4907+
@discriminator('odata.type')
4908+
type alertType = alertWebtestType | alertResourceType | alertMultiResourceType
4909+
type alertResourceType = {
4910+
'odata.type': 'Microsoft.Azure.Monitor.SingleResourceMultipleMetricCriteria'
4911+
allof: array
4912+
}
4913+
type alertMultiResourceType = {
4914+
'odata.type': 'Microsoft.Azure.Monitor.MultipleResourceMultipleMetricCriteria'
4915+
allof: array
4916+
}
4917+
type alertWebtestType = {
4918+
'odata.type': 'Microsoft.Azure.Monitor.WebtestLocationAvailabilityCriteria'
4919+
componentId: string
4920+
failedLocationCount: int
4921+
webTestId: string
4922+
}
4923+
4924+
param myAlert alertType = |>
4925+
""";
4926+
4927+
var (text, cursor) = ParserHelper.GetFileWithSingleCursor(fileWithCursors, "|>");
4928+
var file = await new ServerRequestHelper(TestContext, ServerWithExtensibilityEnabled).OpenFile(text);
4929+
4930+
var completions = await file.RequestCompletion(cursor);
4931+
4932+
var updatedFile = file.ApplyCompletion(completions, "required-properties-Microsoft.Azure.Monitor.WebtestLocationAvailabilityCriteria");
4933+
updatedFile.Should().HaveSourceText("""
4934+
@discriminator('odata.type')
4935+
type alertType = alertWebtestType | alertResourceType | alertMultiResourceType
4936+
type alertResourceType = {
4937+
'odata.type': 'Microsoft.Azure.Monitor.SingleResourceMultipleMetricCriteria'
4938+
allof: array
4939+
}
4940+
type alertMultiResourceType = {
4941+
'odata.type': 'Microsoft.Azure.Monitor.MultipleResourceMultipleMetricCriteria'
4942+
allof: array
4943+
}
4944+
type alertWebtestType = {
4945+
'odata.type': 'Microsoft.Azure.Monitor.WebtestLocationAvailabilityCriteria'
4946+
componentId: string
4947+
failedLocationCount: int
4948+
webTestId: string
4949+
}
4950+
4951+
param myAlert alertType = {
4952+
componentId: $1
4953+
failedLocationCount: $2
4954+
'odata.type': 'Microsoft.Azure.Monitor.WebtestLocationAvailabilityCriteria'
4955+
webTestId: $3
4956+
}|
4957+
""");
4958+
}
4959+
4960+
[TestMethod]
4961+
public async Task Nested_tab_stops_are_correctly_ordered_in_required_properties_snippet()
4962+
{
4963+
var fileWithCursors = """
4964+
type nestedType = {
4965+
foo: string
4966+
bar: {
4967+
bar: string
4968+
}
4969+
baz: string
4970+
}
4971+
4972+
param test nestedType = |>
4973+
""";
4974+
4975+
var (text, cursor) = ParserHelper.GetFileWithSingleCursor(fileWithCursors, "|>");
4976+
var file = await new ServerRequestHelper(TestContext, ServerWithExtensibilityEnabled).OpenFile(text);
4977+
4978+
var completions = await file.RequestCompletion(cursor);
4979+
4980+
var updatedFile = file.ApplyCompletion(completions, "required-properties");
4981+
updatedFile.Should().HaveSourceText("""
4982+
type nestedType = {
4983+
foo: string
4984+
bar: {
4985+
bar: string
4986+
}
4987+
baz: string
4988+
}
4989+
4990+
param test nestedType = {
4991+
bar: {
4992+
bar: $1
4993+
}
4994+
baz: $2
4995+
foo: $3
4996+
}|
4997+
""");
4998+
}
49024999
}
49035000
}

src/Bicep.LangServer/Snippets/SnippetsProvider.cs

Lines changed: 52 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@
33

44
using System.Collections.Concurrent;
55
using System.Collections.Immutable;
6+
using System.DirectoryServices.Protocols;
67
using System.Text;
78
using System.Text.RegularExpressions;
89
using Bicep.Core;
10+
using Bicep.Core.Parsing;
11+
using Bicep.Core.PrettyPrint;
12+
using Bicep.Core.PrettyPrintV2;
913
using Bicep.Core.Resources;
1014
using Bicep.Core.Syntax;
1115
using Bicep.Core.TypeSystem;
@@ -25,7 +29,7 @@ public class SnippetsProvider : ISnippetsProvider
2529
// The common properties should be authored consistently to provide for understandability and consumption of the code.
2630
// See https://github.com/Azure/azure-quickstart-templates/blob/master/1-CONTRIBUTION-GUIDE/best-practices.md#resources
2731
// for more information
28-
private readonly ImmutableArray<string> propertiesSortPreferenceList = ["scope", "parent", "name", "location", "zones", "sku", "kind", "scale", "plan", "identity", "tags", "properties", "dependsOn"];
32+
private static readonly ImmutableArray<string> PropertiesSortPreferenceList = ["scope", "parent", "name", "location", "zones", "sku", "kind", "scale", "plan", "identity", "tags", "properties", "dependsOn"];
2933

3034
private static readonly SnippetCache snippetCache = SnippetCache.FromManifest();
3135

@@ -119,96 +123,68 @@ private IEnumerable<Snippet> GetRequiredPropertiesSnippetsForDisciminatedObjectT
119123
}
120124
}
121125

122-
private Snippet? GetRequiredPropertiesSnippet(ObjectType objectType, string label, string? discriminatedObjectKey = null)
126+
private static ObjectSyntax GetObjectSnippetSyntax(ObjectType objectType, ref int tabStopIndex, string? discriminatedObjectKey)
123127
{
124-
int index = 1;
125-
StringBuilder sb = new();
126-
127-
var sortedProperties = objectType.Properties.OrderBy(x =>
128+
var typeProperties = objectType.Properties.Values.OrderBy(x =>
129+
PropertiesSortPreferenceList.IndexOf(x.Name) switch {
130+
-1 => int.MaxValue,
131+
int index => index,
132+
})
133+
.Where(TypeHelper.IsRequired);
134+
135+
var objectProperties = new List<ObjectPropertySyntax>();
136+
foreach (var typeProperty in typeProperties)
128137
{
129-
var index = propertiesSortPreferenceList.IndexOf(x.Key);
138+
// Here we deliberately want to iterate in the correct order, and use a DFS approach, to ensure that the tab stops are correctly ordered.
139+
// For example, we want to ensure we output: {\n foo: $1\n nested: {\n bar: $2\n }\n baz: $3\n}
140+
// Instead of: {\n foo: $1\n nested: {\n bar: $3\n }\n baz: $2\n}
141+
objectProperties.Add(GetObjectPropertySnippetSyntax(typeProperty, ref tabStopIndex, discriminatedObjectKey));
142+
}
130143

131-
return (index > -1) ? index : (propertiesSortPreferenceList.Length - 1);
132-
});
144+
return SyntaxFactory.CreateObject(objectProperties);
145+
}
133146

134-
foreach (var (key, value) in sortedProperties)
147+
private static ObjectPropertySyntax GetObjectPropertySnippetSyntax(TypeProperty typeProperty, ref int tabStopIndex, string? discriminatedObjectKey)
148+
{
149+
var valueType = typeProperty.TypeReference.Type;
150+
if (valueType is ObjectType objectType)
135151
{
136-
string? snippetText = GetSnippetText(value, indentLevel: 1, ref index, discriminatedObjectKey);
137-
138-
if (snippetText is not null)
139-
{
140-
sb.Append(snippetText);
141-
}
152+
return SyntaxFactory.CreateObjectProperty(
153+
typeProperty.Name,
154+
GetObjectSnippetSyntax(objectType, ref tabStopIndex, null));
142155
}
143-
144-
if (sb.Length > 0)
156+
else if (discriminatedObjectKey is {} &&
157+
valueType is StringLiteralType stringLiteralType &&
158+
stringLiteralType.Name == discriminatedObjectKey)
145159
{
146-
// Insert open curly at the beginning
147-
sb.Insert(0, "{\n");
148-
149-
// Insert final tab stop outside the top level object
150-
sb.Append("}$0");
151-
152-
return new Snippet(sb.ToString(), CompletionPriority.Medium, label, RequiredPropertiesDescription);
160+
return SyntaxFactory.CreateObjectProperty(
161+
typeProperty.Name,
162+
SyntaxFactory.CreateStringLiteral(stringLiteralType.RawStringValue));
163+
}
164+
else
165+
{
166+
var newTabStopIndex = tabStopIndex++;
167+
return SyntaxFactory.CreateObjectProperty(
168+
typeProperty.Name,
169+
SyntaxFactory.CreateFreeformToken(TokenType.Unrecognized, GetTabStop(newTabStopIndex)));
153170
}
154-
155-
return null;
156171
}
157172

158-
private string? GetSnippetText(TypeProperty typeProperty, int indentLevel, ref int index, string? discrimatedObjectKey = null)
173+
private static string GetTabStop(int index)
174+
=> $"${index}";
175+
176+
private Snippet? GetRequiredPropertiesSnippet(ObjectType objectType, string label, string? discriminatedObjectKey = null)
159177
{
160-
if (TypeHelper.IsRequired(typeProperty))
178+
if (!objectType.Properties.Values.Any(TypeHelper.IsRequired))
161179
{
162-
StringBuilder sb = new();
163-
164-
if (typeProperty.TypeReference.Type is ObjectType objectType)
165-
{
166-
sb.AppendLine(GetIndentString(indentLevel) + typeProperty.Name + ": {");
167-
168-
indentLevel++;
169-
170-
foreach (KeyValuePair<string, TypeProperty> kvp in objectType.Properties.OrderBy(x => x.Key))
171-
{
172-
string? snippetText = GetSnippetText(kvp.Value, indentLevel, ref index);
173-
if (snippetText is not null)
174-
{
175-
sb.Append(snippetText);
176-
}
177-
}
178-
179-
indentLevel--;
180-
sb.AppendLine(GetIndentString(indentLevel) + "}");
181-
}
182-
else
183-
{
184-
string value = ": $" + (index).ToString();
185-
bool shouldIncrementIndent = true;
186-
187-
if (discrimatedObjectKey is not null &&
188-
typeProperty.TypeReference.Type is TypeSymbol typeSymbol &&
189-
typeSymbol.Name == discrimatedObjectKey)
190-
{
191-
value = ": " + discrimatedObjectKey;
192-
shouldIncrementIndent = false;
193-
}
194-
195-
sb.AppendLine(GetIndentString(indentLevel) + typeProperty.Name + value);
196-
197-
if (shouldIncrementIndent)
198-
{
199-
index++;
200-
}
201-
}
202-
203-
return sb.ToString();
180+
return null;
204181
}
205182

206-
return null;
207-
}
183+
var tabStopIndex = 1;
184+
var syntax = GetObjectSnippetSyntax(objectType, ref tabStopIndex, discriminatedObjectKey);
208185

209-
private string GetIndentString(int indentLevel)
210-
{
211-
return new string('\t', indentLevel);
186+
var output = PrettyPrinterV2.PrintValid(syntax, PrettyPrinterV2Options.Default with { IndentKind = IndentKind.Tab }) + GetTabStop(0);
187+
return new Snippet(output, CompletionPriority.Medium, label, RequiredPropertiesDescription);
212188
}
213189

214190
private Snippet GetEmptySnippet()

0 commit comments

Comments
 (0)