// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Collections.Generic; using System.Collections.Immutable; using System.ComponentModel.Composition; 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.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 { /// /// Service to maintain information about the suppression state of specific set of items in the error list. /// [Export(typeof(IVisualStudioDiagnosticListSuppressionStateService))] internal class VisualStudioDiagnosticListSuppressionStateService : IVisualStudioDiagnosticListSuppressionStateService { private readonly VisualStudioWorkspace _workspace; private readonly IVsUIShell _shellService; private readonly IWpfTableControl _tableControl; private int _selectedActiveItems; private int _selectedSuppressedItems; private int _selectedRoslynItems; private int _selectedCompilerDiagnosticItems; private int _selectedNoLocationDiagnosticItems; private int _selectedNonSuppressionStateItems; [ImportingConstructor] public VisualStudioDiagnosticListSuppressionStateService( SVsServiceProvider serviceProvider, VisualStudioWorkspace workspace) { _workspace = workspace; _shellService = (IVsUIShell)serviceProvider.GetService(typeof(SVsUIShell)); var errorList = serviceProvider.GetService(typeof(SVsErrorList)) as IErrorList; _tableControl = errorList?.TableControl; 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 only suppressed items are selected, we enable remove suppressions. public bool CanRemoveSuppressionsSelectedEntries => _selectedActiveItems == 0 && _selectedSuppressedItems > 0; // If only Roslyn active items are selected, we enable suppress in source. public bool CanSuppressSelectedEntriesInSource => _selectedActiveItems > 0 && _selectedSuppressedItems == 0 && _selectedRoslynItems == _selectedActiveItems && (_selectedRoslynItems - _selectedNoLocationDiagnosticItems) > 0; // If only active items are selected, and there is at least one Roslyn item, 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 && _selectedSuppressedItems == 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(); } } /// /// Updates suppression state information when the selected entries change in the error list. /// 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 entryHandles, bool added) { bool isRoslynEntry, isSuppressedEntry, isCompilerDiagnosticEntry, isNoLocationDiagnosticEntry; var hasSuppressionStateEntry = false; foreach (var entryHandle in entryHandles) { if (EntrySupportsSuppressionState(entryHandle, out isRoslynEntry, out isSuppressedEntry, out isCompilerDiagnosticEntry, out 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) { string filePath; isNoLocationDiagnosticEntry = !entryHandle.TryGetValue(StandardTableColumnDefinitions.DocumentName, out filePath) || string.IsNullOrEmpty(filePath); int index; var roslynSnapshot = GetEntriesSnapshot(entryHandle, out index); if (roslynSnapshot == null) { isRoslynEntry = false; isCompilerDiagnosticEntry = false; return IsNonRoslynEntrySupportingSuppressionState(entryHandle, out isSuppressedEntry); } var diagnosticData = roslynSnapshot?.GetItem(index)?.Primary; if (!IsEntryWithConfigurableSuppressionState(diagnosticData)) { isRoslynEntry = false; isSuppressedEntry = false; isCompilerDiagnosticEntry = false; return false; } isRoslynEntry = true; isSuppressedEntry = diagnosticData.IsSuppressed; isCompilerDiagnosticEntry = SuppressionHelpers.IsCompilerDiagnostic(diagnosticData); return true; } private static bool IsNonRoslynEntrySupportingSuppressionState(ITableEntryHandle entryHandle, out bool isSuppressedEntry) { string suppressionStateValue; if (entryHandle.TryGetValue(SuppressionStateColumnDefinition.ColumnName, out suppressionStateValue)) { isSuppressedEntry = suppressionStateValue == ServicesVSResources.SuppressionStateSuppressed; return true; } isSuppressedEntry = false; return false; } /// /// Returns true if an entry's suppression state can be modified. /// /// private static bool IsEntryWithConfigurableSuppressionState(DiagnosticData entry) { return entry != null && !SuppressionHelpers.IsNotConfigurableDiagnostic(entry); } private static AbstractTableEntriesSnapshot GetEntriesSnapshot(ITableEntryHandle entryHandle) { int index; return GetEntriesSnapshot(entryHandle, out index); } private static AbstractTableEntriesSnapshot GetEntriesSnapshot(ITableEntryHandle entryHandle, out int index) { ITableEntriesSnapshot snapshot; if (!entryHandle.TryGetSnapshot(out snapshot, out index)) { return null; } return snapshot as AbstractTableEntriesSnapshot; } /// /// Gets objects for error list entries, filtered based on the given parameters. /// public async Task> GetItemsAsync(bool selectedEntriesOnly, bool isAddSuppression, bool isSuppressionInSource, bool onlyCompilerDiagnostics, CancellationToken cancellationToken) { var builder = ImmutableArray.CreateBuilder(); Dictionary projectNameToProjectMapOpt = null; Dictionary> filePathToDocumentMapOpt = null; var entries = selectedEntriesOnly ? _tableControl.SelectedEntries : _tableControl.Entries; foreach (var entryHandle in entries) { cancellationToken.ThrowIfCancellationRequested(); DiagnosticData diagnosticData = null; int index; var roslynSnapshot = GetEntriesSnapshot(entryHandle, out index); if (roslynSnapshot != null) { diagnosticData = roslynSnapshot.GetItem(index)?.Primary; } else if (!isAddSuppression) { // For suppression removal, we also need to handle FxCop entries. bool isSuppressedEntry; if (!IsNonRoslynEntrySupportingSuppressionState(entryHandle, out isSuppressedEntry) || !isSuppressedEntry) { continue; } string errorCode = null, category = null, message = null, filePath = null, projectName = null; int line = -1; // FxCop only supports line, not column. var location = Location.None; if (entryHandle.TryGetValue(StandardTableColumnDefinitions.ErrorCode, out errorCode) && !string.IsNullOrEmpty(errorCode) && entryHandle.TryGetValue(StandardTableColumnDefinitions.ErrorCategory, out category) && !string.IsNullOrEmpty(category) && entryHandle.TryGetValue(StandardTableColumnDefinitions.Text, out message) && !string.IsNullOrEmpty(message) && entryHandle.TryGetValue(StandardTableColumnDefinitions.ProjectName, out projectName) && !string.IsNullOrEmpty(projectName)) { if (projectNameToProjectMapOpt == null) { projectNameToProjectMapOpt = new Dictionary(); foreach (var p in _workspace.CurrentSolution.Projects) { projectNameToProjectMapOpt[p.Name] = p; } } cancellationToken.ThrowIfCancellationRequested(); Project project; if (!projectNameToProjectMapOpt.TryGetValue(projectName, out 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) { if (string.IsNullOrEmpty(filePath) || line < 0) { // bail out continue; } ImmutableDictionary filePathMap; filePathToDocumentMapOpt = filePathToDocumentMapOpt ?? new Dictionary>(); if (!filePathToDocumentMapOpt.TryGetValue(project, out filePathMap)) { filePathMap = await GetFilePathToDocumentMapAsync(project, cancellationToken).ConfigureAwait(false); filePathToDocumentMapOpt[project] = filePathMap; } if (!filePathMap.TryGetValue(filePath, out document)) { // bail out continue; } var tree = await document.GetSyntaxTreeAsync(cancellationToken).ConfigureAwait(false); var linePosition = new LinePosition(line, 0); var linePositionSpan = new LinePositionSpan(start: linePosition, end: linePosition); var textSpan = (await tree.GetTextAsync(cancellationToken).ConfigureAwait(false)).Lines.GetTextSpan(linePositionSpan); location = tree.GetLocation(textSpan); } Contract.ThrowIfNull(project); Contract.ThrowIfFalse((document != null) == location.IsInSource); // 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). var diagnostic = Diagnostic.Create( 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); diagnosticData = document != null ? DiagnosticData.Create(document, diagnostic) : DiagnosticData.Create(project, diagnostic); } } if (IsEntryWithConfigurableSuppressionState(diagnosticData)) { var isCompilerDiagnostic = SuppressionHelpers.IsCompilerDiagnostic(diagnosticData); if (onlyCompilerDiagnostics && !isCompilerDiagnostic) { continue; } if (isAddSuppression) { // Compiler diagnostics can only be suppressed in source. if (!diagnosticData.IsSuppressed && (isSuppressionInSource || !isCompilerDiagnostic)) { builder.Add(diagnosticData); } } else if (diagnosticData.IsSuppressed) { builder.Add(diagnosticData); } } } return builder.ToImmutable(); } private static async Task> GetFilePathToDocumentMapAsync(Project project, CancellationToken cancellationToken) { var builder = ImmutableDictionary.CreateBuilder(); foreach (var document in project.Documents) { cancellationToken.ThrowIfCancellationRequested(); var tree = await document.GetSyntaxTreeAsync(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. if (_shellService != null) { _shellService.UpdateCommandUI(0); } } } }