Skip to content

Commit 524fa48

Browse files
SLVS-2430 Trigger analysis on pressed key with debounce
1 parent c56d19b commit 524fa48

File tree

4 files changed

+112
-24
lines changed

4 files changed

+112
-24
lines changed

src/Integration.Vsix.UnitTests/SonarLintTagger/TaggerProviderTests.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
using SonarLint.VisualStudio.Integration.Vsix;
3232
using SonarLint.VisualStudio.Integration.Vsix.Analysis;
3333
using SonarLint.VisualStudio.Integration.Vsix.ErrorList;
34+
using SonarLint.VisualStudio.Integration.Vsix.SonarLintTagger;
3435
using SonarLint.VisualStudio.IssueVisualization.Editor;
3536
using SonarLint.VisualStudio.IssueVisualization.Editor.LanguageDetection;
3637

@@ -57,6 +58,7 @@ public class TaggerProviderTests
5758
private IAnalyzer analyzer;
5859
private IInitializationProcessorFactory initializationProcessorFactory;
5960
private IThreadHandling threadHandling;
61+
private ITaskExecutorWithDebounceFactory taskExecutorWithDebounceFactory;
6062

6163
private static readonly AnalysisLanguage[] DetectedLanguagesJsTs = [AnalysisLanguage.TypeScript, AnalysisLanguage.Javascript];
6264

@@ -90,6 +92,8 @@ public void SetUp()
9092

9193
threadHandling = Substitute.ForPartsOf<NoOpThreadHandler>();
9294

95+
taskExecutorWithDebounceFactory = Substitute.For<ITaskExecutorWithDebounceFactory>();
96+
9397
testSubject = CreateAndInitializeTestSubject();
9498
}
9599

@@ -121,7 +125,8 @@ private static Export[] GetRequiredExports() =>
121125
MefTestHelpers.CreateExport<IFileTracker>(),
122126
MefTestHelpers.CreateExport<IAnalyzer>(),
123127
MefTestHelpers.CreateExport<ILogger>(),
124-
MefTestHelpers.CreateExport<IInitializationProcessorFactory>()
128+
MefTestHelpers.CreateExport<IInitializationProcessorFactory>(),
129+
MefTestHelpers.CreateExport<ITaskExecutorWithDebounceFactory>(),
125130
];
126131

127132
#endregion MEF tests
@@ -147,6 +152,7 @@ public void CreateTagger_should_create_tracker_when_tagger_is_created()
147152
tagger.Should().NotBeNull();
148153

149154
VerifyCreateIssueConsumerWasCalled(doc);
155+
taskExecutorWithDebounceFactory.Received(1).Create<ITextSnapshot>(debounceMilliseconds: 1000);
150156
}
151157

152158
[TestMethod]
@@ -611,7 +617,7 @@ private TaggerProvider CreateAndInitializeTestSubject()
611617
var taggerProvider = new TaggerProvider(
612618
mockSonarErrorDataSource, dummyDocumentFactoryService, serviceProvider,
613619
mockSonarLanguageRecognizer, mockAnalysisRequester, vsProjectInfoProvider, issueConsumerFactory, issueConsumerStorage,
614-
mockTaggableBufferIndicator, mockFileTracker, analyzer, logger, initializationProcessorFactory);
620+
mockTaggableBufferIndicator, mockFileTracker, analyzer, logger, initializationProcessorFactory, taskExecutorWithDebounceFactory);
615621
taggerProvider.InitializationProcessor.InitializeAsync().GetAwaiter().GetResult();
616622
return taggerProvider;
617623
}

src/Integration.Vsix.UnitTests/SonarLintTagger/TextBufferIssueTrackerTests.cs

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ public class TextBufferIssueTrackerTests
5656
private IIssueConsumerFactory issueConsumerFactory;
5757
private IIssueConsumerStorage issueConsumerStorage;
5858
private IIssueConsumer issueConsumer;
59+
private ITaskExecutorWithDebounceFactory taskExecutorWithDebounceFactory;
60+
private ITaskExecutorWithDebounce<ITextSnapshot> taskExecutorWithDebounce;
5961

