Skip to content

Commit 5a9c620

Browse files
SLVS-2430 Trigger analysis on pressed key with debounce (#6402)
1 parent e1714a0 commit 5a9c620

File tree

7 files changed

+365
-47
lines changed

7 files changed

+365
-47
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(debounceMilliseconds: TimeSpan.FromMilliseconds(500));
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
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* SonarLint for Visual Studio
3+
* Copyright (C) 2016-2025 SonarSource SA
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
21+
using SonarLint.VisualStudio.Core.Synchronization;
22+
using SonarLint.VisualStudio.Integration.Vsix.SonarLintTagger;
23+
24+
namespace SonarLint.VisualStudio.Integration.UnitTests.SonarLintTagger;
25+
26+
[TestClass]
27+
public class TaskExecutorWithDebounceFactoryTest
28+
{
29+
private TaskExecutorWithDebounceFactory testSubject;
30+
private IAsyncLockFactory asyncLockFactory;
31+
32+
[TestInitialize]
33+
public void TestInitialize()
34+
{
35+
asyncLockFactory = Substitute.For<IAsyncLockFactory>();
36+
testSubject = new TaskExecutorWithDebounceFactory(asyncLockFactory);
37+
}
38+
39+
[TestMethod]
40+
public void MefCtor_CheckIsExported() =>
41+
MefTestHelpers.CheckTypeCanBeImported<TaskExecutorWithDebounceFactory, ITaskExecutorWithDebounceFactory>(
42+
MefTestHelpers.CreateExport<IAsyncLockFactory>());
43+
44+
[TestMethod]
45+
public void MefCtor_CheckIsSingleton() => MefTestHelpers.CheckIsSingletonMefComponent<TaskExecutorWithDebounceFactory>();
46+
47+
[TestMethod]
48+
public void Create_ShouldReturnInstance()
49+
{
50+
var result = testSubject.Create(TimeSpan.FromMilliseconds(1));
51+
52+
result.Should().NotBeNull();
53+
result.Should().BeOfType<TaskExecutorWithDebounce>();
54+
}
55+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
* SonarLint for Visual Studio
3+
* Copyright (C) 2016-2025 SonarSource SA
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
21+
using Microsoft.VisualStudio.Threading;
22+
using SonarLint.VisualStudio.Core.Synchronization;
23+
using SonarLint.VisualStudio.Integration.TestInfrastructure;
24+
using SonarLint.VisualStudio.Integration.Vsix.SonarLintTagger;
25+
26+
namespace SonarLint.VisualStudio.Integration.UnitTests.SonarLintTagger;
27+
28+
[TestClass]
29+
public class TaskExecutorWithDebounceTest
30+
{
31+
private readonly TimeSpan debounceTimeInMs = TimeSpan.FromMilliseconds(100);
32+
private IAsyncLock asyncLock;
33+
private IAsyncLockFactory asyncLockFactory;
34+
private TaskExecutorWithDebounce testSubject;
35+
36+
[TestInitialize]
37+
public void TestInitialize()
38+
{
39+
asyncLockFactory = Substitute.For<IAsyncLockFactory>();
40+
asyncLock = Substitute.For<IAsyncLock>();
41+
asyncLockFactory.Create().Returns(asyncLock);
42+
testSubject = new TaskExecutorWithDebounce(asyncLockFactory, debounceTimeInMs);
43+
}
44+
45+
[TestMethod]
46+
public async Task DebounceAsync_ExecutesTaskWithDebounce()
47+
{
48+
var currentState = new TestData { Value = 1 };
49+
var tcs = new TaskCompletionSource<int>();
50+
var stopwatch = Stopwatch.StartNew();
51+
52+
testSubject.DebounceAsync(() =>
53+
{
54+
UpdateState(currentState, 2, tcs);
55+
stopwatch.Stop();
56+
}).Forget();
57+
await tcs.Task;
58+
59+
asyncLock.Received(1).AcquireAsync().IgnoreAwaitForAssert();
60+
currentState.Value.Should().Be(2);
61+
stopwatch.ElapsedMilliseconds.Should().BeGreaterOrEqualTo(debounceTimeInMs.Milliseconds);
62+
}
63+
64+
[TestMethod]
65+
public async Task DebounceAsync_MultipleTimes_UpdatesWithLatestState()
66+
{
67+
var currentState = new TestData { Value = 1 };
68+
var tcs = new TaskCompletionSource<int>();
69+
70+
testSubject.DebounceAsync(() => UpdateState(currentState, 2)).Forget();
71+
testSubject.DebounceAsync(() => UpdateState(currentState, 3)).Forget();
72+
testSubject.DebounceAsync(() => UpdateState(currentState, 4, tcs)).Forget();
73+
await tcs.Task;
74+
75+
asyncLock.Received(3).AcquireAsync().IgnoreAwaitForAssert();
76+
currentState.Value.Should().Be(4);
77+
}
78+
79+
private static void UpdateState(TestData date, int newValue, TaskCompletionSource<int> taskCompletionSource = null)
80+
{
81+
date.Value = newValue;
82+
taskCompletionSource?.SetResult(1);
83+
}
84+
85+
private record TestData
86+
{
87+
public int Value { get; set; }
88+
}
89+
}

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

Lines changed: 66 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 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>();
78+
taskExecutorWithDebounceFactory.Create(Arg.Any<TimeSpan>()).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.
@@ -150,6 +156,7 @@ public void Dispose_CleansUpEventsAndRegistrations()
150156
taggerProvider.ActiveTrackersForTesting.Should().BeEmpty();
151157

152158
mockedJavascriptDocumentFooJs.Received(1).FileActionOccurred -= Arg.Any<EventHandler<TextDocumentFileActionEventArgs>>();
159+
((ITextBuffer2)mockDocumentTextBuffer).Received(1).ChangedOnBackground -= Arg.Any<EventHandler<TextContentChangedEventArgs>>();
153160
}
154161

155162
[TestMethod]
@@ -196,7 +203,6 @@ public void WhenFileIsLoaded_EventsAreNotRaised()
196203
var renamedEventHandler = Substitute.For<EventHandler<DocumentRenamedEventArgs>>();
197204
var savedEventHandler = Substitute.For<EventHandler<DocumentEventArgs>>();
198205
taggerProvider.OpenDocumentRenamed += renamedEventHandler;
199-
taggerProvider.DocumentSaved += savedEventHandler;
200206

201207
RaiseFileLoadedEvent(mockedJavascriptDocumentFooJs);
202208

@@ -326,6 +332,21 @@ public void UpdateAnalysisState_CriticalException_IsNotSuppressed()
326332
.WithMessage("this is a test");
327333
}
328334

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

