diff --git a/.gitattributes b/.gitattributes index b04e8951755..b9d2a65cfe0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,4 +7,7 @@ /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 +/src/Bicep.RpcClient.Tests/Files/PublicApis/*.txt text eol=lf +/src/Bicep.Core.UnitTests/Files/Documentation/*.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 6174044ebf7..4a99f21776f 100644 --- a/docs/experimental-features.md +++ b/docs/experimental-features.md @@ -125,3 +125,7 @@ Command that allows the publishing of extensions to container registries. For mo ### Bicep MCP Server See [Using Bicep MCP Server in VS Code (Preview!)](./experimental/mcp-tools.md). + +### `docs` CLI Command + +Generates module documentation. For command and template model details, see [Generate module documentation](./experimental/docs-commands.md). diff --git a/docs/experimental/docs-commands.md b/docs/experimental/docs-commands.md new file mode 100644 index 00000000000..2c68952819f --- /dev/null +++ b/docs/experimental/docs-commands.md @@ -0,0 +1,464 @@ +# Generate module documentation + +The experimental `docs` command group renders documentation from compiled Bicep modules. The semantic model supplies resource types, parameters, exported types, exported variables, exported functions, outputs, and referenced modules. Local files can supply usage examples. A [Scriban](https://github.com/scriban/scriban) template turns that model into text. + +> [!WARNING] +> `docs` is experimental. Command options, configuration, the template model, and built-in Markdown may change without a breaking-change notice. + +- [Experimental status](#experimental-status) +- [Command](#command) +- [Input and output resolution](#input-and-output-resolution) +- [Options](#options) +- [Configuration](#configuration) +- [Usage example discovery](#usage-example-discovery) +- [Custom templates and values](#custom-templates-and-values) +- [Diagnostics and failures](#diagnostics-and-failures) +- [Template model](#template-model) +- [JSON-RPC](#json-rpc) + +## Experimental status + +The `docs` command group does not use a feature flag. It is available whenever the installed Bicep CLI contains it: + +```powershell +bicep docs --help +``` + +A successful non-SARIF invocation writes the standard experimental warning to stderr. Bulk generation writes it at most once. SARIF mode suppresses the plain-text warning so stderr remains one valid SARIF document. + +The `experimentalFeaturesWarning` setting controls warnings for experimental language features used by a Bicep file. It does not enable, disable, or suppress the `docs` command group warning. + +## Command + +```text +bicep docs generate [] [options] +``` + +The command requires either one positional `.bicep` input file or `--pattern`. + +Write `README.md` beside one module: + +```powershell +bicep docs generate .\main.bicep +``` + +Write documentation for every matched module: + +```powershell +bicep docs generate --pattern '.\modules\**\main.bicep' +``` + +Render one module to stdout without writing a file: + +```powershell +bicep docs generate .\main.bicep --stdout +``` + +Redirect stdout: + +```powershell +bicep docs generate .\main.bicep --stdout > .\docs\module.md +``` + +`--stdout` is single-module only. It cannot be combined with `--pattern`, `--outdir`, or `--outfile`. + +## Input and output resolution + +### Input + +The positional input must resolve to a `.bicep` file. Directory inputs are not supported. + +`--pattern` selects `.bicep` files directly. The longest literal directory prefix before the first wildcard is the pattern root. This is also the root used when preserving relative directories under `--outdir`. + +Supplying both a positional input and `--pattern` fails. Omitting both fails with: + +```text +Either the input file path or the --pattern parameter must be specified +``` + +### Output + +For every successfully rendered module, `docs generate` resolves its output as follows: + +| Situation | Destination | +| :-- | :-- | +| No output option | `documentation.output.file` beside the input module. Default: `README.md`. | +| `--outfile ` | Exactly that path. Only valid for a single input. | +| `--outdir ` with one input | `documentation.output.file` inside ``. | +| `--pattern` with no `--outdir` | `documentation.output.file` beside each matched module. | +| `--pattern` with `--outdir ` | Recreates each matched relative directory under ``, then writes `documentation.output.file`. | +| `--stdout` | Writes the rendered document to stdout and creates no file. | + +Output paths are validated before writes: + +- Documentation cannot overwrite its input Bicep file. +- Documentation cannot use a `.bicep` or `.bicepparam` extension. +- Multiple inputs cannot resolve to the same output path. + +Compilation and rendering complete before any output is written for that module. A compile or render failure therefore does not overwrite an existing output. + +## Options + +| Option | Argument | Description | +| :-- | :-- | :-- | +| `--stdout` | flag | Print one rendered document to stdout. | +| `--pattern` | glob | Generate documentation for every matched Bicep file. | +| `--outdir` | directory | Write generated documentation beneath this directory. | +| `--outfile` | path | Write one generated document to this exact path. | +| `--template-file` | path | Use a custom Scriban template instead of the built-in Markdown template. | +| `--template-root` | directory | Set the root used by Scriban `include`. Defaults to the module directory. | +| `--custom-template-value` | `key=value` | Supply one custom string value. Repeatable. | +| `--custom-template-value-file-path` | path | Load custom string values from a JSON object. Repeatable. | +| `--no-restore` | flag | Skip restoring external modules before compilation. | +| `--diagnostics-format` | `default` or `sarif` | Select diagnostic output format. | + +Mutually exclusive combinations use the same validation as other Bicep commands: + +| Combination | Result | +| :-- | :-- | +| positional input and `--pattern` | Error | +| `--stdout` and `--pattern` | Error | +| `--stdout` and `--outdir` | Error | +| `--stdout` and `--outfile` | Error | +| `--outdir` and `--outfile` | Error | +| `--outfile` and `--pattern` | Error | + +## Configuration + +Documentation settings live under `documentation` in `bicepconfig.json`. + +```json +{ + "documentation": { + "output": { + "file": "README.md" + }, + "template": { + "file": "docs/templates/readme.scriban", + "includeRoot": "docs/templates", + "values": { + "owner": "Platform Team" + } + }, + "examples": { + "sources": [ + { + "path": "examples", + "include": ["*.bicep", "**/main.bicep"], + "exclude": ["**/dependencies*.bicep"] + }, + { + "path": "tests", + "include": ["**/*.test.bicep"], + "exclude": ["**/dependencies*.bicep"] + } + ], + "reassignments": [] + } + } +} +``` + +The existing `bicepconfig.json` schema provides validation and editor completion. + +### Resolution behavior + +Configuration is resolved independently for each input Bicep file using the standard Bicep configuration lookup. + +The lookup starts in the source file's directory and walks toward the filesystem root. The first `bicepconfig.json` found is merged with built-in defaults and used for that module. + +> [!IMPORTANT] +> `bicepconfig.json` is nearest-file-wins and does not merge ancestor configuration files. If a module has its own `bicepconfig.json`, it does not inherit repository-level `documentation` settings. Omitted settings fall back to built-in defaults. + +For repositories that require identical documentation settings across every module, enforce a policy that prevents nested `bicepconfig.json` files. Configuration inheritance is designed in [REP 0023](https://github.com/Azure/bicep-reps/blob/main/active/0023-bicep-configuration-inheritance.md), which adds an `extends` property to `bicepconfig.json`; once implemented, a nested configuration will be able to inherit repository-level `documentation` settings explicitly. + +### Precedence + +Explicit command-line options override the corresponding `documentation` settings. Configuration overrides built-in defaults. + +| Setting | Built-in default | Configuration | CLI override | +| :-- | :-- | :-- | :-- | +| Output file name | `README.md` | `documentation.output.file` | `--outfile` or `--outdir` | +| Template | Built-in Markdown | `documentation.template.file` | `--template-file` | +| Include root | Module directory | `documentation.template.includeRoot` | `--template-root` | +| Custom values | None | `documentation.template.values` | Custom value options | +| Example sources | `examples` and `tests` | `documentation.examples.sources` | None | +| Example reassignments | None | `documentation.examples.reassignments` | None | + +### Configuration properties + +| JSON path | Type | Default | Description | +| :-- | :-- | :-- | :-- | +| `documentation.output.file` | string | `README.md` | A portable file name without directory separators. | +| `documentation.template.file` | string | Built-in template | Custom Scriban template path. | +| `documentation.template.includeRoot` | string | Module directory | Root for Scriban includes. Must exist. | +| `documentation.template.values` | object of string | `{}` | Baseline custom template values. | +| `documentation.examples.sources` | array | See below | Ordered example sources. Supplied values replace the defaults. | +| `documentation.examples.sources[].path` | string | Required | Directory relative to each module root. `.` selects the module root. | +| `documentation.examples.sources[].include` | array of string | `[]` | Case-insensitive include globs relative to the source path. | +| `documentation.examples.sources[].exclude` | array of string | `[]` | Case-insensitive exclude globs relative to the source path. | +| `documentation.examples.reassignments` | array | `[]` | Ordered parent-to-child reassignment rules. | +| `documentation.examples.reassignments[].from.include` | array of string | Required | Paths selected from the parent examples. | +| `documentation.examples.reassignments[].from.exclude` | array of string | `[]` | Parent example paths excluded from the rule. | +| `documentation.examples.reassignments[].to` | string | Required | One direct child directory name. | + +An omitted `documentation.examples.sources` uses the built-in sources. An explicit empty array disables usage-example discovery. + +The built-in sources are: + +```json +[ + { + "path": "examples", + "include": ["*.bicep", "**/main.bicep"], + "exclude": ["**/dependencies*.bicep"] + }, + { + "path": "tests", + "include": ["**/*.test.bicep"], + "exclude": ["**/dependencies*.bicep"] + } +] +``` + +### Path anchoring + +| Value | Anchor | +| :-- | :-- | +| `documentation.template.file` | Directory containing the resolved `bicepconfig.json`. | +| `documentation.template.includeRoot` | Directory containing the resolved `bicepconfig.json`. | +| `documentation.examples.sources[].path` | Each module's own directory. | +| `documentation.examples.reassignments[].to` | Parent module directory. | +| `--template-file` and `--template-root` | Current working directory. | +| `--custom-template-value-file-path` | Current working directory. | + +Rooted template paths are used as-is. A relative configured template path requires a resolved user `bicepconfig.json`; built-in configuration has no filesystem directory to use as an anchor. + +### AVM-style example reassignment + +Some repositories keep scope-specific examples beside a parent module while documenting them on child modules: + +```json +{ + "documentation": { + "examples": { + "sources": [ + { + "path": "tests", + "include": ["**/*.test.bicep"], + "exclude": ["**/dependencies*.bicep"] + } + ], + "reassignments": [ + { + "from": { + "include": ["**/rg-scope.*/**"] + }, + "to": "rg-scope" + }, + { + "from": { + "include": ["**/sub-scope.*/**"] + }, + "to": "sub-scope" + }, + { + "from": { + "include": ["**/mg-scope.*/**"] + }, + "to": "mg-scope" + } + ] + } + } +} +``` + +When the parent is documented, matching examples are removed if the named child exists. When that child is documented, matching parent examples are added with paths relative to the child. If the child does not exist, the rule is a no-op. + +## Usage example discovery + +Discovery runs once per compiled module. + +1. Each source is processed in declaration order. +2. `/` is resolved. Missing directories are skipped. +3. Files are traversed recursively. Reparse points are skipped and traversal is bounded to 100 directory levels. +4. Paths relative to the source directory are matched case-insensitively against `include` and `exclude`. +5. Resolved files are deduplicated. The first source that matches a file wins. +6. Results are sorted by path relative to the module root. + +Example names are selected in this order: + +1. Literal `metadata name`. +2. The containing directory name when nested. +3. The file name without `.bicep`. + +Descriptions come from literal `metadata description`, then leading contiguous `//` comments, then `null`. + +## Custom templates and values + +Custom templates use Scriban: + +```scriban +# {{ module.name }} + +{{ module.description }} + +{{ for parameter in module.parameters }} +- `{{ parameter.name }}`: {{ parameter.description }} +{{ end }} +``` + +Use includes for reusable fragments: + +```scriban +{{ include "_header.md" }} +``` + +Includes resolve from the module directory unless `documentation.template.includeRoot` or `--template-root` is supplied. + +Rendered output uses `\n` line endings and exactly one trailing newline. Template loops are limited to 100,000 iterations. + +### Custom values + +Custom values are available as `custom.` and `module.custom.`. + +Inline values use `key=value`: + +```powershell +bicep docs generate .\main.bicep --stdout ` + --custom-template-value owner="Platform Team" +``` + +Value files contain a JSON object whose values are strings: + +```json +{ + "owner": "Platform Team", + "supportUrl": "https://contoso.example/support" +} +``` + +Configuration values are applied first. Inline values and value files are then applied in command-line order. The last occurrence of a key wins. + +## Diagnostics and failures + +Modules are compiled before rendering. `--no-restore` skips external module restoration. + +Compilation failures use normal Bicep diagnostics. Rendering and orchestration failures use: + +| Code | Meaning | +| :-- | :-- | +| `DOCS001` | Invalid input, option, configuration-dependent path, or compilation setup. | +| `DOCS002` | Output write failure. | +| `DOCS003` | Documentation model or template rendering failure. | + +Any failure returns exit code `1`. Pattern generation continues processing remaining modules and returns `1` if any module fails. + +With `--diagnostics-format sarif`, diagnostics from all modules are emitted as one SARIF log on stderr. `--stdout` writes nothing on failure. + +## Template model + +The root Scriban object contains `module` and `custom`. + +| Field | Type | Description | +| :-- | :-- | :-- | +| `module.name` | string | Literal `metadata name`, or the module directory/file fallback. | +| `module.description` | string or null | Module description. | +| `module.path` | string | Entrypoint file path. | +| `module.targetScope` | string | Bicep target scope. | +| `module.custom` | object | Effective custom values. | +| `module.resourceTypes` | array | Declared resource types. | +| `module.parameters` | array | Parameters and nested type information. | +| `module.exportedTypes` | array | Named exported types and nested type information. | +| `module.exportedVariables` | array | Exported variables and inferred type information. | +| `module.exportedFunctions` | array | Exported functions. | +| `module.outputs` | array | Module outputs. | +| `module.references` | array | Referenced local modules. | +| `module.usageExamples` | array | Discovered usage examples. | +| `custom` | object | Effective custom values. | + +Collections are deterministic and sorted by name, except usage examples, which are sorted by relative path. + +### Resource types + +Each resource type contains: + +| Field | Type | +| :-- | :-- | +| `type` | string | +| `existing` | bool | + +### Parameters + +Each parameter and nested property 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 for the default. | +| `defaultValueFence` | string or null | Markdown-safe code fence. | +| `allowedValues` | array | Literal allowed values. | +| `minValue`, `maxValue` | integer or null | Numeric bounds. | +| `minLength`, `maxLength` | integer or null | Length bounds. | +| `pattern` | string or null | String validation pattern. | +| `truncated` | bool | Whether bounded expansion omitted nested details. | +| `properties` | array | Nested properties. | +| `discriminator` | object or null | Discriminator property and cases. | + +Type expansion is bounded to 20 levels and 10,000 expanded nodes, and detects recursive cycles. + +### Exported types and variables + +Each exported type or variable contains: + +| Field | Type | +| :-- | :-- | +| `name` | string | +| `type` | string | +| `secure` | bool | +| `description` | string or null | +| `allowedValues` | array | +| `minValue`, `maxValue` | integer or null | +| `minLength`, `maxLength` | integer or null | +| `pattern` | string or null | +| `truncated` | bool | +| `properties` | array | +| `discriminator` | object or null | + +Exported types unwrap the compiler's type-value wrapper so templates receive the documented type itself. + +### Exported functions + +Each exported function contains `name`, `parameters`, `returnType`, and `description`. Function parameters contain `name`, `type`, and `description`. + +### Outputs + +Each output contains `name`, `type`, `secure`, and `description`. + +### References + +Each reference contains `symbolicName`, `path`, and `description`. + +### Usage examples + +Each usage example contains `name`, `path`, `description`, `contents`, and `fence`. The fence is longer than any backtick run in the contents. + +## JSON-RPC + +Long-lived clients can use: + +- `bicep/generateDocs` for one or more file-oriented results. +- `bicep/outputDocs` for one string-oriented result. + +The JSON-RPC method names remain separate even though the CLI uses `docs generate --stdout`. + +Both requests accept an optional command-line template path, template root, custom values, and `noRestore`. `bicep/generateDocs` also accepts an optional output file name. Configuration is resolved independently from `bicepconfig.json` for each requested Bicep file. + +Each result contains the input path, optional output path, success state, diagnostics, and rendered contents. + +The same model builder and renderer are available directly from `Bicep.Core` through `IBicepDocumentationGenerator`. diff --git a/src/Bicep.Cli.IntegrationTests/DocsCommandTests.cs b/src/Bicep.Cli.IntegrationTests/DocsCommandTests.cs new file mode 100644 index 00000000000..4d3f6c1c903 --- /dev/null +++ b/src/Bicep.Cli.IntegrationTests/DocsCommandTests.cs @@ -0,0 +1,1616 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +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.Configuration; +using Bicep.Core.Documentation; +using Bicep.Core.Exceptions; +using Bicep.Core.Json; +using Bicep.Core.UnitTests; +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; + +namespace Bicep.Cli.IntegrationTests; + +[TestClass] +public class DocsCommandTests : TestBase +{ + private const string ConventionalConfigFileName = "bicepconfig.json"; + private const string FixturePrefix = "Files/DocsCommandTests/Comprehensive/"; + + private static InvocationSettings DocsEnabledSettings() => InvocationSettings.Default; + + private string SaveComprehensiveFixture() => + FileHelper.SaveEmbeddedResourcesWithPathPrefix(TestContext, Assembly.GetExecutingAssembly(), FixturePrefix); + + [TestMethod] + public async Task DocsCommand_IsAvailableWithoutBicepConfigFeatureFlag() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("main.bicep", "metadata name = 'No feature flag'"), + new("bicepconfig.json", "{}"), + ]); + + var result = await Bicep( + "docs", + "generate", + "--stdout", + Path.Combine(root, "main.bicep")); + + result.ExitCode.Should().Be(0); + result.Stdout.Should().Contain("# No feature flag"); + result.Stderr.Should().Contain( + "following experimental Bicep features have been enabled: docs"); + } + + [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("docs"); + 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", "generate", "--stdout", 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", + "generate", + "--stdout", + mainFile, + "--template-file", + templateFile, + "--template-root", + moduleRoot, + "--custom-template-value", + "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"); + + var trailingSeparatorResult = await Bicep( + "docs", + "generate", + "--stdout", + mainFile, + "--template-file", + templateFile, + "--template-root", + moduleRoot + Path.DirectorySeparatorChar, + "--custom-template-value", + "owner=Platform Team"); + trailingSeparatorResult.ExitCode.Should().Be(0); + } + + [TestMethod] + public async Task BicepConfig_AppliesOutputTemplateValuesSourcesAndCliPrecedence() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("module.bicep", "metadata name = 'Configured module'"), + new("templates/readme.scriban", "{{ include \"_header.md\" }}\n{{ module.name }}|{{ custom.owner }}|{{ custom.configOnly }}|{{ for example in module.usageExamples }}{{ example.name }}{{ end }}"), + new("templates/_header.md", "Header"), + new("override.scriban", "Override|{{ custom.configOnly }}"), + new("samples/kept/example.demo", "metadata name = 'sample'"), + new("samples/ignored/example.demo", "metadata name = 'ignored'"), + new("bicepconfig.json", """ + { + "documentation": { + "output": { + "file": "GENERATED.md" + }, + "template": { + "file": "templates/readme.scriban", + "includeRoot": "templates", + "values": { + "owner": "Config", + "configOnly": "retained" + } + }, + "examples": { + "sources": [ + { + "path": "samples", + "include": ["**/*.demo"], + "exclude": ["**/ignored/**"] + } + ] + } + } + } + """), + ]); + var generateResult = await Bicep( + "docs", + "generate", + Path.Combine(root, "module.bicep"), + "--custom-template-value", + "owner=CLI"); + var outputResult = await Bicep( + "docs", + "generate", + "--stdout", + Path.Combine(root, "module.bicep"), + "--custom-template-value", + "owner=CLI"); + + generateResult.ExitCode.Should().Be(0); + outputResult.ExitCode.Should().Be(0); + var expected = "Header\nConfigured module|CLI|retained|sample\n"; + File.ReadAllText(Path.Combine(root, "GENERATED.md")).Should().Be(expected); + outputResult.Stdout.Should().Be(expected); + File.Exists(Path.Combine(root, "README.md")).Should().BeFalse(); + + var overrideResult = await Bicep( + "docs", + "generate", + Path.Combine(root, "module.bicep"), + "--outfile", + Path.Combine(root, "OVERRIDE.md"), + "--template-file", + Path.Combine(root, "override.scriban")); + overrideResult.ExitCode.Should().Be(0); + File.ReadAllText(Path.Combine(root, "OVERRIDE.md")).Should().Be("Override|retained\n"); + } + + [TestMethod] + public async Task Config_ReassignsParentExamplesToChildrenAndIsNoOpForOrdinaryModules() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("main.bicep", "metadata name = 'Parent'"), + new("mg-scope/main.bicep", "metadata name = 'Child'"), + new("tests/e2e/mg-scope.defaults/main.test.bicep", "metadata name = 'mapped'"), + new("tests/e2e/unmapped/main.test.bicep", "metadata name = 'unmapped'"), + new("ordinary/main.bicep", "metadata name = 'Ordinary'"), + new("ordinary/tests/e2e/default/main.test.bicep", "metadata name = 'ordinary'"), + new("examples.scriban", "{{ for example in module.usageExamples }}{{ example.name }}|{{ example.path }}\n{{ end }}"), + new("bicepconfig.json", """ + { + "documentation": { + "examples": { + "reassignments": [ + { + "from": { + "include": ["**/mg-scope.*/**"], + "exclude": ["**/*.skip/**"] + }, + "to": "mg-scope" + } + ] + } + } + } + """), + ]); + var templatePath = Path.Combine(root, "examples.scriban"); + + var parentResult = await Bicep( + "docs", + "generate", + "--stdout", + Path.Combine(root, "main.bicep"), + "--template-file", + templatePath); + var childResult = await Bicep( + "docs", + "generate", + "--stdout", + Path.Combine(root, "mg-scope", "main.bicep"), + "--template-file", + templatePath); + var ordinaryResult = await Bicep( + "docs", + "generate", + "--stdout", + Path.Combine(root, "ordinary", "main.bicep"), + "--template-file", + templatePath); + + parentResult.ExitCode.Should().Be(0, parentResult.Stderr); + parentResult.Stdout.Should().Be("unmapped|tests/e2e/unmapped/main.test.bicep\n"); + childResult.ExitCode.Should().Be(0); + childResult.Stdout.Should().Be("mapped|../tests/e2e/mg-scope.defaults/main.test.bicep\n"); + ordinaryResult.ExitCode.Should().Be(0); + ordinaryResult.Stdout.Should().Be("ordinary|tests/e2e/default/main.test.bicep\n"); + } + + [DataTestMethod] + [DataRow("""{ "output": { "file": "nested/README.md" } }""", "cannot traverse")] + [DataRow("""{ "output": { "file": "CON.md" } }""", "portable file name")] + [DataRow("""{ "output": { "file": "README.md." } }""", "portable file name")] + [DataRow("""{ "template": { "file": "" } }""", "cannot be empty")] + [DataRow("""{ "template": { "includeRoot": "" } }""", "cannot be empty")] + [DataRow("""{ "template": { "values": { "": "value" } } }""", "cannot be empty")] + [DataRow("""{ "examples": { "sources": [null] } }""", "cannot contain null values")] + [DataRow("""{ "examples": { "reassignments": [null] } }""", "cannot contain null values")] + [DataRow("""{ "examples": { "reassignments": [{ "from": null, "to": "child" }] } }""", "cannot contain null values")] + [DataRow("""{ "examples": { "sources": [{ "path": "../samples" }] } }""", "cannot traverse")] + [DataRow("""{ "examples": { "sources": [{ "path": "samples", "include": [""] }] } }""", "cannot be empty")] + [DataRow("""{ "examples": { "reassignments": [{ "from": {}, "to": "child" }] } }""", "must contain")] + [DataRow("""{ "examples": { "reassignments": [{ "from": { "include": ["**/*"] }, "to": "nested/child" }] } }""", "cannot traverse")] + public async Task Config_InvalidValuesReturnActionableErrors(string contents, string expected) + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("main.bicep", "metadata name = 'Example'"), + new("bicepconfig.json", $$"""{ "documentation": {{contents}} }"""), + ]); + + var result = await Bicep( + "docs", + "generate", + "--stdout", + Path.Combine(root, "main.bicep")); + + result.ExitCode.Should().Be(1); + result.Stderr.Should().Contain(expected); + result.Stderr.Should().NotContain("Unhandled exception"); + } + + [TestMethod] + public async Task Generate_MissingInputAndPatternReturnsActionableError() + { + var result = await Bicep("docs", "generate"); + + result.ExitCode.Should().Be(1); + result.Stderr.Should().Contain("Either the input file path or the --pattern parameter must be specified"); + } + + [TestMethod] + public async Task Generate_DirectoryInputIsRejected() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [new("main.bicep", "metadata name = 'Example'")]); + + var result = await Bicep("docs", "generate", root); + + result.ExitCode.Should().Be(1); + result.Stderr.Should().Contain("not recognized as a Bicep file"); + } + + [TestMethod] + public async Task Config_AbsoluteTemplatePathIsSupported() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("main.bicep", "metadata name = 'Absolute template'"), + new("readme.scriban", "{{ module.name }}"), + ]); + var configPath = Path.Combine(root, "bicepconfig.json"); + File.WriteAllText( + configPath, + JsonSerializer.Serialize(new + { + documentation = new + { + template = new + { + file = Path.Combine(root, "readme.scriban"), + }, + }, + })); + + var result = await Bicep( + "docs", + "generate", + "--stdout", + Path.Combine(root, "main.bicep")); + + result.ExitCode.Should().Be(0); + result.Stdout.Should().Be("Absolute template\n"); + } + + [TestMethod] + public async Task Config_EmptyObjectUsesBuiltInDefaults() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("main.bicep", "metadata name = 'Defaults'"), + new("bicepconfig.json", "{}"), + ]); + + var result = await Bicep( + "docs", + "generate", + Path.Combine(root, "main.bicep")); + + result.ExitCode.Should().Be(0); + File.Exists(Path.Combine(root, "README.md")).Should().BeTrue(); + } + + [TestMethod] + public async Task Config_EmptyNestedSettingsUseTheirDefaults() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("main.bicep", "metadata name = 'Nested defaults'"), + new("bicepconfig.json", """ + { + "documentation": { + "output": {}, + "template": {}, + "examples": { + "sources": [ + { + "path": "missing" + } + ] + } + } + } + """), + ]); + + var result = await Bicep( + "docs", + "generate", + Path.Combine(root, "main.bicep")); + + result.ExitCode.Should().Be(0); + File.Exists(Path.Combine(root, "README.md")).Should().BeTrue(); + } + + [TestMethod] + public async Task Config_FileInputUsesNearestBicepConfig() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("bicepconfig.json", """ + { + "documentation": { + "output": { "file": "ROOT.md" } + } + } + """), + new("module/main.bicep", "metadata name = 'Module'"), + new("module/bicepconfig.json", """ + { + "documentation": { + "output": { "file": "MODULE.md" } + } + } + """), + ]); + + var result = await Bicep( + "docs", + "generate", + Path.Combine(root, "module", "main.bicep")); + + result.ExitCode.Should().Be(0); + File.Exists(Path.Combine(root, "module", "MODULE.md")).Should().BeTrue(); + File.Exists(Path.Combine(root, "module", "ROOT.md")).Should().BeFalse(); + } + + [TestMethod] + public async Task Config_PatternUsesPerModuleNearestBicepConfig() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("modules/a/main.bicep", "metadata name = 'A'"), + new("modules/b/main.bicep", "metadata name = 'B'"), + new("modules/c/main.bicep", "metadata name = 'C'"), + new("bicepconfig.json", """ + { + "documentation": { + "output": { "file": "ROOT.md" } + } + } + """), + new("modules/a/bicepconfig.json", """ + { + "documentation": { + "output": { "file": "MODULE.md" } + } + } + """), + new("modules/b/bicepconfig.json", """{ "experimentalFeaturesWarning": false }"""), + ]); + + var result = await Bicep( + "docs", + "generate", + "--pattern", + Path.Combine(root, "modules", "*", "main.bicep")); + + result.ExitCode.Should().Be(0); + File.Exists(Path.Combine(root, "modules", "a", "MODULE.md")).Should().BeTrue(); + File.Exists(Path.Combine(root, "modules", "b", "README.md")).Should().BeTrue(); + File.Exists(Path.Combine(root, "modules", "c", "ROOT.md")).Should().BeTrue(); + } + + [TestMethod] + public async Task Config_SearchesParentDirectories() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("bicepconfig.json", """ + { + "documentation": { + "output": { "file": "PARENT.md" } + } + } + """), + new("target/main.bicep", "metadata name = 'Target'"), + ]); + + var result = await Bicep( + "docs", + "generate", + Path.Combine(root, "target", "main.bicep")); + + result.ExitCode.Should().Be(0); + File.Exists(Path.Combine(root, "target", "PARENT.md")).Should().BeTrue(); + } + + [TestMethod] + public async Task Config_CommandLineTemplateAndOutputOverrideBicepConfig() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("main.bicep", "metadata name = 'Target'"), + new("configured.scriban", "Configured"), + new("override.scriban", "Override"), + new("bicepconfig.json", """ + { + "documentation": { + "output": { "file": "CONFIGURED.md" }, + "template": { "file": "configured.scriban" } + } + } + """), + ]); + + var result = await Bicep( + "docs", + "generate", + Path.Combine(root, "main.bicep"), + "--outfile", + Path.Combine(root, "OVERRIDE.md"), + "--template-file", + Path.Combine(root, "override.scriban")); + + result.ExitCode.Should().Be(0); + File.ReadAllText(Path.Combine(root, "OVERRIDE.md")).Should().Be("Override\n"); + File.Exists(Path.Combine(root, "CONFIGURED.md")).Should().BeFalse(); + } + + [TestMethod] + public async Task Config_MissingBicepConfigUsesBuiltInDefaults() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [new("main.bicep", "metadata name = 'Defaults'")]); + + var result = await Bicep( + "docs", + "generate", + Path.Combine(root, "main.bicep")); + + result.ExitCode.Should().Be(0); + result.Stderr.Should().NotContain(ConventionalConfigFileName); + File.Exists(Path.Combine(root, "README.md")).Should().BeTrue(); + } + + [DataTestMethod] + [DataRow("{ invalid", "invalid")] + [DataRow("""{ "documentation": { "output": { "file": "../README.md" } } }""", "cannot traverse")] + public async Task Config_InvalidBicepConfigReturnsNamedError(string contents, string expectedError) + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("main.bicep", "metadata name = 'Invalid config'"), + new("bicepconfig.json", contents), + ]); + var configPath = Path.Combine(root, ConventionalConfigFileName); + + var result = await Bicep( + "docs", + "generate", + "--stdout", + Path.Combine(root, "main.bicep")); + + result.ExitCode.Should().Be(1); + result.Stderr.Should().Contain(configPath); + result.Stderr.Should().Contain(expectedError); + } + + [TestMethod] + public async Task Generate_PatternSelectsMultipleInputsWithoutConfigInputSettings() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("modules/a/module.bicep", "metadata name = 'A'"), + new("modules/a/MODULE.TEST.BICEP", "metadata name = 'Excluded'"), + new("modules/b/module.bicep", "metadata name = 'B'"), + new("modules/c/module.bicep", "metadata name = 'C'"), + new("bicepconfig.json", """ + { + "documentation": { + "output": { + "file": "DOCS.md" + } + } + } + """), + ]); + + var generateResult = await Bicep( + "docs", + "generate", + "--pattern", + Path.Combine(root, "modules", "*", "module.bicep")); + + generateResult.ExitCode.Should().Be(0); + File.Exists(Path.Combine(root, "modules", "a", "DOCS.md")).Should().BeTrue(); + File.Exists(Path.Combine(root, "modules", "b", "DOCS.md")).Should().BeTrue(); + File.Exists(Path.Combine(root, "modules", "c", "DOCS.md")).Should().BeTrue(); + File.Exists(Path.Combine(root, "modules", "a", "README.md")).Should().BeFalse(); + } + + [TestMethod] + public async Task Generate_UnmatchedPatternWritesNothing() + { + var result = await Bicep( + "docs", + "generate", + "--pattern", + Path.Combine(FileHelper.GetUniqueTestOutputPath(TestContext), "**", "main.bicep")); + + result.ExitCode.Should().Be(0); + result.Stdout.Should().BeEmpty(); + result.Stderr.Should().NotContain("Unhandled exception"); + } + + [TestMethod] + public async Task Config_PatternGenerationUsesConfiguredOutputForEveryModule() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("modules/a/main.bicep", "metadata name = 'A'"), + new("modules/b/main.bicep", "metadata name = 'B'"), + new("bicepconfig.json", """ + { + "documentation": { + "output": { "file": "DOCS.md" } + } + } + """), + ]); + + var result = await Bicep( + "docs", + "generate", + "--pattern", + Path.Combine(root, "modules", "*", "main.bicep")); + + result.ExitCode.Should().Be(0); + File.Exists(Path.Combine(root, "modules", "a", "DOCS.md")).Should().BeTrue(); + File.Exists(Path.Combine(root, "modules", "b", "DOCS.md")).Should().BeTrue(); + Directory.EnumerateFiles(root, "README.md", SearchOption.AllDirectories).Should().BeEmpty(); + } + + [TestMethod] + public async Task Output_CustomTemplateValues_MergeFilesAndIndividualValuesInCommandLineOrder() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("main.bicep", "metadata name = 'Custom values'"), + new("template.scriban", "{{ custom.owner }}|{{ custom.fromFirst }}|{{ custom.fromSecond }}|{{ custom.inlineOnly }}"), + new("first.json", """{ "owner": "first file", "fromFirst": "one" }"""), + new("second.json", """{ "owner": "second file", "fromSecond": "two" }"""), + ]); + var mainFile = Path.Combine(root, "main.bicep"); + var templateFile = Path.Combine(root, "template.scriban"); + var firstFile = Path.Combine(root, "first.json"); + var secondFile = Path.Combine(root, "second.json"); + + var fileLast = await Bicep( + "docs", + "generate", + "--stdout", + mainFile, + "--template-file", + templateFile, + "--custom-template-value-file-path", + firstFile, + "--custom-template-value", + "owner=first inline", + "--custom-template-value", + "owner=second inline", + "--custom-template-value-file-path", + secondFile, + "--custom-template-value", + "inlineOnly=three"); + var inlineLast = await Bicep( + "docs", + "generate", + "--stdout", + mainFile, + "--template-file", + templateFile, + "--custom-template-value-file-path", + secondFile, + "--custom-template-value", + "owner=last inline"); + + fileLast.ExitCode.Should().Be(0); + fileLast.Stdout.Should().Be("second file|one|two|three\n"); + inlineLast.ExitCode.Should().Be(0); + inlineLast.Stdout.Should().Be("last inline||two|\n"); + } + + [DataTestMethod] + [DataRow("[]", "must contain a JSON object")] + [DataRow("""{ "count": 1 }""", "value for \"count\" must be a string")] + [DataRow("""{ "value": null }""", "value for \"value\" must be a string")] + [DataRow("""{ "": "value" }""", "contains an empty key")] + [DataRow("""{ "value": "first", "value": "second" }""", "contains the duplicate key \"value\"")] + [DataRow("{ invalid", "is not valid JSON")] + public async Task Output_CustomTemplateValueFile_RejectsInvalidContent(string contents, string expectedError) + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("main.bicep", "metadata name = 'Invalid values'"), + new("values.json", contents), + ]); + + var result = await Bicep( + "docs", + "generate", + "--stdout", + Path.Combine(root, "main.bicep"), + "--custom-template-value-file-path", + Path.Combine(root, "values.json")); + + result.ExitCode.Should().Be(1); + result.Stderr.Should().Contain(expectedError); + } + + [TestMethod] + public async Task Output_CustomTemplateValueFile_RejectsMissingFile() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [new("main.bicep", "metadata name = 'Missing values'")]); + + var result = await Bicep( + "docs", + "generate", + "--stdout", + Path.Combine(root, "main.bicep"), + "--custom-template-value-file-path", + Path.Combine(root, "missing.json")); + + result.ExitCode.Should().Be(1); + result.Stderr.Should().Contain("does not exist"); + } + + [TestMethod] + public async Task Output_CustomTemplateValueFile_RejectsEmptyPath() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [new("main.bicep", "metadata name = 'Empty path'")]); + + var result = await Bicep( + "docs", + "generate", + "--stdout", + Path.Combine(root, "main.bicep"), + "--custom-template-value-file-path", + ""); + + result.ExitCode.Should().Be(1); + result.Stderr.Should().Contain("expects a nonempty path"); + } + + [TestMethod] + public async Task Output_CustomTemplateValueFile_WrapsInvalidPath() + { + 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(new ArgumentException("invalid path")); + + var result = await Bicep( + DocsEnabledSettings(), + services => services.AddSingleton(fileSystem.Object), + TestContext.CancellationTokenSource.Token, + "docs", + "generate", + "--stdout", + "main.bicep", + "--custom-template-value-file-path", + "invalid"); + + result.ExitCode.Should().Be(1); + result.Stderr.Should().ContainAll("is invalid", "invalid path"); + } + + [TestMethod] + public async Task Output_CustomTemplateValueFile_WrapsReadFailures() + { + var fileSystem = new Mock(MockBehavior.Strict); + var path = new Mock(MockBehavior.Strict); + var file = new Mock(MockBehavior.Strict); + fileSystem.SetupGet(system => system.Path).Returns(path.Object); + fileSystem.SetupGet(system => system.File).Returns(file.Object); + path.Setup(systemPath => systemPath.GetFullPath("values.json")).Returns("C:\\values.json"); + file.Setup(systemFile => systemFile.Exists("C:\\values.json")).Returns(true); + file.Setup(systemFile => systemFile.ReadAllText("C:\\values.json")).Throws(new IOException("read failed")); + var result = await Bicep( + DocsEnabledSettings(), + services => services.AddSingleton(fileSystem.Object), + TestContext.CancellationTokenSource.Token, + "docs", + "generate", + "--stdout", + "main.bicep", + "--custom-template-value-file-path", + "values.json"); + + result.ExitCode.Should().Be(1); + result.Stderr.Should().ContainAll("Unable to read", "read failed"); + } + + [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_PatternWithOutDir_PreservesRelativeDirectories() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("modules/a/main.bicep", "metadata name = 'A'"), + new("modules/b/main.bicep", "metadata name = 'B'"), + ]); + var outputRoot = Path.Combine(root, "generated"); + + var result = await Bicep( + "docs", + "generate", + "--pattern", + Path.Combine(root, "modules", "**", "main.bicep"), + "--outdir", + outputRoot); + + result.ExitCode.Should().Be(0); + File.Exists(Path.Combine(outputRoot, "a", "README.md")).Should().BeTrue(); + File.Exists(Path.Combine(outputRoot, "b", "README.md")).Should().BeTrue(); + } + + [TestMethod] + public async Task Generate_RootPatternWithOutDir_WritesReadmeAtOutputRoot() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [new("main.bicep", "metadata name = 'Root'")]); + var outputRoot = Path.Combine(root, "generated"); + + var result = await Bicep( + "docs", + "generate", + "--pattern", + Path.Combine(root, "*.bicep"), + "--outdir", + outputRoot); + + result.ExitCode.Should().Be(0); + File.Exists(Path.Combine(outputRoot, "README.md")).Should().BeTrue(); + } + + [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", Path.Combine(root, "main.bicep")); + + 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", + Path.Combine(root, "main.bicep"), + "--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_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", + Path.Combine(root, "main.bicep"), + "--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() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("main.bicep", "metadata name = 'Example'"), + new("README.md", "preserve me"), + ]); + var fileSystem = new System.IO.Abstractions.FileSystem(); + var fileExplorer = new WriteFailingFileExplorer( + new FileSystemFileExplorer(fileSystem), + "README.md", + new IOException("write failed")); + + var result = await Bicep( + DocsEnabledSettings(), + services => services + .AddSingleton(fileSystem) + .AddSingleton(fileExplorer), + TestContext.CancellationTokenSource.Token, + "docs", + "generate", + Path.Combine(root, "main.bicep")); + + result.ExitCode.Should().Be(1); + result.Stderr.Should().Contain("write failed"); + 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 fileSystem = new System.IO.Abstractions.FileSystem(); + var fileExplorer = new WriteFailingFileExplorer( + new FileSystemFileExplorer(fileSystem), + "README.md", + new IOException("write failed")); + + var result = await Bicep( + DocsEnabledSettings(), + services => services + .AddSingleton(fileSystem) + .AddSingleton(fileExplorer), + TestContext.CancellationTokenSource.Token, + "docs", + "generate", + Path.Combine(root, "main.bicep"), + "--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() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [new("main.bicep", "metadata name = 'Example'\nparam value string = 'ok'")]); + + var defaultResult = await Bicep(DocsEnabledSettings(), "docs", "generate", "--stdout", Path.Combine(root, "main.bicep")); + var generateResult = await Bicep( + DocsEnabledSettings(), + "docs", + "generate", + Path.Combine(root, "main.bicep"), + "--outfile", + Path.Combine(root, "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_RejectsBicepOutputExtension() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [new("main.bicep", "metadata name = 'Example'")]); + + var result = await Bicep( + "docs", + "generate", + Path.Combine(root, "main.bicep"), + "--outfile", + Path.Combine(root, "output.bicep")); + + result.ExitCode.Should().Be(1); + result.Stderr.Should().Contain("cannot use a Bicep source file extension"); + File.Exists(Path.Combine(root, "output.bicep")).Should().BeFalse(); + } + + [TestMethod] + public async Task Generate_RejectsInputOverwrite() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [new("main.bicep", "metadata name = 'Example'")]); + var mainFile = Path.Combine(root, "main.bicep"); + + var result = await Bicep( + "docs", + "generate", + mainFile, + "--outfile", + mainFile); + + result.ExitCode.Should().Be(1); + result.Stderr.Should().Contain("cannot overwrite the input"); + File.ReadAllText(mainFile).Should().Contain("metadata name"); + + if (OperatingSystem.IsWindows()) + { + var aliasedResult = await Bicep( + "docs", + "generate", + mainFile, + "--outfile", + $"{mainFile}."); + + aliasedResult.ExitCode.Should().Be(1); + aliasedResult.Stderr.Should().Contain("cannot overwrite the input"); + File.ReadAllText(mainFile).Should().Contain("metadata name"); + } + } + + [TestMethod] + public async Task Commands_RejectReservedWindowsPathsWithoutCrashing() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var root = FileHelper.SaveResultFiles( + TestContext, + [new("main.bicep", "metadata name = 'Example'")]); + + var outputResult = await Bicep( + "docs", + "generate", + "--stdout", + Path.Combine(root, "CON")); + var generateResult = await Bicep( + "docs", + "generate", + Path.Combine(root, "main.bicep"), + "--outfile", + Path.Combine(root, "CON.md")); + + outputResult.ExitCode.Should().Be(1); + outputResult.Stderr.Should().Contain("reserved file name"); + outputResult.Stderr.Should().NotContain("Unhandled exception"); + generateResult.ExitCode.Should().Be(1); + generateResult.Stderr.Should().Contain("reserved file name"); + generateResult.Stderr.Should().NotContain("Unhandled exception"); + } + + [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( + "docs", + "generate", + "--pattern", + Path.Combine(root, "*.bicep")); + + result.ExitCode.Should().Be(1); + result.Stderr.Should().Contain("resolve to the output file"); + 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", + "generate", + "--stdout", + Path.Combine(root, "main.bicep"), + "--diagnostics-format", + "sarif"); + + result.ExitCode.Should().Be(1); + result.Stdout.Should().BeEmpty(); + 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", + "generate", + "--stdout", + Path.Combine(root, "main.bicep"), + "--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 Generate_PatternCompilationFailureThenSuccess_LogsExperimentalDisclaimerOnce() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [ + new("a-invalid/main.bicep", "param value invalidType"), + new("b-valid/main.bicep", "metadata name = 'Valid'"), + ]); + + var result = await Bicep( + "docs", + "generate", + "--pattern", + Path.Combine(root, "*", "main.bicep")); + + result.ExitCode.Should().Be(1); + result.Stderr.Split( + "following experimental Bicep features have been enabled: docs", + StringSplitOptions.None) + .Should().HaveCount(2); + File.Exists(Path.Combine(root, "a-invalid", "README.md")).Should().BeFalse(); + File.Exists(Path.Combine(root, "b-valid", "README.md")).Should().BeTrue(); + } + + [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")), + null, + null, + new Dictionary(), + noRestore: false, + diagnosticsFormat: null, + workspace: 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_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)); + Action registerServices = services => services + .AddSingleton(fileSystem) + .AddSingleton(fileExplorer.Object); + + 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"); + var outputSarifResult = await Bicep( + DocsEnabledSettings(), + registerServices, + TestContext.CancellationTokenSource.Token, + "docs", + "generate", + "--stdout", + "/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"); + outputSarifResult.ExitCode.Should().Be(1); + outputSarifResult.Stdout.Should().BeEmpty(); + using var outputDocument = JsonDocument.Parse(outputSarifResult.Stderr); + outputDocument.RootElement.ToString().Should().ContainAll("DOCS001", "compilation setup failed"); + } + + [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", + "generate", + "--stdout", + Path.Combine(root, fileName)); + + result.ExitCode.Should().Be(1); + result.Stdout.Should().BeEmpty(); + result.Stderr.Should().NotBeEmpty(); + } + + [DataTestMethod] + [DataRow(typeof(IOException))] + [DataRow(typeof(UnauthorizedAccessException))] + [DataRow(typeof(ArgumentException))] + [DataRow(typeof(NotSupportedException))] + public async Task Output_WrapsInputPathExceptions(Type exceptionType) + { + var exception = (Exception)Activator.CreateInstance(exceptionType, "invalid path")!; + 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 result = await Bicep( + DocsEnabledSettings(), + services => services.AddSingleton(fileSystem.Object), + TestContext.CancellationTokenSource.Token, + "docs", + "generate", + "--stdout", + "invalid"); + + result.ExitCode.Should().Be(1); + result.Stderr.Should().Contain("invalid path"); + } + + [TestMethod] + public async Task Generate_RejectsMissingTemplateFile() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [new("main.bicep", "metadata name = 'Example'")]); + + var result = await Bicep( + "docs", + "generate", + "--stdout", + Path.Combine(root, "main.bicep"), + "--template-file", + Path.Combine(root, "missing.scriban")); + + result.ExitCode.Should().Be(1); + result.Stderr.Should().Contain("does not exist"); + } + + [DataTestMethod] + [DataRow(["docs", "generate", "main.bicep", "--custom-template-value"])] + [DataRow(["docs", "generate", "main.bicep", "--custom-template-value", "invalid"])] + [DataRow(["docs", "generate", "main.bicep", "--custom-template-value-file-path"])] + [DataRow(["docs", "generate", "main.bicep", "--pattern", "**/main.bicep", "--outfile", "README.md"])] + [DataRow(["docs", "generate", "main.bicep", "--stdout", "--pattern", "**/main.bicep"])] + [DataRow(["docs", "generate", "main.bicep", "--stdout", "--outdir", "docs"])] + [DataRow(["docs", "generate", "main.bicep", "--stdout", "--outfile", "README.md"])] + 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 Generate_RejectsMissingTemplateRoot() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [new("main.bicep", "param value string = 'ok'")]); + + var result = await Bicep( + DocsEnabledSettings(), + "docs", + "generate", + "--stdout", + Path.Combine(root, "main.bicep"), + "--template-root", + Path.Combine(root, "missing")); + + result.ExitCode.Should().Be(1); + result.Stderr.Should().Contain("does not exist"); + } + + [TestMethod] + public async Task Generate_MissingTemplateRootWithSarifEmitsStructuredFailure() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [new("main.bicep", "param value string = 'ok'")]); + + var result = await Bicep( + "docs", + "generate", + "--stdout", + Path.Combine(root, "main.bicep"), + "--template-root", + Path.Combine(root, "missing"), + "--diagnostics-format", + "sarif"); + + result.ExitCode.Should().Be(1); + using var document = JsonDocument.Parse(result.Stderr); + document.RootElement.ToString().Should().ContainAll("DOCS001", "does not exist"); + } + + [TestMethod] + public void InputOutputResolver_UsesFixedReadmeNameForDocs() + { + var fileSystem = new MockFileSystem(new Dictionary + { + ["/module/main.bicep"] = "metadata name = 'Example'", + }); + var resolver = new InputOutputArgumentsResolver(fileSystem); + var arguments = new DocsGenerateArguments( + "/module/main.bicep", + null, + null, + null, + System.Collections.Immutable.ImmutableSortedDictionary.Empty, + false, + null, + null, + false, + null); + + var defaultOutput = resolver.ResolveFilePatternInputOutputArguments(arguments, (_, _) => "README.md"); + var explicitOutput = resolver.ResolveFilePatternInputOutputArguments( + arguments with { OutputFile = "/docs/custom.md" }, + (_, _) => "README.md"); + var fixedOutDir = resolver.ResolveFilePatternInputOutputArguments( + arguments with { OutputDir = "/docs" }, + (_, _) => "README.md"); + var extensionOutput = resolver.ResolveFilePatternInputOutputArguments(arguments); + FluentActions.Invoking(() => resolver.ResolveFilePatternInputOutputArguments( + arguments with { InputFile = null })) + .Should().Throw(); + + Path.GetFileName(defaultOutput.Single().OutputUri.GetFilePath()).Should().Be("README.md"); + explicitOutput.Single().OutputUri.GetFilePath().Should().Be(fileSystem.Path.GetFullPath("/docs/custom.md")); + fixedOutDir.Single().OutputUri.GetFilePath().Should().Be(fileSystem.Path.GetFullPath("/docs/README.md")); + Path.GetFileName(extensionOutput.Single().OutputUri.GetFilePath()).Should().Be("main.md"); + + var physicalRoot = FileHelper.SaveResultFiles( + TestContext, + [new("main.bicep", "metadata name = 'Pattern'")]); + var physicalFileSystem = new FileSystem(); + var physicalResolver = new InputOutputArgumentsResolver(physicalFileSystem); + var patternArguments = arguments with + { + InputFile = null, + FilePattern = Path.Combine(physicalRoot, "*.bicep"), + }; + var patternExplicitOutput = physicalResolver.ResolveFilePatternInputOutputArguments( + patternArguments with { OutputFile = Path.Combine(physicalRoot, "pattern.md") }); + var patternExtensionOutput = physicalResolver.ResolveFilePatternInputOutputArguments( + patternArguments with { OutputDir = Path.Combine(physicalRoot, "docs") }); + + patternExplicitOutput.Single().OutputUri.GetFilePath().Should().Be(Path.Combine(physicalRoot, "pattern.md")); + patternExtensionOutput.Single().OutputUri.GetFilePath().Should().Be(Path.Combine(physicalRoot, "docs", "main.md")); + } + + [TestMethod] + public void InputOutputResolver_PreservesExtensionBehaviorWithoutFixedName() + { + var root = FileHelper.SaveResultFiles( + TestContext, + [new("nested/main.bicep", "metadata name = 'Nested'")]); + var fileSystem = new System.IO.Abstractions.FileSystem(); + var resolver = new InputOutputArgumentsResolver(fileSystem); + var arguments = new DocsGenerateArguments( + null, + Path.Combine(root, "*", "main.bicep"), + null, + null, + System.Collections.Immutable.ImmutableSortedDictionary.Empty, + false, + null, + null, + false, + null); + + var output = resolver.ResolveFilePatternInputOutputArguments(arguments); + + output.Should().ContainSingle(); + output.Single().OutputUri.GetFilePath().Should().Be(Path.Combine(root, "nested", "main.md")); + } + + [TestMethod] + public void OptionsResolver_AnchorsConfiguredPathsAndMergesValues() + { + var fileSystem = new MockFileSystem(); + var configPath = fileSystem.Path.GetFullPath("/repo/bicepconfig.json"); + var templateRoot = fileSystem.Path.GetFullPath("/repo/templates"); + fileSystem.AddDirectory(templateRoot); + var configuration = BicepTestConstants.BuiltInConfiguration.With( + documentation: DocumentationConfiguration.Bind(JsonElementFactory.CreateElement(""" + { + "template": { + "file": "templates/readme.scriban", + "includeRoot": "templates", + "values": { + "owner": "Config", + "retained": "yes" + } + } + } + """)), + configFileIdentifier: IOUri.FromFilePath(configPath)); + var resolver = new DocsGenerationOptionsResolver( + new InputOutputArgumentsResolver(fileSystem), + fileSystem); + + var options = resolver.Resolve( + configuration, + templateFile: null, + templateRoot: null, + new Dictionary { ["owner"] = "CLI" }); + + options.TemplateFile!.GetFilePath().Should().Be(fileSystem.Path.Combine(templateRoot, "readme.scriban")); + options.TemplateRoot!.GetFilePath().TrimEnd(fileSystem.Path.DirectorySeparatorChar) + .Should().Be(templateRoot.TrimEnd(fileSystem.Path.DirectorySeparatorChar)); + options.CustomValues.Should().Contain("owner", "CLI").And.Contain("retained", "yes"); + } + + [TestMethod] + public void OptionsResolver_RejectsRelativeConfiguredPathWithoutConfigFile() + { + var fileSystem = new MockFileSystem(); + var configuration = BicepTestConstants.BuiltInConfiguration.With( + documentation: DocumentationConfiguration.Bind(JsonElementFactory.CreateElement(""" + { + "template": { + "file": "templates/readme.scriban" + } + } + """))); + var resolver = new DocsGenerationOptionsResolver( + new InputOutputArgumentsResolver(fileSystem), + fileSystem); + + FluentActions.Invoking(() => resolver.Resolve(configuration, null, null, new Dictionary())) + .Should().Throw() + .WithMessage("*no bicepconfig.json file was resolved*"); + } + +} 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..bd80b632858 --- /dev/null +++ b/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/README.expected.md @@ -0,0 +1,237 @@ +# Comprehensive Module + +Exercises every documentation feature | with multiline details. +Second line. + +## Navigation + +- [Resource Types](#resource-types) +- [Usage Examples](#usage-examples) +- [Parameters](#parameters) +- [Exported Types](#exported-types) +- [Exported Variables](#exported-variables) +- [Exported Functions](#exported-functions) +- [Outputs](#outputs) +- [Cross-referenced Modules](#cross-referenced-modules) + +## Resource Types + +| Resource Type | Existing | +| :-- | :-- | +| `Microsoft.Resources/resourceGroups@2024-03-01` | No | +| `Microsoft.Resources/resourceGroups@2024-03-01` | Yes | + +## Usage Examples + +### Example 1: _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' + } +} +``` + +### Example 2: _restricted_ + +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: + +```bicep +[ + 'default' +] +``` + +- Min length: 1 + +- Max length: 5 + +### `networkAccess` + +- Default value: + +```bicep +{ + kind: 'public' +} +``` + +- Discriminator: `kind` + - `public`: + - `kind` (`string`), required + - Allowed values: `public` + - `restricted`: + - `allowedCidrs` (`array`), required: Allowed CIDR ranges. + - `kind` (`string`), required + - Allowed values: `restricted` + +### `resourceGroupName` + +- Min length: 3 + +- Max length: 90 + +### `retentionInDays` + +- Default value: `30` + +- Min value: 1 + +- Max value: 365 + +### `secret` + +- Secure: Yes + +### `settings` + +- Default value: + +```bicep +{ + 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 Types + +| Name | Type | Description | +| :-- | :-- | :-- | +| `networkAccessType` | `object` | | +| `publicAccessType` | `object` | | +| `restrictedAccessType` | `object` | | +| `settingsType` | `object` | Nested module settings. | + +### `networkAccessType` + +- Discriminator: `kind` + - `public`: + - `kind` (`string`), required + - Allowed values: `public` + - `restricted`: + - `allowedCidrs` (`array`), required: Allowed CIDR ranges. + - `kind` (`string`), required + - Allowed values: `restricted` + +### `publicAccessType` + +- Properties: + - `kind` (`string`), required + - Allowed values: `public` + +### `restrictedAccessType` + +- Properties: + - `allowedCidrs` (`array`), required: Allowed CIDR ranges. + - `kind` (`string`), required + - Allowed values: `restricted` + +### `settingsType` + +- Properties: + - `enabled` (`bool`), required: Whether the feature is enabled. + - `labels` (`object`), required: Labels applied to resources. + - `environment` (`string`), required: Environment label. + +## Exported Variables + +| Name | Type | Description | +| :-- | :-- | :-- | +| `defaultDeploymentPrefix` | `string` | The default deployment prefix. | + +### `defaultDeploymentPrefix` + +- Allowed values: `sample` + +## 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. | 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..0967ef424bc --- /dev/null +++ b/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/bicepconfig.json @@ -0,0 +1 @@ +{} 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..35aa4c9d5b0 --- /dev/null +++ b/src/Bicep.Cli.IntegrationTests/Files/DocsCommandTests/Comprehensive/main.bicep @@ -0,0 +1,127 @@ +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('The default deployment prefix.') +var defaultDeploymentPrefix = 'sample' + +@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..25d1f65f93c 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,39 @@ 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"); + + using (new AssertionScope()) + { + groupResult.Should().Be(0); + groupError.Should().BeEmpty(); + groupOutput.Should().ContainAll( + "docs", + "generate", + "[Experimental]"); + groupOutput.Should().NotContain("output"); + + generateResult.Should().Be(0); + generateError.Should().BeEmpty(); + generateOutput.Should().ContainAll( + "[Experimental]", + "--template-file", + "--template-root", + "--custom-template-value", + "--custom-template-value-file-path", + "--outdir", + "--outfile", + "--stdout", + "--pattern", + "--no-restore", + "--diagnostics-format"); + } + } + [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..69d6e6fad1c 100644 --- a/src/Bicep.Cli.IntegrationTests/JsonRpcCommandTests.cs +++ b/src/Bicep.Cli.IntegrationTests/JsonRpcCommandTests.cs @@ -7,14 +7,24 @@ using System.Text.Json; using System.Text.Json.Nodes; using Bicep.Cli.Rpc; +using Bicep.Cli.Services; +using Bicep.Core.Configuration; +using Bicep.Core.Documentation; +using Bicep.Core.Exceptions; +using Bicep.Core.Features; using Bicep.Core.Json; +using Bicep.Core.Semantics; 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 Moq; using Newtonsoft.Json.Linq; using StreamJsonRpc; @@ -153,6 +163,458 @@ 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), + async (client, token) => + { + var response = await client.OutputDocs( + new("/main.bicep", 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), + async (client, token) => + { + var response = await client.OutputDocs( + new( + "/main.bicep", + "/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 Docs_methods_apply_configuration_and_request_overrides() + { + var fileSystem = new MockFileSystem(new Dictionary + { + ["/module/main.bicep"] = "metadata name = 'RPC Config'", + ["/module/examples/default/main.bicep"] = "metadata name = 'ignored'", + ["/template.scriban"] = "{{ module.name }}|{{ custom.owner }}|{{ module.usageExamples.size }}", + ["/bicepconfig.json"] = """ + { + "documentation": { + "output": { + "file": "RPC.md" + }, + "template": { + "file": "template.scriban", + "values": { + "owner": "Config" + } + }, + "examples": { + "sources": [] + } + } + } + """, + }); + + await RunServerTest( + services => services.WithFileSystem(fileSystem), + async (client, token) => + { + var output = await client.OutputDocs( + new( + "/module/main.bicep", + null, + null, + new() { ["owner"] = "Request" }, + NoRestore: false), + token); + var generated = await client.GenerateDocs( + new( + ["/module/main.bicep"], + null, + null, + new() { ["owner"] = "Request" }, + null, + NoRestore: false), + token); + + output.Result.Success.Should().BeTrue(); + output.Result.Contents.Should().Be("RPC Config|Request|0\n"); + generated.Results.Should().ContainSingle(); + generated.Results[0].Success.Should().BeTrue(); + generated.Results[0].OutputPath.Should().Be(fileSystem.Path.GetFullPath("/module/RPC.md")); + fileSystem.File.ReadAllText("/module/RPC.md").Should().Be(output.Result.Contents); + }); + } + + [TestMethod] + public async Task Docs_methods_use_discovered_bicep_configuration() + { + var fileSystem = new MockFileSystem(new Dictionary + { + ["/module/main.bicep"] = "metadata name = 'RPC defaults'", + ["/bicepconfig.json"] = """ + { + "documentation": { + "output": { + "file": "RPC.md" + } + } + } + """, + }); + + await RunServerTest( + services => services.WithFileSystem(fileSystem), + async (client, token) => + { + var output = await client.OutputDocs( + new("/module/main.bicep", null, null, null, NoRestore: false), + token); + var generated = await client.GenerateDocs( + new(["/module/main.bicep"], null, null, null, null, NoRestore: false), + token); + + output.Result.Success.Should().BeTrue(); + output.Result.Contents.Should().Contain("# RPC defaults"); + generated.Results.Should().ContainSingle(result => + result.Success && + result.OutputPath == fileSystem.Path.GetFullPath("/module/RPC.md")); + }); + } + + [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), + async (client, token) => + { + var response = await client.GenerateDocs( + new( + ["/valid/main.bicep", "/invalid/main.bicep"], + 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 invalidExtension = await client.OutputDocs( + new("/main.txt", null, null, null, NoRestore: false), + token); + invalidExtension.Result.Success.Should().BeFalse(); + invalidExtension.Result.Diagnostics.Should().ContainSingle(diagnostic => diagnostic.Code == "DOCS001"); + + var invalidPath = await client.OutputDocs( + new("invalid\0path", null, null, null, NoRestore: false), + token); + invalidPath.Result.Success.Should().BeFalse(); + invalidPath.Result.Diagnostics.Should().ContainSingle(diagnostic => diagnostic.Code == "DOCS001"); + + var invalidGeneratePath = await client.GenerateDocs( + new(["invalid\0path"], null, null, null, null, NoRestore: false), + token); + invalidGeneratePath.Results.Should().ContainSingle(); + invalidGeneratePath.Results[0].Success.Should().BeFalse(); + + var missingGeneratePath = await client.GenerateDocs( + new(["/missing.bicep"], null, null, null, null, NoRestore: false), + token); + missingGeneratePath.Results.Should().ContainSingle(); + missingGeneratePath.Results[0].Success.Should().BeFalse(); + + var missingPath = await client.OutputDocs( + new("/missing", 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", "/invalid.scriban", null, null, NoRestore: false), + token); + invalidTemplate.Result.Success.Should().BeFalse(); + invalidTemplate.Result.Diagnostics.Should().ContainSingle(diagnostic => diagnostic.Code == "DOCS003"); + + var missingTemplateRoot = await client.OutputDocs( + new("/main.bicep", null, "/missing", null, NoRestore: false), + token); + missingTemplateRoot.Result.Success.Should().BeFalse(); + missingTemplateRoot.Result.Diagnostics.Should().ContainSingle(diagnostic => diagnostic.Code == "DOCS001"); + + var inputOverwrite = await client.GenerateDocs( + new(["/main.bicep"], null, null, null, "main.bicep", NoRestore: false), + token); + inputOverwrite.Results.Should().ContainSingle(); + inputOverwrite.Results[0].Success.Should().BeFalse(); + + var sourceExtension = await client.GenerateDocs( + new(["/main.bicep"], null, null, null, "child.bicep", NoRestore: false), + token); + sourceExtension.Results.Should().ContainSingle(); + sourceExtension.Results[0].Success.Should().BeFalse(); + + foreach (var invalidOutputFile in new[] { "", " ", ".", "..", "../README.md", @"..\README.md", "bad?.md", "README.md.", "CON.md" }) + { + var invalidOutput = await client.GenerateDocs( + new(["/main.bicep"], null, null, null, invalidOutputFile, NoRestore: false), + token); + invalidOutput.Results.Should().ContainSingle(); + invalidOutput.Results[0].Success.Should().BeFalse(); + invalidOutput.Results[0].Diagnostics.Should().ContainSingle(diagnostic => + diagnostic.Code == "DOCS001" && + diagnostic.Message.Contains("must be a file name")); + } + + var outputCollision = await client.GenerateDocs( + new(["/a.bicep", "/b.bicep"], null, null, null, null, NoRestore: false), + token); + outputCollision.Results.Should().HaveCount(2); + outputCollision.Results[0].Success.Should().BeTrue(); + outputCollision.Results[1].Success.Should().BeFalse(); + outputCollision.Results[1].Diagnostics.Should().ContainSingle(diagnostic => + diagnostic.Code == "DOCS001" && + diagnostic.Message.Contains("resolve to the output file")); + + var mixedResult = await client.GenerateDocs( + new(["/missing", "/main.bicep"], null, null, null, null, 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_rejects_windows_aliased_and_reserved_output_paths() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var root = FileHelper.SaveResultFiles( + TestContext, + [new("main.bicep", "metadata name = 'Safe'")]); + var mainFile = Path.Combine(root, "main.bicep"); + + await RunServerTest( + services => { }, + async (client, token) => + { + var aliasedOutput = await client.GenerateDocs( + new([mainFile], null, null, null, "main.bicep.", NoRestore: false), + token); + aliasedOutput.Results.Should().ContainSingle(); + aliasedOutput.Results[0].Success.Should().BeFalse(); + File.ReadAllText(mainFile).Should().Contain("metadata name"); + + var reservedOutput = await client.GenerateDocs( + new([mainFile], null, null, null, "CON.md", NoRestore: false), + token); + reservedOutput.Results.Should().ContainSingle(); + reservedOutput.Results[0].Success.Should().BeFalse(); + reservedOutput.Results[0].Diagnostics.Should().ContainSingle(diagnostic => diagnostic.Code == "DOCS001"); + }); + } + + [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"); + var fileSystem = new System.IO.Abstractions.FileSystem(); + var fileExplorer = new WriteFailingFileExplorer( + new FileSystemFileExplorer(fileSystem), + "README.md", + new IOException("write failed")); + + await RunServerTest( + services => services + .WithFileSystem(fileSystem) + .WithFileExplorer(fileExplorer), + async (client, token) => + { + var response = await client.GenerateDocs( + new([Path.Combine(root, "main.bicep")], 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" && + diagnostic.Message == "write failed"); + }); + + File.ReadAllText(outputFile).Should().Be("preserve me"); + } + + [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), + async (client, token) => + { + var response = await client.OutputDocs( + new("/main.bicep", 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 Docs_methods_return_structured_path_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(new ArgumentException("invalid path")); + + await RunServerTest( + services => services.WithFileSystem(fileSystem.Object), + async (client, token) => + { + var output = await client.OutputDocs( + new("invalid", null, null, null, NoRestore: false), + token); + var generate = await client.GenerateDocs( + new(["invalid"], null, null, null, null, NoRestore: false), + token); + + output.Result.Success.Should().BeFalse(); + output.Result.Diagnostics.Should().ContainSingle(diagnostic => + diagnostic.Code == "DOCS001" && + diagnostic.Message == "invalid path"); + generate.Results.Should().ContainSingle(); + generate.Results[0].Success.Should().BeFalse(); + generate.Results[0].Diagnostics.Should().ContainSingle(diagnostic => + diagnostic.Code == "DOCS001" && + diagnostic.Message == "invalid path"); + }); + } + + [TestMethod] + public async Task OutputDocs_passes_request_cancellation_to_generation() + { + var fileSystem = new MockFileSystem(new Dictionary + { + ["/main.bicep"] = "metadata name = 'Cancellation'", + }); + var generator = new CancellationObservingDocumentationGenerator(); + + await RunServerTest( + services => services + .WithFileSystem(fileSystem) + .AddSingleton(generator), + async (client, token) => + { + var response = await client.OutputDocs( + new("/main.bicep", null, null, null, NoRestore: false), + token); + + response.Result.Success.Should().BeTrue(); + generator.BuildObserved.Should().BeTrue(); + generator.RenderObserved.Should().BeTrue(); + }); + } + [TestMethod] public async Task GetDeploymentGraph_returns_deployment_graph() { @@ -473,4 +935,46 @@ await RunServerTest( response.Contents.Should().Contain(" location: 'East US'"); }); } + + private sealed class CancellationObservingDocumentationGenerator : IBicepDocumentationGenerator + { + public bool BuildObserved { get; private set; } + + public bool RenderObserved { get; private set; } + + public BicepDocumentationModel BuildModel( + Compilation compilation, + IReadOnlyDictionary? customValues = null, + CancellationToken cancellationToken = default) + { + cancellationToken.CanBeCanceled.Should().BeTrue(); + BuildObserved = true; + + return new( + "Cancellation", + null, + compilation.SourceFileGrouping.EntryPoint.FileHandle.Uri.GetFilePath(), + "resourceGroup", + ImmutableSortedDictionary.Empty, + [], + [], + [], + [], + [], + [], + [], + []); + } + + public string Render( + BicepDocumentationModel model, + BicepDocumentationGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + cancellationToken.CanBeCanceled.Should().BeTrue(); + RenderObserved = true; + + return "# Cancellation\n"; + } + } } diff --git a/src/Bicep.Cli.IntegrationTests/WriteFailingFileExplorer.cs b/src/Bicep.Cli.IntegrationTests/WriteFailingFileExplorer.cs new file mode 100644 index 00000000000..3442681c581 --- /dev/null +++ b/src/Bicep.Cli.IntegrationTests/WriteFailingFileExplorer.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Bicep.IO.Abstraction; + +namespace Bicep.Cli.IntegrationTests; + +internal sealed class WriteFailingFileExplorer( + IFileExplorer inner, + string outputFileName, + Exception exception) : IFileExplorer +{ + public IDirectoryHandle GetDirectory(IOUri uri) => inner.GetDirectory(uri); + + public IFileHandle GetFile(IOUri uri) + { + var file = inner.GetFile(uri); + var fileName = uri.GetFileName(); + return fileName.Equals(outputFileName, StringComparison.OrdinalIgnoreCase) + ? new WriteFailingFileHandle(file, exception) + : file; + } + + private sealed class WriteFailingFileHandle(IFileHandle inner, Exception exception) : IFileHandle + { + public IOUri Uri => inner.Uri; + + public bool Exists() => inner.Exists(); + + public string ReadAllText() => inner.ReadAllText(); + + public Task ReadAllTextAsync(CancellationToken cancellationToken = default) => + inner.ReadAllTextAsync(cancellationToken); + + public bool Equals(IIOHandle? other) => inner.Equals(other); + + public IDirectoryHandle GetParent() => inner.GetParent(); + + public IFileHandle EnsureExists() => inner.EnsureExists(); + + public Stream OpenRead() => inner.OpenRead(); + + public Stream OpenWrite() => throw exception; + + public void WriteAllText(string text) => throw exception; + + public Task WriteAllTextAsync(string text, CancellationToken cancellationToken = default) => + Task.FromException(exception); + + public void Delete() => inner.Delete(); + + public void MakeExecutable() => inner.MakeExecutable(); + + public IFileLock? TryLock() => inner.TryLock(); + } +} 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/Arguments/DocsGenerateArguments.cs b/src/Bicep.Cli/Arguments/DocsGenerateArguments.cs new file mode 100644 index 00000000000..8b3067f51d3 --- /dev/null +++ b/src/Bicep.Cli/Arguments/DocsGenerateArguments.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using Bicep.IO.Abstraction; + +namespace Bicep.Cli.Arguments; + +public record DocsGenerateArguments( + string? InputFile, + string? FilePattern, + string? TemplateFile, + string? TemplateRoot, + ImmutableSortedDictionary CustomValues, + bool OutputToStdOut, + string? OutputDir, + string? OutputFile, + bool NoRestore, + DiagnosticsFormat? DiagnosticsFormat) : IFilePatternInputOutputArguments +{ + public static Func OutputFileExtensionResolver => (_, _) => ".md"; +} diff --git a/src/Bicep.Cli/Arguments/InputOutputArgumentsResolver.cs b/src/Bicep.Cli/Arguments/InputOutputArgumentsResolver.cs index f3cfc0cfe9e..b2276bd138b 100644 --- a/src/Bicep.Cli/Arguments/InputOutputArgumentsResolver.cs +++ b/src/Bicep.Cli/Arguments/InputOutputArgumentsResolver.cs @@ -18,13 +18,32 @@ public InputOutputArgumentsResolver(IFileSystem fileSystem) } public IOUri PathToUri(string path) + { + try + { + return IOUri.FromFilePath(GetFullPath(path)); + } + catch (Exception exception) when (exception.IsPathException()) + { + throw new CommandLineException(exception.Message, exception); + } + } + + public string GetFullPath(string path) { if (!OperatingSystem.IsWindows() && path.Contains('\\')) { throw new CommandLineException(string.Format(CliResources.FilePathContainsBackslash, path)); } - return IOUri.FromFilePath(GetFullPath(path)); + try + { + return this.fileSystem.Path.GetFullPath(path); + } + catch (Exception exception) when (exception.IsPathException()) + { + throw new CommandLineException(exception.Message, exception); + } } public IOUri ResolveInputArguments(IInputArguments arguments) @@ -68,58 +87,122 @@ public IReadOnlyList ResolveFilePatternInputArguments(IFilePatternInputAr throw new CommandLineException("Either the input file path or the --pattern parameter must be specified"); } - public IReadOnlyList<(IOUri InputUri, IOUri OutputUri)> ResolveFilePatternInputOutputArguments(T arguments) + public IReadOnlyList<(IOUri InputUri, IOUri OutputUri)> ResolveFilePatternInputOutputArguments( + T arguments, + Func? outputFileNameResolver = null) where T : IFilePatternInputOutputArguments { if (arguments.InputFile is not null) { - return [this.ResolveInputOutputArguments(arguments)]; + var inputUri = this.ResolveInputArguments(arguments); + var outputUri = this.ResolveOutputUri( + inputUri, + arguments.OutputDir, + arguments.OutputFile, + T.OutputFileExtensionResolver.Invoke(arguments, inputUri), + outputFileNameResolver?.Invoke(arguments, inputUri)); + return [(inputUri, outputUri)]; } if (arguments.FilePattern is not null) { - var result = new List<(IOUri InputUri, IOUri OutputUri)>(); var (rootUri, inputRelativePaths) = this.ResolveFilePattern(arguments.FilePattern); + return ResolveFileSetInputOutputArguments( + arguments, + rootUri, + inputRelativePaths.Select(rootUri.Resolve).ToArray(), + outputFileNameResolver); + } - foreach (var inputRelativePath in inputRelativePaths) + throw new CommandLineException("Either the input file path or the --pattern parameter must be specified"); + } + + internal IReadOnlyList<(IOUri InputUri, IOUri OutputUri)> ResolveFileSetInputOutputArguments( + T arguments, + IOUri rootUri, + IReadOnlyList inputUris, + Func? outputFileNameResolver = null) + where T : IFilePatternInputOutputArguments + { + if (arguments.OutputFile is not null) + { + if (inputUris.Count != 1) { - var inputUri = rootUri.Resolve(inputRelativePath); - var outputRootPath = arguments.OutputDir ?? rootUri.GetFilePath(); - var outputRelativePath = this.fileSystem.Path.ChangeExtension(inputRelativePath, T.OutputFileExtensionResolver.Invoke(arguments, inputUri)); - var outputPath = this.fileSystem.Path.Combine(outputRootPath, outputRelativePath); - var outputUri = this.PathToUri(outputPath); + throw new CommandLineException("The --outfile parameter can only be used when exactly one input file is selected."); + } - result.Add((inputUri, outputUri)); + var inputUri = inputUris[0]; + return + [ + ( + inputUri, + ResolveOutputUri( + inputUri, + arguments.OutputDir, + arguments.OutputFile, + T.OutputFileExtensionResolver.Invoke(arguments, inputUri), + outputFileNameResolver?.Invoke(arguments, inputUri))) + ]; + } + + var result = new List<(IOUri InputUri, IOUri OutputUri)>(); + foreach (var inputUri in inputUris) + { + if (arguments.OutputDir is null) + { + result.Add(( + inputUri, + ResolveOutputUri( + inputUri, + null, + null, + T.OutputFileExtensionResolver.Invoke(arguments, inputUri), + outputFileNameResolver?.Invoke(arguments, inputUri)))); + continue; } - return result; + var inputRelativePath = inputUri.GetPathRelativeTo(rootUri); + var outputRelativePath = outputFileNameResolver is null + ? this.fileSystem.Path.ChangeExtension(inputRelativePath, T.OutputFileExtensionResolver.Invoke(arguments, inputUri)) + : this.fileSystem.Path.Combine( + inputRelativePath[..^this.fileSystem.Path.GetFileName(inputRelativePath).Length], + outputFileNameResolver(arguments, inputUri)); + var outputPath = this.fileSystem.Path.Combine( + GetFullPath(arguments.OutputDir), + outputRelativePath); + result.Add((inputUri, PathToUri(outputPath))); } - throw new CommandLineException("Either the input file path or the --pattern parameter must be specified"); + return result; } - private IOUri ResolveOutputUri(IOUri inputUri, string? outputDir, string? outputFile, string outputFileExtension) + private IOUri ResolveOutputUri( + IOUri inputUri, + string? outputDir, + string? outputFile, + string outputFileExtension, + string? outputFileName = null) { if (outputDir is not null) { - outputDir = this.fileSystem.Path.GetFullPath(outputDir); - var outputFileName = inputUri.GetFileNameWithoutExtension().ToString() + outputFileExtension; - var outputPath = this.fileSystem.Path.Combine(outputDir, outputFileName); + outputDir = this.GetFullPath(outputDir); + var resolvedOutputFileName = outputFileName ?? inputUri.GetFileNameWithoutExtension().ToString() + outputFileExtension; + var outputPath = this.fileSystem.Path.Combine(outputDir, resolvedOutputFileName); return this.PathToUri(outputPath); } if (outputFile is not null) { - return this.PathToUri(this.fileSystem.Path.GetFullPath(outputFile)); + return this.PathToUri(outputFile); } - return inputUri.WithExtension(outputFileExtension); + return outputFileName is null + ? inputUri.WithExtension(outputFileExtension) + : inputUri.Resolve(outputFileName); } - private string GetFullPath(string path) => this.fileSystem.Path.GetFullPath(path); - - private (IOUri rootUri, IReadOnlyList relativePaths) ResolveFilePattern(string filePattern) + internal (IOUri rootUri, IReadOnlyList relativePaths) ResolveFilePattern(string filePattern) { var (rootPath, relativePattern) = SplitFilePatternOnWildcard(filePattern); var rootUri = IOUri.FromFilePath(rootPath); diff --git a/src/Bicep.Cli/Commands/DocsCommand.cs b/src/Bicep.Cli/Commands/DocsCommand.cs new file mode 100644 index 00000000000..d5a73b5ac7f --- /dev/null +++ b/src/Bicep.Cli/Commands/DocsCommand.cs @@ -0,0 +1,236 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using System.CommandLine.Parsing; +using System.IO.Abstractions; +using System.Text.Json; +using Bicep.Cli.Arguments; +using Bicep.Core.Diagnostics; +using Bicep.Core.Documentation; +using Bicep.Core.Exceptions; +using Bicep.Core.Extensions; +using Bicep.Core.Semantics; +using Bicep.Core.SourceGraph; +using Bicep.IO.Abstraction; +using Option = Bicep.Cli.Constants.Option; + +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, + "[Experimental] Generates documentation for Bicep modules."); + command.Add(DocsGenerateCommand.CreateCommand(context)); + + return command; + } + + internal static ImmutableSortedDictionary ParseCustomValues( + System.CommandLine.ParseResult result, + System.CommandLine.Option customTemplateValueOption, + System.CommandLine.Option customTemplateValueFilePathOption, + IFileSystem fileSystem) + { + var customValues = ImmutableSortedDictionary.CreateBuilder(StringComparer.Ordinal); + var tokens = result.Tokens; + for (var index = 0; index < tokens.Count; index++) + { + var token = tokens[index]; + if (token.Type != TokenType.Option) + { + continue; + } + + if (token.Value.Equals(customTemplateValueOption.Name, StringComparison.Ordinal)) + { + SetValue(customValues, GetOptionValue(tokens, ref index, Option.CustomTemplateValue)); + } + else if (token.Value.Equals(customTemplateValueFilePathOption.Name, StringComparison.Ordinal)) + { + LoadValuesFile( + customValues, + GetOptionValue(tokens, ref index, Option.CustomTemplateValueFilePath), + fileSystem); + } + } + + return customValues.ToImmutable(); + } + + private static string GetOptionValue( + IReadOnlyList tokens, + ref int optionIndex, + string optionName) + { + if (optionIndex + 1 >= tokens.Count || tokens[optionIndex + 1].Type != TokenType.Argument) + { + throw new CommandLineException($"The {optionName} parameter expects an argument."); + } + + return tokens[++optionIndex].Value; + } + + private static void SetValue( + ImmutableSortedDictionary.Builder customValues, + string value) + { + var separatorIndex = value.IndexOf('='); + if (separatorIndex <= 0) + { + throw new CommandLineException( + $"The {Option.CustomTemplateValue} value \"{value}\" must use the format key=value."); + } + + customValues[value[..separatorIndex]] = value[(separatorIndex + 1)..]; + } + + private static void LoadValuesFile( + ImmutableSortedDictionary.Builder customValues, + string path, + IFileSystem fileSystem) + { + if (string.IsNullOrWhiteSpace(path)) + { + throw new CommandLineException( + $"The {Option.CustomTemplateValueFilePath} parameter expects a nonempty path."); + } + + string fullPath; + try + { + fullPath = fileSystem.Path.GetFullPath(path); + } + catch (Exception exception) when (exception is ArgumentException or NotSupportedException) + { + throw new CommandLineException( + $"The custom template value file path \"{path}\" is invalid: {exception.Message}", + exception); + } + + if (!fileSystem.File.Exists(fullPath)) + { + throw new CommandLineException($"The custom template value file \"{fullPath}\" does not exist."); + } + + try + { + using var document = JsonDocument.Parse(fileSystem.File.ReadAllText(fullPath)); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + throw new CommandLineException( + $"The custom template value file \"{fullPath}\" must contain a JSON object."); + } + + var keys = new HashSet(StringComparer.Ordinal); + foreach (var property in document.RootElement.EnumerateObject()) + { + if (property.Name.Length == 0) + { + throw new CommandLineException( + $"The custom template value file \"{fullPath}\" contains an empty key."); + } + + if (!keys.Add(property.Name)) + { + throw new CommandLineException( + $"The custom template value file \"{fullPath}\" contains the duplicate key \"{property.Name}\"."); + } + + if (property.Value.ValueKind != JsonValueKind.String) + { + throw new CommandLineException( + $"The custom template value file \"{fullPath}\" value for \"{property.Name}\" must be a string."); + } + + customValues[property.Name] = property.Value.ToString(); + } + } + catch (JsonException exception) + { + throw new CommandLineException( + $"The custom template value file \"{fullPath}\" is not valid JSON: {exception.Message}", + exception); + } + catch (Exception exception) when (exception.IsFileSystemException()) + { + throw new CommandLineException( + $"Unable to read custom template value file \"{fullPath}\": {exception.Message}", + exception); + } + } + + 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); + + internal static void ValidateOutputPaths(IReadOnlyList<(IOUri InputUri, IOUri OutputUri)> paths) + { + var outputUris = new HashSet(); + foreach (var (inputUri, outputUri) in paths) + { + if (inputUri.Equals(outputUri)) + { + throw new CommandLineException("The documentation output path cannot overwrite the input Bicep file."); + } + + if (outputUri.HasBicepExtension() || outputUri.HasBicepParamExtension()) + { + throw new CommandLineException("Documentation output cannot use a Bicep source file extension."); + } + + if (!outputUris.Add(outputUri)) + { + throw new CommandLineException($"Multiple input files resolve to the output file \"{outputUri}\"."); + } + } + } + +} diff --git a/src/Bicep.Cli/Commands/DocsGenerateCommand.cs b/src/Bicep.Cli/Commands/DocsGenerateCommand.cs new file mode 100644 index 00000000000..aba532244eb --- /dev/null +++ b/src/Bicep.Cli/Commands/DocsGenerateCommand.cs @@ -0,0 +1,265 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using System.CommandLine; +using System.IO.Abstractions; +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; + +public class DocsGenerateCommand( + IOContext io, + InputOutputArgumentsResolver argumentsResolver, + DocsCommandRunner runner, + OutputWriter writer, + DiagnosticLogger diagnosticLogger, + IFileSystem fileSystem) : ICommand +{ + public async Task RunAsync(DocsGenerateArguments arguments, CancellationToken cancellationToken = default) + { + var (inputRoot, inputUris) = ResolveInputs(arguments); + var workspace = new ActiveSourceFileSet(); + var successes = new Dictionary(); + 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 in inputUris) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentHelper.ValidateBicepFile(module); + var result = await runner.RenderAsync( + module, + arguments.TemplateFile, + arguments.TemplateRoot, + arguments.CustomValues, + arguments.NoRestore, + arguments.DiagnosticsFormat, + workspace, + logExperimentalWarning: !experimentalWarningLogged, + logDiagnostics: !aggregateSarif, + cancellationToken: cancellationToken); + + if (result.CompilationResult is { } compilation) + { + sarifResults.Add((result.SourceUri, compilation, result.DocumentationDiagnostic)); + } + else if (aggregateSarif) + { + sarifResults.Add((result.SourceUri, null, result.DocumentationDiagnostic)); + } + + if (result is not DocsRenderResult.Succeeded success) + { + if (result.DocumentationDiagnostic?.Code == DocsCommand.RenderFailureCode) + { + experimentalWarningLogged = true; + } + + hasErrors = true; + continue; + } + + experimentalWarningLogged = true; + successes.Add(module, success); + } + + if (arguments.OutputToStdOut) + { + if (successes.Values.SingleOrDefault() is { } success) + { + await io.Output.Writer.WriteAsync(success.Contents.AsMemory(), cancellationToken); + } + } + else if (successes.Count > 0) + { + var inputOutputPairs = argumentsResolver.ResolveFileSetInputOutputArguments( + arguments, + inputRoot, + successes.Keys.ToArray(), + (_, inputUri) => successes[inputUri].Configuration.Documentation.Data.Output.File); + DocsCommand.ValidateOutputPaths(inputOutputPairs); + + foreach (var (inputUri, outputUri) in inputOutputPairs) + { + var success = successes[inputUri]; + try + { + await writer.WriteToFileAsync(outputUri, success.Contents); + } + catch (BicepException exception) + { + 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; + } + + private (Bicep.IO.Abstraction.IOUri RootUri, IReadOnlyList InputUris) ResolveInputs( + DocsGenerateArguments arguments) + { + if (arguments.InputFile is not null) + { + var inputUri = argumentsResolver.ResolveInputArguments(arguments); + return (inputUri.Resolve("."), [inputUri]); + } + + if (arguments.FilePattern is not null) + { + var (rootUri, relativePaths) = argumentsResolver.ResolveFilePattern(arguments.FilePattern); + return (rootUri, relativePaths.Select(rootUri.Resolve).ToArray()); + } + + throw new CommandLineException("Either the input file path or the --pattern parameter must be specified"); + } + + private ImmutableSortedDictionary ParseCustomValues( + System.CommandLine.ParseResult result, + System.CommandLine.Option customTemplateValueOption, + System.CommandLine.Option customTemplateValueFilePathOption) => + DocsCommand.ParseCustomValues( + result, + customTemplateValueOption, + customTemplateValueFilePathOption, + fileSystem); + + internal static System.CommandLine.Command CreateCommand(CommandLineBuilderContext context) + { + var command = new System.CommandLine.Command(Constants.Command.DocsGenerate, "[Experimental] Generates documentation files for Bicep modules.") + { + TreatUnmatchedTokensAsErrors = true, + }; + var inputFileArgument = new System.CommandLine.Argument(Constants.Argument.InputFile) + { + Description = "The path to an input .bicep file.", + Arity = ArgumentArity.ZeroOrOne, + }; + var stdoutOption = new System.CommandLine.Option(Option.Stdout) + { + Description = "Prints the generated documentation to stdout.", + }; + 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. Defaults to the module directory.", + }; + var customTemplateValueOption = new System.CommandLine.Option(Option.CustomTemplateValue) + { + Description = "Supplies a custom template value in key=value form. May be repeated.", + Arity = ArgumentArity.ZeroOrMore, + AllowMultipleArgumentsPerToken = false, + }; + var customTemplateValueFilePathOption = new System.CommandLine.Option(Option.CustomTemplateValueFilePath) + { + Description = "Loads custom template string values from a JSON object file. May be repeated.", + Arity = ArgumentArity.ZeroOrMore, + AllowMultipleArgumentsPerToken = false, + }; + var outDirOption = new System.CommandLine.Option(Option.OutDir) + { + Description = "Saves the generated README.md files beneath the specified directory.", + }; + var outFileOption = new System.CommandLine.Option(Option.OutFile) + { + Description = "Saves the generated documentation as the specified file path.", + }; + var patternOption = new System.CommandLine.Option(Option.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) + { + 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(stdoutOption); + command.Add(templateFileOption); + command.Add(templateRootOption); + command.Add(customTemplateValueOption); + command.Add(customTemplateValueFilePathOption); + command.Add(outDirOption); + command.Add(outFileOption); + 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 () => + { + var handler = context.GetCommand(); + var customValues = handler.ParseCustomValues( + result, + customTemplateValueOption, + customTemplateValueFilePathOption); + var outputDir = result.GetValue(outDirOption); + var outputFile = result.GetValue(outFileOption); + var filePattern = result.GetValue(patternOption); + var outputToStdOut = result.GetValue(stdoutOption); + ArgumentHelper.ValidateOutputOptions(outputToStdOut, outputDir, outputFile, filePattern); + var arguments = new DocsGenerateArguments( + result.GetValue(inputFileArgument), + filePattern, + result.GetValue(templateFileOption), + result.GetValue(templateRootOption), + customValues, + outputToStdOut, + outputDir, + outputFile, + result.GetValue(noRestoreOption), + result.GetValue(diagnosticsFormatOption)); + + return await handler.RunAsync(arguments, ct); + })); + + return command; + } +} diff --git a/src/Bicep.Cli/Commands/JsonRpcCommand.cs b/src/Bicep.Cli/Commands/JsonRpcCommand.cs index 90a70082891..ae8135b0abb 100644 --- a/src/Bicep.Cli/Commands/JsonRpcCommand.cs +++ b/src/Bicep.Cli/Commands/JsonRpcCommand.cs @@ -3,13 +3,16 @@ using System.CommandLine; using System.Diagnostics; +using System.IO.Abstractions; using System.IO.Pipes; using System.Net; using System.Net.Sockets; 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 +24,11 @@ namespace Bicep.Cli.Commands; public class JsonRpcCommand( BicepCompiler compiler, InputOutputArgumentsResolver inputOutputArgumentsResolver, - IEnvironment environment) : ICommand + IEnvironment environment, + IFileSystem fileSystem, + IBicepDocumentationGenerator documentationGenerator, + DocsGenerationOptionsResolver docsOptionsResolver, + OutputWriter writer) : ICommand { public async Task RunAsync(JsonRpcArguments args, CancellationToken cancellationToken) { @@ -62,7 +69,14 @@ 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, + docsOptionsResolver, + fileSystem, + 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..7c0fe4bd503 100644 --- a/src/Bicep.Cli/Constants/CliConstants.cs +++ b/src/Bicep.Cli/Constants/CliConstants.cs @@ -23,6 +23,8 @@ 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 Root = ""; } @@ -49,6 +51,10 @@ public static class Option public const string NoRestore = "--no-restore"; public const string Force = "--force"; public const string DiagnosticsFormat = "--diagnostics-format"; + public const string CustomTemplateValue = "--custom-template-value"; + public const string CustomTemplateValueFilePath = "--custom-template-value-file-path"; + 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..41ab9aaef7a 100644 --- a/src/Bicep.Cli/Helpers/IServiceCollectionExtensions.cs +++ b/src/Bicep.Cli/Helpers/IServiceCollectionExtensions.cs @@ -63,5 +63,6 @@ public static IServiceCollection AddCommands(this IServiceCollection services) = .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddSingleton(); } 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() .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..16026ef897d 100644 --- a/src/Bicep.Cli/Rpc/CliJsonRpcServer.cs +++ b/src/Bicep.Cli/Rpc/CliJsonRpcServer.cs @@ -2,11 +2,18 @@ // Licensed under the MIT License. using System.Collections.Immutable; +using System.IO.Abstractions; using Bicep.Cli.Arguments; +using Bicep.Cli.Commands; using Bicep.Cli.Helpers; +using Bicep.Cli.Services; using Bicep.Core; +using Bicep.Core.Configuration; +using Bicep.Core.Documentation; using Bicep.Core.Emit; +using Bicep.Core.Exceptions; using Bicep.Core.Extensions; +using Bicep.Core.Features; using Bicep.Core.Navigation; using Bicep.Core.PrettyPrint; using Bicep.Core.PrettyPrintV2; @@ -26,7 +33,11 @@ namespace Bicep.Cli.Rpc; public class CliJsonRpcServer( BicepCompiler compiler, InputOutputArgumentsResolver inputOutputArgumentsResolver, - IEnvironment environment) : ICliJsonRpcProtocol + IEnvironment environment, + IBicepDocumentationGenerator documentationGenerator, + DocsGenerationOptionsResolver docsOptionsResolver, + IFileSystem fileSystem, + OutputWriter writer) : ICliJsonRpcProtocol { public static IJsonRpcMessageHandler CreateMessageHandler(Stream inputStream, Stream outputStream) { @@ -267,6 +278,289 @@ public async Task Format(FormatRequest request, CancellationToke return new(formattedContent); } + /// + public async Task GenerateDocs(GenerateDocsRequest request, CancellationToken cancellationToken) + { + var results = ImmutableArray.CreateBuilder(); + 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 + { + var inputUri = inputOutputArgumentsResolver.PathToUri(path); + if (!inputUri.HasBicepExtension()) + { + failures[index] = CreateDocsFailure(path, DocsCommand.InputFailureCode, $"Invalid Bicep file path: {inputUri}"); + continue; + } + + if (!fileSystem.File.Exists(inputUri.GetFilePath())) + { + failures[index] = CreateDocsFailure(path, DocsCommand.InputFailureCode, $"The input file \"{inputUri}\" does not exist."); + continue; + } + + validTargets.Add((index, path, inputUri)); + } + catch (Exception exception) when (exception is BicepException || exception.IsPathException()) + { + failures[index] = CreateDocsFailure(path, DocsCommand.InputFailureCode, exception.Message); + } + } + + var rendered = new Dictionary(); + var workspace = new ActiveSourceFileSet(); + foreach (var target in validTargets) + { + rendered[target.Index] = await RenderDocs( + target.InputUri, + request.TemplateFile, + request.TemplateRoot, + request.Custom, + request.NoRestore, + cancellationToken, + workspace); + } + + var targets = new List(); + var outputUris = new HashSet(); + foreach (var target in validTargets) + { + var renderedResult = rendered[target.Index]; + if (!renderedResult.Result.Success || renderedResult.Result.Contents is null) + { + continue; + } + + try + { + var outputFile = request.OutputFile ?? + renderedResult.Configuration!.Documentation.Data.Output.File; + ValidateDocsOutputFileName(outputFile); + var outputUri = inputOutputArgumentsResolver.PathToUri(target.InputUri.Resolve(outputFile).GetFilePath()); + + if (outputUri.Equals(target.InputUri)) + { + failures[target.Index] = CreateDocsFailure( + target.RequestedPath, + DocsCommand.InputFailureCode, + "The documentation output path cannot overwrite the input Bicep file."); + continue; + } + + if (outputUri.HasBicepExtension() || outputUri.HasBicepParamExtension()) + { + failures[target.Index] = CreateDocsFailure( + target.RequestedPath, + DocsCommand.InputFailureCode, + "Documentation output cannot use a Bicep source file extension."); + continue; + } + + if (!outputUris.Add(outputUri)) + { + failures[target.Index] = CreateDocsFailure( + target.RequestedPath, + DocsCommand.InputFailureCode, + $"Multiple input files resolve to the output file \"{outputUri}\"."); + continue; + } + + targets.Add(new DocsTarget(target.Index, target.InputUri, outputUri)); + } + catch (Exception exception) when (exception is BicepException || exception.IsPathException()) + { + failures[target.Index] = CreateDocsFailure( + target.RequestedPath, + DocsCommand.InputFailureCode, + 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 result = rendered[index].Result; + if (!result.Success || result.Contents is null) + { + results.Add(result); + continue; + } + + var target = targetsByIndex[index]; + try + { + await writer.WriteToFileAsync(target.OutputUri, result.Contents); + results.Add(result with { OutputPath = target.OutputUri.GetFilePath() }); + } + catch (Exception exception) when (exception is BicepException || exception.IsPathException()) + { + results.Add(AddDocsFailure(result, DocsCommand.WriteFailureCode, exception.Message)); + } + } + + return new(results.ToImmutable()); + } + + /// + public async Task OutputDocs(OutputDocsRequest request, CancellationToken cancellationToken) + => new((await RenderDocs( + request.Path, + request.TemplateFile, + request.TemplateRoot, + request.Custom, + request.NoRestore, + cancellationToken, + workspace: null)).Result); + + private async Task RenderDocs( + string path, + string? templateFile, + string? templateRoot, + IReadOnlyDictionary? custom, + bool noRestore, + CancellationToken cancellationToken, + ActiveSourceFileSet? workspace) + { + IOUri inputUri; + try + { + inputUri = inputOutputArgumentsResolver.PathToUri(path); + } + catch (Exception exception) when (exception is BicepException || exception.IsPathException()) + { + return new(CreateDocsFailure(path, DocsCommand.InputFailureCode, exception.Message), null); + } + + return await RenderDocs( + inputUri, + templateFile, + templateRoot, + custom, + noRestore, + cancellationToken, + workspace); + } + + private async Task RenderDocs( + IOUri inputUri, + string? templateFile, + string? templateRoot, + IReadOnlyDictionary? custom, + bool noRestore, + CancellationToken cancellationToken, + ActiveSourceFileSet? workspace) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!inputUri.HasBicepExtension()) + { + return new( + CreateDocsFailure(inputUri.GetFilePath(), DocsCommand.InputFailureCode, $"Invalid Bicep file path: {inputUri}"), + null); + } + + Compilation compilation; + try + { + compilation = await compiler.CreateCompilation(inputUri, workspace, skipRestore: noRestore); + workspace?.UpsertSourceFiles(compilation.SourceFileGrouping.SourceFiles); + } + catch (BicepException exception) + { + return new(CreateDocsFailure(inputUri.GetFilePath(), DocsCommand.InputFailureCode, exception.Message), null); + } + + var diagnostics = GetDiagnostics(compilation).ToImmutableArray(); + var model = compilation.GetEntrypointSemanticModel(); + + if (model.HasErrors()) + { + return new( + new(inputUri.GetFilePath(), null, false, diagnostics, null), + model.Configuration); + } + + BicepDocumentationGenerationOptions options; + try + { + options = docsOptionsResolver.Resolve( + model.Configuration, + templateFile, + templateRoot, + custom ?? ImmutableDictionary.Empty); + } + catch (CommandLineException exception) + { + return new( + CreateDocsFailure(inputUri.GetFilePath(), DocsCommand.InputFailureCode, exception.Message), + model.Configuration); + } + + try + { + cancellationToken.ThrowIfCancellationRequested(); + return new( + new( + inputUri.GetFilePath(), + null, + true, + diagnostics, + documentationGenerator.Generate(compilation, options, cancellationToken)), + model.Configuration); + } + catch (BicepDocumentationException exception) + { + return new( + AddDocsFailure( + new(inputUri.GetFilePath(), null, false, diagnostics, null), + DocsCommand.RenderFailureCode, + exception.Message), + model.Configuration); + } + } + + // These codes describe CLI and RPC orchestration failures that have no source position. + 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 void ValidateDocsOutputFileName(string outputFile) + { + if (string.IsNullOrWhiteSpace(outputFile) || + outputFile is "." or ".." || + outputFile.Contains('/') || + outputFile.Any(FilePathFacts.IsForbiddenPathCharacter) || + FilePathFacts.IsForbiddenPathTerminatorCharacter(outputFile[^1]) || + FilePathFacts.ContainsWindowsReservedFileName(outputFile)) + { + throw new CommandLineException("The documentation output file must be a file name without a directory path."); + } + } + + 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 record RenderedDocsResult(DocsResult Result, RootConfiguration? Configuration); + 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..ae891742b8b 100644 --- a/src/Bicep.Cli/Rpc/ICliJsonRpcProtocol.cs +++ b/src/Bicep.Cli/Rpc/ICliJsonRpcProtocol.cs @@ -129,6 +129,49 @@ public record FormatRequest( public record FormatResponse( string Contents); +/// +/// Requests documentation files for one or more modules. +/// +public record GenerateDocsRequest( + ImmutableArray Paths, + string? TemplateFile, + string? TemplateRoot, + Dictionary? Custom, + string? OutputFile, + bool NoRestore); + +/// +/// Requests rendered documentation for one module. +/// +public record OutputDocsRequest( + string Path, + 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 +227,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..489e3d31c72 --- /dev/null +++ b/src/Bicep.Cli/Services/DocsCommandRunner.cs @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. +// 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.Configuration; +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 abstract record DocsRenderResult( + IOUri SourceUri, + Compilation? CompilationResult, + IDiagnostic? DocumentationDiagnostic) +{ + public sealed record Succeeded( + IOUri SourceUri, + Compilation Compilation, + RootConfiguration Configuration, + 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, + IOContext io, + DiagnosticLogger diagnosticLogger, + BicepCompiler compiler, + IBicepDocumentationGenerator documentationGenerator, + DocsGenerationOptionsResolver optionsResolver) +{ + public async Task RenderAsync( + IOUri inputUri, + string? templateFile, + string? templateRoot, + IReadOnlyDictionary customValues, + bool noRestore, + DiagnosticsFormat? diagnosticsFormat, + ActiveSourceFileSet workspace, + bool logExperimentalWarning = true, + bool logDiagnostics = true, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + Compilation compilation; + try + { + compilation = await compiler.CreateCompilation(inputUri, workspace, skipRestore: noRestore); + workspace.UpsertSourceFiles(compilation.SourceFileGrouping.SourceFiles); + } + 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)); + } + + var shouldLogExperimentalWarning = logExperimentalWarning && diagnosticsFormat is not DiagnosticsFormat.Sarif; + if (shouldLogExperimentalWarning) + { + 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); + } + + if (shouldLogExperimentalWarning) + { + logger.LogWarning(string.Format( + CliResources.ExperimentalFeaturesDisclaimerMessage, + "docs")); + } + + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var configuration = compilation.GetEntrypointSemanticModel().Configuration; + var options = optionsResolver.Resolve( + configuration, + templateFile, + templateRoot, + customValues); + return new DocsRenderResult.Succeeded( + inputUri, + compilation, + configuration, + documentationGenerator.Generate(compilation, options, cancellationToken)); + } + catch (CommandLineException exception) + { + if (diagnosticsFormat is not DiagnosticsFormat.Sarif) + { + await io.Error.Writer.WriteLineAsync(exception.Message); + } + + return new DocsRenderResult.Failed( + inputUri, + compilation, + DocsCommand.CreateDiagnostic(DocsCommand.InputFailureCode, exception.Message)); + } + catch (BicepDocumentationException exception) + { + 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/DocsGenerationOptionsResolver.cs b/src/Bicep.Cli/Services/DocsGenerationOptionsResolver.cs new file mode 100644 index 00000000000..72aa34f5208 --- /dev/null +++ b/src/Bicep.Cli/Services/DocsGenerationOptionsResolver.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using System.IO.Abstractions; +using Bicep.Cli.Arguments; +using Bicep.Core.Configuration; +using Bicep.Core.Documentation; +using Bicep.IO.Abstraction; + +namespace Bicep.Cli.Services; + +public class DocsGenerationOptionsResolver( + InputOutputArgumentsResolver argumentsResolver, + IFileSystem fileSystem) +{ + public BicepDocumentationGenerationOptions Resolve( + RootConfiguration configuration, + string? templateFile, + string? templateRoot, + IReadOnlyDictionary customValues) + { + var settings = configuration.Documentation.Data; + + return new( + ResolveTemplateFile(configuration, templateFile, settings.Template.File), + ResolveTemplateRoot(configuration, templateRoot, settings.Template.IncludeRoot), + MergeCustomValues(settings.Template.Values, customValues)) + { + Examples = settings.Examples, + }; + } + + private IOUri? ResolveTemplateFile( + RootConfiguration configuration, + string? commandLinePath, + string? configuredPath) => + commandLinePath is not null + ? argumentsResolver.PathToUri(commandLinePath) + : configuredPath is not null + ? argumentsResolver.PathToUri(ResolveConfiguredPath(configuration, configuredPath, "template.file")) + : null; + + private IOUri? ResolveTemplateRoot( + RootConfiguration configuration, + string? commandLinePath, + string? configuredPath) + { + var fullPath = commandLinePath is not null + ? argumentsResolver.GetFullPath(commandLinePath) + : configuredPath is not null + ? ResolveConfiguredPath(configuration, configuredPath, "template.includeRoot") + : null; + if (fullPath is null) + { + return null; + } + + if (!fileSystem.Directory.Exists(fullPath)) + { + throw new CommandLineException($"The template include root directory \"{fullPath}\" does not exist."); + } + + return argumentsResolver + .PathToUri(fileSystem.Path.Combine(fullPath, ".bicep-docs-root")) + .Resolve("."); + } + + private string ResolveConfiguredPath( + RootConfiguration configuration, + string configuredPath, + string propertyName) + { + if (fileSystem.Path.IsPathRooted(configuredPath)) + { + return argumentsResolver.GetFullPath(configuredPath); + } + + if (configuration.ConfigFileUri is not { } configFileUri) + { + throw new CommandLineException( + $"The documentation {propertyName} path \"{configuredPath}\" is relative, but no bicepconfig.json file was resolved."); + } + + return argumentsResolver.GetFullPath( + fileSystem.Path.Combine(configFileUri.Resolve(".").GetFilePath(), configuredPath)); + } + + private static ImmutableSortedDictionary MergeCustomValues( + ImmutableSortedDictionary configuredValues, + IReadOnlyDictionary commandLineValues) + { + var values = configuredValues.ToBuilder(); + foreach (var (key, value) in commandLineValues) + { + values[key] = value; + } + + return values.ToImmutable(); + } +} diff --git a/src/Bicep.Cli/Services/OutputWriter.cs b/src/Bicep.Cli/Services/OutputWriter.cs index 2b468dba783..225119f1633 100644 --- a/src/Bicep.Cli/Services/OutputWriter.cs +++ b/src/Bicep.Cli/Services/OutputWriter.cs @@ -170,5 +170,6 @@ public async Task WriteToFileAsync(IOUri fileUri, string contents) throw new BicepException(exception.Message, exception); } } + } } 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/Bicep.Core.UnitTests.csproj b/src/Bicep.Core.UnitTests/Bicep.Core.UnitTests.csproj index 548b58326b3..cc13de4db8a 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..d06cfe31760 100644 --- a/src/Bicep.Core.UnitTests/Configuration/ConfigurationManagerTests.cs +++ b/src/Bicep.Core.UnitTests/Configuration/ConfigurationManagerTests.cs @@ -11,8 +11,8 @@ using Bicep.IO.Abstraction; using Bicep.IO.FileSystem; using Bicep.IO.InMemory; -using Bicep.Testing.IO; using Bicep.Testing; +using Bicep.Testing.IO; using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -126,6 +126,25 @@ public void GetBuiltInConfiguration_NoParameter_ReturnsBuiltInConfigurationWithA "insertFinalNewline": true, "indentSize": 2, "width": 120 + }, + "documentation": { + "output": { "file": "README.md" }, + "template": { "values": {} }, + "examples": { + "sources": [ + { + "path": "examples", + "include": ["*.bicep", "**/main.bicep"], + "exclude": ["**/dependencies*.bicep"] + }, + { + "path": "tests", + "include": ["**/*.test.bicep"], + "exclude": ["**/dependencies*.bicep"] + } + ], + "reassignments": [] + } } } """); @@ -214,6 +233,25 @@ public void GetBuiltInConfiguration_DisableAllAnalyzers_ReturnsBuiltInConfigurat "insertFinalNewline": true, "indentSize": 2, "width": 120 + }, + "documentation": { + "output": { "file": "README.md" }, + "template": { "values": {} }, + "examples": { + "sources": [ + { + "path": "examples", + "include": ["*.bicep", "**/main.bicep"], + "exclude": ["**/dependencies*.bicep"] + }, + { + "path": "tests", + "include": ["**/*.test.bicep"], + "exclude": ["**/dependencies*.bicep"] + } + ], + "reassignments": [] + } } } """); @@ -324,6 +362,25 @@ public void GetBuiltInConfiguration_DisableAnalyzers_ReturnsBuiltInConfiguration "insertFinalNewline": true, "indentSize": 2, "width": 120 + }, + "documentation": { + "output": { "file": "README.md" }, + "template": { "values": {} }, + "examples": { + "sources": [ + { + "path": "examples", + "include": ["*.bicep", "**/main.bicep"], + "exclude": ["**/dependencies*.bicep"] + }, + { + "path": "tests", + "include": ["**/*.test.bicep"], + "exclude": ["**/dependencies*.bicep"] + } + ], + "reassignments": [] + } } } """); @@ -501,6 +558,25 @@ public void GetBuiltInConfiguration_EnableExperimentalFeature_ReturnsBuiltInConf "insertFinalNewline": true, "indentSize": 2, "width": 120 + }, + "documentation": { + "output": { "file": "README.md" }, + "template": { "values": {} }, + "examples": { + "sources": [ + { + "path": "examples", + "include": ["*.bicep", "**/main.bicep"], + "exclude": ["**/dependencies*.bicep"] + }, + { + "path": "tests", + "include": ["**/*.test.bicep"], + "exclude": ["**/dependencies*.bicep"] + } + ], + "reassignments": [] + } } } """); @@ -859,6 +935,25 @@ public void GetConfiguration_ValidCustomConfiguration_OverridesBuiltInConfigurat "insertFinalNewline": true, "indentSize": 2, "width": 80 + }, + "documentation": { + "output": { "file": "README.md" }, + "template": { "values": {} }, + "examples": { + "sources": [ + { + "path": "examples", + "include": ["*.bicep", "**/main.bicep"], + "exclude": ["**/dependencies*.bicep"] + }, + { + "path": "tests", + "include": ["**/*.test.bicep"], + "exclude": ["**/dependencies*.bicep"] + } + ], + "reassignments": [] + } } } """); diff --git a/src/Bicep.Core.UnitTests/Configuration/DocumentationConfigSchemaTests.cs b/src/Bicep.Core.UnitTests/Configuration/DocumentationConfigSchemaTests.cs new file mode 100644 index 00000000000..8ef9603cbe4 --- /dev/null +++ b/src/Bicep.Core.UnitTests/Configuration/DocumentationConfigSchemaTests.cs @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using Bicep.Core.Configuration; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Schema; +using DocumentationSettings = Bicep.Core.Configuration.Documentation; + +namespace Bicep.Core.UnitTests.Configuration; + +[TestClass] +public class DocumentationConfigSchemaTests +{ + private static string GetSchemaContents() + { + using var stream = typeof(DocumentationConfigSchemaTests).Assembly.GetManifestResourceStream( + $"{typeof(DocumentationConfigSchemaTests).Assembly.GetName().Name}.bicepconfig.schema.json"); + Assert.IsNotNull(stream); + + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } + + [TestMethod] + public void Schema_should_parse() + { + var schema = JSchema.Parse(GetSchemaContents()); + + schema.Should().NotBeNull(); + } + + [TestMethod] + public void Schema_should_cover_every_configuration_property() + { + var schema = JObject.Parse(GetSchemaContents()); + var documentationSchema = schema.SelectToken("properties.documentation") + .Should().BeOfType().Subject; + + AssertPropertiesHaveSchema(typeof(DocumentationSettings), documentationSchema, schema); + } + + [TestMethod] + public void Default_configuration_should_validate() + { + var schema = JSchema.Parse(GetSchemaContents()); + var json = JsonSerializer.Serialize( + new + { + documentation = new DocumentationSettings(), + }, + new JsonSerializerOptions + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }); + var document = JObject.Parse(json); + + document.SelectToken("documentation.output.file")!.Value().Should().Be("README.md"); + (document.SelectToken("documentation.examples.sources") as JArray)! + .Count.Should().Be(2); + document.IsValid(schema, out IList errors).Should().BeTrue(string.Join(Environment.NewLine, errors)); + } + + private static void AssertPropertiesHaveSchema(Type type, JObject schemaNode, JObject rootSchema) + { + schemaNode = ResolveReference(schemaNode, rootSchema); + var schemaProperties = schemaNode["properties"] as JObject; + Assert.IsNotNull(schemaProperties, $"{type.Name} must define schema properties"); + + foreach (var property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + var propertyName = property.GetCustomAttribute()?.Name + ?? JsonNamingPolicy.CamelCase.ConvertName(property.Name); + var propertySchema = schemaProperties[propertyName] as JObject; + Assert.IsNotNull(propertySchema, $"{type.Name}.{property.Name} must have a schema property named {propertyName}"); + ResolveReference(propertySchema, rootSchema)["description"]?.Value() + .Should().NotBeNullOrWhiteSpace($"{type.Name}.{property.Name} must have a schema description"); + + if (GetNestedConfigurationType(property.PropertyType) is not { } nestedType) + { + continue; + } + + var nestedSchema = property.PropertyType.IsGenericType && + property.PropertyType.GetGenericTypeDefinition() == typeof(ImmutableArray<>) + ? propertySchema["items"] as JObject + : propertySchema; + Assert.IsNotNull(nestedSchema, $"{type.Name}.{property.Name} must identify its nested schema"); + AssertPropertiesHaveSchema(nestedType, nestedSchema, rootSchema); + } + } + + private static Type? GetNestedConfigurationType(Type type) + { + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ImmutableArray<>)) + { + type = type.GetGenericArguments()[0]; + } + + return type.Namespace == typeof(DocumentationSettings).Namespace && + (type == typeof(DocumentationSettings) || type.Name.StartsWith(nameof(DocumentationSettings), StringComparison.Ordinal)) + ? type + : null; + } + + private static JObject ResolveReference(JObject schemaNode, JObject rootSchema) + { + while (schemaNode["$ref"]?.Value() is { } reference) + { + reference.Should().StartWith("#/"); + var token = reference[2..] + .Split('/') + .Aggregate(rootSchema, (current, segment) => current[segment]!); + schemaNode = token.Should().BeOfType().Subject; + } + + return schemaNode; + } +} diff --git a/src/Bicep.Core.UnitTests/Configuration/RootConfigurationTests.cs b/src/Bicep.Core.UnitTests/Configuration/RootConfigurationTests.cs index 394087fceb7..b602d9d5d39 100644 --- a/src/Bicep.Core.UnitTests/Configuration/RootConfigurationTests.cs +++ b/src/Bicep.Core.UnitTests/Configuration/RootConfigurationTests.cs @@ -2,6 +2,9 @@ // Licensed under the MIT License. using Bicep.Core.Configuration; +using Bicep.Core.Extensions; +using Bicep.Core.Json; +using Bicep.IO.Abstraction; using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -10,6 +13,100 @@ namespace Bicep.Core.UnitTests.Configuration; [TestClass] public class RootConfigurationTests { + [TestMethod] + public void Built_in_documentation_configuration_has_complete_defaults() + { + var documentation = BicepTestConstants.BuiltInConfiguration.Documentation.Data; + + documentation.Output.File.Should().Be("README.md"); + documentation.Template.File.Should().BeNull(); + documentation.Template.IncludeRoot.Should().BeNull(); + documentation.Template.Values.Should().BeEmpty(); + documentation.Examples.Sources.Should().HaveCount(2); + documentation.Examples.Reassignments.Should().BeEmpty(); + } + + [TestMethod] + public void Bind_and_serialize_preserve_documentation_configuration() + { + var configFileUri = IOUri.FromFilePath(Path.GetFullPath("bicepconfig.json")); + var element = IConfigurationManager.BuiltInConfigurationElement.Merge( + JsonElementFactory.CreateElement(""" + { + "documentation": { + "output": { + "file": "DOCS.md" + }, + "template": { + "file": "templates/readme.scriban", + "values": { + "owner": "Platform" + } + }, + "examples": { + "sources": [] + } + } + } + """)); + + var configuration = RootConfiguration.Bind(element, configFileUri); + + configuration.ConfigFileUri.Should().Be(configFileUri); + configuration.Documentation.Data.Output.File.Should().Be("DOCS.md"); + configuration.Documentation.Data.Template.File.Should().Be("templates/readme.scriban"); + configuration.Documentation.Data.Template.Values.Should().Contain("owner", "Platform"); + configuration.Documentation.Data.Examples.Sources.Should().BeEmpty(); + configuration.ToUtf8Json().Should().ContainAll( + "\"documentation\"", + "\"file\": \"DOCS.md\"", + "\"owner\": \"Platform\""); + } + + [DataTestMethod] + [DataRow("""{ "output": null }""", "output, template, and examples")] + [DataRow("""{ "template": null }""", "output, template, and examples")] + [DataRow("""{ "examples": null }""", "output, template, and examples")] + [DataRow("""{ "template": { "values": null } }""", "template.values")] + [DataRow("""{ "examples": { "sources": [{ "path": "/samples" }] } }""", "relative path")] + [DataRow("""{ "examples": { "sources": [{ "path": "\\samples" }] } }""", "relative path")] + [DataRow("""{ "examples": { "sources": [{ "path": "C:\\samples" }] } }""", "relative path")] + public void Documentation_configuration_rejects_invalid_values(string json, string expectedMessage) + { + FluentActions.Invoking(() => + DocumentationConfiguration.Bind(JsonElementFactory.CreateElement(json))) + .Should().Throw() + .WithMessage($"*{expectedMessage}*"); + } + + [TestMethod] + public void Documentation_configuration_normalizes_omitted_nested_collections() + { + var configuration = DocumentationConfiguration.Bind(JsonElementFactory.CreateElement(""" + { + "examples": { + "sources": [ + { + "path": "." + } + ], + "reassignments": [ + { + "from": { + "include": ["**/*"] + }, + "to": "child" + } + ] + } + } + """)); + + configuration.Data.Examples.Sources.Single().Include.Should().BeEmpty(); + configuration.Data.Examples.Sources.Single().Exclude.Should().BeEmpty(); + configuration.Data.Examples.Reassignments.Single().From.Exclude.Should().BeEmpty(); + } + [DataTestMethod] [DynamicData(nameof(GetTestData), DynamicDataSourceType.Method)] public void RootConfiguration_LeadingTildeInCacheRootDirectory_ExpandPath(string cacheRootDirectory, string expectedExpandedDirectory) @@ -25,6 +122,7 @@ public void RootConfiguration_LeadingTildeInCacheRootDirectory_ExpandPath(string BicepTestConstants.BuiltInConfiguration.ExperimentalFeaturesWarning, BicepTestConstants.BuiltInConfiguration.ExperimentalFeaturesEnabled, BicepTestConstants.BuiltInConfiguration.Formatting, + BicepTestConstants.BuiltInConfiguration.Documentation, BicepTestConstants.BuiltInConfiguration.ConfigFileUri, BicepTestConstants.BuiltInConfiguration.Diagnostics); diff --git a/src/Bicep.Core.UnitTests/Diagnostics/LinterRuleTests/UseRecentApiVersionRuleTests.cs b/src/Bicep.Core.UnitTests/Diagnostics/LinterRuleTests/UseRecentApiVersionRuleTests.cs index 0694c0997a2..70fb1747026 100644 --- a/src/Bicep.Core.UnitTests/Diagnostics/LinterRuleTests/UseRecentApiVersionRuleTests.cs +++ b/src/Bicep.Core.UnitTests/Diagnostics/LinterRuleTests/UseRecentApiVersionRuleTests.cs @@ -135,6 +135,7 @@ original.ExperimentalFeaturesEnabled with SymbolicNameCodegen = true, }, original.Formatting, + original.Documentation, null, null); } diff --git a/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationExampleDiscoveryTests.cs b/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationExampleDiscoveryTests.cs new file mode 100644 index 00000000000..8afcd0f3a69 --- /dev/null +++ b/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationExampleDiscoveryTests.cs @@ -0,0 +1,564 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Bicep.Core.Configuration; +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; + +[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_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_UsesLastValue() + { + var fileSet = MockFileSystemTestFileSet.Create( + ("examples/default/main.bicep", "metadata name = 'first'\nmetadata name = 'second'")); + + var examples = BicepDocumentationExampleDiscovery.Discover(GetModuleRoot(fileSet)); + + examples.Should().ContainSingle(); + examples[0].Name.Should().Be("second"); + } + + [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_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() + { + 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_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"), + ("tests/dependencies.test.bicep", "param ignored string"), + ("examples/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_CustomSourcesReplaceDefaultsAndDeduplicateOverlaps() + { + var fileSet = MockFileSystemTestFileSet.Create( + ("examples/default/main.bicep", "metadata name = 'default'"), + ("samples/kept/example.sample", "metadata name = 'kept'"), + ("samples/ignored/example.sample", "metadata name = 'ignored'")); + var configuration = new DocumentationExamples + { + Sources = + [ + new() + { + Path = "samples", + Include = ["**/*.sample"], + Exclude = ["**/ignored/**"], + }, + new() + { + Path = ".", + Include = ["samples/kept/*.sample"], + }, + ], + }; + + var examples = BicepDocumentationExampleDiscovery.Discover( + GetModuleRoot(fileSet), + configuration); + + examples.Should().ContainSingle(); + examples[0].Name.Should().Be("kept"); + } + + [TestMethod] + public void Discover_ExplicitEmptySourcesDisableDiscovery() + { + var fileSet = MockFileSystemTestFileSet.Create( + ("examples/default/main.bicep", "metadata name = 'default'")); + var configuration = new DocumentationExamples + { + Sources = [], + }; + + var examples = BicepDocumentationExampleDiscovery.Discover( + GetModuleRoot(fileSet), + configuration); + + examples.Should().BeEmpty(); + } + + [TestMethod] + public void Discover_DefaultImmutableArraysUseBuiltInSources() + { + var fileSet = MockFileSystemTestFileSet.Create( + ("examples/default/main.bicep", "metadata name = 'default'")); + var configuration = new DocumentationExamples + { + Sources = default, + Reassignments = default, + }; + + var examples = BicepDocumentationExampleDiscovery.Discover( + GetModuleRoot(fileSet), + configuration); + + examples.Should().ContainSingle(); + examples[0].Name.Should().Be("default"); + } + + [TestMethod] + public void Discover_CustomExtensionAtSourceRootUsesCompleteFileNameFallback() + { + var fileSet = MockFileSystemTestFileSet.Create( + ("samples/example.demo", "param value string")); + var configuration = new DocumentationExamples + { + Sources = + [ + new() + { + Path = "samples", + Include = ["*.demo"], + }, + ], + }; + + var examples = BicepDocumentationExampleDiscovery.Discover( + GetModuleRoot(fileSet), + configuration); + + examples.Should().ContainSingle(); + examples[0].Name.Should().Be("example.demo"); + } + + [TestMethod] + public void Discover_DefaultPatternArraysMatchNothing() + { + var fileSet = MockFileSystemTestFileSet.Create( + ("samples/example.bicep", "metadata name = 'ignored'")); + var configuration = new DocumentationExamples + { + Sources = + [ + new() + { + Path = "samples", + Include = default, + Exclude = default, + }, + ], + }; + + var examples = BicepDocumentationExampleDiscovery.Discover( + GetModuleRoot(fileSet), + configuration); + + examples.Should().BeEmpty(); + } + + [TestMethod] + public void Discover_ReassignmentMovesParentExamplesToMatchingChildAndMergesLocalExamples() + { + var fileSet = MockFileSystemTestFileSet.Create( + ("main.bicep", "metadata name = 'parent'"), + ("mg-scope/main.bicep", "metadata name = 'child'"), + ("mg-scope/examples/local/main.bicep", "metadata name = 'local'"), + ("tests/e2e/mg-scope.defaults/main.test.bicep", "metadata name = 'mapped'"), + ("tests/e2e/mg-scope.skip/main.test.bicep", "metadata name = 'excluded'"), + ("tests/e2e/unmapped/main.test.bicep", "metadata name = 'unmapped'")); + var configuration = new DocumentationExamples + { + Reassignments = + [ + new() + { + From = new() + { + Include = ["**/mg-scope.*/**"], + Exclude = ["**/*.skip/**"], + }, + To = "mg-scope", + }, + ], + }; + var parentRoot = GetModuleRoot(fileSet); + var childRoot = fileSet.FileExplorer.GetDirectory(fileSet.GetUri("mg-scope/")); + + var parentExamples = BicepDocumentationExampleDiscovery.Discover(parentRoot, configuration); + var childExamples = BicepDocumentationExampleDiscovery.Discover(childRoot, configuration); + + parentExamples.Select(example => example.Name).Should().Equal("excluded", "unmapped"); + childExamples.Select(example => example.Name).Should().Equal("mapped", "local"); + childExamples.Single(example => example.Name == "mapped").RelativePath + .Should().Be("../tests/e2e/mg-scope.defaults/main.test.bicep"); + } + + [TestMethod] + public void Discover_ReassignmentWithoutMatchingChildIsNoOp() + { + var fileSet = MockFileSystemTestFileSet.Create( + ("tests/e2e/mg-scope.defaults/main.test.bicep", "metadata name = 'kept'")); + var configuration = new DocumentationExamples + { + Reassignments = + [ + new() + { + From = new() { Include = ["**/mg-scope.*/**"] }, + To = "mg-scope", + }, + ], + }; + + var examples = BicepDocumentationExampleDiscovery.Discover( + GetModuleRoot(fileSet), + configuration); + + examples.Should().ContainSingle(); + examples[0].Name.Should().Be("kept"); + } + + [TestMethod] + public void Discover_ReassignmentAtFilesystemRootIsNoOp() + { + var moduleRoot = new Mock(MockBehavior.Strict); + var target = new Mock(MockBehavior.Strict); + moduleRoot.Setup(handle => handle.GetDirectory("child")).Returns(target.Object); + moduleRoot.Setup(handle => handle.GetParent()).Returns((IDirectoryHandle?)null); + target.Setup(handle => handle.Exists()).Returns(false); + var configuration = new DocumentationExamples + { + Sources = [], + Reassignments = + [ + new() + { + From = new() { Include = ["**/*"] }, + To = "child", + }, + ], + }; + + var examples = BicepDocumentationExampleDiscovery.Discover( + moduleRoot.Object, + configuration); + + examples.Should().BeEmpty(); + } + + [TestMethod] + public void Discover_InvalidConfigurationThrowsActionableException() + { + var fileSet = MockFileSystemTestFileSet.Create( + ("samples/example.bicep", "param value string")); + var invalidConfigurations = new DocumentationExamples[] + { + new() { Sources = [null!] }, + new() { Sources = [new() { Path = "" }] }, + new() + { + Sources = + [ + new() + { + Path = "samples", + Include = [null!], + }, + ], + }, + new() { Sources = [], Reassignments = [null!] }, + new() + { + Sources = [], + Reassignments = + [ + new() + { + From = null!, + To = "child", + }, + ], + }, + new() + { + Sources = [], + Reassignments = + [ + new() + { + From = new() { Include = ["**/*"] }, + To = "", + }, + ], + }, + new() + { + Sources = [], + Reassignments = + [ + new() + { + From = new() { Include = ["**/*"] }, + To = "nested/child", + }, + ], + }, + new() + { + Sources = [], + Reassignments = + [ + new() + { + From = new() { Include = ["**/*"] }, + To = ".", + }, + ], + }, + }; + + foreach (var configuration in invalidConfigurations) + { + var action = () => BicepDocumentationExampleDiscovery.Discover( + GetModuleRoot(fileSet), + configuration); + + action.Should().Throw(); + } + } + + [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() + { + 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..1f4c71a4b71 --- /dev/null +++ b/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationGeneratorTests.cs @@ -0,0 +1,1046 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using System.IO.Abstractions; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Bicep.Core.Configuration; +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; +using Moq; + +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('The default storage tier.') + var defaultStorageTier = 'Standard' + + @export() + @description('The default replication mode.') + var defaultReplication = 'LRS' + + @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.ExportedTypes.Select(type => type.Name).Should().Equal( + "allowAllNetworkRule", + "ipRestrictedNetworkRule", + "networkRuleUnion"); + model.ExportedTypes.Single(type => type.Name == "networkRuleUnion") + .Discriminator!.Cases.Select(discriminatorCase => discriminatorCase.Value) + .Should().Equal("allowAll", "ipRestricted"); + + model.ExportedVariables.Select(variable => variable.Name).Should().Equal( + "defaultReplication", + "defaultStorageTier"); + model.ExportedVariables.Single(variable => variable.Name == "defaultStorageTier") + .AllowedValues.Should().Equal("Standard"); + + 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(); + + } + + [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 async Task BuildModel_SortsMultipleFunctionsAndReferences() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile( + ("main.bicep", """ + @export() + func zebra() string => 'z' + + @export() + func alpha() string => 'a' + + module zebraModule 'zebra.bicep' = { + name: 'zebra' + } + + module alphaModule 'alpha.bicep' = { + name: 'alpha' + } + """), + ("zebra.bicep", "metadata description = 'Zebra'"), + ("alpha.bicep", "metadata description = 'Alpha'")); + + var model = compiler.GetService().BuildModel(result.Compilation); + + model.ExportedFunctions.Select(function => function.Name).Should().Equal("alpha", "zebra"); + model.References.Select(reference => reference.SymbolicName).Should().Equal("alphaModule", "zebraModule"); + } + + [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( + TemplateFile: compiler.FileSet.GetUri("readme.scriban"), + TemplateRoot: null, + CustomValues: null); + + var rendered = generator.Generate(result.Compilation, options); + + rendered.Should().Be("# to\n"); + } + + [TestMethod] + public async Task Generate_WithConfiguredExampleSources_UsesOptionsDuringModelConstruction() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile( + ("main.bicep", "metadata name = 'Configured examples'"), + ("examples/default/main.bicep", "metadata name = 'default'"), + ("samples/custom/example.bicep", "metadata name = 'custom'")); + compiler.FileSet.AddFile( + "readme.scriban", + "{{ for example in module.usageExamples }}{{ example.name }}{{ end }}"); + var generator = compiler.GetService(); + var options = new BicepDocumentationGenerationOptions( + TemplateFile: compiler.FileSet.GetUri("readme.scriban"), + TemplateRoot: null, + CustomValues: null) + { + Examples = new() + { + Sources = + [ + new() + { + Path = "samples", + Include = ["**/*.bicep"], + }, + ], + }, + }; + + var rendered = generator.Generate(result.Compilation, options); + + rendered.Should().Be("custom\n"); + } + + [TestMethod] + public void GenerationOptions_EqualityAndWith_BehaveAsValueRecord() + { + var options = BicepDocumentationGenerationOptions.Default; + var clone = options with { }; + var different = options with { TemplateFile = IOUri.FromFilePath(Path.GetFullPath("readme.scriban")) }; + var differentExamples = options with + { + Examples = new() { Sources = [] }, + }; + + options.Should().Be(clone); + (options == clone).Should().BeTrue(); + options.Should().NotBe(different); + options.Should().NotBe(differentExamples); + options.GetHashCode().Should().Be(clone.GetHashCode()); + options.ToString().Should().Contain("TemplateFile"); + } + + [TestMethod] + public void DocumentationConfiguration_DefaultsAreCompleteAndReplaceable() + { + var configuration = new Bicep.Core.Configuration.Documentation(); + var clone = configuration with { }; + var withoutExamples = configuration with + { + Examples = configuration.Examples with { Sources = [] }, + }; + var custom = new Bicep.Core.Configuration.Documentation + { + Output = new() { File = "DOCS.md" }, + Template = new() + { + File = "readme.scriban", + IncludeRoot = "templates", + Values = ImmutableSortedDictionary.Empty.Add("owner", "Platform"), + }, + Examples = new() + { + Sources = + [ + new() + { + Path = "samples", + Include = ["**/*.demo"], + Exclude = ["**/ignored/**"], + }, + ], + Reassignments = + [ + new() + { + From = new() + { + Include = ["**/parent/**"], + Exclude = ["**/ignored/**"], + }, + To = "child", + }, + ], + }, + }; + + configuration.Output.File.Should().Be("README.md"); + configuration.Template.File.Should().BeNull(); + configuration.Template.IncludeRoot.Should().BeNull(); + configuration.Template.Values.Should().BeEmpty(); + configuration.Examples.Sources.Should().HaveCount(2); + configuration.Examples.Reassignments.Should().BeEmpty(); + configuration.Should().Be(clone); + configuration.Should().NotBe(withoutExamples); + custom.Output.File.Should().Be("DOCS.md"); + custom.Template.File.Should().Be("readme.scriban"); + custom.Template.IncludeRoot.Should().Be("templates"); + custom.Template.Values.Should().ContainKey("owner"); + custom.Examples.Sources.Single().Path.Should().Be("samples"); + custom.Examples.Sources.Single().Include.Should().ContainSingle(); + custom.Examples.Sources.Single().Exclude.Should().ContainSingle(); + custom.Examples.Reassignments.Single().From.Include.Should().ContainSingle(); + custom.Examples.Reassignments.Single().From.Exclude.Should().ContainSingle(); + custom.Examples.Reassignments.Single().To.Should().Be("child"); + custom.Should().Be(custom with { }); + (custom.Output == (custom.Output with { })).Should().BeTrue(); + (custom.Template == (custom.Template with { })).Should().BeTrue(); + (custom.Examples.Sources.Single() == (custom.Examples.Sources.Single() with { })).Should().BeTrue(); + (custom.Examples.Reassignments.Single() == (custom.Examples.Reassignments.Single() with { })).Should().BeTrue(); + (custom.Examples.Reassignments.Single().From == (custom.Examples.Reassignments.Single().From with { })).Should().BeTrue(); + custom.Output.GetHashCode().Should().Be((custom.Output with { }).GetHashCode()); + custom.Template.ToString().Should().Contain("readme.scriban"); + } + + [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( + 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_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( + 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( + 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_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() + { + 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( + 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( + 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_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( + 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( + 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( + 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( + 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( + 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( + 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.ExportedTypes.Should().BeEmpty(); + model.ExportedVariables.Should().BeEmpty(); + model.ExportedFunctions.Should().BeEmpty(); + model.References.Should().BeEmpty(); + model.UsageExamples.Should().BeEmpty(); + + 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 Types"); + rendered.Should().NotContain("Exported Variables"); + 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_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(); + } + + [TestMethod] + public void Generate_LegacyInterfaceImplementationUsesCompatibilityModelBuilder() + { + IBicepDocumentationGenerator generator = new LegacyDocumentationGenerator(); + var options = new BicepDocumentationGenerationOptions( + TemplateFile: null, + TemplateRoot: null, + CustomValues: new Dictionary { ["value"] = "configured" }); + + var rendered = generator.Generate(null!, options); + + rendered.Should().Be("configured"); + } + + 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: [], + ExportedTypes: [], + ExportedVariables: [], + ExportedFunctions: [], + References: [], + UsageExamples: []); + + private static string GetEmbeddedFixture(string name) + { + var resourceName = $"Files/Documentation/{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(); + } + + private sealed class LegacyDocumentationGenerator : IBicepDocumentationGenerator + { + public BicepDocumentationModel BuildModel( + Bicep.Core.Semantics.Compilation compilation, + IReadOnlyDictionary? customValues = null, + CancellationToken cancellationToken = default) => + MinimalModel() with + { + Custom = customValues is null + ? ImmutableSortedDictionary.Empty + : customValues.ToImmutableSortedDictionary(StringComparer.Ordinal), + }; + + public string Render( + BicepDocumentationModel model, + BicepDocumentationGenerationOptions? options = null, + CancellationToken cancellationToken = default) => + model.Custom["value"]; + } +} 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..1fbedc04140 --- /dev/null +++ b/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationScriptModelFactoryTests.cs @@ -0,0 +1,179 @@ +// 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]+$", + 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.")], + ExportedTypes: + [ + new BicepDocumentationExport( + "settingsType", + "object", + false, + "Settings.", + [], + null, + null, + null, + null, + null, + false, + [new BicepDocumentationParameter("enabled", "bool", true, false, null, null, [], null, null, null, null, null, false, [], null)], + new BicepDocumentationDiscriminator( + "kind", + [new BicepDocumentationDiscriminatorCase("default", [])])), + ], + ExportedVariables: + [ + new BicepDocumentationExport( + "defaultName", + "string", + false, + "Default name.", + ["default"], + null, + null, + 1, + 20, + "^[a-z]+$", + false, + [], + null), + ], + 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")]); + + 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("truncated").Should().BeTrue(); + parameter.GetSafeValue("defaultValueFence").Should().Be("`````"); + (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 exportedType = module.GetSafeValue("exportedTypes")![0] as ScriptObject; + exportedType!.GetSafeValue("name").Should().Be("settingsType"); + exportedType.GetSafeValue("properties").Should().ContainSingle(); + + var exportedVariable = module.GetSafeValue("exportedVariables")![0] as ScriptObject; + exportedVariable!.GetSafeValue("name").Should().Be("defaultName"); + exportedVariable.GetSafeValue("pattern").Should().Be("^[a-z]+$"); + + 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"); + usageExample.GetSafeValue("fence").Should().Be("```"); + + } + + [TestMethod] + public void Create_MinimalModel_ProjectsNullDiscriminatorAndEmptyArrays() + { + 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, false, [], null)], + Outputs: [], + ExportedTypes: [], + ExportedVariables: [], + ExportedFunctions: [], + References: [], + UsageExamples: []); + + var scriptObject = BicepDocumentationScriptModelFactory.Create(model); + var module = scriptObject.GetSafeValue("module")!; + + module.GetSafeValue("description").Should().BeNull(); + module.GetSafeValue("resourceTypes").Should().BeEmpty(); + + 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/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..1b664a978e4 --- /dev/null +++ b/src/Bicep.Core.UnitTests/Documentation/BicepDocumentationTypeAnalyzerTests.cs @@ -0,0 +1,536 @@ +// 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 analyzer = new BicepDocumentationTypeAnalyzer(); + + 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"); + nonLiteral.TypeName.Should().Be("string | int"); + 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] + 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 = 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() + { + 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 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() + { + 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_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() + { + 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_SecureObjectProperty_ExpandsSchemaWithoutValues() + { + var compiler = TestCompiler.ForMockFileSystemCompilation(); + var result = await compiler.Compile(""" + type settings = { + @secure() + protectedSettings: { + token: string + } + } + param configuration settings + """); + + result.Diagnostics.Should().NotContain(d => d.Level == Bicep.Core.Diagnostics.DiagnosticLevel.Error); + + var generator = compiler.GetService(); + var model = generator.BuildModel(result.Compilation); + var protectedSettings = model.Parameters.Single().NestedProperties.Single(); + + protectedSettings.IsSecure.Should().BeTrue(); + protectedSettings.NestedProperties.Select(property => property.Name).Should().Equal("token"); + protectedSettings.NestedProperties.Single().DefaultValue.Should().BeNull(); + } + + [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.IsTruncated.Should().BeTrue(); + 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.IsTruncated.Should().BeTrue(); + 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 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(); + 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/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..727e9c7efc1 100644 --- a/src/Bicep.Core.UnitTests/Features/FeatureProviderOverrides.cs +++ b/src/Bicep.Core.UnitTests/Features/FeatureProviderOverrides.cs @@ -72,4 +72,3 @@ public FeatureProviderOverrides( AzExtensionConfigEnabled) { } } - diff --git a/src/Bicep.Core.UnitTests/Features/FeatureProviderTests.cs b/src/Bicep.Core.UnitTests/Features/FeatureProviderTests.cs index bccde29f80e..9ae9a9ebc6c 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,5 @@ public void PropertyLookup_WithFeatureEnabledViaBicepConfig_ReturnsTrue() var subDirFeatures = fpm.GetFeatureProvider(fileSet.GetUri("repo/subdir/module.bicep")); subDirFeatures.SymbolicNameCodegenEnabled.Should().BeTrue(); } + } diff --git a/src/Bicep.Core.UnitTests/Features/OverriddenFeatureProvider.cs b/src/Bicep.Core.UnitTests/Features/OverriddenFeatureProvider.cs index cbc828d35e7..98fe06e2873 100644 --- a/src/Bicep.Core.UnitTests/Features/OverriddenFeatureProvider.cs +++ b/src/Bicep.Core.UnitTests/Features/OverriddenFeatureProvider.cs @@ -52,4 +52,5 @@ public OverriddenFeatureProvider(IFeatureProvider features, FeatureProviderOverr public bool RuntimeValuesInTagsAndSkuEnabled => overrides.RuntimeValuesInTagsAndSkuEnabled ?? features.RuntimeValuesInTagsAndSkuEnabled; public bool AzExtensionConfigEnabled => overrides.AzExtensionConfigEnabled ?? features.AzExtensionConfigEnabled; + } diff --git a/src/Bicep.Core.UnitTests/Files/Documentation/ExpectedMarkdown.md b/src/Bicep.Core.UnitTests/Files/Documentation/ExpectedMarkdown.md new file mode 100644 index 00000000000..47217e71d6d --- /dev/null +++ b/src/Bicep.Core.UnitTests/Files/Documentation/ExpectedMarkdown.md @@ -0,0 +1,168 @@ +# Storage Module + +Creates a storage account with example telemetry and diagnostics settings. + +## Navigation + +- [Resource Types](#resource-types) +- [Usage Examples](#usage-examples) +- [Parameters](#parameters) +- [Exported Types](#exported-types) +- [Exported Variables](#exported-variables) +- [Exported Functions](#exported-functions) +- [Outputs](#outputs) +- [Cross-referenced Modules](#cross-referenced-modules) + +## Resource Types + +| Resource Type | Existing | +| :-- | :-- | +| `Microsoft.Network/virtualNetworks@2023-09-01` | Yes | +| `Microsoft.Storage/storageAccounts@2023-01-01` | No | + +## Usage Examples + +### Example 1: _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: + +```bicep +{ + type: 'allowAll' +} +``` + +- Discriminator: `type` + - `allowAll`: + - `type` (`string`), required + - Allowed values: `allowAll` + - `ipRestricted`: + - `allowedIpRanges` (`array`), required: Allowed IP ranges in CIDR notation. + - `type` (`string`), required + - Allowed values: `ipRestricted` + +### `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 Types + +| Name | Type | Description | +| :-- | :-- | :-- | +| `allowAllNetworkRule` | `object` | An allow-all network rule. | +| `ipRestrictedNetworkRule` | `object` | An IP-restricted network rule. | +| `networkRuleUnion` | `object` | | + +### `allowAllNetworkRule` + +- Properties: + - `type` (`string`), required + - Allowed values: `allowAll` + +### `ipRestrictedNetworkRule` + +- Properties: + - `allowedIpRanges` (`array`), required: Allowed IP ranges in CIDR notation. + - `type` (`string`), required + - Allowed values: `ipRestricted` + +### `networkRuleUnion` + +- Discriminator: `type` + - `allowAll`: + - `type` (`string`), required + - Allowed values: `allowAll` + - `ipRestricted`: + - `allowedIpRanges` (`array`), required: Allowed IP ranges in CIDR notation. + - `type` (`string`), required + - Allowed values: `ipRestricted` + +## Exported Variables + +| Name | Type | Description | +| :-- | :-- | :-- | +| `defaultReplication` | `string` | The default replication mode. | +| `defaultStorageTier` | `string` | The default storage tier. | + +### `defaultReplication` + +- Allowed values: `LRS` + +### `defaultStorageTier` + +- Allowed values: `Standard` + +## 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` | | diff --git a/src/Bicep.Core/Bicep.Core.csproj b/src/Bicep.Core/Bicep.Core.csproj index ae06322bb49..57063fb30d6 100644 --- a/src/Bicep.Core/Bicep.Core.csproj +++ b/src/Bicep.Core/Bicep.Core.csproj @@ -25,6 +25,7 @@ + @@ -48,9 +49,11 @@ + + 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/AnalyzersConfigurationExtensions.cs b/src/Bicep.Core/Configuration/AnalyzersConfigurationExtensions.cs index 2170f6cc41e..c94ca8be254 100644 --- a/src/Bicep.Core/Configuration/AnalyzersConfigurationExtensions.cs +++ b/src/Bicep.Core/Configuration/AnalyzersConfigurationExtensions.cs @@ -48,6 +48,7 @@ public static RootConfiguration WithAnalyzersConfiguration(this RootConfiguratio current.ExperimentalFeaturesWarning, current.ExperimentalFeaturesEnabled, current.Formatting, + current.Documentation, current.ConfigFileUri, current.Diagnostics); @@ -72,6 +73,7 @@ public static RootConfiguration WithCloudConfiguration(this RootConfiguration cu current.ExperimentalFeaturesWarning, current.ExperimentalFeaturesEnabled, current.Formatting, + current.Documentation, current.ConfigFileUri, current.Diagnostics); diff --git a/src/Bicep.Core/Configuration/DocumentationConfiguration.cs b/src/Bicep.Core/Configuration/DocumentationConfiguration.cs new file mode 100644 index 00000000000..97c18c92567 --- /dev/null +++ b/src/Bicep.Core/Configuration/DocumentationConfiguration.cs @@ -0,0 +1,322 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using System.Text.Json; +using Bicep.Core.Extensions; +using Bicep.IO.Abstraction; +using Microsoft.Extensions.FileSystemGlobbing; + +namespace Bicep.Core.Configuration; + +/// +/// Configures module documentation generation. +/// +public sealed record Documentation +{ + /// + /// Gets output settings. + /// + public DocumentationOutput Output { get; init; } = new(); + + /// + /// Gets template settings. + /// + public DocumentationTemplate Template { get; init; } = new(); + + /// + /// Gets usage-example settings. + /// + public DocumentationExamples Examples { get; init; } = new(); +} + +/// +/// Configures generated documentation output. +/// +public sealed record DocumentationOutput +{ + /// + /// Gets the generated file name. + /// + public string File { get; init; } = "README.md"; +} + +/// +/// Configures documentation templates. +/// +public sealed record DocumentationTemplate +{ + /// + /// Gets an optional Scriban template file. + /// + public string? File { get; init; } + + /// + /// Gets an optional root directory for template includes. + /// + public string? IncludeRoot { get; init; } + + /// + /// Gets baseline custom template values. + /// + public ImmutableSortedDictionary Values { get; init; } = + ImmutableSortedDictionary.Empty.WithComparers(StringComparer.Ordinal); +} + +/// +/// Configures usage-example discovery. +/// +public sealed record DocumentationExamples +{ + /// + /// Gets discovery sources relative to each module root. + /// + public ImmutableArray Sources { get; init; } = + [ + new() + { + Path = "examples", + Include = ["*.bicep", "**/main.bicep"], + Exclude = ["**/dependencies*.bicep"], + }, + new() + { + Path = "tests", + Include = ["**/*.test.bicep"], + Exclude = ["**/dependencies*.bicep"], + }, + ]; + + /// + /// Gets conditional parent-to-child example reassignments. + /// + public ImmutableArray Reassignments { get; init; } = []; +} + +/// +/// Defines one usage-example discovery source. +/// +public sealed record DocumentationExampleSource +{ + /// + /// Gets the source path relative to a module root. + /// + public required string Path { get; init; } + + /// + /// Gets included example globs. + /// + public ImmutableArray Include { get; init; } = []; + + /// + /// Gets excluded example globs. + /// + public ImmutableArray Exclude { get; init; } = []; +} + +/// +/// Reassigns matching parent examples to one child module directory. +/// +public sealed record DocumentationExampleReassignment +{ + /// + /// Gets patterns selecting examples from a parent module. + /// + public DocumentationPatternSet From { get; init; } = new(); + + /// + /// Gets the destination directory relative to the parent module root. + /// + public required string To { get; init; } +} + +/// +/// Includes and excludes paths using glob patterns. +/// +public sealed record DocumentationPatternSet +{ + /// + /// Gets included path globs. + /// + public ImmutableArray Include { get; init; } = []; + + /// + /// Gets excluded path globs. + /// + public ImmutableArray Exclude { get; init; } = []; +} + +/// +/// Provides the documentation section of a Bicep configuration. +/// +public sealed class DocumentationConfiguration : ConfigurationSection +{ + public DocumentationConfiguration(Documentation data) + : base(data) + { + } + + public static DocumentationConfiguration Bind(JsonElement element) + { + var data = Normalize(element.ToNonNullObject()); + Validate(data); + + return new(data); + } + + private static Documentation Normalize(Documentation data) + { + if (data.Output is null || data.Template is null || data.Examples is null) + { + throw new ConfigurationException("The documentation output, template, and examples properties cannot be null."); + } + + if (data.Template.Values is null) + { + throw new ConfigurationException("The documentation template.values property cannot be null."); + } + + return data with + { + Template = data.Template with + { + Values = data.Template.Values.ToImmutableSortedDictionary(StringComparer.Ordinal), + }, + Examples = data.Examples with + { + Sources = NormalizeSources(data.Examples.Sources), + Reassignments = NormalizeReassignments(data.Examples.Reassignments), + }, + }; + } + + private static ImmutableArray NormalizeSources( + ImmutableArray sources) + { + var normalized = ImmutableArray.CreateBuilder(sources.Length); + foreach (var source in sources) + { + if (source is null) + { + throw new ConfigurationException("The documentation examples.sources property cannot contain null values."); + } + normalized.Add(source); + } + + return normalized.ToImmutable(); + } + + private static ImmutableArray NormalizeReassignments( + ImmutableArray reassignments) + { + var normalized = ImmutableArray.CreateBuilder(reassignments.Length); + foreach (var reassignment in reassignments) + { + if (reassignment is null || reassignment.From is null) + { + throw new ConfigurationException("The documentation examples.reassignments property cannot contain null values."); + } + normalized.Add(reassignment); + } + + return normalized.ToImmutable(); + } + + private static void Validate(Documentation data) + { + ValidateFileName(data.Output.File, "output.file"); + + if (data.Template.File is not null) + { + ValidateNonempty(data.Template.File, "template.file"); + } + + if (data.Template.IncludeRoot is not null) + { + ValidateNonempty(data.Template.IncludeRoot, "template.includeRoot"); + } + + foreach (var key in data.Template.Values.Keys) + { + ValidateNonempty(key, "template.values key"); + } + + foreach (var source in data.Examples.Sources) + { + if (source.Path != ".") + { + ValidateRelativePath(source.Path, "examples.sources[].path", allowNested: true); + } + + ValidatePatterns(source.Include, source.Exclude, "examples.sources[]"); + } + + foreach (var reassignment in data.Examples.Reassignments) + { + ValidatePatterns(reassignment.From.Include, reassignment.From.Exclude, "examples.reassignments[].from"); + if (reassignment.From.Include.IsDefaultOrEmpty) + { + throw new ConfigurationException("The documentation examples.reassignments[].from.include property must contain at least one pattern."); + } + + ValidateRelativePath(reassignment.To, "examples.reassignments[].to", allowNested: false); + } + } + + private static void ValidatePatterns( + ImmutableArray includes, + ImmutableArray excludes, + string path) + { + var matcher = new Matcher(StringComparison.OrdinalIgnoreCase); + foreach (var pattern in includes) + { + ValidateNonempty(pattern, $"{path}.include[]"); + matcher.AddInclude(pattern); + } + + foreach (var pattern in excludes) + { + ValidateNonempty(pattern, $"{path}.exclude[]"); + matcher.AddExclude(pattern); + } + } + + private static void ValidateRelativePath(string value, string path, bool allowNested) + { + ValidateNonempty(value, path); + if (value.StartsWith('/') || + value.StartsWith('\\') || + (value.Length > 1 && value[1] == ':') || + FilePathFacts.IsWindowsDosDevicePath(value)) + { + throw new ConfigurationException($"The documentation {path} property must be a relative path."); + } + + var segments = value.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries); + if (segments.Any(segment => segment is "." or "..") || + (!allowNested && segments.Length != 1)) + { + throw new ConfigurationException($"The documentation {path} property cannot traverse directories."); + } + } + + private static void ValidateFileName(string value, string path) + { + ValidateRelativePath(value, path, allowNested: false); + if (value.Any(FilePathFacts.IsForbiddenPathCharacter) || + FilePathFacts.IsForbiddenPathTerminatorCharacter(value[^1]) || + FilePathFacts.ContainsWindowsReservedFileName(value)) + { + throw new ConfigurationException($"The documentation {path} property must be a portable file name."); + } + } + + private static void ValidateNonempty(string value, string path) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ConfigurationException($"The documentation {path} property cannot be empty."); + } + } +} diff --git a/src/Bicep.Core/Configuration/ExperimentalFeaturesExtensions.cs b/src/Bicep.Core/Configuration/ExperimentalFeaturesExtensions.cs index 8e34ce80e59..9eafa31e2ba 100644 --- a/src/Bicep.Core/Configuration/ExperimentalFeaturesExtensions.cs +++ b/src/Bicep.Core/Configuration/ExperimentalFeaturesExtensions.cs @@ -17,6 +17,7 @@ public static RootConfiguration WithExperimentalFeaturesConfiguration(this RootC current.ExperimentalFeaturesWarning, featuresEnabled, current.Formatting, + current.Documentation, current.ConfigFileUri, current.Diagnostics); diff --git a/src/Bicep.Core/Configuration/ExtensionsConfigurationExtensions.cs b/src/Bicep.Core/Configuration/ExtensionsConfigurationExtensions.cs index 7380759c4c5..cb20006530b 100644 --- a/src/Bicep.Core/Configuration/ExtensionsConfigurationExtensions.cs +++ b/src/Bicep.Core/Configuration/ExtensionsConfigurationExtensions.cs @@ -26,6 +26,7 @@ public static RootConfiguration WithExtensions(this RootConfiguration rootConfig rootConfiguration.ExperimentalFeaturesWarning, rootConfiguration.ExperimentalFeaturesEnabled, rootConfiguration.Formatting, + rootConfiguration.Documentation, rootConfiguration.ConfigFileUri, rootConfiguration.Diagnostics); } @@ -43,6 +44,7 @@ public static RootConfiguration WithImplicitExtensions(this RootConfiguration ro rootConfiguration.ExperimentalFeaturesWarning, rootConfiguration.ExperimentalFeaturesEnabled, rootConfiguration.Formatting, + rootConfiguration.Documentation, rootConfiguration.ConfigFileUri, rootConfiguration.Diagnostics); } diff --git a/src/Bicep.Core/Configuration/RootConfiguration.cs b/src/Bicep.Core/Configuration/RootConfiguration.cs index 496b8480da6..54ad5033d37 100644 --- a/src/Bicep.Core/Configuration/RootConfiguration.cs +++ b/src/Bicep.Core/Configuration/RootConfiguration.cs @@ -34,6 +34,8 @@ public class RootConfiguration public const string FormattingKey = "formatting"; + public const string DocumentationKey = "documentation"; + public RootConfiguration( CloudConfiguration cloud, ModuleAliasesConfiguration moduleAliases, @@ -45,6 +47,7 @@ public RootConfiguration( bool experimentalFeaturesWarning, ExperimentalFeaturesEnabled experimentalFeaturesEnabled, FormattingConfiguration formatting, + DocumentationConfiguration documentation, IOUri? configFileUri, IEnumerable? diagnostics) { @@ -58,6 +61,7 @@ public RootConfiguration( this.ExperimentalFeaturesWarning = experimentalFeaturesWarning; this.ExperimentalFeaturesEnabled = experimentalFeaturesEnabled; this.Formatting = formatting; + this.Documentation = documentation; this.ConfigFileUri = configFileUri; this.Diagnostics = diagnostics?.ToImmutableArray() ?? []; } @@ -74,11 +78,14 @@ public static RootConfiguration Bind(JsonElement element, IOUri? configFileUri = var experimentalFeaturesWarning = element.TryGetProperty(ExperimentalFeaturesWarningKey, out var value) && value.GetBoolean(); var experimentalFeaturesEnabled = ExperimentalFeaturesEnabled.Bind(element.GetProperty(ExperimentalFeaturesEnabledKey)); var formatting = FormattingConfiguration.Bind(element.GetProperty(FormattingKey)); + var documentation = element.TryGetProperty(DocumentationKey, out var documentationElement) + ? DocumentationConfiguration.Bind(documentationElement) + : new DocumentationConfiguration(new()); var extensions = ExtensionsConfiguration.Bind(element.GetProperty(ExtensionsKey)); var implicitExtensions = ImplicitExtensionsConfiguration.Bind(element.GetProperty(ImplicitExtensionsKey)); - return new(cloud, moduleAliases, moduleAliasesMock, extensions, implicitExtensions, analyzers, cacheRootDirectory, experimentalFeaturesWarning, experimentalFeaturesEnabled, formatting, configFileUri, null); + return new(cloud, moduleAliases, moduleAliasesMock, extensions, implicitExtensions, analyzers, cacheRootDirectory, experimentalFeaturesWarning, experimentalFeaturesEnabled, formatting, documentation, configFileUri, null); } public CloudConfiguration Cloud { get; } @@ -101,6 +108,8 @@ public static RootConfiguration Bind(JsonElement element, IOUri? configFileUri = public FormattingConfiguration Formatting { get; } + public DocumentationConfiguration Documentation { get; } + public IOUri? ConfigFileUri { get; } public ImmutableArray Diagnostics { get; } @@ -118,6 +127,7 @@ public RootConfiguration With( bool? experimentalFeaturesWarning = null, ExperimentalFeaturesEnabled? experimentalFeaturesEnabled = null, FormattingConfiguration? formatting = null, + DocumentationConfiguration? documentation = null, IOUri? configFileIdentifier = null, IEnumerable? diagnostics = null) { @@ -132,6 +142,7 @@ public RootConfiguration With( experimentalFeaturesWarning ?? this.ExperimentalFeaturesWarning, experimentalFeaturesEnabled ?? this.ExperimentalFeaturesEnabled, formatting ?? this.Formatting, + documentation ?? this.Documentation, configFileIdentifier ?? this.ConfigFileUri, diagnostics ?? this.Diagnostics); } @@ -174,6 +185,9 @@ public string ToUtf8Json() writer.WritePropertyName(FormattingKey); this.Formatting.WriteTo(writer); + writer.WritePropertyName(DocumentationKey); + this.Documentation.WriteTo(writer); + writer.WriteEndObject(); } diff --git a/src/Bicep.Core/Configuration/bicepconfig.json b/src/Bicep.Core/Configuration/bicepconfig.json index 0612830eb3f..d556cf20178 100644 --- a/src/Bicep.Core/Configuration/bicepconfig.json +++ b/src/Bicep.Core/Configuration/bicepconfig.json @@ -65,5 +65,28 @@ }, "experimentalFeaturesWarning": true, "experimentalFeaturesEnabled": {}, - "formatting": {} + "formatting": {}, + "documentation": { + "output": { + "file": "README.md" + }, + "template": { + "values": {} + }, + "examples": { + "sources": [ + { + "path": "examples", + "include": ["*.bicep", "**/main.bicep"], + "exclude": ["**/dependencies*.bicep"] + }, + { + "path": "tests", + "include": ["**/*.test.bicep"], + "exclude": ["**/dependencies*.bicep"] + } + ], + "reassignments": [] + } + } } diff --git a/src/Bicep.Core/Documentation/BicepDocumentationExampleDiscovery.cs b/src/Bicep.Core/Documentation/BicepDocumentationExampleDiscovery.cs new file mode 100644 index 00000000000..58f40ca739a --- /dev/null +++ b/src/Bicep.Core/Documentation/BicepDocumentationExampleDiscovery.cs @@ -0,0 +1,291 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using Bicep.Core.Configuration; +using Bicep.Core.Parsing; +using Bicep.Core.Syntax; +using Bicep.IO.Abstraction; +using Microsoft.Extensions.FileSystemGlobbing; + +namespace Bicep.Core.Documentation; + +internal static class BicepDocumentationExampleDiscovery +{ + private const int MaxDirectoryDepth = 100; + + public static ImmutableArray Discover( + IDirectoryHandle moduleRoot, + Func? shouldSkip = null) => + Discover(moduleRoot, new(), shouldSkip); + + public static ImmutableArray Discover( + IDirectoryHandle moduleRoot, + DocumentationExamples configuration, + Func? shouldSkip = null) + { + try + { + var sources = configuration.Sources.IsDefault + ? new DocumentationExamples().Sources + : configuration.Sources; + var discovered = DiscoverLocalFiles(moduleRoot, sources, shouldSkip); + ApplyParentReassignments(moduleRoot, configuration, discovered); + ApplyChildReassignments(moduleRoot, configuration, sources, discovered, shouldSkip); + + return BicepDocumentationOrdering.SortByName( + discovered.Values.Select(item => BuildExample(moduleRoot, item)).ToImmutableArray(), + example => example.RelativePath); + } + catch (BicepDocumentationException) + { + throw; + } + catch (Exception exception) when (exception.IsFileSystemException()) + { + throw new BicepDocumentationException( + $"Unable to discover usage examples under '{moduleRoot.Uri}': {exception.Message}", + exception); + } + } + + private static Dictionary DiscoverLocalFiles( + IDirectoryHandle moduleRoot, + ImmutableArray sources, + Func? shouldSkip) + { + var discovered = new Dictionary(); + foreach (var source in sources) + { + if (source is null || string.IsNullOrWhiteSpace(source.Path)) + { + throw new BicepDocumentationException("Usage-example source paths cannot be empty."); + } + + var sourceRoot = moduleRoot.GetDirectory(source.Path); + if (!sourceRoot.Exists()) + { + continue; + } + + var matcher = CreateMatcher(source.Include, source.Exclude); + foreach (var file in EnumerateFiles(sourceRoot, shouldSkip)) + { + var sourceRelativePath = file.Uri.GetPathRelativeTo(sourceRoot.Uri); + if (matcher.Match(sourceRelativePath).HasMatches) + { + discovered.TryAdd(file.Uri, new(file, sourceRoot.Uri, sourceRelativePath)); + } + } + } + + return discovered; + } + + private static Matcher CreateMatcher(ImmutableArray includes, ImmutableArray excludes) + { + try + { + var matcher = new Matcher(StringComparison.OrdinalIgnoreCase); + foreach (var include in includes.IsDefault ? [] : includes) + { + matcher.AddInclude(include); + } + + foreach (var exclude in excludes.IsDefault ? [] : excludes) + { + matcher.AddExclude(exclude); + } + + return matcher; + } + catch (ArgumentException exception) + { + throw new BicepDocumentationException($"Invalid usage-example glob: {exception.Message}", exception); + } + } + + private static void ApplyParentReassignments( + IDirectoryHandle moduleRoot, + DocumentationExamples configuration, + Dictionary discovered) + { + if (configuration.Reassignments.IsDefaultOrEmpty) + { + return; + } + + foreach (var reassignment in configuration.Reassignments) + { + ValidateReassignment(reassignment); + if (!moduleRoot.GetDirectory(reassignment.To).Exists()) + { + continue; + } + + var matcher = CreateMatcher(reassignment.From.Include, reassignment.From.Exclude); + foreach (var fileUri in discovered + .Where(item => matcher.Match(item.Value.SourceRelativePath).HasMatches) + .Select(item => item.Key) + .ToArray()) + { + discovered.Remove(fileUri); + } + } + } + + private static void ApplyChildReassignments( + IDirectoryHandle moduleRoot, + DocumentationExamples configuration, + ImmutableArray sources, + Dictionary discovered, + Func? shouldSkip) + { + if (configuration.Reassignments.IsDefaultOrEmpty) + { + return; + } + + if (moduleRoot.GetParent() is not { } parentRoot) + { + return; + } + + ImmutableDictionary? parentFiles = null; + foreach (var reassignment in configuration.Reassignments) + { + ValidateReassignment(reassignment); + if (!parentRoot.GetDirectory(reassignment.To).Uri.Equals(moduleRoot.Uri)) + { + continue; + } + + parentFiles ??= DiscoverLocalFiles(parentRoot, sources, shouldSkip).ToImmutableDictionary(); + var matcher = CreateMatcher(reassignment.From.Include, reassignment.From.Exclude); + foreach (var item in parentFiles.Where(item => matcher.Match(item.Value.SourceRelativePath).HasMatches)) + { + discovered.TryAdd(item.Key, item.Value); + } + } + } + + private static void ValidateReassignment(DocumentationExampleReassignment reassignment) + { + if (reassignment is null || + reassignment.From is null || + string.IsNullOrWhiteSpace(reassignment.To) || + reassignment.To.IndexOfAny(['/', '\\']) >= 0 || + reassignment.To is "." or "..") + { + throw new BicepDocumentationException("Usage-example reassignments must identify one child module directory."); + } + } + + private static BicepDocumentationUsageExample BuildExample( + IDirectoryHandle moduleRoot, + DiscoveredFile discovered) + { + string contents; + try + { + contents = discovered.File.ReadAllText(); + } + catch (Exception exception) when (exception.IsFileSystemException()) + { + throw new BicepDocumentationException( + $"Unable to read usage example '{discovered.File.Uri}': {exception.Message}", + exception); + } + + var metadata = GetStringMetadata(contents); + var name = metadata.GetValueOrDefault("name") ?? GetExampleName(discovered.SourceRoot, discovered.File.Uri); + + return new( + name, + discovered.File.Uri.GetPathRelativeTo(moduleRoot.Uri), + metadata.GetValueOrDefault("description") ?? TryGetLeadingComment(contents), + contents.TrimEnd()); + } + + private static IEnumerable EnumerateFiles( + IDirectoryHandle directory, + Func? shouldSkip) + { + var pending = new Stack<(IDirectoryHandle Directory, int Depth)>(); + pending.Push((directory, 0)); + + while (pending.TryPop(out var current)) + { + if (current.Depth > MaxDirectoryDepth) + { + throw new BicepDocumentationException( + $"Usage example discovery exceeded the maximum directory depth of {MaxDirectoryDepth} under '{directory.Uri}'."); + } + + foreach (var file in current.Directory.EnumerateFiles("*")) + { + if (shouldSkip?.Invoke(file.Uri) != true) + { + yield return file; + } + } + + foreach (var subdirectory in current.Directory.EnumerateDirectories("*")) + { + if (shouldSkip?.Invoke(subdirectory.Uri) != true) + { + pending.Push((subdirectory, current.Depth + 1)); + } + } + } + } + + private static string GetExampleName(IOUri sourceRoot, IOUri file) + { + var relativeToSource = file.GetPathRelativeTo(sourceRoot); + var segments = relativeToSource.Split('/', StringSplitOptions.RemoveEmptyEntries); + + if (segments.Length > 1) + { + return segments[^2]; + } + + var fileName = segments[^1]; + return fileName.EndsWith(".bicep", StringComparison.OrdinalIgnoreCase) + ? fileName[..^".bicep".Length] + : fileName; + } + + private static ImmutableDictionary GetStringMetadata(string contents) + { + var metadataValues = ImmutableDictionary.CreateBuilder(LanguageConstants.IdentifierComparer); + foreach (var metadata in new Parser(contents).Program().Declarations.OfType()) + { + if (metadata.Value is StringSyntax stringSyntax && + stringSyntax.TryGetLiteralValue() is { } value) + { + metadataValues[metadata.Name.IdentifierName] = value; + } + } + + return metadataValues.ToImmutable(); + } + + private static string? TryGetLeadingComment(string contents) + { + 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; + } + + private sealed record DiscoveredFile( + IFileHandle File, + IOUri SourceRoot, + string SourceRelativePath); +} 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/BicepDocumentationGenerationOptions.cs b/src/Bicep.Core/Documentation/BicepDocumentationGenerationOptions.cs new file mode 100644 index 00000000000..6faca41ab0f --- /dev/null +++ b/src/Bicep.Core/Documentation/BicepDocumentationGenerationOptions.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Bicep.Core.Configuration; +using Bicep.IO.Abstraction; + +namespace Bicep.Core.Documentation; + +/// +/// Options controlling how renders documentation for a module. +/// +/// An optional Scriban template file. +/// An optional root directory for template includes. +/// Optional string values exposed to the template. +public record BicepDocumentationGenerationOptions( + IOUri? TemplateFile, + IOUri? TemplateRoot, + IReadOnlyDictionary? CustomValues) +{ + /// + /// Gets usage-example discovery settings. + /// + public DocumentationExamples Examples { get; init; } = new(); + + /// + /// Gets the built-in Markdown options. + /// + public static BicepDocumentationGenerationOptions Default { get; } = new( + 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..41c5166c394 --- /dev/null +++ b/src/Bicep.Core/Documentation/BicepDocumentationGenerator.cs @@ -0,0 +1,382 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using System.IO.Abstractions; +using System.Reflection; +using Bicep.Core.Configuration; +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; +using Scriban.Syntax; + +namespace Bicep.Core.Documentation; + +public class BicepDocumentationGenerator( + IFileExplorer fileExplorer, + IFileSystem? fileSystem = null) : IBicepDocumentationGenerator +{ + private const string BuiltInTemplateResourceName = "Bicep.Core.Documentation.Templates.Markdown.scriban"; + + private const string MetadataNamePropertyName = "name"; + + private static readonly Lazy BuiltInTemplateSource = new(LoadBuiltInTemplateSource); + + private static readonly Lazy