6062
[TestInitialize]
6163
public void SetUp()
@@ -71,6 +73,9 @@ public void SetUp()
7173
mockDocumentTextBuffer = CreateTextBufferMock(mockTextSnapshot);
7274
mockedJavascriptDocumentFooJs = CreateDocumentMock("foo.js", mockDocumentTextBuffer);
7375
javascriptLanguage = [AnalysisLanguage.Javascript];
76+
taskExecutorWithDebounceFactory = Substitute.For<ITaskExecutorWithDebounceFactory>();
77+
taskExecutorWithDebounce = Substitute.For<ITaskExecutorWithDebounce<ITextSnapshot>>();
78+
taskExecutorWithDebounceFactory.Create<ITextSnapshot>(Arg.Any<double>()).Returns(taskExecutorWithDebounce);
7479
MockIssueConsumerFactory(mockedJavascriptDocumentFooJs, issueConsumer);
7580

7681
testSubject = CreateTestSubject(mockedJavascriptDocumentFooJs);
@@ -98,6 +103,7 @@ public void Ctor_RegistersEventsTrackerAndFactory()
98103
taggerProvider.ActiveTrackersForTesting.Should().BeEquivalentTo(testSubject);
99104

100105
mockedJavascriptDocumentFooJs.Received(1).FileActionOccurred += Arg.Any<EventHandler<TextDocumentFileActionEventArgs>>();
106+
((ITextBuffer2)mockDocumentTextBuffer).Received(1).ChangedOnBackground += Arg.Any<EventHandler<TextContentChangedEventArgs>>();
101107

102108
// Note: the test subject isn't responsible for adding the entry to the buffer.Properties
103109
// - that's done by the TaggerProvider.
@@ -196,7 +202,6 @@ public void WhenFileIsLoaded_EventsAreNotRaised()
196202
var renamedEventHandler = Substitute.For<EventHandler<DocumentRenamedEventArgs>>();
197203
var savedEventHandler = Substitute.For<EventHandler<DocumentEventArgs>>();
198204
taggerProvider.OpenDocumentRenamed += renamedEventHandler;
199-
taggerProvider.DocumentSaved += savedEventHandler;
200205

201206
RaiseFileLoadedEvent(mockedJavascriptDocumentFooJs);
202207

@@ -326,6 +331,21 @@ public void UpdateAnalysisState_CriticalException_IsNotSuppressed()
326331
.WithMessage("this is a test");
327332
}
328333

334+
[TestMethod]
335+
public void OnTextBufferChangedOnBackground_UpdatesAnalysisState()
336+
{
337+
var eventHandler = SubscribeToDocumentSaved();
338+
var newContent = "new content";
339+
var newSnapshot = CreateTextSnapshotMock(newContent);
340+
var newAnalysisSnapshot = new AnalysisSnapshot(mockedJavascriptDocumentFooJs.FilePath, newSnapshot);
341+
MockTaskExecutorWithDebounce(newSnapshot);
342+
CreateTestSubject(mockedJavascriptDocumentFooJs);
343+
344+
RaiseTextBufferChangedOnBackground(currentTextBuffer: mockDocumentTextBuffer, newSnapshot);
345+
346+
VerifyAnalysisStateUpdated(mockedJavascriptDocumentFooJs, newAnalysisSnapshot, eventHandler, newContent);
347+
}
348+
329349
private void SetUpIssueConsumerStorageThrows(Exception exception) => issueConsumerStorage.When(x => x.Remove(Arg.Any<string>())).Do(x => throw exception);
330350

331351
private static void VerifySingletonManagerDoesNotExist(ITextBuffer buffer) => FindSingletonManagerInPropertyCollection(buffer).Should().BeNull();
@@ -372,21 +392,21 @@ private TaggerProvider CreateTaggerProvider()
372392
var analysisRequester = mockAnalysisRequester;
373393
var provider = new TaggerProvider(sonarErrorListDataSource, textDocumentFactoryService,
374394
serviceProvider, languageRecognizer, analysisRequester, vsProjectInfoProvider, issueConsumerFactory, issueConsumerStorage, Mock.Of<ITaggableBufferIndicator>(),
375-
mockFileTracker, analyzer, logger, new InitializationProcessorFactory(Substitute.For<IAsyncLockFactory>(), new NoOpThreadHandler(), new TestLogger()));
395+
mockFileTracker, analyzer, logger, new InitializationProcessorFactory(Substitute.For<IAsyncLockFactory>(), new NoOpThreadHandler(), new TestLogger()), taskExecutorWithDebounceFactory);
376396
return provider;
377397
}
378398

