Skip to content

Commit

Permalink
Code lenses to switch between bicep and JSON module source (#12762)
Browse files Browse the repository at this point in the history
Fixes #12757 

No source was published:

![image](https://github.com/Azure/bicep/assets/6913354/c7a9493e-2714-477a-94d0-7b7ac1c8b669)

Showing module's main.bicep source:

![image](https://github.com/Azure/bicep/assets/6913354/edfc5fe7-dcbc-483a-85ea-c72581455974)

After clicking on "Show compiled JSON":

![image](https://github.com/Azure/bicep/assets/6913354/83d8af0a-a0b7-4802-ba73-9675f7514dfc)


###### Microsoft Reviewers: [Open in
CodeFlow](https://microsoft.github.io/open-pr/?codeflow=https://github.com/Azure/bicep/pull/12762)

---------

Co-authored-by: Stephen Weatherford <Stephen.Weatherford.com>
  • Loading branch information
StephenWeatherford committed Dec 17, 2023
1 parent 5c0d0fe commit 5e43858
Show file tree
Hide file tree
Showing 24 changed files with 719 additions and 69 deletions.
70 changes: 70 additions & 0 deletions src/Bicep.Core.UnitTests/Assertions/CodeLensAssertions.cs
@@ -0,0 +1,70 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Linq;
using System.Reactive.Subjects;
using Bicep.Core.UnitTests.Registry;
using FluentAssertions;
using FluentAssertions.Primitives;
using OmniSharp.Extensions.LanguageServer.Protocol.Models;

namespace Bicep.Core.UnitTests.Assertions;

public static class CodeLensAssertionsExtensions
{
public static CodeLensAssertions Should(this CodeLens codeLens)
{
return new CodeLensAssertions(codeLens);
}
}

public class CodeLensAssertions : ObjectAssertions<CodeLens, CodeLensAssertions>
{
public CodeLensAssertions(CodeLens subject)
: base(subject)
{
}

protected override string Identifier => nameof(CodeLens);

public AndConstraint<CodeLensAssertions> HaveCommandTitle(string title, string because = "", params object[] becauseArgs)
{
Subject.Command.Should().NotBeNull("Code lens command should not be null");
Subject.Command!.Title.Should().Be(title, because, becauseArgs);

return new(this);
}

public AndConstraint<CodeLensAssertions> HaveCommandName(string commandName, string because = "", params object[] becauseArgs)
{
Subject.Command.Should().NotBeNull("Code lens command should not be null");
Subject.Command!.Name.Should().Be(commandName, because, becauseArgs);

return new(this);
}

public AndConstraint<CodeLensAssertions> HaveCommandArguments(params string[] commandArguments)
{
Subject.Command.Should().NotBeNull("Code lens command should not be null");
Subject.Command!.Arguments.Should().NotBeNull("Command args should not be null");
var actualCommandArguments = Subject.CommandArguments();
actualCommandArguments.Should().BeEquivalentTo(commandArguments);

return new(this);
}

public AndConstraint<CodeLensAssertions> HaveNoCommandArguments()
{
Subject.CommandArguments().Should().BeEmpty("Command should have no arguments");

return new(this);
}

public AndConstraint<CodeLensAssertions> HaveRange(Range range, string because = "", params object[] becauseArgs)
{
Subject.Range.Should().Be(range, because, becauseArgs);

return new(this);
}


}
15 changes: 15 additions & 0 deletions src/Bicep.Core.UnitTests/Extensions/CodeLensExtensions.cs
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Linq;
using OmniSharp.Extensions.LanguageServer.Protocol.Models;

namespace Bicep.Core.UnitTests.Assertions;

public static class CodeLensExtensions
{
public static string[]? CommandArguments(this CodeLens codeLens)
{
return codeLens.Command?.Arguments?.Children().Select(token => token.ToString()).ToArray();
}
}
2 changes: 1 addition & 1 deletion src/Bicep.Core.UnitTests/Utils/OciModuleRegistryHelper.cs
Expand Up @@ -67,7 +67,7 @@ public static OciArtifactReference CreateModuleReference(string registry, string
}
}

// public a new (real) OciArtifactRegistry instance with an empty on-disk cache that can push and pull modules
// create a new (real) OciArtifactRegistry instance with an empty on-disk cache that can push and pull modules
public static (OciArtifactRegistry, MockRegistryBlobClient) CreateModuleRegistry(
Uri parentModuleUri,
IFeatureProvider featureProvider)
Expand Down
2 changes: 1 addition & 1 deletion src/Bicep.Core/Navigation/IArtifactReferenceSyntax.cs
Expand Up @@ -6,7 +6,7 @@
namespace Bicep.Core.Navigation;

/// <summary>
/// Objects that implement IArtifactReferenceSyntax, contain syntax that can reference a foregin artifact, the artifact address
/// Objects that implement IArtifactReferenceSyntax, contain syntax that can reference a foreign artifact, the artifact address
/// is returned by `TryGetPath` and `SourceSyntax` contains the source syntax object to use for error propagation.
/// </summary>
public interface IArtifactReferenceSyntax
Expand Down
7 changes: 6 additions & 1 deletion src/Bicep.Core/Registry/DefaultArtifactRegistryProvider.cs
Expand Up @@ -29,7 +29,12 @@ public DefaultArtifactRegistryProvider(IServiceProvider serviceProvider, IFileRe
this.serviceProvider = serviceProvider;
}

// NOTE: The templateUri affects how module aliases are resolved, by determining how the bicepconfig.json is located, which contains alias definitions
/// <summary>
/// Gets the registries available for module references inside a given template URI.
/// </summary>
/// <param name="templateUri">URI of the Bicep template source code which contains the module references.
/// This is needed to determine the appropriate bicepconfig.json (which contains module alias definitions) and features provider to bind to</param>
/// <returns></returns>
public ImmutableArray<IArtifactRegistry> Registries(Uri templateUri)
{
var configuration = configurationManager.GetConfiguration(templateUri);
Expand Down
2 changes: 1 addition & 1 deletion src/Bicep.Core/Utils/Result.cs
Expand Up @@ -43,7 +43,7 @@ public bool IsSuccess([NotNullWhen(true)] out TSuccess? success, [NotNullWhen(fa

/// <summary>
/// Returns the succcessful result, assuming success. Throws an exception if not.
/// This should only be called if you'e already verified that the result is successful.
/// This should only be called if you've already verified that the result is successful.
/// </summary>
public TSuccess Unwrap()
=> TryUnwrap() ?? throw new InvalidOperationException("Cannot unwrap a failed result.");
Expand Down
246 changes: 246 additions & 0 deletions src/Bicep.LangServer.IntegrationTests/CodeLensTests.cs
@@ -0,0 +1,246 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Reactive.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Bicep.Core.Registry;
using Bicep.Core.Samples;
using Bicep.Core.SourceCode;
using Bicep.Core.UnitTests;
using Bicep.Core.UnitTests.Assertions;
using Bicep.Core.UnitTests.Mock;
using Bicep.Core.UnitTests.Utils;
using Bicep.Core.Workspaces;
using Bicep.LangServer.IntegrationTests.Helpers;
using Bicep.LanguageServer.Handlers;
using Bicep.LanguageServer.Registry;
using FluentAssertions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Microsoft.WindowsAzure.ResourceStack.Common.Extensions;
using Moq;
using OmniSharp.Extensions.LanguageServer.Protocol;
using OmniSharp.Extensions.LanguageServer.Protocol.Document;
using OmniSharp.Extensions.LanguageServer.Protocol.Models;
using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range;

namespace Bicep.LangServer.IntegrationTests
{
[TestClass]
public class CodeLensTests
{
[NotNull]
public TestContext? TestContext { get; set; }

public static string GetDisplayName(MethodInfo info, object[] row)
{
row.Should().HaveCount(3);
row[0].Should().BeOfType<DataSet>();
row[1].Should().BeOfType<string>();
row[2].Should().BeAssignableTo<IList<Position>>();

return $"{info.Name}_{((DataSet)row[0]).Name}_{row[1]}";
}

// If entrypointSource is not null, then a source archive will be created with the given entrypointSource, otherwise no source archive will be created.
private SharedLanguageHelperManager CreateServer(Uri? bicepModuleEntrypoint, string? entrypointSource)
{
var moduleRegistry = StrictMock.Of<IArtifactRegistry>();
SourceArchive? sourceArchive = null;
if (bicepModuleEntrypoint is not null && entrypointSource is not null)
{
BicepFile moduleEntrypointFile = SourceFileFactory.CreateBicepFile(bicepModuleEntrypoint, entrypointSource);
sourceArchive = SourceArchive.FromStream(SourceArchive.PackSourcesIntoStream(moduleEntrypointFile.FileUri, moduleEntrypointFile));
}
moduleRegistry.Setup(m => m.TryGetSource(It.IsAny<ArtifactReference>())).Returns(sourceArchive);

var moduleDispatcher = StrictMock.Of<IModuleDispatcher>();
moduleDispatcher.Setup(x => x.RestoreModules(It.IsAny<ImmutableArray<ArtifactReference>>(), It.IsAny<bool>())).
ReturnsAsync(true);
moduleDispatcher.Setup(x => x.PruneRestoreStatuses());

MockRepository repository = new(MockBehavior.Strict);
var provider = repository.Create<IArtifactRegistryProvider>();

var artifactRegistries = moduleRegistry.Object.AsArray();

moduleDispatcher.Setup(m => m.TryGetModuleSources(It.IsAny<ArtifactReference>())).Returns((ArtifactReference reference) =>
artifactRegistries.Select(r => r.TryGetSource(reference)).FirstOrDefault(s => s is not null));

var defaultServer = new SharedLanguageHelperManager();
defaultServer.Initialize(
async () => await MultiFileLanguageServerHelper.StartLanguageServer(
TestContext,
services => services
.WithModuleDispatcher(moduleDispatcher.Object)
.WithFeatureOverrides(new(TestContext, ExtensibilityEnabled: true))));
return defaultServer;
}

[DataTestMethod]
[DataRow("file://path/to/localfile.bicep")]
[DataRow("file://path/to/localfile.json")]
[DataRow("file://path/to/localfile.bicepparam")]
[DataRow("untitled:Untitled-1")]
public async Task DisplayingLocalFile_NotExtSourceScheme_ShouldNotHaveCodeLens(string fileName)
{
var uri = DocumentUri.From($"/{this.TestContext.TestName}");

await using var server = CreateServer(null, null);
var helper = await server.GetAsync();
await helper.OpenFileOnceAsync(this.TestContext, string.Empty, uri);

// Local files will have a "file://" scheme
var documentUri = DocumentUri.FromFileSystemPath(fileName);
var lenses = await GetExternalSourceCodeLenses(helper, documentUri);

lenses.Should().BeEmpty();
}

[TestMethod]
public async Task DisplayingExternalModuleSource_EntrypointFile_ShouldHaveCodeLens_ToShowModuleCompiledJson()
{
var uri = DocumentUri.From($"/{this.TestContext.TestName}");
var moduleEntrypointUri = DocumentUri.From($"/module entrypoint.bicep");

await using var server = CreateServer(moduleEntrypointUri.ToUriEncoded(), "// module entrypoint");
var helper = await server.GetAsync();
await helper.OpenFileOnceAsync(this.TestContext, string.Empty, uri);

var externalSourceUri = new ExternalSourceReference("title", "br:myregistry.azurecr.io/myrepo/bicep/module1:v1", Path.GetFileName(moduleEntrypointUri.Path)).ToUri();
var lenses = await GetExternalSourceCodeLenses(helper, externalSourceUri);

lenses.Should().HaveCount(1);
var lens = lenses.First();
lens.Should().HaveRange(new Range(0, 0, 0, 0));
lens.Should().HaveCommandName("bicep.internal.showModuleSourceFile");
lens.Should().HaveCommandTitle("Show compiled JSON");
var target = new ExternalSourceReference(lens.CommandArguments().Single());
target.IsRequestingCompiledJson.Should().BeTrue();
}

[TestMethod]
public async Task DisplayingExternalModuleSource_BicepButNotEntrypointFile_ShouldHaveCodeLens_ToShowModuleCompiledJson()
{
var uri = DocumentUri.From($"/{this.TestContext.TestName}");
var moduleEntrypointUri = DocumentUri.From($"/module entrypoint.bicep");

await using var server = CreateServer(moduleEntrypointUri.ToUriEncoded(), "// module entrypoint");
var helper = await server.GetAsync();
await helper.OpenFileOnceAsync(this.TestContext, string.Empty, uri);

var externalSourceUri = new ExternalSourceReference("title", "br:myregistry.azurecr.io/myrepo/bicep/module1:v1", "not the entrypoint.bicep").ToUri();
var lenses = await GetExternalSourceCodeLenses(helper, externalSourceUri);

lenses.Should().HaveCount(1);
var lens = lenses.First();
lens.Should().HaveRange(new Range(0, 0, 0, 0));
lens.Should().HaveCommandName("bicep.internal.showModuleSourceFile");
lens.Should().HaveCommandTitle("Show compiled JSON");
var target = new ExternalSourceReference(lens.CommandArguments().Single());
target.IsRequestingCompiledJson.Should().BeTrue();
}

[TestMethod]
public async Task DisplayingExternalModuleSource_JsonFileThatIsIncludedInSources_ShouldHaveCodeLens_ToShowCompiledJson_ForTheWholeModule()
{
var uri = DocumentUri.From($"/{this.TestContext.TestName}");
var moduleEntrypointUri = DocumentUri.From($"/module entrypoint.bicep");

await using var server = CreateServer(moduleEntrypointUri.ToUriEncoded(), "// module entrypoint");
var helper = await server.GetAsync();
await helper.OpenFileOnceAsync(this.TestContext, string.Empty, uri);

var externalSourceUri = new ExternalSourceReference("title", "br:myregistry.azurecr.io/myrepo/bicep/module1:v1", "source file.json").ToUri();
var lenses = await GetExternalSourceCodeLenses(helper, externalSourceUri);

lenses.Should().HaveCount(1);
var lens = lenses.First();
lens.Should().HaveRange(new Range(0, 0, 0, 0));
lens.Should().HaveCommandName("bicep.internal.showModuleSourceFile");
lens.Should().HaveCommandTitle("Show compiled JSON");
var target = new ExternalSourceReference(lens.CommandArguments().Single());
target.IsRequestingCompiledJson.Should().BeTrue();
}

[TestMethod]
public async Task DisplayingModuleCompiledJsonFile_AndSourceIsAvailable_ShouldHaveCodeLens_ToShowBicepEntrypointFile()
{
var uri = DocumentUri.From($"/{this.TestContext.TestName}");
var moduleEntrypointUri = DocumentUri.From($"/module entrypoint.bicep");

await using var server = CreateServer(moduleEntrypointUri.ToUriEncoded(), "// module entrypoint");
var helper = await server.GetAsync();
await helper.OpenFileOnceAsync(this.TestContext, string.Empty, uri);

var externalSourceUri = new ExternalSourceReference("title", "br:myregistry.azurecr.io/myrepo/bicep/module1:v1", null /* main.json */).ToUri();
var lenses = await GetExternalSourceCodeLenses(helper, externalSourceUri);

lenses.Should().HaveCount(1);
var lens = lenses.First();
lens.Should().HaveRange(new Range(0, 0, 0, 0));
lens.Should().HaveCommandName("bicep.internal.showModuleSourceFile");
lens.Should().HaveCommandTitle("Show Bicep source");
var target = new ExternalSourceReference(lens.CommandArguments().Single());
target.IsRequestingCompiledJson.Should().BeFalse();
target.RequestedFile.Should().Be(Path.GetFileName(moduleEntrypointUri.Path));
}

[TestMethod]
public async Task DisplayingModuleCompiledJsonFile_AndSourceNotAvailable_ShouldHaveCodeLens_ToExplainWhyNoSources()
{
var uri = DocumentUri.From($"/{this.TestContext.TestName}");
var moduleEntrypointUri = DocumentUri.From($"/module entrypoint.bicep");

await using var server = CreateServer(moduleEntrypointUri.ToUriEncoded(), null);
var helper = await server.GetAsync();
await helper.OpenFileOnceAsync(this.TestContext, string.Empty, uri);

var externalSourceUri = new ExternalSourceReference("title", "br:myregistry.azurecr.io/myrepo/bicep/module1:v1", null /* main.json */).ToUri();
var lenses = await GetExternalSourceCodeLenses(helper, externalSourceUri);

lenses.Should().HaveCount(1);
var lens = lenses.First();
lens.Should().HaveRange(new Range(0, 0, 0, 0));
lens.Should().HaveCommandName("");
lens.Should().HaveCommandTitle("No source code is available for this module");
lens.Should().HaveNoCommandArguments();
}

[TestMethod]
public async Task HasBadUri_ShouldHaveCodeLens_ToExplainError()
{
var uri = DocumentUri.From($"/{this.TestContext.TestName}");
var moduleEntrypointUri = DocumentUri.From($"/module entrypoint.bicep");

await using var server = CreateServer(moduleEntrypointUri.ToUriEncoded(), "// module entrypoint");
var helper = await server.GetAsync();
await helper.OpenFileOnceAsync(this.TestContext, string.Empty, uri);

var badDocumentUri = new ExternalSourceReference("title", "br:myregistry.azurecr.io/myrepo/bicep/module1:v1", null /* main.json */).ToUri().AbsoluteUri.Replace("v1", ""); // bad version string
var lenses = await GetExternalSourceCodeLenses(helper, badDocumentUri);

lenses.Should().HaveCount(1);
var lens = lenses.First();
lens.Should().HaveRange(new Range(0, 0, 0, 0));
lens.Should().HaveCommandName("");
lens.Should().HaveCommandTitle("There was an error retrieving source code for this module: Invalid module reference 'br:myregistry.azurecr.io/myrepo/bicep/module1:'. The specified OCI artifact reference \"br:myregistry.azurecr.io/myrepo/bicep/module1:\" is not valid. The module tag or digest is missing. (Parameter 'fullyQualifiedModuleReference')");
lens.Should().HaveNoCommandArguments();
}

private async Task<CodeLens[]> GetExternalSourceCodeLenses(MultiFileLanguageServerHelper helper, DocumentUri documentUri)
{
return (await helper.Client.RequestCodeLens(new CodeLensParams
{
TextDocument = new TextDocumentIdentifier(documentUri)
}))?.Where(a => a.IsExternalSourceCodeLens()).ToArray()
?? Array.Empty<CodeLens>();
}
}
}
Expand Up @@ -5,6 +5,7 @@
using System.Linq;
using System.Threading.Tasks;
using Bicep.Core.Navigation;
using Bicep.Core.Registry;
using Bicep.Core.Workspaces;
using Bicep.LangServer.IntegrationTests.Helpers;
using Bicep.LanguageServer;
Expand Down
Expand Up @@ -89,6 +89,14 @@ public async Task<CompletionList> RequestCompletion(int cursor)
});
}

public async Task<CodeLensContainer?> RequestCodeLens(int cursor)
{
return await client.RequestCodeLens(new CodeLensParams
{
TextDocument = new TextDocumentIdentifier(bicepFile.FileUri),
});
}

public async Task<SignatureHelp?> RequestSignatureHelp(int cursor, SignatureHelpContext? context = null) =>
await client.RequestSignatureHelp(new SignatureHelpParams
{
Expand Down

0 comments on commit 5e43858

Please sign in to comment.