File: TableDataSource\Suppression\VisualStudioDiagnosticListSuppressionStateService.cs
Web Access
Project: src\src\VisualStudio\Core\Def\Microsoft.VisualStudio.LanguageServices_pxr0p0dn_wpftmp.csproj (Microsoft.VisualStudio.LanguageServices)
// 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.ComponentModel.Composition;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeFixes.Suppression;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Editor.Shared.Utilities;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Microsoft.VisualStudio.Shell;
using Microsoft.VisualStudio.Shell.Interop;
using Microsoft.VisualStudio.Shell.TableControl;
using Microsoft.VisualStudio.Shell.TableManager;
using Roslyn.Utilities;
 
namespace Microsoft.VisualStudio.LanguageServices.Implementation.TableDataSource;
 
/// <summary>
/// Service to maintain information about the suppression state of specific set of items in the error list.
/// </summary>
[Export(typeof(IVisualStudioDiagnosticListSuppressionStateService))]
[Export(typeof(VisualStudioDiagnosticListSuppressionStateService))]
internal class VisualStudioDiagnosticListSuppressionStateService : IVisualStudioDiagnosticListSuppressionStateService
{
    private readonly IThreadingContext _threadingContext;
    private readonly VisualStudioWorkspace _workspace;
 
    private IVsUIShell? _shellService;
    private IWpfTableControl? _tableControl;
 
    private int _selectedActiveItems;
    private int _selectedSuppressedItems;
    private int _selectedRoslynItems;
    private int _selectedCompilerDiagnosticItems;
    private int _selectedNoLocationDiagnosticItems;
    private int _selectedNonSuppressionStateItems;
 