331352
private static void VerifySingletonManagerDoesNotExist(ITextBuffer buffer) => FindSingletonManagerInPropertyCollection(buffer).Should().BeNull();
@@ -372,21 +393,21 @@ private TaggerProvider CreateTaggerProvider()
372393
var analysisRequester = mockAnalysisRequester;
373394
var provider = new TaggerProvider(sonarErrorListDataSource, textDocumentFactoryService,
374395
serviceProvider, languageRecognizer, analysisRequester, vsProjectInfoProvider, issueConsumerFactory, issueConsumerStorage, Mock.Of<ITaggableBufferIndicator>(),
375-
mockFileTracker, analyzer, logger, new InitializationProcessorFactory(Substitute.For<IAsyncLockFactory>(), new NoOpThreadHandler(), new TestLogger()));
396+
mockFileTracker, analyzer, logger, new InitializationProcessorFactory(Substitute.For<IAsyncLockFactory>(), new NoOpThreadHandler(), new TestLogger()), taskExecutorWithDebounceFactory);
376397
return provider;
377398
}
378399

379-
private static ITextSnapshot CreateTextSnapshotMock()
400+
private static ITextSnapshot CreateTextSnapshotMock(string content = TextContent)
380401
{
381402
var textSnapshot = Substitute.For<ITextSnapshot>();
382-
textSnapshot.GetText().Returns(TextContent);
403+
textSnapshot.GetText().Returns(content);
383404
return textSnapshot;
384405
}
385406

386407
private static ITextBuffer CreateTextBufferMock(ITextSnapshot textSnapshot)
387408
{
388409
// Text buffer with a properties collection and current snapshot
389-
var mockTextBuffer = Substitute.For<ITextBuffer>();
410+
var mockTextBuffer = Substitute.For<ITextBuffer2>();
390411

391412
var dummyProperties = new PropertyCollection();
392413
mockTextBuffer.Properties.Returns(dummyProperties);
@@ -446,12 +467,51 @@ private TextBufferIssueTracker CreateTestSubject(ITextDocument textDocument)
446467
private TextBufferIssueTracker CreateTestSubject(ITextDocument textDocument, ILogger logger) =>
447468
new(taggerProvider,
448469
textDocument, javascriptLanguage,
449-
mockSonarErrorDataSource, vsProjectInfoProvider, issueConsumerFactory, issueConsumerStorage, logger);
470+
mockSonarErrorDataSource, vsProjectInfoProvider, issueConsumerFactory, issueConsumerStorage, taskExecutorWithDebounce, logger);
450471

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

0 commit comments

Comments
 (0)