diff --git a/src/Bicep.Cli.E2eTests/src/jsonrpc.test.ts b/src/Bicep.Cli.E2eTests/src/jsonrpc.test.ts index e6ff910415a..5f2eeb03fdc 100644 --- a/src/Bicep.Cli.E2eTests/src/jsonrpc.test.ts +++ b/src/Bicep.Cli.E2eTests/src/jsonrpc.test.ts @@ -8,14 +8,20 @@ */ import { MessageConnection } from "vscode-jsonrpc"; -import { pathToExampleFile } from "./utils/fs"; -import { compileRequestType, openConnection, validateRequestType } from "./utils/jsonrpc"; +import { pathToExampleFile, writeTempFile } from "./utils/fs"; +import { compileRequestType, getDeploymentGraphRequestType, getMetadataRequestType, openConnection, versionRequestType } from "./utils/jsonrpc"; let connection: MessageConnection; beforeAll(async () => (connection = await openConnection())); afterAll(() => connection.dispose()); describe("bicep jsonrpc", () => { + it("should return a version number", async () => { + const result = await version(connection); + + expect(result.version).toMatch(/^\d+\.\d+\.\d+/); + }); + it("should build a bicep file", async () => { const result = await compile( connection, @@ -26,6 +32,29 @@ describe("bicep jsonrpc", () => { expect(result.contents?.length).toBeGreaterThan(0); }); + it("should return a deployment graph", async () => { + const bicepPath = writeTempFile("jsonrpc", "metadata.bicep", ` + resource foo 'My.Rp/foo@2020-01-01' = { + name: 'foo' + } + + resource bar 'My.Rp/foo@2020-01-01' existing = { + name: 'bar' + dependsOn: [foo] + } + + resource baz 'My.Rp/foo@2020-01-01' = { + name: 'baz' + dependsOn: [bar] + } + `); + + const result = await getDeploymentGraph(connection, bicepPath); + + expect(result.nodes).toHaveLength(3); + expect(result.edges).toHaveLength(2); + }); + it("should return diagnostics if the bicep file has errors", async () => { const result = await compile( connection, @@ -39,29 +68,45 @@ describe("bicep jsonrpc", () => { expect(error.message).toBe('The name "osDiskSizeGb" does not exist in the current context.'); }); - // preflight doesn't work in this test suite as it requires authentication. Change xit -> it to test locally. - xit("should validate a bicepparam file", async () => { - const result = await validate( + it("should return metadata for a bicep file", async () => { + const bicepPath = writeTempFile("jsonrpc", "metadata.bicep", ` + metadata description = 'my file' + + @description('foo param') + param foo string + + @description('bar output') + output bar string = foo + `); + + const result = await getMetadata( connection, - pathToExampleFile("bicepparam", "main.bicepparam"), - ); + bicepPath); - expect(result.error!.code).toBe("InvalidTemplateDeployment"); - expect(result.error!.details![0].code).toBe("PreflightValidationCheckFailed"); - expect(result.error!.details![0].details![0].code).toBe("AccountNameInvalid"); - }, 60000); + expect(result.metadata.filter(x => x.name === 'description')[0].value).toEqual('my file'); + expect(result.parameters.filter(x => x.name === 'foo')[0].description).toEqual('foo param'); + expect(result.outputs.filter(x => x.name === 'bar')[0].description).toEqual('bar output'); + }); }); +async function version(connection: MessageConnection) { + return await connection.sendRequest(versionRequestType, {}); +} + async function compile(connection: MessageConnection, bicepFile: string) { return await connection.sendRequest(compileRequestType, { path: bicepFile, }); } -async function validate(connection: MessageConnection, bicepparamFile: string) { - return await connection.sendRequest(validateRequestType, { - subscriptionId: 'a1bfa635-f2bf-42f1-86b5-848c674fc321', - resourceGroup: 'ant-test', - path: bicepparamFile, +async function getMetadata(connection: MessageConnection, bicepFile: string) { + return await connection.sendRequest(getMetadataRequestType, { + path: bicepFile, + }); +} + +async function getDeploymentGraph(connection: MessageConnection, bicepFile: string) { + return await connection.sendRequest(getDeploymentGraphRequestType, { + path: bicepFile, }); } \ No newline at end of file diff --git a/src/Bicep.Cli.E2eTests/src/utils/jsonrpc.ts b/src/Bicep.Cli.E2eTests/src/utils/jsonrpc.ts index fa4b2198110..3794954e706 100644 --- a/src/Bicep.Cli.E2eTests/src/utils/jsonrpc.ts +++ b/src/Bicep.Cli.E2eTests/src/utils/jsonrpc.ts @@ -11,6 +11,44 @@ import { } from "vscode-jsonrpc/node"; import { bicepCli } from "./fs"; +interface VersionRequest {} + +interface VersionResponse { + version: string; +} + +interface GetDeploymentGraphRequest { + path: string; +} + +interface GetDeploymentGraphResponse { + nodes: GetDeploymentGraphResponseNode[]; + edges: GetDeploymentGraphResponseEdge[]; +} + +interface GetDeploymentGraphResponseNode { + range: Range; + name: string; + type: string; + isExisting: boolean; + relativePath?: string; +} + +interface GetDeploymentGraphResponseEdge { + source: string; + target: string; +} + +interface Position { + line: number; + char: number; +} + +interface Range { + start: Position; + end: Position; +} + interface CompileRequest { path: string; } @@ -22,41 +60,56 @@ interface CompileResponse { } interface CompileResponseDiagnostic { - line: number; - char: number; + range: Range; level: string; code: string; message: string; } -export const compileRequestType = new RequestType< - CompileRequest, - CompileResponse, - never ->("bicep/compile"); - -interface ValidateRequest { - subscriptionId: string; - resourceGroup: string; +interface GetMetadataRequest { path: string; } -interface ValidateResponse { - error?: ValidateResponseError; +interface GetMetadataResponse { + metadata: MetadataDefinition[]; + parameters: ParamDefinition[]; + outputs: ParamDefinition[]; } -interface ValidateResponseError { - code: string; - message: string; - target?: string; - details?: ValidateResponseError[]; +interface MetadataDefinition { + name: string; + value: string; +} + +interface ParamDefinition { + range: Range; + name: string; + description?: string; } -export const validateRequestType = new RequestType< - ValidateRequest, - ValidateResponse, +export const versionRequestType = new RequestType< + VersionRequest, + VersionResponse, + never +>("bicep/version"); + +export const compileRequestType = new RequestType< + CompileRequest, + CompileResponse, + never +>("bicep/compile"); + +export const getMetadataRequestType = new RequestType< + GetMetadataRequest, + GetMetadataResponse, + never +>("bicep/getMetadata"); + +export const getDeploymentGraphRequestType = new RequestType< + GetDeploymentGraphRequest, + GetDeploymentGraphResponse, never ->("bicep/validate"); +>("bicep/getDeploymentGraph"); function generateRandomPipeName(): string { const randomSuffix = randomBytes(21).toString("hex"); diff --git a/src/Bicep.Cli.IntegrationTests/JsonRpcCommandTests.cs b/src/Bicep.Cli.IntegrationTests/JsonRpcCommandTests.cs new file mode 100644 index 00000000000..4b2f4388bf9 --- /dev/null +++ b/src/Bicep.Cli.IntegrationTests/JsonRpcCommandTests.cs @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO.Abstractions.TestingHelpers; +using System.IO.Pipes; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Bicep.Cli.Rpc; +using Bicep.Core.UnitTests; +using Bicep.Core.UnitTests.Assertions; +using Bicep.Core.UnitTests.Utils; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.WindowsAzure.ResourceStack.Common.Json; +using Newtonsoft.Json.Linq; +using StreamJsonRpc; + +namespace Bicep.Cli.IntegrationTests; + +[TestClass] +public class JsonRpcCommandTests : TestBase +{ + private async Task RunServerTest(Action registerAction, Func testFunc) + { + var pipeName = Guid.NewGuid().ToString(); + using var pipeStream = new NamedPipeServerStream(pipeName, PipeDirection.InOut, NamedPipeServerStream.MaxAllowedServerInstances, PipeTransmissionMode.Byte, PipeOptions.Asynchronous); + + var testTimeout = TimeSpan.FromMinutes(1); + var cts = new CancellationTokenSource(testTimeout); + + await Task.WhenAll( + Task.Run(async () => + { + var result = await Bicep(registerAction, cts.Token, "jsonrpc", "--pipe", pipeName); + result.ExitCode.Should().Be(0); + result.Stderr.Should().EqualIgnoringNewlines("The 'jsonrpc' CLI command group is an experimental feature. Experimental features should be enabled for testing purposes only, as there are no guarantees about the quality or stability of these features. Do not enable these settings for any production usage, or your production environment may be subject to breaking.\n"); + result.Stdout.Should().Be(""); + }), + Task.Run(async () => + { + try + { + await pipeStream.WaitForConnectionAsync(cts.Token); + var client = JsonRpc.Attach(CliJsonRpcServer.CreateMessageHandler(pipeStream, pipeStream)); + await testFunc(client, cts.Token); + } + finally + { + cts.Cancel(); + } + }, cts.Token)); + } + + [TestMethod] + public async Task Version_returns_bicep_version() + { + await RunServerTest( + services => {}, + async (client, token) => + { + var response = await client.Version(new(), token); + response.Version.Should().Be(ThisAssembly.AssemblyInformationalVersion.Split('+')[0]); + }); + } + + [TestMethod] + public async Task Compile_returns_a_compilation_result() + { + var fileSystem = new MockFileSystem(new Dictionary + { + ["/main.bicepparam"] = """ +using 'main.bicep' + +param foo = 'foo' +""", + ["/main.bicep"] = """ +param foo string +""", + }); + + await RunServerTest( + services => services.WithFileSystem(fileSystem), + async (client, token) => + { + var response = await client.Compile(new("/main.bicep"), token); + response.Contents.FromJson().Should().HaveValueAtPath("$['$schema']", "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#"); + response.Contents.FromJson().Should().HaveJsonAtPath("$.parameters['foo']", """ + { + "type": "string" + } + """); + + response = await client.Compile(new("/main.bicepparam"), token); + response.Contents.FromJson().Should().HaveValueAtPath("$['$schema']", "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#"); + response.Contents.FromJson().Should().HaveJsonAtPath("$.parameters['foo']", """ + { + "value": "foo" + } + """); + }); + } + + [TestMethod] + public async Task GetMetadata_returns_file_metadata() + { + var fileSystem = new MockFileSystem(new Dictionary + { + ["/main.bicep"] = """ +metadata description = 'my file' + +@description('foo param') +param foo string + +@description('bar output') +output bar string = foo +""", + }); + + await RunServerTest( + services => services.WithFileSystem(fileSystem), + async (client, token) => + { + var response = await client.GetMetadata(new("/main.bicep"), token); + response.Metadata.Should().Equal(new GetMetadataResponse.MetadataDefinition[] { + new("description", "my file"), + }); + response.Parameters.Should().Equal(new GetMetadataResponse.SymbolDefinition[] { + new(new(new(2, 0), new(3, 16)), "foo", "foo param"), + }); + response.Outputs.Should().Equal(new GetMetadataResponse.SymbolDefinition[] { + new(new(new(5, 0), new(6, 23)), "bar", "bar output"), + }); + }); + } + + [TestMethod] + public async Task GetDeploymentGraph_returns_deployment_graph() + { + var fileSystem = new MockFileSystem(new Dictionary + { + ["/main.bicep"] = """ +resource foo 'My.Rp/foo@2020-01-01' = { + name: 'foo' +} + +resource bar 'My.Rp/foo@2020-01-01' existing = { + name: 'bar' + dependsOn: [foo] +} + +resource baz 'My.Rp/foo@2020-01-01' = { + name: 'baz' + dependsOn: [bar] +} +""", + }); + + await RunServerTest( + services => services.WithFileSystem(fileSystem), + async (client, token) => + { + var response = await client.GetDeploymentGraph(new("/main.bicep"), token); + response.Nodes.Should().Equal(new GetDeploymentGraphResponse.Node[] { + new(new(new(4, 0), new(7, 1)), "bar", "My.Rp/foo", true, null), + new(new(new(9, 0), new(12, 1)), "baz", "My.Rp/foo", false, null), + new(new(new(0, 0), new(2, 1)), "foo", "My.Rp/foo", false, null), + }); + response.Edges.Should().Equal(new GetDeploymentGraphResponse.Edge[] { + new("bar", "foo"), + new("baz", "bar"), + }); + }); + } +} \ No newline at end of file diff --git a/src/Bicep.Cli.IntegrationTests/TestBase.cs b/src/Bicep.Cli.IntegrationTests/TestBase.cs index 2e4f84cc995..237f6f3b5c0 100644 --- a/src/Bicep.Cli.IntegrationTests/TestBase.cs +++ b/src/Bicep.Cli.IntegrationTests/TestBase.cs @@ -45,10 +45,13 @@ protected record InvocationSettings( ClientFactory: Repository.Create().Object, TemplateSpecRepositoryFactory: Repository.Create().Object); - protected static Task Bicep(InvocationSettings settings, params string?[] args /*null args are ignored*/) + protected static Task Bicep(Action registerAction, CancellationToken cancellationToken, params string[] args) => TextWriterHelper.InvokeWriterAction((@out, err) - => new Program(new(Output: @out, Error: err), services - => + => new Program(new(Output: @out, Error: err), registerAction) + .RunAsync(args, cancellationToken)); + + protected static Task Bicep(InvocationSettings settings, params string?[] args /*null args are ignored*/) + => Bicep(services => { if (settings.FeatureOverrides is { }) { @@ -60,8 +63,7 @@ protected static Task Bicep(InvocationSettings settings, params strin .AddSingleton(settings.Environment ?? BicepTestConstants.EmptyEnvironment) .AddSingleton(settings.ClientFactory) .AddSingleton(settings.TemplateSpecRepositoryFactory); - }) - .RunAsync(args.ToArrayExcludingNull(), CancellationToken.None)); + }, CancellationToken.None, args.ToArrayExcludingNull()); protected static void AssertNoErrors(string error) { diff --git a/src/Bicep.Cli/Commands/JsonRpcCommand.cs b/src/Bicep.Cli/Commands/JsonRpcCommand.cs index 65ec84a0c34..179d2ab5f1f 100644 --- a/src/Bicep.Cli/Commands/JsonRpcCommand.cs +++ b/src/Bicep.Cli/Commands/JsonRpcCommand.cs @@ -18,13 +18,11 @@ namespace Bicep.Cli.Commands; public class JsonRpcCommand : ICommand { private readonly BicepCompiler compiler; - private readonly ITokenCredentialFactory credentialFactory; private readonly IOContext io; - public JsonRpcCommand(BicepCompiler compiler, ITokenCredentialFactory credentialFactory, IOContext io) + public JsonRpcCommand(BicepCompiler compiler, IOContext io) { this.compiler = compiler; - this.credentialFactory = credentialFactory; this.io = io; } @@ -43,7 +41,7 @@ public async Task RunAsync(JsonRpcArguments args, CancellationToken cancell await clientPipe.ConnectAsync(cancellationToken); - using var rpc = new JsonRpc(clientPipe, clientPipe); + using var rpc = new JsonRpc(CliJsonRpcServer.CreateMessageHandler(clientPipe, clientPipe)); await RunServer(rpc, cancellationToken); } else if (args.Socket is { } port) @@ -53,12 +51,12 @@ public async Task RunAsync(JsonRpcArguments args, CancellationToken cancell await tcpClient.ConnectAsync(IPAddress.Loopback, port, cancellationToken); using var tcpStream = tcpClient.GetStream(); - using var rpc = new JsonRpc(tcpStream, tcpStream); + using var rpc = new JsonRpc(CliJsonRpcServer.CreateMessageHandler(tcpStream, tcpStream)); await RunServer(rpc, cancellationToken); } else { - using var rpc = new JsonRpc(Console.OpenStandardOutput(), Console.OpenStandardInput()); + using var rpc = new JsonRpc(CliJsonRpcServer.CreateMessageHandler(Console.OpenStandardOutput(), Console.OpenStandardInput())); await RunServer(rpc, cancellationToken); } @@ -67,8 +65,9 @@ public async Task RunAsync(JsonRpcArguments args, CancellationToken cancell private async Task RunServer(JsonRpc jsonRpc, CancellationToken cancellationToken) { - var server = new RpcServer(compiler, credentialFactory); - jsonRpc.AddLocalRpcTarget(server); + var server = new CliJsonRpcServer(compiler); + jsonRpc.AddLocalRpcTarget(server, null); + jsonRpc.StartListening(); await Task.WhenAny(jsonRpc.Completion, WaitForCancellation(cancellationToken)); diff --git a/src/Bicep.Cli/Rpc/CliJsonRpcServer.cs b/src/Bicep.Cli/Rpc/CliJsonRpcServer.cs new file mode 100644 index 00000000000..275fb278d44 --- /dev/null +++ b/src/Bicep.Cli/Rpc/CliJsonRpcServer.cs @@ -0,0 +1,185 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure; +using Azure.ResourceManager; +using Azure.ResourceManager.Resources; +using Azure.ResourceManager.Resources.Models; +using Bicep.Core; +using Bicep.Core.Diagnostics; +using Bicep.Core.Emit; +using Bicep.Core.Extensions; +using Bicep.Core.FileSystem; +using Bicep.Core.Json; +using Bicep.Core.Navigation; +using Bicep.Core.Parsing; +using Bicep.Core.Registry.Auth; +using Bicep.Core.Semantics; +using Bicep.Core.Syntax; +using Bicep.Core.Text; +using Bicep.Core.Tracing; +using Bicep.Core.TypeSystem; +using Bicep.Core.Workspaces; +using Microsoft.WindowsAzure.ResourceStack.Common.Json; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; +using StreamJsonRpc; + +namespace Bicep.Cli.Rpc; + +public class CliJsonRpcServer : ICliJsonRpcProtocol +{ + public static IJsonRpcMessageHandler CreateMessageHandler(Stream inputStream, Stream outputStream) + { + var formatter = new JsonMessageFormatter(); + formatter.JsonSerializer.ContractResolver = new CamelCasePropertyNamesContractResolver(); + + return new HeaderDelimitedMessageHandler(inputStream, outputStream, formatter); + } + + private readonly BicepCompiler compiler; + + public CliJsonRpcServer(BicepCompiler compiler) + { + this.compiler = compiler; + } + + public async Task Version(VersionRequest request, CancellationToken cancellationToken) + { + await Task.Yield(); + + return new( + ThisAssembly.AssemblyInformationalVersion.Split('+')[0]); + } + + public async Task Compile(CompileRequest request, CancellationToken cancellationToken) + { + var model = await GetSemanticModel(compiler, request.Path); + var diagnostics = GetDiagnostics(model.Compilation).ToImmutableArray(); + + var writer = new StringWriter(); + var result = model.SourceFileKind == BicepSourceFileKind.BicepFile ? + new TemplateEmitter(model).Emit(writer) : + new ParametersEmitter(model).Emit(writer); + var success = result.Status == EmitStatus.Succeeded; + + return new(success, diagnostics, success ? writer.ToString() : null); + } + + public async Task GetMetadata(GetMetadataRequest request, CancellationToken cancellationToken) + { + var model = await GetSemanticModel(compiler, request.Path); + + var metadata = GetModelMetadata(model).ToImmutableArray(); + + var parameters = model.Root.ParameterDeclarations + .Select(x => new GetMetadataResponse.SymbolDefinition(GetRange(model.SourceFile, x.DeclaringSyntax), x.Name, x.TryGetDescriptionFromDecorator())) + .ToImmutableArray(); + + var outputs = model.Root.OutputDeclarations + .Select(x => new GetMetadataResponse.SymbolDefinition(GetRange(model.SourceFile, x.DeclaringSyntax),x.Name, x.TryGetDescriptionFromDecorator())) + .ToImmutableArray(); + + return new(metadata, parameters, outputs); + } + + public async Task GetDeploymentGraph(GetDeploymentGraphRequest request, CancellationToken cancellationToken) + { + var model = await GetSemanticModel(compiler, request.Path); + var dependenciesBySymbol = ResourceDependencyVisitor.GetResourceDependencies(model, new() { IncludeExisting = true }) + .Where(x => !x.Key.Type.IsError()) + .ToImmutableDictionary(x => x.Key, x => x.Value); + + Dictionary nodesBySymbol = new(); + foreach (var symbol in dependenciesBySymbol.Keys) + { + var range = GetRange(model.SourceFile, symbol.DeclaringSyntax); + if (symbol is ResourceSymbol resourceSymbol) + { + var resourceType = resourceSymbol.TryGetResourceTypeReference()?.FormatType() ?? ""; + var isExisting = resourceSymbol.DeclaringResource.IsExistingResource(); + nodesBySymbol[symbol] = new(range, symbol.Name, resourceType, isExisting, null); + } + if (symbol is ModuleSymbol moduleSymbol) + { + var modulePath = moduleSymbol.DeclaringModule.TryGetPath()?.TryGetLiteralValue(); + nodesBySymbol[symbol] = new(range, symbol.Name, "", false, modulePath); + } + } + + List edges = new(); + foreach (var (symbol, dependencies) in dependenciesBySymbol) + { + var source = nodesBySymbol.TryGetValue(symbol); + foreach (var dependency in dependencies.Where(d => d.Kind == ResourceDependencyKind.Primary)) + { + var target = nodesBySymbol.TryGetValue(dependency.Resource); + if (source is {} && target is {}) + { + edges.Add(new(source.Name, target.Name)); + } + } + } + + return new( + nodesBySymbol.Values.OrderBy(x => x.Name).ToImmutableArray(), + edges.OrderBy(x => x.Source).ThenBy(x => x.Target).ToImmutableArray()); + } + + private static async Task GetSemanticModel(BicepCompiler compiler, string filePath) + { + var fileUri = PathHelper.FilePathToFileUrl(filePath); + if (!PathHelper.HasBicepExtension(fileUri) && + !PathHelper.HasBicepparamsExension(fileUri)) + { + throw new InvalidOperationException($"Invalid file path: {fileUri}"); + } + + var compilation = await compiler.CreateCompilation(fileUri); + + return compilation.GetEntrypointSemanticModel(); + } + + private static IEnumerable GetDiagnostics(Compilation compilation) + { + foreach (var (bicepFile, diagnostics) in compilation.GetAllDiagnosticsByBicepFile()) + { + foreach (var diagnostic in diagnostics) + { + yield return new(GetRange(bicepFile, diagnostic), diagnostic.Level.ToString(), diagnostic.Code, diagnostic.Message); + } + } + } + + private IEnumerable GetModelMetadata(SemanticModel model) + { + foreach (var metadata in model.Root.MetadataDeclarations) + { + if (metadata.DeclaringSyntax is MetadataDeclarationSyntax declarationSyntax && + declarationSyntax.Value is StringSyntax stringSyntax && + stringSyntax.TryGetLiteralValue() is string value) + { + yield return new(metadata.Name, value); + } + } + } + + private static Range GetRange(BicepSourceFile file, IPositionable positionable) + { + var start = TextCoordinateConverter.GetPosition(file.LineStarts, positionable.GetPosition()); + var end = TextCoordinateConverter.GetPosition(file.LineStarts, positionable.GetEndPosition()); + + return new(new(start.line, start.character), new(end.line, end.character)); + } +} diff --git a/src/Bicep.Cli/Rpc/ICliJsonRpcProtocol.cs b/src/Bicep.Cli/Rpc/ICliJsonRpcProtocol.cs new file mode 100644 index 00000000000..dfa62919e9c --- /dev/null +++ b/src/Bicep.Cli/Rpc/ICliJsonRpcProtocol.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Serialization; +using StreamJsonRpc; + +namespace Bicep.Cli.Rpc; + +public record Position( + int Line, + int Char); + +public record Range( + Position Start, + Position End); + +public record VersionRequest(); + +public record VersionResponse( + string Version); + +public record CompileRequest( + string Path); + +public record CompileResponse( + bool Success, + ImmutableArray Diagnostics, + string? Contents) +{ + public record DiagnosticDefinition( + Range Range, + string Level, + string Code, + string Message); +} + +public record GetMetadataRequest( + string Path); + +public record GetMetadataResponse( + ImmutableArray Metadata, + ImmutableArray Parameters, + ImmutableArray Outputs) +{ + public record SymbolDefinition( + Range Range, + string Name, + string? Description); + + public record MetadataDefinition( + string Name, + string Value); +} + +public record GetDeploymentGraphRequest( + string Path); + +public record GetDeploymentGraphResponse( + ImmutableArray Nodes, + ImmutableArray Edges) +{ + public record Node( + Range Range, + string Name, + string Type, + bool IsExisting, + string? RelativePath); + + public record Edge( + string Source, + string Target); +} + +public interface ICliJsonRpcProtocol +{ + [JsonRpcMethod("bicep/version", UseSingleObjectParameterDeserialization = true)] + Task Version(VersionRequest request, CancellationToken cancellationToken); + + [JsonRpcMethod("bicep/compile", UseSingleObjectParameterDeserialization = true)] + Task Compile(CompileRequest request, CancellationToken cancellationToken); + + [JsonRpcMethod("bicep/getMetadata", UseSingleObjectParameterDeserialization = true)] + Task GetMetadata(GetMetadataRequest request, CancellationToken cancellationToken); + + [JsonRpcMethod("bicep/getDeploymentGraph", UseSingleObjectParameterDeserialization = true)] + Task GetDeploymentGraph(GetDeploymentGraphRequest request, CancellationToken cancellationToken); +} diff --git a/src/Bicep.Cli/Rpc/RpcServer.cs b/src/Bicep.Cli/Rpc/RpcServer.cs deleted file mode 100644 index 04d3d8b5671..00000000000 --- a/src/Bicep.Cli/Rpc/RpcServer.cs +++ /dev/null @@ -1,197 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Azure; -using Azure.ResourceManager; -using Azure.ResourceManager.Resources; -using Azure.ResourceManager.Resources.Models; -using Bicep.Core; -using Bicep.Core.Diagnostics; -using Bicep.Core.Emit; -using Bicep.Core.FileSystem; -using Bicep.Core.Json; -using Bicep.Core.Registry.Auth; -using Bicep.Core.Semantics; -using Bicep.Core.Text; -using Bicep.Core.Tracing; -using Bicep.Core.Workspaces; -using Microsoft.WindowsAzure.ResourceStack.Common.Json; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using StreamJsonRpc; - -namespace Bicep.Cli.Rpc; - -public class RpcServer -{ -#pragma warning disable IDE1006 - public record CompileRequest( - string path); - - public record CompileResponse( - bool success, - ImmutableArray diagnostics, - string? contents); - - public record CompileResponseDiagnostic( - int line, - int @char, - string level, - string code, - string message); - - public record ValidateRequest( - string subscriptionId, - string resourceGroup, - string path); - - public record ValidateResponse( - ValidateResponseError? error); - - public record ValidateResponseError( - string code, - string message, - string? target, - ValidateResponseError[]? details); -#pragma warning restore IDE1006 - - private readonly BicepCompiler compiler; - private readonly ITokenCredentialFactory credentialFactory; - - public RpcServer(BicepCompiler compiler, ITokenCredentialFactory credentialFactory) - { - this.compiler = compiler; - this.credentialFactory = credentialFactory; - } - - [JsonRpcMethod("bicep/compile", UseSingleObjectParameterDeserialization = true)] - public async Task Compile(CompileRequest request, CancellationToken cancellationToken) - { - var inputUri = PathHelper.FilePathToFileUrl(request.path); - if (!PathHelper.HasBicepExtension(inputUri) && - !PathHelper.HasBicepparamsExension(inputUri)) - { - throw new InvalidOperationException("Cannot compile"); - } - - var compilation = await compiler.CreateCompilation(inputUri, new Workspace()); - var model = compilation.GetEntrypointSemanticModel(); - var diagnostics = GetDiagnostics(compilation).ToImmutableArray(); - - if (model.HasErrors()) - { - return new(false, diagnostics, null); - } - - var writer = new StringWriter(); - if (PathHelper.HasBicepparamsExension(inputUri)) - { - new ParametersEmitter(model).Emit(writer); - } - else - { - new TemplateEmitter(model).Emit(writer); - } - - return new(true, diagnostics, writer.ToString()); - } - - [JsonRpcMethod("bicep/validate", UseSingleObjectParameterDeserialization = true)] - public async Task Validate(ValidateRequest request, CancellationToken cancellationToken) - { - var inputUri = PathHelper.FilePathToFileUrl(request.path); - if (!PathHelper.HasBicepparamsExension(inputUri)) - { - throw new InvalidOperationException("Cannot compile"); - } - - var compilation = await compiler.CreateCompilation(inputUri, new Workspace()); - var model = compilation.GetEntrypointSemanticModel(); - var diagnostics = GetDiagnostics(compilation).ToImmutableArray(); - - if (model.HasErrors() || - !model.Root.TryGetBicepFileSemanticModelViaUsing().IsSuccess(out var usingModel) || - usingModel is not SemanticModel bicepModel) - { - // TODO support ts & br - throw new InvalidOperationException(); - } - - var paramsWriter = new StringWriter(); - new ParametersEmitter(model).Emit(paramsWriter); - - var templateWriter = new StringWriter(); - new TemplateEmitter(bicepModel).Emit(templateWriter); - - var configuration = model.Configuration; - - var parameters = JsonDocument.Parse(paramsWriter.ToString()).RootElement.GetProperty("parameters"); - - var deploymentProperties = new ArmDeploymentProperties(ArmDeploymentMode.Incremental) - { - Template = BinaryData.FromString(templateWriter.ToString()), - Parameters = BinaryData.FromObjectAsJson(parameters), - }; - - var armDeploymentContent = new ArmDeploymentContent(deploymentProperties); - - var options = new ArmClientOptions(); - options.Diagnostics.ApplySharedResourceManagerSettings(); - options.Environment = new ArmEnvironment(configuration.Cloud.ResourceManagerEndpointUri, configuration.Cloud.AuthenticationScope); - - var credential = credentialFactory.CreateChain(configuration.Cloud.CredentialPrecedence, configuration.Cloud.CredentialOptions, configuration.Cloud.ActiveDirectoryAuthorityUri); - var armClient = new ArmClient(credential, request.subscriptionId, options); - - var deploymentScope = $"/subscriptions/{request.subscriptionId}/resourceGroups/{request.resourceGroup}"; - var deploymentName = "validate-test"; - var deploymentId = ArmDeploymentResource.CreateResourceIdentifier(deploymentScope, deploymentName); - var armDeployment = armClient.GetArmDeploymentResource(deploymentId); - - try - { - var response = await armDeployment.ValidateAsync(WaitUntil.Completed, armDeploymentContent, cancellationToken); - - return new(null); - } - catch (RequestFailedException ex) when (ex.GetRawResponse() is { } rawResponse) - { - var error = rawResponse.Content.ToString() - .FromJson() - .TryGetProperty("error"); - - return new(GetErrorRecursive(error)); - } - } - - private static ValidateResponseError GetErrorRecursive(JToken error) - { - var code = error.TryGetProperty("code"); - var message = error.TryGetProperty("message"); - var target = error.TryGetProperty("target"); - var details = error.TryGetProperty("details")?.Select(GetErrorRecursive).ToArray(); - - return new(code, message, target, details); - } - - private static IEnumerable GetDiagnostics(Compilation compilation) - { - foreach (var (bicepFile, diagnostics) in compilation.GetAllDiagnosticsByBicepFile()) - { - foreach (var diagnostic in diagnostics) - { - (int line, int character) = TextCoordinateConverter.GetPosition(bicepFile.LineStarts, diagnostic.Span.Position); - yield return new(line, character, diagnostic.Level.ToString(), diagnostic.Code, diagnostic.Message); - } - } - } -} diff --git a/src/Bicep.Core.UnitTests/Assertions/JTokenAssertionsExtensions.cs b/src/Bicep.Core.UnitTests/Assertions/JTokenAssertionsExtensions.cs index 4417faee6f9..e14f6264fcf 100644 --- a/src/Bicep.Core.UnitTests/Assertions/JTokenAssertionsExtensions.cs +++ b/src/Bicep.Core.UnitTests/Assertions/JTokenAssertionsExtensions.cs @@ -97,6 +97,9 @@ public static AndConstraint HaveValueAtPath(this JTokenAsserti return new AndConstraint(instance); } + public static AndConstraint HaveJsonAtPath(this JTokenAssertions instance, string jtokenPath, string json, string because = "", params object[] becauseArgs) + => HaveValueAtPath(instance, jtokenPath, JToken.Parse(json), because, becauseArgs); + public static AndConstraint NotHaveValueAtPath(this JTokenAssertions instance, string jtokenPath, string because = "", params object[] becauseArgs) { var valueAtPath = instance.Subject?.SelectToken(jtokenPath); diff --git a/src/Bicep.Core/Semantics/SymbolExtensions.cs b/src/Bicep.Core/Semantics/SymbolExtensions.cs index 4584f8a642b..c19be1d25e8 100644 --- a/src/Bicep.Core/Semantics/SymbolExtensions.cs +++ b/src/Bicep.Core/Semantics/SymbolExtensions.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.Configuration; using System.Linq; using Bicep.Core.Syntax; using Bicep.Core.TypeSystem; @@ -88,5 +89,8 @@ getAssignedArgumentType is not null && /// public static bool CanBeReferenced(this DeclaredSymbol declaredSymbol) => declaredSymbol is not OutputSymbol and not MetadataSymbol; + + public static string? TryGetDescriptionFromDecorator(this DeclaredSymbol symbol) + => symbol.DeclaringSyntax is DecorableSyntax decorableSyntax ? DescriptionHelper.TryGetFromDecorator(symbol.Context.Compilation.GetSemanticModel(symbol.Context.SourceFile), decorableSyntax) : null; } }