File: Handler\Configuration\DidChangeConfigurationNotificationHandler.cs
Web Access
Project: src\src\LanguageServer\Protocol\Microsoft.CodeAnalysis.LanguageServer.Protocol.csproj (Microsoft.CodeAnalysis.LanguageServer.Protocol)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
 
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.ImplementType;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CommonLanguageServerProtocol.Framework;
using Roslyn.LanguageServer.Protocol;
using Roslyn.Utilities;
using LSP = Roslyn.LanguageServer.Protocol;
 
namespace Microsoft.CodeAnalysis.LanguageServer.Handler.Configuration;
 
[Method(Methods.WorkspaceDidChangeConfigurationName)]
internal sealed partial class DidChangeConfigurationNotificationHandler : ILspServiceNotificationHandler<LSP.DidChangeConfigurationParams>, IOnInitialized
{
    private bool _supportWorkspaceConfiguration;
    private readonly ILspLogger _lspLogger;
    private readonly IGlobalOptionService _globalOptionService;
    private readonly IClientLanguageServerManager _clientLanguageServerManager;
    private readonly Guid _registrationId;
 
    /// <summary>
    /// All the <see cref="ConfigurationItem.Section"/> needs to be refreshed from the client. 
    /// </summary>
    private readonly ImmutableArray<ConfigurationItem> _configurationItems;
 
    /// <summary>
    /// The matching option and its language name needs to be refreshed. The order matches <see cref="_configurationItems"/> sent to the client.
    /// LanguageName would be null if the option is <see cref="ISingleValuedOption"/>.
    /// </summary>
    private readonly ImmutableArray<(IOption2 option, string? lanugageName)> _optionsAndLanguageNamesToRefresh;
 
    private static readonly ImmutableDictionary<string, string> s_languageNameToPrefix = ImmutableDictionary<string, string>.Empty
        .Add(LanguageNames.CSharp, "csharp")
        .Add(LanguageNames.VisualBasic, "visual_basic");
 
    public static readonly ImmutableArray<string> SupportedLanguages = [LanguageNames.CSharp, LanguageNames.VisualBasic];
 
    public DidChangeConfigurationNotificationHandler(
        ILspLogger logger,
        IGlobalOptionService globalOptionService,
        IClientLanguageServerManager clientLanguageServerManager)
    {
        _lspLogger = logger;
        _globalOptionService = globalOptionService;
        _clientLanguageServerManager = clientLanguageServerManager;
        _registrationId = Guid.NewGuid();
        _configurationItems = GenerateGlobalConfigurationItems();
        _optionsAndLanguageNamesToRefresh = GenerateOptionsNeedsToRefresh();
        RoslynDebug.Assert(_configurationItems.Length == _optionsAndLanguageNamesToRefresh.Length);
    }
 
    public bool MutatesSolutionState => true;
 
    public bool RequiresLSPSolution => false;
 
    public Task HandleNotificationAsync(DidChangeConfigurationParams request, RequestContext requestContext, CancellationToken cancellationToken)
        => RefreshOptionsAsync(cancellationToken);
 
    private async Task RefreshOptionsAsync(CancellationToken cancellationToken)
    {
        // We rely on the workspace/configuration to get the option values. If client doesn't support this, don't update.
        if (!_supportWorkspaceConfiguration)
        {
            return;
        }
 
        var configurationsFromClient = await GetConfigurationsAsync(cancellationToken).ConfigureAwait(false);
        if (configurationsFromClient.IsEmpty)
        {
            // Failed to get values from client, do nothing.
            return;
        }
 
        // We always fetch VB and C# value from client if the option is IPerLanguageValuedOption.
        RoslynDebug.Assert(configurationsFromClient.Length == SupportedOptions.Sum(option => option is IPerLanguageValuedOption ? 2 : 1));
 
        // LSP ensures the order of result from client should match the order we sent from server.
        using var _ = ArrayBuilder<KeyValuePair<OptionKey2, object?>>.GetInstance(out var optionsToUpdate);
 
        for (var i = 0; i < configurationsFromClient.Length; i++)
        {
            var valueFromClient = configurationsFromClient[i];
            var (option, languageName) = _optionsAndLanguageNamesToRefresh[i];
 
            // If option doesn't exist in the client, don't try to update the option.
            if (!string.IsNullOrEmpty(valueFromClient))
            {
                if (option.Definition.Serializer.TryParse(valueFromClient, out var parsedValue))
                {
                    optionsToUpdate.Add(KeyValuePairUtil.Create(new OptionKey2(option, language: languageName), parsedValue));
                }
                else
                {
                    _lspLogger.LogWarning($"Failed to parse '{valueFromClient}' to type: '{option.Type.Name}'. '{option.Name}' would not be updated.");
                }
            }
        }
 
        _globalOptionService.SetGlobalOptions(optionsToUpdate.ToImmutable());
    }
 