379-
private static ITextSnapshot CreateTextSnapshotMock()
399+
private static ITextSnapshot CreateTextSnapshotMock(string content = TextContent)
380400
{
381401
var textSnapshot = Substitute.For<ITextSnapshot>();
382-
textSnapshot.GetText().Returns(TextContent);
402+
textSnapshot.GetText().Returns(content);
383403
return textSnapshot;
384404
}
385405

386406
private static ITextBuffer CreateTextBufferMock(ITextSnapshot textSnapshot)
387407
{
388408
// Text buffer with a properties collection and current snapshot
389-
var mockTextBuffer = Substitute.For<ITextBuffer>();
409+
var mockTextBuffer = Substitute.For<ITextBuffer2>();
390410

391411
var dummyProperties = new PropertyCollection();
392412
mockTextBuffer.Properties.Returns(dummyProperties);
@@ -446,12 +466,51 @@ private TextBufferIssueTracker CreateTestSubject(ITextDocument textDocument)
446466
private TextBufferIssueTracker CreateTestSubject(ITextDocument textDocument, ILogger logger) =>
447467
new(taggerProvider,
448468
textDocument, javascriptLanguage,
449-
mockSonarErrorDataSource, vsProjectInfoProvider, issueConsumerFactory, issueConsumerStorage, logger);
469+
mockSonarErrorDataSource, vsProjectInfoProvider, issueConsumerFactory, issueConsumerStorage, taskExecutorWithDebounce, logger);
450470

451471
private void ClearIssueConsumerCalls()
452472
{
453473
issueConsumerStorage.ClearReceivedCalls();
454474
issueConsumerFactory.ClearReceivedCalls();
455475
vsProjectInfoProvider.ClearReceivedCalls();
456476
}
477+
478+
private static void RaiseTextBufferChangedOnBackground(ITextBuffer currentTextBuffer, ITextSnapshot newTextSnapshot)
479+
{
480+
var args = new TextContentChangedEventArgs(Substitute.For<ITextSnapshot>(), newTextSnapshot, EditOptions.DefaultMinimalChange, null);
481+
((ITextBuffer2)currentTextBuffer).ChangedOnBackground += Raise.EventWith(null, args);
482+
}
483+
484+
private EventHandler<DocumentEventArgs> SubscribeToDocumentSaved()
485+
{
486+
var eventHandler = Substitute.For<EventHandler<DocumentEventArgs>>();
487+
taggerProvider.DocumentUpdated += eventHandler;
488+
return eventHandler;
489+
}
490+
491+
private void MockTaskExecutorWithDebounce(ITextSnapshot newSnapshot) =>
492+
taskExecutorWithDebounce.When(x => x.DebounceAsync(Arg.Is<ITextSnapshot>(x => x == newSnapshot), Arg.Any<Action<ITextSnapshot>>())).Do(callInfo =>
493+
{
494+
var action = callInfo.Arg<Action<ITextSnapshot>>();
495+
action(newSnapshot);
496+
});
497+
498+
private void VerifyAnalysisStateUpdated(
499+
ITextDocument textDocument,
500+
AnalysisSnapshot newAnalysisSnapshot,
501+
EventHandler<DocumentEventArgs> eventHandler,
502+
string newContent)
503+
{
504+
issueConsumerStorage.Received().Remove(textDocument.FilePath);
505+
vsProjectInfoProvider.Received().GetDocumentProjectInfo(newAnalysisSnapshot.FilePath);
506+
issueConsumerFactory.Received().Create(textDocument, newAnalysisSnapshot.FilePath, newAnalysisSnapshot.TextSnapshot, Arg.Any<string>(), Arg.Any<Guid>(),
507+
Arg.Any<SnapshotChangedHandler>());
508+
issueConsumerStorage.Received().Set(textDocument.FilePath, Arg.Any<IIssueConsumer>());
509+
issueConsumer.Received().SetIssues(textDocument.FilePath, []);
510+
issueConsumer.Received().SetHotspots(textDocument.FilePath, []);
511+
eventHandler.Received().Invoke(taggerProvider,
512+
Arg.Is<DocumentEventArgs>(x => x.Document.FullPath == textDocument.FilePath
513+
&& x.Document.DetectedLanguages == javascriptLanguage
514+
&& x.Content == newContent));
515+
}
457516
}

