// 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.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Classification;
using Microsoft.CodeAnalysis.DocumentHighlighting;
using Microsoft.CodeAnalysis.FindSymbols.Finders;
using Microsoft.CodeAnalysis.FindUsages;
using Microsoft.CodeAnalysis.Host;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Text;
using Microsoft.VisualStudio.Shell.FindAllReferences;
using Microsoft.VisualStudio.Shell.TableControl;
using Microsoft.VisualStudio.Shell.TableManager;
using Roslyn.Utilities;
namespace Microsoft.VisualStudio.LanguageServices.FindUsages
{
internal partial class StreamingFindUsagesPresenter
{
private abstract class AbstractTableDataSourceFindUsagesContext :
FindUsagesContext, ITableDataSource, ITableEntriesSnapshotFactory
{
private readonly CancellationTokenSource _cancellationTokenSource = new();
private ITableDataSink _tableDataSink;
public readonly StreamingFindUsagesPresenter Presenter;
private readonly IFindAllReferencesWindow _findReferencesWindow;
protected readonly IWpfTableControl2 TableControl;
private readonly AsyncBatchingWorkQueue<(int current, int maximum)> _progressQueue;
protected readonly object Gate = new();
#region Fields that should be locked by _gate
///
/// If we've been cleared or not. If we're cleared we'll just return an empty
/// list of results whenever queried for the current snapshot.
///
private bool _cleared;
///
/// The list of all definitions we've heard about. This may be a superset of the
/// keys in because we may encounter definitions
/// we don't create definition buckets for. For example, if the definition asks
/// us to not display it if it has no references, and we don't run into any
/// references for it (common with implicitly declared symbols).
///
protected readonly List Definitions = new();
///
/// We will hear about the same definition over and over again. i.e. for each reference
/// to a definition, we will be told about the same definition. However, we only want to
/// create a single actual for the definition. To accomplish
/// this we keep a map from the definition to the task that we're using to create the
/// bucket for it. The first time we hear about a definition we'll make a single task
/// and then always return that for all future references found.
///
private readonly Dictionary _definitionToBucket =
new();
///
/// We want to hide declarations of a symbol if the user is grouping by definition.
/// With such grouping on, having both the definition group and the declaration item
/// is just redundant. To make life easier we keep around two groups of entries.
/// One group for when we are grouping by definition, and one when we're not.
///
private bool _currentlyGroupingByDefinition;
protected ImmutableList EntriesWhenNotGroupingByDefinition = ImmutableList.Empty;
protected ImmutableList EntriesWhenGroupingByDefinition = ImmutableList.Empty;
private TableEntriesSnapshot _lastSnapshot;
public int CurrentVersionNumber { get; protected set; }
#endregion
protected AbstractTableDataSourceFindUsagesContext(
StreamingFindUsagesPresenter presenter,
IFindAllReferencesWindow findReferencesWindow,
ImmutableArray customColumns,
bool includeContainingTypeAndMemberColumns,
bool includeKindColumn)
{
presenter.AssertIsForeground();
Presenter = presenter;
_findReferencesWindow = findReferencesWindow;
TableControl = (IWpfTableControl2)findReferencesWindow.TableControl;
TableControl.GroupingsChanged += OnTableControlGroupingsChanged;
// If the window is closed, cancel any work we're doing.
_findReferencesWindow.Closed += OnFindReferencesWindowClosed;
DetermineCurrentGroupingByDefinitionState();
Debug.Assert(_findReferencesWindow.Manager.Sources.Count == 0);
// Add ourselves as the source of results for the window.
// Additionally, add applicable custom columns to display custom reference information
_findReferencesWindow.Manager.AddSource(
this,
SelectCustomColumnsToInclude(customColumns, includeContainingTypeAndMemberColumns, includeKindColumn));
// After adding us as the source, the manager should immediately call into us to
// tell us what the data sink is.
Debug.Assert(_tableDataSink != null);
// https://devdiv.visualstudio.com/web/wi.aspx?pcguid=011b8bdf-6d56-4f87-be0d-0092136884d9&id=359162
// VS actually responds to each SetProgess call by queuing a UI task to do the
// progress bar update. This can made FindReferences feel extremely slow when
// thousands of SetProgress calls are made.
//
// To ensure a reasonable experience, we instead add the progress into a queue and
// only update the UI a few times a second so as to not overload it.
_progressQueue = new AsyncBatchingWorkQueue<(int current, int maximum)>(
TimeSpan.FromMilliseconds(250),
this.UpdateTableProgressAsync,
this.CancellationToken);
}
private static ImmutableArray SelectCustomColumnsToInclude(ImmutableArray customColumns, bool includeContainingTypeAndMemberColumns, bool includeKindColumn)
{
var customColumnsToInclude = ArrayBuilder.GetInstance();
foreach (var column in customColumns)
{
switch (column.Name)
{
case AbstractReferenceFinder.ContainingMemberInfoPropertyName:
case AbstractReferenceFinder.ContainingTypeInfoPropertyName:
if (includeContainingTypeAndMemberColumns)
{
customColumnsToInclude.Add(column.Name);
}
break;
case StandardTableColumnDefinitions2.SymbolKind:
if (includeKindColumn)
{
customColumnsToInclude.Add(column.Name);
}
break;
}
}
customColumnsToInclude.Add(StandardTableKeyNames.Repository);
customColumnsToInclude.Add(StandardTableKeyNames.ItemOrigin);
return customColumnsToInclude.ToImmutableAndFree();
}
protected void NotifyChange()
=> _tableDataSink.FactorySnapshotChanged(this);
private void OnFindReferencesWindowClosed(object sender, EventArgs e)
{
Presenter.AssertIsForeground();
CancelSearch();
_findReferencesWindow.Closed -= OnFindReferencesWindowClosed;
TableControl.GroupingsChanged -= OnTableControlGroupingsChanged;
}
private void OnTableControlGroupingsChanged(object sender, EventArgs e)
{
Presenter.AssertIsForeground();
UpdateGroupingByDefinition();
}
private void UpdateGroupingByDefinition()
{
Presenter.AssertIsForeground();
var changed = DetermineCurrentGroupingByDefinitionState();
if (changed)
{
// We changed from grouping-by-definition to not (or vice versa).
// Change which list we show the user.
lock (Gate)
{
CurrentVersionNumber++;
}
// Let all our subscriptions know that we've updated. That way they'll refresh
// and we'll show/hide declarations as appropriate.
NotifyChange();
}
}
private bool DetermineCurrentGroupingByDefinitionState()
{
Presenter.AssertIsForeground();
var definitionColumn = _findReferencesWindow.GetDefinitionColumn();
lock (Gate)
{
var oldGroupingByDefinition = _currentlyGroupingByDefinition;
_currentlyGroupingByDefinition = definitionColumn?.GroupingPriority > 0;
return oldGroupingByDefinition != _currentlyGroupingByDefinition;
}
}
private void CancelSearch()
{
Presenter.AssertIsForeground();
_cancellationTokenSource.Cancel();
}
public sealed override CancellationToken CancellationToken => _cancellationTokenSource.Token;
public void Clear()
{
this.Presenter.AssertIsForeground();
// Stop all existing work.
this.CancelSearch();
// Clear the title of the window. It will go back to the default editor title.
this._findReferencesWindow.Title = null;
lock (Gate)
{
// Mark ourselves as clear so that no further changes are made.
// Note: we don't actually mutate any of our entry-lists. Instead,
// GetCurrentSnapshot will simply ignore them if it sees that _cleared
// is true. This way we don't have to do anything complicated if we
// keep hearing about definitions/references on the background.
_cleared = true;
CurrentVersionNumber++;
}
// Let all our subscriptions know that we've updated. That way they'll refresh
// and remove all the data.
NotifyChange();
}
#region ITableDataSource
public string DisplayName => "Roslyn Data Source";
public string Identifier
=> StreamingFindUsagesPresenter.RoslynFindUsagesTableDataSourceIdentifier;
public string SourceTypeIdentifier
=> StreamingFindUsagesPresenter.RoslynFindUsagesTableDataSourceSourceTypeIdentifier;
public IDisposable Subscribe(ITableDataSink sink)
{
Presenter.AssertIsForeground();
Debug.Assert(_tableDataSink == null);
_tableDataSink = sink;
_tableDataSink.AddFactory(this, removeAllFactories: true);
_tableDataSink.IsStable = false;
return this;
}
#endregion
#region FindUsagesContext overrides.
public sealed override ValueTask SetSearchTitleAsync(string title)
{
// Note: IFindAllReferenceWindow.Title is safe to set from any thread.
_findReferencesWindow.Title = title;
return default;
}
public sealed override async ValueTask OnCompletedAsync()
{
await OnCompletedAsyncWorkerAsync().ConfigureAwait(false);
_tableDataSink.IsStable = true;
}
protected abstract Task OnCompletedAsyncWorkerAsync();
public sealed override ValueTask OnDefinitionFoundAsync(DefinitionItem definition)
{
lock (Gate)
{
Definitions.Add(definition);
}
return OnDefinitionFoundWorkerAsync(definition);
}
protected abstract ValueTask OnDefinitionFoundWorkerAsync(DefinitionItem definition);
protected async Task<(Guid, string projectName, SourceText)> GetGuidAndProjectNameAndSourceTextAsync(Document document)
{
// The FAR system needs to know the guid for the project that a def/reference is
// from (to support features like filtering). Normally that would mean we could
// only support this from a VisualStudioWorkspace. However, we want till work
// in cases like Any-Code (which does not use a VSWorkspace). So we are tolerant
// when we have another type of workspace. This means we will show results, but
// certain features (like filtering) may not work in that context.
var vsWorkspace = document.Project.Solution.Workspace as VisualStudioWorkspace;
var projectName = document.Project.Name;
var guid = vsWorkspace?.GetProjectGuid(document.Project.Id) ?? Guid.Empty;
var sourceText = await document.GetTextAsync(CancellationToken).ConfigureAwait(false);
return (guid, projectName, sourceText);
}
protected async Task TryCreateDocumentSpanEntryAsync(
RoslynDefinitionBucket definitionBucket,
DocumentSpan documentSpan,
HighlightSpanKind spanKind,
SymbolUsageInfo symbolUsageInfo,
ImmutableDictionary additionalProperties)
{
var document = documentSpan.Document;
var (guid, projectName, sourceText) = await GetGuidAndProjectNameAndSourceTextAsync(document).ConfigureAwait(false);
var (excerptResult, lineText) = await ExcerptAsync(sourceText, documentSpan).ConfigureAwait(false);
var mappedDocumentSpan = await AbstractDocumentSpanEntry.TryMapAndGetFirstAsync(documentSpan, sourceText, CancellationToken).ConfigureAwait(false);
if (mappedDocumentSpan == null)
{
// this will be removed from the result
return null;
}
return new DocumentSpanEntry(
this, definitionBucket, spanKind, projectName,
guid, mappedDocumentSpan.Value, excerptResult, lineText, symbolUsageInfo, additionalProperties);
}
private async Task<(ExcerptResult, SourceText)> ExcerptAsync(SourceText sourceText, DocumentSpan documentSpan)
{
var excerptService = documentSpan.Document.Services.GetService();
if (excerptService != null)
{
var result = await excerptService.TryExcerptAsync(documentSpan.Document, documentSpan.SourceSpan, ExcerptMode.SingleLine, CancellationToken).ConfigureAwait(false);
if (result != null)
{
return (result.Value, AbstractDocumentSpanEntry.GetLineContainingPosition(result.Value.Content, result.Value.MappedSpan.Start));
}
}
var classificationResult = await ClassifiedSpansAndHighlightSpanFactory.ClassifyAsync(documentSpan, CancellationToken).ConfigureAwait(false);
// need to fix the span issue tracking here - https://github.com/dotnet/roslyn/issues/31001
var excerptResult = new ExcerptResult(
sourceText,
classificationResult.HighlightSpan,
classificationResult.ClassifiedSpans,
documentSpan.Document,
documentSpan.SourceSpan);
return (excerptResult, AbstractDocumentSpanEntry.GetLineContainingPosition(sourceText, documentSpan.SourceSpan.Start));
}
public sealed override ValueTask OnReferenceFoundAsync(SourceReferenceItem reference)
=> OnReferenceFoundWorkerAsync(reference);
protected abstract ValueTask OnReferenceFoundWorkerAsync(SourceReferenceItem reference);
protected RoslynDefinitionBucket GetOrCreateDefinitionBucket(DefinitionItem definition)
{
lock (Gate)
{
if (!_definitionToBucket.TryGetValue(definition, out var bucket))
{
bucket = RoslynDefinitionBucket.Create(Presenter, this, definition);
_definitionToBucket.Add(definition, bucket);
}
return bucket;
}
}
public sealed override ValueTask ReportMessageAsync(string message)
=> throw new InvalidOperationException("This should never be called in the streaming case.");
protected sealed override ValueTask ReportProgressAsync(int current, int maximum)
{
_progressQueue.AddWork((current, maximum));
return default;
}
private Task UpdateTableProgressAsync(ImmutableArray<(int current, int maximum)> nextBatch, CancellationToken cancellationToken)
{
if (!nextBatch.IsEmpty)
{
var (current, maximum) = nextBatch.Last();
// Do not update the UI if the current progress is zero. It will switch us from the indeterminate
// progress bar (which conveys to the user that we're working) to showing effectively nothing (which
// makes it appear as if the search is complete). So the user sees:
//
// indeterminate->complete->progress
//
// instead of:
//
// indeterminate->progress
if (current > 0)
_findReferencesWindow.SetProgress(current, maximum);
}
return Task.CompletedTask;
}
#endregion
#region ITableEntriesSnapshotFactory
public ITableEntriesSnapshot GetCurrentSnapshot()
{
lock (Gate)
{
// If our last cached snapshot matches our current version number, then we
// can just return it. Otherwise, we need to make a snapshot that matches
// our version.
if (_lastSnapshot?.VersionNumber != CurrentVersionNumber)
{
// If we've been cleared, then just return an empty list of entries.
// Otherwise return the appropriate list based on how we're currently
// grouping.
var entries = _cleared
? ImmutableList.Empty
: _currentlyGroupingByDefinition
? EntriesWhenGroupingByDefinition
: EntriesWhenNotGroupingByDefinition;
_lastSnapshot = new TableEntriesSnapshot(entries, CurrentVersionNumber);
}
return _lastSnapshot;
}
}
public ITableEntriesSnapshot GetSnapshot(int versionNumber)
{
lock (Gate)
{
if (_lastSnapshot?.VersionNumber == versionNumber)
{
return _lastSnapshot;
}
if (versionNumber == CurrentVersionNumber)
{
return GetCurrentSnapshot();
}
}
// We didn't have this version. Notify the sinks that something must have changed
// so that they call back into us with the latest version.
NotifyChange();
return null;
}
void IDisposable.Dispose()
{
this.Presenter.AssertIsForeground();
// VS is letting go of us. i.e. because a new FAR call is happening, or because
// of some other event (like the solution being closed). Remove us from the set
// of sources for the window so that the existing data is cleared out.
Debug.Assert(_findReferencesWindow.Manager.Sources.Count == 1);
Debug.Assert(_findReferencesWindow.Manager.Sources[0] == this);
_findReferencesWindow.Manager.RemoveSource(this);
CancelSearch();
// Remove ourselves from the list of contexts that are currently active.
Presenter._currentContexts.Remove(this);
}
#endregion
}
}
}