File: Completion\CompletionListMerger.cs
Web Access
Project: src\src\Razor\src\Razor\src\Microsoft.CodeAnalysis.Razor.Workspaces\Microsoft.CodeAnalysis.Razor.Workspaces.csproj (Microsoft.CodeAnalysis.Razor.Workspaces)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Razor.PooledObjects;
 
namespace Microsoft.CodeAnalysis.Razor.Completion;
 
internal static class CompletionListMerger
{
    private const string Data1Key = nameof(MergedCompletionListData.Data1);
    private const string Data2Key = nameof(MergedCompletionListData.Data2);
    private static readonly object s_emptyData = new();
 
    [return: NotNullIfNotNull(nameof(razorCompletionList))]
    [return: NotNullIfNotNull(nameof(delegatedCompletionList))]
    public static RazorVSInternalCompletionList? Merge(RazorVSInternalCompletionList? razorCompletionList, RazorVSInternalCompletionList? delegatedCompletionList)
    {
        // In VSCode case we always think completion was invoked explicitly and create empty Razor completion list,
        // so check for empty Items collection as well. 
        if (razorCompletionList is null || razorCompletionList.Items.Length == 0)
        {
            return delegatedCompletionList;
        }
 
        if (delegatedCompletionList is null || delegatedCompletionList.Items.Length == 0)
        {
            return razorCompletionList;
        }
 
        EnsureMergeableCommitCharacters(razorCompletionList, delegatedCompletionList);
        EnsureMergeableData(razorCompletionList, delegatedCompletionList);
 
        var mergedIsIncomplete = razorCompletionList.IsIncomplete || delegatedCompletionList.IsIncomplete;
        VSInternalCompletionItem[] mergedItems = [.. razorCompletionList.Items, .. delegatedCompletionList.Items];
        var mergedData = MergeData(razorCompletionList.Data, delegatedCompletionList.Data);
        var mergedCommitCharacters = razorCompletionList.CommitCharacters
            ?? delegatedCompletionList.CommitCharacters;
        var mergedSuggestionMode = razorCompletionList.SuggestionMode || delegatedCompletionList.SuggestionMode;
 
        // We don't fully support merging continue characters currently. Razor doesn't currently use them so delegated completion lists always win.
        var mergedContinueWithCharacters = razorCompletionList.ContinueCharacters ?? delegatedCompletionList.ContinueCharacters;
 
        var mergedItemDefaultsData = MergeData(razorCompletionList.ItemDefaults?.Data, delegatedCompletionList.ItemDefaults?.Data);
        // We don't fully support merging edit ranges currently. Razor doesn't currently use them so delegated completion lists always win.
        var mergedItemDefaultsEditRange = razorCompletionList.ItemDefaults?.EditRange ?? delegatedCompletionList.ItemDefaults?.EditRange;
 
        var mergedCompletionList = new RazorVSInternalCompletionList()
        {
            CommitCharacters = mergedCommitCharacters,
            Data = mergedData,
            IsIncomplete = mergedIsIncomplete,
            Items = mergedItems,
            SuggestionMode = mergedSuggestionMode,
            ContinueCharacters = mergedContinueWithCharacters,
            ItemDefaults = new CompletionListItemDefaults()
            {
                Data = mergedItemDefaultsData,
                EditRange = mergedItemDefaultsEditRange,
                // VSCode won't use VSInternalCompletionList.CommitCharacters, make sure we don't lose item defaults
                CommitCharacters = razorCompletionList.ItemDefaults?.CommitCharacters ?? delegatedCompletionList.ItemDefaults?.CommitCharacters
            }
        };
 
        return mergedCompletionList;
    }
 
    public static object? MergeData(object? data1, object? data2)
    {
        if (data1 is null)
        {
            return data2;
        }
 
        if (data2 is null)
        {
            return data1;
        }
 
        return new MergedCompletionListData(data1, data2);
    }
 
    public static bool TrySplit(object? data, out ImmutableArray<JsonElement> splitData)
    {
        if (data is null)
        {
            splitData = default;
            return false;
        }
 
        // Needed for tests. We shouldn't ever have RazorCompletionResolveData leak out, but in our tests we avoid some
        // serialization boundaries, like between devenv and OOP. In production not only should it never happen, but
        // if it did, the type of Data would be JsonElement, so we wouldn't fall into this branch anyway.
        if (data is RazorCompletionResolveData { OriginalData: var originalData })
        {
            return TrySplit(originalData, out splitData);
        }
 
        using var collector = new PooledArrayBuilder<JsonElement>();
        Split(data, ref collector.AsRef());
 
        if (collector.Count == 0)
        {
            splitData = default;
            return false;
        }
 
        splitData = collector.ToImmutable();
        return true;
    }
 
    private static void Split(object data, ref PooledArrayBuilder<JsonElement> collector)
    {
        if (data is MergedCompletionListData mergedData)
        {
            // Merged data adds an extra object wrapper around the original data, so remove
            // that to restore to the original form.
            Split(mergedData.Data1, ref collector);
            Split(mergedData.Data2, ref collector);
            return;
        }
 
        TrySplitJsonElement(data, ref collector);
    }
 
