diff --git a/src/EditorFeatures/Core/Classification/Semantic/AbstractSemanticOrEmbeddedClassificationViewTaggerProvider.cs b/src/EditorFeatures/Core/Classification/Semantic/AbstractSemanticOrEmbeddedClassificationViewTaggerProvider.cs index 96ea478c3b9c8..f98ee744936f0 100644 --- a/src/EditorFeatures/Core/Classification/Semantic/AbstractSemanticOrEmbeddedClassificationViewTaggerProvider.cs +++ b/src/EditorFeatures/Core/Classification/Semantic/AbstractSemanticOrEmbeddedClassificationViewTaggerProvider.cs @@ -39,9 +39,6 @@ internal abstract class AbstractSemanticOrEmbeddedClassificationViewTaggerProvid private readonly IGlobalOptionService _globalOptions; private readonly ClassificationType _type; - // We want to track text changes so that we can try to only reclassify a method body if - // all edits were contained within one. - protected sealed override TaggerTextChangeBehavior TextChangeBehavior => TaggerTextChangeBehavior.TrackTextChanges; protected sealed override ImmutableArray Options { get; } = [SemanticColorizerOptionsStorage.SemanticColorizer]; protected AbstractSemanticOrEmbeddedClassificationViewTaggerProvider( @@ -137,8 +134,9 @@ public async Task ProduceTagsAsync( if (document == null) return; + var currentSemanticVersion = await document.Project.GetDependentSemanticVersionAsync(cancellationToken).ConfigureAwait(false); var classified = await TryClassifyContainingMemberSpanAsync( - context, document, spanToTag.SnapshotSpan, classificationService, options, cancellationToken).ConfigureAwait(false); + context, document, spanToTag.SnapshotSpan, classificationService, options, currentSemanticVersion, cancellationToken).ConfigureAwait(false); if (classified) { return; @@ -147,7 +145,7 @@ public async Task ProduceTagsAsync( // We weren't able to use our specialized codepaths for semantic classifying. // Fall back to classifying the full span that was asked for. await ClassifySpansAsync( - context, document, spanToTag.SnapshotSpan, classificationService, options, cancellationToken).ConfigureAwait(false); + context, document, spanToTag.SnapshotSpan, classificationService, options, currentSemanticVersion, cancellationToken).ConfigureAwait(false); } private async Task TryClassifyContainingMemberSpanAsync( @@ -156,39 +154,39 @@ private async Task TryClassifyContainingMemberSpanAsync( SnapshotSpan snapshotSpan, IClassificationService classificationService, ClassificationOptions options, + VersionStamp currentSemanticVersion, CancellationToken cancellationToken) { - var range = context.TextChangeRange; - if (range == null) - { - // There was no text change range, we can't just reclassify a member body. - return false; - } - // there was top level edit, check whether that edit updated top level element if (!document.SupportsSyntaxTree) return false; - var lastSemanticVersion = (VersionStamp?)context.State; - if (lastSemanticVersion != null) - { - var currentSemanticVersion = await document.Project.GetDependentSemanticVersionAsync(cancellationToken).ConfigureAwait(false); - if (lastSemanticVersion.Value != currentSemanticVersion) - { - // A top level change was made. We can't perform this optimization. - return false; - } - } + // No cached state, so we can't check if the edits were just inside a member. + if (context.State is null) + return false; + + // Retrieve the information about the last time we classified this document. + var (lastSemanticVersion, lastTextImageVersion) = ((VersionStamp, ITextImageVersion))context.State; + + // if a top level change was made. We can't perform this optimization. + if (lastSemanticVersion != currentSemanticVersion) + return false; var service = document.GetRequiredLanguageService(); - // perf optimization. Check whether all edits since the last update has happened within - // a member. If it did, it will find the member that contains the changes and only refresh - // that member. If possible, try to get a speculative binder to make things even cheaper. + // perf optimization. Check whether all edits since the last update has happened within a member. If it did, it + // will find the member that contains the changes and only refresh that member. If possible, try to get a + // speculative binder to make things even cheaper. + + var currentTextImageVersion = GetTextImageVersion(snapshotSpan); + + var textChangeRanges = ITextImageHelpers.GetChangeRanges(lastTextImageVersion, currentTextImageVersion); + var collapsedRange = TextChangeRange.Collapse(textChangeRanges); + + var changedSpan = new TextSpan(collapsedRange.Span.Start, collapsedRange.NewLength); var root = await document.GetRequiredSyntaxRootAsync(cancellationToken).ConfigureAwait(false); - var changedSpan = new TextSpan(range.Value.Span.Start, range.Value.NewLength); var member = service.GetContainingMemberDeclaration(root, changedSpan.Start); if (member == null || !member.FullSpan.Contains(changedSpan)) { @@ -221,16 +219,20 @@ private async Task TryClassifyContainingMemberSpanAsync( // re-classify only the member we're inside. await ClassifySpansAsync( - context, document, subSpanToTag, classificationService, options, cancellationToken).ConfigureAwait(false); + context, document, subSpanToTag, classificationService, options, currentSemanticVersion, cancellationToken).ConfigureAwait(false); return true; } + private static ITextImageVersion GetTextImageVersion(SnapshotSpan snapshotSpan) + => ((ITextSnapshot2)snapshotSpan.Snapshot).TextImage.Version; + private async Task ClassifySpansAsync( TaggerContext context, Document document, SnapshotSpan snapshotSpan, IClassificationService classificationService, ClassificationOptions options, + VersionStamp currentSemanticVersion, CancellationToken cancellationToken) { try @@ -243,29 +245,33 @@ private async Task ClassifySpansAsync( // that we preserve that same behavior in OOP if we end up computing the tags there. options = options with { FrozenPartialSemantics = context.FrozenPartialSemantics }; + var span = snapshotSpan.Span; + var snapshot = snapshotSpan.Snapshot; + if (_type == ClassificationType.Semantic) { await classificationService.AddSemanticClassificationsAsync( - document, snapshotSpan.Span.ToTextSpan(), options, classifiedSpans, cancellationToken).ConfigureAwait(false); + document, span.ToTextSpan(), options, classifiedSpans, cancellationToken).ConfigureAwait(false); } else if (_type == ClassificationType.EmbeddedLanguage) { await classificationService.AddEmbeddedLanguageClassificationsAsync( - document, snapshotSpan.Span.ToTextSpan(), options, classifiedSpans, cancellationToken).ConfigureAwait(false); + document, span.ToTextSpan(), options, classifiedSpans, cancellationToken).ConfigureAwait(false); } else { throw ExceptionUtilities.UnexpectedValue(_type); } - foreach (var span in classifiedSpans) - context.AddTag(ClassificationUtilities.Convert(_typeMap, snapshotSpan.Snapshot, span)); - - var version = await document.Project.GetDependentSemanticVersionAsync(cancellationToken).ConfigureAwait(false); + foreach (var classifiedSpan in classifiedSpans) + context.AddTag(ClassificationUtilities.Convert(_typeMap, snapshot, classifiedSpan)); // Let the context know that this was the span we actually tried to tag. context.SetSpansTagged([snapshotSpan]); - context.State = version; + + // Store the semantic version and text-image-version we used to produce these tags. We can use this in + // the future to try to limit what we classify, if all edits were made within a single member. + context.State = (currentSemanticVersion, GetTextImageVersion(snapshotSpan)); } } catch (Exception e) when (FatalError.ReportAndPropagateUnlessCanceled(e, cancellationToken)) diff --git a/src/EditorFeatures/Core/EditAndContinue/ActiveStatementTaggerProvider.cs b/src/EditorFeatures/Core/EditAndContinue/ActiveStatementTaggerProvider.cs index f76bdbbe9b2bb..911b8fa3f5487 100644 --- a/src/EditorFeatures/Core/EditAndContinue/ActiveStatementTaggerProvider.cs +++ b/src/EditorFeatures/Core/EditAndContinue/ActiveStatementTaggerProvider.cs @@ -40,10 +40,6 @@ internal partial class ActiveStatementTaggerProvider( [Import(AllowDefault = true)] ITextBufferVisibilityTracker? visibilityTracker, IAsynchronousOperationListenerProvider listenerProvider) : AsynchronousTaggerProvider(threadingContext, globalOptions, visibilityTracker, listenerProvider.GetListener(FeatureAttribute.Classification)) { - // We want to track text changes so that we can try to only reclassify a method body if - // all edits were contained within one. - protected override TaggerTextChangeBehavior TextChangeBehavior => TaggerTextChangeBehavior.TrackTextChanges; - protected override TaggerDelay EventChangeDelay => TaggerDelay.NearImmediate; protected override ITaggerEventSource CreateEventSource(ITextView? textView, ITextBuffer subjectBuffer) diff --git a/src/EditorFeatures/Core/Tagging/AbstractAsynchronousTaggerProvider.TagSource.cs b/src/EditorFeatures/Core/Tagging/AbstractAsynchronousTaggerProvider.TagSource.cs index 8c93a15e83032..2524f86018236 100644 --- a/src/EditorFeatures/Core/Tagging/AbstractAsynchronousTaggerProvider.TagSource.cs +++ b/src/EditorFeatures/Core/Tagging/AbstractAsynchronousTaggerProvider.TagSource.cs @@ -5,7 +5,9 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading; using Microsoft.CodeAnalysis.Editor.Shared.Extensions; using Microsoft.CodeAnalysis.Editor.Shared.Tagging; @@ -27,12 +29,12 @@ internal partial class AbstractAsynchronousTaggerProvider /// tagging infrastructure. It is the coordinator between s, /// s, and s. /// - /// The is the type that actually owns the - /// list of cached tags. When an says tags need to be recomputed, - /// the tag source starts the computation and calls to build - /// the new list of tags. When that's done, the tags are stored in . The - /// tagger, when asked for tags from the editor, then returns the tags that are stored in - /// + /// The is the type that actually owns the list of cached tags. When an says tags need to be recomputed, the tag source starts the computation and calls + /// to build the new list of tags. When + /// that's done, the tags are stored in . The tagger, when asked + /// for tags from the editor, then returns the tags that are stored in /// /// There is a one-to-many relationship between s /// and s. Special cases, like reference highlighting (which processes multiple @@ -86,6 +88,20 @@ private sealed partial class TagSource /// private readonly CancellationSeries _nonFrozenComputationCancellationSeries; + /// + /// The last tag trees that we computed per buffer. Note: this can be read/written from any thread. Because of + /// that, we have to use safe operations to actually read or write it. This includes using looping "compare and + /// swap" algorithms to make sure that it is consistently moved forward no matter which thread is trying to + /// mutate it. + /// + private ImmutableDictionary> _cachedTagTrees_mayChangeFromAnyThread = ImmutableDictionary>.Empty; + + #endregion + + #region Mutable state. Only accessed from _eventChangeQueue + + private object? _state_accessOnlyFromEventChangeQueueCallback; + #endregion #region Fields that can only be accessed from the foreground thread @@ -121,13 +137,6 @@ private sealed partial class TagSource #region Mutable state. Can only be accessed from the foreground thread - /// - /// accumulated text changes since last tag calculation - /// - private TextChangeRange? _accumulatedTextChanges_doNotAccessDirectly; - private ImmutableDictionary> _cachedTagTrees_doNotAccessDirectly = ImmutableDictionary.Create>(); - private object? _state_doNotAccessDirecty; - /// /// Keep track of if we are processing the first request. If our provider returns /// for , @@ -202,9 +211,13 @@ public TagSource( // Create the tagger-specific events that will cause the tagger to refresh. _eventSource = CreateEventSource(); - // any time visibility changes, resume tagging on all taggers. Any non-visible taggers will pause - // themselves immediately afterwards. - _onVisibilityChanged = () => ResumeIfVisible(); + // Any time visibility changes try to pause us if we're not visible, or resume us if we are. + _onVisibilityChanged = () => + { + _dataSource.ThreadingContext.ThrowIfNotOnUIThread(); + PauseIfNotVisible(); + ResumeIfVisible(); + }; // Now hook up this tagger to all interesting events. Connect(); @@ -225,8 +238,11 @@ void Connect() _eventSource.Changed += OnEventSourceChanged; - if (_dataSource.TextChangeBehavior.HasFlag(TaggerTextChangeBehavior.TrackTextChanges)) + if (_dataSource.TextChangeBehavior.HasFlag(TaggerTextChangeBehavior.RemoveAllTags) || + _dataSource.TextChangeBehavior.HasFlag(TaggerTextChangeBehavior.RemoveTagsThatIntersectEdits)) + { _subjectBuffer.Changed += OnSubjectBufferChanged; + } if (_dataSource.CaretChangeBehavior.HasFlag(TaggerCaretChangeBehavior.RemoveAllTagsOnCaretMoveOutsideOfTag)) { @@ -270,8 +286,11 @@ void Disconnect() _textView.Caret.PositionChanged -= OnCaretPositionChanged; } - if (_dataSource.TextChangeBehavior.HasFlag(TaggerTextChangeBehavior.TrackTextChanges)) + if (_dataSource.TextChangeBehavior.HasFlag(TaggerTextChangeBehavior.RemoveAllTags) || + _dataSource.TextChangeBehavior.HasFlag(TaggerTextChangeBehavior.RemoveTagsThatIntersectEdits)) + { _subjectBuffer.Changed -= OnSubjectBufferChanged; + } _eventSource.Changed -= OnEventSourceChanged; @@ -336,51 +355,6 @@ private ITaggerEventSource CreateEventSource() return TaggerEventSources.Compose(optionChangedEventSources); } - private TextChangeRange? AccumulatedTextChanges - { - get - { - _dataSource.ThreadingContext.ThrowIfNotOnUIThread(); - return _accumulatedTextChanges_doNotAccessDirectly; - } - - set - { - _dataSource.ThreadingContext.ThrowIfNotOnUIThread(); - _accumulatedTextChanges_doNotAccessDirectly = value; - } - } - - private ImmutableDictionary> CachedTagTrees - { - get - { - _dataSource.ThreadingContext.ThrowIfNotOnUIThread(); - return _cachedTagTrees_doNotAccessDirectly; - } - - set - { - _dataSource.ThreadingContext.ThrowIfNotOnUIThread(); - _cachedTagTrees_doNotAccessDirectly = value; - } - } - - private object? State - { - get - { - _dataSource.ThreadingContext.ThrowIfNotOnUIThread(); - return _state_doNotAccessDirecty; - } - - set - { - _dataSource.ThreadingContext.ThrowIfNotOnUIThread(); - _state_doNotAccessDirecty = value; - } - } - private void RaiseTagsChanged(ITextBuffer buffer, DiffResult difference) { _dataSource.ThreadingContext.ThrowIfNotOnUIThread(); diff --git a/src/EditorFeatures/Core/Tagging/AbstractAsynchronousTaggerProvider.TagSource_ProduceTags.cs b/src/EditorFeatures/Core/Tagging/AbstractAsynchronousTaggerProvider.TagSource_ProduceTags.cs index c40775baecbff..8a2fca0a6b0b5 100644 --- a/src/EditorFeatures/Core/Tagging/AbstractAsynchronousTaggerProvider.TagSource_ProduceTags.cs +++ b/src/EditorFeatures/Core/Tagging/AbstractAsynchronousTaggerProvider.TagSource_ProduceTags.cs @@ -54,8 +54,8 @@ private void RemoveAllTags() { _dataSource.ThreadingContext.ThrowIfNotOnUIThread(); - var oldTagTrees = this.CachedTagTrees; - this.CachedTagTrees = ImmutableDictionary>.Empty; + var oldTagTrees = Interlocked.Exchange( + ref _cachedTagTrees_mayChangeFromAnyThread, ImmutableDictionary>.Empty); var snapshot = _subjectBuffer.CurrentSnapshot; var oldTagTree = GetTagTree(snapshot, oldTagTrees); @@ -68,42 +68,6 @@ private void OnSubjectBufferChanged(object? _, TextContentChangedEventArgs e) { _dataSource.ThreadingContext.ThrowIfNotOnUIThread(); UpdateTagsForTextChange(e); - AccumulateTextChanges(e); - } - - private void AccumulateTextChanges(TextContentChangedEventArgs contentChanged) - { - _dataSource.ThreadingContext.ThrowIfNotOnUIThread(); - var contentChanges = contentChanged.Changes; - var count = contentChanges.Count; - - switch (count) - { - case 0: - return; - - case 1: - // PERF: Optimize for the simple case of typing on a line. - { - var c = contentChanges[0]; - var textChangeRange = new TextChangeRange(new TextSpan(c.OldSpan.Start, c.OldSpan.Length), c.NewLength); - this.AccumulatedTextChanges = this.AccumulatedTextChanges == null - ? textChangeRange - : this.AccumulatedTextChanges.Accumulate([textChangeRange]); - } - - break; - - default: - { - using var _ = ArrayBuilder.GetInstance(count, out var textChangeRanges); - foreach (var c in contentChanges) - textChangeRanges.Add(new TextChangeRange(new TextSpan(c.OldSpan.Start, c.OldSpan.Length), c.NewLength)); - - this.AccumulatedTextChanges = this.AccumulatedTextChanges.Accumulate(textChangeRanges); - break; - } - } } private void UpdateTagsForTextChange(TextContentChangedEventArgs e) @@ -129,32 +93,48 @@ private void RemoveTagsThatIntersectEdit(TextContentChangedEventArgs e) if (e.Changes.Count == 0) return; - var buffer = e.After.TextBuffer; - if (!this.CachedTagTrees.TryGetValue(buffer, out var treeForBuffer)) - return; - var snapshot = e.After; + var buffer = snapshot.TextBuffer; - var tagsToRemove = e.Changes.SelectMany(c => treeForBuffer.GetIntersectingSpans(new SnapshotSpan(snapshot, c.NewSpan))); - if (!tagsToRemove.Any()) - return; + // Everything we're passing in here is synchronous. So we can assert that this must complete synchronously + // as well. + var (oldTagTrees, newTagTrees) = CompareAndSwapTagTreesAsync( + oldTagTrees => + { + if (oldTagTrees.TryGetValue(buffer, out var treeForBuffer)) + { + var tagsToRemove = e.Changes.SelectMany(c => treeForBuffer.GetIntersectingSpans(new SnapshotSpan(snapshot, c.NewSpan))); + if (tagsToRemove.Any()) + { + var allTags = treeForBuffer.GetSpans(e.After).ToList(); + var newTagTree = new TagSpanIntervalTree( + buffer, + treeForBuffer.SpanTrackingMode, + allTags.Except(tagsToRemove, comparer: this)); + return new(oldTagTrees.SetItem(buffer, newTagTree)); + } + } - var allTags = treeForBuffer.GetSpans(e.After).ToList(); - var newTagTree = new TagSpanIntervalTree( - buffer, - treeForBuffer.SpanTrackingMode, - allTags.Except(tagsToRemove, comparer: this)); + // return oldTagTrees to indicate nothing changed. + return new(oldTagTrees); + }, _disposalTokenSource.Token).VerifyCompleted(); - this.CachedTagTrees = this.CachedTagTrees.SetItem(snapshot.TextBuffer, newTagTree); + // Can happen if we were canceled. Just bail out immediate. + if (newTagTrees is null) + return; + + // Nothing changed. Bail out. + if (oldTagTrees == newTagTrees) + return; // Not sure why we are diffing when we already have tagsToRemove. is it due to _tagSpanComparer might return // different result than GetIntersectingSpans? // // treeForBuffer basically points to oldTagTrees. case where oldTagTrees not exist is already taken cared by // CachedTagTrees.TryGetValue. - var difference = ComputeDifference(snapshot, newTagTree, treeForBuffer); + var difference = ComputeDifference(snapshot, newTagTrees[buffer], oldTagTrees[buffer]); - RaiseTagsChanged(snapshot.TextBuffer, difference); + RaiseTagsChanged(buffer, difference); } private TagSpanIntervalTree GetTagTree(ITextSnapshot snapshot, ImmutableDictionary> tagTrees) @@ -212,7 +192,8 @@ private async ValueTask ProcessEventChangeAsync( using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(lastNonFrozenComputationToken, cancellationToken); try { - return await RecomputeTagsAsync(highPriority, frozenPartialSemantics, linkedTokenSource.Token).ConfigureAwait(false); + await RecomputeTagsAsync(highPriority, frozenPartialSemantics, calledFromJtfRun: false, linkedTokenSource.Token).ConfigureAwait(false); + return default; } catch (OperationCanceledException ex) when (ExceptionUtilities.IsCurrentOperationBeingCancelled(ex, linkedTokenSource.Token)) { @@ -223,10 +204,39 @@ private async ValueTask ProcessEventChangeAsync( { // Normal request to either compute frozen partial tags, or compute normal tags in a tagger that does // *not* support frozen partial tagging. - return await RecomputeTagsAsync(highPriority, frozenPartialSemantics, cancellationToken).ConfigureAwait(false); + await RecomputeTagsAsync(highPriority, frozenPartialSemantics, calledFromJtfRun: false, cancellationToken).ConfigureAwait(false); + return default; } } + /// + /// Spins, repeatedly calling into with the current state of the tag trees. When + /// the result of the callback can be saved without any intervening writes to happening on another thread, then this helper returns. This + /// helper may also returns in the case of cancellation. + /// + private async Task<(ImmutableDictionary> oldTagTrees, ImmutableDictionary> newTagTrees)> + CompareAndSwapTagTreesAsync( + Func>, ValueTask>>> callback, + CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + var oldTagTrees = _cachedTagTrees_mayChangeFromAnyThread; + + // Compute the new tag trees, based on what the current tag trees are. Intentionally CA(true) here so + // we stay on the UI thread if we're in a JTF blocking call. + var newTagTrees = await callback(oldTagTrees).ConfigureAwait(true); + + // Now, try to update the cached tag trees to what we computed. If we win, we're done. Otherwise, some + // other thread was able to do this, and we need to try again. + if (oldTagTrees == Interlocked.CompareExchange(ref _cachedTagTrees_mayChangeFromAnyThread, newTagTrees, oldTagTrees)) + return (oldTagTrees, newTagTrees); + } + + return default; + } + /// /// Passed a boolean to say if we're computing the /// initial set of tags or not. If we're computing the initial set of tags, we lower @@ -238,17 +248,21 @@ private async ValueTask ProcessEventChangeAsync( /// In the event of a cancellation request, this method may either return at the next availability /// or throw a cancellation exception. /// - /// - /// If this tagging request should be processed as quickly as possible with no extra delays added for it. + /// If this tagging request should be processed as quickly as possible with no extra + /// delays added for it. /// - private async Task RecomputeTagsAsync( + /// If this method is being called from within a JTF.Run call. This is used to + /// ensure we don't do unnecessary switches to the threadpool while JTF is waiting on us. + private async Task>?> RecomputeTagsAsync( bool highPriority, bool frozenPartialSemantics, + bool calledFromJtfRun, CancellationToken cancellationToken) { + // Jump to the main thread, as we have to grab the spans to tag and the caret point. await _dataSource.ThreadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken).NoThrowAwaitable(); if (cancellationToken.IsCancellationRequested) - return default; + return null; // if we're tagging documents that are not visible, then introduce a long delay so that we avoid // consuming machine resources on work the user isn't likely to see. ConfigureAwait(true) so that if @@ -262,32 +276,27 @@ private async Task RecomputeTagsAsync( // bails gracefully by checking below. await _visibilityTracker.DelayWhileNonVisibleAsync( _dataSource.ThreadingContext, _dataSource.AsyncListener, _subjectBuffer, DelayTimeSpan.NonFocus, cancellationToken).NoThrowAwaitable(captureContext: true); - - if (cancellationToken.IsCancellationRequested) - return default; } - _dataSource.ThreadingContext.ThrowIfNotOnUIThread(); - if (cancellationToken.IsCancellationRequested) - return default; - using (Logger.LogBlock(FunctionId.Tagger_TagSource_RecomputeTags, cancellationToken)) { + _dataSource.ThreadingContext.ThrowIfNotOnUIThread(); + if (cancellationToken.IsCancellationRequested) + return null; + // Make a copy of all the data we need while we're on the foreground. Then switch to a threadpool // thread to do the computation. Finally, once new tags have been computed, then we update our state // again on the foreground. var spansToTag = GetSpansAndDocumentsToTag(); var caretPosition = _dataSource.GetCaretPoint(_textView, _subjectBuffer); - var oldTagTrees = this.CachedTagTrees; - var oldState = this.State; - - var textChangeRange = this.AccumulatedTextChanges; - var subjectBufferVersion = _subjectBuffer.CurrentSnapshot.Version.VersionNumber; - await TaskScheduler.Default; + // If we're being called from within a blocking JTF.Run call, we don't want to switch to the background + // if we can avoid it. + if (!calledFromJtfRun) + await TaskScheduler.Default; if (cancellationToken.IsCancellationRequested) - return default; + return null; if (frozenPartialSemantics) { @@ -296,54 +305,49 @@ await _visibilityTracker.DelayWhileNonVisibleAsync( ds.SnapshotSpan)); } - // Create a context to store pass the information along and collect the results. - var context = new TaggerContext( - oldState, frozenPartialSemantics, spansToTag, caretPosition, textChangeRange, oldTagTrees); - await ProduceTagsAsync(context, cancellationToken).ConfigureAwait(false); + // Now spin, trying to compute the updated tags. We only need to do this as the tag state is also + // allowed to change on the UI thread (for example, taggers can say they want tags to be immediately + // removed when an edit happens. So, we need to keep recomputing the tags until we win and become the + // latest tags. + var oldState = _state_accessOnlyFromEventChangeQueueCallback; - if (cancellationToken.IsCancellationRequested) - return default; + TaggerContext context = null!; + var (oldTagTrees, newTagTrees) = await CompareAndSwapTagTreesAsync( + async oldTagTrees => + { + // Create a context to store pass the information along and collect the results. + context = new TaggerContext( + oldState, frozenPartialSemantics, spansToTag, caretPosition, oldTagTrees); + await ProduceTagsAsync(context, cancellationToken).ConfigureAwait(false); - // Process the result to determine what changed. - var newTagTrees = ComputeNewTagTrees(oldTagTrees, context); - var bufferToChanges = ProcessNewTagTrees(spansToTag, oldTagTrees, newTagTrees, cancellationToken); + return ComputeNewTagTrees(oldTagTrees, context); + }, cancellationToken).ConfigureAwait(continueOnCapturedContext: calledFromJtfRun); - // Then switch back to the UI thread to update our state and kick off the work to notify the editor. - await _dataSource.ThreadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken).NoThrowAwaitable(); - if (cancellationToken.IsCancellationRequested) - return default; + // We may get back null if we were canceled. Immediately bail out in that case. + if (newTagTrees is null) + return null; - // Once we assign our state, we're uncancellable. We must report the changed information - // to the editor. The only case where it's ok not to is if the tagger itself is disposed. + // Once we assign our state, we're uncancellable. We must report the changed information to the editor. + // The only case where it's ok not to is if the tagger itself is disposed. Null out our token so nothing + // accidentally attempts to use it. cancellationToken = CancellationToken.None; - this.CachedTagTrees = newTagTrees; - this.State = context.State; - if (this._subjectBuffer.CurrentSnapshot.Version.VersionNumber == subjectBufferVersion) - { - // Only clear the accumulated text changes if the subject buffer didn't change during the - // tagging operation. Otherwise, it is impossible to know which changes occurred prior to the - // request to tag, and which ones occurred during the tagging itself. Since - // AccumulatedTextChanges is a conservative representation of the work that needs to be done, in - // the event this value is not cleared the only potential impact will be slightly more work - // being done during the next classification pass. - this.AccumulatedTextChanges = null; - } + var bufferToChanges = ProcessNewTagTrees(spansToTag, oldTagTrees, newTagTrees); - OnTagsChangedForBuffer(bufferToChanges, highPriority); + // Note: assigning to 'State' is completely safe. It is only ever read from the _eventChangeQueue + // serial callbacks on the threadpool. + _state_accessOnlyFromEventChangeQueueCallback = context.State; - // Once we've computed tags, pause ourselves if we're no longer visible. That way we don't consume any - // machine resources that the user won't even notice. - PauseIfNotVisible(); + OnTagsChangedForBuffer(bufferToChanges, highPriority); // If we were computing with frozen partial semantics here, enqueue work to compute *without* frozen // partial snapshots so we move to accurate results shortly. Create and pass along a new cancellation // token for this expensive work so that it can be canceled by future lightweight work. if (frozenPartialSemantics) this.EnqueueWork(highPriority, frozenPartialSemantics: false, _nonFrozenComputationCancellationSeries.CreateNext(default)); - } - return default; + return newTagTrees; + } } private ImmutableArray GetSpansAndDocumentsToTag() @@ -383,9 +387,7 @@ private static void CheckSnapshot(ITextSnapshot snapshot) } } - private ImmutableDictionary> ComputeNewTagTrees( - ImmutableDictionary> oldTagTrees, - TaggerContext context) + private ImmutableDictionary> ComputeNewTagTrees(ImmutableDictionary> oldTagTrees, TaggerContext context) { // Ignore any tag spans reported for any buffers we weren't interested in. @@ -492,10 +494,9 @@ private Task ProduceTagsAsync(TaggerContext context, CancellationToken can private Dictionary ProcessNewTagTrees( ImmutableArray spansToTag, ImmutableDictionary> oldTagTrees, - ImmutableDictionary> newTagTrees, - CancellationToken cancellationToken) + ImmutableDictionary> newTagTrees) { - using (Logger.LogBlock(FunctionId.Tagger_TagSource_ProcessNewTags, cancellationToken)) + using (Logger.LogBlock(FunctionId.Tagger_TagSource_ProcessNewTags, CancellationToken.None)) { var bufferToChanges = new Dictionary(); @@ -617,23 +618,27 @@ private DiffResult ComputeDifference( if (_disposalTokenSource.Token.IsCancellationRequested) return null; - // If this is the first time we're being asked for tags, and we're a tagger that requires the initial - // tags be available synchronously on this call, and the computation of tags hasn't completed yet, then - // force the tags to be computed now on this thread. The singular use case for this is Outlining which - // needs those tags synchronously computed for things like Metadata-as-Source collapsing. + // If this is the first time we're being asked for tags, and we're a tagger that requires the initial tags + // be available synchronously on this call, and the computation of tags hasn't completed yet, then force the + // tags to be computed now on this thread. The singular use case for this is Outlining which needs those + // tags synchronously computed for things like Metadata-as-Source collapsing. + var tagTrees = _cachedTagTrees_mayChangeFromAnyThread; if (_firstTagsRequest && _dataSource.ComputeInitialTagsSynchronously(buffer) && - !this.CachedTagTrees.TryGetValue(buffer, out _)) + !tagTrees.TryGetValue(buffer, out _)) { // Compute this as a high priority work item to have the lease amount of blocking as possible. - _dataSource.ThreadingContext.JoinableTaskFactory.Run(() => - this.RecomputeTagsAsync(highPriority: true, _dataSource.SupportsFrozenPartialSemantics, _disposalTokenSource.Token)); + tagTrees = _dataSource.ThreadingContext.JoinableTaskFactory.Run(() => + this.RecomputeTagsAsync(highPriority: true, _dataSource.SupportsFrozenPartialSemantics, calledFromJtfRun: true, _disposalTokenSource.Token)); } _firstTagsRequest = false; - // We're on the UI thread, so it's safe to access these variables. - this.CachedTagTrees.TryGetValue(buffer, out var tags); + // We can get null back if we were canceled. + if (tagTrees is null) + return null; + + tagTrees.TryGetValue(buffer, out var tags); return tags; } diff --git a/src/EditorFeatures/Core/Tagging/AbstractAsynchronousTaggerProvider.TagSource_TagsChanged.cs b/src/EditorFeatures/Core/Tagging/AbstractAsynchronousTaggerProvider.TagSource_TagsChanged.cs index 957730c5b3e05..a0862bffeebb7 100644 --- a/src/EditorFeatures/Core/Tagging/AbstractAsynchronousTaggerProvider.TagSource_TagsChanged.cs +++ b/src/EditorFeatures/Core/Tagging/AbstractAsynchronousTaggerProvider.TagSource_TagsChanged.cs @@ -8,7 +8,6 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis.Collections; -using Microsoft.CodeAnalysis.Editor.Shared.Extensions; using Microsoft.CodeAnalysis.Text.Shared.Extensions; using Microsoft.VisualStudio.Text; using Roslyn.Utilities; @@ -24,7 +23,8 @@ private partial class TagSource private void OnTagsChangedForBuffer( ICollection> changes, bool highPriority) { - _dataSource.ThreadingContext.ThrowIfNotOnUIThread(); + // Can be called from any thread. Just filters out changes that aren't for our buffer and adds to the right + // queue to actually notify interested parties. foreach (var change in changes) { diff --git a/src/EditorFeatures/Core/Tagging/AbstractAsynchronousTaggerProvider.cs b/src/EditorFeatures/Core/Tagging/AbstractAsynchronousTaggerProvider.cs index 08b0c89e4d93a..b5ebabac14f20 100644 --- a/src/EditorFeatures/Core/Tagging/AbstractAsynchronousTaggerProvider.cs +++ b/src/EditorFeatures/Core/Tagging/AbstractAsynchronousTaggerProvider.cs @@ -40,14 +40,9 @@ internal abstract partial class AbstractAsynchronousTaggerProvider where T private readonly ITextBufferVisibilityTracker? _visibilityTracker; /// - /// The behavior the tagger engine will have when text changes happen to the subject buffer - /// it is attached to. Most taggers can simply use . - /// However, advanced taggers that want to perform specialized behavior depending on what has - /// actually changed in the file can specify . - /// - /// If this is specified the tagger engine will track text changes and pass them along as - /// when calling - /// . + /// The behavior the tagger engine will have when text changes happen to the subject buffer it is attached to. Most + /// taggers can simply use . However, advanced taggers that want to + /// perform specialized behavior depending on what has actually changed in the file can specify that here. /// protected virtual TaggerTextChangeBehavior TextChangeBehavior => TaggerTextChangeBehavior.None; diff --git a/src/EditorFeatures/Core/Tagging/TaggerContext.cs b/src/EditorFeatures/Core/Tagging/TaggerContext.cs index fab148c0c801a..ba972866c5417 100644 --- a/src/EditorFeatures/Core/Tagging/TaggerContext.cs +++ b/src/EditorFeatures/Core/Tagging/TaggerContext.cs @@ -8,14 +8,13 @@ using System.Threading; using Microsoft.CodeAnalysis.Collections; using Microsoft.CodeAnalysis.Editor.Shared.Tagging; -using Microsoft.CodeAnalysis.Text; using Microsoft.CodeAnalysis.Text.Shared.Extensions; using Microsoft.VisualStudio.Text; using Microsoft.VisualStudio.Text.Tagging; namespace Microsoft.CodeAnalysis.Editor.Tagging; -internal class TaggerContext where TTag : ITag +internal sealed class TaggerContext where TTag : ITag { private readonly ImmutableDictionary> _existingTags; @@ -33,13 +32,6 @@ internal class TaggerContext where TTag : ITag public ImmutableArray SpansToTag { get; } public SnapshotPoint? CaretPosition { get; } - /// - /// The text that has changed between the last successful tagging and this new request to - /// produce tags. In order to be passed this value, - /// must be specified in . - /// - public TextChangeRange? TextChangeRange { get; } - /// /// The state of the tagger. Taggers can use this to keep track of information across calls /// to . Note: state will @@ -55,14 +47,12 @@ internal TaggerContext( Document document, ITextSnapshot snapshot, bool frozenPartialSemantics, - SnapshotPoint? caretPosition = null, - TextChangeRange? textChangeRange = null) + SnapshotPoint? caretPosition = null) : this( state: null, frozenPartialSemantics, [new DocumentSnapshotSpan(document, snapshot.GetFullSpan())], caretPosition, - textChangeRange, existingTags: null) { } @@ -72,14 +62,12 @@ internal TaggerContext( bool frozenPartialSemantics, ImmutableArray spansToTag, SnapshotPoint? caretPosition, - TextChangeRange? textChangeRange, ImmutableDictionary> existingTags) { this.State = state; this.FrozenPartialSemantics = frozenPartialSemantics; this.SpansToTag = spansToTag; this.CaretPosition = caretPosition; - this.TextChangeRange = textChangeRange; _spansTagged = spansToTag.SelectAsArray(ds => ds.SnapshotSpan); _existingTags = existingTags; diff --git a/src/EditorFeatures/Core/Tagging/TaggerTextChangeBehavior.cs b/src/EditorFeatures/Core/Tagging/TaggerTextChangeBehavior.cs index b91bfe6d6058f..008425992693f 100644 --- a/src/EditorFeatures/Core/Tagging/TaggerTextChangeBehavior.cs +++ b/src/EditorFeatures/Core/Tagging/TaggerTextChangeBehavior.cs @@ -20,26 +20,14 @@ internal enum TaggerTextChangeBehavior None = 0, /// - /// The async tagger infrastructure will track text changes to the subject buffer it is - /// attached to. The text changes will be provided to the - /// that is passed to . + /// The async tagger infrastructure will track text changes to the subject buffer it is attached to. On any edit, + /// tags that intersect the text change range will immediately removed. /// - TrackTextChanges = 1 << 0, + RemoveTagsThatIntersectEdits = 1 << 1, /// - /// The async tagger infrastructure will track text changes to the subject buffer it is - /// attached to. The text changes will be provided to the - /// that is passed to . - /// - /// On any edit, tags that intersect the text change range will immediately removed. + /// The async tagger infrastructure will track text changes to the subject buffer it is attached to. On any edit all + /// tags will we be removed. /// - RemoveTagsThatIntersectEdits = TrackTextChanges | (1 << 1), - - /// - /// The async tagger infrastructure will track text changes to the subject buffer it is - /// attached to. - /// - /// On any edit all tags will we be removed. - /// - RemoveAllTags = TrackTextChanges | (1 << 2), + RemoveAllTags = 1 << 2, } diff --git a/src/EditorFeatures/Text/Extensions.SnapshotSourceText.cs b/src/EditorFeatures/Text/Extensions.SnapshotSourceText.cs index 9adb305083698..a85c7fa6f7847 100644 --- a/src/EditorFeatures/Text/Extensions.SnapshotSourceText.cs +++ b/src/EditorFeatures/Text/Extensions.SnapshotSourceText.cs @@ -24,7 +24,6 @@ public static partial class Extensions /// private class SnapshotSourceText : SourceText { - private static readonly Func s_textLog = (v1, v2) => string.Format("FullRange : from {0} to {1}", v1, v2); /// /// The backing the SourceText instance @@ -361,7 +360,7 @@ private IReadOnlyList GetChangeRanges(ITextImage? oldImage, int } else { - return GetChangeRanges(oldImage, newImage, forward: oldImage.Version.VersionNumber <= newImage.Version.VersionNumber); + return ITextImageHelpers.GetChangeRanges(oldImage, newImage); } } @@ -373,78 +372,6 @@ private static bool AreSameReiteratedVersion(ITextImage oldImage, ITextImage new return oldSnapshot != null && newSnapshot != null && oldSnapshot.Version.ReiteratedVersionNumber == newSnapshot.Version.ReiteratedVersionNumber; } - private static readonly Func s_forwardTextChangeRange = c => CreateTextChangeRange(c, forward: true); - private static readonly Func s_backwardTextChangeRange = c => CreateTextChangeRange(c, forward: false); - - private static IReadOnlyList GetChangeRanges(ITextImage snapshot1, ITextImage snapshot2, bool forward) - { - var oldSnapshot = forward ? snapshot1 : snapshot2; - var newSnapshot = forward ? snapshot2 : snapshot1; - - INormalizedTextChangeCollection? changes = null; - for (var oldVersion = oldSnapshot.Version; - oldVersion != newSnapshot.Version; - oldVersion = oldVersion.Next) - { - if (oldVersion.Changes.Count != 0) - { - if (changes != null) - { - // Oops - more than one "textual" change between these snapshots, bail and try to find smallest changes span - Logger.Log(FunctionId.Workspace_SourceText_GetChangeRanges, s_textLog, snapshot1.Version.VersionNumber, snapshot2.Version.VersionNumber); - - return [GetChangeRanges(oldSnapshot.Version, newSnapshot.Version, forward)]; - } - else - { - changes = oldVersion.Changes; - } - } - } - - if (changes == null) - { - return ImmutableArray.Create(); - } - else - { - return ImmutableArray.CreateRange(changes.Select(forward ? s_forwardTextChangeRange : s_backwardTextChangeRange)); - } - } - - private static TextChangeRange GetChangeRanges(ITextImageVersion oldVersion, ITextImageVersion newVersion, bool forward) - { - TextChangeRange? range = null; - var iterator = GetMultipleVersionTextChanges(oldVersion, newVersion, forward); - foreach (var changes in forward ? iterator : iterator.Reverse()) - { - range = range.Accumulate(changes); - } - - RoslynDebug.Assert(range.HasValue); - return range.Value; - } - - private static IEnumerable> GetMultipleVersionTextChanges( - ITextImageVersion oldVersion, ITextImageVersion newVersion, bool forward) - { - for (var version = oldVersion; version != newVersion; version = version.Next) - { - yield return version.Changes.Select(forward ? s_forwardTextChangeRange : s_backwardTextChangeRange); - } - } - - private static TextChangeRange CreateTextChangeRange(ITextChange change, bool forward) - { - if (forward) - { - return new TextChangeRange(new TextSpan(change.OldSpan.Start, change.OldSpan.Length), change.NewLength); - } - else - { - return new TextChangeRange(new TextSpan(change.NewSpan.Start, change.NewSpan.Length), change.OldLength); - } - } #endregion } } diff --git a/src/EditorFeatures/Text/ITextImageHelpers.cs b/src/EditorFeatures/Text/ITextImageHelpers.cs new file mode 100644 index 0000000000000..69a640f2e45c7 --- /dev/null +++ b/src/EditorFeatures/Text/ITextImageHelpers.cs @@ -0,0 +1,96 @@ +// 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.Linq; +using Microsoft.CodeAnalysis.Internal.Log; +using Microsoft.VisualStudio.Text; +using Roslyn.Utilities; + +namespace Microsoft.CodeAnalysis.Text; + +internal static class ITextImageHelpers +{ + private static readonly Func s_textLog = (v1, v2) => string.Format("FullRange : from {0} to {1}", v1, v2); + + private static readonly Func s_forwardTextChangeRange = c => CreateTextChangeRange(c, forward: true); + private static readonly Func s_backwardTextChangeRange = c => CreateTextChangeRange(c, forward: false); + + public static IReadOnlyList GetChangeRanges(ITextImage oldImage, ITextImage newImage) + => GetChangeRanges(oldImage.Version, newImage.Version); + + public static IReadOnlyList GetChangeRanges(ITextImageVersion oldImageVersion, ITextImageVersion newImageVersion) + { + var forward = oldImageVersion.VersionNumber <= newImageVersion.VersionNumber; + + var oldSnapshotVersion = forward ? oldImageVersion : newImageVersion; + var newSnapshotVersion = forward ? newImageVersion : oldImageVersion; + + INormalizedTextChangeCollection? changes = null; + for (var oldVersion = oldSnapshotVersion; + oldVersion != newSnapshotVersion; + oldVersion = oldVersion.Next) + { + if (oldVersion.Changes.Count != 0) + { + if (changes != null) + { + // Oops - more than one "textual" change between these snapshots, bail and try to find smallest changes span + Logger.Log(FunctionId.Workspace_SourceText_GetChangeRanges, s_textLog, oldImageVersion.VersionNumber, newImageVersion.VersionNumber); + + return [GetChangeRanges(oldSnapshotVersion, newSnapshotVersion, forward)]; + } + else + { + changes = oldVersion.Changes; + } + } + } + + if (changes == null) + { + return []; + } + else + { + return ImmutableArray.CreateRange(changes.Select(forward ? s_forwardTextChangeRange : s_backwardTextChangeRange)); + } + } + + private static TextChangeRange GetChangeRanges(ITextImageVersion oldVersion, ITextImageVersion newVersion, bool forward) + { + TextChangeRange? range = null; + var iterator = GetMultipleVersionTextChanges(oldVersion, newVersion, forward); + foreach (var changes in forward ? iterator : iterator.Reverse()) + { + range = range.Accumulate(changes); + } + + RoslynDebug.Assert(range.HasValue); + return range.Value; + } + + private static IEnumerable> GetMultipleVersionTextChanges( + ITextImageVersion oldVersion, ITextImageVersion newVersion, bool forward) + { + for (var version = oldVersion; version != newVersion; version = version.Next) + { + yield return version.Changes.Select(forward ? s_forwardTextChangeRange : s_backwardTextChangeRange); + } + } + + private static TextChangeRange CreateTextChangeRange(ITextChange change, bool forward) + { + if (forward) + { + return new TextChangeRange(new TextSpan(change.OldSpan.Start, change.OldSpan.Length), change.NewLength); + } + else + { + return new TextChangeRange(new TextSpan(change.NewSpan.Start, change.NewSpan.Length), change.OldLength); + } + } +}