Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Code lenses to switch between bicep and JSON module source #12762

Merged
merged 2 commits into from
Dec 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/Bicep.Core.UnitTests/Assertions/CodeLensAssertions.cs
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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:https://path/to/localfile.bicep")]
[DataRow("file:https://path/to/localfile.json")]
[DataRow("file:https://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:https://" 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>();
}
}
}
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading
Loading