    [ImportingConstructor]
    [Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
    public VisualStudioDiagnosticListSuppressionStateService(
        IThreadingContext threadingContext,
        VisualStudioWorkspace workspace)
    {
        _threadingContext = threadingContext;
        _workspace = workspace;
    }
 
    public async Task InitializeAsync(IAsyncServiceProvider serviceProvider, CancellationToken cancellationToken)
    {
        _shellService = await serviceProvider.GetServiceAsync<SVsUIShell, IVsUIShell>(_threadingContext.JoinableTaskFactory).ConfigureAwait(false);
        var errorList = await serviceProvider.GetServiceAsync<SVsErrorList, IErrorList>(_threadingContext.JoinableTaskFactory, throwOnFailure: false).ConfigureAwait(false);
        _tableControl = errorList?.TableControl;
 
        await _threadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
 
        ClearState();
        InitializeFromTableControlIfNeeded();
    }
 
    private int SelectedItems => _selectedActiveItems + _selectedSuppressedItems + _selectedNonSuppressionStateItems;
 
    // If we can suppress either in source or in suppression file, we enable suppress context menu.
    public bool CanSuppressSelectedEntries => CanSuppressSelectedEntriesInSource || CanSuppressSelectedEntriesInSuppressionFiles;
 
    // If at least one suppressed item is selected, we enable remove suppressions.
    public bool CanRemoveSuppressionsSelectedEntries => _selectedSuppressedItems > 0;
 
    // If at least one Roslyn active item with location is selected, we enable suppress in source.
    // Note that we do not support suppress in source when mix of Roslyn and non-Roslyn items are selected as in-source suppression has different meaning and implementation for these.
    public bool CanSuppressSelectedEntriesInSource => _selectedActiveItems > 0 &&
        _selectedRoslynItems == _selectedActiveItems &&
        (_selectedRoslynItems - _selectedNoLocationDiagnosticItems) > 0;
 
    // If at least one Roslyn active item is selected, we enable suppress in suppression file.
    // Also, compiler diagnostics cannot be suppressed in suppression file, so there must be at least one non-compiler item.
    public bool CanSuppressSelectedEntriesInSuppressionFiles => _selectedActiveItems > 0 &&
        (_selectedRoslynItems - _selectedCompilerDiagnosticItems) > 0;
 
    private void ClearState()
    {
        _selectedActiveItems = 0;
        _selectedSuppressedItems = 0;
        _selectedRoslynItems = 0;
        _selectedCompilerDiagnosticItems = 0;
        _selectedNoLocationDiagnosticItems = 0;
        _selectedNonSuppressionStateItems = 0;
    }
 
    private void InitializeFromTableControlIfNeeded()
    {
        if (_tableControl == null)
        {
            return;
        }
 
        if (SelectedItems == _tableControl.SelectedEntries.Count())
        {
            // We already have up-to-date state data, so don't need to re-compute.
            return;
        }
 
        ClearState();
        if (ProcessEntries(_tableControl.SelectedEntries, added: true))
        {
            UpdateQueryStatus();
        }
    }
 
    /// <summary>
    /// Updates suppression state information when the selected entries change in the error list.
    /// </summary>
    public void ProcessSelectionChanged(TableSelectionChangedEventArgs e)
    {
        var hasAddedSuppressionStateEntry = ProcessEntries(e.AddedEntries, added: true);
        var hasRemovedSuppressionStateEntry = ProcessEntries(e.RemovedEntries, added: false);
 
        // If any entry that supports suppression state was ever involved, update query status since each item in the error list
        // can have different context menu.
        if (hasAddedSuppressionStateEntry || hasRemovedSuppressionStateEntry)
        {
            UpdateQueryStatus();
        }
 
        InitializeFromTableControlIfNeeded();
    }
 
    private bool ProcessEntries(IEnumerable<ITableEntryHandle> entryHandles, bool added)
    {
        var hasSuppressionStateEntry = false;
        foreach (var entryHandle in entryHandles)
        {
            if (EntrySupportsSuppressionState(entryHandle, out var isRoslynEntry, out var isSuppressedEntry, out var isCompilerDiagnosticEntry, out var isNoLocationDiagnosticEntry))
            {
                hasSuppressionStateEntry = true;
                HandleSuppressionStateEntry(isRoslynEntry, isSuppressedEntry, isCompilerDiagnosticEntry, isNoLocationDiagnosticEntry, added);
            }
            else
            {
                HandleNonSuppressionStateEntry(added);
            }
        }
 
        return hasSuppressionStateEntry;
    }
 
    private static bool EntrySupportsSuppressionState(ITableEntryHandle entryHandle, out bool isRoslynEntry, out bool isSuppressedEntry, out bool isCompilerDiagnosticEntry, out bool isNoLocationDiagnosticEntry)
    {
        isNoLocationDiagnosticEntry = !entryHandle.TryGetValue(StandardTableColumnDefinitions.DocumentName, out string filePath) ||
            string.IsNullOrEmpty(filePath);
 
        isRoslynEntry = false;
        isCompilerDiagnosticEntry = false;
        return IsNonRoslynEntrySupportingSuppressionState(entryHandle, out isSuppressedEntry);
    }
 
    private static bool IsNonRoslynEntrySupportingSuppressionState(ITableEntryHandle entryHandle, out bool isSuppressedEntry)
    {
        if (entryHandle.TryGetValue(StandardTableKeyNames.SuppressionState, out SuppressionState suppressionStateValue))
        {
            isSuppressedEntry = suppressionStateValue == SuppressionState.Suppressed;
            return true;
        }
 
        isSuppressedEntry = false;
        return false;
    }
 
    /// <summary>
    /// Returns true if an entry's suppression state can be modified.
    /// </summary>
    private static bool IsEntryWithConfigurableSuppressionState([NotNullWhen(true)] DiagnosticData? entry)
        => entry != null && !SuppressionHelpers.IsNotConfigurableDiagnostic(entry);
 
    /// <summary>
    /// Gets <see cref="DiagnosticData"/> objects for selected error list entries.
    /// For remove suppression, the method also returns selected external source diagnostics.
    /// </summary>
    public async Task<ImmutableArray<DiagnosticData>> GetSelectedItemsAsync(bool isAddSuppression, CancellationToken cancellationToken)
    {
        Contract.ThrowIfNull(_tableControl);
 
        using var _ = ArrayBuilder<DiagnosticData>.GetInstance(out var builder);
 
        Dictionary<string, Project>? projectNameToProjectMap = null;
        Dictionary<Project, ImmutableDictionary<string, Document>>? filePathToDocumentMap = null;
 
        foreach (var entryHandle in _tableControl.SelectedEntries)
        {
            cancellationToken.ThrowIfCancellationRequested();
 
            DiagnosticData? diagnosticData = null;
 
            if (!isAddSuppression)
            {
                // For suppression removal, we also need to handle FxCop entries.
                if (!IsNonRoslynEntrySupportingSuppressionState(entryHandle, out var isSuppressedEntry) ||
                    !isSuppressedEntry)
                {
                    continue;
                }
 
                string? filePath = null;
                var line = -1; // FxCop only supports line, not column.
 
                if (entryHandle.TryGetValue(StandardTableColumnDefinitions.ErrorCode, out string errorCode) && !string.IsNullOrEmpty(errorCode) &&
                    entryHandle.TryGetValue(StandardTableColumnDefinitions.ErrorCategory, out string category) && !string.IsNullOrEmpty(category) &&
                    entryHandle.TryGetValue(StandardTableColumnDefinitions.Text, out string message) && !string.IsNullOrEmpty(message) &&
                    entryHandle.TryGetValue(StandardTableColumnDefinitions.ProjectName, out string projectName) && !string.IsNullOrEmpty(projectName))
                {
                    if (projectNameToProjectMap == null)
                    {
                        projectNameToProjectMap = [];
                        foreach (var p in _workspace.CurrentSolution.Projects)
                        {
                            projectNameToProjectMap[p.Name] = p;
                        }
                    }
 
                    cancellationToken.ThrowIfCancellationRequested();
                    if (!projectNameToProjectMap.TryGetValue(projectName, out var project))
                    {
                        // bail out
                        continue;
                    }
 
                    Document? document = null;
                    var hasLocation =
                        entryHandle.TryGetValue(StandardTableColumnDefinitions.DocumentName, out filePath) && !string.IsNullOrEmpty(filePath) &&
                        entryHandle.TryGetValue(StandardTableColumnDefinitions.Line, out line) && line >= 0;
                    if (!hasLocation)
                        continue;
 
                    if (RoslynString.IsNullOrEmpty(filePath) || line < 0)
                    {
                        // bail out
                        continue;
                    }
 
                    filePathToDocumentMap ??= [];
                    if (!filePathToDocumentMap.TryGetValue(project, out var filePathMap))
                    {
                        filePathMap = await GetFilePathToDocumentMapAsync(project, cancellationToken).ConfigureAwait(false);
                        filePathToDocumentMap[project] = filePathMap;
                    }
 
                    if (!filePathMap.TryGetValue(filePath, out document))
                    {
                        // bail out
                        continue;
                    }
 
                    // TODO: should we use the tree of the document (if available) to get the correct mapped span for this location?
                    var text = await document.GetValueTextAsync(cancellationToken).ConfigureAwait(false);
                    var linePosition = new LinePosition(line, 0);
                    var linePositionSpan = new LinePositionSpan(start: linePosition, end: linePosition);
                    var location = new DiagnosticDataLocation(
                        new FileLinePositionSpan(filePath, linePositionSpan), document.Id);
 
                    Contract.ThrowIfNull(project);
 
                    // Create a diagnostic with correct values for fields we care about: id, category, message, isSuppressed, location
                    // and default values for the rest of the fields (not used by suppression fixer).
                    diagnosticData = new DiagnosticData(
                        id: errorCode,
                        category: category,
                        message: message,
                        severity: DiagnosticSeverity.Warning,
                        defaultSeverity: DiagnosticSeverity.Warning,
                        isEnabledByDefault: true,
                        warningLevel: 1,
                        isSuppressed: isSuppressedEntry,
                        title: message,
                        location: location,
                        customTags: SuppressionHelpers.SynthesizedExternalSourceDiagnosticCustomTags,
                        properties: ImmutableDictionary<string, string?>.Empty,
                        projectId: project.Id);
                }
            }
 
            if (IsEntryWithConfigurableSuppressionState(diagnosticData))
            {
                builder.Add(diagnosticData);
            }
        }
 
        return builder.ToImmutableAndClear();
    }
 
    private static async Task<ImmutableDictionary<string, Document>> GetFilePathToDocumentMapAsync(Project project, CancellationToken cancellationToken)
    {
        var builder = ImmutableDictionary.CreateBuilder<string, Document>();
        foreach (var document in project.Documents)
        {
            cancellationToken.ThrowIfCancellationRequested();
 
            var tree = await document.GetRequiredSyntaxTreeAsync(cancellationToken).ConfigureAwait(false);
            var filePath = tree.FilePath;
            if (filePath != null)
            {
                builder.Add(filePath, document);
            }
        }
 
        return builder.ToImmutable();
    }
 
    private static void UpdateSelectedItems(bool added, ref int count)
    {
        if (added)
        {
            count++;
        }
        else
        {
            count--;
        }
    }
 
    private void HandleSuppressionStateEntry(bool isRoslynEntry, bool isSuppressedEntry, bool isCompilerDiagnosticEntry, bool isNoLocationDiagnosticEntry, bool added)
    {
        if (isRoslynEntry)
        {
            UpdateSelectedItems(added, ref _selectedRoslynItems);
        }
 
        if (isCompilerDiagnosticEntry)
        {
            UpdateSelectedItems(added, ref _selectedCompilerDiagnosticItems);
        }
 
        if (isNoLocationDiagnosticEntry)
        {
            UpdateSelectedItems(added, ref _selectedNoLocationDiagnosticItems);
        }
 
        if (isSuppressedEntry)
        {
            UpdateSelectedItems(added, ref _selectedSuppressedItems);
        }
        else
        {
            UpdateSelectedItems(added, ref _selectedActiveItems);
        }
    }
 
    private void HandleNonSuppressionStateEntry(bool added)
        => UpdateSelectedItems(added, ref _selectedNonSuppressionStateItems);
 
    private void UpdateQueryStatus()
    {
        // Force the shell to refresh the QueryStatus for all the command since default behavior is it only does query
        // when focus on error list has changed, not individual items.
        _shellService?.UpdateCommandUI(0);
    }
}