Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Watch for creation/deletion/modification of local bicepconfig.json file #4038

Merged
merged 39 commits into from
Sep 14, 2021

Conversation

bhsubra
Copy link
Contributor

@bhsubra bhsubra commented Aug 16, 2021

Fixes:
#4009
#4027

BicepConfigChanges

{
// We want to push error dignostics in case of invalid bicepconfig.json.
// We won't be able to apply validation until user fixes issues in config file
return true;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why return true if it's unparseable?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will enable to re trigger compilation and push error diagnostics. RetriggerCompilationOfSourceFilesInWorkspace_WithInvalidBicepConfigFile_ShouldRetriggerCompilation unit test covers this scenario. Let me know if I am missing something here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code doesn't exist anymore. Made updates here:
7c53107

@@ -39,7 +37,7 @@ private IConfigurationRoot BuildConfig(string? localFolder)
// load the default settings from file embedded as resource
var assembly = Assembly.GetExecutingAssembly();
var names = assembly.GetManifestResourceNames();
var defaultConfigResourceName = names.FirstOrDefault(n => n.EndsWith(SettingsFileName));
var defaultConfigResourceName = names.FirstOrDefault(n => n.EndsWith(LanguageConstants.BicepConfigSettingsFileName));
Copy link
Member

@anthony-c-martin anthony-c-martin Aug 16, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this is existing code, but I think it would be better to be precise here and hard-code the manifest resource path, rather than searching for it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated here:
6992577

try
{
JObject jObject = JObject.Parse(bicepConfig);
return jObject.IsValid(bicepConfigSchema);
Copy link
Member

@anthony-c-martin anthony-c-martin Aug 16, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to do server-side schema validation? Performance on schema validation can be quite slow.

If so, shouldn't this be built into the Bicep core libraries to provide a consistent experience for CLI & LSP users?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did this to avoid re triggering compilation on every key stroke. I couldn't think of a better way to handle the scenario. I don't quite understand your question on moving to bicep core libraries. Do you mean the validation logic?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So at present, ConfigHelper handles the loading and reading of config deep inside the semantic model:

this.linterAnalyzerLazy = new Lazy<LinterAnalyzer>(() => new LinterAnalyzer());

->
private readonly ConfigHelper defaultConfigHelper = new ConfigHelper();

->
this.Config = BuildConfig(Directory.GetCurrentDirectory());

->
if (DiscoverLocalConfigurationFile(localFolder) is string localConfig)
{
// we must set reloadOnChange to false here - if it set to true, then ConfigurationBuilder will initialize
// a FileSystem.Watcher instance - which has severe performance impact on non-Windows OSes (https://github.com/dotnet/runtime/issues/42036)
configBuilder.AddJsonFile(localConfig, optional: true, reloadOnChange: false);
this.CustomSettingsFileName = localConfig;
}

I'm wondering if the creator of the SemanticModel instance should be responsible for creating the config and passing it in as a constructor argument, to avoid unneccessary file reads. The ConfigHelper itself could also be responsible for validating the config file, rather than relying on a JSON schema validator.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated as part of this commit:
7c53107

@@ -11,11 +11,18 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.14" />
Copy link
Member

@anthony-c-martin anthony-c-martin Aug 16, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This library has some strange commercial licensing setup attached to it which I've never fully understood. Are there any issues using it here? See https://www.newtonsoft.com/jsonschema ("Licensing & Pricing") for more info.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At one point it’d be nice to have errors on invalid values, but viscose can provide that out of the box, I think we don’t need to build validation in bicep itself.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing this. Looks like it was pulled out of Json.Net(which is under MIT license) and is not free anymore.

I am trying couple other libraries. Let me know if you have any suggestions.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I can use this one instead: https://www.nuget.org/packages/NJsonSchema/. It's under MIT license.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed offline, removed the validation as part of this commit:
7c53107

// Retrigger compilation of source files in workspace when local bicepconfig.json file is edited
if (bicepConfigFileChangeEvents.Any())
{
bicepConfigChangeHandler.RetriggerCompilationOfSourceFilesInWorkspace(compilationManager, bicepConfigFileChangeEvents.First(), workspace);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently every time we rebuild a semantic model, we reload configuration from disk. Should we cache the deserialized configuration and pass it down to the semantic model, to avoid this?

This is technically a different issue, but it feels like it might be simpler to address under this PR as it may change the design significantly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated here:
7c53107

Let me know if you have any questions/concerns. Thanks!

IEnumerable<FileEvent> bicepConfigFileChangeEvents = fileEvents.Where(x => x.Uri.Path.EndsWith(LanguageConstants.BicepConfigSettingsFileName));

// Retrigger compilation of source files in workspace when local bicepconfig.json file is edited
if (bicepConfigFileChangeEvents.Any())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's OK to assume that any config file change => recompile everything, but is it worth trying to be more precise (e.g. figuring out which files will actually be impacted by a given config file)?

Might not be worth optimizing for a relatively rare scenario, just wanted to raise it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made some changes as part of below commit, which should cover this scenario. Recompilation will only be triggered when LanguageId is "bicep":
7c53107

Let me know if I am missing something.

@@ -41,9 +41,9 @@ public CompilationContext Update(IReadOnlyWorkspace workspace, CompilationContex
return this.CreateContext(syntaxTreeGrouping, modelLookup);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you need to transfer the previous configHelper here?

FWIW, I generally feel like default parameters are worth avoiding in C#, as they can be very easy to miss when refactoring, and in this case - it's very important to get it right. IMO it would be worth making configHelper a non-default param on the CreateContext method and Compilation constructor.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. Updated here:
74db298

Comment on lines 195 to 221
ConfigHelper? configHelper = null;

if (!reloadBicepConfig &&
activeContexts.TryGetValue(documentUri, out CompilationContext? compilationContext) &&
compilationContext is not null &&
compilationContext.Compilation.ConfigHelper is ConfigHelper previousConfigHelper)
{
configHelper = previousConfigHelper;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be simplified to:

var configHelper = activeContexts.TryGetValue(documentUri)?.Compilation.ConfigHelper;
if (reloadBicepConfig)
{
    configHelper = null;
}

Copy link
Member

@anthony-c-martin anthony-c-martin Aug 23, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EDIT: In fact, looks like you already have access to the previous context with the prevContext variable, so you shouldn't need to fetch it back with TryGetValue().

Generally - we need to be super careful accessing this dictionary, as we're inside the AddOrUpdate method of a concurrent dictionary - need to be 100% sure what we're doing is thread-safe.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplified as part of this commit:
74db298

if (fileResolver.TryRead(sourceFileUri, out string? bicepFileContents, out ErrorBuilderDelegate _) &&
!string.IsNullOrWhiteSpace(bicepFileContents))
{
compilationManager.UpsertCompilation(DocumentUri.From(sourceFileUri), null, bicepFileContents, reloadBicepConfig: true);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had an offline conversation with @majastrz. I'll be making changes to use RefreshCompilation(..) instead of UpsertCompilation(..) as the latter doesn't handle unsaved changes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated here:
58045fa

Comment on lines 24 to 31
if (configHelper is null)
{
ConfigHelper = new ConfigHelper();
}
else
{
ConfigHelper = configHelper;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Can simplify to just:

ConfigHelper = configHelper ?? new ConfigHelper();

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated as part of this commit:
b90b61e

Comment on lines 43 to 53
IEnumerable<IDiagnostic> diagnostics;

try
{
diagnostics = compilationContext.Compilation.GetEntrypointSemanticModel().GetAllDiagnostics();
}
catch
{
return Task.FromResult(new CommandOrCodeActionContainer());
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the purpose of this code block?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm I don't recollect why I added this. Reverted the change here:
765c098

if (bicepConfigFileChangeEvents.Any())
{
Uri uri = bicepConfigFileChangeEvents.First().Uri.ToUri();
BicepConfigChangeHandler.RefreshCompilationOfSourceFilesInWorkspace(compilationManager, uri, workspace, string.Empty);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use null here - otherwise you can't differentiate between "empty file" and "deleted file"

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In fact, what's the meaning of string.Empty? It seems from the implementation of RefreshCompilationOfSourceFilesInWorkspace() that this signifies a file removal - but are you checking whether it's a delete here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed offline, updated to use null here and added comments:
d3cf19a

{
public class BicepConfigChangeHandler
{
public static void RefreshCompilationOfSourceFilesInWorkspace(ICompilationManager compilationManager, Uri bicepConfigUri, IWorkspace workspace, string bicepConfigFileContents)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make the bicepConfigFileContents param nullable (string?), and change the below check from string.IsNullOrWhiteSpace(bicepConfigFileContents) -> bicepConfigFileContents is null - it's worth being precise to be able to differentiate between "deleted file" and "file with whitespace"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed offline, updated to use null here and added comments:
d3cf19a

@@ -26,6 +28,13 @@ public IEnumerable<ISourceFile> GetSourceFilesForDirectory(Uri fileUri)
public ImmutableDictionary<Uri, ISourceFile> GetActiveSourceFilesByUri()
=> activeFiles.ToImmutableDictionary();

public void UpsertActiveBicepConfig(BicepConfig? bicepConfig)
{
activeConfig = bicepConfig;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't it possible for there to be multiple bicepconfig.json files in a workspace? How do you decide which one to replace?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With multiple config files, we should always pick one in current directory or closer to current directory correct?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing in folder information as part of this commit:
bd34647

Added few integration tests to test multiple config files scenario. Let me know if you have any questions.

@@ -33,6 +34,8 @@ public class BicepCompilationManager : ICompilationManager
// represents compilations of open bicep files
private readonly ConcurrentDictionary<DocumentUri, CompilationContext> activeContexts = new ConcurrentDictionary<DocumentUri, CompilationContext>();

private ConfigHelper? configHelper;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is going to need some change - some things to be aware of:

  • This class can handle concurrent requests so has to be race-condition safe; a mutable field without locking feels like it's going to be problematic.
  • There can be multiple bicepconfig.json files active in the workspace, so storing the value in a single field feels like it can yield incorrect results.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated here:
5f750d2

@bhsubra bhsubra force-pushed the dev/bhsubra/WatchForBicepConfigUpdates branch from 54920d8 to 740b7f8 Compare August 30, 2021 05:35
@@ -77,23 +74,13 @@ public LinterAnalyzer()

internal IEnumerable<IDiagnostic> Analyze(SemanticModel semanticModel, ConfigHelper? overrideConfig = default)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that we have activeConfigHelper set via the constructor, do we need to keep this overload which accepts overrideConfig? If so, do you know which code paths require it? It's always felt like an anti-pattern, as it negates the ability to cache and reuse semantic models for diagnostics.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was used in tests. I removed passing overrideConfig as part of this commit:
1e2de9b

{
this.activeConfigHelper = this.defaultConfigHelper;
this.activeConfigHelper = configHelper;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] rename activeConfigHelper to just configHelper

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated as part of this commit:
1e2de9b

@@ -2,13 +2,19 @@
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.Text;

namespace Bicep.Core.Configuration
{
public class BicepConfig
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm glad you found a way to reuse this strange class 😄

@bhsubra bhsubra force-pushed the dev/bhsubra/WatchForBicepConfigUpdates branch 2 times, most recently from 37e6507 to ff64dd3 Compare September 4, 2021 00:00
Comment on lines 48 to 51
if (activeBicepConfigs.ContainsKey(uri))
{
activeBicepConfigs.TryRemove(uri, out _);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need the ContainsKey check - TryRemove does this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed here:
bbb280e

Comment on lines +54 to +66
if (reloadBicepConfig &&
workspace.TryGetSourceFile(documentUri.ToUri(), out ISourceFile? sourceFile) &&
sourceFile is BicepFile)
{
UpsertCompilationInternal(documentUri, null, sourceFile, reloadBicepConfig);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like @majastrz to take a look at the changes to this class - as the mechanism he added for the module registry work may be useful here for triggering changes on a source file with no syntax differences.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was the mechanism. Although, I'm planning to change the refresh part to take a module reference and have logic to compute which document URIs need to updated and then recompile them in optimal order.

}
else
{
string bicepConfigInCurrentDirectory = Path.Combine(Directory.GetCurrentDirectory(), LanguageConstants.BicepConfigSettingsFileName);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't seem right - won't Directory.GetCurrentDirectory() give you the current working directory? I'd expect there to be some code you can reuse for fetching the correct bicepconfig.json to use for a given Bicep file - would be better to commonize it and use it here rather than reimplementing it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EDIT: I think this implementation is in ConfigHelper.DiscoverLocalConfigurationFile()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to use ConfigHelper.DiscoverLocalConfigurationFile(..) here:
bbb280e

@bhsubra bhsubra force-pushed the dev/bhsubra/WatchForBicepConfigUpdates branch from 5376c82 to 5142ece Compare September 13, 2021 18:55
using System.Reflection;

namespace Bicep.Core.Configuration
{
public class ConfigHelper
{
private const string SettingsFileName = "bicepconfig.json";
private const string bicepConfigResourceName = "Bicep.Core.Configuration.bicepconfig.json";
private readonly IFileResolver fileResolver;

/// <summary>
/// Property exposes the configuration root
/// that is currently loaded
/// </summary>
public IConfigurationRoot Config { get; private set; }
Copy link
Contributor

@shenglol shenglol Sep 13, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One question (non blocking, not related to this PR) - is there any reason why we are using the raw IConfigurationRoot interface? Would it be possible to use ConfigurationBinder.Bind to bind the IConfigurationRoot to an instance of a strongly typed config class?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The linter rules currently read dynamically from the config:

this.disallowedHosts = GetArray(nameof(DisallowedHosts), Array.Empty<string>())
.ToImmutableArray();
this.MinimumHostLength = this.disallowedHosts.Value.Min(h => h.Length);
this.excludedHosts = GetArray(nameof(ExcludedHosts), Array.Empty<string>())
.ToImmutableArray();

It would be nice to have a strongly-typed config class (e.g. for the alias work you're doing), but might need to keep some dynamic-ness for linters if we want 3rd parties to be able to author their own.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll sync up with you guys offline to see if we can improve something here that will help alias work. Thanks!

Copy link
Member

@anthony-c-martin anthony-c-martin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
5 participants