diff --git a/src/Bicep.Core/Configuration/BicepConfigurationChain.cs b/src/Bicep.Core/Configuration/BicepConfigurationChain.cs index 0cc1a84181e..8c9993b6603 100644 --- a/src/Bicep.Core/Configuration/BicepConfigurationChain.cs +++ b/src/Bicep.Core/Configuration/BicepConfigurationChain.cs @@ -3,6 +3,7 @@ using System.Collections.Immutable; using Bicep.Core.Diagnostics; +using Bicep.IO.Abstraction; namespace Bicep.Core.Configuration; @@ -69,6 +70,17 @@ public IEnumerable GetAllDiagnostics() return this.aggregatedDiagnostics.Value; } + /// + /// Returns diagnostics grouped by the config file URI they originated from. + /// Built-in default layers (no ) are excluded. + /// + public DiagnosticsPerFile EnumerateDiagnosticsPerFile() + => this.layers + .Where(layer => !layer.IsBuiltIn) + .Select(layer => new KeyValuePair>( + layer.ConfigFileUri!, + layer.GetDiagnostics().ToImmutableArray())); + /// /// The number of configuration files in the chain, including the built-in defaults. /// diff --git a/src/Bicep.Core/Configuration/IBicepConfigurationChain.cs b/src/Bicep.Core/Configuration/IBicepConfigurationChain.cs index 698dcf9d6b7..fa8be21723e 100644 --- a/src/Bicep.Core/Configuration/IBicepConfigurationChain.cs +++ b/src/Bicep.Core/Configuration/IBicepConfigurationChain.cs @@ -17,6 +17,12 @@ public interface IBicepConfigurationChain /// IBicepConfiguration GetEffectiveConfiguration(); + /// + /// Returns diagnostics grouped by the config file URI they originated from. + /// Only user-defined config files are included (built-in defaults are excluded). + /// + DiagnosticsPerFile EnumerateDiagnosticsPerFile(); + /// /// The number of configuration files in the chain. /// diff --git a/src/Bicep.Core/Diagnostics/GlobalUsings.cs b/src/Bicep.Core/Diagnostics/GlobalUsings.cs new file mode 100644 index 00000000000..ac6c8bf5e62 --- /dev/null +++ b/src/Bicep.Core/Diagnostics/GlobalUsings.cs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +global using DiagnosticsPerFile = + System.Collections.Generic.IEnumerable< + System.Collections.Generic.KeyValuePair< + Bicep.IO.Abstraction.IOUri, + System.Collections.Immutable.ImmutableArray>>; diff --git a/src/Bicep.LangServer.UnitTests/Configuration/BicepConfigChangeHandlerTests.cs b/src/Bicep.LangServer.UnitTests/Configuration/BicepConfigChangeHandlerTests.cs index 73fa16e228c..1eb186d3605 100644 --- a/src/Bicep.LangServer.UnitTests/Configuration/BicepConfigChangeHandlerTests.cs +++ b/src/Bicep.LangServer.UnitTests/Configuration/BicepConfigChangeHandlerTests.cs @@ -194,6 +194,42 @@ public void RefreshCompilationOfSourceFilesInWorkspace_WithEmptySourceFile_Shoul diagnostics.Should().BeNullOrEmpty(); } + [TestMethod] + public void HandleBicepConfigOpenEvent_WithCyclicExtends_PublishesBCP454ToConfigFileUri() + { + var mockFileSystem = new MockFileSystem(); + mockFileSystem.AddFile("/a/bicepconfig.json", """{ "extends": "./b/bicepconfig.json" }"""); + mockFileSystem.AddFile("/a/b/bicepconfig.json", """{ "extends": "../bicepconfig.json" }"""); + + PublishDiagnosticsParams? receivedParams = null; + var document = BicepCompilationManagerHelper.CreateMockDocument(p => receivedParams = p); + ILanguageServerFacade server = BicepCompilationManagerHelper.CreateMockServer(document).Object; + + var fileExplorer = new FileSystemFileExplorer(mockFileSystem); + var bicepConfigManager = new BicepConfigurationManager(fileExplorer); + var configurationManager = new ConfigurationManager(fileExplorer, bicepConfigManager); + var workspace = new ActiveSourceFileSet(); + var bicepCompilationManager = new BicepCompilationManager( + server, + BicepCompilationManagerHelper.CreateEmptyCompilationProvider(configurationManager), + workspace, + BicepCompilationManagerHelper.CreateMockScheduler().Object, + new SourceFileFactory(configurationManager, BicepTestConstants.FeatureProviderFactory, BicepTestConstants.AuxiliaryFileCache, BicepTestConstants.FileExplorer), + BicepTestConstants.AuxiliaryFileCache); + + var lifecycleManager = new BicepConfigLifecycleManager( + bicepCompilationManager, + configurationManager, + bicepConfigManager, + server); + + var configUri = DocumentUri.From(InMemoryFileResolver.GetFileUri("/a/bicepconfig.json")); + lifecycleManager.HandleBicepConfigOpenEvent(configUri); + + receivedParams.Should().NotBeNull(); + receivedParams!.Diagnostics.Should().ContainSingle(d => d.Code!.Value.String == "BCP454"); + } + [TestMethod] public void RefreshCompilationOfSourceFilesInWorkspace_WithoutBicepConfigFile_ShouldUseDefaultConfigAndRefreshCompilation() { @@ -246,7 +282,8 @@ private void RefreshCompilationOfSourceFilesInWorkspace(string bicepFileContents var workspace = new ActiveSourceFileSet(); var fileExplorer = new FileSystemFileExplorer(mockFileSystem); - var configurationManager = new ConfigurationManager(fileExplorer, new BicepConfigurationManager(fileExplorer)); + var bicepConfigManager = new BicepConfigurationManager(fileExplorer); + var configurationManager = new ConfigurationManager(fileExplorer, bicepConfigManager); var sourceFileFactory = new SourceFileFactory(configurationManager, BicepTestConstants.FeatureProviderFactory, BicepTestConstants.AuxiliaryFileCache, BicepTestConstants.FileExplorer); var bicepCompilationManager = new BicepCompilationManager( server, @@ -259,7 +296,9 @@ private void RefreshCompilationOfSourceFilesInWorkspace(string bicepFileContents var bicepConfigLifecycleManager = new BicepConfigLifecycleManager( bicepCompilationManager, - configurationManager); + configurationManager, + bicepConfigManager, + server); bicepConfigLifecycleManager.RefreshCompilationOfSourceFilesInWorkspace(); diff --git a/src/Bicep.LangServer/BicepConfig/BicepConfigLifecycleManager.cs b/src/Bicep.LangServer/BicepConfig/BicepConfigLifecycleManager.cs index 1eaa98646e5..e28f35e9882 100644 --- a/src/Bicep.LangServer/BicepConfig/BicepConfigLifecycleManager.cs +++ b/src/Bicep.LangServer/BicepConfig/BicepConfigLifecycleManager.cs @@ -2,8 +2,14 @@ // Licensed under the MIT License. using Bicep.Core.Configuration; +using Bicep.Core.Diagnostics; using Bicep.LanguageServer.Compilation; +using Bicep.LanguageServer.Extensions; using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Document; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; +using LspDiagnostic = OmniSharp.Extensions.LanguageServer.Protocol.Models.Diagnostic; namespace Bicep.LanguageServer.BicepConfig { @@ -11,12 +17,18 @@ public class BicepConfigLifecycleManager : IBicepConfigLifecycleManager { private readonly ICompilationManager compilationManager; private readonly ConfigurationManager configurationManager; + private readonly IBicepConfigurationManager bicepConfigurationManager; + private readonly ILanguageServerFacade server; public BicepConfigLifecycleManager(ICompilationManager compilationManager, - ConfigurationManager configurationManager) + ConfigurationManager configurationManager, + IBicepConfigurationManager bicepConfigurationManager, + ILanguageServerFacade server) { this.compilationManager = compilationManager; this.configurationManager = configurationManager; + this.bicepConfigurationManager = bicepConfigurationManager; + this.server = server; } public void RefreshCompilationOfSourceFilesInWorkspace() @@ -29,6 +41,7 @@ public void RefreshCompilationOfSourceFilesInWorkspace() public void HandleBicepConfigOpenEvent(DocumentUri documentUri) { HandleBicepConfigOpenOrChangeEvent(documentUri); + PublishConfigDiagnostics(documentUri); } public void HandleBicepConfigChangeEvent(DocumentUri documentUri) @@ -44,9 +57,68 @@ private void HandleBicepConfigOpenOrChangeEvent(DocumentUri documentUri) => configurationManager.RefreshConfigCacheEntry(documentUri.ToIOUri()); public void HandleBicepConfigSaveEvent(DocumentUri documentUri) - => configurationManager.RefreshConfigCacheEntry(documentUri.ToIOUri()); + { + configurationManager.RefreshConfigCacheEntry(documentUri.ToIOUri()); + PublishConfigDiagnostics(documentUri); + } public void HandleBicepConfigCloseEvent(DocumentUri documentUri) - => configurationManager.RemoveConfigCacheEntry(documentUri.ToIOUri()); + { + configurationManager.RemoveConfigCacheEntry(documentUri.ToIOUri()); + + // Clear squiggles when the file is closed. + server.TextDocument.PublishDiagnostics(new PublishDiagnosticsParams + { + Uri = documentUri, + Diagnostics = new Container() + }); + } + + private void PublishConfigDiagnostics(DocumentUri documentUri) + { + var chain = bicepConfigurationManager.GetConfigurationChain(documentUri.ToIOUri()); + + // Always publish to documentUri first — covers chain-level errors (cycle, too deep) + // stored on the built-in fallback layer, and clears stale squiggles on the active file. + var effectiveDiagnostics = chain.GetEffectiveConfiguration().GetDiagnostics(); + server.TextDocument.PublishDiagnostics(new PublishDiagnosticsParams + { + Uri = documentUri, + Diagnostics = new Container(effectiveDiagnostics.Select(d => new LspDiagnostic + { + Severity = ToLspSeverity(d.Level), + Code = new DiagnosticCode(d.Code), + Message = d.Message, + Range = new OmniSharp.Extensions.LanguageServer.Protocol.Models.Range(0, 0, 0, 0), + Source = "bicep" + })) + }); + + // Publish diagnostics for every user-layer config file in the chain — including empty + // arrays so the client clears stale squiggles for files that no longer have errors. + foreach (var (fileUri, diagnostics) in chain.EnumerateDiagnosticsPerFile()) + { + server.TextDocument.PublishDiagnostics(new PublishDiagnosticsParams + { + Uri = DocumentUri.From(fileUri.ToUri()), + Diagnostics = new Container(diagnostics.Select(d => new LspDiagnostic + { + Severity = ToLspSeverity(d.Level), + Code = new DiagnosticCode(d.Code), + Message = d.Message, + Range = new OmniSharp.Extensions.LanguageServer.Protocol.Models.Range(0, 0, 0, 0), + Source = "bicep" + })) + }); + } + } + + private static DiagnosticSeverity ToLspSeverity(DiagnosticLevel level) => level switch + { + DiagnosticLevel.Error => DiagnosticSeverity.Error, + DiagnosticLevel.Warning => DiagnosticSeverity.Warning, + DiagnosticLevel.Info => DiagnosticSeverity.Information, + _ => DiagnosticSeverity.Hint + }; } } diff --git a/src/Bicep.LangServer/IServiceCollectionExtensions.cs b/src/Bicep.LangServer/IServiceCollectionExtensions.cs index bf88b7eb17f..6dfa73b2521 100644 --- a/src/Bicep.LangServer/IServiceCollectionExtensions.cs +++ b/src/Bicep.LangServer/IServiceCollectionExtensions.cs @@ -54,6 +54,7 @@ BicepLangServerOptions bicepLangServerOptions .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/src/vscode-bicep/resources/configuration/bicepconfig.schema.json b/src/vscode-bicep/resources/configuration/bicepconfig.schema.json index 4f25890ac4a..4fea562e64d 100644 --- a/src/vscode-bicep/resources/configuration/bicepconfig.schema.json +++ b/src/vscode-bicep/resources/configuration/bicepconfig.schema.json @@ -157,6 +157,11 @@ "type": "object", "default": {}, "properties": { + "extends": { + "title": "Extends", + "description": "Relative path to a base bicepconfig.json to inherit settings from. Absolute paths are not allowed.", + "type": "string" + }, "cloud": { "title": "Cloud", "type": "object",