From e0ff9ef1cfb702b1ace5d74cf5739066137d76d6 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Fri, 14 Aug 2026 13:55:11 +0100 Subject: [PATCH 01/15] Implement Bicep module documentation generation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .gitattributes | 22 +- docs/experimental-features.md | 4 + .../DocsCommandTests.cs | 510 ++++++++++++ .../Comprehensive/README.expected.md | 179 ++++ .../DocsCommandTests/Comprehensive/_header.md | 1 + .../Comprehensive/bicepconfig.json | 5 + .../Comprehensive/examples/default/main.bicep | 11 + .../DocsCommandTests/Comprehensive/main.bicep | 123 +++ .../Comprehensive/modules/logging.bicep | 7 + .../Comprehensive/shared/_footer.md | 1 + .../Comprehensive/templates/custom.scriban | 7 + .../tests/e2e/restricted/main.test.bicep | 15 + src/Bicep.Cli.IntegrationTests/HelpTests.cs | 40 + .../JsonRpcCommandTests.cs | 259 ++++++ .../Arguments/DocsGenerateArguments.cs | 18 + .../Arguments/DocsOutputArguments.cs | 16 + src/Bicep.Cli/Commands/DocsCommand.cs | 62 ++ src/Bicep.Cli/Commands/DocsGenerateCommand.cs | 144 ++++ src/Bicep.Cli/Commands/DocsOutputCommand.cs | 105 +++ src/Bicep.Cli/Commands/JsonRpcCommand.cs | 15 +- src/Bicep.Cli/Constants/CliConstants.cs | 8 + .../Helpers/IServiceCollectionExtensions.cs | 4 +- src/Bicep.Cli/Program.cs | 3 + src/Bicep.Cli/Rpc/CliJsonRpcServer.cs | 209 ++++- src/Bicep.Cli/Rpc/ICliJsonRpcProtocol.cs | 57 ++ src/Bicep.Cli/Services/DocsCommandRunner.cs | 77 ++ src/Bicep.Cli/Services/DocsModuleScanner.cs | 153 ++++ src/Bicep.Cli/Services/OutputWriter.cs | 30 + .../Bicep.Core.UnitTests.csproj | 5 + .../ConfigurationManagerTests.cs | 18 +- ...BicepDocumentationExampleDiscoveryTests.cs | 96 +++ .../BicepDocumentationGeneratorTests.cs | 787 ++++++++++++++++++ .../BicepDocumentationOrderingTests.cs | 57 ++ ...cepDocumentationScriptModelFactoryTests.cs | 133 +++ .../BicepDocumentationTemplateLoaderTests.cs | 135 +++ .../BicepDocumentationTypeAnalyzerTests.cs | 242 ++++++ .../Documentation/Files/ExpectedMarkdown.md | 119 +++ .../Documentation/ThrowingFileExplorer.cs | 53 ++ .../Features/FeatureProviderOverrides.cs | 10 +- .../Features/FeatureProviderTests.cs | 66 ++ .../Features/OverriddenFeatureProvider.cs | 2 + src/Bicep.Core/Bicep.Core.csproj | 2 + .../BicepCoreServiceCollectionExtensions.cs | 2 + .../ExperimentalFeaturesEnabled.cs | 46 +- .../BicepDocumentationDataCollection.cs | 9 + .../BicepDocumentationExampleDiscovery.cs | 93 +++ .../BicepDocumentationException.cs | 17 + .../BicepDocumentationFunction.cs | 20 + .../BicepDocumentationGenerationOptions.cs | 29 + .../BicepDocumentationGenerator.cs | 318 +++++++ .../Documentation/BicepDocumentationModel.cs | 23 + .../BicepDocumentationOrdering.cs | 21 + .../Documentation/BicepDocumentationOutput.cs | 9 + .../BicepDocumentationParameter.cs | 39 + .../Documentation/BicepDocumentationPreset.cs | 15 + .../BicepDocumentationReference.cs | 9 + .../BicepDocumentationResourceType.cs | 9 + .../BicepDocumentationScriptModelFactory.cs | 172 ++++ .../BicepDocumentationTemplateLoader.cs | 60 ++ .../BicepDocumentationTypeAnalyzer.cs | 206 +++++ .../BicepDocumentationUsageExample.cs | 9 + .../IBicepDocumentationGenerator.cs | 39 + .../Documentation/Templates/Markdown.scriban | 215 +++++ src/Bicep.Core/Features/FeatureProvider.cs | 2 + src/Bicep.Core/Features/IFeatureProvider.cs | 3 + .../Features/RecordBasedFeatureProvider.cs | 1 + .../BicepClientUnitTests.cs | 56 ++ .../PublicApis/Azure.Bicep.RpcClient.txt | 45 + .../PooledBicepClientFactoryTests.cs | 40 +- src/Bicep.RpcClient/BicepClient.cs | 16 +- .../IBicepDocumentationClient.cs | 29 + src/Bicep.RpcClient/Models/Models.cs | 65 ++ .../PooledBicepClientFactory.cs | 11 +- .../TestFeatureProviderFactory.cs | 2 + src/Directory.Packages.props | 1 + .../schemas/bicepconfig.schema.json | 4 + 76 files changed, 5416 insertions(+), 29 deletions(-) create mode 100644 src/Bicep.Cli.IntegrationTests/DocsCommandTests.cs create mode 100644 src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/README.expected.md create mode 100644 src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/_header.md create mode 100644 src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/bicepconfig.json create mode 100644 src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/examples/default/main.bicep create mode 100644 src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/main.bicep create mode 100644 src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/modules/logging.bicep create mode 100644 src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/shared/_footer.md create mode 100644 src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/templates/custom.scriban create mode 100644 src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/tests/e2e/restricted/main.test.bicep create mode 100644 src/Bicep.Cli/Arguments/DocsGenerateArguments.cs create mode 100644 src/Bicep.Cli/Arguments/DocsOutputArguments.cs create mode 100644 src/Bicep.Cli/Commands/DocsCommand.cs create mode 100644 src/Bicep.Cli/Commands/DocsGenerateCommand.cs create mode 100644 src/Bicep.Cli/Commands/DocsOutputCommand.cs create mode 100644 src/Bicep.Cli/Services/DocsCommandRunner.cs create mode 100644 src/Bicep.Cli/Services/DocsModuleScanner.cs create mode 100644 src/Bicep.Core.UnitTests/Documentation/BicepDocumentationExampleDiscoveryTests.cs create mode 100644 src/Bicep.Core.UnitTests/Documentation/BicepDocumentationGeneratorTests.cs create mode 100644 src/Bicep.Core.UnitTests/Documentation/BicepDocumentationOrderingTests.cs create mode 100644 src/Bicep.Core.UnitTests/Documentation/BicepDocumentationScriptModelFactoryTests.cs create mode 100644 src/Bicep.Core.UnitTests/Documentation/BicepDocumentationTemplateLoaderTests.cs create mode 100644 src/Bicep.Core.UnitTests/Documentation/BicepDocumentationTypeAnalyzerTests.cs create mode 100644 src/Bicep.Core.UnitTests/Documentation/Files/ExpectedMarkdown.md create mode 100644 src/Bicep.Core.UnitTests/Documentation/ThrowingFileExplorer.cs create mode 100644 src/Bicep.Core/Documentation/BicepDocumentationDataCollection.cs create mode 100644 src/Bicep.Core/Documentation/BicepDocumentationExampleDiscovery.cs create mode 100644 src/Bicep.Core/Documentation/BicepDocumentationException.cs create mode 100644 src/Bicep.Core/Documentation/BicepDocumentationFunction.cs create mode 100644 src/Bicep.Core/Documentation/BicepDocumentationGenerationOptions.cs create mode 100644 src/Bicep.Core/Documentation/BicepDocumentationGenerator.cs create mode 100644 src/Bicep.Core/Documentation/BicepDocumentationModel.cs create mode 100644 src/Bicep.Core/Documentation/BicepDocumentationOrdering.cs create mode 100644 src/Bicep.Core/Documentation/BicepDocumentationOutput.cs create mode 100644 src/Bicep.Core/Documentation/BicepDocumentationParameter.cs create mode 100644 src/Bicep.Core/Documentation/BicepDocumentationPreset.cs create mode 100644 src/Bicep.Core/Documentation/BicepDocumentationReference.cs create mode 100644 src/Bicep.Core/Documentation/BicepDocumentationResourceType.cs create mode 100644 src/Bicep.Core/Documentation/BicepDocumentationScriptModelFactory.cs create mode 100644 src/Bicep.Core/Documentation/BicepDocumentationTemplateLoader.cs create mode 100644 src/Bicep.Core/Documentation/BicepDocumentationTypeAnalyzer.cs create mode 100644 src/Bicep.Core/Documentation/BicepDocumentationUsageExample.cs create mode 100644 src/Bicep.Core/Documentation/IBicepDocumentationGenerator.cs create mode 100644 src/Bicep.Core/Documentation/Templates/Markdown.scriban create mode 100644 src/Bicep.RpcClient/IBicepDocumentationClient.cs diff --git a/.gitattributes b/.gitattributes index b04e8951755..162b4c1ddb5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,10 +1,12 @@ -*.bicep -text -*.bicepparam -text -*.ts text eol=lf -*.cs text eol=lf -*.sh text eol=lf -/src/Bicep.Core.Samples/Files/**/Assets/**/*.txt -text -/src/Bicep.Core.Samples/Files/baselines/*_LF/**/*.bicep eol=lf -/src/Bicep.Core.Samples/Files/baselines/*_CRLF/**/*.bicep eol=crlf -/src/Bicep.Core.Samples/Files/baselines/*_CRLF/**/*.json text eol=crlf -/src/Bicep.RpcClient.Tests/Files/PublicApis/*.txt text eol=lf \ No newline at end of file +*.bicep -text +*.bicepparam -text +*.ts text eol=lf +*.cs text eol=lf +*.sh text eol=lf +/src/Bicep.Core.Samples/Files/**/Assets/**/*.txt -text +/src/Bicep.Core.Samples/Files/baselines/*_LF/**/*.bicep eol=lf +/src/Bicep.Core.Samples/Files/baselines/*_CRLF/**/*.bicep eol=crlf +/src/Bicep.Core.Samples/Files/baselines/*_CRLF/**/*.json text eol=crlf +/src/Bicep.RpcClient.Tests/Files/PublicApis/*.txt text eol=lf +/src/Bicep.Core.UnitTests/Documentation/Files/*.md text eol=lf +/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/**/*.md text eol=lf diff --git a/docs/experimental-features.md b/docs/experimental-features.md index 6174044ebf7..a0e37a4fb9c 100644 --- a/docs/experimental-features.md +++ b/docs/experimental-features.md @@ -34,6 +34,10 @@ extension az with { Enables `deploy`, `what-if` and `teardown` command groups, as well as the `with` syntax in a `.bicepparam` file. For more information, see [Using the Deploy Commands](./experimental/deploy-commands.md). +### `docsGeneration` + +Enables the `bicep docs generate` and `bicep docs output` commands for generating module documentation. + ### `legacyFormatter` Enables code formatting with the legacy formatter. This feature flag is introduced to ensure a safer transition to the v2 formatter that implements a pretty-printing algorithm. It is intended for temporary use and will be phased out soon. diff --git a/src/Bicep.Cli.IntegrationTests/DocsCommandTests.cs b/src/Bicep.Cli.IntegrationTests/DocsCommandTests.cs new file mode 100644 index 00000000000..d436a3f5994 --- /dev/null +++ b/src/Bicep.Cli.IntegrationTests/DocsCommandTests.cs @@ -0,0 +1,510 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.IO.Abstractions.TestingHelpers; +using System.IO.Abstractions; +using System.Reflection; +using Bicep.Cli.Arguments; +using Bicep.Cli.Services; +using Bicep.Core.Documentation; +using Bicep.Core.Exceptions; +using Bicep.Core.UnitTests.Features; +using Bicep.Core.UnitTests.Utils; +using Bicep.IO.Abstraction; +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Bicep.Cli.IntegrationTests; + +[TestClass] +public class DocsCommandTests : TestBase +{ + private const string FixturePrefix = "Files/DocsCommandTests/Comprehensive/"; + + private InvocationSettings DocsEnabledSettings() => + CreateDefaultSettings(overrides => overrides with { DocsGenerationEnabled = true }); + + private string SaveComprehensiveFixture() => + FileHelper.SaveEmbeddedResourcesWithPathPrefix(TestContext, Assembly.GetExecutingAssembly(), FixturePrefix); + + [TestMethod] + public async Task Generate_ComprehensiveFixture_PerformsRealIoAndMatchesGoldenFile() + { + var moduleRoot = SaveComprehensiveFixture(); + var mainFile = Path.Combine(moduleRoot, "main.bicep"); + var outputFile = Path.Combine(moduleRoot, "README.md"); + var expectedFile = Path.Combine(moduleRoot, "README.expected.md"); + File.WriteAllText(outputFile, "stale content"); + + var result = await Bicep("docs", "generate", mainFile); + + using (new AssertionScope()) + { + result.ExitCode.Should().Be(0); + result.Stdout.Should().BeEmpty(); + result.Stderr.Should().Contain("DocsGeneration"); + File.ReadAllText(outputFile).Should().Be(File.ReadAllText(expectedFile)); + File.ReadAllText(outputFile).Should().NotContain("stale content"); + } + } + + [TestMethod] + public async Task Output_ComprehensiveFixture_MatchesGeneratedContentWithoutWriting() + { + var moduleRoot = SaveComprehensiveFixture(); + var mainFile = Path.Combine(moduleRoot, "main.bicep"); + var outputFile = Path.Combine(moduleRoot, "README.md"); + File.Delete(outputFile); + + var outputResult = await Bicep("docs", "output", mainFile); + + outputResult.ExitCode.Should().Be(0); + outputResult.Stdout.Should().Be(File.ReadAllText(Path.Combine(moduleRoot, "README.expected.md"))); + File.Exists(outputFile).Should().BeFalse(); + } + + [TestMethod] + public async Task Output_CustomTemplate_SupportsIncludesTemplateRootAndCustomValues() + { + var moduleRoot = SaveComprehensiveFixture(); + var mainFile = Path.Combine(moduleRoot, "main.bicep"); + var templateFile = Path.Combine(moduleRoot, "templates", "custom.scriban"); + + var result = await Bicep( + "docs", + "output", + mainFile, + "--template-file", + templateFile, + "--template-root", + moduleRoot, + "--set", + "owner=Platform Team", + "--no-restore"); + + result.ExitCode.Should().Be(0); + result.Stdout.Should().Be(""" + > Generated module documentation. + + # Comprehensive Module + + Owner: Platform Team + Scope: subscription + Parameters: 9 + Documentation footer. + """.ReplaceLineEndings("\n") + "\n"); + } + + [TestMethod] + public async Task Generate_Pattern_ContinuesAfterCompilationFailure() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("valid/main.bicep", "metadata name = 'Valid'\nparam value string = 'ok'"), + new("invalid/main.bicep", "param value invalidType"), + ]); + var pattern = Path.Combine(root, "**", "main.bicep"); + + var result = await Bicep(DocsEnabledSettings(), "docs", "generate", "--pattern", pattern); + + result.ExitCode.Should().Be(1); + File.Exists(Path.Combine(root, "valid", "README.md")).Should().BeTrue(); + File.Exists(Path.Combine(root, "invalid", "README.md")).Should().BeFalse(); + result.Stderr.Should().Contain("invalidType"); + } + + [TestMethod] + public async Task Generate_CompilationFailure_DoesNotOverwriteExistingOutput() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("main.bicep", "param value invalidType"), + new("README.md", "preserve me"), + ]); + + var result = await Bicep(DocsEnabledSettings(), "docs", "generate", root); + + result.ExitCode.Should().Be(1); + File.ReadAllText(Path.Combine(root, "README.md")).Should().Be("preserve me"); + } + + [TestMethod] + public async Task Generate_TemplateFailure_DoesNotOverwriteExistingOutput() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("main.bicep", "param value string = 'ok'"), + new("invalid.scriban", "{{ if module.name }}"), + new("README.md", "preserve me"), + ]); + + var result = await Bicep( + DocsEnabledSettings(), + "docs", + "generate", + root, + "--template-file", + Path.Combine(root, "invalid.scriban")); + + result.ExitCode.Should().Be(1); + result.Stderr.Should().Contain("Failed to parse"); + File.ReadAllText(Path.Combine(root, "README.md")).Should().Be("preserve me"); + } + + [TestMethod] + public async Task Generate_WriteFailure_ReturnsNonZero() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("main.bicep", "metadata name = 'Example'"), + new("README.md", "locked"), + ]); + await using var lockStream = new FileStream( + Path.Combine(root, "README.md"), + FileMode.Open, + FileAccess.Read, + FileShare.None); + + var result = await Bicep(DocsEnabledSettings(), "docs", "generate", root); + + result.ExitCode.Should().Be(1); + result.Stderr.Should().NotBeEmpty(); + await lockStream.DisposeAsync(); + File.ReadAllText(Path.Combine(root, "README.md")).Should().Be("locked"); + Directory.EnumerateFiles(root, "*.tmp").Should().BeEmpty(); + } + + [TestMethod] + public async Task Generate_OutputFile_ChangesOnlyTheDestinationName() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [new("main.bicep", "metadata name = 'Example'\nparam value string = 'ok'")]); + + var defaultResult = await Bicep(DocsEnabledSettings(), "docs", "output", root); + var generateResult = await Bicep( + DocsEnabledSettings(), + "docs", + "generate", + root, + "--output-file", + "MODULE.md"); + + generateResult.ExitCode.Should().Be(0); + File.ReadAllText(Path.Combine(root, "MODULE.md")).Should().Be(defaultResult.Stdout); + File.Exists(Path.Combine(root, "README.md")).Should().BeFalse(); + } + + [TestMethod] + public async Task Generate_OutputFileCannotOverwriteAnUnselectedBicepDependency() + { + const string childSource = "metadata name = 'Child'"; + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("main.bicep", "module child 'child.bicep' = { name: 'child' }"), + new("child.bicep", childSource), + ]); + + var result = await Bicep( + DocsEnabledSettings(), + "docs", + "generate", + root, + "--output-file", + "child.bicep"); + + result.ExitCode.Should().Be(1); + File.ReadAllText(Path.Combine(root, "child.bicep")).Should().Be(childSource); + } + + [TestMethod] + public async Task Generate_PatternRejectsCollidingOutputFilesBeforeWriting() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("a.bicep", "metadata name = 'A'"), + new("b.bicep", "metadata name = 'B'"), + ]); + + var result = await Bicep( + DocsEnabledSettings(), + "docs", + "generate", + "--pattern", + Path.Combine(root, "*.bicep")); + + result.ExitCode.Should().Be(1); + File.Exists(Path.Combine(root, "README.md")).Should().BeFalse(); + } + + [TestMethod] + public async Task Output_SarifDiagnostics_KeepStdoutEmptyOnFailure() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [new("main.bicep", "param value invalidType")]); + + var result = await Bicep( + DocsEnabledSettings(), + "docs", + "output", + root, + "--diagnostics-format", + "sarif"); + + result.ExitCode.Should().Be(1); + result.Stdout.Should().BeEmpty(); + result.Stderr.Should().ContainAll("\"runs\"", "invalidType"); + } + + [TestMethod] + public async Task Commands_RequireTheExperimentalFeature() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [new("main.bicep", "param value string = 'ok'")]); + + var result = await Bicep("docs", "output", root); + + result.ExitCode.Should().Be(1); + result.Stdout.Should().BeEmpty(); + result.Stderr.Should().Contain("DocsGeneration"); + } + + [DataTestMethod] + [DataRow("missing.bicep")] + [DataRow("module.txt")] + public async Task Output_InvalidInput_ReturnsNonZero(string fileName) + { + var root = FileHelper.SaveResultFiles( + TestContext, + [new("module.txt", "not bicep")]); + + var result = await Bicep( + DocsEnabledSettings(), + "docs", + "output", + Path.Combine(root, fileName)); + + result.ExitCode.Should().Be(1); + result.Stdout.Should().BeEmpty(); + result.Stderr.Should().NotBeEmpty(); + } + + [DataTestMethod] + [DataRow(["docs", "generate", "main.bicep", "--preset", "html"])] + [DataRow(["docs", "generate", "main.bicep", "--set"])] + [DataRow(["docs", "generate", "main.bicep", "--set", "invalid"])] + [DataRow(["docs", "generate", "main.bicep", "--set", "key=one", "--set", "key=two"])] + [DataRow(["docs", "generate", "main.bicep", "--output-file", "nested/README.md"])] + [DataRow(["docs", "output", "main.bicep", "--pattern", "**/main.bicep"])] + public async Task InvalidArguments_ReturnNonZero(string[] arguments) + { + var result = await Bicep(DocsEnabledSettings(), arguments); + + result.ExitCode.Should().Be(1); + result.Stderr.Should().NotBeEmpty(); + } + + [TestMethod] + public async Task Generate_RejectsInputPathWithPattern() + { + var result = await Bicep( + DocsEnabledSettings(), + "docs", + "generate", + "main.bicep", + "--pattern", + "**/main.bicep"); + + result.ExitCode.Should().Be(1); + result.Stderr.Should().Contain("cannot both be specified"); + } + + [TestMethod] + public async Task Output_RejectsMissingTemplateRoot() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [new("main.bicep", "param value string = 'ok'")]); + + var result = await Bicep( + DocsEnabledSettings(), + "docs", + "output", + root, + "--template-root", + Path.Combine(root, "missing")); + + result.ExitCode.Should().Be(1); + result.Stderr.Should().Contain("does not exist"); + } + + [TestMethod] + [DoNotParallelize] + public async Task Generate_WithoutPath_UsesCurrentDirectory() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [new("main.bicep", "metadata name = 'Current Directory'")]); + var previousDirectory = Directory.GetCurrentDirectory(); + + try + { + Directory.SetCurrentDirectory(root); + var result = await Bicep(DocsEnabledSettings(), "docs", "generate"); + + result.ExitCode.Should().Be(0); + File.Exists(Path.Combine(root, "README.md")).Should().BeTrue(); + } + finally + { + Directory.SetCurrentDirectory(previousDirectory); + } + } + + [TestMethod] + public void ModuleScanner_ValidatesResolutionEdgeCases() + { + var fileSystem = new MockFileSystem(new Dictionary + { + ["/module/main.bicep"] = "metadata name = 'Example'", + ["/module/other.bicep"] = "metadata name = 'Other'", + ["/template.scriban"] = "# Example", + }); + var resolver = new InputOutputArgumentsResolver(fileSystem); + var scanner = new DocsModuleScanner(fileSystem, resolver); + + scanner.ResolveModule("/module").GetFilePath().Should().Be(fileSystem.Path.GetFullPath("/module/main.bicep")); + scanner.ResolveModule("/module/main.bicep").GetFilePath().Should().Be(fileSystem.Path.GetFullPath("/module/main.bicep")); + scanner.ResolveOptionalFile(null).Should().BeNull(); + scanner.ResolveOptionalFile("/template.scriban").Should().NotBeNull(); + scanner.ResolveOptionalDirectory(null).Should().BeNull(); + scanner.ResolveOptionalDirectory("/module").Should().NotBeNull(); + scanner.ResolveOptionalDirectory("/module/").Should().NotBeNull(); + scanner.ValidateOutputFileName("README.md"); + + var directArguments = new DocsGenerateArguments( + "/module/main.bicep", + null, + BicepDocumentationPreset.Markdown, + null, + null, + [], + "README.md", + false, + null); + scanner.ResolveModules(directArguments).Should().ContainSingle(); + var mainUri = scanner.ResolveModule("/module/main.bicep"); + var otherUri = scanner.ResolveModule("/module/other.bicep"); + scanner.ResolveOutputFiles([mainUri], "MODULE.md") + .Should().ContainSingle(pair => Path.GetFileName(pair.OutputUri.GetFilePath()) == "MODULE.md"); + FluentActions.Invoking(() => scanner.ResolveOutputFiles([mainUri], "main.bicep")) + .Should().Throw().WithMessage("*source file extension*"); + FluentActions.Invoking(() => scanner.ResolveOutputFiles([mainUri, otherUri], "README.md")) + .Should().Throw().WithMessage("*same output file*"); + + var conflictingArguments = directArguments with { FilePattern = "**/main.bicep" }; + FluentActions.Invoking(() => scanner.ResolveModules(conflictingArguments)) + .Should().Throw().WithMessage("*cannot both be specified*"); + + FluentActions.Invoking(() => scanner.ResolveModule("/missing")) + .Should().Throw().WithMessage("*does not exist*"); + FluentActions.Invoking(() => scanner.ResolveOptionalDirectory("/missing")) + .Should().Throw().WithMessage("*does not exist*"); + FluentActions.Invoking(() => scanner.ValidateOutputFileName("")) + .Should().Throw(); + FluentActions.Invoking(() => scanner.ValidateOutputFileName("nested/README.md")) + .Should().Throw(); + FluentActions.Invoking(() => scanner.ValidateOutputFileName("invalid\0.md")) + .Should().Throw(); + } + + [DataTestMethod] + [DataRow("")] + [DataRow(" ")] + [DataRow(".")] + [DataRow("..")] + [DataRow("nested/README.md")] + [DataRow(@"nested\README.md")] + [DataRow("README.md ")] + [DataRow("README.md.")] + [DataRow("CON")] + [DataRow("PRN.txt")] + [DataRow("AUX")] + [DataRow("NUL.md")] + [DataRow("COM1")] + [DataRow("LPT9.txt")] + [DataRow("module.bicep")] + [DataRow("module.bicepparam")] + public void ModuleScanner_RejectsInvalidOutputFileNames(string outputFile) + { + var fileSystem = new MockFileSystem(); + var scanner = new DocsModuleScanner(fileSystem, new(fileSystem)); + + FluentActions.Invoking(() => scanner.ValidateOutputFileName(outputFile)) + .Should().Throw(); + } + + [TestMethod] + public void ModuleScanner_ConvertsPathExceptionsToCommandLineErrors() + { + Exception[] exceptions = + [ + new IOException("io"), + new UnauthorizedAccessException("unauthorized"), + new ArgumentException("argument"), + new NotSupportedException("unsupported"), + ]; + + foreach (var exception in exceptions) + { + var fileSystem = new Mock(MockBehavior.Strict); + var path = new Mock(MockBehavior.Strict); + fileSystem.SetupGet(system => system.Path).Returns(path.Object); + path.Setup(systemPath => systemPath.GetFullPath("invalid")).Throws(exception); + var scanner = new DocsModuleScanner(fileSystem.Object, new(fileSystem.Object)); + + FluentActions.Invoking(() => scanner.ResolveModule("invalid")) + .Should().Throw() + .WithMessage(exception.Message); + } + } + + [TestMethod] + public async Task OutputWriter_AtomicWrite_ReportsUnauthorizedAccess() + { + var fileSystem = new Mock(MockBehavior.Strict); + var file = new Mock(MockBehavior.Strict); + var fileExplorer = new Mock(MockBehavior.Strict); + var temporaryFile = new Mock(MockBehavior.Strict); + fileSystem.SetupGet(system => system.File).Returns(file.Object); + fileExplorer + .Setup(explorer => explorer.GetFile(It.IsAny())) + .Returns(temporaryFile.Object); + temporaryFile + .Setup(handle => handle.WriteAllTextAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new UnauthorizedAccessException("denied")); + file.Setup(systemFile => systemFile.Exists(It.IsAny())).Returns(false); + var writer = new OutputWriter( + new( + new(new StringReader(string.Empty), false), + new(new StringWriter(), false), + new(new StringWriter(), false)), + fileSystem.Object, + fileExplorer.Object); + var outputUri = IOUri.FromFilePath(Path.GetFullPath("README.md")); + + await FluentActions.Invoking(() => writer.WriteToFileAtomicallyAsync(outputUri, "contents")) + .Should().ThrowAsync() + .WithMessage("denied"); + } +} diff --git a/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/README.expected.md b/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/README.expected.md new file mode 100644 index 00000000000..4db8612eb5c --- /dev/null +++ b/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/README.expected.md @@ -0,0 +1,179 @@ +# Comprehensive Module + +Exercises every documentation feature | with multiline details. +Second line. + + +## Navigation + +- [Resource Types](#resource-types) +- [Usage Examples](#usage-examples) +- [Parameters](#parameters) +- [Exported Functions](#exported-functions) +- [Outputs](#outputs) +- [Cross-referenced Modules](#cross-referenced-modules) +- [Data Collection](#data-collection) + +## Resource Types + +| Resource Type | Existing | +| :-- | :-- | +| `Microsoft.Resources/resourceGroups@2024-03-01` | No | +| `Microsoft.Resources/resourceGroups@2024-03-01` | Yes | + +## Usage Examples + +### default + +Deploys the module with its default settings. + +```bicep +targetScope = 'subscription' + +metadata description = 'Deploys the module with its default settings.' + +module example '../../main.bicep' = { + name: 'example' + params: { + resourceGroupName: 'example-rg' + secret: 'example' + } +} + +``` + +### e2e + +Exercises restricted network access. + +```bicep +metadata description = 'Exercises restricted network access.' + +module test '../../../main.bicep' = { + name: 'test' + params: { + resourceGroupName: 'restricted-rg' + secret: 'example' + networkAccess: { + kind: 'restricted' + allowedCidrs: [ + '10.0.0.0/24' + ] + } + } +} + +``` + +## Parameters + +| Name | Type | Required | Description | +| :-- | :-- | :-- | :-- | +| `enableTelemetry` | `bool` | No | Enables anonymous usage telemetry. | +| `location` | `string` | No | Deployment location. | +| `names` | `array` | No | Names assigned to the deployment. | +| `networkAccess` | `object` | No | Network access configuration. | +| `resourceGroupName` | `string` | Yes | Resource group \| name. Second line. | +| `retentionInDays` | `int` | No | Retention period in days. | +| `secret` | `securestring` | Yes | Secret used by the child module. | +| `settings` | `object` | No | Nested settings. | +| `tier` | `string` | No | Deployment tier. | + +### `enableTelemetry` + +- Default value: `false` + +### `location` + +- Default value: `deployment().location` + +### `names` + +- Default value: `[ + 'default' +]` + +- Min length: 1 + +- Max length: 5 + +### `networkAccess` + +- Default value: `{ + kind: 'public' +}` + +- Discriminator: `kind` + - `public`: + - `kind` (`'public'`), required + - `restricted`: + - `allowedCidrs` (`array`), required: Allowed CIDR ranges. + - `kind` (`'restricted'`), required + +### `resourceGroupName` + +- Min length: 3 + +- Max length: 90 + +### `retentionInDays` + +- Default value: `30` + +- Min value: 1 + +- Max value: 365 + +### `secret` + +- Secure: Yes + +### `settings` + +- Default value: `{ + enabled: true + labels: { + environment: 'test' + } +}` + +- Properties: + - `enabled` (`bool`), required: Whether the feature is enabled. + - `labels` (`object`), required: Labels applied to resources. + - `environment` (`string`), required: Environment label. + +### `tier` + +- Default value: `'Standard'` + +- Allowed values: `Premium`, `Standard` + +## Exported Functions + +### `buildDisplayName` + +Builds a display name. + +Returns: `string` + +| Name | Type | Description | +| :-- | :-- | :-- | +| `prefix` | `string` | | + +## Outputs + +| Name | Type | Description | +| :-- | :-- | :-- | +| `existingResourceGroupId` | `string` | The existing resource group ID. | +| `resourceGroupId` | `string` | The deployed resource group ID. | +| `secureValue` | `securestring` | A secure output used to exercise output metadata. | + +## Cross-referenced Modules + +| Symbolic Name | Path | Description | +| :-- | :-- | :-- | +| `logging` | `modules/logging.bicep` | Configures diagnostic logging. | + +## Data Collection + +This module uses the `enableTelemetry` parameter to report anonymized module usage to Microsoft, in support of continued investment in the Bicep and Azure Verified Modules ecosystems. No resource-specific data is collected. diff --git a/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/_header.md b/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/_header.md new file mode 100644 index 00000000000..060ae932a24 --- /dev/null +++ b/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/_header.md @@ -0,0 +1 @@ +> Generated module documentation. diff --git a/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/bicepconfig.json b/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/bicepconfig.json new file mode 100644 index 00000000000..1d681fa67b7 --- /dev/null +++ b/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/bicepconfig.json @@ -0,0 +1,5 @@ +{ + "experimentalFeaturesEnabled": { + "docsGeneration": true + } +} diff --git a/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/examples/default/main.bicep b/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/examples/default/main.bicep new file mode 100644 index 00000000000..2fa4f1aa3e8 --- /dev/null +++ b/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/examples/default/main.bicep @@ -0,0 +1,11 @@ +targetScope = 'subscription' + +metadata description = 'Deploys the module with its default settings.' + +module example '../../main.bicep' = { + name: 'example' + params: { + resourceGroupName: 'example-rg' + secret: 'example' + } +} diff --git a/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/main.bicep b/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/main.bicep new file mode 100644 index 00000000000..4757ea25ca4 --- /dev/null +++ b/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/main.bicep @@ -0,0 +1,123 @@ +targetScope = 'subscription' + +metadata name = 'Comprehensive Module' +metadata description = ''' +Exercises every documentation feature | with multiline details. +Second line. +''' + +@description('Deployment location.') +param location string = deployment().location + +@description(''' +Resource group | name. +Second line. +''') +@minLength(3) +@maxLength(90) +param resourceGroupName string + +@description('Deployment tier.') +@allowed([ + 'Standard' + 'Premium' +]) +param tier string = 'Standard' + +@description('Retention period in days.') +@minValue(1) +@maxValue(365) +param retentionInDays int = 30 + +@description('Names assigned to the deployment.') +@minLength(1) +@maxLength(5) +param names string[] = [ + 'default' +] + +@description('Secret used by the child module.') +@secure() +param secret string + +@description('Network access configuration.') +param networkAccess networkAccessType = { + kind: 'public' +} + +@description('Nested settings.') +param settings settingsType = { + enabled: true + labels: { + environment: 'test' + } +} + +@description('Enables anonymous usage telemetry.') +param enableTelemetry bool = false + +@export() +@description('Nested module settings.') +type settingsType = { + @description('Whether the feature is enabled.') + enabled: bool + @description('Labels applied to resources.') + labels: { + @description('Environment label.') + environment: string + } +} + +@export() +type publicAccessType = { + kind: 'public' +} + +@export() +type restrictedAccessType = { + kind: 'restricted' + @description('Allowed CIDR ranges.') + allowedCidrs: string[] +} + +@export() +@discriminator('kind') +type networkAccessType = publicAccessType | restrictedAccessType + +resource resourceGroup 'Microsoft.Resources/resourceGroups@2024-03-01' = { + name: resourceGroupName + location: location + tags: { + tier: tier + retentionInDays: string(retentionInDays) + primaryName: names[0] + settingsEnabled: string(settings.enabled) + networkAccessKind: networkAccess.kind + } +} + +resource existingResourceGroup 'Microsoft.Resources/resourceGroups@2024-03-01' existing = { + name: 'existing-resource-group' +} + +module logging 'modules/logging.bicep' = { + name: 'logging' + scope: resourceGroup + params: { + secret: secret + } +} + +@export() +@description('Builds a display name.') +func buildDisplayName(prefix string) string => '${prefix}-deployment' + +@description('The deployed resource group ID.') +output resourceGroupId string = resourceGroup.id + +@description('The existing resource group ID.') +output existingResourceGroupId string = existingResourceGroup.id + +@secure() +@description('A secure output used to exercise output metadata.') +output secureValue string = secret diff --git a/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/modules/logging.bicep b/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/modules/logging.bicep new file mode 100644 index 00000000000..dfe90a1a12c --- /dev/null +++ b/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/modules/logging.bicep @@ -0,0 +1,7 @@ +metadata description = 'Configures diagnostic logging.' + +@description('Secret passed from the parent module.') +@secure() +param secret string + +output configured bool = !empty(secret) diff --git a/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/shared/_footer.md b/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/shared/_footer.md new file mode 100644 index 00000000000..57b7a7aaeb2 --- /dev/null +++ b/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/shared/_footer.md @@ -0,0 +1 @@ +Documentation footer. diff --git a/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/templates/custom.scriban b/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/templates/custom.scriban new file mode 100644 index 00000000000..4a7ff3a8ae0 --- /dev/null +++ b/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/templates/custom.scriban @@ -0,0 +1,7 @@ +{{ include "_header.md" }} +# {{ module.name }} + +Owner: {{ custom.owner }} +Scope: {{ module.targetScope }} +Parameters: {{ module.parameters.size }} +{{ include "shared/_footer.md" }} diff --git a/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/tests/e2e/restricted/main.test.bicep b/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/tests/e2e/restricted/main.test.bicep new file mode 100644 index 00000000000..88bf79df74f --- /dev/null +++ b/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/tests/e2e/restricted/main.test.bicep @@ -0,0 +1,15 @@ +metadata description = 'Exercises restricted network access.' + +module test '../../../main.bicep' = { + name: 'test' + params: { + resourceGroupName: 'restricted-rg' + secret: 'example' + networkAccess: { + kind: 'restricted' + allowedCidrs: [ + '10.0.0.0/24' + ] + } + } +} diff --git a/src/Bicep.Cli.IntegrationTests/HelpTests.cs b/src/Bicep.Cli.IntegrationTests/HelpTests.cs index 1ccffd7ba3d..f6517e73a22 100644 --- a/src/Bicep.Cli.IntegrationTests/HelpTests.cs +++ b/src/Bicep.Cli.IntegrationTests/HelpTests.cs @@ -25,6 +25,7 @@ public async Task Root_Help_ShouldSucceed_WithExpectedOutput() "build-params", "decompile", "decompile-params", + "docs", "format", "generate-params", "lint", @@ -37,6 +38,45 @@ public async Task Root_Help_ShouldSucceed_WithExpectedOutput() } } + [TestMethod] + public async Task Docs_Help_ShouldSucceed_WithExpectedOutput() + { + var (groupOutput, groupError, groupResult) = await Bicep("docs", "--help"); + var (generateOutput, generateError, generateResult) = await Bicep("docs", "generate", "--help"); + var (outputOutput, outputError, outputResult) = await Bicep("docs", "output", "--help"); + + using (new AssertionScope()) + { + groupResult.Should().Be(0); + groupError.Should().BeEmpty(); + groupOutput.Should().ContainAll("docs", "generate", "output"); + + generateResult.Should().Be(0); + generateError.Should().BeEmpty(); + generateOutput.Should().ContainAll( + "--preset", + "--template-file", + "--template-root", + "--set", + "--output-file", + "--pattern", + "--no-restore", + "--diagnostics-format"); + + outputResult.Should().Be(0); + outputError.Should().BeEmpty(); + outputOutput.Should().ContainAll( + "--preset", + "--template-file", + "--template-root", + "--set", + "--no-restore", + "--diagnostics-format"); + outputOutput.Should().NotContain("--pattern"); + outputOutput.Should().NotContain("--output-file"); + } + } + [TestMethod] public async Task Build_Help_ShouldSucceed_WithExpectedOutput() { diff --git a/src/Bicep.Cli.IntegrationTests/JsonRpcCommandTests.cs b/src/Bicep.Cli.IntegrationTests/JsonRpcCommandTests.cs index a8781757ff1..99716bdd1fe 100644 --- a/src/Bicep.Cli.IntegrationTests/JsonRpcCommandTests.cs +++ b/src/Bicep.Cli.IntegrationTests/JsonRpcCommandTests.cs @@ -8,14 +8,19 @@ using System.Text.Json.Nodes; using Bicep.Cli.Rpc; using Bicep.Core.Json; +using Bicep.Core.Exceptions; using Bicep.Core.UnitTests; using Bicep.Core.UnitTests.Assertions; +using Bicep.Core.UnitTests.Features; using Bicep.Core.UnitTests.Utils; +using Bicep.IO.Abstraction; +using Bicep.IO.FileSystem; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using Microsoft.VisualStudio.TestTools.UnitTesting; using Microsoft.WindowsAzure.ResourceStack.Common.Json; using Newtonsoft.Json.Linq; +using Moq; using StreamJsonRpc; namespace Bicep.Cli.IntegrationTests; @@ -153,6 +158,260 @@ await RunServerTest( }); } + [TestMethod] + public async Task OutputDocs_returns_rendered_documentation() + { + var fileSystem = new MockFileSystem(new Dictionary + { + ["/main.bicep"] = """ + metadata name = 'RPC Module' + metadata description = 'Rendered through JSON-RPC.' + + @description('Example value.') + param value string = 'default' + """, + }); + + await RunServerTest( + services => services + .WithFileSystem(fileSystem) + .WithFeatureOverrides(new(DocsGenerationEnabled: true)), + async (client, token) => + { + var response = await client.OutputDocs( + new("/main.bicep", null, null, null, null, NoRestore: false), + token); + + response.Result.Success.Should().BeTrue(); + response.Result.Path.Should().Be(fileSystem.Path.GetFullPath("/main.bicep")); + response.Result.OutputPath.Should().BeNull(); + response.Result.Diagnostics.Should().ContainSingle(diagnostic => + diagnostic.Level == "Warning" && + diagnostic.Code == "no-unused-params"); + response.Result.Contents.Should().ContainAll("# RPC Module", "Rendered through JSON-RPC.", "`value`"); + fileSystem.File.Exists("/README.md").Should().BeFalse(); + }); + } + + [TestMethod] + public async Task OutputDocs_custom_template_supports_includes_and_custom_values() + { + var fileSystem = new MockFileSystem(new Dictionary + { + ["/main.bicep"] = "metadata name = 'RPC Module'", + ["/template.scriban"] = "{{ include \"_header.md\" }} {{ module.name }} {{ custom.owner }}", + ["/_header.md"] = "Header", + }); + + await RunServerTest( + services => services + .WithFileSystem(fileSystem) + .WithFeatureOverrides(new(DocsGenerationEnabled: true)), + async (client, token) => + { + var response = await client.OutputDocs( + new( + "/main.bicep", + "markdown", + "/template.scriban", + "/", + new() { ["owner"] = "Platform" }, + NoRestore: true), + token); + + response.Result.Success.Should().BeTrue(); + response.Result.Contents.Should().Be("Header RPC Module Platform\n"); + }); + } + + [TestMethod] + public async Task GenerateDocs_writes_successful_modules_and_continues_failures() + { + var fileSystem = new MockFileSystem(new Dictionary + { + ["/valid/main.bicep"] = "metadata name = 'Valid'", + ["/invalid/main.bicep"] = "param value invalidType", + }); + + await RunServerTest( + services => services + .WithFileSystem(fileSystem) + .WithFeatureOverrides(new(DocsGenerationEnabled: true)), + async (client, token) => + { + var response = await client.GenerateDocs( + new( + ["/valid/main.bicep", "/invalid/main.bicep"], + null, + null, + null, + null, + null, + NoRestore: false), + token); + + response.Results.Should().HaveCount(2); + response.Results[0].Success.Should().BeTrue(); + response.Results[0].OutputPath.Should().Be(fileSystem.Path.GetFullPath("/valid/README.md")); + response.Results[0].Contents.Should().Be(fileSystem.File.ReadAllText("/valid/README.md")); + response.Results[1].Success.Should().BeFalse(); + response.Results[1].Contents.Should().BeNull(); + response.Results[1].Diagnostics.Should().Contain(diagnostic => diagnostic.Level == "Error"); + fileSystem.File.Exists("/invalid/README.md").Should().BeFalse(); + }); + } + + [TestMethod] + public async Task Docs_methods_return_structured_failures() + { + var fileSystem = new MockFileSystem(new Dictionary + { + ["/main.bicep"] = "metadata name = 'Disabled'", + ["/main.txt"] = "not bicep", + ["/invalid.scriban"] = "{{ if module.name }}", + ["/a.bicep"] = "metadata name = 'A'", + ["/b.bicep"] = "metadata name = 'B'", + }); + + await RunServerTest( + services => services.WithFileSystem(fileSystem), + async (client, token) => + { + var disabled = await client.OutputDocs( + new("/main.bicep", null, null, null, null, NoRestore: false), + token); + disabled.Result.Success.Should().BeFalse(); + disabled.Result.Diagnostics.Should().ContainSingle(diagnostic => + diagnostic.Code == "DOCS001" && + diagnostic.Message.Contains("DocsGeneration")); + }); + + await RunServerTest( + services => services + .WithFileSystem(fileSystem) + .WithFeatureOverrides(new(DocsGenerationEnabled: true)), + async (client, token) => + { + var invalidPreset = await client.OutputDocs( + new("/main.bicep", "html", null, null, null, NoRestore: false), + token); + invalidPreset.Result.Success.Should().BeFalse(); + invalidPreset.Result.Diagnostics.Should().ContainSingle(diagnostic => diagnostic.Code == "DOCS001"); + + var invalidExtension = await client.OutputDocs( + new("/main.txt", null, null, null, null, NoRestore: false), + token); + invalidExtension.Result.Success.Should().BeFalse(); + invalidExtension.Result.Diagnostics.Should().ContainSingle(diagnostic => diagnostic.Code == "DOCS001"); + + var missingPath = await client.OutputDocs( + new("/missing", null, null, null, null, NoRestore: false), + token); + missingPath.Result.Success.Should().BeFalse(); + missingPath.Result.Diagnostics.Should().ContainSingle(diagnostic => diagnostic.Code == "DOCS001"); + + var invalidTemplate = await client.OutputDocs( + new("/main.bicep", null, "/invalid.scriban", null, null, NoRestore: false), + token); + invalidTemplate.Result.Success.Should().BeFalse(); + invalidTemplate.Result.Diagnostics.Should().ContainSingle(diagnostic => diagnostic.Code == "DOCS003"); + + var invalidOutput = await client.GenerateDocs( + new(["/main.bicep"], null, null, null, null, "nested/README.md", NoRestore: false), + token); + invalidOutput.Results.Should().ContainSingle(); + invalidOutput.Results[0].Success.Should().BeFalse(); + invalidOutput.Results[0].Diagnostics.Should().ContainSingle(diagnostic => diagnostic.Code == "DOCS001"); + + var inputOverwrite = await client.GenerateDocs( + new(["/main.bicep"], null, null, null, null, "main.bicep", NoRestore: false), + token); + inputOverwrite.Results.Should().ContainSingle(); + inputOverwrite.Results[0].Success.Should().BeFalse(); + + var outputCollision = await client.GenerateDocs( + new(["/missing", "/a.bicep", "/b.bicep"], null, null, null, null, null, NoRestore: false), + token); + outputCollision.Results.Should().HaveCount(3); + outputCollision.Results.Should().OnlyContain(result => !result.Success); + + var mixedResult = await client.GenerateDocs( + new(["/missing", "/main.bicep"], null, null, null, null, "MIXED.md", NoRestore: false), + token); + mixedResult.Results.Should().HaveCount(2); + mixedResult.Results[0].Success.Should().BeFalse(); + mixedResult.Results[1].Success.Should().BeTrue(); + }); + } + + [TestMethod] + public async Task GenerateDocs_returns_structured_write_failures() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("main.bicep", "metadata name = 'Example'"), + new("README.md", "preserve me"), + ]); + var outputFile = Path.Combine(root, "README.md"); + await using var lockStream = new FileStream(outputFile, FileMode.Open, FileAccess.Read, FileShare.None); + + await RunServerTest( + services => services.WithFeatureOverrides(new(DocsGenerationEnabled: true)), + async (client, token) => + { + var response = await client.GenerateDocs( + new([Path.Combine(root, "main.bicep")], null, null, null, null, null, NoRestore: false), + token); + + response.Results.Should().ContainSingle(); + response.Results[0].Success.Should().BeFalse(); + response.Results[0].Diagnostics.Should().ContainSingle(diagnostic => + diagnostic.Code == "DOCS002"); + }); + + await lockStream.DisposeAsync(); + File.ReadAllText(outputFile).Should().Be("preserve me"); + Directory.EnumerateFiles(root, "*.tmp").Should().BeEmpty(); + } + + [TestMethod] + public async Task OutputDocs_returns_structured_compilation_exceptions() + { + var fileSystem = new MockFileSystem(new Dictionary + { + ["/main.bicep"] = "metadata name = 'Example'", + }); + var innerExplorer = new FileSystemFileExplorer(fileSystem); + var mainFile = IOUri.FromFilePath(fileSystem.Path.GetFullPath("/main.bicep")); + var explorer = new Mock(MockBehavior.Strict); + explorer + .Setup(fileExplorer => fileExplorer.GetDirectory(It.IsAny())) + .Returns((IOUri uri) => innerExplorer.GetDirectory(uri)); + explorer + .Setup(fileExplorer => fileExplorer.GetFile(It.IsAny())) + .Returns((IOUri uri) => uri.Equals(mainFile) + ? throw new BicepException("compilation failed") + : innerExplorer.GetFile(uri)); + + await RunServerTest( + services => services + .WithFileSystem(fileSystem) + .WithFileExplorer(explorer.Object) + .WithFeatureOverrides(new(DocsGenerationEnabled: true)), + async (client, token) => + { + var response = await client.OutputDocs( + new("/main.bicep", null, null, null, null, NoRestore: false), + token); + + response.Result.Success.Should().BeFalse(); + response.Result.Diagnostics.Should().ContainSingle(diagnostic => + diagnostic.Code == "DOCS001" && + diagnostic.Message == "compilation failed"); + }); + } + [TestMethod] public async Task GetDeploymentGraph_returns_deployment_graph() { diff --git a/src/Bicep.Cli/Arguments/DocsGenerateArguments.cs b/src/Bicep.Cli/Arguments/DocsGenerateArguments.cs new file mode 100644 index 00000000000..06f3a587f71 --- /dev/null +++ b/src/Bicep.Cli/Arguments/DocsGenerateArguments.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using Bicep.Core.Documentation; + +namespace Bicep.Cli.Arguments; + +public record DocsGenerateArguments( + string? InputFile, + string? FilePattern, + BicepDocumentationPreset Preset, + string? TemplateFile, + string? TemplateRoot, + ImmutableArray CustomValues, + string OutputFile, + bool NoRestore, + DiagnosticsFormat? DiagnosticsFormat) : IFilePatternInputArguments; diff --git a/src/Bicep.Cli/Arguments/DocsOutputArguments.cs b/src/Bicep.Cli/Arguments/DocsOutputArguments.cs new file mode 100644 index 00000000000..881916562b8 --- /dev/null +++ b/src/Bicep.Cli/Arguments/DocsOutputArguments.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using Bicep.Core.Documentation; + +namespace Bicep.Cli.Arguments; + +public record DocsOutputArguments( + string? InputFile, + BicepDocumentationPreset Preset, + string? TemplateFile, + string? TemplateRoot, + ImmutableArray CustomValues, + bool NoRestore, + DiagnosticsFormat? DiagnosticsFormat) : IInputArguments; diff --git a/src/Bicep.Cli/Commands/DocsCommand.cs b/src/Bicep.Cli/Commands/DocsCommand.cs new file mode 100644 index 00000000000..181422bc4b4 --- /dev/null +++ b/src/Bicep.Cli/Commands/DocsCommand.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using Bicep.Core.Documentation; +using Bicep.Core.Exceptions; + +namespace Bicep.Cli.Commands; + +public static class DocsCommand +{ + internal static System.CommandLine.Command CreateCommand(CommandLineBuilderContext context) + { + var command = new System.CommandLine.Command(Constants.Command.Docs, "Generates documentation for Bicep modules."); + command.Add(DocsGenerateCommand.CreateCommand(context)); + command.Add(DocsOutputCommand.CreateCommand(context)); + + return command; + } + + internal static BicepDocumentationPreset ParsePreset(string? value) + { + if (value is null || value.Equals("markdown", StringComparison.OrdinalIgnoreCase)) + { + return BicepDocumentationPreset.Markdown; + } + + throw new CommandLineException($"The preset \"{value}\" is not supported. The only supported preset is \"markdown\"."); + } + + internal static ImmutableSortedDictionary ParseCustomValues(IEnumerable values) + { + var customValues = ImmutableSortedDictionary.CreateBuilder(StringComparer.Ordinal); + + foreach (var value in values) + { + var separatorIndex = value.IndexOf('='); + if (separatorIndex <= 0) + { + throw new CommandLineException($"The --set value \"{value}\" must use the format key=value."); + } + + var key = value[..separatorIndex]; + if (!customValues.TryAdd(key, value[(separatorIndex + 1)..])) + { + throw new CommandLineException($"The --set key \"{key}\" cannot be specified more than once."); + } + } + + return customValues.ToImmutable(); + } + + internal static void ValidateSetOption( + System.CommandLine.ParseResult result, + System.CommandLine.Option setOption) + { + if (result.GetResult(setOption) is { Implicit: false, Tokens.Count: 0 }) + { + throw new CommandLineException("The --set parameter expects a key=value argument."); + } + } +} diff --git a/src/Bicep.Cli/Commands/DocsGenerateCommand.cs b/src/Bicep.Cli/Commands/DocsGenerateCommand.cs new file mode 100644 index 00000000000..aa4cae430ab --- /dev/null +++ b/src/Bicep.Cli/Commands/DocsGenerateCommand.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using Bicep.Cli.Arguments; +using Bicep.Cli.Helpers; +using Bicep.Cli.Services; +using Bicep.Core.Exceptions; +using Option = Bicep.Cli.Constants.Option; + +namespace Bicep.Cli.Commands; + +public class DocsGenerateCommand( + IOContext io, + DocsModuleScanner moduleScanner, + DocsCommandRunner runner, + OutputWriter writer) : ICommand +{ + public async Task RunAsync(DocsGenerateArguments arguments) + { + var modules = moduleScanner.ResolveModules(arguments); + var inputOutputPairs = moduleScanner.ResolveOutputFiles(modules, arguments.OutputFile); + var templateFile = moduleScanner.ResolveOptionalFile(arguments.TemplateFile); + var templateRoot = moduleScanner.ResolveOptionalDirectory(arguments.TemplateRoot); + var customValues = DocsCommand.ParseCustomValues(arguments.CustomValues); + var hasErrors = false; + + foreach (var (module, outputUri) in inputOutputPairs) + { + ArgumentHelper.ValidateBicepFile(module); + var result = await runner.RenderAsync( + module, + arguments.Preset, + templateFile, + templateRoot, + customValues, + arguments.NoRestore, + arguments.DiagnosticsFormat); + + if (!result.Success || result.Contents is null) + { + hasErrors = true; + continue; + } + + try + { + await writer.WriteToFileAtomicallyAsync(outputUri, result.Contents); + } + catch (BicepException exception) + { + await io.Error.Writer.WriteLineAsync(exception.Message); + hasErrors = true; + } + } + + return hasErrors ? 1 : 0; + } + + internal static System.CommandLine.Command CreateCommand(CommandLineBuilderContext context) + { + var command = new System.CommandLine.Command(Constants.Command.DocsGenerate, "Generates documentation files for Bicep modules.") + { + TreatUnmatchedTokensAsErrors = true, + }; + var inputFileArgument = new System.CommandLine.Argument(Constants.Argument.InputFile) + { + Description = "The path to a .bicep file or module directory. Defaults to the current directory.", + Arity = ArgumentArity.ZeroOrOne, + }; + var presetOption = new System.CommandLine.Option(Option.Preset) + { + Description = "Selects a built-in preset. The only supported value is markdown.", + }; + var templateFileOption = new System.CommandLine.Option(Option.TemplateFile) + { + Description = "Uses a custom Scriban template file.", + }; + var templateRootOption = new System.CommandLine.Option(Option.TemplateRoot) + { + Description = "Sets the root directory for template includes.", + }; + var setOption = new System.CommandLine.Option(Option.Set) + { + Description = "Supplies a custom template value in key=value form. May be repeated.", + Arity = ArgumentArity.ZeroOrMore, + }; + var outputFileOption = new System.CommandLine.Option(Option.OutputFile) + { + Description = "Sets the output file name. Defaults to README.md.", + }; + var patternOption = new System.CommandLine.Option(Option.Pattern) + { + Description = "Generates documentation for all files matching the glob pattern.", + }; + var noRestoreOption = new System.CommandLine.Option(Option.NoRestore) + { + Description = "Skips restoring external modules.", + }; + var diagnosticsFormatOption = new System.CommandLine.Option(Option.DiagnosticsFormat) + { + Description = "Sets the diagnostics format. Valid values are (Default, SARIF).", + }; + + command.Add(inputFileArgument); + command.Add(presetOption); + command.Add(templateFileOption); + command.Add(templateRootOption); + command.Add(setOption); + command.Add(outputFileOption); + command.Add(patternOption); + command.Add(noRestoreOption); + command.Add(diagnosticsFormatOption); + command.Validators.Add(result => + { + CommandLineBuilderContext.ValidatePositionalArgument(result, inputFileArgument); + if (result.GetValue(inputFileArgument) is not null && result.GetValue(patternOption) is not null) + { + result.AddError("The input path and --pattern parameter cannot both be specified."); + } + }); + + command.SetAction((result, ct) => context.RunCommandAsync(async () => + { + DocsCommand.ValidateSetOption(result, setOption); + var customValues = result.GetValue(setOption); + ArgumentNullException.ThrowIfNull(customValues); + var arguments = new DocsGenerateArguments( + result.GetValue(inputFileArgument), + result.GetValue(patternOption), + DocsCommand.ParsePreset(result.GetValue(presetOption)), + result.GetValue(templateFileOption), + result.GetValue(templateRootOption), + [.. customValues], + result.GetValue(outputFileOption) ?? "README.md", + result.GetValue(noRestoreOption), + result.GetValue(diagnosticsFormatOption)); + + return await context.GetCommand().RunAsync(arguments); + })); + + return command; + } +} diff --git a/src/Bicep.Cli/Commands/DocsOutputCommand.cs b/src/Bicep.Cli/Commands/DocsOutputCommand.cs new file mode 100644 index 00000000000..069f6015145 --- /dev/null +++ b/src/Bicep.Cli/Commands/DocsOutputCommand.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using Bicep.Cli.Arguments; +using Bicep.Cli.Helpers; +using Bicep.Cli.Services; +using Option = Bicep.Cli.Constants.Option; + +namespace Bicep.Cli.Commands; + +public class DocsOutputCommand( + IOContext io, + DocsModuleScanner moduleScanner, + DocsCommandRunner runner) : ICommand +{ + public async Task RunAsync(DocsOutputArguments arguments) + { + var module = moduleScanner.ResolveModule(arguments.InputFile); + ArgumentHelper.ValidateBicepFile(module); + + var result = await runner.RenderAsync( + module, + arguments.Preset, + moduleScanner.ResolveOptionalFile(arguments.TemplateFile), + moduleScanner.ResolveOptionalDirectory(arguments.TemplateRoot), + DocsCommand.ParseCustomValues(arguments.CustomValues), + arguments.NoRestore, + arguments.DiagnosticsFormat); + + if (!result.Success || result.Contents is null) + { + return 1; + } + + await io.Output.Writer.WriteAsync(result.Contents); + return 0; + } + + internal static System.CommandLine.Command CreateCommand(CommandLineBuilderContext context) + { + var command = new System.CommandLine.Command(Constants.Command.DocsOutput, "Renders documentation for one Bicep module to stdout.") + { + TreatUnmatchedTokensAsErrors = true, + }; + var inputFileArgument = new System.CommandLine.Argument(Constants.Argument.InputFile) + { + Description = "The path to a .bicep file or module directory. Defaults to the current directory.", + Arity = ArgumentArity.ZeroOrOne, + }; + var presetOption = new System.CommandLine.Option(Option.Preset) + { + Description = "Selects a built-in preset. The only supported value is markdown.", + }; + var templateFileOption = new System.CommandLine.Option(Option.TemplateFile) + { + Description = "Uses a custom Scriban template file.", + }; + var templateRootOption = new System.CommandLine.Option(Option.TemplateRoot) + { + Description = "Sets the root directory for template includes.", + }; + var setOption = new System.CommandLine.Option(Option.Set) + { + Description = "Supplies a custom template value in key=value form. May be repeated.", + Arity = ArgumentArity.ZeroOrMore, + }; + var noRestoreOption = new System.CommandLine.Option(Option.NoRestore) + { + Description = "Skips restoring external modules.", + }; + var diagnosticsFormatOption = new System.CommandLine.Option(Option.DiagnosticsFormat) + { + Description = "Sets the diagnostics format. Valid values are (Default, SARIF).", + }; + + command.Add(inputFileArgument); + command.Add(presetOption); + command.Add(templateFileOption); + command.Add(templateRootOption); + command.Add(setOption); + command.Add(noRestoreOption); + command.Add(diagnosticsFormatOption); + command.Validators.Add(result => CommandLineBuilderContext.ValidatePositionalArgument(result, inputFileArgument)); + + command.SetAction((result, ct) => context.RunCommandAsync(async () => + { + DocsCommand.ValidateSetOption(result, setOption); + var customValues = result.GetValue(setOption); + ArgumentNullException.ThrowIfNull(customValues); + var arguments = new DocsOutputArguments( + result.GetValue(inputFileArgument), + DocsCommand.ParsePreset(result.GetValue(presetOption)), + result.GetValue(templateFileOption), + result.GetValue(templateRootOption), + [.. customValues], + result.GetValue(noRestoreOption), + result.GetValue(diagnosticsFormatOption)); + + return await context.GetCommand().RunAsync(arguments); + })); + + return command; + } +} diff --git a/src/Bicep.Cli/Commands/JsonRpcCommand.cs b/src/Bicep.Cli/Commands/JsonRpcCommand.cs index 90a70082891..a31b4f9955e 100644 --- a/src/Bicep.Cli/Commands/JsonRpcCommand.cs +++ b/src/Bicep.Cli/Commands/JsonRpcCommand.cs @@ -9,7 +9,9 @@ using Bicep.Cli.Arguments; using Bicep.Cli.Constants; using Bicep.Cli.Rpc; +using Bicep.Cli.Services; using Bicep.Core; +using Bicep.Core.Documentation; using Bicep.Core.Features; using Bicep.Core.Utils; using Microsoft.Extensions.DependencyInjection; @@ -21,7 +23,10 @@ namespace Bicep.Cli.Commands; public class JsonRpcCommand( BicepCompiler compiler, InputOutputArgumentsResolver inputOutputArgumentsResolver, - IEnvironment environment) : ICommand + IEnvironment environment, + IBicepDocumentationGenerator documentationGenerator, + DocsModuleScanner docsModuleScanner, + OutputWriter writer) : ICommand { public async Task RunAsync(JsonRpcArguments args, CancellationToken cancellationToken) { @@ -62,7 +67,13 @@ private async Task RunServer(Stream inputStream, Stream outputStream, Cancellati jsonRpc.TraceSource.Listeners.AddRange(Trace.Listeners); } - var server = new CliJsonRpcServer(compiler, inputOutputArgumentsResolver, environment); + var server = new CliJsonRpcServer( + compiler, + inputOutputArgumentsResolver, + environment, + documentationGenerator, + docsModuleScanner, + writer); jsonRpc.AddLocalRpcTarget(server, null); jsonRpc.StartListening(); diff --git a/src/Bicep.Cli/Constants/CliConstants.cs b/src/Bicep.Cli/Constants/CliConstants.cs index 549645ce37a..a2f5ba0452b 100644 --- a/src/Bicep.Cli/Constants/CliConstants.cs +++ b/src/Bicep.Cli/Constants/CliConstants.cs @@ -23,6 +23,9 @@ public static class Command public const string WhatIf = "what-if"; public const string Teardown = "teardown"; public const string Console = "console"; + public const string Docs = "docs"; + public const string DocsGenerate = "generate"; + public const string DocsOutput = "output"; public const string Root = ""; } @@ -49,6 +52,11 @@ public static class Option public const string NoRestore = "--no-restore"; public const string Force = "--force"; public const string DiagnosticsFormat = "--diagnostics-format"; + public const string OutputFile = "--output-file"; + public const string Preset = "--preset"; + public const string Set = "--set"; + public const string TemplateFile = "--template-file"; + public const string TemplateRoot = "--template-root"; // Build / BuildParams public const string BicepFile = "--bicep-file"; diff --git a/src/Bicep.Cli/Helpers/IServiceCollectionExtensions.cs b/src/Bicep.Cli/Helpers/IServiceCollectionExtensions.cs index 5780baf3f94..763854b4a3c 100644 --- a/src/Bicep.Cli/Helpers/IServiceCollectionExtensions.cs +++ b/src/Bicep.Cli/Helpers/IServiceCollectionExtensions.cs @@ -63,5 +63,7 @@ public static IServiceCollection AddCommands(this IServiceCollection services) = .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddSingleton() + .AddSingleton(); } diff --git a/src/Bicep.Cli/Program.cs b/src/Bicep.Cli/Program.cs index eb3104e9568..0cf2ef11149 100644 --- a/src/Bicep.Cli/Program.cs +++ b/src/Bicep.Cli/Program.cs @@ -158,6 +158,7 @@ private SclRootCommand BuildCommandLine() rootCommand.Add(WhatIfCommand.CreateCommand(context)); rootCommand.Add(TeardownCommand.CreateCommand(context)); rootCommand.Add(ConsoleCommand.CreateCommand(context)); + rootCommand.Add(DocsCommand.CreateCommand(context)); return rootCommand; } @@ -205,6 +206,8 @@ private static IServiceCollection ConfigureServices(IOContext io) .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton(io) .AddSingleton() diff --git a/src/Bicep.Cli/Rpc/CliJsonRpcServer.cs b/src/Bicep.Cli/Rpc/CliJsonRpcServer.cs index a1a6ab152f7..7b7b62007da 100644 --- a/src/Bicep.Cli/Rpc/CliJsonRpcServer.cs +++ b/src/Bicep.Cli/Rpc/CliJsonRpcServer.cs @@ -3,9 +3,13 @@ using System.Collections.Immutable; using Bicep.Cli.Arguments; +using Bicep.Cli.Commands; +using Bicep.Cli.Services; +using Bicep.Core.Exceptions; using Bicep.Cli.Helpers; using Bicep.Core; using Bicep.Core.Emit; +using Bicep.Core.Documentation; using Bicep.Core.Extensions; using Bicep.Core.Navigation; using Bicep.Core.PrettyPrint; @@ -26,7 +30,10 @@ namespace Bicep.Cli.Rpc; public class CliJsonRpcServer( BicepCompiler compiler, InputOutputArgumentsResolver inputOutputArgumentsResolver, - IEnvironment environment) : ICliJsonRpcProtocol + IEnvironment environment, + IBicepDocumentationGenerator documentationGenerator, + DocsModuleScanner docsModuleScanner, + OutputWriter writer) : ICliJsonRpcProtocol { public static IJsonRpcMessageHandler CreateMessageHandler(Stream inputStream, Stream outputStream) { @@ -267,6 +274,206 @@ public async Task Format(FormatRequest request, CancellationToke return new(formattedContent); } + /// + public async Task GenerateDocs(GenerateDocsRequest request, CancellationToken cancellationToken) + { + var results = ImmutableArray.CreateBuilder(); + var outputFile = request.OutputFile ?? "README.md"; + var failures = new Dictionary(); + var validTargets = new List<(int Index, string RequestedPath, IOUri InputUri)>(); + + for (var index = 0; index < request.Paths.Length; index++) + { + var path = request.Paths[index]; + try + { + validTargets.Add((index, path, docsModuleScanner.ResolveModule(path))); + } + catch (BicepException exception) + { + failures[index] = CreateDocsFailure(path, "DOCS001", exception.Message); + } + } + + DocsTarget[] targets = []; + try + { + var validInputs = validTargets.Select(target => target.InputUri).ToArray(); + var outputFiles = docsModuleScanner.ResolveOutputFiles(validInputs, outputFile) + .ToDictionary(pair => pair.InputUri, pair => pair.OutputUri); + + targets = validTargets + .Select(target => new DocsTarget( + target.Index, + target.InputUri, + outputFiles[target.InputUri])) + .ToArray(); + } + catch (BicepException exception) + { + foreach (var target in validTargets) + { + failures[target.Index] = CreateDocsFailure(target.RequestedPath, "DOCS001", exception.Message); + } + } + + var targetsByIndex = targets.ToDictionary(target => target.Index); + for (var index = 0; index < request.Paths.Length; index++) + { + if (failures.TryGetValue(index, out var failure)) + { + results.Add(failure); + continue; + } + + var target = targetsByIndex[index]; + + var result = await RenderDocs( + target.InputUri, + request.Preset, + request.TemplateFile, + request.TemplateRoot, + request.Custom, + request.NoRestore, + cancellationToken); + + if (!result.Success || result.Contents is null) + { + results.Add(result); + continue; + } + + try + { + await writer.WriteToFileAtomicallyAsync(target.OutputUri, result.Contents, cancellationToken); + results.Add(result with { OutputPath = target.OutputUri.GetFilePath() }); + } + catch (BicepException exception) + { + results.Add(AddDocsFailure(result, "DOCS002", exception.Message)); + } + } + + return new(results.ToImmutable()); + } + + /// + public async Task OutputDocs(OutputDocsRequest request, CancellationToken cancellationToken) => + new(await RenderDocs( + request.Path, + request.Preset, + request.TemplateFile, + request.TemplateRoot, + request.Custom, + request.NoRestore, + cancellationToken)); + + private async Task RenderDocs( + string path, + string? preset, + string? templateFile, + string? templateRoot, + IReadOnlyDictionary? custom, + bool noRestore, + CancellationToken cancellationToken) + { + IOUri inputUri; + try + { + inputUri = docsModuleScanner.ResolveModule(path); + } + catch (BicepException exception) + { + return CreateDocsFailure(path, "DOCS001", exception.Message); + } + + return await RenderDocs(inputUri, preset, templateFile, templateRoot, custom, noRestore, cancellationToken); + } + + private async Task RenderDocs( + IOUri inputUri, + string? preset, + string? templateFile, + string? templateRoot, + IReadOnlyDictionary? custom, + bool noRestore, + CancellationToken cancellationToken) + { + if (!inputUri.HasBicepExtension()) + { + return CreateDocsFailure(inputUri.GetFilePath(), "DOCS001", $"Invalid Bicep file path: {inputUri}"); + } + + BicepDocumentationGenerationOptions options; + try + { + options = new( + DocsCommand.ParsePreset(preset), + docsModuleScanner.ResolveOptionalFile(templateFile), + docsModuleScanner.ResolveOptionalDirectory(templateRoot), + custom); + } + catch (BicepException exception) + { + return CreateDocsFailure(inputUri.GetFilePath(), "DOCS001", exception.Message); + } + + Compilation compilation; + try + { + compilation = await compiler.CreateCompilation(inputUri, skipRestore: noRestore); + } + catch (BicepException exception) + { + return CreateDocsFailure(inputUri.GetFilePath(), "DOCS001", exception.Message); + } + + var diagnostics = GetDiagnostics(compilation).ToImmutableArray(); + var model = compilation.GetEntrypointSemanticModel(); + + if (!model.Features.DocsGenerationEnabled) + { + return AddDocsFailure( + new(inputUri.GetFilePath(), null, false, diagnostics, null), + "DOCS001", + $"The '{nameof(Bicep.Core.Configuration.ExperimentalFeaturesEnabled.DocsGeneration)}' experimental feature must be enabled."); + } + + if (model.HasErrors()) + { + return new(inputUri.GetFilePath(), null, false, diagnostics, null); + } + + try + { + return new(inputUri.GetFilePath(), null, true, diagnostics, documentationGenerator.Generate(compilation, options)); + } + catch (BicepDocumentationException exception) + { + return AddDocsFailure( + new(inputUri.GetFilePath(), null, false, diagnostics, null), + "DOCS003", + exception.Message); + } + } + + private static DocsResult AddDocsFailure(DocsResult result, string code, string message) => + result with + { + Success = false, + OutputPath = null, + Contents = null, + Diagnostics = [.. result.Diagnostics, CreateDocsDiagnostic(result.Path, code, message)], + }; + + private static DocsResult CreateDocsFailure(string path, string code, string message) => + new(path, null, false, [CreateDocsDiagnostic(path, code, message)], null); + + private static DiagnosticDefinition CreateDocsDiagnostic(string path, string code, string message) => + new(path, new(new(0, 0), new(0, 0)), "Error", code, message); + + private record DocsTarget(int Index, IOUri InputUri, IOUri OutputUri); + private async Task GetCompilation(BicepCompiler compiler, string filePath) { var fileUri = inputOutputArgumentsResolver.PathToUri(filePath); diff --git a/src/Bicep.Cli/Rpc/ICliJsonRpcProtocol.cs b/src/Bicep.Cli/Rpc/ICliJsonRpcProtocol.cs index 0f295d502ca..8ef8a33fa5a 100644 --- a/src/Bicep.Cli/Rpc/ICliJsonRpcProtocol.cs +++ b/src/Bicep.Cli/Rpc/ICliJsonRpcProtocol.cs @@ -129,6 +129,51 @@ public record FormatRequest( public record FormatResponse( string Contents); +/// +/// Requests documentation files for one or more modules. +/// +public record GenerateDocsRequest( + ImmutableArray Paths, + string? Preset, + string? TemplateFile, + string? TemplateRoot, + Dictionary? Custom, + string? OutputFile, + bool NoRestore); + +/// +/// Requests rendered documentation for one module. +/// +public record OutputDocsRequest( + string Path, + string? Preset, + string? TemplateFile, + string? TemplateRoot, + Dictionary? Custom, + bool NoRestore); + +/// +/// Contains documentation content and diagnostics for one module. +/// +public record DocsResult( + string Path, + string? OutputPath, + bool Success, + ImmutableArray Diagnostics, + string? Contents); + +/// +/// Contains results for all requested modules. +/// +public record GenerateDocsResponse( + ImmutableArray Results); + +/// +/// Contains the result for one rendered module. +/// +public record OutputDocsResponse( + DocsResult Result); + /// /// The definition for the Bicep CLI JSONRPC interface. /// @@ -184,4 +229,16 @@ public interface ICliJsonRpcProtocol /// [JsonRpcMethod("bicep/format", UseSingleObjectParameterDeserialization = true)] Task Format(FormatRequest request, CancellationToken cancellationToken); + + /// + /// Generates documentation files for Bicep modules. + /// + [JsonRpcMethod("bicep/generateDocs", UseSingleObjectParameterDeserialization = true)] + Task GenerateDocs(GenerateDocsRequest request, CancellationToken cancellationToken); + + /// + /// Renders documentation for one Bicep module. + /// + [JsonRpcMethod("bicep/outputDocs", UseSingleObjectParameterDeserialization = true)] + Task OutputDocs(OutputDocsRequest request, CancellationToken cancellationToken); } diff --git a/src/Bicep.Cli/Services/DocsCommandRunner.cs b/src/Bicep.Cli/Services/DocsCommandRunner.cs new file mode 100644 index 00000000000..f059db969f8 --- /dev/null +++ b/src/Bicep.Cli/Services/DocsCommandRunner.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Bicep.Cli.Arguments; +using Bicep.Cli.Helpers; +using Bicep.Cli.Logging; +using Bicep.Core; +using Bicep.Core.Documentation; +using Bicep.Core.Exceptions; +using Bicep.Core.Features; +using Bicep.Core.Semantics; +using Bicep.IO.Abstraction; +using Microsoft.Extensions.Logging; + +namespace Bicep.Cli.Services; + +public record DocsRenderResult(bool Success, string? Contents); + +public class DocsCommandRunner( + ILogger logger, + IOContext io, + DiagnosticLogger diagnosticLogger, + BicepCompiler compiler, + IFeatureProviderFactory featureProviderFactory, + IBicepDocumentationGenerator documentationGenerator) +{ + public async Task RenderAsync( + IOUri inputUri, + BicepDocumentationPreset preset, + IOUri? templateFile, + IOUri? templateRoot, + IReadOnlyDictionary customValues, + bool noRestore, + DiagnosticsFormat? diagnosticsFormat) + { + if (!featureProviderFactory.GetFeatureProvider(inputUri).DocsGenerationEnabled) + { + await io.Error.Writer.WriteLineAsync( + $"The '{nameof(Bicep.Core.Configuration.ExperimentalFeaturesEnabled.DocsGeneration)}' experimental feature must be enabled for \"{inputUri}\"."); + return new(false, null); + } + + Compilation compilation; + try + { + compilation = await compiler.CreateCompilation(inputUri, skipRestore: noRestore); + } + catch (BicepException exception) + { + await io.Error.Writer.WriteLineAsync(exception.Message); + return new(false, null); + } + + CommandHelper.LogExperimentalWarning(logger, compilation); + + var summary = diagnosticLogger.LogDiagnostics(ArgumentHelper.GetDiagnosticOptions(diagnosticsFormat), compilation); + if (summary.HasErrors) + { + return new(false, null); + } + + logger.LogWarning(string.Format( + CliResources.ExperimentalFeaturesDisclaimerMessage, + nameof(Bicep.Core.Configuration.ExperimentalFeaturesEnabled.DocsGeneration))); + + try + { + var options = new BicepDocumentationGenerationOptions(preset, templateFile, templateRoot, customValues); + return new(true, documentationGenerator.Generate(compilation, options)); + } + catch (BicepDocumentationException exception) + { + await io.Error.Writer.WriteLineAsync(exception.Message); + return new(false, null); + } + } +} diff --git a/src/Bicep.Cli/Services/DocsModuleScanner.cs b/src/Bicep.Cli/Services/DocsModuleScanner.cs new file mode 100644 index 00000000000..cf82bd90144 --- /dev/null +++ b/src/Bicep.Cli/Services/DocsModuleScanner.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.IO.Abstractions; +using Bicep.Cli.Arguments; +using Bicep.IO.Abstraction; + +namespace Bicep.Cli.Services; + +public class DocsModuleScanner(IFileSystem fileSystem, InputOutputArgumentsResolver argumentsResolver) +{ + public IReadOnlyList ResolveModules(DocsGenerateArguments arguments) + { + if (arguments.InputFile is not null && arguments.FilePattern is not null) + { + throw new CommandLineException("The input path and --pattern parameter cannot both be specified."); + } + + if (arguments.FilePattern is not null) + { + return ExecutePathOperation(() => + argumentsResolver.ResolveFilePatternInputArguments(arguments) + .OrderBy(uri => uri.ToString(), StringComparer.OrdinalIgnoreCase) + .ThenBy(uri => uri.ToString(), StringComparer.Ordinal) + .ToArray()); + } + + return [ResolveModule(arguments.InputFile)]; + } + + public IOUri ResolveModule(string? path) + { + return ExecutePathOperation(() => + { + path ??= fileSystem.Directory.GetCurrentDirectory(); + var fullPath = fileSystem.Path.GetFullPath(path); + + if (fileSystem.Directory.Exists(fullPath)) + { + fullPath = fileSystem.Path.Combine(fullPath, "main.bicep"); + } + else if (!fileSystem.File.Exists(fullPath) && + !string.Equals(fileSystem.Path.GetExtension(fullPath), ".bicep", StringComparison.OrdinalIgnoreCase)) + { + throw new CommandLineException($"The path \"{fullPath}\" does not exist."); + } + + return argumentsResolver.PathToUri(fullPath); + }); + } + + public IOUri? ResolveOptionalFile(string? path) => + path is null ? null : ExecutePathOperation(() => argumentsResolver.PathToUri(path)); + + public IOUri? ResolveOptionalDirectory(string? path) + { + if (path is null) + { + return null; + } + + return ExecutePathOperation(() => + { + var fullPath = fileSystem.Path.GetFullPath(path); + if (!fileSystem.Directory.Exists(fullPath)) + { + throw new CommandLineException($"The template root directory \"{fullPath}\" does not exist."); + } + + var normalizedPath = Path.EndsInDirectorySeparator(fullPath) + ? fullPath + : fullPath + fileSystem.Path.DirectorySeparatorChar; + + return argumentsResolver.PathToUri(normalizedPath); + }); + } + + public IReadOnlyList<(IOUri InputUri, IOUri OutputUri)> ResolveOutputFiles( + IReadOnlyList modules, + string outputFile) + { + ValidateOutputFileName(outputFile); + + var results = modules + .Select(input => (InputUri: input, OutputUri: input.Resolve(outputFile))) + .ToArray(); + + if (results.Select(result => result.OutputUri).Distinct().Count() != results.Length) + { + throw new CommandLineException("Multiple input modules resolve to the same output file."); + } + + return results; + } + + public void ValidateOutputFileName(string outputFile) + { + var extension = fileSystem.Path.GetExtension(outputFile); + if (string.IsNullOrWhiteSpace(outputFile) || + outputFile is "." or ".." || + outputFile.Contains('/') || + outputFile.Contains('\\') || + outputFile.IndexOfAny(fileSystem.Path.GetInvalidFileNameChars()) >= 0 || + outputFile.EndsWith(' ') || + outputFile.EndsWith('.') || + IsReservedWindowsFileName(outputFile)) + { + throw new CommandLineException("The --output-file parameter must be a file name without a directory path."); + } + + if (extension.Equals(".bicep", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".bicepparam", StringComparison.OrdinalIgnoreCase)) + { + throw new CommandLineException("The --output-file parameter cannot use a Bicep source file extension."); + } + } + + private static bool IsReservedWindowsFileName(string outputFile) + { + var name = Path.GetFileNameWithoutExtension(outputFile); + return name.Equals("CON", StringComparison.OrdinalIgnoreCase) || + name.Equals("PRN", StringComparison.OrdinalIgnoreCase) || + name.Equals("AUX", StringComparison.OrdinalIgnoreCase) || + name.Equals("NUL", StringComparison.OrdinalIgnoreCase) || + Enumerable.Range(1, 9).Any(number => + name.Equals($"COM{number}", StringComparison.OrdinalIgnoreCase) || + name.Equals($"LPT{number}", StringComparison.OrdinalIgnoreCase)); + } + + private static T ExecutePathOperation(Func operation) + { + try + { + return operation(); + } + catch (IOException exception) + { + throw new CommandLineException(exception.Message, exception); + } + catch (UnauthorizedAccessException exception) + { + throw new CommandLineException(exception.Message, exception); + } + catch (ArgumentException exception) + { + throw new CommandLineException(exception.Message, exception); + } + catch (NotSupportedException exception) + { + throw new CommandLineException(exception.Message, exception); + } + } +} diff --git a/src/Bicep.Cli/Services/OutputWriter.cs b/src/Bicep.Cli/Services/OutputWriter.cs index 2b468dba783..d83c55151b7 100644 --- a/src/Bicep.Cli/Services/OutputWriter.cs +++ b/src/Bicep.Cli/Services/OutputWriter.cs @@ -170,5 +170,35 @@ public async Task WriteToFileAsync(IOUri fileUri, string contents) throw new BicepException(exception.Message, exception); } } + + public async Task WriteToFileAtomicallyAsync( + IOUri fileUri, + string contents, + CancellationToken cancellationToken = default) + { + var outputPath = fileUri.GetFilePath(); + var directory = fileUri.Resolve(".").GetFilePath(); + var temporaryPath = Path.Combine( + directory, + $".{Path.GetFileName(outputPath)}.{Guid.NewGuid():N}.tmp"); + var temporaryUri = IOUri.FromFilePath(temporaryPath); + + try + { + await fileExplorer.GetFile(temporaryUri).WriteAllTextAsync(contents, cancellationToken); + fileSystem.File.Move(temporaryPath, outputPath, overwrite: true); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + throw new BicepException(exception.Message, exception); + } + finally + { + if (fileSystem.File.Exists(temporaryPath)) + { + fileSystem.File.Delete(temporaryPath); + } + } + } } } diff --git a/src/Bicep.Core.UnitTests/Bicep.Core.UnitTests.csproj b/src/Bicep.Core.UnitTests/Bicep.Core.UnitTests.csproj index 548b58326b3..4aadebcc9fc 100644 --- a/src/Bicep.Core.UnitTests/Bicep.Core.UnitTests.csproj +++ b/src/Bicep.Core.UnitTests/Bicep.Core.UnitTests.csproj @@ -22,6 +22,11 @@ + + + + + bicepconfig.schema.json diff --git a/src/Bicep.Core.UnitTests/Configuration/ConfigurationManagerTests.cs b/src/Bicep.Core.UnitTests/Configuration/ConfigurationManagerTests.cs index f08e4a0ec94..e8da6c33b98 100644 --- a/src/Bicep.Core.UnitTests/Configuration/ConfigurationManagerTests.cs +++ b/src/Bicep.Core.UnitTests/Configuration/ConfigurationManagerTests.cs @@ -118,7 +118,8 @@ public void GetBuiltInConfiguration_NoParameter_ReturnsBuiltInConfigurationWithA "deployCommands": false, "patch": false, "runtimeValuesInTagsAndSku": false, - "azExtensionConfig": false + "azExtensionConfig": false, + "docsGeneration": false }, "formatting": { "indentKind": "Space", @@ -206,7 +207,8 @@ public void GetBuiltInConfiguration_DisableAllAnalyzers_ReturnsBuiltInConfigurat "deployCommands": false, "patch": false, "runtimeValuesInTagsAndSku": false, - "azExtensionConfig": false + "azExtensionConfig": false, + "docsGeneration": false }, "formatting": { "indentKind": "Space", @@ -316,7 +318,8 @@ public void GetBuiltInConfiguration_DisableAnalyzers_ReturnsBuiltInConfiguration "deployCommands": false, "patch": false, "runtimeValuesInTagsAndSku": false, - "azExtensionConfig": false + "azExtensionConfig": false, + "docsGeneration": false }, "formatting": { "indentKind": "Space", @@ -404,7 +407,8 @@ public void GetBuiltInConfiguration_EnableExperimentalFeature_ReturnsBuiltInConf DeployCommands: false, Patch: false, RuntimeValuesInTagsAndSku: false, - AzExtensionConfig: false); + AzExtensionConfig: false, + DocsGeneration: false); configuration.WithExperimentalFeaturesEnabled(experimentalFeaturesEnabled).Should().HaveContents(/*lang=json,strict*/ """ { @@ -493,7 +497,8 @@ public void GetBuiltInConfiguration_EnableExperimentalFeature_ReturnsBuiltInConf "deployCommands": false, "patch": false, "runtimeValuesInTagsAndSku": false, - "azExtensionConfig": false + "azExtensionConfig": false, + "docsGeneration": false }, "formatting": { "indentKind": "Space", @@ -851,7 +856,8 @@ public void GetConfiguration_ValidCustomConfiguration_OverridesBuiltInConfigurat "deployCommands": false, "patch": false, "runtimeValuesInTagsAndSku": false, - "azExtensionConfig": false + "azExtensionConfig": false, + "docsGeneration": false }, "formatting": { "indentKind": "Space", diff --git a/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationExampleDiscoveryTests.cs b/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationExampleDiscoveryTests.cs new file mode 100644 index 00000000000..cd5295de3a9 --- /dev/null +++ b/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationExampleDiscoveryTests.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Bicep.Core.Documentation; +using Bicep.Testing; +using Bicep.Testing.IO; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bicep.Core.UnitTests.Documentation; + +[TestClass] +public class BicepDocumentationExampleDiscoveryTests +{ + [TestMethod] + public void Discover_NoExamplesOrTestsFolders_ReturnsEmpty() + { + var fileSet = MockFileSystemTestFileSet.Create(("main.bicep", "param foo string")); + + var examples = BicepDocumentationExampleDiscovery.Discover(GetModuleRoot(fileSet)); + + examples.Should().BeEmpty(); + } + + [TestMethod] + public void Discover_MetadataDescription_ExtractsLiteralValue() + { + var fileSet = MockFileSystemTestFileSet.Create( + ("examples/default/main.bicep", "metadata description = 'From metadata.'\nparam foo string")); + + var examples = BicepDocumentationExampleDiscovery.Discover(GetModuleRoot(fileSet)); + + examples.Should().ContainSingle(); + examples[0].Description.Should().Be("From metadata."); + } + + [TestMethod] + public void Discover_LeadingCommentBlock_ExtractsJoinedCommentText() + { + var fileSet = MockFileSystemTestFileSet.Create( + ("examples/default/main.bicep", "// Line one.\n// Line two.\nparam foo string")); + + var examples = BicepDocumentationExampleDiscovery.Discover(GetModuleRoot(fileSet)); + + examples.Should().ContainSingle(); + examples[0].Description.Should().Be("Line one. Line two."); + } + + [TestMethod] + public void Discover_NoMetadataOrLeadingComment_DescriptionIsNull() + { + var fileSet = MockFileSystemTestFileSet.Create(("examples/default/main.bicep", "param foo string")); + + var examples = BicepDocumentationExampleDiscovery.Discover(GetModuleRoot(fileSet)); + + examples.Should().ContainSingle(); + examples[0].Description.Should().BeNull(); + } + + [TestMethod] + public void Discover_NonBicepFilesInCategoryFolders_AreIgnored() + { + var fileSet = MockFileSystemTestFileSet.Create( + ("examples/default/main.bicep", "param foo string"), + ("examples/notes.md", "Not an example.")); + + var examples = BicepDocumentationExampleDiscovery.Discover(GetModuleRoot(fileSet)); + + examples.Select(e => e.RelativePath).Should().Equal("examples/default/main.bicep"); + } + + [TestMethod] + public void Discover_TestsFolderOnly_DiscoversExamplesFromTestsCategory() + { + var fileSet = MockFileSystemTestFileSet.Create(("tests/e2e/main.test.bicep", "param foo string")); + + var examples = BicepDocumentationExampleDiscovery.Discover(GetModuleRoot(fileSet)); + + examples.Should().ContainSingle(); + examples[0].Name.Should().Be("e2e"); + } + + [TestMethod] + public void Discover_DirectFileWithUppercaseExtension_UsesTheSameRulesOnEveryPlatform() + { + var fileSet = MockFileSystemTestFileSet.Create(("examples/Main.BICEP", "param foo string")); + + var examples = BicepDocumentationExampleDiscovery.Discover(GetModuleRoot(fileSet)); + + examples.Should().ContainSingle(); + examples[0].Name.Should().Be("Main"); + } + + private static Bicep.IO.Abstraction.IDirectoryHandle GetModuleRoot(MockFileSystemTestFileSet fileSet) => + fileSet.FileExplorer.GetDirectory(fileSet.GetUri("")); +} diff --git a/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationGeneratorTests.cs b/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationGeneratorTests.cs new file mode 100644 index 00000000000..4224e17552a --- /dev/null +++ b/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationGeneratorTests.cs @@ -0,0 +1,787 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Bicep.Core.Documentation; +using Bicep.Core.UnitTests.Assertions; +using Bicep.Core.UnitTests.Features; +using Bicep.IO.Abstraction; +using Bicep.Testing; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bicep.Core.UnitTests.Documentation; + +[TestClass] +public class BicepDocumentationGeneratorTests +{ + private const string ComprehensiveModule = """ + metadata name = 'Storage Module' + metadata description = 'Creates a storage account with example telemetry and diagnostics settings.' + + @description('Name of the storage account.') + @minLength(3) + @maxLength(24) + param storageAccountName string + + @description('Azure region for the resources.') + param location string = 'westus' + + @description('Storage account SKU name.') + @allowed([ + 'Standard_LRS' + 'Standard_GRS' + ]) + param skuName string = 'Standard_LRS' + + @description('Number of days to retain diagnostic logs.') + @minValue(1) + @maxValue(365) + param retentionInDays int = 30 + + @description('Administrator password for the jumpbox.') + @secure() + param adminPassword string + + @description('Network rule configuration for the storage account.') + param networkRule networkRuleUnion = { + type: 'allowAll' + } + + @description('Enables usage telemetry for this module.') + param enableTelemetry bool = true + + @export() + @description('An allow-all network rule.') + type allowAllNetworkRule = { + type: 'allowAll' + } + + @export() + @description('An IP-restricted network rule.') + type ipRestrictedNetworkRule = { + type: 'ipRestricted' + @description('Allowed IP ranges in CIDR notation.') + allowedIpRanges: string[] + } + + @export() + @discriminator('type') + type networkRuleUnion = allowAllNetworkRule | ipRestrictedNetworkRule + + resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = { + name: storageAccountName + location: location + sku: { + name: skuName + } + kind: 'StorageV2' + tags: { + retentionInDays: string(retentionInDays) + hasAdminPassword: string(length(adminPassword) > 0) + networkRuleType: networkRule.type + } + } + + resource existingVnet 'Microsoft.Network/virtualNetworks@2023-09-01' existing = { + name: 'existing-vnet' + } + + module logging 'modules/logging.bicep' = { + name: 'loggingDeployment' + params: { + location: location + } + } + + @export() + @description('Builds a resource tag object from an environment name.') + func buildTags(environmentName string) object => { + environment: environmentName + } + + @description('The resource ID of the storage account.') + output storageAccountId string = storageAccount.id + """; + + private const string LoggingModule = """ + @description('Azure region for the resources.') + param location string + + output workspaceId string = 'workspace-id' + """; + + [TestMethod] + public async Task BuildModel_ComprehensiveModule_ProjectsDeterministicMetadataAndConstraints() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile( + ("main.bicep", ComprehensiveModule), + ("modules/logging.bicep", LoggingModule)); + + result.Diagnostics.Should().NotContain(d => d.Level == Bicep.Core.Diagnostics.DiagnosticLevel.Error); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + + model.Name.Should().Be("Storage Module"); + model.Description.Should().Be("Creates a storage account with example telemetry and diagnostics settings."); + model.TargetScope.Should().Be("resourceGroup"); + model.Custom.Should().BeEmpty(); + + model.ResourceTypes.Select(r => (r.Type, r.IsExisting)).Should().BeEquivalentTo( + [ + ("Microsoft.Network/virtualNetworks@2023-09-01", true), + ("Microsoft.Storage/storageAccounts@2023-01-01", false), + ], options => options.WithStrictOrdering()); + + // Deterministic ordering: case-insensitive, ordinal tie-break. + model.Parameters.Select(p => p.Name).Should().Equal( + "adminPassword", + "enableTelemetry", + "location", + "networkRule", + "retentionInDays", + "skuName", + "storageAccountName"); + + var storageAccountName = model.Parameters.Single(p => p.Name == "storageAccountName"); + storageAccountName.TypeName.Should().Be("string"); + storageAccountName.IsRequired.Should().BeTrue(); + storageAccountName.IsSecure.Should().BeFalse(); + storageAccountName.MinLength.Should().Be(3); + storageAccountName.MaxLength.Should().Be(24); + storageAccountName.DefaultValue.Should().BeNull(); + + var location = model.Parameters.Single(p => p.Name == "location"); + location.IsRequired.Should().BeFalse(); + location.DefaultValue.Should().Be("'westus'"); + + var skuName = model.Parameters.Single(p => p.Name == "skuName"); + skuName.TypeName.Should().Be("string"); + skuName.AllowedValues.Should().Equal("Standard_GRS", "Standard_LRS"); + + var retentionInDays = model.Parameters.Single(p => p.Name == "retentionInDays"); + retentionInDays.TypeName.Should().Be("int"); + retentionInDays.MinValue.Should().Be(1); + retentionInDays.MaxValue.Should().Be(365); + + var adminPassword = model.Parameters.Single(p => p.Name == "adminPassword"); + adminPassword.IsSecure.Should().BeTrue(); + + var networkRule = model.Parameters.Single(p => p.Name == "networkRule"); + networkRule.Discriminator.Should().NotBeNull(); + networkRule.Discriminator!.PropertyName.Should().Be("type"); + networkRule.Discriminator.Cases.Select(c => c.Value).Should().Equal("allowAll", "ipRestricted"); + + var ipRestrictedCase = networkRule.Discriminator.Cases.Single(c => c.Value == "ipRestricted"); + ipRestrictedCase.Properties.Select(p => p.Name).Should().Contain("allowedIpRanges"); + var allowedIpRanges = ipRestrictedCase.Properties.Single(p => p.Name == "allowedIpRanges"); + allowedIpRanges.TypeName.Should().Be("array"); + allowedIpRanges.Description.Should().Be("Allowed IP ranges in CIDR notation."); + + model.Outputs.Select(o => o.Name).Should().Equal("storageAccountId"); + model.Outputs.Single().TypeName.Should().Be("string"); + + model.ExportedFunctions.Select(f => f.Name).Should().Equal("buildTags"); + var buildTags = model.ExportedFunctions.Single(); + buildTags.Parameters.Select(p => p.Name).Should().Equal("environmentName"); + buildTags.ReturnTypeName.Should().Be("object"); + buildTags.Description.Should().Be("Builds a resource tag object from an environment name."); + + model.References.Should().ContainSingle(); + var reference = model.References.Single(); + reference.SymbolicName.Should().Be("logging"); + reference.Path.Should().Be("modules/logging.bicep"); + reference.Description.Should().BeNull(); + + model.DataCollection.Should().NotBeNull(); + model.DataCollection!.Enabled.Should().BeTrue(); + model.DataCollection.Note.Should().NotBeNullOrWhiteSpace(); + } + + [TestMethod] + public async Task BuildModel_ModuleWithoutMetadataName_FallsBackToDirectoryName() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("param foo string = 'bar'"); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + + model.Name.Should().Be("to"); + model.Description.Should().BeNull(); + } + + [TestMethod] + public void GetFallbackModuleName_RootModule_UsesEntryFileName() + { + var root = new Bicep.IO.Abstraction.IOUri("file", null, "/"); + var entryFile = new Bicep.IO.Abstraction.IOUri("file", null, "/main.bicep"); + + BicepDocumentationGenerator.GetFallbackModuleName(root, entryFile).Should().Be("main"); + } + + [TestMethod] + public async Task BuildModel_CompilationWithErrors_ThrowsBicepDocumentationException() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("param foo invalidType"); + + result.Diagnostics.Should().NotBeEmpty(); + + var generator = compiler.GetService(); + var act = () => generator.BuildModel(result.Compilation); + + act.Should().Throw(); + } + + [TestMethod] + public async Task Generate_CompilationWithErrors_ThrowsBicepDocumentationException() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("param foo invalidType"); + + var generator = compiler.GetService(); + var act = () => generator.Generate(result.Compilation); + + act.Should().Throw(); + } + + [TestMethod] + public async Task Generate_WithExplicitOptions_UsesProvidedOptions() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("param foo string = 'bar'"); + + compiler.FileSet.AddFile("readme.scriban", "# {{ module.name }}"); + + var generator = compiler.GetService(); + var options = new BicepDocumentationGenerationOptions( + BicepDocumentationPreset.Markdown, + TemplateFile: compiler.FileSet.GetUri("readme.scriban"), + TemplateRoot: null, + CustomValues: null); + + var rendered = generator.Generate(result.Compilation, options); + + rendered.Should().Be("# to\n"); + } + + [TestMethod] + public void GenerationOptions_EqualityAndWith_BehaveAsValueRecord() + { + var options = BicepDocumentationGenerationOptions.Default; + var clone = options with { }; + var different = options with { Preset = (BicepDocumentationPreset)1 }; + + options.Should().Be(clone); + (options == clone).Should().BeTrue(); + options.Should().NotBe(different); + options.GetHashCode().Should().Be(clone.GetHashCode()); + options.ToString().Should().Contain("Markdown"); + } + + [TestMethod] + public async Task BuildModel_ExamplesAndTestsFolders_DiscoversUsageExamplesDeterministically() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile( + ("main.bicep", "param foo string = 'bar'"), + ("examples/default/main.bicep", "// Deploys with default settings.\nmodule example '../../main.bicep' = { name: 'example' }"), + ("examples/other.bicep", "module example '../main.bicep' = { name: 'example' }"), + ("tests/e2e/defaults/main.test.bicep", "module test '../../../main.bicep' = { name: 'test' }"), + ("notes.md", "This file must not be treated as a usage example.")); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + + model.UsageExamples.Select(e => e.RelativePath).Should().Equal( + "examples/default/main.bicep", + "examples/other.bicep", + "tests/e2e/defaults/main.test.bicep"); + + var defaultExample = model.UsageExamples.Single(e => e.RelativePath == "examples/default/main.bicep"); + defaultExample.Name.Should().Be("default"); + defaultExample.Description.Should().Be("Deploys with default settings."); + defaultExample.Contents.Should().Contain("module example"); + + var otherExample = model.UsageExamples.Single(e => e.RelativePath == "examples/other.bicep"); + otherExample.Name.Should().Be("other"); + } + + [TestMethod] + public async Task Render_CustomTemplate_SupportsIncludesAndCustomValues() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("param foo string = 'bar'"); + + compiler.FileSet.AddFile("readme.scriban", "{{ include \"_header.md\" }}\n# {{ module.name }}\nOwner: {{ custom.ownerDisplayName }} / {{ module.custom.ownerDisplayName }}\n"); + compiler.FileSet.AddFile("_header.md", "> Header content."); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation, new Dictionary { ["ownerDisplayName"] = "Old Team" }); + + var options = new BicepDocumentationGenerationOptions( + BicepDocumentationPreset.Markdown, + TemplateFile: compiler.FileSet.GetUri("readme.scriban"), + TemplateRoot: null, + CustomValues: new Dictionary { ["ownerDisplayName"] = "Platform Team" }); + + var rendered = generator.Render(model, options); + + rendered.Should().Be("> Header content.\n# to\nOwner: Platform Team / Platform Team\n"); + } + + [TestMethod] + public async Task Render_CustomTemplateWithMissingInclude_ThrowsActionableBicepDocumentationException() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("param foo string = 'bar'"); + + compiler.FileSet.AddFile("readme.scriban", "{{ include \"_missing.md\" }}"); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + + var options = new BicepDocumentationGenerationOptions( + BicepDocumentationPreset.Markdown, + TemplateFile: compiler.FileSet.GetUri("readme.scriban"), + TemplateRoot: null, + CustomValues: null); + + var act = () => generator.Render(model, options); + + act.Should().Throw().WithMessage("*_missing.md*"); + } + + [TestMethod] + public async Task Render_InvalidCustomTemplate_ThrowsActionableBicepDocumentationException() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("param foo string = 'bar'"); + + compiler.FileSet.AddFile("readme.scriban", "{{ if module.name }}"); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + + var options = new BicepDocumentationGenerationOptions( + BicepDocumentationPreset.Markdown, + TemplateFile: compiler.FileSet.GetUri("readme.scriban"), + TemplateRoot: null, + CustomValues: null); + + var act = () => generator.Render(model, options); + + act.Should().Throw(); + } + + [TestMethod] + public async Task Render_BuiltInTemplate_ProducesExpectedMarkdown() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile( + ("main.bicep", ComprehensiveModule), + ("modules/logging.bicep", LoggingModule), + ("examples/default/main.bicep", "// Deploys the module with default settings.\nmodule example '../../main.bicep' = {\n name: 'example'\n}\n")); + + var generator = compiler.GetService(); + var rendered = generator.Generate(result.Compilation); + + var expected = GetEmbeddedFixture("ExpectedMarkdown.md"); + rendered.Should().EqualWithLineByLineDiff(expected); + } + + [TestMethod] + public void LoadTemplateSource_MissingResource_Throws() + { + FluentActions.Invoking(() => + BicepDocumentationGenerator.LoadTemplateSource( + Assembly.GetExecutingAssembly(), + "missing.template")) + .Should().Throw(); + } + + [TestMethod] + public async Task Render_UnsupportedPreset_ThrowsBicepDocumentationException() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("param foo string = 'bar'"); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + + var options = new BicepDocumentationGenerationOptions((BicepDocumentationPreset)999, TemplateFile: null, TemplateRoot: null, CustomValues: null); + + var act = () => generator.Render(model, options); + + act.Should().Throw().WithMessage("*999*"); + } + + [TestMethod] + public async Task Render_MissingCustomTemplateFile_ThrowsBicepDocumentationException() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("param foo string = 'bar'"); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + + var options = new BicepDocumentationGenerationOptions( + BicepDocumentationPreset.Markdown, + TemplateFile: compiler.FileSet.GetUri("missing.scriban"), + TemplateRoot: null, + CustomValues: null); + + var act = () => generator.Render(model, options); + + act.Should().Throw().WithMessage("*missing.scriban*does not exist*"); + } + + [TestMethod] + public async Task Render_CustomTemplateFileReadError_ThrowsBicepDocumentationException() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("param foo string = 'bar'"); + + compiler.FileSet.AddFile("readme.scriban", "# {{ module.name }}"); + + var innerGenerator = compiler.GetService(); + var model = innerGenerator.BuildModel(result.Compilation); + + var throwingGenerator = new BicepDocumentationGenerator(new ThrowingFileExplorer(new IOException("disk error"))); + var options = new BicepDocumentationGenerationOptions( + BicepDocumentationPreset.Markdown, + TemplateFile: compiler.FileSet.GetUri("readme.scriban"), + TemplateRoot: null, + CustomValues: null); + + var act = () => throwingGenerator.Render(model, options); + + act.Should().Throw().WithMessage("*disk error*"); + } + + [TestMethod] + public async Task Render_CustomTemplateIncludeReadError_ThrowsActionableBicepDocumentationException() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("param foo string = 'bar'"); + + compiler.FileSet.AddFile("readme.scriban", "{{ include \"_header.md\" }}"); + compiler.FileSet.AddFile("_header.md", "> Header content."); + + var innerGenerator = compiler.GetService(); + var model = innerGenerator.BuildModel(result.Compilation); + + // The template file itself reads fine, but the included file does not, exercising the template + // loader's own narrow I/O catch rather than the top-level template-file read catch. + var throwingExplorer = new SelectivelyThrowingFileExplorer(compiler.FileSet.FileExplorer, compiler.FileSet.GetUri("_header.md"), new IOException("disk error")); + var throwingGenerator = new BicepDocumentationGenerator(throwingExplorer); + var options = new BicepDocumentationGenerationOptions( + BicepDocumentationPreset.Markdown, + TemplateFile: compiler.FileSet.GetUri("readme.scriban"), + TemplateRoot: null, + CustomValues: null); + + var act = () => throwingGenerator.Render(model, options); + + act.Should().Throw().WithMessage("*disk error*"); + } + + [TestMethod] + public async Task Render_CustomTemplate_WithTemplateRootOverride_ResolvesIncludesRelativeToOverrideRoot() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("param foo string = 'bar'"); + + compiler.FileSet.AddFile("readme.scriban", "{{ include \"_header.md\" }}\n# {{ module.name }}\n"); + compiler.FileSet.AddFile("overrideRoot/_header.md", "> Overridden header."); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + + var options = new BicepDocumentationGenerationOptions( + BicepDocumentationPreset.Markdown, + TemplateFile: compiler.FileSet.GetUri("readme.scriban"), + TemplateRoot: compiler.FileSet.GetUri("overrideRoot"), + CustomValues: null); + + var rendered = generator.Render(model, options); + + rendered.Should().Be("> Overridden header.\n# to\n"); + } + + [TestMethod] + public async Task Render_InvalidModulePathWithoutTemplateRootOverride_ThrowsActionableBicepDocumentationException() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("param foo string = 'bar'"); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation) with { Path = "https://example.com/not-a-file-path/main.bicep" }; + + var act = () => generator.Render(model); + + act.Should().Throw().WithMessage("*Unable to resolve an include root*"); + } + + [TestMethod] + public async Task Render_InvalidModulePathWithTemplateRootOverride_SucceedsUsingOverride() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("param foo string = 'bar'"); + + compiler.FileSet.AddFile("overrideRoot/_header.md", "> Header."); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation) with { Path = "https://example.com/not-a-file-path/main.bicep" }; + + compiler.FileSet.AddFile("readme.scriban", "{{ include \"_header.md\" }}"); + var options = new BicepDocumentationGenerationOptions( + BicepDocumentationPreset.Markdown, + TemplateFile: compiler.FileSet.GetUri("readme.scriban"), + TemplateRoot: compiler.FileSet.FileExplorer.GetDirectory(compiler.FileSet.GetUri("overrideRoot")).Uri, + CustomValues: null); + + var rendered = generator.Render(model, options); + + rendered.Should().Be("> Header.\n"); + } + + [TestMethod] + public void Render_TemplateWithTrailingBlankLinesAndCrlf_NormalizesLineEndingsAndTrailingWhitespace() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + compiler.FileSet.AddFile("readme.scriban", "Line one\r\nLine two\r\n\r\n\r\n"); + + var generator = compiler.GetService(); + var model = MinimalModel(); + + var options = new BicepDocumentationGenerationOptions( + BicepDocumentationPreset.Markdown, + TemplateFile: compiler.FileSet.GetUri("readme.scriban"), + TemplateRoot: compiler.FileSet.FileExplorer.GetDirectory(compiler.FileSet.GetUri("")).Uri, + CustomValues: null); + + var rendered = generator.Render(model, options); + + rendered.Should().Be("Line one\nLine two\n"); + } + + [TestMethod] + public async Task Render_EmptyModule_ShowsFallbackTextAndOmitsOptionalNavigationLinks() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("// This module intentionally declares nothing.\n"); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + + model.ResourceTypes.Should().BeEmpty(); + model.Parameters.Should().BeEmpty(); + model.Outputs.Should().BeEmpty(); + model.ExportedFunctions.Should().BeEmpty(); + model.References.Should().BeEmpty(); + model.UsageExamples.Should().BeEmpty(); + model.DataCollection.Should().BeNull(); + + var rendered = generator.Render(model); + + rendered.Should().Contain("_No resources are declared in this module._"); + rendered.Should().Contain("_No parameters are declared in this module._"); + rendered.Should().Contain("_No outputs are declared in this module._"); + rendered.Should().NotContain("Usage Examples"); + rendered.Should().NotContain("Exported Functions"); + rendered.Should().NotContain("Cross-referenced Modules"); + rendered.Should().NotContain("Data Collection"); + } + + [TestMethod] + public async Task BuildModel_TargetScopeTenant_ProjectsTenantScopeName() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("targetScope = 'tenant'\n"); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + + model.TargetScope.Should().Be("tenant"); + } + + [TestMethod] + public async Task BuildModel_TargetScopeManagementGroup_ProjectsManagementGroupScopeName() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("targetScope = 'managementGroup'\n"); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + + model.TargetScope.Should().Be("managementGroup"); + } + + [TestMethod] + public async Task BuildModel_TargetScopeSubscription_ProjectsSubscriptionScopeName() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("targetScope = 'subscription'\n"); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + + model.TargetScope.Should().Be("subscription"); + } + + [TestMethod] + public async Task BuildModel_TargetScopeLocal_ProjectsLocalScopeName() + { + var compiler = TestCompiler.ForMockFileSystemCompilation() + .WithFeatureOverrides(new FeatureProviderOverrides(LocalDeployEnabled: true)); + var result = await compiler.Compile("targetScope = 'local'\n\nparam foo string = 'bar'\n"); + + result.Diagnostics.Should().NotContain(d => d.Level == Bicep.Core.Diagnostics.DiagnosticLevel.Error); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + + model.TargetScope.Should().Be("local"); + } + + [TestMethod] + public async Task BuildModel_EnableTelemetryDefaultsFalse_ProjectsDisabledDataCollection() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("param enableTelemetry bool = false\n"); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + + model.DataCollection.Should().NotBeNull(); + model.DataCollection!.Enabled.Should().BeFalse(); + } + + [TestMethod] + public async Task BuildModel_EnableTelemetryWithoutDefault_ProjectsEnabledDataCollection() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("param enableTelemetry bool\n"); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation, new Dictionary()); + + model.DataCollection.Should().NotBeNull(); + model.DataCollection!.Enabled.Should().BeTrue(); + } + + [TestMethod] + public async Task BuildModel_NoEnableTelemetryParameter_DataCollectionIsAbsent() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("param foo string = 'bar'"); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + + model.DataCollection.Should().BeNull(); + } + + [TestMethod] + public async Task BuildModel_MetadataNameWithNonStringValue_FallsBackToDirectoryName() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("metadata name = 123\nparam foo string = 'bar'\n"); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + + model.Name.Should().Be("to"); + } + + [TestMethod] + public async Task BuildModel_MetadataNameWithDifferentCase_FallsBackToDirectoryName() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("metadata Name = 'Not the well-known name'\nparam foo string = 'bar'\n"); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + + model.Name.Should().Be("to"); + } + + [TestMethod] + public async Task BuildModel_ModuleReferenceWithDescription_ProjectsDescription() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile( + ("main.bicep", "module logging 'modules/logging.bicep' = {\n name: 'loggingDeployment'\n}\n"), + ("modules/logging.bicep", "metadata description = 'Deploys centralized logging.'\noutput workspaceId string = 'workspace-id'\n")); + + result.Diagnostics.Should().NotContain(d => d.Level == Bicep.Core.Diagnostics.DiagnosticLevel.Error); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + + var reference = model.References.Single(); + reference.SymbolicName.Should().Be("logging"); + reference.Description.Should().Be("Deploys centralized logging."); + } + + [TestMethod] + public async Task BuildModel_CustomValues_AreDeterministicallyOrderedByOrdinalKeyWithExactSpelling() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("param foo string = 'bar'"); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation, new Dictionary + { + ["zeta"] = "last", + ["Alpha"] = "first-capitalized", + ["beta"] = "middle", + }); + + // Ordinal ordering: uppercase 'A' (65) sorts before lowercase 'b' (98) and 'z' (122). + model.Custom.Keys.Should().Equal("Alpha", "beta", "zeta"); + model.Custom["Alpha"].Should().Be("first-capitalized"); + } + + [TestMethod] + public async Task BuildModel_NoCustomValues_ProjectsEmptyCustomDictionary() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("param foo string = 'bar'"); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + + model.Custom.Should().BeEmpty(); + } + + private static BicepDocumentationModel MinimalModel() => new( + Name: "minimal", + Description: null, + Path: "C:\\path\\to\\main.bicep", + TargetScope: "resourceGroup", + Custom: System.Collections.Immutable.ImmutableSortedDictionary.Empty, + ResourceTypes: [], + Parameters: [], + Outputs: [], + ExportedFunctions: [], + References: [], + UsageExamples: [], + DataCollection: null); + + private static string GetEmbeddedFixture(string name) + { + var resourceName = $"Bicep.Core.UnitTests.Documentation.Files.{name}"; + using var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"Could not find embedded fixture '{resourceName}'."); + using var reader = new StreamReader(stream); + + return reader.ReadToEnd(); + } +} diff --git a/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationOrderingTests.cs b/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationOrderingTests.cs new file mode 100644 index 00000000000..8afb46935d9 --- /dev/null +++ b/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationOrderingTests.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Bicep.Core.Documentation; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bicep.Core.UnitTests.Documentation; + +[TestClass] +public class BicepDocumentationOrderingTests +{ + [TestMethod] + public void SortByName_MixedCaseNames_OrdersCaseInsensitivelyWithOrdinalTieBreak() + { + var names = new[] { "zeta", "Alpha", "beta", "alpha" }; + + var sorted = BicepDocumentationOrdering.SortByName(names, name => name); + + // "Alpha" and "alpha" are a case-insensitive tie, broken by ordinal comparison (uppercase sorts first). + sorted.Should().Equal("Alpha", "alpha", "beta", "zeta"); + } + + [TestMethod] + public void SortByName_EmptyInput_ReturnsEmptyArray() + { + var sorted = BicepDocumentationOrdering.SortByName(Array.Empty(), name => name); + + sorted.Should().BeEmpty(); + } + + [TestMethod] + public void NameComparer_CaseInsensitiveDifference_ReturnsNonZeroWithoutOrdinalTieBreak() + { + var result = BicepDocumentationOrdering.NameComparer.Compare("alpha", "beta"); + + result.Should().BeLessThan(0); + } + + [TestMethod] + public void NameComparer_CaseInsensitiveTie_FallsBackToOrdinalComparison() + { + // "Alpha" vs "alpha" are equal under OrdinalIgnoreCase, so the ordinal tie-break must run + // (uppercase 'A' sorts before lowercase 'a' ordinally). + var result = BicepDocumentationOrdering.NameComparer.Compare("Alpha", "alpha"); + + result.Should().BeLessThan(0); + } + + [TestMethod] + public void NameComparer_IdenticalNames_ReturnsZero() + { + var result = BicepDocumentationOrdering.NameComparer.Compare("alpha", "alpha"); + + result.Should().Be(0); + } +} diff --git a/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationScriptModelFactoryTests.cs b/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationScriptModelFactoryTests.cs new file mode 100644 index 00000000000..6d82df1af72 --- /dev/null +++ b/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationScriptModelFactoryTests.cs @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using Bicep.Core.Documentation; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Scriban.Runtime; + +namespace Bicep.Core.UnitTests.Documentation; + +[TestClass] +public class BicepDocumentationScriptModelFactoryTests +{ + [TestMethod] + public void Create_FullyPopulatedModel_ProjectsAllFieldsWithStableCamelCaseNames() + { + var model = new BicepDocumentationModel( + Name: "My Module", + Description: "A description.", + Path: "C:\\modules\\main.bicep", + TargetScope: "resourceGroup", + Custom: new Dictionary { ["ownerDisplayName"] = "Platform Team" }.ToImmutableSortedDictionary(StringComparer.Ordinal), + ResourceTypes: [new BicepDocumentationResourceType("Microsoft.Storage/storageAccounts@2023-01-01", IsExisting: false)], + Parameters: + [ + new BicepDocumentationParameter( + Name: "networkRule", + TypeName: "object", + IsRequired: false, + IsSecure: false, + Description: "A parameter.", + DefaultValue: "{}", + AllowedValues: ["a", "b"], + MinValue: 1, + MaxValue: 10, + MinLength: 1, + MaxLength: 10, + Pattern: "^[a-z]+$", + NestedProperties: [new BicepDocumentationParameter("nested", "string", true, false, null, null, [], null, null, null, null, null, [], null)], + Discriminator: new BicepDocumentationDiscriminator("type", [new BicepDocumentationDiscriminatorCase("allowAll", [])])), + ], + Outputs: [new BicepDocumentationOutput("out1", "string", IsSecure: true, Description: "An output.")], + ExportedFunctions: [new BicepDocumentationFunction("fn", [new BicepDocumentationFunctionParameter("p", "int", "A param.")], "bool", "A function.")], + References: [new BicepDocumentationReference("logging", "modules/logging.bicep", "A reference.")], + UsageExamples: [new BicepDocumentationUsageExample("default", "examples/default/main.bicep", "An example.", "// contents")], + DataCollection: new BicepDocumentationDataCollection(true, "A note.")); + + var scriptObject = BicepDocumentationScriptModelFactory.Create(model); + + scriptObject.GetSafeValue("custom")!.GetSafeValue("ownerDisplayName").Should().Be("Platform Team"); + + var module = scriptObject.GetSafeValue("module")!; + module.GetSafeValue("name").Should().Be("My Module"); + module.GetSafeValue("description").Should().Be("A description."); + module.GetSafeValue("path").Should().Be("C:\\modules\\main.bicep"); + module.GetSafeValue("targetScope").Should().Be("resourceGroup"); + module.GetSafeValue("custom")!.GetSafeValue("ownerDisplayName").Should().Be("Platform Team"); + + var resourceType = module.GetSafeValue("resourceTypes")![0] as ScriptObject; + resourceType!.GetSafeValue("type").Should().Be("Microsoft.Storage/storageAccounts@2023-01-01"); + resourceType.GetSafeValue("existing").Should().BeFalse(); + + var parameter = module.GetSafeValue("parameters")![0] as ScriptObject; + parameter!.GetSafeValue("name").Should().Be("networkRule"); + parameter.GetSafeValue("type").Should().Be("object"); + parameter.GetSafeValue("secure").Should().BeFalse(); + parameter.GetSafeValue("minValue").Should().Be(1); + parameter.GetSafeValue("maxValue").Should().Be(10); + parameter.GetSafeValue("minLength").Should().Be(1); + parameter.GetSafeValue("maxLength").Should().Be(10); + parameter.GetSafeValue("pattern").Should().Be("^[a-z]+$"); + (parameter.GetSafeValue("allowedValues")!).Should().Equal("a", "b"); + + var nested = parameter.GetSafeValue("properties")![0] as ScriptObject; + nested!.GetSafeValue("name").Should().Be("nested"); + nested.GetSafeValue("discriminator").Should().BeNull(); + + var discriminator = parameter.GetSafeValue("discriminator")!; + discriminator.GetSafeValue("propertyName").Should().Be("type"); + var discriminatorCase = discriminator.GetSafeValue("cases")![0] as ScriptObject; + discriminatorCase!.GetSafeValue("value").Should().Be("allowAll"); + + var output = module.GetSafeValue("outputs")![0] as ScriptObject; + output!.GetSafeValue("name").Should().Be("out1"); + output.GetSafeValue("secure").Should().BeTrue(); + + var function = module.GetSafeValue("exportedFunctions")![0] as ScriptObject; + function!.GetSafeValue("returnType").Should().Be("bool"); + var functionParameter = function.GetSafeValue("parameters")![0] as ScriptObject; + functionParameter!.GetSafeValue("name").Should().Be("p"); + + var reference = module.GetSafeValue("references")![0] as ScriptObject; + reference!.GetSafeValue("symbolicName").Should().Be("logging"); + + var usageExample = module.GetSafeValue("usageExamples")![0] as ScriptObject; + usageExample!.GetSafeValue("name").Should().Be("default"); + usageExample.GetSafeValue("contents").Should().Be("// contents"); + + var dataCollection = module.GetSafeValue("dataCollection")!; + dataCollection.GetSafeValue("enabled").Should().BeTrue(); + dataCollection.GetSafeValue("note").Should().Be("A note."); + } + + [TestMethod] + public void Create_MinimalModel_ProjectsNullDataCollectionDiscriminatorAndEmptyArrays() + { + var model = new BicepDocumentationModel( + Name: "Empty", + Description: null, + Path: "C:\\modules\\main.bicep", + TargetScope: "resourceGroup", + Custom: ImmutableSortedDictionary.Empty, + ResourceTypes: [], + Parameters: [new BicepDocumentationParameter("p", "string", false, false, null, null, [], null, null, null, null, null, [], null)], + Outputs: [], + ExportedFunctions: [], + References: [], + UsageExamples: [], + DataCollection: null); + + var scriptObject = BicepDocumentationScriptModelFactory.Create(model); + var module = scriptObject.GetSafeValue("module")!; + + module.GetSafeValue("description").Should().BeNull(); + module.GetSafeValue("resourceTypes").Should().BeEmpty(); + module.GetSafeValue("dataCollection").Should().BeNull(); + + var parameter = module.GetSafeValue("parameters")![0] as ScriptObject; + parameter!.GetSafeValue("discriminator").Should().BeNull(); + parameter.GetSafeValue("properties").Should().BeEmpty(); + } +} diff --git a/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationTemplateLoaderTests.cs b/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationTemplateLoaderTests.cs new file mode 100644 index 00000000000..e6f4c5005c1 --- /dev/null +++ b/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationTemplateLoaderTests.cs @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Bicep.Core.Documentation; +using Bicep.IO.Abstraction; +using Bicep.Testing; +using Bicep.Testing.IO; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Scriban; +using Scriban.Parsing; +using Scriban.Syntax; + +namespace Bicep.Core.UnitTests.Documentation; + +[TestClass] +public class BicepDocumentationTemplateLoaderTests +{ + private static readonly TemplateContext Context = new(); + + private static readonly SourceSpan CallerSpan = new("test", new(), new()); + + [TestMethod] + public void GetPath_ExistingRelativeTemplateName_ReturnsResolvedKey() + { + var fileSet = MockFileSystemTestFileSet.Create(("root/shared/_header.md", "> Header.")); + var root = GetRootDirectoryUri(fileSet, "root"); + var loader = new BicepDocumentationTemplateLoader(fileSet.FileExplorer, root); + + var path = loader.GetPath(Context, CallerSpan, "shared/_header.md"); + + path.Should().Be(fileSet.GetUri("root/shared/_header.md").ToString()); + } + + [TestMethod] + public void GetPath_TraversalAboveRoot_ReturnsResolvedKeyOutsideRoot() + { + var fileSet = MockFileSystemTestFileSet.Create(("shared/_header.md", "> Header.")); + var root = GetRootDirectoryUri(fileSet, "root"); + var loader = new BicepDocumentationTemplateLoader(fileSet.FileExplorer, root); + + var path = loader.GetPath(Context, CallerSpan, "../shared/_header.md"); + + path.Should().Be(fileSet.GetUri("shared/_header.md").ToString()); + } + + [TestMethod] + public void GetPath_UnresolvableTemplateName_ThrowsScriptRuntimeException() + { + // "CON" is a reserved device name on Windows, which IOUri.Resolve rejects with an IOException. + if (!OperatingSystem.IsWindows()) + { + Assert.Inconclusive("Reserved device name resolution failures are Windows-specific."); + return; + } + + var fileSet = MockFileSystemTestFileSet.Create(); + var root = GetRootDirectoryUri(fileSet, "root"); + var loader = new BicepDocumentationTemplateLoader(fileSet.FileExplorer, root); + + var act = () => loader.GetPath(Context, CallerSpan, "CON"); + + act.Should().Throw().WithMessage("*CON*"); + } + + [TestMethod] + public void Load_UnregisteredTemplatePath_ThrowsScriptRuntimeException() + { + var fileSet = MockFileSystemTestFileSet.Create(); + var root = GetRootDirectoryUri(fileSet, "root"); + var loader = new BicepDocumentationTemplateLoader(fileSet.FileExplorer, root); + + var act = () => loader.Load(Context, CallerSpan, "never-resolved.md"); + + act.Should().Throw().WithMessage("*Unable to resolve include path*"); + } + + [TestMethod] + public void Load_MissingIncludeFile_ThrowsScriptRuntimeException() + { + var fileSet = MockFileSystemTestFileSet.Create(); + var root = GetRootDirectoryUri(fileSet, "root"); + var loader = new BicepDocumentationTemplateLoader(fileSet.FileExplorer, root); + + var path = loader.GetPath(Context, CallerSpan, "missing.md"); + + var act = () => loader.Load(Context, CallerSpan, path); + + act.Should().Throw().WithMessage("*does not exist*"); + } + + [TestMethod] + public void Load_ReadError_ThrowsScriptRuntimeException() + { + var fileSet = MockFileSystemTestFileSet.Create(("root/shared/_header.md", "> Header.")); + var root = GetRootDirectoryUri(fileSet, "root"); + var throwingExplorer = new ThrowingFileExplorer(new IOException("disk error")); + var loader = new BicepDocumentationTemplateLoader(throwingExplorer, root); + + var path = loader.GetPath(Context, CallerSpan, "shared/_header.md"); + + var act = () => loader.Load(Context, CallerSpan, path); + + act.Should().Throw().WithMessage("*disk error*"); + } + + [TestMethod] + public void Load_ExistingIncludeFile_ReturnsContents() + { + var fileSet = MockFileSystemTestFileSet.Create(("root/shared/_header.md", "> Header.")); + var root = GetRootDirectoryUri(fileSet, "root"); + var loader = new BicepDocumentationTemplateLoader(fileSet.FileExplorer, root); + + var path = loader.GetPath(Context, CallerSpan, "shared/_header.md"); + + loader.Load(Context, CallerSpan, path).Should().Be("> Header."); + } + + [TestMethod] + public async Task LoadAsync_ExistingIncludeFile_ReturnsContents() + { + var fileSet = MockFileSystemTestFileSet.Create(("root/shared/_header.md", "> Header.")); + var root = GetRootDirectoryUri(fileSet, "root"); + var loader = new BicepDocumentationTemplateLoader(fileSet.FileExplorer, root); + + var path = loader.GetPath(Context, CallerSpan, "shared/_header.md"); + + (await loader.LoadAsync(Context, CallerSpan, path)).Should().Be("> Header."); + } + + // Directory URIs (unlike file URIs) always carry a trailing slash, which IOUri.Resolve relies on to treat + // the URI as "resolve into this directory" rather than "resolve into this file's parent directory". + private static IOUri GetRootDirectoryUri(MockFileSystemTestFileSet fileSet, string path) => + fileSet.FileExplorer.GetDirectory(fileSet.GetUri(path)).Uri; +} diff --git a/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationTypeAnalyzerTests.cs b/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationTypeAnalyzerTests.cs new file mode 100644 index 00000000000..26b321729c0 --- /dev/null +++ b/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationTypeAnalyzerTests.cs @@ -0,0 +1,242 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Linq; +using System.Threading.Tasks; +using Bicep.Core.Documentation; +using Bicep.Core.TypeSystem; +using Bicep.Core.TypeSystem.Types; +using Bicep.Testing; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bicep.Core.UnitTests.Documentation; + +[TestClass] +public class BicepDocumentationTypeAnalyzerTests +{ + [TestMethod] + public async Task BuildModel_LiteralUnionParameters_ProjectsAllowedValuesForBoolIntAndStringUnions() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile(""" + param stringChoice ('b' | 'a') = 'a' + param intChoice (2 | 1) = 1 + param boolChoice (true | false) = true + + output stringChoiceOut string = stringChoice + output intChoiceOut int = intChoice + output boolChoiceOut bool = boolChoice + """); + + result.Diagnostics.Should().NotContain(d => d.Level == Bicep.Core.Diagnostics.DiagnosticLevel.Error); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + + var stringChoice = model.Parameters.Single(p => p.Name == "stringChoice"); + stringChoice.TypeName.Should().Be("string"); + stringChoice.AllowedValues.Should().Equal("a", "b"); + + var intChoice = model.Parameters.Single(p => p.Name == "intChoice"); + intChoice.TypeName.Should().Be("int"); + intChoice.AllowedValues.Should().Equal("1", "2"); + + var boolChoice = model.Parameters.Single(p => p.Name == "boolChoice"); + boolChoice.TypeName.Should().Be("bool"); + boolChoice.AllowedValues.Should().Equal("false", "true"); + } + + [TestMethod] + public void BuildParameter_InternalUnionShapes_AreRepresentedDeterministically() + { + var mixedUnion = new UnionType("mixed", [ + TypeFactory.CreateStringLiteralType("a"), + TypeFactory.CreateIntegerLiteralType(1), + ]); + var nonLiteralUnion = new UnionType("string | int", [ + LanguageConstants.String, + LanguageConstants.Int, + ]); + var array = TypeFactory.CreateArrayType(nonLiteralUnion); + + var mixed = BicepDocumentationTypeAnalyzer.BuildParameter("mixed", mixedUnion, false, null, null); + var nonLiteral = BicepDocumentationTypeAnalyzer.BuildParameter("nonLiteral", nonLiteralUnion, false, null, null); + var arrayParameter = BicepDocumentationTypeAnalyzer.BuildParameter("array", array, false, null, null); + + mixed.TypeName.Should().Be("mixed"); + mixed.AllowedValues.Should().Equal("1", "a"); + nonLiteral.TypeName.Should().Be("string | int"); + nonLiteral.AllowedValues.Should().BeEmpty(); + arrayParameter.TypeName.Should().Be("array"); + arrayParameter.AllowedValues.Should().BeEmpty(); + } + + [TestMethod] + public void BuildParameter_SingleCaseDiscriminator_ProjectsTheCase() + { + var objectType = new ObjectType( + "single", + TypeSymbolValidationFlags.Default, + [new NamedTypeProperty( + "kind", + TypeFactory.CreateStringLiteralType("only"), + TypePropertyFlags.Required)]); + var discriminated = new DiscriminatedObjectType( + "single union", + TypeSymbolValidationFlags.Default, + "kind", + [objectType]); + + var parameter = BicepDocumentationTypeAnalyzer.BuildParameter("value", discriminated, false, null, null); + + parameter.Discriminator.Should().NotBeNull(); + parameter.Discriminator!.Cases.Should().ContainSingle(); + parameter.Discriminator.Cases[0].Value.Should().Be("only"); + } + + [TestMethod] + public async Task BuildModel_ArrayWithLiteralUnionItemType_ProjectsAllowedValues() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("param environments ('dev' | 'test' | 'prod')[] = ['dev']"); + + result.Diagnostics.Should().NotContain(d => d.Level == Bicep.Core.Diagnostics.DiagnosticLevel.Error); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + + var environments = model.Parameters.Single(p => p.Name == "environments"); + environments.TypeName.Should().Be("array"); + environments.AllowedValues.Should().Equal("dev", "prod", "test"); + } + + [TestMethod] + public async Task BuildModel_ArrayWithPlainItemType_HasNoAllowedValues() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("param names string[] = []"); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + + model.Parameters.Single(p => p.Name == "names").AllowedValues.Should().BeEmpty(); + } + + [TestMethod] + public async Task BuildModel_NestedObjectParameter_ExpandsPropertiesWithObjectPrimitiveTypeNameAndSecureNestedField() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile(""" + type credentials = { + username: string + @secure() + password: string + } + param creds credentials + """); + + result.Diagnostics.Should().NotContain(d => d.Level == Bicep.Core.Diagnostics.DiagnosticLevel.Error); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + + var creds = model.Parameters.Single(p => p.Name == "creds"); + creds.TypeName.Should().Be("object"); + creds.Discriminator.Should().BeNull(); + creds.NestedProperties.Select(p => p.Name).Should().Equal("password", "username"); + + var password = creds.NestedProperties.Single(p => p.Name == "password"); + password.IsSecure.Should().BeTrue(); + + var username = creds.NestedProperties.Single(p => p.Name == "username"); + username.IsSecure.Should().BeFalse(); + } + + [TestMethod] + public async Task BuildModel_DeeplyNestedObjectParameter_StopsExpandingBeyondMaxDepthAndUsesObjectTypeName() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile(BuildDeeplyNestedObjectSource()); + + result.Diagnostics.Should().NotContain(d => d.Level == Bicep.Core.Diagnostics.DiagnosticLevel.Error); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + + var root = model.Parameters.Single(p => p.Name == "root"); + root.TypeName.Should().Be("object"); + + // Walk down 20 levels: each should still expand (depth < MaxDepth). + var current = root; + for (var i = 0; i < 20; i++) + { + current.NestedProperties.Should().ContainSingle($"level {i} should expand"); + current = current.NestedProperties.Single(); + } + + // The 21st level (depth == MaxDepth) must stop expanding, but still reports the object primitive type name. + current.TypeName.Should().Be("object"); + current.NestedProperties.Should().BeEmpty(); + } + + [TestMethod] + public async Task BuildModel_DeeplyNestedDiscriminatedUnionParameter_StopsExpandingBeyondMaxDepth() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile(BuildDeeplyNestedDiscriminatedSource()); + + result.Diagnostics.Should().NotContain(d => d.Level == Bicep.Core.Diagnostics.DiagnosticLevel.Error); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + + var current = model.Parameters.Single(p => p.Name == "root"); + for (var i = 0; i < 20; i++) + { + current.NestedProperties.Should().ContainSingle($"level {i} should expand"); + current = current.NestedProperties.Single(); + } + + // At depth == MaxDepth, the discriminated union must stop expanding without a discriminator. + current.TypeName.Should().Be("object"); + current.Discriminator.Should().BeNull(); + } + + private static string BuildDeeplyNestedObjectSource() + { + var builder = new System.Text.StringBuilder(); + const int levels = 21; + + for (var i = 0; i < levels; i++) + { + builder.AppendLine(i == levels - 1 + ? $"type level{i} = {{ value: string }}" + : $"type level{i} = {{ next: level{i + 1} }}"); + } + + builder.AppendLine("param root level0"); + + return builder.ToString(); + } + + private static string BuildDeeplyNestedDiscriminatedSource() + { + var builder = new System.Text.StringBuilder(); + const int plainLevels = 20; + + builder.AppendLine("type leafA = { kind: 'a' }"); + builder.AppendLine("type leafB = { kind: 'b' }"); + builder.AppendLine("@discriminator('kind')"); + builder.AppendLine($"type level{plainLevels} = leafA | leafB"); + + for (var i = 0; i < plainLevels; i++) + { + builder.AppendLine($"type level{i} = {{ next: level{i + 1} }}"); + } + + builder.AppendLine("param root level0"); + + return builder.ToString(); + } +} diff --git a/src/Bicep.Core.UnitTests/Documentation/Files/ExpectedMarkdown.md b/src/Bicep.Core.UnitTests/Documentation/Files/ExpectedMarkdown.md new file mode 100644 index 00000000000..7f26296ee2f --- /dev/null +++ b/src/Bicep.Core.UnitTests/Documentation/Files/ExpectedMarkdown.md @@ -0,0 +1,119 @@ +# Storage Module + +Creates a storage account with example telemetry and diagnostics settings. + +## Navigation + +- [Resource Types](#resource-types) +- [Usage Examples](#usage-examples) +- [Parameters](#parameters) +- [Exported Functions](#exported-functions) +- [Outputs](#outputs) +- [Cross-referenced Modules](#cross-referenced-modules) +- [Data Collection](#data-collection) + +## Resource Types + +| Resource Type | Existing | +| :-- | :-- | +| `Microsoft.Network/virtualNetworks@2023-09-01` | Yes | +| `Microsoft.Storage/storageAccounts@2023-01-01` | No | + +## Usage Examples + +### default + +Deploys the module with default settings. + +```bicep +// Deploys the module with default settings. +module example '../../main.bicep' = { + name: 'example' +} + +``` + +## Parameters + +| Name | Type | Required | Description | +| :-- | :-- | :-- | :-- | +| `adminPassword` | `securestring` | Yes | Administrator password for the jumpbox. | +| `enableTelemetry` | `bool` | No | Enables usage telemetry for this module. | +| `location` | `string` | No | Azure region for the resources. | +| `networkRule` | `object` | No | Network rule configuration for the storage account. | +| `retentionInDays` | `int` | No | Number of days to retain diagnostic logs. | +| `skuName` | `string` | No | Storage account SKU name. | +| `storageAccountName` | `string` | Yes | Name of the storage account. | + +### `adminPassword` + +- Secure: Yes + +### `enableTelemetry` + +- Default value: `true` + +### `location` + +- Default value: `'westus'` + +### `networkRule` + +- Default value: `{ + type: 'allowAll' +}` + +- Discriminator: `type` + - `allowAll`: + - `type` (`'allowAll'`), required + - `ipRestricted`: + - `allowedIpRanges` (`array`), required: Allowed IP ranges in CIDR notation. + - `type` (`'ipRestricted'`), required + +### `retentionInDays` + +- Default value: `30` + +- Min value: 1 + +- Max value: 365 + +### `skuName` + +- Default value: `'Standard_LRS'` + +- Allowed values: `Standard_GRS`, `Standard_LRS` + +### `storageAccountName` + +- Min length: 3 + +- Max length: 24 + +## Exported Functions + +### `buildTags` + +Builds a resource tag object from an environment name. + +Returns: `object` + +| Name | Type | Description | +| :-- | :-- | :-- | +| `environmentName` | `string` | | + +## Outputs + +| Name | Type | Description | +| :-- | :-- | :-- | +| `storageAccountId` | `string` | The resource ID of the storage account. | + +## Cross-referenced Modules + +| Symbolic Name | Path | Description | +| :-- | :-- | :-- | +| `logging` | `modules/logging.bicep` | | + +## Data Collection + +This module uses the `enableTelemetry` parameter to report anonymized module usage to Microsoft, in support of continued investment in the Bicep and Azure Verified Modules ecosystems. No resource-specific data is collected. diff --git a/src/Bicep.Core.UnitTests/Documentation/ThrowingFileExplorer.cs b/src/Bicep.Core.UnitTests/Documentation/ThrowingFileExplorer.cs new file mode 100644 index 00000000000..0171a4ad26f --- /dev/null +++ b/src/Bicep.Core.UnitTests/Documentation/ThrowingFileExplorer.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Bicep.IO.Abstraction; + +namespace Bicep.Core.UnitTests.Documentation; + +internal sealed class ThrowingFileExplorer(Exception exceptionToThrow) : IFileExplorer +{ + public IDirectoryHandle GetDirectory(IOUri uri) => throw new NotSupportedException(); + + public IFileHandle GetFile(IOUri uri) => new ThrowingFileHandle(uri, exceptionToThrow); + + private sealed class ThrowingFileHandle(IOUri uri, Exception exceptionToThrow) : IFileHandle + { + public IOUri Uri { get; } = uri; + + public bool Exists() => true; + + public string ReadAllText() => throw exceptionToThrow; + + public Task ReadAllTextAsync(CancellationToken cancellationToken = default) => throw exceptionToThrow; + + public bool Equals(IIOHandle? other) => other is ThrowingFileHandle otherHandle && Uri.Equals(otherHandle.Uri); + + public IDirectoryHandle GetParent() => throw new NotSupportedException(); + + public IFileHandle EnsureExists() => throw new NotSupportedException(); + + public Stream OpenRead() => throw new NotSupportedException(); + + public Stream OpenWrite() => throw new NotSupportedException(); + + public void WriteAllText(string text) => throw new NotSupportedException(); + + public Task WriteAllTextAsync(string text, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public void Delete() => throw new NotSupportedException(); + + public void MakeExecutable() => throw new NotSupportedException(); + + public IFileLock? TryLock() => throw new NotSupportedException(); + } +} + +internal sealed class SelectivelyThrowingFileExplorer(IFileExplorer inner, IOUri throwingUri, Exception exceptionToThrow) : IFileExplorer +{ + public IDirectoryHandle GetDirectory(IOUri uri) => inner.GetDirectory(uri); + + public IFileHandle GetFile(IOUri uri) => uri.Equals(throwingUri) + ? new ThrowingFileExplorer(exceptionToThrow).GetFile(uri) + : inner.GetFile(uri); +} diff --git a/src/Bicep.Core.UnitTests/Features/FeatureProviderOverrides.cs b/src/Bicep.Core.UnitTests/Features/FeatureProviderOverrides.cs index a70e49e2270..0d4d9c075d4 100644 --- a/src/Bicep.Core.UnitTests/Features/FeatureProviderOverrides.cs +++ b/src/Bicep.Core.UnitTests/Features/FeatureProviderOverrides.cs @@ -27,7 +27,8 @@ public record FeatureProviderOverrides( bool? DeployCommandsEnabled = default, bool? PatchEnabled = default, bool? RuntimeValuesInTagsAndSkuEnabled = default, - bool? AzExtensionConfigEnabled = default) + bool? AzExtensionConfigEnabled = default, + bool? DocsGenerationEnabled = default) { public FeatureProviderOverrides( TestContext testContext, @@ -49,7 +50,8 @@ public FeatureProviderOverrides( bool? DeployCommandsEnabled = default, bool? PatchEnabled = default, bool? RuntimeValuesInTagsAndSkuEnabled = default, - bool? AzExtensionConfigEnabled = default) : this( + bool? AzExtensionConfigEnabled = default, + bool? DocsGenerationEnabled = default) : this( FileHelper.GetCacheRootDirectory(testContext), RegistryEnabled, OciEnabled, @@ -69,7 +71,7 @@ public FeatureProviderOverrides( DeployCommandsEnabled, PatchEnabled, RuntimeValuesInTagsAndSkuEnabled, - AzExtensionConfigEnabled) + AzExtensionConfigEnabled, + DocsGenerationEnabled) { } } - diff --git a/src/Bicep.Core.UnitTests/Features/FeatureProviderTests.cs b/src/Bicep.Core.UnitTests/Features/FeatureProviderTests.cs index bccde29f80e..12eca1c493c 100644 --- a/src/Bicep.Core.UnitTests/Features/FeatureProviderTests.cs +++ b/src/Bicep.Core.UnitTests/Features/FeatureProviderTests.cs @@ -8,6 +8,7 @@ using Bicep.Core.Features; using Bicep.Core.UnitTests.Assertions; using Bicep.IO.FileSystem; +using Bicep.Testing; using Bicep.Testing.IO; using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -66,4 +67,69 @@ public void PropertyLookup_WithFeatureEnabledViaBicepConfig_ReturnsTrue() var subDirFeatures = fpm.GetFeatureProvider(fileSet.GetUri("repo/subdir/module.bicep")); subDirFeatures.SymbolicNameCodegenEnabled.Should().BeTrue(); } + + [TestMethod] + public void DocsGeneration_feature_is_exposed_by_feature_providers() + { + var enabled = ExperimentalFeaturesEnabled.AllDisabled with { DocsGeneration = true }; + var recordProvider = new RecordBasedFeatureProvider(enabled); + + recordProvider.DocsGenerationEnabled.Should().BeTrue(); + + var overridden = new OverriddenFeatureProvider( + recordProvider, + new(DocsGenerationEnabled: false)); + overridden.DocsGenerationEnabled.Should().BeFalse(); + + var assemblyVersionFactory = TestFeatureProviderFactory.WithAssemblyVersion( + IFeatureProviderFactory.WithStaticFeatureProvider(recordProvider), + "test"); + var sourceFileUri = InMemoryTestFileSet.Create(("main.bicep", "")).GetUri("main.bicep"); + assemblyVersionFactory.GetFeatureProvider(sourceFileUri) + .DocsGenerationEnabled.Should().BeTrue(); + + IFeatureProvider legacyProvider = new LegacyFeatureProvider(); + legacyProvider.DocsGenerationEnabled.Should().BeFalse(); + + var legacyConfiguration = new ExperimentalFeaturesEnabled( + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false); + legacyConfiguration.DocsGeneration.Should().BeFalse(); + } + + private sealed class LegacyFeatureProvider : IFeatureProvider + { + public string AssemblyVersion => throw new NotImplementedException(); + public Bicep.IO.Abstraction.IDirectoryHandle CacheRootDirectory => throw new NotImplementedException(); + public bool OciEnabled => false; + public bool SymbolicNameCodegenEnabled => false; + public bool ResourceTypedParamsAndOutputsEnabled => false; + public bool SourceMappingEnabled => false; + public bool LegacyFormatterEnabled => false; + public bool TestFrameworkEnabled => false; + public bool AssertsEnabled => false; + public bool WaitUntilEnabled => false; + public bool LocalDeployEnabled => false; + public bool ResourceInfoCodegenEnabled => false; + public bool ModuleExtensionConfigsEnabled => false; + public bool UserDefinedConstraintsEnabled => false; + public bool DeployCommandsEnabled => false; + public bool PatchEnabled => false; + public bool RuntimeValuesInTagsAndSkuEnabled => false; + public bool AzExtensionConfigEnabled => false; + } } diff --git a/src/Bicep.Core.UnitTests/Features/OverriddenFeatureProvider.cs b/src/Bicep.Core.UnitTests/Features/OverriddenFeatureProvider.cs index cbc828d35e7..2e6b0a98696 100644 --- a/src/Bicep.Core.UnitTests/Features/OverriddenFeatureProvider.cs +++ b/src/Bicep.Core.UnitTests/Features/OverriddenFeatureProvider.cs @@ -52,4 +52,6 @@ public OverriddenFeatureProvider(IFeatureProvider features, FeatureProviderOverr public bool RuntimeValuesInTagsAndSkuEnabled => overrides.RuntimeValuesInTagsAndSkuEnabled ?? features.RuntimeValuesInTagsAndSkuEnabled; public bool AzExtensionConfigEnabled => overrides.AzExtensionConfigEnabled ?? features.AzExtensionConfigEnabled; + + public bool DocsGenerationEnabled => overrides.DocsGenerationEnabled ?? features.DocsGenerationEnabled; } diff --git a/src/Bicep.Core/Bicep.Core.csproj b/src/Bicep.Core/Bicep.Core.csproj index ae06322bb49..66eb9ce6f08 100644 --- a/src/Bicep.Core/Bicep.Core.csproj +++ b/src/Bicep.Core/Bicep.Core.csproj @@ -25,6 +25,7 @@ + @@ -51,6 +52,7 @@ + diff --git a/src/Bicep.Core/BicepCoreServiceCollectionExtensions.cs b/src/Bicep.Core/BicepCoreServiceCollectionExtensions.cs index 62211bf3ad1..2dfbee734f0 100644 --- a/src/Bicep.Core/BicepCoreServiceCollectionExtensions.cs +++ b/src/Bicep.Core/BicepCoreServiceCollectionExtensions.cs @@ -10,6 +10,7 @@ using Bicep.Core.Analyzers.Linter.ApiVersions; using Bicep.Core.AzureApi; using Bicep.Core.Configuration; +using Bicep.Core.Documentation; using Bicep.Core.Features; using Bicep.Core.Registry; using Bicep.Core.Registry.Catalog; @@ -65,6 +66,7 @@ public static IServiceCollection AddBicepCore(this IServiceCollection services) services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.AddBicepRegistryCatalogServices(); services.TryAddSingleton(); diff --git a/src/Bicep.Core/Configuration/ExperimentalFeaturesEnabled.cs b/src/Bicep.Core/Configuration/ExperimentalFeaturesEnabled.cs index 59c9071fa7a..25ec4f20821 100644 --- a/src/Bicep.Core/Configuration/ExperimentalFeaturesEnabled.cs +++ b/src/Bicep.Core/Configuration/ExperimentalFeaturesEnabled.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Text.Json; +using System.Text.Json.Serialization; using Bicep.Core.Extensions; using Bicep.Core.Features; using Bicep.Core.Json; @@ -9,6 +10,7 @@ namespace Bicep.Core.Configuration; +[method: JsonConstructor] public record ExperimentalFeaturesEnabled( bool OciEnabled, bool SymbolicNameCodegen, @@ -25,8 +27,47 @@ public record ExperimentalFeaturesEnabled( bool DeployCommands, bool Patch, bool RuntimeValuesInTagsAndSku, - bool AzExtensionConfig) + bool AzExtensionConfig, + bool DocsGeneration) { + public ExperimentalFeaturesEnabled( + bool OciEnabled, + bool SymbolicNameCodegen, + bool ResourceTypedParamsAndOutputs, + bool SourceMapping, + bool LegacyFormatter, + bool TestFramework, + bool Assertions, + bool WaitUntil, + bool LocalDeploy, + bool ResourceInfoCodegen, + bool ModuleExtensionConfigs, + bool UserDefinedConstraints, + bool DeployCommands, + bool Patch, + bool RuntimeValuesInTagsAndSku, + bool AzExtensionConfig) + : this( + OciEnabled, + SymbolicNameCodegen, + ResourceTypedParamsAndOutputs, + SourceMapping, + LegacyFormatter, + TestFramework, + Assertions, + WaitUntil, + LocalDeploy, + ResourceInfoCodegen, + ModuleExtensionConfigs, + UserDefinedConstraints, + DeployCommands, + Patch, + RuntimeValuesInTagsAndSku, + AzExtensionConfig, + DocsGeneration: false) + { + } + public static ExperimentalFeaturesEnabled Bind(JsonElement element) => element.ToNonNullObject(); @@ -48,5 +89,6 @@ public static ExperimentalFeaturesEnabled Bind(JsonElement element) DeployCommands: false, Patch: false, RuntimeValuesInTagsAndSku: false, - AzExtensionConfig: false); + AzExtensionConfig: false, + DocsGeneration: false); } diff --git a/src/Bicep.Core/Documentation/BicepDocumentationDataCollection.cs b/src/Bicep.Core/Documentation/BicepDocumentationDataCollection.cs new file mode 100644 index 00000000000..ac33d76a7ea --- /dev/null +++ b/src/Bicep.Core/Documentation/BicepDocumentationDataCollection.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Bicep.Core.Documentation; + +/// +/// Describes a module's telemetry behavior. +/// +public record BicepDocumentationDataCollection(bool Enabled, string Note); diff --git a/src/Bicep.Core/Documentation/BicepDocumentationExampleDiscovery.cs b/src/Bicep.Core/Documentation/BicepDocumentationExampleDiscovery.cs new file mode 100644 index 00000000000..ece8a0e57e0 --- /dev/null +++ b/src/Bicep.Core/Documentation/BicepDocumentationExampleDiscovery.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using System.Text.RegularExpressions; +using Bicep.IO.Abstraction; + +namespace Bicep.Core.Documentation; + +internal static partial class BicepDocumentationExampleDiscovery +{ + private static readonly ImmutableArray CategoryFolderNames = ["examples", "tests"]; + + public static ImmutableArray Discover(IDirectoryHandle moduleRoot) + { + var examples = ImmutableArray.CreateBuilder(); + + foreach (var categoryFolderName in CategoryFolderNames) + { + var categoryRoot = moduleRoot.GetDirectory(categoryFolderName); + if (!categoryRoot.Exists()) + { + continue; + } + + foreach (var file in EnumerateBicepFiles(categoryRoot)) + { + var relativePath = file.Uri.GetPathRelativeTo(moduleRoot.Uri); + var name = GetExampleName(categoryRoot.Uri, file.Uri); + var contents = file.ReadAllText(); + + examples.Add(new BicepDocumentationUsageExample(name, relativePath, TryGetDescription(contents), contents)); + } + } + + return BicepDocumentationOrdering.SortByName(examples.ToImmutable(), e => e.RelativePath); + } + + private static IEnumerable EnumerateBicepFiles(IDirectoryHandle directory) + { + foreach (var file in directory.EnumerateFiles("*") + .Where(file => file.Uri.Path.EndsWith(".bicep", StringComparison.OrdinalIgnoreCase))) + { + yield return file; + } + + foreach (var subdirectory in directory.EnumerateDirectories("*")) + { + foreach (var file in EnumerateBicepFiles(subdirectory)) + { + yield return file; + } + } + } + + private static string GetExampleName(IOUri categoryRoot, IOUri file) + { + var relativeToCategory = file.GetPathRelativeTo(categoryRoot); + var segments = relativeToCategory.Split('/', StringSplitOptions.RemoveEmptyEntries); + + if (segments.Length > 1) + { + return segments[0]; + } + + var fileName = segments[^1]; + + return fileName[..^".bicep".Length]; + } + + // Avoids a full compile: uses a literal `metadata description = '...'` if present, else leading `//` comments. + private static string? TryGetDescription(string contents) + { + var match = MetadataDescriptionPattern().Match(contents); + if (match.Success) + { + return match.Groups[1].Value; + } + + var leadingComment = contents + .ReplaceLineEndings("\n") + .Split('\n') + .TakeWhile(line => line.TrimStart().StartsWith("//", StringComparison.Ordinal) || line.Trim().Length == 0) + .Select(line => line.TrimStart().TrimStart('/').Trim()) + .Where(line => line.Length > 0) + .ToArray(); + + return leadingComment.Length > 0 ? string.Join(' ', leadingComment) : null; + } + + [GeneratedRegex("""metadata\s+description\s*=\s*'((?:[^'\\]|\\.)*)'""")] + private static partial Regex MetadataDescriptionPattern(); +} diff --git a/src/Bicep.Core/Documentation/BicepDocumentationException.cs b/src/Bicep.Core/Documentation/BicepDocumentationException.cs new file mode 100644 index 00000000000..33a957ea434 --- /dev/null +++ b/src/Bicep.Core/Documentation/BicepDocumentationException.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Bicep.Core.Exceptions; + +namespace Bicep.Core.Documentation; + +/// +/// Represents a documentation model or rendering failure. +/// +public class BicepDocumentationException : BicepException +{ + public BicepDocumentationException(string message, Exception? innerException = null) + : base(message, innerException) + { + } +} diff --git a/src/Bicep.Core/Documentation/BicepDocumentationFunction.cs b/src/Bicep.Core/Documentation/BicepDocumentationFunction.cs new file mode 100644 index 00000000000..e347c3a1ac6 --- /dev/null +++ b/src/Bicep.Core/Documentation/BicepDocumentationFunction.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; + +namespace Bicep.Core.Documentation; + +/// +/// A user-defined function exported from a module (via @export()). +/// +public record BicepDocumentationFunction( + string Name, + ImmutableArray Parameters, + string ReturnTypeName, + string? Description); + +/// +/// A single parameter of an exported user-defined function. +/// +public record BicepDocumentationFunctionParameter(string Name, string TypeName, string? Description); diff --git a/src/Bicep.Core/Documentation/BicepDocumentationGenerationOptions.cs b/src/Bicep.Core/Documentation/BicepDocumentationGenerationOptions.cs new file mode 100644 index 00000000000..b03c9362e2f --- /dev/null +++ b/src/Bicep.Core/Documentation/BicepDocumentationGenerationOptions.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Bicep.IO.Abstraction; + +namespace Bicep.Core.Documentation; + +/// +/// Options controlling how renders documentation for a module. +/// +/// The built-in preset. Only Markdown is supported. +/// An optional Scriban template file. +/// An optional root directory for template includes. +/// Optional string values exposed to the template. +public record BicepDocumentationGenerationOptions( + BicepDocumentationPreset Preset, + IOUri? TemplateFile, + IOUri? TemplateRoot, + IReadOnlyDictionary? CustomValues) +{ + /// + /// Gets the built-in Markdown options. + /// + public static BicepDocumentationGenerationOptions Default { get; } = new( + Preset: BicepDocumentationPreset.Markdown, + TemplateFile: null, + TemplateRoot: null, + CustomValues: null); +} diff --git a/src/Bicep.Core/Documentation/BicepDocumentationGenerator.cs b/src/Bicep.Core/Documentation/BicepDocumentationGenerator.cs new file mode 100644 index 00000000000..867199aa41f --- /dev/null +++ b/src/Bicep.Core/Documentation/BicepDocumentationGenerator.cs @@ -0,0 +1,318 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using System.Reflection; +using Bicep.Core.Semantics; +using Bicep.Core.Semantics.Metadata; +using Bicep.Core.Syntax; +using Bicep.Core.TypeSystem; +using Bicep.IO.Abstraction; +using Scriban; +using Scriban.Parsing; +using Scriban.Syntax; + +namespace Bicep.Core.Documentation; + +public class BicepDocumentationGenerator(IFileExplorer fileExplorer) : IBicepDocumentationGenerator +{ + private const string BuiltInTemplateResourceName = "Bicep.Core.Documentation.Templates.Markdown.scriban"; + + private const string EnableTelemetryParameterName = "enableTelemetry"; + + private const string DataCollectionNote = + "This module uses the `enableTelemetry` parameter to report anonymized module usage to Microsoft, " + + "in support of continued investment in the Bicep and Azure Verified Modules ecosystems. No resource-specific data is collected."; + + private const string MetadataNamePropertyName = "name"; + + private static readonly Lazy BuiltInTemplateSource = new(LoadBuiltInTemplateSource); + + private static readonly ImmutableDictionary TargetScopeNames = + new Dictionary + { + [ResourceScope.Tenant] = LanguageConstants.TargetScopeTypeTenant, + [ResourceScope.ManagementGroup] = LanguageConstants.TargetScopeTypeManagementGroup, + [ResourceScope.Subscription] = LanguageConstants.TargetScopeTypeSubscription, + [ResourceScope.Local] = LanguageConstants.TargetScopeTypeLocal, + }.ToImmutableDictionary(); + + public BicepDocumentationModel BuildModel(Compilation compilation, IReadOnlyDictionary? customValues = null) + { + var semanticModel = compilation.GetEntrypointSemanticModel(); + + if (semanticModel.HasErrors()) + { + throw new BicepDocumentationException("Cannot generate documentation for a module that has compilation errors."); + } + + var entryFile = semanticModel.SourceFile.FileHandle; + var moduleRoot = entryFile.GetParent(); + + return new BicepDocumentationModel( + Name: GetModuleName(semanticModel, moduleRoot), + Description: DescriptionHelper.TryGetFromSemanticModel(semanticModel), + Path: entryFile.Uri.GetFilePath(), + TargetScope: GetTargetScopeName(semanticModel.TargetScope), + Custom: BuildCustom(customValues), + ResourceTypes: BuildResourceTypes(semanticModel), + Parameters: BuildParameters(semanticModel), + Outputs: BuildOutputs(semanticModel), + ExportedFunctions: BuildExportedFunctions(semanticModel), + References: BuildReferences(semanticModel), + UsageExamples: BicepDocumentationExampleDiscovery.Discover(moduleRoot), + DataCollection: BuildDataCollection(semanticModel)); + } + + public string Render(BicepDocumentationModel model, BicepDocumentationGenerationOptions? options = null) + { + options ??= BicepDocumentationGenerationOptions.Default; + + if (!Enum.IsDefined(options.Preset)) + { + throw new BicepDocumentationException($"The documentation preset '{options.Preset}' is not supported."); + } + + var (templateSource, templateSourcePath) = GetTemplateSource(options); + + var template = Template.Parse(templateSource, templateSourcePath); + + if (template.HasErrors) + { + throw new BicepDocumentationException($"Failed to parse the documentation template '{templateSourcePath}':{System.Environment.NewLine}{template.Messages}"); + } + + var scriptObject = BicepDocumentationScriptModelFactory.Create(ApplyCustomValues(model, options.CustomValues)); + var context = new TemplateContext + { + TemplateLoader = new BicepDocumentationTemplateLoader(fileExplorer, GetIncludeRoot(options, model)), + }; + context.PushGlobal(scriptObject); + + string rendered; + try + { + rendered = template.Render(context); + } + catch (ScriptRuntimeException ex) + { + throw new BicepDocumentationException($"Failed to render the documentation template '{templateSourcePath}': {ex.Message}", ex); + } + + return NormalizeOutput(rendered); + } + + public string Generate(Compilation compilation, BicepDocumentationGenerationOptions? options = null) + { + var model = BuildModel(compilation, options?.CustomValues); + + return Render(model, options); + } + + private static ImmutableSortedDictionary BuildCustom(IReadOnlyDictionary? customValues) => + customValues is null + ? ImmutableSortedDictionary.Create(StringComparer.Ordinal) + : customValues.ToImmutableSortedDictionary(StringComparer.Ordinal); + + private static BicepDocumentationModel ApplyCustomValues( + BicepDocumentationModel model, + IReadOnlyDictionary? customValues) + { + if (customValues is null) + { + return model; + } + + var merged = model.Custom.ToBuilder(); + foreach (var (key, value) in customValues) + { + merged[key] = value; + } + + return model with { Custom = merged.ToImmutable() }; + } + + private IOUri GetIncludeRoot(BicepDocumentationGenerationOptions options, BicepDocumentationModel model) + { + if (options.TemplateRoot is { } templateRoot) + { + return fileExplorer.GetDirectory(templateRoot).Uri; + } + + try + { + return IOUri.FromFilePath(model.Path).Resolve("."); + } + catch (IOException ex) + { + throw new BicepDocumentationException($"Unable to resolve an include root from module path '{model.Path}': {ex.Message}", ex); + } + } + + private (string source, string sourcePath) GetTemplateSource(BicepDocumentationGenerationOptions options) + { + if (options.TemplateFile is not { } templateFileUri) + { + return (BuiltInTemplateSource.Value, BuiltInTemplateResourceName); + } + + var file = fileExplorer.GetFile(templateFileUri); + if (!file.Exists()) + { + throw new BicepDocumentationException($"The template file '{templateFileUri}' does not exist."); + } + + string contents; + try + { + contents = file.ReadAllText(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw new BicepDocumentationException($"Unable to read template file '{templateFileUri}': {ex.Message}", ex); + } + + return (contents, templateFileUri.ToString()); + } + + private static string LoadBuiltInTemplateSource() => + LoadTemplateSource(Assembly.GetExecutingAssembly(), BuiltInTemplateResourceName); + + internal static string LoadTemplateSource(Assembly assembly, string resourceName) + { + using var stream = assembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException("Could not get manifest resource stream for the built-in documentation template."); + using var reader = new StreamReader(stream); + + return reader.ReadToEnd(); + } + + private static string NormalizeOutput(string rendered) + { + var normalized = rendered.ReplaceLineEndings("\n").TrimEnd('\n'); + + return normalized + "\n"; + } + + private static string GetModuleName(SemanticModel semanticModel, IDirectoryHandle moduleRoot) + { + var nameMetadata = semanticModel.Root.MetadataDeclarations + .FirstOrDefault(metadata => LanguageConstants.IdentifierComparer.Equals(metadata.Name, MetadataNamePropertyName)); + + if (nameMetadata?.Value is StringSyntax nameSyntax && nameSyntax.TryGetLiteralValue() is { } literalName) + { + return literalName; + } + + return GetFallbackModuleName(moduleRoot.Uri, semanticModel.SourceFile.FileHandle.Uri); + } + + internal static string GetFallbackModuleName(IOUri moduleRootUri, IOUri entryFileUri) + { + var directoryName = moduleRootUri.GetFileName(); + return directoryName.Length > 0 + ? directoryName + : entryFileUri.GetFileNameWithoutExtension().ToString(); + } + + private static string GetTargetScopeName(ResourceScope targetScope) + { + return TargetScopeNames.TryGetValue(targetScope, out var name) + ? name + : LanguageConstants.TargetScopeTypeResourceGroup; + } + + private static ImmutableArray BuildResourceTypes(SemanticModel semanticModel) + { + var resourceTypes = semanticModel.DeclaredResources + .Select(resource => new BicepDocumentationResourceType(resource.Type.TypeReference.FormatName(), resource.IsExistingResource)) + .Distinct() + .ToImmutableArray(); + + return BicepDocumentationOrdering.SortByName(resourceTypes, r => r.Type); + } + + private static ImmutableArray BuildParameters(SemanticModel semanticModel) + { + var parameters = semanticModel.Root.ParameterDeclarations + .Select(symbol => + { + var metadata = semanticModel.Parameters[symbol.Name]; + var defaultValue = symbol.DeclaringParameter.Modifier is ParameterDefaultValueSyntax defaultValueSyntax + ? SyntaxStringifier.Stringify(defaultValueSyntax.DefaultValue) + : null; + + return BicepDocumentationTypeAnalyzer.BuildParameter(symbol.Name, metadata.TypeReference.Type, metadata.IsRequired, metadata.Description, defaultValue); + }) + .ToImmutableArray(); + + return BicepDocumentationOrdering.SortByName(parameters, p => p.Name); + } + + private static ImmutableArray BuildOutputs(SemanticModel semanticModel) + { + var outputs = semanticModel.Outputs + .Select(output => new BicepDocumentationOutput( + output.Name, + BicepDocumentationTypeAnalyzer.GetTypeName(output.TypeReference.Type), + output.TypeReference.Type.ValidationFlags.HasFlag(TypeSymbolValidationFlags.IsSecure), + output.Description)) + .ToImmutableArray(); + + return BicepDocumentationOrdering.SortByName(outputs, o => o.Name); + } + + private static ImmutableArray BuildExportedFunctions(SemanticModel semanticModel) + { + var functions = semanticModel.Exports.Values + .OfType() + .Select(function => new BicepDocumentationFunction( + function.Name, + function.Parameters + .Select(parameter => new BicepDocumentationFunctionParameter( + parameter.Name, + BicepDocumentationTypeAnalyzer.GetTypeName(parameter.TypeReference.Type), + parameter.Description)) + .ToImmutableArray(), + BicepDocumentationTypeAnalyzer.GetTypeName(function.Return.TypeReference.Type), + function.Description)) + .ToImmutableArray(); + + return BicepDocumentationOrdering.SortByName(functions, f => f.Name); + } + + private static ImmutableArray BuildReferences(SemanticModel semanticModel) + { + var references = semanticModel.Root.ModuleDeclarations + .Select(module => + { + var path = ((StringSyntax)module.DeclaringModule.Path).TryGetLiteralValue(); + string? description = null; + + if (module.TryGetSemanticModel().IsSuccess(out var referencedModel)) + { + description = DescriptionHelper.TryGetFromSemanticModel(referencedModel); + } + + return new BicepDocumentationReference(module.Name, path, description); + }) + .ToImmutableArray(); + + return BicepDocumentationOrdering.SortByName(references, r => r.SymbolicName); + } + + private static BicepDocumentationDataCollection? BuildDataCollection(SemanticModel semanticModel) + { + var enableTelemetryParameter = semanticModel.Root.ParameterDeclarations + .FirstOrDefault(symbol => LanguageConstants.IdentifierComparer.Equals(symbol.Name, EnableTelemetryParameterName)); + + if (enableTelemetryParameter is null) + { + return null; + } + + var enabledByDefault = enableTelemetryParameter.DeclaringParameter.Modifier is not ParameterDefaultValueSyntax { DefaultValue: BooleanLiteralSyntax { Value: false } }; + + return new BicepDocumentationDataCollection(enabledByDefault, DataCollectionNote); + } +} diff --git a/src/Bicep.Core/Documentation/BicepDocumentationModel.cs b/src/Bicep.Core/Documentation/BicepDocumentationModel.cs new file mode 100644 index 00000000000..883dd3038ea --- /dev/null +++ b/src/Bicep.Core/Documentation/BicepDocumentationModel.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; + +namespace Bicep.Core.Documentation; + +/// +/// Represents documentation data for one Bicep module. +/// +public record BicepDocumentationModel( + string Name, + string? Description, + string Path, + string TargetScope, + ImmutableSortedDictionary Custom, + ImmutableArray ResourceTypes, + ImmutableArray Parameters, + ImmutableArray Outputs, + ImmutableArray ExportedFunctions, + ImmutableArray References, + ImmutableArray UsageExamples, + BicepDocumentationDataCollection? DataCollection); diff --git a/src/Bicep.Core/Documentation/BicepDocumentationOrdering.cs b/src/Bicep.Core/Documentation/BicepDocumentationOrdering.cs new file mode 100644 index 00000000000..9080d6c34c8 --- /dev/null +++ b/src/Bicep.Core/Documentation/BicepDocumentationOrdering.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; + +namespace Bicep.Core.Documentation; + +internal static class BicepDocumentationOrdering +{ + public static readonly IComparer NameComparer = Comparer.Create(Compare); + + public static ImmutableArray SortByName(IEnumerable items, Func nameSelector) => + [.. items.OrderBy(nameSelector, NameComparer)]; + + private static int Compare(string left, string right) + { + var caseInsensitive = string.Compare(left, right, StringComparison.OrdinalIgnoreCase); + + return caseInsensitive != 0 ? caseInsensitive : string.CompareOrdinal(left, right); + } +} diff --git a/src/Bicep.Core/Documentation/BicepDocumentationOutput.cs b/src/Bicep.Core/Documentation/BicepDocumentationOutput.cs new file mode 100644 index 00000000000..287ce891d38 --- /dev/null +++ b/src/Bicep.Core/Documentation/BicepDocumentationOutput.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Bicep.Core.Documentation; + +/// +/// A module output. +/// +public record BicepDocumentationOutput(string Name, string TypeName, bool IsSecure, string? Description); diff --git a/src/Bicep.Core/Documentation/BicepDocumentationParameter.cs b/src/Bicep.Core/Documentation/BicepDocumentationParameter.cs new file mode 100644 index 00000000000..49e7df74e2a --- /dev/null +++ b/src/Bicep.Core/Documentation/BicepDocumentationParameter.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; + +namespace Bicep.Core.Documentation; + +/// +/// Represents a module parameter or nested property. +/// +public record BicepDocumentationParameter( + string Name, + string TypeName, + bool IsRequired, + bool IsSecure, + string? Description, + string? DefaultValue, + ImmutableArray AllowedValues, + long? MinValue, + long? MaxValue, + long? MinLength, + long? MaxLength, + string? Pattern, + ImmutableArray NestedProperties, + BicepDocumentationDiscriminator? Discriminator); + +/// +/// Represents a discriminated object type. +/// +public record BicepDocumentationDiscriminator( + string PropertyName, + ImmutableArray Cases); + +/// +/// Represents one discriminator case. +/// +public record BicepDocumentationDiscriminatorCase( + string Value, + ImmutableArray Properties); diff --git a/src/Bicep.Core/Documentation/BicepDocumentationPreset.cs b/src/Bicep.Core/Documentation/BicepDocumentationPreset.cs new file mode 100644 index 00000000000..b8bffe68717 --- /dev/null +++ b/src/Bicep.Core/Documentation/BicepDocumentationPreset.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Bicep.Core.Documentation; + +/// +/// Defines built-in documentation presets. +/// +public enum BicepDocumentationPreset +{ + /// + /// Generates Markdown with the built-in or a custom Scriban template. + /// + Markdown = 0, +} diff --git a/src/Bicep.Core/Documentation/BicepDocumentationReference.cs b/src/Bicep.Core/Documentation/BicepDocumentationReference.cs new file mode 100644 index 00000000000..f268cab9181 --- /dev/null +++ b/src/Bicep.Core/Documentation/BicepDocumentationReference.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Bicep.Core.Documentation; + +/// +/// A cross-referenced module declared with a module statement in the entrypoint file. +/// +public record BicepDocumentationReference(string SymbolicName, string? Path, string? Description); diff --git a/src/Bicep.Core/Documentation/BicepDocumentationResourceType.cs b/src/Bicep.Core/Documentation/BicepDocumentationResourceType.cs new file mode 100644 index 00000000000..a02982a1c5a --- /dev/null +++ b/src/Bicep.Core/Documentation/BicepDocumentationResourceType.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Bicep.Core.Documentation; + +/// +/// A resource type declared (directly or nested) within a module. +/// +public record BicepDocumentationResourceType(string Type, bool IsExisting); diff --git a/src/Bicep.Core/Documentation/BicepDocumentationScriptModelFactory.cs b/src/Bicep.Core/Documentation/BicepDocumentationScriptModelFactory.cs new file mode 100644 index 00000000000..ac949a29d72 --- /dev/null +++ b/src/Bicep.Core/Documentation/BicepDocumentationScriptModelFactory.cs @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using Scriban.Runtime; + +namespace Bicep.Core.Documentation; + +internal static class BicepDocumentationScriptModelFactory +{ + public static ScriptObject Create(BicepDocumentationModel model) + { + var custom = CreateCustom(model.Custom); + + return new ScriptObject + { + { "custom", custom }, + { "module", CreateModule(model, custom) }, + }; + } + + private static ScriptObject CreateModule(BicepDocumentationModel model, ScriptObject custom) => new() + { + { "name", model.Name }, + { "description", model.Description }, + { "path", model.Path }, + { "targetScope", model.TargetScope }, + { "custom", custom }, + { "resourceTypes", CreateArray(model.ResourceTypes, CreateResourceType) }, + { "parameters", CreateParameters(model.Parameters) }, + { "outputs", CreateArray(model.Outputs, CreateOutput) }, + { "exportedFunctions", CreateArray(model.ExportedFunctions, CreateFunction) }, + { "references", CreateArray(model.References, CreateReference) }, + { "usageExamples", CreateArray(model.UsageExamples, CreateUsageExample) }, + { "dataCollection", model.DataCollection is { } dataCollection ? CreateDataCollection(dataCollection) : null }, + }; + + private static ScriptObject CreateCustom(ImmutableSortedDictionary custom) + { + var scriptObject = new ScriptObject(); + foreach (var (key, value) in custom) + { + scriptObject.Add(key, value); + } + + return scriptObject; + } + + private static ScriptObject CreateResourceType(BicepDocumentationResourceType resourceType) => new() + { + { "type", resourceType.Type }, + { "existing", resourceType.IsExisting }, + }; + + private static ScriptObject CreateParameter(BicepDocumentationParameter parameter) => new() + { + { "name", parameter.Name }, + { "type", parameter.TypeName }, + { "required", parameter.IsRequired }, + { "secure", parameter.IsSecure }, + { "description", parameter.Description }, + { "defaultValue", parameter.DefaultValue }, + { "allowedValues", CreateStrings(parameter.AllowedValues) }, + { "minValue", parameter.MinValue }, + { "maxValue", parameter.MaxValue }, + { "minLength", parameter.MinLength }, + { "maxLength", parameter.MaxLength }, + { "pattern", parameter.Pattern }, + { "properties", CreateParameters(parameter.NestedProperties) }, + { "discriminator", parameter.Discriminator is { } discriminator ? CreateDiscriminator(discriminator) : null }, + }; + + private static ScriptObject CreateDiscriminator(BicepDocumentationDiscriminator discriminator) => new() + { + { "propertyName", discriminator.PropertyName }, + { "cases", CreateDiscriminatorCases(discriminator.Cases) }, + }; + + private static ScriptObject CreateDiscriminatorCase(BicepDocumentationDiscriminatorCase discriminatorCase) => new() + { + { "value", discriminatorCase.Value }, + { "properties", CreateParameters(discriminatorCase.Properties) }, + }; + + private static ScriptObject CreateOutput(BicepDocumentationOutput output) => new() + { + { "name", output.Name }, + { "type", output.TypeName }, + { "secure", output.IsSecure }, + { "description", output.Description }, + }; + + private static ScriptObject CreateFunction(BicepDocumentationFunction function) => new() + { + { "name", function.Name }, + { "parameters", CreateArray(function.Parameters, CreateFunctionParameter) }, + { "returnType", function.ReturnTypeName }, + { "description", function.Description }, + }; + + private static ScriptObject CreateFunctionParameter(BicepDocumentationFunctionParameter parameter) => new() + { + { "name", parameter.Name }, + { "type", parameter.TypeName }, + { "description", parameter.Description }, + }; + + private static ScriptObject CreateReference(BicepDocumentationReference reference) => new() + { + { "symbolicName", reference.SymbolicName }, + { "path", reference.Path }, + { "description", reference.Description }, + }; + + private static ScriptObject CreateUsageExample(BicepDocumentationUsageExample example) => new() + { + { "name", example.Name }, + { "path", example.RelativePath }, + { "description", example.Description }, + { "contents", example.Contents }, + }; + + private static ScriptObject CreateDataCollection(BicepDocumentationDataCollection dataCollection) => new() + { + { "enabled", dataCollection.Enabled }, + { "note", dataCollection.Note }, + }; + + private static ScriptArray CreateParameters(ImmutableArray parameters) + { + var array = new ScriptArray(); + foreach (var parameter in parameters) + { + array.Add(CreateParameter(parameter)); + } + + return array; + } + + private static ScriptArray CreateDiscriminatorCases(ImmutableArray cases) + { + var array = new ScriptArray(); + foreach (var discriminatorCase in cases) + { + array.Add(CreateDiscriminatorCase(discriminatorCase)); + } + + return array; + } + + private static ScriptArray CreateStrings(ImmutableArray values) + { + var array = new ScriptArray(); + foreach (var value in values) + { + array.Add(value); + } + + return array; + } + + private static ScriptArray CreateArray(ImmutableArray items, Func project) + { + var array = new ScriptArray(); + foreach (var item in items) + { + array.Add(project(item)); + } + + return array; + } +} diff --git a/src/Bicep.Core/Documentation/BicepDocumentationTemplateLoader.cs b/src/Bicep.Core/Documentation/BicepDocumentationTemplateLoader.cs new file mode 100644 index 00000000000..199c7cd8268 --- /dev/null +++ b/src/Bicep.Core/Documentation/BicepDocumentationTemplateLoader.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Bicep.IO.Abstraction; +using Scriban; +using Scriban.Parsing; +using Scriban.Runtime; +using Scriban.Syntax; + +namespace Bicep.Core.Documentation; + +internal sealed class BicepDocumentationTemplateLoader(IFileExplorer fileExplorer, IOUri root) : ITemplateLoader +{ + private readonly Dictionary resolvedPathsByKey = new(StringComparer.Ordinal); + + public string GetPath(TemplateContext context, SourceSpan callerSpan, string templateName) + { + IOUri resolved; + + try + { + resolved = root.Resolve(templateName); + } + catch (IOException ex) + { + throw new ScriptRuntimeException(callerSpan, $"Unable to resolve include path '{templateName}': {ex.Message}", ex); + } + + var key = resolved.ToString(); + this.resolvedPathsByKey[key] = resolved; + + return key; + } + + public string Load(TemplateContext context, SourceSpan callerSpan, string templatePath) + { + if (!this.resolvedPathsByKey.TryGetValue(templatePath, out var uri)) + { + throw new ScriptRuntimeException(callerSpan, $"Unable to resolve include path '{templatePath}'."); + } + + var file = fileExplorer.GetFile(uri); + if (!file.Exists()) + { + throw new ScriptRuntimeException(callerSpan, $"The include file '{templatePath}' does not exist."); + } + + try + { + return file.ReadAllText(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw new ScriptRuntimeException(callerSpan, $"Unable to read include file '{templatePath}': {ex.Message}", ex); + } + } + + public ValueTask LoadAsync(TemplateContext context, SourceSpan callerSpan, string templatePath) => + new(Load(context, callerSpan, templatePath)); +} diff --git a/src/Bicep.Core/Documentation/BicepDocumentationTypeAnalyzer.cs b/src/Bicep.Core/Documentation/BicepDocumentationTypeAnalyzer.cs new file mode 100644 index 00000000000..a0634391b3f --- /dev/null +++ b/src/Bicep.Core/Documentation/BicepDocumentationTypeAnalyzer.cs @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using System.Globalization; +using Bicep.Core.TypeSystem; +using Bicep.Core.TypeSystem.Types; + +namespace Bicep.Core.Documentation; + +internal static class BicepDocumentationTypeAnalyzer +{ + // Bicep type declarations cannot be cyclic, so a depth limit alone is enough to bound recursion. + private const int MaxDepth = 20; + + public static string GetTypeName(TypeSymbol type) => Analyze(GetEffectiveType(type), MaxDepth).TypeName; + + public static BicepDocumentationParameter BuildParameter(string name, TypeSymbol type, bool isRequired, string? description, string? defaultValue) + { + var effectiveType = GetEffectiveType(type); + var analysis = Analyze(effectiveType, 0); + + return new BicepDocumentationParameter( + name, + analysis.TypeName, + isRequired, + effectiveType.ValidationFlags.HasFlag(TypeSymbolValidationFlags.IsSecure), + description, + defaultValue, + analysis.AllowedValues, + analysis.MinValue, + analysis.MaxValue, + analysis.MinLength, + analysis.MaxLength, + analysis.Pattern, + analysis.NestedProperties, + analysis.Discriminator); + } + + private static BicepDocumentationParameter BuildProperty(string name, NamedTypeProperty property, int depth) + { + var effectiveType = GetEffectiveType(property.TypeReference.Type); + var analysis = Analyze(effectiveType, depth); + + return new BicepDocumentationParameter( + name, + analysis.TypeName, + TypeHelper.IsRequired(property), + effectiveType.ValidationFlags.HasFlag(TypeSymbolValidationFlags.IsSecure), + property.Description, + null, + analysis.AllowedValues, + analysis.MinValue, + analysis.MaxValue, + analysis.MinLength, + analysis.MaxLength, + analysis.Pattern, + analysis.NestedProperties, + analysis.Discriminator); + } + + private static TypeSymbol GetEffectiveType(TypeSymbol type) => TypeHelper.TryRemoveNullability(type) ?? type; + + private static TypeAnalysis Analyze(TypeSymbol type, int depth) + { + switch (type) + { + case UnionType union when union.Members.Length > 0 && union.Members.All(m => IsSimpleLiteral(m.Type)): + var literalTypes = union.Members.Select(m => m.Type).ToImmutableArray(); + var baseTypeName = literalTypes.Select(t => t.GetType()).Distinct().Count() == 1 + ? GetLiteralBaseTypeName(literalTypes[0]) + : union.Name; + + return TypeAnalysis.Simple(baseTypeName) with { AllowedValues = SortLiteralValues(literalTypes) }; + + case IntegerType integer: + return TypeAnalysis.Simple(LanguageConstants.TypeNameInt) with + { + MinValue = integer.MinValue, + MaxValue = integer.MaxValue, + }; + + case StringType str: + return TypeAnalysis.Simple(LanguageConstants.TypeNameString) with + { + MinLength = str.MinLength, + MaxLength = str.MaxLength, + Pattern = str.Pattern, + }; + + case ArrayType array: + return TypeAnalysis.Simple(LanguageConstants.ArrayType) with + { + MinLength = array.MinLength, + MaxLength = array.MaxLength, + AllowedValues = GetLiteralUnionAllowedValues(GetEffectiveType(array.Item.Type)), + }; + + case DiscriminatedObjectType discriminated: + if (depth >= MaxDepth) + { + return TypeAnalysis.Simple(LanguageConstants.ObjectType); + } + + var discriminator = BuildDiscriminator(discriminated, depth + 1); + + return TypeAnalysis.Simple(LanguageConstants.ObjectType) with { Discriminator = discriminator }; + + case ObjectType obj: + if (depth >= MaxDepth) + { + return TypeAnalysis.Simple(LanguageConstants.ObjectType); + } + + var nestedProperties = BuildProperties(obj, depth + 1); + + return TypeAnalysis.Simple(LanguageConstants.ObjectType) with { NestedProperties = nestedProperties }; + + default: + return TypeAnalysis.Simple(type.Name); + } + } + + private static ImmutableArray BuildProperties(ObjectType obj, int depth) + { + var properties = obj.Properties + .Select(kvp => BuildProperty(kvp.Key, kvp.Value, depth)) + .ToImmutableArray(); + + return BicepDocumentationOrdering.SortByName(properties, p => p.Name); + } + + private static BicepDocumentationDiscriminator BuildDiscriminator(DiscriminatedObjectType discriminated, int depth) + { + // UnionMembersByKey keys are escaped Bicep string literals; DiscriminatorKeysUnionType exposes the + // unescaped raw values instead. + var keyLiterals = discriminated.DiscriminatorKeysUnionType switch + { + UnionType union => union.Members.Select(m => m.Type), + var single => [single], + }; + + var cases = keyLiterals + .OfType() + .Where(literal => discriminated.UnionMembersByKey.ContainsKey(literal.Name)) + .Select(literal => new BicepDocumentationDiscriminatorCase( + literal.RawStringValue, + BuildProperties(discriminated.UnionMembersByKey[literal.Name], depth))) + .ToImmutableArray(); + + return new BicepDocumentationDiscriminator(discriminated.DiscriminatorKey, BicepDocumentationOrdering.SortByName(cases, c => c.Value)); + } + + private static bool IsSimpleLiteral(TypeSymbol type) => type is StringLiteralType or IntegerLiteralType or BooleanLiteralType; + + private static string GetLiteralBaseTypeName(TypeSymbol literal) => literal switch + { + StringLiteralType => LanguageConstants.TypeNameString, + IntegerLiteralType => LanguageConstants.TypeNameInt, + _ => LanguageConstants.TypeNameBool, + }; + + private static string GetLiteralRawValue(TypeSymbol literal) => literal switch + { + StringLiteralType stringLiteral => stringLiteral.RawStringValue, + IntegerLiteralType integerLiteral => integerLiteral.Value.ToString(CultureInfo.InvariantCulture), + _ => literal.Name, + }; + + private static ImmutableArray SortLiteralValues(IEnumerable literalTypes) => + literalTypes + .Select(GetLiteralRawValue) + .OrderBy(value => value, StringComparer.OrdinalIgnoreCase) + .ThenBy(value => value, StringComparer.Ordinal) + .ToImmutableArray(); + + private static ImmutableArray GetLiteralUnionAllowedValues(TypeSymbol itemType) => itemType switch + { + UnionType union when union.Members.Length > 0 && union.Members.All(m => IsSimpleLiteral(m.Type)) => + SortLiteralValues(union.Members.Select(m => m.Type)), + _ => [], + }; + + private sealed record TypeAnalysis( + string TypeName, + ImmutableArray AllowedValues, + long? MinValue, + long? MaxValue, + long? MinLength, + long? MaxLength, + string? Pattern, + ImmutableArray NestedProperties, + BicepDocumentationDiscriminator? Discriminator) + { + public static TypeAnalysis Simple(string typeName) => new( + typeName, + [], + null, + null, + null, + null, + null, + [], + null); + } +} diff --git a/src/Bicep.Core/Documentation/BicepDocumentationUsageExample.cs b/src/Bicep.Core/Documentation/BicepDocumentationUsageExample.cs new file mode 100644 index 00000000000..e4ce4441b52 --- /dev/null +++ b/src/Bicep.Core/Documentation/BicepDocumentationUsageExample.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Bicep.Core.Documentation; + +/// +/// Represents a local usage example. +/// +public record BicepDocumentationUsageExample(string Name, string RelativePath, string? Description, string Contents); diff --git a/src/Bicep.Core/Documentation/IBicepDocumentationGenerator.cs b/src/Bicep.Core/Documentation/IBicepDocumentationGenerator.cs new file mode 100644 index 00000000000..4379fd68198 --- /dev/null +++ b/src/Bicep.Core/Documentation/IBicepDocumentationGenerator.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Bicep.Core.Semantics; + +namespace Bicep.Core.Documentation; + +/// +/// Generates documentation for Bicep modules. +/// +public interface IBicepDocumentationGenerator +{ + /// + /// Builds the deterministic, typed documentation model for the entrypoint module of the given compilation. + /// + /// A successfully-compiled module. Compilations with errors are rejected. + /// Optional string values exposed to templates. + /// The typed documentation model. + /// The compilation contains errors. + BicepDocumentationModel BuildModel(Compilation compilation, IReadOnlyDictionary? customValues = null); + + /// + /// Renders a previously-built documentation model using the built-in template or a caller-supplied template file. + /// + /// The documentation model. + /// Optional rendering settings. + /// The rendered document. + /// The template cannot be loaded or rendered. + string Render(BicepDocumentationModel model, BicepDocumentationGenerationOptions? options = null); + + /// + /// Builds the documentation model for the entrypoint module of the given compilation and renders it. + /// + /// The module compilation. + /// Optional rendering settings. + /// The rendered document. + /// The model cannot be built or rendered. + string Generate(Compilation compilation, BicepDocumentationGenerationOptions? options = null); +} diff --git a/src/Bicep.Core/Documentation/Templates/Markdown.scriban b/src/Bicep.Core/Documentation/Templates/Markdown.scriban new file mode 100644 index 00000000000..91c23ba7cf9 --- /dev/null +++ b/src/Bicep.Core/Documentation/Templates/Markdown.scriban @@ -0,0 +1,215 @@ +{{~ func render_properties(items, indent) ~}} +{{~ for item in items ~}} +{{ indent }}- `{{ item.name }}` (`{{ item.type }}`){{ if item.required }}, required{{ end }}{{ if item.secure }}, secure{{ end }}{{ if item.description }}: {{ item.description }}{{ end }} +{{~ if item.allowedValues.size > 0 ~}} +{{ indent }} - Allowed values: {{ for value in item.allowedValues }}`{{ value }}`{{ if !for.last }}, {{ end }}{{ end }} +{{~ end ~}} +{{~ if item.minValue != null ~}} +{{ indent }} - Min value: {{ item.minValue }} +{{~ end ~}} +{{~ if item.maxValue != null ~}} +{{ indent }} - Max value: {{ item.maxValue }} +{{~ end ~}} +{{~ if item.minLength != null ~}} +{{ indent }} - Min length: {{ item.minLength }} +{{~ end ~}} +{{~ if item.maxLength != null ~}} +{{ indent }} - Max length: {{ item.maxLength }} +{{~ end ~}} +{{~ if item.pattern ~}} +{{ indent }} - Pattern: `{{ item.pattern }}` +{{~ end ~}} +{{~ if item.properties.size > 0 ~}} +{{~ render_properties item.properties (indent + " ") ~}} +{{~ end ~}} +{{~ if item.discriminator ~}} +{{ indent }} - Discriminator: `{{ item.discriminator.propertyName }}` +{{~ for discriminatorCase in item.discriminator.cases ~}} +{{ indent }} - `{{ discriminatorCase.value }}`: +{{~ render_properties discriminatorCase.properties (indent + " ") ~}} +{{~ end ~}} +{{~ end ~}} +{{~ end ~}} +{{~ end ~}} +{{~ func md_cell(value) ~}} +{{~ if value == null ~}} +{{~ ret "" ~}} +{{~ end ~}} +{{~ ret (value | string.replace "\r\n" " " | string.replace "\n" " " | string.replace "|" "\\|") ~}} +{{~ end ~}} +{{~ func doc_type(item) ~}} +{{~ if item.secure && item.type == "string" ~}} +{{~ ret "securestring" ~}} +{{~ end ~}} +{{~ if item.secure && item.type == "object" ~}} +{{~ ret "secureObject" ~}} +{{~ end ~}} +{{~ ret item.type ~}} +{{~ end ~}} +# {{ module.name }} +{{~ if module.description ~}} + +{{ module.description }} +{{~ end ~}} + +## Navigation + +- [Resource Types](#resource-types) +{{~ if module.usageExamples.size > 0 ~}} +- [Usage Examples](#usage-examples) +{{~ end ~}} +- [Parameters](#parameters) +{{~ if module.exportedFunctions.size > 0 ~}} +- [Exported Functions](#exported-functions) +{{~ end ~}} +- [Outputs](#outputs) +{{~ if module.references.size > 0 ~}} +- [Cross-referenced Modules](#cross-referenced-modules) +{{~ end ~}} +{{~ if module.dataCollection ~}} +- [Data Collection](#data-collection) +{{~ end ~}} + +## Resource Types +{{~ if module.resourceTypes.size > 0 ~}} + +| Resource Type | Existing | +| :-- | :-- | +{{~ for resourceType in module.resourceTypes ~}} +| `{{ resourceType.type }}` | {{ if resourceType.existing }}Yes{{ else }}No{{ end }} | +{{~ end ~}} +{{~ else ~}} + +_No resources are declared in this module._ +{{~ end ~}} +{{~ if module.usageExamples.size > 0 ~}} + +## Usage Examples +{{~ for example in module.usageExamples ~}} + +### {{ example.name }} +{{~ if example.description ~}} + +{{ example.description }} +{{~ end ~}} + +```bicep +{{ example.contents }} +``` +{{~ end ~}} +{{~ end ~}} + +## Parameters +{{~ if module.parameters.size > 0 ~}} + +| Name | Type | Required | Description | +| :-- | :-- | :-- | :-- | +{{~ for parameter in module.parameters ~}} +| `{{ parameter.name }}` | `{{ md_cell (doc_type parameter) }}` | {{ if parameter.required }}Yes{{ else }}No{{ end }} | {{ md_cell parameter.description }} | +{{~ end ~}} +{{~ for parameter in module.parameters ~}} +{{~ if parameter.secure || parameter.defaultValue || parameter.allowedValues.size > 0 || parameter.minValue != null || parameter.maxValue != null || parameter.minLength != null || parameter.maxLength != null || parameter.pattern || parameter.properties.size > 0 || parameter.discriminator ~}} + +### `{{ parameter.name }}` +{{~ if parameter.secure ~}} + +- Secure: Yes +{{~ end ~}} +{{~ if parameter.defaultValue ~}} + +- Default value: `{{ parameter.defaultValue }}` +{{~ end ~}} +{{~ if parameter.allowedValues.size > 0 ~}} + +- Allowed values: {{ for value in parameter.allowedValues }}`{{ value }}`{{ if !for.last }}, {{ end }}{{ end }} +{{~ end ~}} +{{~ if parameter.minValue != null ~}} + +- Min value: {{ parameter.minValue }} +{{~ end ~}} +{{~ if parameter.maxValue != null ~}} + +- Max value: {{ parameter.maxValue }} +{{~ end ~}} +{{~ if parameter.minLength != null ~}} + +- Min length: {{ parameter.minLength }} +{{~ end ~}} +{{~ if parameter.maxLength != null ~}} + +- Max length: {{ parameter.maxLength }} +{{~ end ~}} +{{~ if parameter.pattern ~}} + +- Pattern: `{{ parameter.pattern }}` +{{~ end ~}} +{{~ if parameter.properties.size > 0 ~}} + +- Properties: +{{~ render_properties parameter.properties " " ~}} +{{~ end ~}} +{{~ if parameter.discriminator ~}} + +- Discriminator: `{{ parameter.discriminator.propertyName }}` +{{~ for discriminatorCase in parameter.discriminator.cases ~}} + - `{{ discriminatorCase.value }}`: +{{~ render_properties discriminatorCase.properties " " ~}} +{{~ end ~}} +{{~ end ~}} +{{~ end ~}} +{{~ end ~}} +{{~ else ~}} + +_No parameters are declared in this module._ +{{~ end ~}} +{{~ if module.exportedFunctions.size > 0 ~}} + +## Exported Functions +{{~ for exportedFunction in module.exportedFunctions ~}} + +### `{{ exportedFunction.name }}` +{{~ if exportedFunction.description ~}} + +{{ exportedFunction.description }} +{{~ end ~}} + +Returns: `{{ exportedFunction.returnType }}` +{{~ if exportedFunction.parameters.size > 0 ~}} + +| Name | Type | Description | +| :-- | :-- | :-- | +{{~ for parameter in exportedFunction.parameters ~}} +| `{{ parameter.name }}` | `{{ md_cell parameter.type }}` | {{ md_cell parameter.description }} | +{{~ end ~}} +{{~ end ~}} +{{~ end ~}} +{{~ end ~}} + +## Outputs +{{~ if module.outputs.size > 0 ~}} + +| Name | Type | Description | +| :-- | :-- | :-- | +{{~ for output in module.outputs ~}} +| `{{ output.name }}` | `{{ md_cell (doc_type output) }}` | {{ md_cell output.description }} | +{{~ end ~}} +{{~ else ~}} + +_No outputs are declared in this module._ +{{~ end ~}} +{{~ if module.references.size > 0 ~}} + +## Cross-referenced Modules + +| Symbolic Name | Path | Description | +| :-- | :-- | :-- | +{{~ for reference in module.references ~}} +| `{{ reference.symbolicName }}` | {{ if reference.path }}`{{ md_cell reference.path }}`{{ end }} | {{ md_cell reference.description }} | +{{~ end ~}} +{{~ end ~}} +{{~ if module.dataCollection ~}} + +## Data Collection + +{{ module.dataCollection.note }} +{{~ end ~}} diff --git a/src/Bicep.Core/Features/FeatureProvider.cs b/src/Bicep.Core/Features/FeatureProvider.cs index 60dbab7a562..5483dbba7f7 100644 --- a/src/Bicep.Core/Features/FeatureProvider.cs +++ b/src/Bicep.Core/Features/FeatureProvider.cs @@ -60,6 +60,8 @@ public FeatureProvider(RootConfiguration configuration, IFileExplorer fileExplor public bool AzExtensionConfigEnabled => configuration.ExperimentalFeaturesEnabled.AzExtensionConfig; + public bool DocsGenerationEnabled => configuration.ExperimentalFeaturesEnabled.DocsGeneration; + private static bool ReadBooleanEnvVar(string envVar, bool defaultValue) => bool.TryParse(Environment.GetEnvironmentVariable(envVar), out var value) ? value : defaultValue; diff --git a/src/Bicep.Core/Features/IFeatureProvider.cs b/src/Bicep.Core/Features/IFeatureProvider.cs index 562fead496f..0cec492943a 100644 --- a/src/Bicep.Core/Features/IFeatureProvider.cs +++ b/src/Bicep.Core/Features/IFeatureProvider.cs @@ -43,6 +43,8 @@ public interface IFeatureProvider bool AzExtensionConfigEnabled { get; } + bool DocsGenerationEnabled => false; + IEnumerable<(string name, bool impactsCompilation, bool usesExperimentalArmEngineFeature)> EnabledFeatureMetadata { get @@ -65,6 +67,7 @@ public interface IFeatureProvider (DeployCommandsEnabled, "Enable deploy commands", true, true), (RuntimeValuesInTagsAndSkuEnabled, "Enable runtime values in tags and SKU", true, true), (AzExtensionConfigEnabled, "Enable configuration for the built-in 'az' extension", true, true), + (DocsGenerationEnabled, "Generate module documentation", false, false), }) { if (enabled) diff --git a/src/Bicep.Core/Features/RecordBasedFeatureProvider.cs b/src/Bicep.Core/Features/RecordBasedFeatureProvider.cs index 7c9e15d308c..fa056669a61 100644 --- a/src/Bicep.Core/Features/RecordBasedFeatureProvider.cs +++ b/src/Bicep.Core/Features/RecordBasedFeatureProvider.cs @@ -28,5 +28,6 @@ public class RecordBasedFeatureProvider(ExperimentalFeaturesEnabled features) : public bool PatchEnabled => features.Patch; public bool RuntimeValuesInTagsAndSkuEnabled => features.RuntimeValuesInTagsAndSku; public bool AzExtensionConfigEnabled => features.AzExtensionConfig; + public bool DocsGenerationEnabled => features.DocsGeneration; } } diff --git a/src/Bicep.RpcClient.Tests/BicepClientUnitTests.cs b/src/Bicep.RpcClient.Tests/BicepClientUnitTests.cs index 7fce9c7e404..095026aaf15 100644 --- a/src/Bicep.RpcClient.Tests/BicepClientUnitTests.cs +++ b/src/Bicep.RpcClient.Tests/BicepClientUnitTests.cs @@ -125,6 +125,62 @@ public async Task GetFileReferences_forwards_request_to_the_expected_method() rpc.CallCount("bicep/getFileReferences").Should().Be(1); } + [TestMethod] + public async Task GenerateDocs_forwards_request_to_the_expected_method() + { + var rpc = new FakeJsonRpcClient(); + rpc.SetResponse("bicep/version", new VersionResponse("0.46.0")); + rpc.SetResponse("bicep/generateDocs", new GenerateDocsResponse([])); + using var client = new BicepClient(rpc); + + var result = await client.GenerateDocs( + new(["main.bicep"], null, null, null, null, null, NoRestore: false), + Token); + + result.Results.Should().BeEmpty(); + rpc.CallCount("bicep/generateDocs").Should().Be(1); + } + + [TestMethod] + public async Task OutputDocs_forwards_request_to_the_expected_method() + { + var rpc = new FakeJsonRpcClient(); + rpc.SetResponse("bicep/version", new VersionResponse("0.46.0")); + rpc.SetResponse( + "bicep/outputDocs", + new OutputDocsResponse(new("main.bicep", null, true, [], "# Module\n"))); + using var client = new BicepClient(rpc); + + var result = await client.OutputDocs( + new("main.bicep", null, null, null, null, NoRestore: false), + Token); + + result.Result.Contents.Should().Be("# Module\n"); + rpc.CallCount("bicep/outputDocs").Should().Be(1); + } + + [TestMethod] + public async Task Docs_methods_throw_when_cli_version_is_below_minimum() + { + var rpc = new FakeJsonRpcClient(); + rpc.SetResponse("bicep/version", new VersionResponse("0.45.0")); + using var client = new BicepClient(rpc); + + await FluentActions.Invoking(() => client.GenerateDocs( + new(["main.bicep"], null, null, null, null, null, NoRestore: false), + Token)) + .Should().ThrowAsync() + .WithMessage("*requires Bicep CLI version '0.46.0' or later*"); + await FluentActions.Invoking(() => client.OutputDocs( + new("main.bicep", null, null, null, null, NoRestore: false), + Token)) + .Should().ThrowAsync() + .WithMessage("*requires Bicep CLI version '0.46.0' or later*"); + + rpc.CallCount("bicep/generateDocs").Should().Be(0); + rpc.CallCount("bicep/outputDocs").Should().Be(0); + } + [TestMethod] public void Dispose_disposes_the_underlying_rpc_client() { diff --git a/src/Bicep.RpcClient.Tests/Files/PublicApis/Azure.Bicep.RpcClient.txt b/src/Bicep.RpcClient.Tests/Files/PublicApis/Azure.Bicep.RpcClient.txt index da79daab5ae..645ba1aa7a4 100644 --- a/src/Bicep.RpcClient.Tests/Files/PublicApis/Azure.Bicep.RpcClient.txt +++ b/src/Bicep.RpcClient.Tests/Files/PublicApis/Azure.Bicep.RpcClient.txt @@ -53,6 +53,11 @@ namespace Bicep.RpcClient "ad.")] System.Threading.Tasks.Task InitializeFromPath(string bicepCliPath, System.Threading.CancellationToken cancellationToken = default); } + public interface IBicepDocumentationClient + { + System.Threading.Tasks.Task GenerateDocs(Bicep.RpcClient.Models.GenerateDocsRequest request, System.Threading.CancellationToken cancellationToken = default); + System.Threading.Tasks.Task OutputDocs(Bicep.RpcClient.Models.OutputDocsRequest request, System.Threading.CancellationToken cancellationToken = default); + } public class PooledBicepClientFactory : Bicep.RpcClient.IBicepClientFactory, System.IDisposable { public PooledBicepClientFactory(System.Net.Http.HttpClient? httpClient = null, System.TimeSpan? inactivityInterval = default) { } @@ -103,6 +108,15 @@ namespace Bicep.RpcClient.Models public Bicep.RpcClient.Models.Range Range { get; init; } public string Source { get; init; } } + public class DocsResult : System.IEquatable + { + public DocsResult(string Path, string? OutputPath, bool Success, System.Collections.Immutable.ImmutableArray Diagnostics, string? Contents) { } + public string? Contents { get; init; } + public System.Collections.Immutable.ImmutableArray Diagnostics { get; init; } + public string? OutputPath { get; init; } + public string Path { get; init; } + public bool Success { get; init; } + } public class FormatRequest : System.IEquatable { public FormatRequest(string Path) { } @@ -113,6 +127,22 @@ namespace Bicep.RpcClient.Models public FormatResponse(string Contents) { } public string Contents { get; init; } } + public class GenerateDocsRequest : System.IEquatable + { + public GenerateDocsRequest(System.Collections.Immutable.ImmutableArray Paths, string? Preset, string? TemplateFile, string? TemplateRoot, System.Collections.Generic.Dictionary? Custom, string? OutputFile, bool NoRestore) { } + public System.Collections.Generic.Dictionary? Custom { get; init; } + public bool NoRestore { get; init; } + public string? OutputFile { get; init; } + public System.Collections.Immutable.ImmutableArray Paths { get; init; } + public string? Preset { get; init; } + public string? TemplateFile { get; init; } + public string? TemplateRoot { get; init; } + } + public class GenerateDocsResponse : System.IEquatable + { + public GenerateDocsResponse(System.Collections.Immutable.ImmutableArray Results) { } + public System.Collections.Immutable.ImmutableArray Results { get; init; } + } public class GetDeploymentGraphRequest : System.IEquatable { public GetDeploymentGraphRequest(string Path) { } @@ -219,6 +249,21 @@ namespace Bicep.RpcClient.Models public GetSnapshotResponse(string Snapshot) { } public string Snapshot { get; init; } } + public class OutputDocsRequest : System.IEquatable + { + public OutputDocsRequest(string Path, string? Preset, string? TemplateFile, string? TemplateRoot, System.Collections.Generic.Dictionary? Custom, bool NoRestore) { } + public System.Collections.Generic.Dictionary? Custom { get; init; } + public bool NoRestore { get; init; } + public string Path { get; init; } + public string? Preset { get; init; } + public string? TemplateFile { get; init; } + public string? TemplateRoot { get; init; } + } + public class OutputDocsResponse : System.IEquatable + { + public OutputDocsResponse(Bicep.RpcClient.Models.DocsResult Result) { } + public Bicep.RpcClient.Models.DocsResult Result { get; init; } + } public class Position : System.IEquatable { public Position(int Line, int Char) { } diff --git a/src/Bicep.RpcClient.Tests/PooledBicepClientFactoryTests.cs b/src/Bicep.RpcClient.Tests/PooledBicepClientFactoryTests.cs index 68f5677888f..f0a9f42eec3 100644 --- a/src/Bicep.RpcClient.Tests/PooledBicepClientFactoryTests.cs +++ b/src/Bicep.RpcClient.Tests/PooledBicepClientFactoryTests.cs @@ -283,6 +283,29 @@ await FluentActions.Invoking(() => wrapper.GetVersion(cts.Token)) wrapper.Dispose(); } + [TestMethod] + public async Task Docs_requests_are_forwarded_through_the_pool() + { + var inner = new FakeBicepClientFactory(); + using var factory = CreatePooledFactory(inner); + var wrapper = await factory.Initialize(new BicepClientConfiguration(), Token); + + var docsClient = (IBicepDocumentationClient)wrapper; + var generated = await docsClient.GenerateDocs( + new(["main.bicep"], null, null, null, null, null, NoRestore: false), + Token); + var output = await docsClient.OutputDocs( + new("main.bicep", null, null, null, null, NoRestore: false), + Token); + + generated.Results.Should().BeEmpty(); + output.Result.Success.Should().BeTrue(); + output.Result.Contents.Should().Be("# Module\n"); + inner.CreatedClients.Single().DocsRequestCount.Should().Be(2); + + wrapper.Dispose(); + } + private CancellationToken Token => TestContext.CancellationTokenSource.Token; private static PooledBicepClientFactory CreatePooledFactory(FakeBicepClientFactory inner, TimeSpan? inactivityInterval = null, TimeSpan? pollInterval = null) @@ -339,14 +362,17 @@ public Task DownloadAndInitialize(BicepClientConfiguration configu => throw new NotSupportedException(); } - private sealed class FakeBicepClient(Func? onRequest = null) : IBicepClient + private sealed class FakeBicepClient(Func? onRequest = null) : IBicepClient, IBicepDocumentationClient { public const string Version = "1.2.3"; private int disposeCount; + private int docsRequestCount; public bool IsDisposed => Volatile.Read(ref disposeCount) > 0; + public int DocsRequestCount => Volatile.Read(ref docsRequestCount); + public async Task GetVersion(CancellationToken cancellationToken = default) { if (onRequest is { }) @@ -366,6 +392,18 @@ public Task CompileParams(CompileParamsRequest request, C public Task Format(FormatRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task GenerateDocs(GenerateDocsRequest request, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref docsRequestCount); + return Task.FromResult(new GenerateDocsResponse([])); + } + + public Task OutputDocs(OutputDocsRequest request, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref docsRequestCount); + return Task.FromResult(new OutputDocsResponse(new(request.Path, null, true, [], "# Module\n"))); + } + public Task GetDeploymentGraph(GetDeploymentGraphRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); diff --git a/src/Bicep.RpcClient/BicepClient.cs b/src/Bicep.RpcClient/BicepClient.cs index 6b99e8631ee..b56dc83611e 100644 --- a/src/Bicep.RpcClient/BicepClient.cs +++ b/src/Bicep.RpcClient/BicepClient.cs @@ -14,7 +14,7 @@ namespace Bicep.RpcClient; -internal class BicepClient : IBicepClient +internal class BicepClient : IBicepClient, IBicepDocumentationClient { private readonly Process? cliProcess; private readonly IJsonRpcClient jsonRpcClient; @@ -138,6 +138,20 @@ public async Task Format(FormatRequest request, CancellationToke return await jsonRpcClient.SendRequest("bicep/format", request, cancellationToken).ConfigureAwait(false); } + /// + public async Task GenerateDocs(GenerateDocsRequest request, CancellationToken cancellationToken) + { + await EnsureMinimumVersion("0.46.0", nameof(GenerateDocs), cancellationToken).ConfigureAwait(false); + return await jsonRpcClient.SendRequest("bicep/generateDocs", request, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task OutputDocs(OutputDocsRequest request, CancellationToken cancellationToken) + { + await EnsureMinimumVersion("0.46.0", nameof(OutputDocs), cancellationToken).ConfigureAwait(false); + return await jsonRpcClient.SendRequest("bicep/outputDocs", request, cancellationToken).ConfigureAwait(false); + } + /// public Task GetDeploymentGraph(GetDeploymentGraphRequest request, CancellationToken cancellationToken) => jsonRpcClient.SendRequest("bicep/getDeploymentGraph", request, cancellationToken); diff --git a/src/Bicep.RpcClient/IBicepDocumentationClient.cs b/src/Bicep.RpcClient/IBicepDocumentationClient.cs new file mode 100644 index 00000000000..26b38989cc4 --- /dev/null +++ b/src/Bicep.RpcClient/IBicepDocumentationClient.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Threading; +using Bicep.RpcClient.Models; + +namespace Bicep.RpcClient; + +/// +/// Provides module documentation operations for compatible Bicep clients. +/// +public interface IBicepDocumentationClient +{ + /// + /// Generates documentation files for Bicep modules. + /// + /// The modules and rendering options. + /// Cancels the request. + /// One result for each requested module. + Task GenerateDocs(GenerateDocsRequest request, CancellationToken cancellationToken = default); + + /// + /// Renders documentation for one Bicep module. + /// + /// The module and rendering options. + /// Cancels the request. + /// The rendered content and diagnostics. + Task OutputDocs(OutputDocsRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/Bicep.RpcClient/Models/Models.cs b/src/Bicep.RpcClient/Models/Models.cs index bb6cec565bb..f970aaa65aa 100644 --- a/src/Bicep.RpcClient/Models/Models.cs +++ b/src/Bicep.RpcClient/Models/Models.cs @@ -281,3 +281,68 @@ public record FormatRequest( /// The formatted Bicep source code as a string. public record FormatResponse( string Contents); + +/// +/// Requests documentation files for one or more Bicep modules. +/// +/// Bicep file or module-directory paths to process. +/// The built-in preset name. Omit to use Markdown. +/// An optional custom Scriban template path. +/// An optional root directory for template includes. +/// Optional string values exposed to the template. +/// The file name written beside each module. Omit to use README.md. +/// Whether external artifact restore is skipped. +public record GenerateDocsRequest( + ImmutableArray Paths, + string? Preset, + string? TemplateFile, + string? TemplateRoot, + Dictionary? Custom, + string? OutputFile, + bool NoRestore); + +/// +/// Requests rendered documentation for one Bicep module. +/// +/// A Bicep file or module-directory path. +/// The built-in preset name. Omit to use Markdown. +/// An optional custom Scriban template path. +/// An optional root directory for template includes. +/// Optional string values exposed to the template. +/// Whether external artifact restore is skipped. +public record OutputDocsRequest( + string Path, + string? Preset, + string? TemplateFile, + string? TemplateRoot, + Dictionary? Custom, + bool NoRestore); + +/// +/// Contains documentation generation results for one module. +/// +/// The resolved module entrypoint path. +/// The written file path, or when no file was written. +/// Whether compilation, rendering, and any requested write succeeded. +/// Compiler and documentation diagnostics for the module. +/// Rendered documentation, or on failure. +public record DocsResult( + string Path, + string? OutputPath, + bool Success, + ImmutableArray Diagnostics, + string? Contents); + +/// +/// Contains documentation generation results for multiple modules. +/// +/// One result for each requested module, in request order. +public record GenerateDocsResponse( + ImmutableArray Results); + +/// +/// Contains rendered documentation for one module. +/// +/// The module result. +public record OutputDocsResponse( + DocsResult Result); diff --git a/src/Bicep.RpcClient/PooledBicepClientFactory.cs b/src/Bicep.RpcClient/PooledBicepClientFactory.cs index 23e15f9e006..4cf1efc7247 100644 --- a/src/Bicep.RpcClient/PooledBicepClientFactory.cs +++ b/src/Bicep.RpcClient/PooledBicepClientFactory.cs @@ -242,7 +242,7 @@ public async Task MakeRequest(Func CompileParams(CompileParamsRequest request, C public Task Format(FormatRequest request, CancellationToken cancellationToken = default) => MakeRequest((client, ct) => client.Format(request, ct), cancellationToken); + public Task GenerateDocs(GenerateDocsRequest request, CancellationToken cancellationToken = default) + => MakeRequest((client, ct) => GetDocumentationClient(client).GenerateDocs(request, ct), cancellationToken); + + public Task OutputDocs(OutputDocsRequest request, CancellationToken cancellationToken = default) + => MakeRequest((client, ct) => GetDocumentationClient(client).OutputDocs(request, ct), cancellationToken); + public Task GetDeploymentGraph(GetDeploymentGraphRequest request, CancellationToken cancellationToken = default) => MakeRequest((client, ct) => client.GetDeploymentGraph(request, ct), cancellationToken); @@ -291,5 +297,8 @@ public void Dispose() { disposedCts.Cancel(); } + + private static IBicepDocumentationClient GetDocumentationClient(IBicepClient client) => + (IBicepDocumentationClient)client; } } diff --git a/src/Bicep.Testing/TestFeatureProviderFactory.cs b/src/Bicep.Testing/TestFeatureProviderFactory.cs index eedbf4b9e8f..4656b2b11ac 100644 --- a/src/Bicep.Testing/TestFeatureProviderFactory.cs +++ b/src/Bicep.Testing/TestFeatureProviderFactory.cs @@ -54,5 +54,7 @@ private sealed class AssemblyVersionFeatureProvider(IFeatureProvider features, s public bool RuntimeValuesInTagsAndSkuEnabled => features.RuntimeValuesInTagsAndSkuEnabled; public bool AzExtensionConfigEnabled => features.AzExtensionConfigEnabled; + + public bool DocsGenerationEnabled => features.DocsGenerationEnabled; } } diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 7ce0bb6fe4a..69e37cbd667 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -89,6 +89,7 @@ + diff --git a/src/vscode-bicep/schemas/bicepconfig.schema.json b/src/vscode-bicep/schemas/bicepconfig.schema.json index dc4e6c9c24f..d461fb7af41 100644 --- a/src/vscode-bicep/schemas/bicepconfig.schema.json +++ b/src/vscode-bicep/schemas/bicepconfig.schema.json @@ -1045,6 +1045,10 @@ "type": "boolean", "description": "Allows you to access the 'deploy', 'what-if' and 'teardown' commands. See https://aka.ms/bicep/experimental-features#deploycommands" }, + "docsGeneration": { + "type": "boolean", + "description": "Allows you to generate module documentation with the 'bicep docs' commands. See https://aka.ms/bicep/experimental-features#docsgeneration" + }, "patch": { "type": "boolean", "description": "Enables the @patch() decorator for deploying resources using the PATCH HTTP method. This feature is restricted to Azure Policy DeployIfNotExists scenarios. See https://aka.ms/bicep/experimental-features#patch" From d1b796f36b19f67a63c0f98c6ec88e9a3a915ec1 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Fri, 14 Aug 2026 14:21:38 +0100 Subject: [PATCH 02/15] Make documentation write failures cross-platform Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../DocsCommandTests.cs | 53 +++++++++++++++---- .../JsonRpcCommandTests.cs | 18 +++++-- src/Bicep.Cli/Commands/DocsGenerateCommand.cs | 4 +- src/Bicep.Cli/Commands/JsonRpcCommand.cs | 2 +- src/Bicep.Cli/Program.cs | 1 + src/Bicep.Cli/Rpc/CliJsonRpcServer.cs | 4 +- src/Bicep.Cli/Services/DocsFileWriter.cs | 30 +++++++++++ 7 files changed, 91 insertions(+), 21 deletions(-) create mode 100644 src/Bicep.Cli/Services/DocsFileWriter.cs diff --git a/src/Bicep.Cli.IntegrationTests/DocsCommandTests.cs b/src/Bicep.Cli.IntegrationTests/DocsCommandTests.cs index d436a3f5994..d05cb80b721 100644 --- a/src/Bicep.Cli.IntegrationTests/DocsCommandTests.cs +++ b/src/Bicep.Cli.IntegrationTests/DocsCommandTests.cs @@ -11,8 +11,10 @@ using Bicep.Core.UnitTests.Features; using Bicep.Core.UnitTests.Utils; using Bicep.IO.Abstraction; +using Bicep.IO.FileSystem; using FluentAssertions; using FluentAssertions.Execution; +using Microsoft.Extensions.DependencyInjection; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -163,21 +165,27 @@ public async Task Generate_WriteFailure_ReturnsNonZero() TestContext, [ new("main.bicep", "metadata name = 'Example'"), - new("README.md", "locked"), + new("README.md", "preserve me"), ]); - await using var lockStream = new FileStream( - Path.Combine(root, "README.md"), - FileMode.Open, - FileAccess.Read, - FileShare.None); + var writer = new Mock(MockBehavior.Strict); + writer + .Setup(fileWriter => fileWriter.WriteAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new BicepException("write failed")); - var result = await Bicep(DocsEnabledSettings(), "docs", "generate", root); + var result = await Bicep( + DocsEnabledSettings(), + services => services.AddSingleton(writer.Object), + TestContext.CancellationTokenSource.Token, + "docs", + "generate", + root); result.ExitCode.Should().Be(1); - result.Stderr.Should().NotBeEmpty(); - await lockStream.DisposeAsync(); - File.ReadAllText(Path.Combine(root, "README.md")).Should().Be("locked"); - Directory.EnumerateFiles(root, "*.tmp").Should().BeEmpty(); + result.Stderr.Should().Contain("write failed"); + File.ReadAllText(Path.Combine(root, "README.md")).Should().Be("preserve me"); } [TestMethod] @@ -507,4 +515,27 @@ await FluentActions.Invoking(() => writer.WriteToFileAtomicallyAsync(outputUri, .Should().ThrowAsync() .WithMessage("denied"); } + + [TestMethod] + public async Task OutputWriter_AtomicWrite_CleansUpTemporaryFileWhenMoveFails() + { + var root = FileHelper.GetUniqueTestOutputPath(TestContext); + var outputDirectory = Path.Combine(root, "output"); + Directory.CreateDirectory(outputDirectory); + var fileSystem = new System.IO.Abstractions.FileSystem(); + var writer = new OutputWriter( + new( + new(new StringReader(string.Empty), false), + new(new StringWriter(), false), + new(new StringWriter(), false)), + fileSystem, + new FileSystemFileExplorer(fileSystem)); + + await FluentActions.Invoking(() => writer.WriteToFileAtomicallyAsync( + IOUri.FromFilePath(outputDirectory), + "contents")) + .Should().ThrowAsync(); + + Directory.EnumerateFiles(root, "*.tmp").Should().BeEmpty(); + } } diff --git a/src/Bicep.Cli.IntegrationTests/JsonRpcCommandTests.cs b/src/Bicep.Cli.IntegrationTests/JsonRpcCommandTests.cs index 99716bdd1fe..de37d3812bc 100644 --- a/src/Bicep.Cli.IntegrationTests/JsonRpcCommandTests.cs +++ b/src/Bicep.Cli.IntegrationTests/JsonRpcCommandTests.cs @@ -7,6 +7,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using Bicep.Cli.Rpc; +using Bicep.Cli.Services; using Bicep.Core.Json; using Bicep.Core.Exceptions; using Bicep.Core.UnitTests; @@ -354,10 +355,18 @@ public async Task GenerateDocs_returns_structured_write_failures() new("README.md", "preserve me"), ]); var outputFile = Path.Combine(root, "README.md"); - await using var lockStream = new FileStream(outputFile, FileMode.Open, FileAccess.Read, FileShare.None); + var writer = new Mock(MockBehavior.Strict); + writer + .Setup(fileWriter => fileWriter.WriteAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new BicepException("write failed")); await RunServerTest( - services => services.WithFeatureOverrides(new(DocsGenerationEnabled: true)), + services => services + .WithFeatureOverrides(new(DocsGenerationEnabled: true)) + .AddSingleton(writer.Object), async (client, token) => { var response = await client.GenerateDocs( @@ -367,12 +376,11 @@ await RunServerTest( response.Results.Should().ContainSingle(); response.Results[0].Success.Should().BeFalse(); response.Results[0].Diagnostics.Should().ContainSingle(diagnostic => - diagnostic.Code == "DOCS002"); + diagnostic.Code == "DOCS002" && + diagnostic.Message == "write failed"); }); - await lockStream.DisposeAsync(); File.ReadAllText(outputFile).Should().Be("preserve me"); - Directory.EnumerateFiles(root, "*.tmp").Should().BeEmpty(); } [TestMethod] diff --git a/src/Bicep.Cli/Commands/DocsGenerateCommand.cs b/src/Bicep.Cli/Commands/DocsGenerateCommand.cs index aa4cae430ab..2432ad8991e 100644 --- a/src/Bicep.Cli/Commands/DocsGenerateCommand.cs +++ b/src/Bicep.Cli/Commands/DocsGenerateCommand.cs @@ -14,7 +14,7 @@ public class DocsGenerateCommand( IOContext io, DocsModuleScanner moduleScanner, DocsCommandRunner runner, - OutputWriter writer) : ICommand + IDocsFileWriter writer) : ICommand { public async Task RunAsync(DocsGenerateArguments arguments) { @@ -45,7 +45,7 @@ public async Task RunAsync(DocsGenerateArguments arguments) try { - await writer.WriteToFileAtomicallyAsync(outputUri, result.Contents); + await writer.WriteAsync(outputUri, result.Contents); } catch (BicepException exception) { diff --git a/src/Bicep.Cli/Commands/JsonRpcCommand.cs b/src/Bicep.Cli/Commands/JsonRpcCommand.cs index a31b4f9955e..5be959c3103 100644 --- a/src/Bicep.Cli/Commands/JsonRpcCommand.cs +++ b/src/Bicep.Cli/Commands/JsonRpcCommand.cs @@ -26,7 +26,7 @@ public class JsonRpcCommand( IEnvironment environment, IBicepDocumentationGenerator documentationGenerator, DocsModuleScanner docsModuleScanner, - OutputWriter writer) : ICommand + IDocsFileWriter writer) : ICommand { public async Task RunAsync(JsonRpcArguments args, CancellationToken cancellationToken) { diff --git a/src/Bicep.Cli/Program.cs b/src/Bicep.Cli/Program.cs index 0cf2ef11149..9c6929ee1f7 100644 --- a/src/Bicep.Cli/Program.cs +++ b/src/Bicep.Cli/Program.cs @@ -206,6 +206,7 @@ private static IServiceCollection ConfigureServices(IOContext io) .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/src/Bicep.Cli/Rpc/CliJsonRpcServer.cs b/src/Bicep.Cli/Rpc/CliJsonRpcServer.cs index 7b7b62007da..c622b81875e 100644 --- a/src/Bicep.Cli/Rpc/CliJsonRpcServer.cs +++ b/src/Bicep.Cli/Rpc/CliJsonRpcServer.cs @@ -33,7 +33,7 @@ public class CliJsonRpcServer( IEnvironment environment, IBicepDocumentationGenerator documentationGenerator, DocsModuleScanner docsModuleScanner, - OutputWriter writer) : ICliJsonRpcProtocol + IDocsFileWriter writer) : ICliJsonRpcProtocol { public static IJsonRpcMessageHandler CreateMessageHandler(Stream inputStream, Stream outputStream) { @@ -345,7 +345,7 @@ public async Task GenerateDocs(GenerateDocsRequest request try { - await writer.WriteToFileAtomicallyAsync(target.OutputUri, result.Contents, cancellationToken); + await writer.WriteAsync(target.OutputUri, result.Contents, cancellationToken); results.Add(result with { OutputPath = target.OutputUri.GetFilePath() }); } catch (BicepException exception) diff --git a/src/Bicep.Cli/Services/DocsFileWriter.cs b/src/Bicep.Cli/Services/DocsFileWriter.cs new file mode 100644 index 00000000000..490aeb9fa72 --- /dev/null +++ b/src/Bicep.Cli/Services/DocsFileWriter.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Bicep.IO.Abstraction; + +namespace Bicep.Cli.Services; + +/// +/// Writes generated module documentation. +/// +public interface IDocsFileWriter +{ + /// + /// Writes a complete document to the specified file. + /// + /// The destination file. + /// The rendered document. + /// Cancels the write. + Task WriteAsync(IOUri outputUri, string contents, CancellationToken cancellationToken = default); +} + +/// +/// Writes documentation with atomic file replacement. +/// +public class DocsFileWriter(OutputWriter writer) : IDocsFileWriter +{ + /// + public Task WriteAsync(IOUri outputUri, string contents, CancellationToken cancellationToken = default) => + writer.WriteToFileAtomicallyAsync(outputUri, contents, cancellationToken); +} From 2b1a183fb6acede19ed0b0afba794cff46819fc9 Mon Sep 17 00:00:00 2001 From: Jared Holgate Date: Sat, 15 Aug 2026 10:09:39 +0100 Subject: [PATCH 03/15] Improve documentation generation robustness Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5b6b551b-dfe1-461c-8caa-e9d250df1026 --- .gitattributes | 19 +- docs/experimental-features.md | 2 +- docs/experimental/docs-commands.md | 171 ++++++++ .../DocsCommandTests.cs | 370 +++++++++++++++++- .../Comprehensive/README.expected.md | 39 +- src/Bicep.Cli.IntegrationTests/HelpTests.cs | 9 +- .../JsonRpcCommandTests.cs | 47 ++- src/Bicep.Cli.Nuget/local-tpn.txt | 29 +- src/Bicep.Cli/Commands/DocsCommand.cs | 56 ++- src/Bicep.Cli/Commands/DocsGenerateCommand.cs | 67 +++- src/Bicep.Cli/Commands/DocsOutputCommand.cs | 38 +- src/Bicep.Cli/Commands/JsonRpcCommand.cs | 2 + src/Bicep.Cli/Logging/DiagnosticLogger.cs | 29 +- src/Bicep.Cli/Rpc/CliJsonRpcServer.cs | 80 ++-- src/Bicep.Cli/Services/DocsCommandRunner.cs | 109 +++++- src/Bicep.Cli/Services/DocsModuleScanner.cs | 8 +- src/Bicep.Cli/Services/OutputWriter.cs | 37 +- src/Bicep.Cli/local-tpn.txt | 29 +- ...BicepDocumentationExampleDiscoveryTests.cs | 175 +++++++++ .../BicepDocumentationGeneratorTests.cs | 137 +++++++ ...cepDocumentationScriptModelFactoryTests.cs | 12 +- .../BicepDocumentationTypeAnalyzerTests.cs | 277 ++++++++++++- .../Documentation/Files/ExpectedMarkdown.md | 17 +- .../Features/FeatureProviderTests.cs | 43 -- .../ExperimentalFeaturesEnabled.cs | 40 -- .../BicepDocumentationExampleDiscovery.cs | 143 +++++-- .../BicepDocumentationGenerator.cs | 119 ++++-- .../BicepDocumentationParameter.cs | 1 + .../BicepDocumentationScriptModelFactory.cs | 16 + .../BicepDocumentationTemplateLoader.cs | 2 +- .../BicepDocumentationTypeAnalyzer.cs | 208 ++++++++-- .../IBicepDocumentationGenerator.cs | 18 +- .../Documentation/Templates/Markdown.scriban | 27 +- src/Bicep.Core/Features/IFeatureProvider.cs | 4 +- src/Bicep.Core/local-tpn.txt | 29 +- src/Bicep.Decompiler/local-tpn.txt | 29 +- src/Bicep.LangServer/local-tpn.txt | 29 +- src/Bicep.Local.Deploy/local-tpn.txt | 29 +- src/Bicep.McpServer.Core/local-tpn.txt | 25 ++ src/Bicep.McpServer/local-tpn.txt | 25 ++ src/Bicep.RegistryModuleTool/local-tpn.txt | 29 +- src/installer-win/local-tpn.txt | 29 +- .../local-tpn.txt | 29 +- src/vscode-bicep-notice/local-tpn.txt | 29 +- 44 files changed, 2339 insertions(+), 323 deletions(-) create mode 100644 docs/experimental/docs-commands.md diff --git a/.gitattributes b/.gitattributes index 162b4c1ddb5..7ac95c47bae 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,12 +1,13 @@ -*.bicep -text -*.bicepparam -text -*.ts text eol=lf -*.cs text eol=lf -*.sh text eol=lf -/src/Bicep.Core.Samples/Files/**/Assets/**/*.txt -text -/src/Bicep.Core.Samples/Files/baselines/*_LF/**/*.bicep eol=lf -/src/Bicep.Core.Samples/Files/baselines/*_CRLF/**/*.bicep eol=crlf -/src/Bicep.Core.Samples/Files/baselines/*_CRLF/**/*.json text eol=crlf +*.bicep -text +*.bicepparam -text +*.ts text eol=lf +*.cs text eol=lf +*.sh text eol=lf +/src/Bicep.Core.Samples/Files/**/Assets/**/*.txt -text +/src/Bicep.Core.Samples/Files/baselines/*_LF/**/*.bicep eol=lf +/src/Bicep.Core.Samples/Files/baselines/*_CRLF/**/*.bicep eol=crlf +/src/Bicep.Core.Samples/Files/baselines/*_CRLF/**/*.json text eol=crlf /src/Bicep.RpcClient.Tests/Files/PublicApis/*.txt text eol=lf /src/Bicep.Core.UnitTests/Documentation/Files/*.md text eol=lf /src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/**/*.md text eol=lf +/.gitattributes whitespace=cr-at-eol \ No newline at end of file diff --git a/docs/experimental-features.md b/docs/experimental-features.md index a0e37a4fb9c..933a02ef0de 100644 --- a/docs/experimental-features.md +++ b/docs/experimental-features.md @@ -36,7 +36,7 @@ Enables `deploy`, `what-if` and `teardown` command groups, as well as the `with` ### `docsGeneration` -Enables the `bicep docs generate` and `bicep docs output` commands for generating module documentation. +Enables the `bicep docs generate` and `bicep docs output` commands. For command and template model details, see [Generate module documentation](./experimental/docs-commands.md). ### `legacyFormatter` diff --git a/docs/experimental/docs-commands.md b/docs/experimental/docs-commands.md new file mode 100644 index 00000000000..1e2f6302f93 --- /dev/null +++ b/docs/experimental/docs-commands.md @@ -0,0 +1,171 @@ +# Generate module documentation + +The experimental `docsGeneration` feature adds commands for rendering documentation from a compiled Bicep module. + +Enable it in `bicepconfig.json`: + +```json +{ + "experimentalFeaturesEnabled": { + "docsGeneration": true + } +} +``` + +## Commands + +Generate `README.md` next to one module: + +```powershell +bicep docs generate .\main.bicep +``` + +Generate documentation for multiple module entrypoints: + +```powershell +bicep docs generate --pattern '.\modules\**\main.bicep' +``` + +Render one module to stdout without writing a file: + +```powershell +bicep docs output .\main.bicep +``` + +Both commands accept: + +- `--preset markdown` +- `--template-file ` +- `--template-root ` +- repeatable `--set key=value` +- `--no-restore` +- `--diagnostics-format default|sarif` + +`docs generate` also accepts `--pattern` and `--output-file`. The output value must be a file name without a directory or Bicep source extension. + +When a directory is supplied, the command uses its `main.bicep`. The default template root is the module directory. + +## Usage examples + +The built-in Markdown template discovers Bicep files below `examples` and `tests`. Files named `dependencies.bicep` are excluded. + +An example name is selected in this order: + +1. Literal `metadata name`. +2. The file's containing folder. +3. The file name when it is directly below `examples` or `tests`. + +Literal `metadata description` provides the description. Otherwise, leading `//` comments are used. The built-in template numbers headings so repeated display names remain unambiguous. + +## Custom templates + +Custom templates use [Scriban](https://github.com/scriban/scriban) syntax: + +```scriban +# {{ module.name }} + +{{ module.description }} + +{{ for parameter in module.parameters }} +- `{{ parameter.name }}`: {{ parameter.description }} +{{ end }} +``` + +Use includes for authored Markdown: + +```scriban +{{ include "_header.md" }} +``` + +Includes resolve from the module directory unless `--template-root` is supplied. Relative traversal such as `../shared/notes.md` is supported. + +Values supplied with `--set owner=Platform` are available as both `custom.owner` and `module.custom.owner`. + +## Template model + +The root object contains `module` and `custom`. + +| Field | Type | Description | +| :-- | :-- | :-- | +| `module.name` | string | Module name from literal `metadata name`, or the module directory name. | +| `module.description` | string or null | Module description. | +| `module.path` | string | Module entrypoint path. | +| `module.targetScope` | string | Bicep target scope. | +| `module.custom` | object | Values supplied with `--set`. | +| `module.resourceTypes` | array | Declared Azure resource types. | +| `module.parameters` | array | Module parameters and nested properties. | +| `module.outputs` | array | Module outputs. | +| `module.exportedFunctions` | array | Exported functions. | +| `module.references` | array | Referenced local modules. | +| `module.usageExamples` | array | Discovered local examples and tests. | +| `module.dataCollection` | object or null | Data collection information. | +| `custom` | object | Values supplied with `--set`. | + +### Resource types + +Each `module.resourceTypes` item contains: + +| Field | Type | +| :-- | :-- | +| `type` | string | +| `existing` | bool | + +### Parameters and nested properties + +Each `module.parameters` item and nested `properties` item contains: + +| Field | Type | Description | +| :-- | :-- | :-- | +| `name` | string | Parameter or property name. | +| `type` | string | Normalized Bicep type. | +| `required` | bool | Whether a value is required. | +| `secure` | bool | Whether the type is secure. | +| `description` | string or null | Description metadata. | +| `defaultValue` | string or null | Bicep source representation of the default. | +| `defaultValueFence` | string or null | Markdown code fence sized for the default value. | +| `allowedValues` | array | Literal allowed values. | +| `minValue` | integer or null | Minimum integer value. | +| `maxValue` | integer or null | Maximum integer value. | +| `minLength` | integer or null | Minimum string or array length. | +| `maxLength` | integer or null | Maximum string or array length. | +| `pattern` | string or null | String validation pattern. | +| `truncated` | bool | Whether recursive or deeply nested properties were omitted. | +| `properties` | array | Nested object or array-item properties. | +| `discriminator` | object or null | Discriminated object details. | + +A discriminator contains `propertyName` and `cases`. Each case contains `value` and `properties`. + +### Outputs + +Each `module.outputs` item contains `name`, `type`, `secure`, and `description`. + +### Exported functions + +Each `module.exportedFunctions` item contains `name`, `parameters`, `returnType`, and `description`. Function parameters contain `name`, `type`, and `description`. + +### References + +Each `module.references` item contains `symbolicName`, `path`, and `description`. + +### Usage examples + +Each `module.usageExamples` item contains `name`, `path`, `description`, `contents`, and a Markdown-safe `fence`. + +### Data collection + +When a Boolean parameter named `enableTelemetry` is present, `module.dataCollection` contains `enabled` and `note`. Otherwise it is null. + +## JSON-RPC + +Long-lived clients can use: + +- `bicep/generateDocs` for one or more file-oriented results. +- `bicep/outputDocs` for one stdout-oriented result. + +Each result contains the input path, optional output path, success state, diagnostics, and rendered contents. Documentation-specific errors use: + +| Code | Meaning | +| :-- | :-- | +| `DOCS001` | Invalid input, option, feature configuration, or compilation setup. | +| `DOCS002` | Output write failure. | +| `DOCS003` | Documentation model or template rendering failure. | diff --git a/src/Bicep.Cli.IntegrationTests/DocsCommandTests.cs b/src/Bicep.Cli.IntegrationTests/DocsCommandTests.cs index d05cb80b721..2b0107a787b 100644 --- a/src/Bicep.Cli.IntegrationTests/DocsCommandTests.cs +++ b/src/Bicep.Cli.IntegrationTests/DocsCommandTests.cs @@ -1,13 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.IO.Abstractions.TestingHelpers; using System.IO.Abstractions; +using System.IO.Abstractions.TestingHelpers; using System.Reflection; +using System.Text.Json; using Bicep.Cli.Arguments; using Bicep.Cli.Services; using Bicep.Core.Documentation; using Bicep.Core.Exceptions; +using Bicep.Core.Features; using Bicep.Core.UnitTests.Features; using Bicep.Core.UnitTests.Utils; using Bicep.IO.Abstraction; @@ -158,6 +160,31 @@ public async Task Generate_TemplateFailure_DoesNotOverwriteExistingOutput() File.ReadAllText(Path.Combine(root, "README.md")).Should().Be("preserve me"); } + [TestMethod] + public async Task Generate_TemplateFailureWithSarif_EmitsOneValidLog() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("main.bicep", "metadata name = 'Example'"), + new("invalid.scriban", "{{ if module.name }}"), + ]); + + var result = await Bicep( + DocsEnabledSettings(), + "docs", + "generate", + root, + "--template-file", + Path.Combine(root, "invalid.scriban"), + "--diagnostics-format", + "sarif"); + + result.ExitCode.Should().Be(1); + using var document = JsonDocument.Parse(result.Stderr); + document.RootElement.ToString().Should().ContainAll("DOCS003", "Failed to parse"); + } + [TestMethod] public async Task Generate_WriteFailure_ReturnsNonZero() { @@ -188,6 +215,35 @@ public async Task Generate_WriteFailure_ReturnsNonZero() File.ReadAllText(Path.Combine(root, "README.md")).Should().Be("preserve me"); } + [TestMethod] + public async Task Generate_WriteFailureWithSarif_EmitsOneValidLog() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [new("main.bicep", "metadata name = 'Example'")]); + var writer = new Mock(MockBehavior.Strict); + writer + .Setup(fileWriter => fileWriter.WriteAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new BicepException("write failed")); + + var result = await Bicep( + DocsEnabledSettings(), + services => services.AddSingleton(writer.Object), + TestContext.CancellationTokenSource.Token, + "docs", + "generate", + root, + "--diagnostics-format", + "sarif"); + + result.ExitCode.Should().Be(1); + using var document = JsonDocument.Parse(result.Stderr); + document.RootElement.ToString().Should().ContainAll("DOCS002", "write failed"); + } + [TestMethod] public async Task Generate_OutputFile_ChangesOnlyTheDestinationName() { @@ -273,6 +329,176 @@ public async Task Output_SarifDiagnostics_KeepStdoutEmptyOnFailure() result.Stderr.Should().ContainAll("\"runs\"", "invalidType"); } + [TestMethod] + public async Task Output_TemplateFailureWithSarif_EmitsOneValidLog() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("main.bicep", "metadata name = 'Example'"), + new("invalid.scriban", "{{ if module.name }}"), + ]); + + var result = await Bicep( + DocsEnabledSettings(), + "docs", + "output", + root, + "--template-file", + Path.Combine(root, "invalid.scriban"), + "--diagnostics-format", + "sarif"); + + result.ExitCode.Should().Be(1); + result.Stdout.Should().BeEmpty(); + using var document = JsonDocument.Parse(result.Stderr); + document.RootElement.ToString().Should().ContainAll("DOCS003", "Failed to parse"); + } + + [TestMethod] + public async Task Generate_PatternSarifDiagnostics_EmitsOneValidLog() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("valid/main.bicep", "metadata name = 'Valid'"), + new("invalid/main.bicep", "param value invalidType"), + ]); + + var result = await Bicep( + DocsEnabledSettings(), + "docs", + "generate", + "--pattern", + Path.Combine(root, "*", "main.bicep"), + "--diagnostics-format", + "sarif"); + + result.ExitCode.Should().Be(1); + result.Stdout.Should().BeEmpty(); + using var document = JsonDocument.Parse(result.Stderr); + document.RootElement.GetProperty("runs").GetArrayLength().Should().Be(1); + result.Stderr.Should().Contain("invalidType"); + result.Stderr.Should().NotContain("WARNING:"); + File.Exists(Path.Combine(root, "valid", "README.md")).Should().BeTrue(); + File.Exists(Path.Combine(root, "invalid", "README.md")).Should().BeFalse(); + } + + [TestMethod] + public async Task CommandRunner_ObservesCancellationBeforeCompilation() + { + var runner = new DocsCommandRunner(null!, null!, null!, null!, null!, null!); + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + + await FluentActions.Invoking(() => runner.RenderAsync( + IOUri.FromFilePath(Path.GetFullPath("main.bicep")), + BicepDocumentationPreset.Markdown, + null, + null, + new Dictionary(), + noRestore: false, + diagnosticsFormat: null, + cancellationToken: cancellation.Token)) + .Should().ThrowAsync(); + } + + [TestMethod] + public void DocsRenderFailure_BehavesAsAValueRecord() + { + var sourceUri = IOUri.FromFilePath(Path.GetFullPath("main.bicep")); + var result = new DocsRenderResult.Failed(sourceUri); + var clone = result with { }; + + result.Compilation.Should().BeNull(); + result.Should().Be(clone); + } + + [TestMethod] + public async Task Generate_FeatureProviderFailure_UsesTheSelectedDiagnosticsFormat() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [new("main.bicep", "metadata name = 'Example'")]); + var featureProviderFactory = new Mock(MockBehavior.Strict); + featureProviderFactory + .Setup(factory => factory.GetFeatureProvider(It.IsAny())) + .Throws(new BicepException("feature lookup failed")); + + var defaultResult = await Bicep( + DocsEnabledSettings(), + services => services.AddSingleton(featureProviderFactory.Object), + TestContext.CancellationTokenSource.Token, + "docs", + "generate", + root); + var sarifResult = await Bicep( + DocsEnabledSettings(), + services => services.AddSingleton(featureProviderFactory.Object), + TestContext.CancellationTokenSource.Token, + "docs", + "generate", + root, + "--diagnostics-format", + "sarif"); + + defaultResult.ExitCode.Should().Be(1); + defaultResult.Stderr.Should().Contain("feature lookup failed"); + sarifResult.ExitCode.Should().Be(1); + using var document = JsonDocument.Parse(sarifResult.Stderr); + document.RootElement.ToString().Should().ContainAll("DOCS001", "feature lookup failed"); + } + + [TestMethod] + public async Task Generate_CompilationSetupFailure_UsesTheSelectedDiagnosticsFormat() + { + var fileSystem = new MockFileSystem(new Dictionary + { + ["/main.bicep"] = "metadata name = 'Example'", + }); + var mainFile = IOUri.FromFilePath(fileSystem.Path.GetFullPath("/main.bicep")); + var innerExplorer = new FileSystemFileExplorer(fileSystem); + var fileExplorer = new Mock(MockBehavior.Strict); + fileExplorer + .Setup(explorer => explorer.GetDirectory(It.IsAny())) + .Returns((IOUri uri) => innerExplorer.GetDirectory(uri)); + fileExplorer + .Setup(explorer => explorer.GetFile(It.IsAny())) + .Returns((IOUri uri) => uri.Equals(mainFile) + ? throw new BicepException("compilation setup failed") + : innerExplorer.GetFile(uri)); + var featureProviderFactory = IFeatureProviderFactory.WithStaticFeatureProvider( + new RecordBasedFeatureProvider( + global::Bicep.Core.Configuration.ExperimentalFeaturesEnabled.AllDisabled with { DocsGeneration = true })); + Action registerServices = services => services + .AddSingleton(fileSystem) + .AddSingleton(fileExplorer.Object) + .AddSingleton(featureProviderFactory); + + var defaultResult = await Bicep( + DocsEnabledSettings(), + registerServices, + TestContext.CancellationTokenSource.Token, + "docs", + "generate", + "/main.bicep"); + var sarifResult = await Bicep( + DocsEnabledSettings(), + registerServices, + TestContext.CancellationTokenSource.Token, + "docs", + "generate", + "/main.bicep", + "--diagnostics-format", + "sarif"); + + defaultResult.ExitCode.Should().Be(1); + defaultResult.Stderr.Should().Contain("compilation setup failed"); + sarifResult.ExitCode.Should().Be(1); + using var document = JsonDocument.Parse(sarifResult.Stderr); + document.RootElement.ToString().Should().ContainAll("DOCS001", "compilation setup failed"); + } + [TestMethod] public async Task Commands_RequireTheExperimentalFeature() { @@ -287,6 +513,26 @@ public async Task Commands_RequireTheExperimentalFeature() result.Stderr.Should().Contain("DocsGeneration"); } + [TestMethod] + public async Task Output_FeatureDisabledWithSarif_EmitsOneValidLog() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [new("main.bicep", "metadata name = 'Example'")]); + + var result = await Bicep( + "docs", + "output", + root, + "--diagnostics-format", + "sarif"); + + result.ExitCode.Should().Be(1); + result.Stdout.Should().BeEmpty(); + using var document = JsonDocument.Parse(result.Stderr); + document.RootElement.ToString().Should().ContainAll("DOCS001", "DocsGeneration"); + } + [DataTestMethod] [DataRow("missing.bicep")] [DataRow("module.txt")] @@ -451,6 +697,8 @@ public void ModuleScanner_ValidatesResolutionEdgeCases() [DataRow("NUL.md")] [DataRow("COM1")] [DataRow("LPT9.txt")] + [DataRow("CONIN$")] + [DataRow("CONOUT$.md")] [DataRow("module.bicep")] [DataRow("module.bicepparam")] public void ModuleScanner_RejectsInvalidOutputFileNames(string outputFile) @@ -492,16 +740,22 @@ public async Task OutputWriter_AtomicWrite_ReportsUnauthorizedAccess() { var fileSystem = new Mock(MockBehavior.Strict); var file = new Mock(MockBehavior.Strict); + var path = new Mock(MockBehavior.Strict); var fileExplorer = new Mock(MockBehavior.Strict); var temporaryFile = new Mock(MockBehavior.Strict); fileSystem.SetupGet(system => system.File).Returns(file.Object); + fileSystem.SetupGet(system => system.Path).Returns(path.Object); + path.Setup(systemPath => systemPath.Combine(It.IsAny(), It.IsAny())) + .Returns((string left, string right) => Path.Combine(left, right)); + path.Setup(systemPath => systemPath.GetFileName(It.IsAny())) + .Returns((string value) => Path.GetFileName(value)); fileExplorer .Setup(explorer => explorer.GetFile(It.IsAny())) .Returns(temporaryFile.Object); temporaryFile .Setup(handle => handle.WriteAllTextAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new UnauthorizedAccessException("denied")); - file.Setup(systemFile => systemFile.Exists(It.IsAny())).Returns(false); + file.Setup(systemFile => systemFile.Delete(It.IsAny())); var writer = new OutputWriter( new( new(new StringReader(string.Empty), false), @@ -538,4 +792,116 @@ await FluentActions.Invoking(() => writer.WriteToFileAtomicallyAsync( Directory.EnumerateFiles(root, "*.tmp").Should().BeEmpty(); } + + [TestMethod] + public async Task OutputWriter_AtomicWrite_PreservesPrimaryFailureWhenCleanupAlsoFails() + { + var fileSystem = new Mock(MockBehavior.Strict); + var file = new Mock(MockBehavior.Strict); + var path = new Mock(MockBehavior.Strict); + var fileExplorer = new Mock(MockBehavior.Strict); + var temporaryFile = new Mock(MockBehavior.Strict); + fileSystem.SetupGet(system => system.File).Returns(file.Object); + fileSystem.SetupGet(system => system.Path).Returns(path.Object); + path.Setup(systemPath => systemPath.Combine(It.IsAny(), It.IsAny())) + .Returns((string left, string right) => Path.Combine(left, right)); + path.Setup(systemPath => systemPath.GetFileName(It.IsAny())) + .Returns((string value) => Path.GetFileName(value)); + fileExplorer.Setup(explorer => explorer.GetFile(It.IsAny())).Returns(temporaryFile.Object); + temporaryFile + .Setup(handle => handle.WriteAllTextAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new IOException("write failed")); + file.Setup(systemFile => systemFile.Delete(It.IsAny())).Throws(new IOException("cleanup failed")); + var writer = new OutputWriter( + new( + new(new StringReader(string.Empty), false), + new(new StringWriter(), false), + new(new StringWriter(), false)), + fileSystem.Object, + fileExplorer.Object); + + var exception = await FluentActions.Invoking(() => writer.WriteToFileAtomicallyAsync( + IOUri.FromFilePath(Path.GetFullPath("README.md")), + "contents")) + .Should().ThrowAsync() + .WithMessage("write failed"); + + exception.Which.InnerException.Should().BeOfType() + .Which.InnerExceptions.Select(inner => inner.Message) + .Should().Equal("write failed", "cleanup failed"); + } + + [TestMethod] + public async Task OutputWriter_AtomicWrite_ReportsCleanupFailureAfterSuccessfulWrite() + { + var fileSystem = new Mock(MockBehavior.Strict); + var file = new Mock(MockBehavior.Strict); + var path = new Mock(MockBehavior.Strict); + var fileExplorer = new Mock(MockBehavior.Strict); + var temporaryFile = new Mock(MockBehavior.Strict); + fileSystem.SetupGet(system => system.File).Returns(file.Object); + fileSystem.SetupGet(system => system.Path).Returns(path.Object); + path.Setup(systemPath => systemPath.Combine(It.IsAny(), It.IsAny())) + .Returns((string left, string right) => Path.Combine(left, right)); + path.Setup(systemPath => systemPath.GetFileName(It.IsAny())) + .Returns((string value) => Path.GetFileName(value)); + fileExplorer.Setup(explorer => explorer.GetFile(It.IsAny())).Returns(temporaryFile.Object); + temporaryFile + .Setup(handle => handle.WriteAllTextAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + file.Setup(systemFile => systemFile.Move(It.IsAny(), It.IsAny(), true)); + file.Setup(systemFile => systemFile.Delete(It.IsAny())).Throws(new IOException("cleanup failed")); + var writer = new OutputWriter( + new( + new(new StringReader(string.Empty), false), + new(new StringWriter(), false), + new(new StringWriter(), false)), + fileSystem.Object, + fileExplorer.Object); + + await FluentActions.Invoking(() => writer.WriteToFileAtomicallyAsync( + IOUri.FromFilePath(Path.GetFullPath("README.md")), + "contents")) + .Should().ThrowAsync() + .WithMessage("cleanup failed"); + } + + [TestMethod] + public async Task OutputWriter_AtomicWrite_CleansUpWithoutMaskingCancellation() + { + var fileSystem = new Mock(MockBehavior.Strict); + var file = new Mock(MockBehavior.Strict); + var path = new Mock(MockBehavior.Strict); + var fileExplorer = new Mock(MockBehavior.Strict); + var temporaryFile = new Mock(MockBehavior.Strict); + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + var canceled = new OperationCanceledException(cancellation.Token); + fileSystem.SetupGet(system => system.File).Returns(file.Object); + fileSystem.SetupGet(system => system.Path).Returns(path.Object); + path.Setup(systemPath => systemPath.Combine(It.IsAny(), It.IsAny())) + .Returns((string left, string right) => Path.Combine(left, right)); + path.Setup(systemPath => systemPath.GetFileName(It.IsAny())) + .Returns((string value) => Path.GetFileName(value)); + fileExplorer.Setup(explorer => explorer.GetFile(It.IsAny())).Returns(temporaryFile.Object); + temporaryFile + .Setup(handle => handle.WriteAllTextAsync(It.IsAny(), cancellation.Token)) + .ThrowsAsync(canceled); + file.Setup(systemFile => systemFile.Delete(It.IsAny())).Throws(new IOException("cleanup failed")); + var writer = new OutputWriter( + new( + new(new StringReader(string.Empty), false), + new(new StringWriter(), false), + new(new StringWriter(), false)), + fileSystem.Object, + fileExplorer.Object); + + var exception = await FluentActions.Invoking(() => writer.WriteToFileAtomicallyAsync( + IOUri.FromFilePath(Path.GetFullPath("README.md")), + "contents", + cancellation.Token)) + .Should().ThrowAsync(); + + exception.Which.Data["TemporaryFileCleanupError"].Should().Be("cleanup failed"); + } } diff --git a/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/README.expected.md b/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/README.expected.md index 4db8612eb5c..0241f51c25f 100644 --- a/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/README.expected.md +++ b/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/README.expected.md @@ -3,7 +3,6 @@ Exercises every documentation feature | with multiline details. Second line. - ## Navigation - [Resource Types](#resource-types) @@ -23,7 +22,7 @@ Second line. ## Usage Examples -### default +### Example 1: _default_ Deploys the module with its default settings. @@ -39,10 +38,9 @@ module example '../../main.bicep' = { secret: 'example' } } - ``` -### e2e +### Example 2: _restricted_ Exercises restricted network access. @@ -62,7 +60,6 @@ module test '../../../main.bicep' = { } } } - ``` ## Parameters @@ -73,7 +70,7 @@ module test '../../../main.bicep' = { | `location` | `string` | No | Deployment location. | | `names` | `array` | No | Names assigned to the deployment. | | `networkAccess` | `object` | No | Network access configuration. | -| `resourceGroupName` | `string` | Yes | Resource group \| name. Second line. | +| `resourceGroupName` | `string` | Yes | Resource group \| name. Second line. | | `retentionInDays` | `int` | No | Retention period in days. | | `secret` | `securestring` | Yes | Secret used by the child module. | | `settings` | `object` | No | Nested settings. | @@ -89,9 +86,13 @@ module test '../../../main.bicep' = { ### `names` -- Default value: `[ +- Default value: + +```bicep +[ 'default' -]` +] +``` - Min length: 1 @@ -99,16 +100,22 @@ module test '../../../main.bicep' = { ### `networkAccess` -- Default value: `{ +- Default value: + +```bicep +{ kind: 'public' -}` +} +``` - Discriminator: `kind` - `public`: - - `kind` (`'public'`), required + - `kind` (`string`), required + - Allowed values: `public` - `restricted`: - `allowedCidrs` (`array`), required: Allowed CIDR ranges. - - `kind` (`'restricted'`), required + - `kind` (`string`), required + - Allowed values: `restricted` ### `resourceGroupName` @@ -130,12 +137,16 @@ module test '../../../main.bicep' = { ### `settings` -- Default value: `{ +- Default value: + +```bicep +{ enabled: true labels: { environment: 'test' } -}` +} +``` - Properties: - `enabled` (`bool`), required: Whether the feature is enabled. diff --git a/src/Bicep.Cli.IntegrationTests/HelpTests.cs b/src/Bicep.Cli.IntegrationTests/HelpTests.cs index f6517e73a22..354c51f20c4 100644 --- a/src/Bicep.Cli.IntegrationTests/HelpTests.cs +++ b/src/Bicep.Cli.IntegrationTests/HelpTests.cs @@ -49,11 +49,17 @@ public async Task Docs_Help_ShouldSucceed_WithExpectedOutput() { groupResult.Should().Be(0); groupError.Should().BeEmpty(); - groupOutput.Should().ContainAll("docs", "generate", "output"); + groupOutput.Should().ContainAll( + "docs", + "generate", + "output", + "[Experimental]", + "experimentalFeaturesEnabled.docsGeneration"); generateResult.Should().Be(0); generateError.Should().BeEmpty(); generateOutput.Should().ContainAll( + "[Experimental]", "--preset", "--template-file", "--template-root", @@ -66,6 +72,7 @@ public async Task Docs_Help_ShouldSucceed_WithExpectedOutput() outputResult.Should().Be(0); outputError.Should().BeEmpty(); outputOutput.Should().ContainAll( + "[Experimental]", "--preset", "--template-file", "--template-root", diff --git a/src/Bicep.Cli.IntegrationTests/JsonRpcCommandTests.cs b/src/Bicep.Cli.IntegrationTests/JsonRpcCommandTests.cs index de37d3812bc..50efdeff88f 100644 --- a/src/Bicep.Cli.IntegrationTests/JsonRpcCommandTests.cs +++ b/src/Bicep.Cli.IntegrationTests/JsonRpcCommandTests.cs @@ -8,8 +8,10 @@ using System.Text.Json.Nodes; using Bicep.Cli.Rpc; using Bicep.Cli.Services; -using Bicep.Core.Json; +using Bicep.Core.Configuration; using Bicep.Core.Exceptions; +using Bicep.Core.Features; +using Bicep.Core.Json; using Bicep.Core.UnitTests; using Bicep.Core.UnitTests.Assertions; using Bicep.Core.UnitTests.Features; @@ -20,8 +22,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.VisualStudio.TestTools.UnitTesting; using Microsoft.WindowsAzure.ResourceStack.Common.Json; -using Newtonsoft.Json.Linq; using Moq; +using Newtonsoft.Json.Linq; using StreamJsonRpc; namespace Bicep.Cli.IntegrationTests; @@ -334,7 +336,12 @@ await RunServerTest( new(["/missing", "/a.bicep", "/b.bicep"], null, null, null, null, null, NoRestore: false), token); outputCollision.Results.Should().HaveCount(3); - outputCollision.Results.Should().OnlyContain(result => !result.Success); + outputCollision.Results[0].Success.Should().BeFalse(); + outputCollision.Results[1].Success.Should().BeTrue(); + outputCollision.Results[2].Success.Should().BeFalse(); + outputCollision.Results[2].Diagnostics.Should().ContainSingle(diagnostic => + diagnostic.Code == "DOCS001" && + diagnostic.Message.Contains("Multiple input modules")); var mixedResult = await client.GenerateDocs( new(["/missing", "/main.bicep"], null, null, null, null, "MIXED.md", NoRestore: false), @@ -406,7 +413,10 @@ await RunServerTest( services => services .WithFileSystem(fileSystem) .WithFileExplorer(explorer.Object) - .WithFeatureOverrides(new(DocsGenerationEnabled: true)), + .AddSingleton( + IFeatureProviderFactory.WithStaticFeatureProvider( + new RecordBasedFeatureProvider( + ExperimentalFeaturesEnabled.AllDisabled with { DocsGeneration = true }))), async (client, token) => { var response = await client.OutputDocs( @@ -420,6 +430,35 @@ await RunServerTest( }); } + [TestMethod] + public async Task OutputDocs_returns_structured_feature_provider_exceptions() + { + var fileSystem = new MockFileSystem(new Dictionary + { + ["/main.bicep"] = "metadata name = 'Example'", + }); + var featureProviderFactory = new Mock(MockBehavior.Strict); + featureProviderFactory + .Setup(factory => factory.GetFeatureProvider(It.IsAny())) + .Throws(new BicepException("feature lookup failed")); + + await RunServerTest( + services => services + .WithFileSystem(fileSystem) + .AddSingleton(featureProviderFactory.Object), + async (client, token) => + { + var response = await client.OutputDocs( + new("/main.bicep", null, null, null, null, NoRestore: false), + token); + + response.Result.Success.Should().BeFalse(); + response.Result.Diagnostics.Should().ContainSingle(diagnostic => + diagnostic.Code == "DOCS001" && + diagnostic.Message == "feature lookup failed"); + }); + } + [TestMethod] public async Task GetDeploymentGraph_returns_deployment_graph() { diff --git a/src/Bicep.Cli.Nuget/local-tpn.txt b/src/Bicep.Cli.Nuget/local-tpn.txt index 4136b3f5625..c6845de8acd 100644 --- a/src/Bicep.Cli.Nuget/local-tpn.txt +++ b/src/Bicep.Cli.Nuget/local-tpn.txt @@ -18,6 +18,34 @@ General Public License. --------------------------------------------------------- +Scriban 7.2.6 - BSD-2-Clause + +Copyright (c) 2016-2026, Alexandre Mutel +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--------------------------------------------------------- + Microsoft.Extensions.ObjectPool 5.0.10 - Apache-2.0 @@ -12363,4 +12391,3 @@ Copyright (c) .NET Foundation and Contributors OTHER --------------------------------------------------------- - diff --git a/src/Bicep.Cli/Commands/DocsCommand.cs b/src/Bicep.Cli/Commands/DocsCommand.cs index 181422bc4b4..99f2174e02d 100644 --- a/src/Bicep.Cli/Commands/DocsCommand.cs +++ b/src/Bicep.Cli/Commands/DocsCommand.cs @@ -2,16 +2,26 @@ // Licensed under the MIT License. using System.Collections.Immutable; +using Bicep.Core.Diagnostics; using Bicep.Core.Documentation; using Bicep.Core.Exceptions; +using Bicep.Core.Semantics; +using Bicep.Core.SourceGraph; +using Bicep.IO.Abstraction; namespace Bicep.Cli.Commands; public static class DocsCommand { + internal const string InputFailureCode = "DOCS001"; + internal const string WriteFailureCode = "DOCS002"; + internal const string RenderFailureCode = "DOCS003"; + internal static System.CommandLine.Command CreateCommand(CommandLineBuilderContext context) { - var command = new System.CommandLine.Command(Constants.Command.Docs, "Generates documentation for Bicep modules."); + var command = new System.CommandLine.Command( + Constants.Command.Docs, + "[Experimental] Generates documentation for Bicep modules. Requires experimentalFeaturesEnabled.docsGeneration."); command.Add(DocsGenerateCommand.CreateCommand(context)); command.Add(DocsOutputCommand.CreateCommand(context)); @@ -59,4 +69,48 @@ internal static void ValidateSetOption( throw new CommandLineException("The --set parameter expects a key=value argument."); } } + + internal static IDiagnostic CreateDiagnostic(string code, string message) => + new Diagnostic(new(0, 0), DiagnosticLevel.Error, DiagnosticSource.Compiler, code, message); + + internal static DocsDiagnostics MergeDiagnostics( + IEnumerable<(IOUri SourceUri, Compilation? Compilation, IDiagnostic? DocumentationDiagnostic)> results) + { + var byUri = new Dictionary.Builder Diagnostics)>(); + var additionalDiagnostics = ImmutableArray.CreateBuilder<(IOUri SourceUri, IDiagnostic Diagnostic)>(); + foreach (var (sourceUri, compilation, documentationDiagnostic) in results) + { + if (compilation is not null) + { + foreach (var (file, diagnostics) in compilation.GetAllDiagnosticsByBicepFile()) + { + if (!byUri.ContainsKey(file.FileHandle.Uri)) + { + byUri[file.FileHandle.Uri] = (file, diagnostics.ToBuilder()); + } + } + } + + if (documentationDiagnostic is not null) + { + if (compilation is null) + { + additionalDiagnostics.Add((sourceUri, documentationDiagnostic)); + } + else + { + var entryFile = compilation.GetEntrypointSemanticModel().SourceFile; + byUri[entryFile.FileHandle.Uri].Diagnostics.Add(documentationDiagnostic); + } + } + } + + return new( + byUri.Values.ToImmutableDictionary(item => item.File, item => item.Diagnostics.ToImmutable()), + additionalDiagnostics.ToImmutable()); + } + + internal record DocsDiagnostics( + ImmutableDictionary> ByFile, + ImmutableArray<(IOUri SourceUri, IDiagnostic Diagnostic)> Additional); } diff --git a/src/Bicep.Cli/Commands/DocsGenerateCommand.cs b/src/Bicep.Cli/Commands/DocsGenerateCommand.cs index 2432ad8991e..45090c40724 100644 --- a/src/Bicep.Cli/Commands/DocsGenerateCommand.cs +++ b/src/Bicep.Cli/Commands/DocsGenerateCommand.cs @@ -4,8 +4,11 @@ using System.CommandLine; using Bicep.Cli.Arguments; using Bicep.Cli.Helpers; +using Bicep.Cli.Logging; using Bicep.Cli.Services; using Bicep.Core.Exceptions; +using Bicep.Core.Semantics; +using Bicep.Core.SourceGraph; using Option = Bicep.Cli.Constants.Option; namespace Bicep.Cli.Commands; @@ -14,19 +17,28 @@ public class DocsGenerateCommand( IOContext io, DocsModuleScanner moduleScanner, DocsCommandRunner runner, - IDocsFileWriter writer) : ICommand + IDocsFileWriter writer, + DiagnosticLogger diagnosticLogger) : ICommand { - public async Task RunAsync(DocsGenerateArguments arguments) + public async Task RunAsync(DocsGenerateArguments arguments, CancellationToken cancellationToken = default) { var modules = moduleScanner.ResolveModules(arguments); var inputOutputPairs = moduleScanner.ResolveOutputFiles(modules, arguments.OutputFile); var templateFile = moduleScanner.ResolveOptionalFile(arguments.TemplateFile); var templateRoot = moduleScanner.ResolveOptionalDirectory(arguments.TemplateRoot); var customValues = DocsCommand.ParseCustomValues(arguments.CustomValues); + var workspace = new ActiveSourceFileSet(); + var sarifResults = new List<( + Bicep.IO.Abstraction.IOUri SourceUri, + Compilation? Compilation, + Bicep.Core.Diagnostics.IDiagnostic? DocumentationDiagnostic)>(); + var aggregateSarif = arguments.DiagnosticsFormat is DiagnosticsFormat.Sarif; + var experimentalWarningLogged = false; var hasErrors = false; foreach (var (module, outputUri) in inputOutputPairs) { + cancellationToken.ThrowIfCancellationRequested(); ArgumentHelper.ValidateBicepFile(module); var result = await runner.RenderAsync( module, @@ -35,9 +47,23 @@ public async Task RunAsync(DocsGenerateArguments arguments) templateRoot, customValues, arguments.NoRestore, - arguments.DiagnosticsFormat); + arguments.DiagnosticsFormat, + workspace, + logExperimentalWarning: !experimentalWarningLogged, + logDiagnostics: !aggregateSarif, + cancellationToken: cancellationToken); - if (!result.Success || result.Contents is null) + if (result.CompilationResult is { } compilation) + { + sarifResults.Add((result.SourceUri, compilation, result.DocumentationDiagnostic)); + experimentalWarningLogged = true; + } + else if (aggregateSarif) + { + sarifResults.Add((result.SourceUri, null, result.DocumentationDiagnostic)); + } + + if (result is not DocsRenderResult.Succeeded success) { hasErrors = true; continue; @@ -45,21 +71,40 @@ public async Task RunAsync(DocsGenerateArguments arguments) try { - await writer.WriteAsync(outputUri, result.Contents); + await writer.WriteAsync(outputUri, success.Contents, cancellationToken); } catch (BicepException exception) { - await io.Error.Writer.WriteLineAsync(exception.Message); + if (aggregateSarif) + { + sarifResults.Add(( + success.SourceUri, + success.Compilation, + DocsCommand.CreateDiagnostic(DocsCommand.WriteFailureCode, exception.Message))); + } + else + { + await io.Error.Writer.WriteLineAsync(exception.Message); + } + hasErrors = true; } } + if (aggregateSarif && sarifResults.Count > 0) + { + var diagnostics = DocsCommand.MergeDiagnostics(sarifResults); + diagnosticLogger.LogSarifDiagnostics( + diagnostics.ByFile, + diagnostics.Additional); + } + return hasErrors ? 1 : 0; } internal static System.CommandLine.Command CreateCommand(CommandLineBuilderContext context) { - var command = new System.CommandLine.Command(Constants.Command.DocsGenerate, "Generates documentation files for Bicep modules.") + var command = new System.CommandLine.Command(Constants.Command.DocsGenerate, "[Experimental] Generates documentation files for Bicep modules.") { TreatUnmatchedTokensAsErrors = true, }; @@ -78,7 +123,7 @@ internal static System.CommandLine.Command CreateCommand(CommandLineBuilderConte }; var templateRootOption = new System.CommandLine.Option(Option.TemplateRoot) { - Description = "Sets the root directory for template includes.", + Description = "Sets the root directory for template includes. Defaults to the module directory.", }; var setOption = new System.CommandLine.Option(Option.Set) { @@ -87,11 +132,11 @@ internal static System.CommandLine.Command CreateCommand(CommandLineBuilderConte }; var outputFileOption = new System.CommandLine.Option(Option.OutputFile) { - Description = "Sets the output file name. Defaults to README.md.", + Description = "Sets the output file name without a directory or Bicep source extension. Defaults to README.md.", }; var patternOption = new System.CommandLine.Option(Option.Pattern) { - Description = "Generates documentation for all files matching the glob pattern.", + Description = "Generates documentation for all files matching the glob pattern. Cannot be used with the input path.", }; var noRestoreOption = new System.CommandLine.Option(Option.NoRestore) { @@ -136,7 +181,7 @@ internal static System.CommandLine.Command CreateCommand(CommandLineBuilderConte result.GetValue(noRestoreOption), result.GetValue(diagnosticsFormatOption)); - return await context.GetCommand().RunAsync(arguments); + return await context.GetCommand().RunAsync(arguments, ct); })); return command; diff --git a/src/Bicep.Cli/Commands/DocsOutputCommand.cs b/src/Bicep.Cli/Commands/DocsOutputCommand.cs index 069f6015145..c3fd3c6a672 100644 --- a/src/Bicep.Cli/Commands/DocsOutputCommand.cs +++ b/src/Bicep.Cli/Commands/DocsOutputCommand.cs @@ -4,6 +4,7 @@ using System.CommandLine; using Bicep.Cli.Arguments; using Bicep.Cli.Helpers; +using Bicep.Cli.Logging; using Bicep.Cli.Services; using Option = Bicep.Cli.Constants.Option; @@ -12,13 +13,15 @@ namespace Bicep.Cli.Commands; public class DocsOutputCommand( IOContext io, DocsModuleScanner moduleScanner, - DocsCommandRunner runner) : ICommand + DocsCommandRunner runner, + DiagnosticLogger diagnosticLogger) : ICommand { - public async Task RunAsync(DocsOutputArguments arguments) + public async Task RunAsync(DocsOutputArguments arguments, CancellationToken cancellationToken = default) { var module = moduleScanner.ResolveModule(arguments.InputFile); ArgumentHelper.ValidateBicepFile(module); + var aggregateSarif = arguments.DiagnosticsFormat is DiagnosticsFormat.Sarif; var result = await runner.RenderAsync( module, arguments.Preset, @@ -26,20 +29,39 @@ public async Task RunAsync(DocsOutputArguments arguments) moduleScanner.ResolveOptionalDirectory(arguments.TemplateRoot), DocsCommand.ParseCustomValues(arguments.CustomValues), arguments.NoRestore, - arguments.DiagnosticsFormat); + arguments.DiagnosticsFormat, + logDiagnostics: !aggregateSarif, + cancellationToken: cancellationToken); - if (!result.Success || result.Contents is null) + if (aggregateSarif && result.CompilationResult is { } compilation) + { + var diagnostics = DocsCommand.MergeDiagnostics( + [(result.SourceUri, compilation, result.DocumentationDiagnostic)]); + diagnosticLogger.LogSarifDiagnostics( + diagnostics.ByFile, + diagnostics.Additional); + } + else if (aggregateSarif && result.DocumentationDiagnostic is { } documentationDiagnostic) + { + var diagnostics = DocsCommand.MergeDiagnostics( + [(result.SourceUri, null, documentationDiagnostic)]); + diagnosticLogger.LogSarifDiagnostics( + diagnostics.ByFile, + diagnostics.Additional); + } + + if (result is not DocsRenderResult.Succeeded success) { return 1; } - await io.Output.Writer.WriteAsync(result.Contents); + await io.Output.Writer.WriteAsync(success.Contents.AsMemory(), cancellationToken); return 0; } internal static System.CommandLine.Command CreateCommand(CommandLineBuilderContext context) { - var command = new System.CommandLine.Command(Constants.Command.DocsOutput, "Renders documentation for one Bicep module to stdout.") + var command = new System.CommandLine.Command(Constants.Command.DocsOutput, "[Experimental] Renders documentation for one Bicep module to stdout.") { TreatUnmatchedTokensAsErrors = true, }; @@ -58,7 +80,7 @@ internal static System.CommandLine.Command CreateCommand(CommandLineBuilderConte }; var templateRootOption = new System.CommandLine.Option(Option.TemplateRoot) { - Description = "Sets the root directory for template includes.", + Description = "Sets the root directory for template includes. Defaults to the module directory.", }; var setOption = new System.CommandLine.Option(Option.Set) { @@ -97,7 +119,7 @@ internal static System.CommandLine.Command CreateCommand(CommandLineBuilderConte result.GetValue(noRestoreOption), result.GetValue(diagnosticsFormatOption)); - return await context.GetCommand().RunAsync(arguments); + return await context.GetCommand().RunAsync(arguments, ct); })); return command; diff --git a/src/Bicep.Cli/Commands/JsonRpcCommand.cs b/src/Bicep.Cli/Commands/JsonRpcCommand.cs index 5be959c3103..a0e08e067b8 100644 --- a/src/Bicep.Cli/Commands/JsonRpcCommand.cs +++ b/src/Bicep.Cli/Commands/JsonRpcCommand.cs @@ -24,6 +24,7 @@ public class JsonRpcCommand( BicepCompiler compiler, InputOutputArgumentsResolver inputOutputArgumentsResolver, IEnvironment environment, + IFeatureProviderFactory featureProviderFactory, IBicepDocumentationGenerator documentationGenerator, DocsModuleScanner docsModuleScanner, IDocsFileWriter writer) : ICommand @@ -71,6 +72,7 @@ private async Task RunServer(Stream inputStream, Stream outputStream, Cancellati compiler, inputOutputArgumentsResolver, environment, + featureProviderFactory, documentationGenerator, docsModuleScanner, writer); diff --git a/src/Bicep.Cli/Logging/DiagnosticLogger.cs b/src/Bicep.Cli/Logging/DiagnosticLogger.cs index 763f9ccf4d0..26bffc3681b 100644 --- a/src/Bicep.Cli/Logging/DiagnosticLogger.cs +++ b/src/Bicep.Cli/Logging/DiagnosticLogger.cs @@ -48,7 +48,7 @@ public DiagnosticSummary LogDiagnostics(DiagnosticOptions options, ImmutableDict break; case DiagnosticsFormat.Sarif: var writer = options.SarifToStdout ? this.ioContext.Output.Writer : this.ioContext.Error.Writer; - LogSarifDiagnostics(writer, diagnosticsByBicepFile); + LogSarifDiagnostics(writer, diagnosticsByBicepFile, []); break; default: throw new NotImplementedException(); @@ -60,6 +60,17 @@ public DiagnosticSummary LogDiagnostics(DiagnosticOptions options, ImmutableDict HasErrors: hasErrors); } + internal DiagnosticSummary LogSarifDiagnostics( + ImmutableDictionary> diagnosticsByBicepFile, + ImmutableArray<(Bicep.IO.Abstraction.IOUri SourceUri, IDiagnostic Diagnostic)> diagnostics) + { + LogSarifDiagnostics(this.ioContext.Error.Writer, diagnosticsByBicepFile, diagnostics); + + return new( + diagnosticsByBicepFile.Values.SelectMany(x => x).Any(x => x.IsError()) || + diagnostics.Any(item => item.Diagnostic.IsError())); + } + private static void LogDefaultDiagnostics(ILogger logger, ImmutableDictionary> diagnosticsByBicepFile) { foreach (var (bicepFile, diagnostics) in diagnosticsByBicepFile) @@ -78,7 +89,10 @@ private static void LogDefaultDiagnostics(ILogger logger, ImmutableDictionary> diagnosticsByBicepFile) + private static void LogSarifDiagnostics( + TextWriter writer, + ImmutableDictionary> diagnosticsByBicepFile, + ImmutableArray<(Bicep.IO.Abstraction.IOUri SourceUri, IDiagnostic Diagnostic)> additionalDiagnostics) { var results = new List(); foreach (var (bicepFile, diagnostics) in diagnosticsByBicepFile) @@ -88,6 +102,7 @@ private static void LogSarifDiagnostics(TextWriter writer, ImmutableDictionary GetSarifDiagnostic(item.SourceUri, 0, 0, item.Diagnostic))); // Add the results from the run to the sarif log, serialize and write to stderr. var sarifLog = new SarifLog @@ -116,7 +131,15 @@ private static void LogSarifDiagnostics(TextWriter writer, ImmutableDictionary GenerateDocs(GenerateDocsRequest request } } - DocsTarget[] targets = []; + var targets = new List(); try { - var validInputs = validTargets.Select(target => target.InputUri).ToArray(); - var outputFiles = docsModuleScanner.ResolveOutputFiles(validInputs, outputFile) - .ToDictionary(pair => pair.InputUri, pair => pair.OutputUri); - - targets = validTargets - .Select(target => new DocsTarget( - target.Index, - target.InputUri, - outputFiles[target.InputUri])) - .ToArray(); + docsModuleScanner.ValidateOutputFileName(outputFile); + var outputUris = new HashSet(); + foreach (var target in validTargets) + { + var outputUri = target.InputUri.Resolve(outputFile); + if (!outputUris.Add(outputUri)) + { + failures[target.Index] = CreateDocsFailure( + target.RequestedPath, + "DOCS001", + $"Multiple input modules resolve to the output file '{outputUri.GetFilePath()}'."); + continue; + } + + targets.Add(new DocsTarget(target.Index, target.InputUri, outputUri)); + } } catch (BicepException exception) { @@ -318,6 +326,7 @@ public async Task GenerateDocs(GenerateDocsRequest request } var targetsByIndex = targets.ToDictionary(target => target.Index); + var workspace = new ActiveSourceFileSet(); for (var index = 0; index < request.Paths.Length; index++) { if (failures.TryGetValue(index, out var failure)) @@ -335,7 +344,8 @@ public async Task GenerateDocs(GenerateDocsRequest request request.TemplateRoot, request.Custom, request.NoRestore, - cancellationToken); + cancellationToken, + workspace); if (!result.Success || result.Contents is null) { @@ -366,7 +376,8 @@ public async Task OutputDocs(OutputDocsRequest request, Canc request.TemplateRoot, request.Custom, request.NoRestore, - cancellationToken)); + cancellationToken, + workspace: null)); private async Task RenderDocs( string path, @@ -375,7 +386,8 @@ private async Task RenderDocs( string? templateRoot, IReadOnlyDictionary? custom, bool noRestore, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + ActiveSourceFileSet? workspace = null) { IOUri inputUri; try @@ -387,7 +399,7 @@ private async Task RenderDocs( return CreateDocsFailure(path, "DOCS001", exception.Message); } - return await RenderDocs(inputUri, preset, templateFile, templateRoot, custom, noRestore, cancellationToken); + return await RenderDocs(inputUri, preset, templateFile, templateRoot, custom, noRestore, cancellationToken, workspace); } private async Task RenderDocs( @@ -397,13 +409,31 @@ private async Task RenderDocs( string? templateRoot, IReadOnlyDictionary? custom, bool noRestore, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + ActiveSourceFileSet? workspace = null) { + cancellationToken.ThrowIfCancellationRequested(); + if (!inputUri.HasBicepExtension()) { return CreateDocsFailure(inputUri.GetFilePath(), "DOCS001", $"Invalid Bicep file path: {inputUri}"); } + try + { + if (!featureProviderFactory.GetFeatureProvider(inputUri).DocsGenerationEnabled) + { + return CreateDocsFailure( + inputUri.GetFilePath(), + "DOCS001", + $"The '{nameof(Bicep.Core.Configuration.ExperimentalFeaturesEnabled.DocsGeneration)}' experimental feature must be enabled."); + } + } + catch (BicepException exception) + { + return CreateDocsFailure(inputUri.GetFilePath(), "DOCS001", exception.Message); + } + BicepDocumentationGenerationOptions options; try { @@ -421,7 +451,8 @@ private async Task RenderDocs( Compilation compilation; try { - compilation = await compiler.CreateCompilation(inputUri, skipRestore: noRestore); + compilation = await compiler.CreateCompilation(inputUri, workspace, skipRestore: noRestore); + workspace?.UpsertSourceFiles(compilation.SourceFileGrouping.SourceFiles); } catch (BicepException exception) { @@ -431,14 +462,6 @@ private async Task RenderDocs( var diagnostics = GetDiagnostics(compilation).ToImmutableArray(); var model = compilation.GetEntrypointSemanticModel(); - if (!model.Features.DocsGenerationEnabled) - { - return AddDocsFailure( - new(inputUri.GetFilePath(), null, false, diagnostics, null), - "DOCS001", - $"The '{nameof(Bicep.Core.Configuration.ExperimentalFeaturesEnabled.DocsGeneration)}' experimental feature must be enabled."); - } - if (model.HasErrors()) { return new(inputUri.GetFilePath(), null, false, diagnostics, null); @@ -446,6 +469,7 @@ private async Task RenderDocs( try { + cancellationToken.ThrowIfCancellationRequested(); return new(inputUri.GetFilePath(), null, true, diagnostics, documentationGenerator.Generate(compilation, options)); } catch (BicepDocumentationException exception) diff --git a/src/Bicep.Cli/Services/DocsCommandRunner.cs b/src/Bicep.Cli/Services/DocsCommandRunner.cs index f059db969f8..b7d114f7eb1 100644 --- a/src/Bicep.Cli/Services/DocsCommandRunner.cs +++ b/src/Bicep.Cli/Services/DocsCommandRunner.cs @@ -2,19 +2,35 @@ // Licensed under the MIT License. using Bicep.Cli.Arguments; +using Bicep.Cli.Commands; using Bicep.Cli.Helpers; using Bicep.Cli.Logging; using Bicep.Core; +using Bicep.Core.Diagnostics; using Bicep.Core.Documentation; using Bicep.Core.Exceptions; using Bicep.Core.Features; using Bicep.Core.Semantics; +using Bicep.Core.SourceGraph; using Bicep.IO.Abstraction; using Microsoft.Extensions.Logging; namespace Bicep.Cli.Services; -public record DocsRenderResult(bool Success, string? Contents); +public abstract record DocsRenderResult( + IOUri SourceUri, + Compilation? CompilationResult, + IDiagnostic? DocumentationDiagnostic) +{ + public sealed record Succeeded(IOUri SourceUri, Compilation Compilation, string Contents) + : DocsRenderResult(SourceUri, Compilation, null); + + public sealed record Failed( + IOUri SourceUri, + Compilation? Compilation = null, + IDiagnostic? DocumentationDiagnostic = null) + : DocsRenderResult(SourceUri, Compilation, DocumentationDiagnostic); +} public class DocsCommandRunner( ILogger logger, @@ -31,47 +47,102 @@ public async Task RenderAsync( IOUri? templateRoot, IReadOnlyDictionary customValues, bool noRestore, - DiagnosticsFormat? diagnosticsFormat) + DiagnosticsFormat? diagnosticsFormat, + ActiveSourceFileSet? workspace = null, + bool logExperimentalWarning = true, + bool logDiagnostics = true, + CancellationToken cancellationToken = default) { - if (!featureProviderFactory.GetFeatureProvider(inputUri).DocsGenerationEnabled) + cancellationToken.ThrowIfCancellationRequested(); + + try { - await io.Error.Writer.WriteLineAsync( - $"The '{nameof(Bicep.Core.Configuration.ExperimentalFeaturesEnabled.DocsGeneration)}' experimental feature must be enabled for \"{inputUri}\"."); - return new(false, null); + if (!featureProviderFactory.GetFeatureProvider(inputUri).DocsGenerationEnabled) + { + var message = + $"The '{nameof(Bicep.Core.Configuration.ExperimentalFeaturesEnabled.DocsGeneration)}' experimental feature must be enabled for \"{inputUri}\"."; + if (diagnosticsFormat is not DiagnosticsFormat.Sarif) + { + await io.Error.Writer.WriteLineAsync(message); + } + + return new DocsRenderResult.Failed( + inputUri, + DocumentationDiagnostic: DocsCommand.CreateDiagnostic(DocsCommand.InputFailureCode, message)); + } + } + catch (BicepException exception) + { + if (diagnosticsFormat is not DiagnosticsFormat.Sarif) + { + await io.Error.Writer.WriteLineAsync(exception.Message); + } + + return new DocsRenderResult.Failed( + inputUri, + DocumentationDiagnostic: DocsCommand.CreateDiagnostic(DocsCommand.InputFailureCode, exception.Message)); } Compilation compilation; try { - compilation = await compiler.CreateCompilation(inputUri, skipRestore: noRestore); + compilation = await compiler.CreateCompilation(inputUri, workspace, skipRestore: noRestore); + workspace?.UpsertSourceFiles(compilation.SourceFileGrouping.SourceFiles); } catch (BicepException exception) { - await io.Error.Writer.WriteLineAsync(exception.Message); - return new(false, null); + if (diagnosticsFormat is not DiagnosticsFormat.Sarif) + { + await io.Error.Writer.WriteLineAsync(exception.Message); + } + + return new DocsRenderResult.Failed( + inputUri, + DocumentationDiagnostic: DocsCommand.CreateDiagnostic(DocsCommand.InputFailureCode, exception.Message)); + } + + var shouldLogExperimentalWarning = logExperimentalWarning && diagnosticsFormat is not DiagnosticsFormat.Sarif; + if (shouldLogExperimentalWarning) + { + CommandHelper.LogExperimentalWarning(logger, compilation); } - CommandHelper.LogExperimentalWarning(logger, compilation); + var hasErrors = logDiagnostics + ? diagnosticLogger.LogDiagnostics(ArgumentHelper.GetDiagnosticOptions(diagnosticsFormat), compilation).HasErrors + : compilation.GetAllDiagnosticsByBicepFile().Values.SelectMany(diagnostics => diagnostics).Any(diagnostic => diagnostic.IsError()); + if (hasErrors) + { + return new DocsRenderResult.Failed(inputUri, compilation); + } - var summary = diagnosticLogger.LogDiagnostics(ArgumentHelper.GetDiagnosticOptions(diagnosticsFormat), compilation); - if (summary.HasErrors) + if (shouldLogExperimentalWarning) { - return new(false, null); + logger.LogWarning(string.Format( + CliResources.ExperimentalFeaturesDisclaimerMessage, + nameof(Bicep.Core.Configuration.ExperimentalFeaturesEnabled.DocsGeneration))); } - logger.LogWarning(string.Format( - CliResources.ExperimentalFeaturesDisclaimerMessage, - nameof(Bicep.Core.Configuration.ExperimentalFeaturesEnabled.DocsGeneration))); + cancellationToken.ThrowIfCancellationRequested(); try { var options = new BicepDocumentationGenerationOptions(preset, templateFile, templateRoot, customValues); - return new(true, documentationGenerator.Generate(compilation, options)); + return new DocsRenderResult.Succeeded( + inputUri, + compilation, + documentationGenerator.Generate(compilation, options, cancellationToken)); } catch (BicepDocumentationException exception) { - await io.Error.Writer.WriteLineAsync(exception.Message); - return new(false, null); + if (diagnosticsFormat is not DiagnosticsFormat.Sarif) + { + await io.Error.Writer.WriteLineAsync(exception.Message); + } + + return new DocsRenderResult.Failed( + inputUri, + compilation, + DocsCommand.CreateDiagnostic(DocsCommand.RenderFailureCode, exception.Message)); } } } diff --git a/src/Bicep.Cli/Services/DocsModuleScanner.cs b/src/Bicep.Cli/Services/DocsModuleScanner.cs index cf82bd90144..566e615dbec 100644 --- a/src/Bicep.Cli/Services/DocsModuleScanner.cs +++ b/src/Bicep.Cli/Services/DocsModuleScanner.cs @@ -67,7 +67,7 @@ public IOUri ResolveModule(string? path) throw new CommandLineException($"The template root directory \"{fullPath}\" does not exist."); } - var normalizedPath = Path.EndsInDirectorySeparator(fullPath) + var normalizedPath = fileSystem.Path.EndsInDirectorySeparator(fullPath) ? fullPath : fullPath + fileSystem.Path.DirectorySeparatorChar; @@ -115,10 +115,12 @@ outputFile is "." or ".." || } } - private static bool IsReservedWindowsFileName(string outputFile) + private bool IsReservedWindowsFileName(string outputFile) { - var name = Path.GetFileNameWithoutExtension(outputFile); + var name = fileSystem.Path.GetFileNameWithoutExtension(outputFile); return name.Equals("CON", StringComparison.OrdinalIgnoreCase) || + name.Equals("CONIN$", StringComparison.OrdinalIgnoreCase) || + name.Equals("CONOUT$", StringComparison.OrdinalIgnoreCase) || name.Equals("PRN", StringComparison.OrdinalIgnoreCase) || name.Equals("AUX", StringComparison.OrdinalIgnoreCase) || name.Equals("NUL", StringComparison.OrdinalIgnoreCase) || diff --git a/src/Bicep.Cli/Services/OutputWriter.cs b/src/Bicep.Cli/Services/OutputWriter.cs index d83c55151b7..b0e6475a562 100644 --- a/src/Bicep.Cli/Services/OutputWriter.cs +++ b/src/Bicep.Cli/Services/OutputWriter.cs @@ -178,10 +178,12 @@ public async Task WriteToFileAtomicallyAsync( { var outputPath = fileUri.GetFilePath(); var directory = fileUri.Resolve(".").GetFilePath(); - var temporaryPath = Path.Combine( + var temporaryPath = fileSystem.Path.Combine( directory, - $".{Path.GetFileName(outputPath)}.{Guid.NewGuid():N}.tmp"); + $".{fileSystem.Path.GetFileName(outputPath)}.{Guid.NewGuid():N}.tmp"); var temporaryUri = IOUri.FromFilePath(temporaryPath); + BicepException? operationException = null; + OperationCanceledException? cancellationException = null; try { @@ -190,14 +192,41 @@ public async Task WriteToFileAtomicallyAsync( } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { - throw new BicepException(exception.Message, exception); + operationException = new BicepException(exception.Message, exception); + } + catch (OperationCanceledException exception) + { + cancellationException = exception; + throw; } finally { - if (fileSystem.File.Exists(temporaryPath)) + try { fileSystem.File.Delete(temporaryPath); } + catch (Exception cleanupException) when (cleanupException is IOException or UnauthorizedAccessException) + { + if (cancellationException is not null) + { + cancellationException.Data["TemporaryFileCleanupError"] = cleanupException.Message; + } + else if (operationException is null) + { + throw new BicepException(cleanupException.Message, cleanupException); + } + else + { + operationException = new BicepException( + operationException.Message, + new AggregateException(operationException, cleanupException)); + } + } + } + + if (operationException is not null) + { + throw operationException; } } } diff --git a/src/Bicep.Cli/local-tpn.txt b/src/Bicep.Cli/local-tpn.txt index 4136b3f5625..c6845de8acd 100644 --- a/src/Bicep.Cli/local-tpn.txt +++ b/src/Bicep.Cli/local-tpn.txt @@ -18,6 +18,34 @@ General Public License. --------------------------------------------------------- +Scriban 7.2.6 - BSD-2-Clause + +Copyright (c) 2016-2026, Alexandre Mutel +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--------------------------------------------------------- + Microsoft.Extensions.ObjectPool 5.0.10 - Apache-2.0 @@ -12363,4 +12391,3 @@ Copyright (c) .NET Foundation and Contributors OTHER --------------------------------------------------------- - diff --git a/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationExampleDiscoveryTests.cs b/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationExampleDiscoveryTests.cs index cd5295de3a9..4f11e1ec560 100644 --- a/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationExampleDiscoveryTests.cs +++ b/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationExampleDiscoveryTests.cs @@ -2,10 +2,12 @@ // Licensed under the MIT License. using Bicep.Core.Documentation; +using Bicep.IO.Abstraction; using Bicep.Testing; using Bicep.Testing.IO; using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; namespace Bicep.Core.UnitTests.Documentation; @@ -34,6 +36,68 @@ public void Discover_MetadataDescription_ExtractsLiteralValue() examples[0].Description.Should().Be("From metadata."); } + [TestMethod] + public void Discover_MetadataDescription_UsesParsedEscapedAndMultilineLiteralValues() + { + var fileSet = MockFileSystemTestFileSet.Create( + ("examples/escaped/main.bicep", "metadata description = 'It\\'s ready'"), + ("examples/multiline/main.bicep", "metadata description = '''\nLine one.\nLine two.\n'''")); + + var examples = BicepDocumentationExampleDiscovery.Discover(GetModuleRoot(fileSet)); + + examples.Single(example => example.Name == "escaped").Description.Should().Be("It's ready"); + examples.Single(example => example.Name == "multiline").Description.Should().Be("Line one.\nLine two.\n"); + } + + [TestMethod] + public void Discover_CommentedOutMetadata_IsIgnored() + { + var fileSet = MockFileSystemTestFileSet.Create( + ("examples/default/main.bicep", "// metadata description = 'Not metadata.'\nparam foo string")); + + var examples = BicepDocumentationExampleDiscovery.Discover(GetModuleRoot(fileSet)); + + examples.Should().ContainSingle(); + examples[0].Description.Should().Be("metadata description = 'Not metadata.'"); + } + + [TestMethod] + public void Discover_MetadataName_OverridesFolderName() + { + var fileSet = MockFileSystemTestFileSet.Create( + ("tests/e2e/defaults/main.test.bicep", "metadata name = 'Using only defaults'")); + + var examples = BicepDocumentationExampleDiscovery.Discover(GetModuleRoot(fileSet)); + + examples.Should().ContainSingle(); + examples[0].Name.Should().Be("Using only defaults"); + } + + [TestMethod] + public void Discover_NonStringOrInterpolatedMetadata_UsesFallbacks() + { + var fileSet = MockFileSystemTestFileSet.Create( + ("examples/default/main.bicep", "metadata name = 42\nmetadata description = 'prefix-${name}'")); + + var examples = BicepDocumentationExampleDiscovery.Discover(GetModuleRoot(fileSet)); + + examples.Should().ContainSingle(); + examples[0].Name.Should().Be("default"); + examples[0].Description.Should().BeNull(); + } + + [TestMethod] + public void Discover_DuplicateStringMetadata_ThrowsDocumentationException() + { + var fileSet = MockFileSystemTestFileSet.Create( + ("examples/default/main.bicep", "metadata name = 'first'\nmetadata name = 'second'")); + + var action = () => BicepDocumentationExampleDiscovery.Discover(GetModuleRoot(fileSet)); + + action.Should().Throw() + .WithMessage("*metadata 'name' is declared more than once*"); + } + [TestMethod] public void Discover_LeadingCommentBlock_ExtractsJoinedCommentText() { @@ -69,6 +133,20 @@ public void Discover_NonBicepFilesInCategoryFolders_AreIgnored() examples.Select(e => e.RelativePath).Should().Equal("examples/default/main.bicep"); } + [TestMethod] + public void Discover_SkipPredicateExcludesFilesAndDirectories() + { + var fileSet = MockFileSystemTestFileSet.Create( + ("examples/direct.bicep", "param direct string"), + ("examples/skipped/main.bicep", "param nested string")); + + var examples = BicepDocumentationExampleDiscovery.Discover( + GetModuleRoot(fileSet), + uri => uri.Path.Contains("direct.bicep") || uri.Path.Contains("/skipped")); + + examples.Should().BeEmpty(); + } + [TestMethod] public void Discover_TestsFolderOnly_DiscoversExamplesFromTestsCategory() { @@ -80,6 +158,103 @@ public void Discover_TestsFolderOnly_DiscoversExamplesFromTestsCategory() examples[0].Name.Should().Be("e2e"); } + [TestMethod] + public void Discover_NestedSiblingTests_UseTheirContainingFolderNames() + { + var fileSet = MockFileSystemTestFileSet.Create( + ("tests/e2e/defaults/main.test.bicep", "param foo string"), + ("tests/e2e/waf-aligned/main.test.bicep", "param foo string"), + ("tests/e2e/defaults/dependencies.bicep", "param ignored string")); + + var examples = BicepDocumentationExampleDiscovery.Discover(GetModuleRoot(fileSet)); + + examples.Select(example => example.Name).Should().Equal("defaults", "waf-aligned"); + examples.Select(example => example.RelativePath).Should().NotContain(path => path.EndsWith("dependencies.bicep")); + } + + [TestMethod] + public void Discover_DuplicateDisplayNames_ArePreservedInPathOrder() + { + var fileSet = MockFileSystemTestFileSet.Create( + ("examples/first/main.bicep", "metadata name = 'same'"), + ("tests/e2e/second/main.test.bicep", "metadata name = 'Same'")); + + var examples = BicepDocumentationExampleDiscovery.Discover(GetModuleRoot(fileSet)); + + examples.Select(example => example.Name).Should().Equal("same", "Same"); + } + + [TestMethod] + public void Discover_ExampleReadFailure_ThrowsActionableDocumentationException() + { + var moduleUri = IOUri.FromFilePath(Path.GetFullPath("module")); + var moduleRoot = new Mock(MockBehavior.Strict); + var examplesRoot = new Mock(MockBehavior.Strict); + var testsRoot = new Mock(MockBehavior.Strict); + var file = new Mock(MockBehavior.Strict); + moduleRoot.SetupGet(handle => handle.Uri).Returns(moduleUri); + moduleRoot.Setup(handle => handle.GetDirectory("examples")).Returns(examplesRoot.Object); + examplesRoot.SetupGet(handle => handle.Uri).Returns(moduleUri.Resolve("examples/")); + examplesRoot.Setup(handle => handle.Exists()).Returns(true); + examplesRoot.Setup(handle => handle.EnumerateFiles("*")).Returns([file.Object]); + examplesRoot.Setup(handle => handle.EnumerateDirectories("*")).Returns([]); + file.SetupGet(handle => handle.Uri).Returns(moduleUri.Resolve("examples/main.bicep")); + file.Setup(handle => handle.ReadAllText()).Throws(new IOException("disk error")); + moduleRoot.Setup(handle => handle.GetDirectory("tests")).Returns(testsRoot.Object); + testsRoot.Setup(handle => handle.Exists()).Returns(false); + + var action = () => BicepDocumentationExampleDiscovery.Discover(moduleRoot.Object); + + action.Should().Throw() + .WithMessage("*examples*main.bicep*disk error*") + .WithInnerException(); + } + + [TestMethod] + public void Discover_DirectoryEnumerationFailure_ThrowsActionableDocumentationException() + { + var moduleUri = IOUri.FromFilePath(Path.GetFullPath("module")); + var moduleRoot = new Mock(MockBehavior.Strict); + var examplesRoot = new Mock(MockBehavior.Strict); + moduleRoot.SetupGet(handle => handle.Uri).Returns(moduleUri); + moduleRoot.Setup(handle => handle.GetDirectory("examples")).Returns(examplesRoot.Object); + examplesRoot.SetupGet(handle => handle.Uri).Returns(moduleUri.Resolve("examples/")); + examplesRoot.Setup(handle => handle.Exists()).Returns(true); + examplesRoot.Setup(handle => handle.EnumerateFiles("*")).Throws(new UnauthorizedAccessException("denied")); + + var action = () => BicepDocumentationExampleDiscovery.Discover(moduleRoot.Object); + + action.Should().Throw() + .WithMessage("*Unable to discover usage examples*denied*") + .WithInnerException(); + } + + [TestMethod] + public void Discover_ExcessiveDirectoryDepth_ThrowsActionableDocumentationException() + { + var moduleUri = IOUri.FromFilePath(Path.GetFullPath("module")); + var moduleRoot = new Mock(MockBehavior.Strict); + var directories = Enumerable.Range(0, 102) + .Select(_ => new Mock(MockBehavior.Strict)) + .ToArray(); + moduleRoot.SetupGet(handle => handle.Uri).Returns(moduleUri); + moduleRoot.Setup(handle => handle.GetDirectory("examples")).Returns(directories[0].Object); + directories[0].Setup(handle => handle.Exists()).Returns(true); + + for (var index = 0; index < directories.Length; index++) + { + directories[index].SetupGet(handle => handle.Uri).Returns(moduleUri.Resolve($"examples/{index}/")); + directories[index].Setup(handle => handle.EnumerateFiles("*")).Returns([]); + directories[index].Setup(handle => handle.EnumerateDirectories("*")) + .Returns(index + 1 < directories.Length ? [directories[index + 1].Object] : []); + } + + var action = () => BicepDocumentationExampleDiscovery.Discover(moduleRoot.Object); + + action.Should().Throw() + .WithMessage("*maximum directory depth*"); + } + [TestMethod] public void Discover_DirectFileWithUppercaseExtension_UsesTheSameRulesOnEveryPlatform() { diff --git a/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationGeneratorTests.cs b/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationGeneratorTests.cs index 4224e17552a..c294c7f3a34 100644 --- a/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationGeneratorTests.cs +++ b/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationGeneratorTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.IO.Abstractions; using System.Linq; using System.Reflection; using System.Threading.Tasks; @@ -11,6 +12,7 @@ using Bicep.Testing; using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; namespace Bicep.Core.UnitTests.Documentation; @@ -335,6 +337,141 @@ public async Task Render_CustomTemplate_SupportsIncludesAndCustomValues() rendered.Should().Be("> Header content.\n# to\nOwner: Platform Team / Platform Team\n"); } + [TestMethod] + public async Task Render_CustomTemplate_SupportsLargeRepeatedIncludesWithoutTruncation() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("param foo string = 'bar'"); + var included = new string('x', 600_000); + + compiler.FileSet.AddFile("readme.scriban", "{{ include \"_large.md\" }}{{ include \"_large.md\" }}"); + compiler.FileSet.AddFile("_large.md", included); + + var generator = compiler.GetService(); + var options = new BicepDocumentationGenerationOptions( + BicepDocumentationPreset.Markdown, + TemplateFile: compiler.FileSet.GetUri("readme.scriban"), + TemplateRoot: null, + CustomValues: null); + + var rendered = generator.Generate(result.Compilation, options); + + rendered.Should().Be(included + included + "\n"); + } + + [TestMethod] + public async Task Render_CustomTemplate_AllowsMoreThanTheScribanDefaultLoopLimit() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("param foo string = 'bar'"); + + compiler.FileSet.AddFile("readme.scriban", "{{ for i in 0..1001 }}x{{ end }}"); + + var generator = compiler.GetService(); + var options = new BicepDocumentationGenerationOptions( + BicepDocumentationPreset.Markdown, + TemplateFile: compiler.FileSet.GetUri("readme.scriban"), + TemplateRoot: null, + CustomValues: null); + + var rendered = generator.Generate(result.Compilation, options); + + rendered.Should().Be(new string('x', 1002) + "\n"); + } + + [TestMethod] + public async Task Render_BuiltInTemplate_UsesFenceLongerThanEmbeddedExampleFence() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile( + ("main.bicep", "metadata name = 'Fence example'"), + ("examples/default/main.bicep", "var markdown = '''\n````\ncontent\n````\n'''")); + + var generator = compiler.GetService(); + var rendered = generator.Generate(result.Compilation); + + rendered.Should().Contain("`````bicep\nvar markdown"); + rendered.Should().Contain("\n````\ncontent\n````\n"); + rendered.Should().Contain("\n`````\n"); + } + + [TestMethod] + public async Task Render_BuiltInTemplate_NumbersDuplicateExampleNames() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile( + ("main.bicep", "metadata name = 'Duplicate examples'"), + ("examples/first/main.bicep", "metadata name = 'same'"), + ("examples/second/main.bicep", "metadata name = 'Same'")); + + var generator = compiler.GetService(); + var rendered = generator.Generate(result.Compilation); + + rendered.Should().Contain("### Example 1: _same_"); + rendered.Should().Contain("### Example 2: _Same_"); + } + + [TestMethod] + public async Task BuildModel_NonBooleanEnableTelemetryParameter_DoesNotAddDataCollection() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("param enableTelemetry string = 'not telemetry'"); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + + model.DataCollection.Should().BeNull(); + } + + [TestMethod] + public async Task BuildModel_ReparsePointExample_IsNotRead() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile( + ("main.bicep", "metadata name = 'Safe'"), + ("examples/leak/main.bicep", "sensitive contents")); + var fileSystem = new Mock(MockBehavior.Strict); + var file = new Mock(MockBehavior.Strict); + fileSystem.SetupGet(system => system.File).Returns(file.Object); + file.Setup(systemFile => systemFile.GetAttributes(It.IsAny())) + .Returns((string path) => path.EndsWith("main.bicep", StringComparison.OrdinalIgnoreCase) + ? FileAttributes.ReparsePoint + : FileAttributes.Normal); + var generator = new BicepDocumentationGenerator(compiler.FileSet.FileExplorer, fileSystem.Object); + + var model = generator.BuildModel(result.Compilation); + + model.UsageExamples.Should().BeEmpty(); + } + + [TestMethod] + public async Task BuildModel_WithoutFileSystem_StillDiscoversExamples() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile( + ("main.bicep", "metadata name = 'Example'"), + ("examples/default/main.bicep", "metadata name = 'Default'")); + var generator = new BicepDocumentationGenerator(compiler.FileSet.FileExplorer); + + var model = generator.BuildModel(result.Compilation); + + model.UsageExamples.Should().ContainSingle(); + } + + [TestMethod] + public async Task BuildModel_ObservesCancellation() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile("param value string"); + var generator = compiler.GetService(); + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + + var action = () => generator.BuildModel(result.Compilation, cancellationToken: cancellation.Token); + + action.Should().Throw(); + } + [TestMethod] public async Task Render_CustomTemplateWithMissingInclude_ThrowsActionableBicepDocumentationException() { diff --git a/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationScriptModelFactoryTests.cs b/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationScriptModelFactoryTests.cs index 6d82df1af72..d2ab03e1ad3 100644 --- a/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationScriptModelFactoryTests.cs +++ b/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationScriptModelFactoryTests.cs @@ -30,14 +30,15 @@ public void Create_FullyPopulatedModel_ProjectsAllFieldsWithStableCamelCaseNames IsRequired: false, IsSecure: false, Description: "A parameter.", - DefaultValue: "{}", + DefaultValue: "````", AllowedValues: ["a", "b"], MinValue: 1, MaxValue: 10, MinLength: 1, MaxLength: 10, Pattern: "^[a-z]+$", - NestedProperties: [new BicepDocumentationParameter("nested", "string", true, false, null, null, [], null, null, null, null, null, [], null)], + IsTruncated: true, + NestedProperties: [new BicepDocumentationParameter("nested", "string", true, false, null, null, [], null, null, null, null, null, false, [], null)], Discriminator: new BicepDocumentationDiscriminator("type", [new BicepDocumentationDiscriminatorCase("allowAll", [])])), ], Outputs: [new BicepDocumentationOutput("out1", "string", IsSecure: true, Description: "An output.")], @@ -70,6 +71,8 @@ public void Create_FullyPopulatedModel_ProjectsAllFieldsWithStableCamelCaseNames parameter.GetSafeValue("minLength").Should().Be(1); parameter.GetSafeValue("maxLength").Should().Be(10); parameter.GetSafeValue("pattern").Should().Be("^[a-z]+$"); + parameter.GetSafeValue("truncated").Should().BeTrue(); + parameter.GetSafeValue("defaultValueFence").Should().Be("`````"); (parameter.GetSafeValue("allowedValues")!).Should().Equal("a", "b"); var nested = parameter.GetSafeValue("properties")![0] as ScriptObject; @@ -96,6 +99,7 @@ public void Create_FullyPopulatedModel_ProjectsAllFieldsWithStableCamelCaseNames var usageExample = module.GetSafeValue("usageExamples")![0] as ScriptObject; usageExample!.GetSafeValue("name").Should().Be("default"); usageExample.GetSafeValue("contents").Should().Be("// contents"); + usageExample.GetSafeValue("fence").Should().Be("```"); var dataCollection = module.GetSafeValue("dataCollection")!; dataCollection.GetSafeValue("enabled").Should().BeTrue(); @@ -112,7 +116,7 @@ public void Create_MinimalModel_ProjectsNullDataCollectionDiscriminatorAndEmptyA TargetScope: "resourceGroup", Custom: ImmutableSortedDictionary.Empty, ResourceTypes: [], - Parameters: [new BicepDocumentationParameter("p", "string", false, false, null, null, [], null, null, null, null, null, [], null)], + Parameters: [new BicepDocumentationParameter("p", "string", false, false, null, null, [], null, null, null, null, null, false, [], null)], Outputs: [], ExportedFunctions: [], References: [], @@ -128,6 +132,8 @@ public void Create_MinimalModel_ProjectsNullDataCollectionDiscriminatorAndEmptyA var parameter = module.GetSafeValue("parameters")![0] as ScriptObject; parameter!.GetSafeValue("discriminator").Should().BeNull(); + parameter.GetSafeValue("defaultValueFence").Should().BeNull(); + parameter.GetSafeValue("truncated").Should().BeFalse(); parameter.GetSafeValue("properties").Should().BeEmpty(); } } diff --git a/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationTypeAnalyzerTests.cs b/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationTypeAnalyzerTests.cs index 26b321729c0..5775068c3cf 100644 --- a/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationTypeAnalyzerTests.cs +++ b/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationTypeAnalyzerTests.cs @@ -59,10 +59,11 @@ public void BuildParameter_InternalUnionShapes_AreRepresentedDeterministically() LanguageConstants.Int, ]); var array = TypeFactory.CreateArrayType(nonLiteralUnion); + var analyzer = new BicepDocumentationTypeAnalyzer(); - var mixed = BicepDocumentationTypeAnalyzer.BuildParameter("mixed", mixedUnion, false, null, null); - var nonLiteral = BicepDocumentationTypeAnalyzer.BuildParameter("nonLiteral", nonLiteralUnion, false, null, null); - var arrayParameter = BicepDocumentationTypeAnalyzer.BuildParameter("array", array, false, null, null); + var mixed = analyzer.BuildParameter("mixed", mixedUnion, false, null, null); + var nonLiteral = analyzer.BuildParameter("nonLiteral", nonLiteralUnion, false, null, null); + var arrayParameter = analyzer.BuildParameter("array", array, false, null, null); mixed.TypeName.Should().Be("mixed"); mixed.AllowedValues.Should().Equal("1", "a"); @@ -70,6 +71,15 @@ public void BuildParameter_InternalUnionShapes_AreRepresentedDeterministically() nonLiteral.AllowedValues.Should().BeEmpty(); arrayParameter.TypeName.Should().Be("array"); arrayParameter.AllowedValues.Should().BeEmpty(); + + var singleLiteral = analyzer.BuildParameter( + "singleLiteral", + TypeFactory.CreateStringLiteralType("public"), + false, + null, + null); + singleLiteral.TypeName.Should().Be("string"); + singleLiteral.AllowedValues.Should().Equal("public"); } [TestMethod] @@ -88,13 +98,162 @@ [new NamedTypeProperty( "kind", [objectType]); - var parameter = BicepDocumentationTypeAnalyzer.BuildParameter("value", discriminated, false, null, null); + var parameter = new BicepDocumentationTypeAnalyzer().BuildParameter("value", discriminated, false, null, null); parameter.Discriminator.Should().NotBeNull(); parameter.Discriminator!.Cases.Should().ContainSingle(); parameter.Discriminator.Cases[0].Value.Should().Be("only"); } + [TestMethod] + public void BuildParameter_ReusedRootType_ReusesTheCachedAnalysis() + { + var objectType = new ObjectType( + "shared", + TypeSymbolValidationFlags.Default, + [new NamedTypeProperty("name", LanguageConstants.String)]); + var analyzer = new BicepDocumentationTypeAnalyzer(); + + var first = analyzer.BuildParameter("first", objectType, false, null, null); + var second = analyzer.BuildParameter("second", objectType, false, null, null); + + (first.NestedProperties == second.NestedProperties).Should().BeTrue(); + } + + [TestMethod] + public void BuildParameter_CyclicCompoundTypes_StopAtTheRepeatedType() + { + TypedArrayType? cyclicArray = null; + var cyclicArrayItem = new ObjectType( + "cyclicArrayItem", + TypeSymbolValidationFlags.Default, + [new NamedTypeProperty("next", new DeferredTypeReference(() => cyclicArray!))]); + cyclicArray = new TypedArrayType( + cyclicArrayItem, + TypeSymbolValidationFlags.Default); + + ObjectType? cyclicObject = null; + cyclicObject = new ObjectType( + "cyclicObject", + TypeSymbolValidationFlags.Default, + [new NamedTypeProperty("next", new DeferredTypeReference(() => cyclicObject!))]); + + DiscriminatedObjectType? cyclicDiscriminator = null; + var variant = new ObjectType( + "variant", + TypeSymbolValidationFlags.Default, + [ + new NamedTypeProperty( + "kind", + TypeFactory.CreateStringLiteralType("only"), + TypePropertyFlags.Required), + new NamedTypeProperty( + "next", + new DeferredTypeReference(() => cyclicDiscriminator!)), + ]); + cyclicDiscriminator = new DiscriminatedObjectType( + "cyclicDiscriminator", + TypeSymbolValidationFlags.Default, + "kind", + [variant]); + + var analyzer = new BicepDocumentationTypeAnalyzer(); + var arrayParameter = analyzer.BuildParameter( + "array", + cyclicArray, + false, + null, + null); + var objectParameter = analyzer.BuildParameter( + "object", + cyclicObject, + false, + null, + null); + var discriminatorParameter = analyzer.BuildParameter( + "discriminator", + cyclicDiscriminator, + false, + null, + null); + + arrayParameter.NestedProperties.Single().NestedProperties.Should().BeEmpty(); + arrayParameter.NestedProperties.Single().IsTruncated.Should().BeTrue(); + objectParameter.NestedProperties.Single().NestedProperties.Should().BeEmpty(); + objectParameter.NestedProperties.Single().IsTruncated.Should().BeTrue(); + discriminatorParameter.Discriminator!.Cases.Single() + .Properties.Single(property => property.Name == "next") + .Discriminator.Should().BeNull(); + discriminatorParameter.Discriminator.Cases.Single() + .Properties.Single(property => property.Name == "next") + .IsTruncated.Should().BeTrue(); + } + + [TestMethod] + public async Task BuildModel_RecursiveBicepType_StopsAtTheFirstRepeatedType() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile(""" + type node = { + name: string + left: node? + right: node? + } + param tree node + """); + + result.Diagnostics.Should().NotContain(diagnostic => diagnostic.Level == Bicep.Core.Diagnostics.DiagnosticLevel.Error); + + var generator = compiler.GetService(); + var tree = generator.BuildModel(result.Compilation).Parameters.Single(parameter => parameter.Name == "tree"); + + tree.NestedProperties.Select(property => property.Name).Should().Equal("left", "name", "right"); + tree.NestedProperties.Single(property => property.Name == "left").IsTruncated.Should().BeTrue(); + tree.NestedProperties.Single(property => property.Name == "right").IsTruncated.Should().BeTrue(); + } + + [TestMethod] + public void GetTypeName_Array_ReturnsPrimitiveNameWithoutExpansion() + { + new BicepDocumentationTypeAnalyzer().GetTypeName(TypeFactory.CreateStringArrayType()) + .Should().Be("array"); + } + + [TestMethod] + public void GetTypeName_ProjectsPrimitiveCompoundAndLiteralTypes() + { + var analyzer = new BicepDocumentationTypeAnalyzer(); + var objectType = new ObjectType("object", TypeSymbolValidationFlags.Default, []); + var discriminatorMember = new ObjectType( + "case", + TypeSymbolValidationFlags.Default, + [new NamedTypeProperty("kind", TypeFactory.CreateStringLiteralType("only"), TypePropertyFlags.Required)]); + var discriminated = new DiscriminatedObjectType( + "discriminated", + TypeSymbolValidationFlags.Default, + "kind", + [discriminatorMember]); + var stringUnion = new UnionType("string union", [ + TypeFactory.CreateStringLiteralType("a"), + TypeFactory.CreateStringLiteralType("b"), + ]); + var mixedUnion = new UnionType("mixed union", [ + TypeFactory.CreateStringLiteralType("a"), + TypeFactory.CreateIntegerLiteralType(1), + ]); + var nonLiteralUnion = new UnionType("string | int", [LanguageConstants.String, LanguageConstants.Int]); + + analyzer.GetTypeName(stringUnion).Should().Be("string"); + analyzer.GetTypeName(mixedUnion).Should().Be("mixed union"); + analyzer.GetTypeName(nonLiteralUnion).Should().Be("string | int"); + analyzer.GetTypeName(TypeFactory.CreateStringLiteralType("a")).Should().Be("string"); + analyzer.GetTypeName(LanguageConstants.Int).Should().Be("int"); + analyzer.GetTypeName(LanguageConstants.String).Should().Be("string"); + analyzer.GetTypeName(objectType).Should().Be("object"); + analyzer.GetTypeName(discriminated).Should().Be("object"); + analyzer.GetTypeName(LanguageConstants.Bool).Should().Be("bool"); + } + [TestMethod] public async Task BuildModel_ArrayWithLiteralUnionItemType_ProjectsAllowedValues() { @@ -111,6 +270,82 @@ public async Task BuildModel_ArrayWithLiteralUnionItemType_ProjectsAllowedValues environments.AllowedValues.Should().Equal("dev", "prod", "test"); } + [TestMethod] + public void BuildParameter_ArrayWithLiteralItem_ProjectsAllowedValue() + { + var array = TypeFactory.CreateArrayType(TypeFactory.CreateStringLiteralType("only")); + + var parameter = new BicepDocumentationTypeAnalyzer().BuildParameter("items", array, false, null, null); + + parameter.AllowedValues.Should().Equal("only"); + } + + [TestMethod] + public void BuildParameter_DeeplyNestedArrays_StopsAtTheDepthLimit() + { + TypeSymbol type = LanguageConstants.String; + for (var index = 0; index <= 20; index++) + { + type = TypeFactory.CreateArrayType(type); + } + + var parameter = new BicepDocumentationTypeAnalyzer().BuildParameter("items", type, false, null, null); + + parameter.IsTruncated.Should().BeTrue(); + } + + [TestMethod] + public void BuildParameter_BranchingTypeGraph_StopsAtTheNodeBudget() + { + TypeSymbol type = new ObjectType( + "leaf", + TypeSymbolValidationFlags.Default, + [new NamedTypeProperty("value", LanguageConstants.String)]); + for (var index = 0; index < 20; index++) + { + var childType = type; + type = new ObjectType( + $"level{index}", + TypeSymbolValidationFlags.Default, + [ + new NamedTypeProperty("left", childType), + new NamedTypeProperty("right", childType), + ]); + } + + var parameter = new BicepDocumentationTypeAnalyzer().BuildParameter("root", type, false, null, null); + var descendants = Flatten(parameter).ToArray(); + + descendants.Length.Should().BeLessThan(10_100); + descendants.Should().Contain(item => item.IsTruncated); + } + + [TestMethod] + public async Task BuildParameter_ObservesCancellation() + { + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + var analyzer = new BicepDocumentationTypeAnalyzer(cancellation.Token); + + var action = () => analyzer.BuildParameter("value", LanguageConstants.String, false, null, null); + + action.Should().Throw(); + } + + [TestMethod] + public void BuildParameter_TopLevelParametersBeyondTheNodeBudget_AreTruncated() + { + var analyzer = new BicepDocumentationTypeAnalyzer(); + BicepDocumentationParameter parameter = null!; + for (var index = 0; index <= 10_000; index++) + { + parameter = analyzer.BuildParameter($"value{index}", LanguageConstants.String, false, null, null); + } + + parameter.IsTruncated.Should().BeTrue(); + parameter.TypeName.Should().Be("string"); + } + [TestMethod] public async Task BuildModel_ArrayWithPlainItemType_HasNoAllowedValues() { @@ -123,6 +358,29 @@ public async Task BuildModel_ArrayWithPlainItemType_HasNoAllowedValues() model.Parameters.Single(p => p.Name == "names").AllowedValues.Should().BeEmpty(); } + [TestMethod] + public async Task BuildModel_ArrayOfObjects_ExpandsItemPropertiesAndAdditionalProperties() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile(""" + type item = { + name: string + *: int + } + param items item[] + """); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + var items = model.Parameters.Single(p => p.Name == "items"); + + items.NestedProperties.Select(property => property.Name).Should().Equal( + ">Any_other_property<", + "name"); + items.NestedProperties.Single(property => property.Name == "name").TypeName.Should().Be("string"); + items.NestedProperties.Single(property => property.Name == ">Any_other_property<").TypeName.Should().Be("int"); + } + [TestMethod] public async Task BuildModel_NestedObjectParameter_ExpandsPropertiesWithObjectPrimitiveTypeNameAndSecureNestedField() { @@ -177,6 +435,7 @@ public async Task BuildModel_DeeplyNestedObjectParameter_StopsExpandingBeyondMax // The 21st level (depth == MaxDepth) must stop expanding, but still reports the object primitive type name. current.TypeName.Should().Be("object"); + current.IsTruncated.Should().BeTrue(); current.NestedProperties.Should().BeEmpty(); } @@ -200,6 +459,7 @@ public async Task BuildModel_DeeplyNestedDiscriminatedUnionParameter_StopsExpand // At depth == MaxDepth, the discriminated union must stop expanding without a discriminator. current.TypeName.Should().Be("object"); + current.IsTruncated.Should().BeTrue(); current.Discriminator.Should().BeNull(); } @@ -220,6 +480,15 @@ private static string BuildDeeplyNestedObjectSource() return builder.ToString(); } + private static IEnumerable Flatten(BicepDocumentationParameter parameter) + { + yield return parameter; + foreach (var child in parameter.NestedProperties.SelectMany(Flatten)) + { + yield return child; + } + } + private static string BuildDeeplyNestedDiscriminatedSource() { var builder = new System.Text.StringBuilder(); diff --git a/src/Bicep.Core.UnitTests/Documentation/Files/ExpectedMarkdown.md b/src/Bicep.Core.UnitTests/Documentation/Files/ExpectedMarkdown.md index 7f26296ee2f..8f3cc0e4151 100644 --- a/src/Bicep.Core.UnitTests/Documentation/Files/ExpectedMarkdown.md +++ b/src/Bicep.Core.UnitTests/Documentation/Files/ExpectedMarkdown.md @@ -21,7 +21,7 @@ Creates a storage account with example telemetry and diagnostics settings. ## Usage Examples -### default +### Example 1: _default_ Deploys the module with default settings. @@ -30,7 +30,6 @@ Deploys the module with default settings. module example '../../main.bicep' = { name: 'example' } - ``` ## Parameters @@ -59,16 +58,22 @@ module example '../../main.bicep' = { ### `networkRule` -- Default value: `{ +- Default value: + +```bicep +{ type: 'allowAll' -}` +} +``` - Discriminator: `type` - `allowAll`: - - `type` (`'allowAll'`), required + - `type` (`string`), required + - Allowed values: `allowAll` - `ipRestricted`: - `allowedIpRanges` (`array`), required: Allowed IP ranges in CIDR notation. - - `type` (`'ipRestricted'`), required + - `type` (`string`), required + - Allowed values: `ipRestricted` ### `retentionInDays` diff --git a/src/Bicep.Core.UnitTests/Features/FeatureProviderTests.cs b/src/Bicep.Core.UnitTests/Features/FeatureProviderTests.cs index 12eca1c493c..9d7d011b687 100644 --- a/src/Bicep.Core.UnitTests/Features/FeatureProviderTests.cs +++ b/src/Bicep.Core.UnitTests/Features/FeatureProviderTests.cs @@ -88,48 +88,5 @@ public void DocsGeneration_feature_is_exposed_by_feature_providers() assemblyVersionFactory.GetFeatureProvider(sourceFileUri) .DocsGenerationEnabled.Should().BeTrue(); - IFeatureProvider legacyProvider = new LegacyFeatureProvider(); - legacyProvider.DocsGenerationEnabled.Should().BeFalse(); - - var legacyConfiguration = new ExperimentalFeaturesEnabled( - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false); - legacyConfiguration.DocsGeneration.Should().BeFalse(); - } - - private sealed class LegacyFeatureProvider : IFeatureProvider - { - public string AssemblyVersion => throw new NotImplementedException(); - public Bicep.IO.Abstraction.IDirectoryHandle CacheRootDirectory => throw new NotImplementedException(); - public bool OciEnabled => false; - public bool SymbolicNameCodegenEnabled => false; - public bool ResourceTypedParamsAndOutputsEnabled => false; - public bool SourceMappingEnabled => false; - public bool LegacyFormatterEnabled => false; - public bool TestFrameworkEnabled => false; - public bool AssertsEnabled => false; - public bool WaitUntilEnabled => false; - public bool LocalDeployEnabled => false; - public bool ResourceInfoCodegenEnabled => false; - public bool ModuleExtensionConfigsEnabled => false; - public bool UserDefinedConstraintsEnabled => false; - public bool DeployCommandsEnabled => false; - public bool PatchEnabled => false; - public bool RuntimeValuesInTagsAndSkuEnabled => false; - public bool AzExtensionConfigEnabled => false; } } diff --git a/src/Bicep.Core/Configuration/ExperimentalFeaturesEnabled.cs b/src/Bicep.Core/Configuration/ExperimentalFeaturesEnabled.cs index 25ec4f20821..3c92ce9c3e6 100644 --- a/src/Bicep.Core/Configuration/ExperimentalFeaturesEnabled.cs +++ b/src/Bicep.Core/Configuration/ExperimentalFeaturesEnabled.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. using System.Text.Json; -using System.Text.Json.Serialization; using Bicep.Core.Extensions; using Bicep.Core.Features; using Bicep.Core.Json; @@ -10,7 +9,6 @@ namespace Bicep.Core.Configuration; -[method: JsonConstructor] public record ExperimentalFeaturesEnabled( bool OciEnabled, bool SymbolicNameCodegen, @@ -30,44 +28,6 @@ public record ExperimentalFeaturesEnabled( bool AzExtensionConfig, bool DocsGeneration) { - public ExperimentalFeaturesEnabled( - bool OciEnabled, - bool SymbolicNameCodegen, - bool ResourceTypedParamsAndOutputs, - bool SourceMapping, - bool LegacyFormatter, - bool TestFramework, - bool Assertions, - bool WaitUntil, - bool LocalDeploy, - bool ResourceInfoCodegen, - bool ModuleExtensionConfigs, - bool UserDefinedConstraints, - bool DeployCommands, - bool Patch, - bool RuntimeValuesInTagsAndSku, - bool AzExtensionConfig) - : this( - OciEnabled, - SymbolicNameCodegen, - ResourceTypedParamsAndOutputs, - SourceMapping, - LegacyFormatter, - TestFramework, - Assertions, - WaitUntil, - LocalDeploy, - ResourceInfoCodegen, - ModuleExtensionConfigs, - UserDefinedConstraints, - DeployCommands, - Patch, - RuntimeValuesInTagsAndSku, - AzExtensionConfig, - DocsGeneration: false) - { - } - public static ExperimentalFeaturesEnabled Bind(JsonElement element) => element.ToNonNullObject(); diff --git a/src/Bicep.Core/Documentation/BicepDocumentationExampleDiscovery.cs b/src/Bicep.Core/Documentation/BicepDocumentationExampleDiscovery.cs index ece8a0e57e0..2a52d69150e 100644 --- a/src/Bicep.Core/Documentation/BicepDocumentationExampleDiscovery.cs +++ b/src/Bicep.Core/Documentation/BicepDocumentationExampleDiscovery.cs @@ -2,55 +2,117 @@ // Licensed under the MIT License. using System.Collections.Immutable; -using System.Text.RegularExpressions; +using Bicep.Core.Parsing; +using Bicep.Core.Syntax; using Bicep.IO.Abstraction; namespace Bicep.Core.Documentation; -internal static partial class BicepDocumentationExampleDiscovery +internal static class BicepDocumentationExampleDiscovery { private static readonly ImmutableArray CategoryFolderNames = ["examples", "tests"]; + private const int MaxDirectoryDepth = 100; - public static ImmutableArray Discover(IDirectoryHandle moduleRoot) + public static ImmutableArray Discover( + IDirectoryHandle moduleRoot, + Func? shouldSkip = null) { - var examples = ImmutableArray.CreateBuilder(); + try + { + var examples = ImmutableArray.CreateBuilder(); - foreach (var categoryFolderName in CategoryFolderNames) + foreach (var categoryFolderName in CategoryFolderNames) + { + var categoryRoot = moduleRoot.GetDirectory(categoryFolderName); + if (!categoryRoot.Exists()) + { + continue; + } + + foreach (var file in EnumerateBicepFiles(categoryRoot, categoryFolderName, shouldSkip)) + { + var relativePath = file.Uri.GetPathRelativeTo(moduleRoot.Uri); + string contents; + try + { + contents = file.ReadAllText(); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + throw new BicepDocumentationException($"Unable to read usage example '{file.Uri}': {exception.Message}", exception); + } + + var metadata = GetStringMetadata(contents); + var name = metadata.GetValueOrDefault("name") ?? GetExampleName(categoryRoot.Uri, file.Uri); + + examples.Add(new BicepDocumentationUsageExample( + name, + relativePath, + metadata.GetValueOrDefault("description") ?? TryGetLeadingComment(contents), + contents.TrimEnd())); + } + } + + return BicepDocumentationOrdering.SortByName(examples.ToImmutable(), e => e.RelativePath); + } + catch (BicepDocumentationException) { - var categoryRoot = moduleRoot.GetDirectory(categoryFolderName); - if (!categoryRoot.Exists()) + throw; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + throw new BicepDocumentationException( + $"Unable to discover usage examples under '{moduleRoot.Uri}': {exception.Message}", + exception); + } + } + + private static IEnumerable EnumerateBicepFiles( + IDirectoryHandle directory, + string categoryFolderName, + Func? shouldSkip) + { + var pending = new Stack<(IDirectoryHandle Directory, int Depth)>(); + pending.Push((directory, 0)); + + while (pending.TryPop(out var current)) + { + if (current.Depth > MaxDirectoryDepth) { - continue; + throw new BicepDocumentationException( + $"Usage example discovery exceeded the maximum directory depth of {MaxDirectoryDepth} under '{directory.Uri}'."); } - foreach (var file in EnumerateBicepFiles(categoryRoot)) + foreach (var file in current.Directory.EnumerateFiles("*") + .Where(file => + shouldSkip?.Invoke(file.Uri) != true && + IsExampleEntrypoint(file, categoryFolderName, current.Depth))) { - var relativePath = file.Uri.GetPathRelativeTo(moduleRoot.Uri); - var name = GetExampleName(categoryRoot.Uri, file.Uri); - var contents = file.ReadAllText(); + yield return file; + } - examples.Add(new BicepDocumentationUsageExample(name, relativePath, TryGetDescription(contents), contents)); + foreach (var subdirectory in current.Directory.EnumerateDirectories("*")) + { + if (shouldSkip?.Invoke(subdirectory.Uri) != true) + { + pending.Push((subdirectory, current.Depth + 1)); + } } } - - return BicepDocumentationOrdering.SortByName(examples.ToImmutable(), e => e.RelativePath); } - private static IEnumerable EnumerateBicepFiles(IDirectoryHandle directory) + private static bool IsExampleEntrypoint(IFileHandle file, string categoryFolderName, int depth) { - foreach (var file in directory.EnumerateFiles("*") - .Where(file => file.Uri.Path.EndsWith(".bicep", StringComparison.OrdinalIgnoreCase))) + var fileName = file.Uri.GetFileName(); + if (!fileName.EndsWith(".bicep", StringComparison.OrdinalIgnoreCase) || + fileName.StartsWith("dependencies", StringComparison.OrdinalIgnoreCase)) { - yield return file; + return false; } - foreach (var subdirectory in directory.EnumerateDirectories("*")) - { - foreach (var file in EnumerateBicepFiles(subdirectory)) - { - yield return file; - } - } + return categoryFolderName.Equals("tests", StringComparison.OrdinalIgnoreCase) + ? fileName.EndsWith(".test.bicep", StringComparison.OrdinalIgnoreCase) + : depth == 0 || fileName.Equals("main.bicep", StringComparison.OrdinalIgnoreCase); } private static string GetExampleName(IOUri categoryRoot, IOUri file) @@ -60,7 +122,7 @@ private static string GetExampleName(IOUri categoryRoot, IOUri file) if (segments.Length > 1) { - return segments[0]; + return segments[^2]; } var fileName = segments[^1]; @@ -68,15 +130,29 @@ private static string GetExampleName(IOUri categoryRoot, IOUri file) return fileName[..^".bicep".Length]; } - // Avoids a full compile: uses a literal `metadata description = '...'` if present, else leading `//` comments. - private static string? TryGetDescription(string contents) + private static ImmutableDictionary GetStringMetadata(string contents) { - var match = MetadataDescriptionPattern().Match(contents); - if (match.Success) + var metadataValues = ImmutableDictionary.CreateBuilder(LanguageConstants.IdentifierComparer); + foreach (var metadata in new Parser(contents).Program().Declarations.OfType()) { - return match.Groups[1].Value; + if (metadata.Value is not StringSyntax stringSyntax || + stringSyntax.TryGetLiteralValue() is not { } value) + { + continue; + } + + if (!metadataValues.TryAdd(metadata.Name.IdentifierName, value)) + { + throw new BicepDocumentationException( + $"Usage example metadata '{metadata.Name.IdentifierName}' is declared more than once."); + } } + return metadataValues.ToImmutable(); + } + + private static string? TryGetLeadingComment(string contents) + { var leadingComment = contents .ReplaceLineEndings("\n") .Split('\n') @@ -87,7 +163,4 @@ private static string GetExampleName(IOUri categoryRoot, IOUri file) return leadingComment.Length > 0 ? string.Join(' ', leadingComment) : null; } - - [GeneratedRegex("""metadata\s+description\s*=\s*'((?:[^'\\]|\\.)*)'""")] - private static partial Regex MetadataDescriptionPattern(); } diff --git a/src/Bicep.Core/Documentation/BicepDocumentationGenerator.cs b/src/Bicep.Core/Documentation/BicepDocumentationGenerator.cs index 867199aa41f..d7ac5e10bc0 100644 --- a/src/Bicep.Core/Documentation/BicepDocumentationGenerator.cs +++ b/src/Bicep.Core/Documentation/BicepDocumentationGenerator.cs @@ -2,11 +2,13 @@ // Licensed under the MIT License. using System.Collections.Immutable; +using System.IO.Abstractions; using System.Reflection; using Bicep.Core.Semantics; using Bicep.Core.Semantics.Metadata; using Bicep.Core.Syntax; using Bicep.Core.TypeSystem; +using Bicep.Core.TypeSystem.Types; using Bicep.IO.Abstraction; using Scriban; using Scriban.Parsing; @@ -14,7 +16,9 @@ namespace Bicep.Core.Documentation; -public class BicepDocumentationGenerator(IFileExplorer fileExplorer) : IBicepDocumentationGenerator +public class BicepDocumentationGenerator( + IFileExplorer fileExplorer, + IFileSystem? fileSystem = null) : IBicepDocumentationGenerator { private const string BuiltInTemplateResourceName = "Bicep.Core.Documentation.Templates.Markdown.scriban"; @@ -28,6 +32,9 @@ public class BicepDocumentationGenerator(IFileExplorer fileExplorer) : IBicepDoc private static readonly Lazy BuiltInTemplateSource = new(LoadBuiltInTemplateSource); + private static readonly Lazy