src/Integration.Vsix/SonarLintTagger/TaggerProvider.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ namespace SonarLint.VisualStudio.Integration.Vsix;
5353
[PartCreationPolicy(CreationPolicy.Shared)]
5454
internal sealed class TaggerProvider : ITaggerProvider, IRequireInitialization, IDocumentTracker, IDisposable
5555
{
56+
private const double DebounceMilliseconds = 1000;
5657
internal static readonly Type SingletonManagerPropertyCollectionKey = typeof(SingletonDisposableTaggerManager<IErrorTag>);
5758
private readonly IAnalyzer analyzer;
5859
private readonly IFileTracker fileTracker;
@@ -64,6 +65,7 @@ internal sealed class TaggerProvider : ITaggerProvider, IRequireInitialization,
6465
private readonly ISonarLanguageRecognizer languageRecognizer;
6566
private readonly IAnalysisRequester analysisRequester;
6667
private readonly ILogger logger;
68+
private readonly ITaskExecutorWithDebounceFactory taskExecutorWithDebounceFactory;
6769

6870
private readonly object reanalysisLockObject = new();
6971

@@ -94,7 +96,8 @@ internal TaggerProvider(
9496
IFileTracker fileTracker,
9597
IAnalyzer analyzer,
9698
ILogger logger,
97-
IInitializationProcessorFactory initializationProcessorFactory)
99+
IInitializationProcessorFactory initializationProcessorFactory,
100+
ITaskExecutorWithDebounceFactory taskExecutorWithDebounceFactory)
98101
{
99102
this.sonarErrorDataSource = sonarErrorDataSource;
100103
this.textDocumentFactoryService = textDocumentFactoryService;
@@ -107,6 +110,7 @@ internal TaggerProvider(
107110
this.fileTracker = fileTracker;
108111
this.analyzer = analyzer;
109112
this.logger = logger;
113+
this.taskExecutorWithDebounceFactory = taskExecutorWithDebounceFactory;
110114

111115
InitializationProcessor = initializationProcessorFactory.CreateAndStart<TaggerProvider>(
112116
[],
@@ -216,6 +220,7 @@ private TextBufferIssueTracker InternalCreateTextBufferIssueTracker(ITextDocumen
216220
vsProjectInfoProvider,
217221
issueConsumerFactory,
218222
issueConsumerStorage,
223+
taskExecutorWithDebounceFactory.Create<ITextSnapshot>(DebounceMilliseconds),
219224
logger);
220225

221226
#endregion IViewTaggerProvider members

src/Integration.Vsix/SonarLintTagger/TextBufferIssueTracker.cs

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
using Microsoft.VisualStudio.Text;
2222
using Microsoft.VisualStudio.Text.Tagging;
23+
using Microsoft.VisualStudio.Threading;
2324
using SonarLint.VisualStudio.Core;
2425
using SonarLint.VisualStudio.Core.Analysis;
2526
using SonarLint.VisualStudio.Integration.Vsix.Analysis;
@@ -48,10 +49,12 @@ internal sealed class TextBufferIssueTracker : IIssueTracker, ITagger<IErrorTag>
4849
private readonly ITextDocument document;
4950
private readonly IIssueConsumerFactory issueConsumerFactory;
5051
private readonly IIssueConsumerStorage issueConsumerStorage;
52+
private readonly ITaskExecutorWithDebounce<ITextSnapshot> taskExecutorWithDebounce;
5153
private readonly ILogger logger;
5254
private readonly ISonarErrorListDataSource sonarErrorDataSource;
5355
private readonly ITextBuffer textBuffer;
5456
private readonly IVsProjectInfoProvider vsProjectInfoProvider;
57+
5558
internal /* for testing */ TaggerProvider Provider { get; }
5659
internal /* for testing */ IssuesSnapshotFactory Factory { get; }
5760

@@ -63,6 +66,7 @@ public TextBufferIssueTracker(
6366
IVsProjectInfoProvider vsProjectInfoProvider,
6467
IIssueConsumerFactory issueConsumerFactory,
6568
IIssueConsumerStorage issueConsumerStorage,
69+
ITaskExecutorWithDebounce<ITextSnapshot> taskExecutorWithDebounce,
6670
ILogger logger)
6771
{
6872
Provider = provider;
@@ -72,6 +76,7 @@ public TextBufferIssueTracker(
7276
this.vsProjectInfoProvider = vsProjectInfoProvider;
7377
this.issueConsumerFactory = issueConsumerFactory;
7478
this.issueConsumerStorage = issueConsumerStorage;
79+
this.taskExecutorWithDebounce = taskExecutorWithDebounce;
7580
this.logger = logger;
7681
logger.ForContext(nameof(TextBufferIssueTracker));
7782

@@ -81,6 +86,10 @@ public TextBufferIssueTracker(
8186
Factory = new IssuesSnapshotFactory(LastAnalysisFilePath);
8287

8388
document.FileActionOccurred += SafeOnFileActionOccurred;
89+
if (textBuffer is ITextBuffer2 textBuffer2)
90+
{
91+
textBuffer2.ChangedOnBackground += TextBuffer_OnChangedOnBackground;
92+
}
8493

8594
sonarErrorDataSource.AddFactory(Factory);
8695
Provider.AddIssueTracker(this);
@@ -91,18 +100,7 @@ public TextBufferIssueTracker(
91100
public string LastAnalysisFilePath { get; private set; }
92101
public IEnumerable<AnalysisLanguage> DetectedLanguages { get; }
93102

94-
public void UpdateAnalysisState()
95-
{
96-
try
97-
{
98-
RemoveIssueConsumer(LastAnalysisFilePath);
99-
InitializeAnalysisState();
100-
}
101-
catch (Exception ex) when (!ErrorHandler.IsCriticalException(ex))
102-
{
103-
logger.WriteLine(Strings.Analysis_ErrorUpdatingAnalysisState, ex);
104-
}
105-
}
103+
public void UpdateAnalysisState() => RefreshAnalysisState();
106104

107105
public string GetText() => document.TextBuffer.CurrentSnapshot.GetText();
108106

@@ -151,6 +149,19 @@ private void SafeOnFileActionOccurred(object sender, TextDocumentFileActionEvent
151149
}
152150
}
153151

152+
private void RefreshAnalysisState(ITextSnapshot newTextSnapshot = null)
153+
{
154+
try
155+
{
156+
RemoveIssueConsumer(LastAnalysisFilePath);
157+
InitializeAnalysisState(newTextSnapshot);
158+
}
159+
catch (Exception ex) when (!ErrorHandler.IsCriticalException(ex))
160+
{
161+
logger.WriteLine(Strings.Analysis_ErrorUpdatingAnalysisState, ex);
162+
}
163+
}
164+
154165
private void SnapToNewSnapshot(IIssuesSnapshot newSnapshot)
155166
{
156167
// Tell our factory to snap to a new snapshot.
@@ -159,11 +170,11 @@ private void SnapToNewSnapshot(IIssuesSnapshot newSnapshot)
159170
sonarErrorDataSource.RefreshErrorList(Factory);
160171
}
161172

162-
private AnalysisSnapshot GetAnalysisSnapshot() => new(LastAnalysisFilePath, document.TextBuffer.CurrentSnapshot);
173+
private AnalysisSnapshot GetAnalysisSnapshot(ITextSnapshot newTextSnapshot = null) => new(LastAnalysisFilePath, newTextSnapshot ?? document.TextBuffer.CurrentSnapshot);
163174

164-
private void InitializeAnalysisState()
175+
private void InitializeAnalysisState(ITextSnapshot newTextSnapshot = null)
165176
{
166-
var analysisSnapshot = GetAnalysisSnapshot();
177+
var analysisSnapshot = GetAnalysisSnapshot(newTextSnapshot);
167178
CreateIssueConsumer(analysisSnapshot);
168179
}
169180

@@ -182,4 +193,11 @@ private static void ClearErrorList(string filePath, IIssueConsumer issueConsumer
182193
issueConsumer.SetIssues(filePath, []);
183194
issueConsumer.SetHotspots(filePath, []);
184195
}
196+
197+
private void TextBuffer_OnChangedOnBackground(object sender, TextContentChangedEventArgs e) =>
198+
taskExecutorWithDebounce.DebounceAsync(e.After, textSnapshot =>
199+
{
200+
RefreshAnalysisState(textSnapshot);
201+
Provider.OnDocumentUpdated(document.FilePath, textSnapshot.GetText(), DetectedLanguages);
202+
}).Forget();
185203
}

0 commit comments

Comments
 (0)