    private static void TrySplitJsonElement(object data, ref PooledArrayBuilder<JsonElement> collector)
    {
        if (data is not JsonElement jsonElement)
        {
            return;
        }
 
        if (jsonElement.TryGetProperty(Data1Key, out _) && jsonElement.TryGetProperty(Data2Key, out _))
        {
            // Merged data
            var mergedCompletionListData = jsonElement.Deserialize<MergedCompletionListData>();
 
            if (mergedCompletionListData is null)
            {
                Debug.Fail("Merged completion list data is null, this should never happen.");
                return;
            }
 
            Split(mergedCompletionListData.Data1, ref collector);
            Split(mergedCompletionListData.Data2, ref collector);
        }
        else
        {
            collector.Add(jsonElement);
        }
    }
 
    private static void EnsureMergeableData(RazorVSInternalCompletionList completionListA, RazorVSInternalCompletionList completionListB)
    {
        var completionListAData = completionListA.Data ?? completionListA.ItemDefaults?.Data;
        var completionListBData = completionListB.Data ?? completionListB.ItemDefaults?.Data;
        if (completionListAData != completionListBData &&
            (completionListAData is null || completionListBData is null))
        {
            // One of the completion lists have data while the other does not, we need to ensure that any non-data centric items don't get incorrect data associated
 
            // The candidate completion list will be one where we populate empty data for any `null` specifying data given we'll be merging
            // two completion lists together we don't want incorrect data to be inherited down
            var candidateCompletionList = completionListAData is null ? completionListA : completionListB;
            for (var i = 0; i < candidateCompletionList.Items.Length; i++)
            {
                var item = candidateCompletionList.Items[i];
                item.Data ??= s_emptyData;
            }
        }
    }
 
    private static void EnsureMergeableCommitCharacters(RazorVSInternalCompletionList completionListA, RazorVSInternalCompletionList completionListB)
    {
        var aInheritsCommitCharacters = completionListA.CommitCharacters is not null || completionListA.ItemDefaults?.CommitCharacters is not null;
        var bInheritsCommitCharacters = completionListB.CommitCharacters is not null || completionListB.ItemDefaults?.CommitCharacters is not null;
        if (aInheritsCommitCharacters && bInheritsCommitCharacters)
        {
            // Need to merge commit characters because both are trying to inherit
 
            var inheritableCommitCharacterCompletionsA = GetCompletionsThatDoNotSpecifyCommitCharacters(completionListA);
            var inheritableCommitCharacterCompletionsB = GetCompletionsThatDoNotSpecifyCommitCharacters(completionListB);
            IReadOnlyList<VSInternalCompletionItem>? completionItemsToStopInheriting;
            RazorVSInternalCompletionList? completionListToStopInheriting;
 
            // Decide which completion list has more items that benefit from "inheriting" commit characters.
            if (inheritableCommitCharacterCompletionsA.Length >= inheritableCommitCharacterCompletionsB.Length)
            {
                completionListToStopInheriting = completionListB;
                completionItemsToStopInheriting = inheritableCommitCharacterCompletionsB;
            }
            else
            {
                completionListToStopInheriting = completionListA;
                completionItemsToStopInheriting = inheritableCommitCharacterCompletionsA;
            }
 
            for (var i = 0; i < completionItemsToStopInheriting.Count; i++)
            {
                if (completionListToStopInheriting.CommitCharacters is not null)
                {
                    completionItemsToStopInheriting[i].VsCommitCharacters = completionListToStopInheriting.CommitCharacters;
                }
                else if (completionListToStopInheriting.ItemDefaults?.CommitCharacters is not null)
                {
                    completionItemsToStopInheriting[i].VsCommitCharacters = completionListToStopInheriting.ItemDefaults?.CommitCharacters;
                }
            }
 
            completionListToStopInheriting.CommitCharacters = null;
            completionListToStopInheriting.ItemDefaults?.CommitCharacters = null;
        }
    }
 
    private static ImmutableArray<VSInternalCompletionItem> GetCompletionsThatDoNotSpecifyCommitCharacters(RazorVSInternalCompletionList completionList)
    {
        using var inheritableCompletions = new PooledArrayBuilder<VSInternalCompletionItem>();
        for (var i = 0; i < completionList.Items.Length; i++)
        {
            if (completionList.Items[i] is not VSInternalCompletionItem completionItem ||
                completionItem.CommitCharacters is not null ||
                completionItem.VsCommitCharacters is not null)
            {
                // Completion item wasn't the right type or already specifies commit characters
                continue;
            }
 
            inheritableCompletions.Add(completionItem);
        }
 
        return inheritableCompletions.ToImmutable();
    }
 
    private record MergedCompletionListData(
        [property: JsonPropertyName(Data1Key)] object Data1,
        [property: JsonPropertyName(Data2Key)] object Data2);
}