diff --git a/src/Bicep.Cli.IntegrationTests/LintCommandTests.cs b/src/Bicep.Cli.IntegrationTests/LintCommandTests.cs index a43de486022..2104038d3e9 100644 --- a/src/Bicep.Cli.IntegrationTests/LintCommandTests.cs +++ b/src/Bicep.Cli.IntegrationTests/LintCommandTests.cs @@ -256,6 +256,81 @@ param notUsedParam int error.Should().Contain($"{inputFile}(3,1) : Error BCP259: The parameter \"notUsedParm\" is assigned in the params file without being declared in the Bicep file."); } + [TestMethod] + public async Task Lint_with_config_option_should_override_discovered_configuration() + { + var outputPath = FileHelper.GetUniqueTestOutputPath(TestContext); + + var inputFile = FileHelper.SaveResultFile(TestContext, "main.bicep", """ + param unusedParam string + """, outputPath); + + // This config will be discovered automatically and should produce a warning. + FileHelper.SaveResultFile(TestContext, "bicepconfig.json", """ + { + "analyzers": { + "core": { + "rules": { + "no-unused-params": { + "level": "warning" + } + } + } + } + } + """, outputPath); + + // This config is supplied explicitly and disables the rule. + var overrideConfig = FileHelper.SaveResultFile(TestContext, "override.json", """ + { + "analyzers": { + "core": { + "rules": { + "no-unused-params": { + "level": "off" + } + } + } + } + } + """, outputPath); + + var (output, error, result) = await Bicep( + "lint", + inputFile, + "--config", + overrideConfig); + + result.Should().Be(0); + output.Should().BeEmpty(); + + // Rule should NOT fire because override.json was used. + error.Should().BeEmpty(); + } + + [TestMethod] + public async Task Lint_with_missing_config_should_report_configuration_error() + { + var outputPath = FileHelper.GetUniqueTestOutputPath(TestContext); + + var inputFile = FileHelper.SaveResultFile(TestContext, "main.bicep", """ + param foo string + """, outputPath); + + var missingConfig = Path.Combine(outputPath, "doesnotexist.json"); + + var (output, error, result) = await Bicep( + "lint", + inputFile, + "--config", + missingConfig); + + result.Should().Be(1); + output.Should().BeEmpty(); + + error.Should().Contain("doesnotexist.json"); + } + [TestMethod] public async Task Lint_with_sarif_diagnostics_format_should_output_valid_sarif() { diff --git a/src/Bicep.Cli/Arguments/LintArguments.cs b/src/Bicep.Cli/Arguments/LintArguments.cs index 4652869e745..8b110499e8f 100644 --- a/src/Bicep.Cli/Arguments/LintArguments.cs +++ b/src/Bicep.Cli/Arguments/LintArguments.cs @@ -7,4 +7,5 @@ public record LintArguments( string? InputFile, string? FilePattern, DiagnosticsFormat? DiagnosticsFormat, - bool NoRestore) : IFilePatternInputArguments; + bool NoRestore, + string? Config) : IFilePatternInputArguments; diff --git a/src/Bicep.Cli/Commands/LintCommand.cs b/src/Bicep.Cli/Commands/LintCommand.cs index c95a8fa61b3..228f4cd1c98 100644 --- a/src/Bicep.Cli/Commands/LintCommand.cs +++ b/src/Bicep.Cli/Commands/LintCommand.cs @@ -7,6 +7,7 @@ using Bicep.Cli.Helpers; using Bicep.Cli.Logging; using Bicep.Core; +using Bicep.Core.Configuration; using Bicep.Core.Features; using Bicep.Core.Utils; using Bicep.IO.Abstraction; @@ -20,7 +21,8 @@ public class LintCommand( ILogger logger, DiagnosticLogger diagnosticLogger, BicepCompiler compiler, - InputOutputArgumentsResolver inputOutputArgumentsResolver) : ICommand + InputOutputArgumentsResolver inputOutputArgumentsResolver, + IConfigurationManager configurationManager) : ICommand { public async Task RunAsync(LintArguments args) { @@ -30,16 +32,20 @@ public async Task RunAsync(LintArguments args) { ArgumentHelper.ValidateBicepOrBicepParamFile(inputUri); - var result = await Lint(inputUri, args.NoRestore, args.DiagnosticsFormat); + var result = await Lint(inputUri, args.NoRestore, args.DiagnosticsFormat, args.Config); hasErrors |= result.HasErrors; } return CommandHelper.GetExitCode(new(hasErrors)); } - private async Task Lint(IOUri inputUri, bool noRestore, DiagnosticsFormat? diagnosticsFormat) + private async Task Lint(IOUri inputUri, bool noRestore, DiagnosticsFormat? diagnosticsFormat, string? config) { - var compilation = await compiler.CreateCompilation(inputUri, skipRestore: noRestore); + + var lintCompiler = GetCompiler(config); + + var compilation = await lintCompiler.CreateCompilation(inputUri, skipRestore: noRestore); + CommandHelper.LogExperimentalWarning(logger, compilation); var summary = diagnosticLogger.LogDiagnostics(ArgumentHelper.GetDiagnosticOptions(diagnosticsFormat) with { SarifToStdout = true }, compilation); @@ -68,11 +74,16 @@ internal static System.CommandLine.Command CreateCommand(CommandLineBuilderConte { Description = "Sets the diagnostics format. Valid values are (Default, SARIF).", }; + var configOption = new System.CommandLine.Option(Option.Config) + { + Description = "Specifies the path to a bicepconfig.json file to use.", + }; command.Add(inputFileArgument); command.Add(filePatternOption); command.Add(noRestoreOption); command.Add(diagnosticsFormatOption); + command.Add(configOption); command.Validators.Add((System.CommandLine.Parsing.CommandResult result) => CommandLineBuilderContext.ValidatePositionalArgument(result, inputFileArgument)); command.SetAction((result, ct) => context.RunCommandAsync(async () => @@ -82,11 +93,30 @@ internal static System.CommandLine.Command CreateCommand(CommandLineBuilderConte result.GetValue(inputFileArgument), result.GetValue(filePatternOption), diagnosticsFormat, - result.GetValue(noRestoreOption)); + result.GetValue(noRestoreOption), + result.GetValue(configOption)); return await context.GetCommand().RunAsync(args); })); return command; } + + private BicepCompiler GetCompiler(string? config) + { + if (config is null) + { + return compiler; + } + + var configUri = IOUri.FromFilePath(config); + + var configuration = configurationManager.LoadConfiguration(configUri); + + return BicepCompiler.Create(services => + { + services.AddSingleton( + IConfigurationManager.WithStaticConfiguration(configuration)); + }); + } } diff --git a/src/Bicep.Cli/Constants/CliConstants.cs b/src/Bicep.Cli/Constants/CliConstants.cs index 549645ce37a..d416c3c3db6 100644 --- a/src/Bicep.Cli/Constants/CliConstants.cs +++ b/src/Bicep.Cli/Constants/CliConstants.cs @@ -63,6 +63,9 @@ public static class Option public const string IndentSize = "--indent-size"; public const string InsertFinalNewline = "--insert-final-newline"; + // Lint + public const string Config = "--config"; + // Publish public const string Target = "--target"; public const string DocumentationUri = "--documentation-uri"; diff --git a/src/Bicep.Core.UnitTests/Configuration/ConfigurationManagerTests.cs b/src/Bicep.Core.UnitTests/Configuration/ConfigurationManagerTests.cs index f08e4a0ec94..b8ef6b359b7 100644 --- a/src/Bicep.Core.UnitTests/Configuration/ConfigurationManagerTests.cs +++ b/src/Bicep.Core.UnitTests/Configuration/ConfigurationManagerTests.cs @@ -958,5 +958,45 @@ public void GetConfiguration_ModuleAliasesMock_FallsBackToModuleAliasesWhenAlias configuration.ModuleAliases.TryGetOciArtifactModuleAlias("myAlias").IsSuccess(out var alias).Should().BeTrue(); alias!.Registry.Should().Be("real.azurecr.io"); } + + [TestMethod] + public void LoadConfiguration_LoadsExplicitConfigFile() + { + // Arrange. + var fileExplorer = new InMemoryFileExplorer(); + var configFileUri = TestFileUri.FromInMemoryPath("path/to/bicepconfig.json"); + + var configFile = fileExplorer.GetFile(configFileUri); + + using (var stream = configFile.OpenWrite()) + using (var writer = new StreamWriter(stream)) + { + writer.Write("{}"); + } + + var sut = new ConfigurationManager(fileExplorer); + + // Act. + var configuration = sut.LoadConfiguration(configFileUri); + + // Assert. + configuration.Should().NotBeNull(); + } + + [TestMethod] + public void LoadConfiguration_MissingFileReturnsDiagnostic() + { + // Arrange. + var fileExplorer = new InMemoryFileExplorer(); + var sut = new ConfigurationManager(fileExplorer); + + var configFileUri = TestFileUri.FromInMemoryPath("path/to/missing/bicepconfig.json"); + + // Act. + var configuration = sut.LoadConfiguration(configFileUri); + + // Assert. + configuration.Diagnostics.Should().NotBeEmpty(); + } } } diff --git a/src/Bicep.Core.UnitTests/Configuration/PatchingConfigurationManager.cs b/src/Bicep.Core.UnitTests/Configuration/PatchingConfigurationManager.cs index bfffef26013..54cf8b8bab4 100644 --- a/src/Bicep.Core.UnitTests/Configuration/PatchingConfigurationManager.cs +++ b/src/Bicep.Core.UnitTests/Configuration/PatchingConfigurationManager.cs @@ -18,4 +18,6 @@ public PatchingConfigurationManager(ConfigurationManager configurationManager, F } public RootConfiguration GetConfiguration(IOUri sourceFileUri) => patchFunc(configurationManager.GetConfiguration(sourceFileUri)); + + public RootConfiguration LoadConfiguration(IOUri configFileUri) => patchFunc(configurationManager.LoadConfiguration(configFileUri)); } diff --git a/src/Bicep.Core/Configuration/ConfigurationManager.cs b/src/Bicep.Core/Configuration/ConfigurationManager.cs index 3b44be13640..e49f881478f 100644 --- a/src/Bicep.Core/Configuration/ConfigurationManager.cs +++ b/src/Bicep.Core/Configuration/ConfigurationManager.cs @@ -2,10 +2,6 @@ // Licensed under the MIT License. using System.Collections.Concurrent; -using System.Collections.Immutable; -using System.IO.Abstractions; -using System.Security; -using System.ServiceModel; using System.Text.Json; using Bicep.Core.Diagnostics; using Bicep.Core.Extensions; @@ -46,12 +42,35 @@ public RootConfiguration GetConfiguration(IOUri sourceFileUri) return GetDefaultConfiguration(); } - if (!loadedConfigCache.GetOrAdd(configFileHandle, LoadConfiguration).IsSuccess(out var configuration, out var loadDiagnostic)) + if (!loadedConfigCache.GetOrAdd(configFileHandle, LoadConfigurationInternal).IsSuccess(out var configuration, out var diagnostic)) { - return GetDefaultConfiguration().With(diagnostics: [loadDiagnostic]); + return GetDefaultConfiguration() + .With(diagnostics: [diagnostic]); } - return configuration; + return configuration!; + } + + public RootConfiguration LoadConfiguration(IOUri configFileUri) + { + var configFileHandle = this.fileExplorer.GetFile(configFileUri); + + if (!configFileHandle.Exists()) + { + return GetDefaultConfiguration() + .With(diagnostics: + [ + ConfigDiagnosticBuilder.ConfigurationFileNotFound(configFileHandle.Uri) + ]); + } + + if (!loadedConfigCache.GetOrAdd(configFileHandle, LoadConfigurationInternal).IsSuccess(out var configuration, out var diagnostic)) + { + return GetDefaultConfiguration() + .With(diagnostics: [diagnostic]); + } + + return configuration!; } public void PurgeCache() @@ -66,13 +85,14 @@ public void PurgeCache() { (RootConfiguration, RootConfiguration)? returnVal = null; var configFileHandle = this.fileExplorer.GetFile(configFileIdentifier); - loadedConfigCache.AddOrUpdate(configFileHandle, LoadConfiguration, (handle, prev) => + loadedConfigCache.AddOrUpdate(configFileHandle, LoadConfigurationInternal, (handle, prev) => { - var reloaded = LoadConfiguration(handle); + var reloaded = LoadConfigurationInternal(handle); if (prev.IsSuccess(out var prevConfig) && reloaded.IsSuccess(out var newConfig)) { returnVal = (prevConfig, newConfig); } + return reloaded; }); @@ -91,7 +111,7 @@ public void RemoveConfigCacheEntry(IOUri configFileUri) private static RootConfiguration GetDefaultConfiguration() => IConfigurationManager.GetBuiltInConfiguration(); - private static ResultWithDiagnostic LoadConfiguration(IFileHandle configFileHandle) + private static ResultWithDiagnostic LoadConfigurationInternal(IFileHandle configFileHandle) { try { diff --git a/src/Bicep.Core/Configuration/IConfigurationManager.cs b/src/Bicep.Core/Configuration/IConfigurationManager.cs index 84e4bc17943..b84ee82289d 100644 --- a/src/Bicep.Core/Configuration/IConfigurationManager.cs +++ b/src/Bicep.Core/Configuration/IConfigurationManager.cs @@ -20,6 +20,14 @@ public interface IConfigurationManager /// The configuration for the source file. RootConfiguration GetConfiguration(IOUri sourceFileUri); + /// + /// Gets the configuration from the specified configuration file. + /// If the configuration file cannot be loaded, the built-in configuration is returned with diagnostics. + /// + /// The URI of the configuration file to load. + /// The configuration loaded from the specified file. + RootConfiguration LoadConfiguration(IOUri configFileUri); + /// /// Gets the built-in configuration. /// @@ -56,6 +64,8 @@ internal ConstantConfigurationManager(RootConfiguration configuration) } public RootConfiguration GetConfiguration(IOUri sourceFileUri) => configuration; + + public RootConfiguration LoadConfiguration(IOUri configFileUri) => configuration; } } }