in src/Bicep.LangServer/Handlers/BicepDecompileForPasteCommandHandler.cs [24:634]
public record BicepDecompileForPasteCommandParams(
string bicepContent,
int rangeOffset,
int rangeLength,
string jsonContent,
bool queryCanPaste, // True if client is testing clipboard text for menu enabling only, false if the user actually requested a paste,
string languageId
);
public record BicepDecompileForPasteCommandResult
(
string DecompileId, // Used to synchronize `ry events
string Output,
string? PasteContext,
string? PasteType,
string? ErrorMessage, // This is null if pasteType == null, otherwise indicates an error trying to decompile to the given paste type
string? Bicep, // Null if pasteType == null or errorMessage != null
string? Disclaimer
);
/// <summary>
/// Handles a request from the client to analyze/decompile a JSON fragment for possible conversion into Bicep (for pasting into a Bicep file)
/// </summary>
public class BicepDecompileForPasteCommandHandler(
ISerializer serializer,
ILanguageServerFacade server,
ITelemetryProvider telemetryProvider,
ISourceFileFactory sourceFileFactory,
BicepCompiler bicepCompiler)
: ExecuteTypedResponseCommandHandlerBase<BicepDecompileForPasteCommandParams, BicepDecompileForPasteCommandResult>(LangServerConstants.DecompileForPasteCommand, serializer)
{
private readonly TelemetryAndErrorHandlingHelper<BicepDecompileForPasteCommandResult> telemetryHelper = new(server.Window, telemetryProvider);
private static readonly Uri JsonDummyUri = new("file:///from-clipboard.json", UriKind.Absolute);
private static readonly Uri BicepDummyUri = PathHelper.ChangeToBicepExtension(JsonDummyUri);
private static readonly Uri BicepParamsDummyUri = PathHelper.ChangeToBicepparamExtension(JsonDummyUri);
public enum PasteType
{
None = 0,
FullTemplate = 1, // Full template
SingleResource = 2, // Single resource
ResourceList = 3,// List of multiple resources
JsonValue = 4, // Single JSON value (number, object, array etc)
BicepValue = 5, // JSON value that is also valid Bicep (e.g. "[1, {}]")
FullParams = 6 // Full parameters file
}
public enum PasteContext
{
None,
String, // Pasting inside a string
ParamsWithUsingDeclaration, // Pasting inside a parameters file with an existing using declaration
}
private enum LanguageId
{
Bicep,
BicepParams,
}
private static LanguageId GetLanguageId(string languageId)
{
return languageId switch
{
"bicep" => LanguageId.Bicep,
"bicep-params" => LanguageId.BicepParams,
_ => throw new ArgumentException($"Unexpected languageId value {languageId}"),
};
}
private static string LanguageIdAsString(LanguageId languageId)
{
return languageId switch
{
LanguageId.Bicep => "bicep",
LanguageId.BicepParams => "bicep-params",
_ => throw new ArgumentException($"Unexpected languageId value {languageId}"),
};
}
private record ResultAndTelemetry(BicepDecompileForPasteCommandResult Result, BicepTelemetryEvent? SuccessTelemetry);
public override Task<BicepDecompileForPasteCommandResult> Handle(BicepDecompileForPasteCommandParams parameters, CancellationToken cancellationToken)
{
return telemetryHelper.ExecuteWithTelemetryAndErrorHandling((Func<Task<(BicepDecompileForPasteCommandResult result, BicepTelemetryEvent? successTelemetry)>>)(async () =>
{
var (result, successTelemetry) = await TryDecompileForPaste(
parameters.bicepContent,
parameters.rangeOffset,
parameters.rangeLength,
parameters.jsonContent,
parameters.queryCanPaste,
GetLanguageId(parameters.languageId));
return (result, successTelemetry);
}));
}
private static PasteContext GetPasteContext(string bicepContents, int offset, int length, LanguageId languageId)
{
var newContents = string.Concat(bicepContents.AsSpan()[..offset], bicepContents.AsSpan(offset + length));
BaseParser parser = languageId switch
{
LanguageId.Bicep => new Parser(newContents),
LanguageId.BicepParams => new ParamsParser(newContents),
_ => throw new ArgumentException($"Unexpected languageId value {languageId}"),
};
var program = parser.Program();
// Find the innermost string that contains the given offset, and which isn't inside an interpolation hole.
// Note that a hole can contain nested strings which may contain holes...
var stringSyntax = (StringSyntax?)program.TryFindMostSpecificNodeInclusive(offset, syntax =>
{
if (syntax is not StringSyntax stringSyntax)
{
return false;
}
// The inclusive version of this function does not quite match what we want (and exclusive misses some valid offsets)...
//
// Example: 'str' (the syntax span includes the quotes)
// Span start is on the first "'", span end (exclusive) is after the last "'"
// An insertion with the cursor on the beginning "'" will end up before the string, not inside it.
// An insertion with the cursor on the ending "'" will end up in the string
if (offset <= syntax.Span.Position || offset >= syntax.GetEndPosition())
{
// Ignore this node
return false;
}
foreach (var interpolation in stringSyntax.Expressions)
{
// Remove expression holes from consideration (if they contain strings that will be caught in the next iteration)
//
// Example: 'str${v1}', the expression node 'v1' does *not* include the ${ and } delimiters
// Span start is on the 'v', span end (exclusive) is on the '}'
// An insertion with the cursor on the v, 1 or '{' will end up inside the expression hole
if (offset >= interpolation.Span.Position && offset <= interpolation.GetEndPosition())
{
// Ignore this node
return false;
}
}
return true;
});
if (stringSyntax is not null)
{
return PasteContext.String;
}
if (languageId == LanguageId.BicepParams && program.TryFindMostSpecificNodeInclusive(0, syntax => syntax is UsingDeclarationSyntax) is not null)
{
return PasteContext.ParamsWithUsingDeclaration;
}
return PasteContext.None;
}
private static string DisclaimerMessage => BicepDecompiler.DecompilerDisclaimerMessage;
private static void Log(StringBuilder output, string message)
{
output.AppendLine(message);
Trace.TraceInformation(message);
}
private async Task<ResultAndTelemetry> TryDecompileForPaste(string bicepContents, int rangeOffset, int rangeLength, string json, bool queryCanPaste, LanguageId languageId)
{
StringBuilder output = new();
var decompileId = Guid.NewGuid().ToString();
var pasteContext = GetPasteContext(bicepContents, rangeOffset, rangeLength, languageId);
if (pasteContext == PasteContext.String)
{
// Don't convert to Bicep if inside a string
return new(new(decompileId, output.ToString(), PasteContextAsString(pasteContext), PasteType: null, ErrorMessage: null,
Bicep: null, Disclaimer: null),
GetSuccessTelemetry(queryCanPaste, decompileId, json, pasteContext, pasteType: null, bicep: null, languageId: languageId));
}
if (string.IsNullOrWhiteSpace(json))
{
return new(new(decompileId, output.ToString(), PasteContextAsString(pasteContext), PasteType: null, ErrorMessage: null,
Bicep: null, Disclaimer: null),
GetSuccessTelemetry(queryCanPaste, decompileId, json, pasteContext, pasteType: null, bicep: null, languageId: languageId));
}
var (pasteType, constructedJsonTemplate) = languageId switch
{
LanguageId.Bicep => TryConstructFullJsonTemplate(json),
LanguageId.BicepParams => TryConstructFullJsonParams(json),
_ => (PasteType.None, null),
};
switch (pasteType)
{
case PasteType.None:
{
// It's not a template or resource. Try treating it as a JSON value.
var resultAndTelemetry = TryConvertFromJsonValue(output, json, decompileId, pasteContext, queryCanPaste, languageId);
if (resultAndTelemetry is not null)
{
return resultAndTelemetry;
}
break;
}
case PasteType.FullParams:
{
// It's a full parameters file
var result = TryConvertFromConstructedParameters(output, json, decompileId, pasteContext, pasteType, queryCanPaste, constructedJsonTemplate, languageId);
if (result is not null)
{
return result;
}
break;
}
default:
{
// It's a full or partial template and we have converted it into a full template to parse
var result = await TryConvertFromConstructedTemplate(output, json, decompileId, pasteContext, pasteType, queryCanPaste, constructedJsonTemplate, languageId);
if (result is not null)
{
return result;
}
break;
}
}
// It's not anything we know how to convert to Bicep
return new(
new(
decompileId, output.ToString(), PasteContextAsString(pasteContext), PasteType: null, ErrorMessage: null,
Bicep: null, Disclaimer: null),
GetSuccessTelemetry(queryCanPaste, decompileId, json, pasteContext, pasteType: null, bicep: null, languageId: languageId));
}
private async Task<ResultAndTelemetry?> TryConvertFromConstructedTemplate(StringBuilder output, string json, string decompileId, PasteContext pasteContext, PasteType pasteType, bool queryCanPaste, string? constructedJsonTemplate, LanguageId languageId)
{
ImmutableDictionary<Uri, string> filesToSave;
try
{
// Decompile the full template
Debug.Assert(constructedJsonTemplate is not null);
Log(output, string.Format(LangServerResources.Decompile_DecompilationStartMsg, "clipboard text"));
var decompiler = new BicepDecompiler(bicepCompiler);
var options = GetDecompileOptions(pasteType);
(_, filesToSave) = await decompiler.Decompile(BicepDummyUri, constructedJsonTemplate, options: options);
}
catch (Exception ex)
{
// We don't ever throw. If we reached here, the pasted text was in a format we think we can handle but there was some
// sort of error. Tell the client it can be pasted and let the client show the end user the error if they do.
// deal with any bicep errors found.
var message = ex.Message;
Log(output, $"Decompilation failed: {message}");
return new(new(decompileId, output.ToString(), PasteContextAsString(pasteContext), PasteTypeAsString(pasteType), message, Bicep: null, Disclaimer: null),
GetSuccessTelemetry(queryCanPaste, decompileId, json, pasteContext, PasteTypeAsString(pasteType), bicep: null, languageId: languageId));
}
// Get Bicep output from the main file (all others are currently ignored)
var bicepOutput = filesToSave.Single(kvp => BicepDummyUri.Equals(kvp.Key)).Value;
if (string.IsNullOrWhiteSpace(bicepOutput))
{
return null;
}
// Ensure ends with newline
bicepOutput = bicepOutput.TrimEnd() + "\n";
// Show disclaimer and return result
Log(output, DisclaimerMessage);
return new(new(decompileId, output.ToString(), PasteContextAsString(pasteContext), PasteTypeAsString(pasteType), null, bicepOutput, DisclaimerMessage),
GetSuccessTelemetry(queryCanPaste, decompileId, json, pasteContext, PasteTypeAsString(pasteType), bicepOutput, languageId: languageId));
}
private ResultAndTelemetry? TryConvertFromConstructedParameters(StringBuilder output, string json, string decompileId, PasteContext pasteContext, PasteType pasteType, bool queryCanPaste, string? constructedJsonTemplate, LanguageId languageId)
{
ImmutableDictionary<Uri, string> filesToSave;
try
{
// Decompile the full template
Debug.Assert(constructedJsonTemplate is not null);
Log(output, string.Format(LangServerResources.Decompile_DecompilationStartMsg, "clipboard text"));
var decompiler = new BicepDecompiler(bicepCompiler);
(_, filesToSave) = decompiler.DecompileParameters(constructedJsonTemplate, BicepParamsDummyUri, null, new()
{
IncludeUsingDeclaration = pasteContext != PasteContext.ParamsWithUsingDeclaration
});
}
catch (Exception ex)
{
// We don't ever throw. If we reached here, the pasted text was in a format we think we can handle but there was some
// sort of error. Tell the client it can be pasted and let the client show the end user the error if they do.
// deal with any bicep errors found.
var message = ex.Message;
Log(output, $"Decompilation failed: {message}");
return new(new(decompileId, output.ToString(), PasteContextAsString(pasteContext), PasteTypeAsString(pasteType), message, Bicep: null, Disclaimer: null),
GetSuccessTelemetry(queryCanPaste, decompileId, json, pasteContext, PasteTypeAsString(pasteType), bicep: null, languageId: languageId));
}
// Get Bicep output from the main file (all others are currently ignored)
var bicepOutput = filesToSave.Single(kvp => BicepParamsDummyUri.Equals(kvp.Key)).Value;
if (string.IsNullOrWhiteSpace(bicepOutput))
{
return null;
}
// Ensure ends with newline
bicepOutput = bicepOutput.TrimEnd() + "\n";
// Show disclaimer and return result
Log(output, DisclaimerMessage);
return new(new(decompileId, output.ToString(), PasteContextAsString(pasteContext), PasteTypeAsString(pasteType), null, bicepOutput, DisclaimerMessage),
GetSuccessTelemetry(queryCanPaste, decompileId, json, pasteContext, PasteTypeAsString(pasteType), bicepOutput, languageId: languageId));
}
private static string PasteContextAsString(PasteContext pasteContext)
{
return pasteContext switch
{
PasteContext.None => "none",
PasteContext.String => "string",
PasteContext.ParamsWithUsingDeclaration => "none",
_ => throw new($"Unexpected pasteContext value {pasteContext}"),
};
}
private BicepTelemetryEvent? GetSuccessTelemetry(bool queryCanPaste, string decompileId, string json, PasteContext pasteContext, string? pasteType, string? bicep, LanguageId languageId)
{
// Don't log telemetry if we're just determining if we can paste, because this will happen a lot
// (on changing between editors for instance)
// TODO: but we don't call back for telemetry if we use the result
return queryCanPaste ?
null :
BicepTelemetryEvent.DecompileForPaste(decompileId, PasteContextAsString(pasteContext), pasteType, json.Length, bicep?.Length, LanguageIdAsString(languageId));
}
private static DecompileOptions GetDecompileOptions(PasteType pasteType)
{
return new()
{
// For partial template pastes, we don't error out on missing parameters and variables because they won't
// ever have definitions in the pasted portion
AllowMissingParamsAndVars = pasteType != PasteType.FullTemplate,
// ... but don't allow them in nested templates, which should be fully complete and valid
AllowMissingParamsAndVarsInNestedTemplates = false,
IgnoreTrailingInput = pasteType != PasteType.JsonValue,
};
}
private ResultAndTelemetry? TryConvertFromJsonValue(StringBuilder output, string json, string decompileId, PasteContext pasteContext, bool queryCanPaste, LanguageId languageId)
{
// Is it valid JSON that we can convert into Bicep?
var pasteType = PasteType.JsonValue;
var options = GetDecompileOptions(pasteType);
var bicep = BicepDecompiler.DecompileJsonValue(sourceFileFactory, json, options);
if (bicep is null)
{
return null;
}
// Technically we've already converted, but we only want to show this message if we think the pasted text is convertible
Log(output, string.Format(LangServerResources.Decompile_DecompilationStartMsg, "clipboard text"));
// Even though it's valid JSON, it might also be valid Bicep, in which case we want to leave it alone if we're
// doing an automatic copy/paste conversion.
// Is the input already a valid Bicep expression with comments removed?
var parser = new Parser("var v = " + json);
_ = parser.Program();
if (!parser.LexingErrorLookup.Any() && !parser.ParsingErrorLookup.Any())
{
// We still want to have the converted bicep available (via the "bicep" output) in the case
// that the user is explicitly doing a Paste as Bicep command, so allow "bicep" to keep its value.
pasteType = PasteType.BicepValue;
}
else
{
// An edge case - it could be a valid Bicep expression with comments and newlines. This would be
// valid if pasting inside a multi-line array. We don't want to convert it in this case because the
// comments would be removed by the decompiler.
parser = new("var v = [\n" + json + "\n]");
_ = parser.Program();
if (!parser.LexingErrorLookup.Any() && !parser.ParsingErrorLookup.Any())
{
// We still want to have the converted bicep available (via the "bicep" output) in the case
// that the user is explicitly doing a Paste as Bicep command, so allow "bicep" to keep its value.
pasteType = PasteType.BicepValue;
}
}
return new(new(decompileId, output.ToString(), PasteContextAsString(pasteContext), PasteTypeAsString(pasteType),
ErrorMessage: null, bicep, Disclaimer: null),
GetSuccessTelemetry(queryCanPaste, decompileId, json, pasteContext, PasteTypeAsString(pasteType), bicep, languageId: languageId));
}
/// <summary>
/// If the given JSON matches a pattern that we know how to paste as Bicep, convert it into a full template to be decompiled
/// </summary>
private (PasteType pasteType, string? fullJsonTemplate) TryConstructFullJsonTemplate(string json)
{
using var streamReader = new StringReader(json);
using var reader = new JsonTextReader(streamReader);
reader.SupportMultipleContent = true; // Allows for handling of lists of resources separated by commas
if (LoadValue(reader, readFirst: true) is not { } value)
{
return (PasteType.None, null);
}
if (value.Type != JTokenType.Object)
{
return (PasteType.None, null);
}
var obj = (JObject)value;
if (TryGetStringProperty(obj, "$schema") is { } schema)
{
// Template converter will do a more thorough check, we just want to know if it *looks* like a template
var looksLikeArmSchema = LanguageConstants.ArmTemplateSchemaRegex.IsMatch(schema);
if (looksLikeArmSchema)
{
// Json is already a full template
return (PasteType.FullTemplate, json);
}
else
{
return (PasteType.None, null);
}
}
// If it's a resource object or a list of resource objects, accept it
if (IsResourceObject(obj))
{
return ConstructFullTemplateFromSequenceOfResources(obj, reader);
}
return (PasteType.None, null);
}
/// <summary>
/// Handles an optionally comma-separated sequence of JSON resource objects:
///
/// {
/// apiVersion: "..."
/// ...
/// },
/// {
/// apiVersion: "..."
/// ...
/// }
///
/// Note that this is not a valid JSON construct by itself, unless it's just a single resource
/// </summary>
private static (PasteType pasteType, string constructedJsonTemplate) ConstructFullTemplateFromSequenceOfResources(JObject firstResourceObject, JsonTextReader reader)
{
Debug.Assert(IsResourceObject(firstResourceObject));
Debug.Assert(reader.TokenType == JsonToken.EndObject, "Reader should be on end squiggly of first resource object");
var resourceObjects = new List<JObject>();
var obj = firstResourceObject;
while (obj is not null)
{
if (IsResourceObject(obj))
{
resourceObjects.Add(obj);
}
try
{
if (!reader.Read())
{
break;
}
SkipComments(reader);
if (reader.TokenType != JsonToken.StartObject)
{
break;
}
obj = LoadValue(reader, readFirst: false) as JObject;
}
catch (Exception)
{
// Ignore any additional JSON
break;
}
}
var resourcesAsJson = string.Join(",\n", resourceObjects.Select(ro => ro.ToString()));
var templateJson = """{ "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", "contentVersion": "1.0.0.0", "resources": [""" +
resourcesAsJson
+ "]}";
return (
resourceObjects.Count == 1 ? PasteType.SingleResource : PasteType.ResourceList,
templateJson
);
}
private static void SkipComments(JsonTextReader reader)
{
while (reader.TokenType == JsonToken.Comment)
{
reader.Read();
}
}
private static bool IsResourceObject(JObject? obj)
{
return obj is not null
&& !string.IsNullOrEmpty(TryGetStringProperty(obj, "type"))
&& !string.IsNullOrEmpty(TryGetStringProperty(obj, "name"))
&& !string.IsNullOrEmpty(TryGetStringProperty(obj, "apiVersion"));
}
private static JToken? LoadValue(JsonTextReader reader, bool readFirst)
{
try
{
if (readFirst && !reader.Read())
{
return null;
}
if (reader.TokenType == JsonToken.None)
{
return null;
}
return JToken.Load(reader, new()
{
CommentHandling = CommentHandling.Ignore,
});
}
catch (JsonException)
{
return null;
}
}
private static JProperty? TryGetProperty(JObject obj, string name)
=> obj.Property(name, StringComparison.OrdinalIgnoreCase);
private static string? TryGetStringProperty(JObject obj, string name)
=> (TryGetProperty(obj, name)?.Value as JValue)?.Value as string;
/// <summary>
/// If the given JSON matches a pattern that we know how to paste as Bicep, convert it into a full json params to be decompiled
/// </summary>
private (PasteType pasteType, string? fullJsonTemplate) TryConstructFullJsonParams(string json)
{
using var streamReader = new StringReader(json);
using var reader = new JsonTextReader(streamReader);
reader.SupportMultipleContent = true; // Allows for handling of lists of resources separated by commas
if (LoadValue(reader, readFirst: true) is not { } value)
{
return (PasteType.None, null);
}
if (value.Type != JTokenType.Object)
{
return (PasteType.None, null);
}
var obj = (JObject)value;
if (TryGetStringProperty(obj, "$schema") is not { } schema)
{
return (PasteType.None, null);
}
// Template converter will do a more thorough check, we just want to know if it *looks* like a template
var looksLikeArmSchema = LanguageConstants.ArmParametersSchemaRegex.IsMatch(schema);
if (looksLikeArmSchema)
{
// Json is already a full json params
return (PasteType.FullParams, json);
}
return (PasteType.None, null);
}
private static string? PasteTypeAsString(PasteType pasteType) => pasteType switch
{
PasteType.None => null,
PasteType.FullTemplate => "fullTemplate",
PasteType.SingleResource => "resource",
PasteType.ResourceList => "resourceList",
PasteType.JsonValue => "jsonValue",
PasteType.BicepValue => "bicepValue",
PasteType.FullParams => "fullParams",
_ => throw new($"Unexpected pasteType value {pasteType}"),
};
}