    private async Task<ImmutableArray<string?>> GetConfigurationsAsync(CancellationToken cancellationToken)
    {
        // Attempt to get configurations from the client.  If this throws we'll get NFW reports.
        var configurationParams = new ConfigurationParams() { Items = _configurationItems.AsArray() };
        var options = await _clientLanguageServerManager.SendRequestAsync<ConfigurationParams, JsonArray>(
            Methods.WorkspaceConfigurationName, configurationParams, cancellationToken).ConfigureAwait(false);
 
        // Failed to get result from client.
        Contract.ThrowIfNull(options);
        var converted = options.SelectAsArray(token => token?.ToString());
        return converted;
    }
 
    private static ImmutableArray<(IOption2 option, string? langaugeName)> GenerateOptionsNeedsToRefresh()
    {
        using var _ = ArrayBuilder<(IOption2, string?)>.GetInstance(out var builder);
        foreach (var option in SupportedOptions)
        {
            if (option is IPerLanguageValuedOption)
            {
                foreach (var language in SupportedLanguages)
                {
                    builder.Add((option, language));
                }
            }
            else
            {
                builder.Add((option, null));
            }
        }
 
        return builder.ToImmutableAndClear();
    }
 
    /// <summary>
    /// Generate the configuration items send to the client.
    /// For each option, generate its full name. If the option is <see cref="ISingleValuedOption"/> it's is what we sent to the client.
    /// If it is <see cref="IPerLanguageValuedOption"/>, then generate two configurationItems with prefix visual_basic and csharp.
    /// </summary>
    private static ImmutableArray<ConfigurationItem> GenerateGlobalConfigurationItems()
    {
        using var _ = ArrayBuilder<ConfigurationItem>.GetInstance(out var builder);
        foreach (var option in SupportedOptions)
        {
            var fullOptionName = GenerateFullNameForOption(option);
            if (option is IPerLanguageValuedOption)
            {
                foreach (var language in SupportedLanguages)
                {
                    builder.Add(new ConfigurationItem()
                    {
                        Section = string.Concat(s_languageNameToPrefix[language], '|', fullOptionName),
                    });
                }
            }
            else
            {
                builder.Add(new ConfigurationItem()
                {
                    Section = fullOptionName,
                });
            }
        }
 
        return builder.ToImmutableAndClear();
    }
 
    /// <summary>
    /// Generate the full name of <param name="option"/>.
    /// It would be in the format like {optionGroupName}.{OptionName}
    /// </summary>
    /// <remarks>
    /// Example:Full name of <see cref="ImplementTypeOptionsStorage.InsertionBehavior"/> would be:
    /// implement_type.dotnet_member_insertion_location
    /// </remarks>
    internal static string GenerateFullNameForOption(IOption2 option)
    {
        var optionGroupName = GenerateOptionGroupName(option);
        // All options send to the client should have group name and config name.
        RoslynDebug.Assert(!string.IsNullOrEmpty(optionGroupName));
        RoslynDebug.Assert(!string.IsNullOrEmpty(option.Definition.ConfigName));
        return string.Concat(optionGroupName, '.', option.Definition.ConfigName);
    }
 
    private static string GenerateOptionGroupName(IOption2 option)
    {
        using var pooledStack = SharedPools.Default<Stack<string>>().GetPooledObject();
        var stack = pooledStack.Object;
        // Get the full name of option group, we are at the tail now, so use a stack to reverse it.
        var optionGroup = option.Definition.Group;
        while (optionGroup != null && optionGroup.Name != null)
        {
            stack.Push(optionGroup.Name);
            optionGroup = optionGroup.Parent;
        }
 
        using var _ = PooledStringBuilder.GetInstance(out var stringBuilder);
        while (!stack.IsEmpty())
        {
            if (stringBuilder.Length > 0)
            {
                stringBuilder.Append('.');
            }
 
            stringBuilder.Append(stack.Pop());
        }
 
        return stringBuilder.ToString();
    }
}