1616import static org .eclipse .lsp4e .internal .NullSafetyHelper .lateNonNull ;
1717
1818import java .net .URI ;
19+ import java .time .Duration ;
20+ import java .util .ArrayList ;
1921import java .util .HashMap ;
2022import java .util .List ;
2123import java .util .Map .Entry ;
5153import org .eclipse .lsp4e .LSPEclipseUtils ;
5254import org .eclipse .lsp4e .LanguageServerPlugin ;
5355import org .eclipse .lsp4e .LanguageServers ;
56+ import org .eclipse .lsp4e .internal .DocumentOffsetAsyncCache ;
5457import org .eclipse .lsp4j .DocumentHighlight ;
5558import org .eclipse .lsp4j .DocumentHighlightKind ;
5659import org .eclipse .lsp4j .DocumentHighlightParams ;
@@ -77,6 +80,16 @@ public class HighlightReconcilingStrategy
7780 private @ Nullable IDocument document ;
7881 private @ Nullable Job highlightJob ;
7982
83+ // Debounce to avoid flooding requests while the user moves the caret/mouse.
84+ private static final int HIGHLIGHT_DEBOUNCE_MS = 75 ;
85+
86+ // Short-lived cache for highlights per document+normalized offset.
87+ private static final DocumentOffsetAsyncCache <List <DocumentHighlight >> HIGHLIGHT_CACHE =
88+ new DocumentOffsetAsyncCache <>(Duration .ofSeconds (10 ));
89+
90+ // Track the last normalized cache key to avoid canceling identical in-flight work.
91+ private int lastCacheKeyOffset = -1 ;
92+
8093 /**
8194 * Holds the current occurrence annotations.
8295 */
@@ -119,7 +132,8 @@ private void updateHighlights(ISelection selection) {
119132 }
120133 highlightJob = Job .createSystem ("LSP4E Highlight" , //$NON-NLS-1$
121134 (ICoreRunnable )(monitor -> collectHighlights (textSelection .getOffset (), monitor )));
122- highlightJob .schedule ();
135+ // Debounce scheduling slightly to coalesce rapid selection changes
136+ highlightJob .schedule (HIGHLIGHT_DEBOUNCE_MS );
123137 }
124138 }
125139
@@ -188,9 +202,20 @@ private void collectHighlights(int caretOffset, @Nullable IProgressMonitor monit
188202 if (sourceViewer == null || document == null || !enabled || monitor != null && monitor .isCanceled ()) {
189203 return ;
190204 }
191- cancel ();
205+
206+ // Normalize the cache key to the start of the word/symbol.
207+ final int cacheKeyOffset = normalizedOffset (document , caretOffset );
208+
209+ // Only cancel previous requests if the target symbol actually changed.
210+ if (cacheKeyOffset != lastCacheKeyOffset ) {
211+ cancel ();
212+ lastCacheKeyOffset = cacheKeyOffset ;
213+ }
214+
192215 Position position ;
193216 try {
217+ // Send the original caret offset to the LS to preserve behavior
218+ // expected by tests and servers that distinguish positions within a word.
194219 position = LSPEclipseUtils .toPosition (caretOffset , document );
195220 } catch (BadLocationException e ) {
196221 LanguageServerPlugin .logError (e );
@@ -202,14 +227,36 @@ private void collectHighlights(int caretOffset, @Nullable IProgressMonitor monit
202227 }
203228 final var identifier = LSPEclipseUtils .toTextDocumentIdentifier (uri );
204229 final var params = new DocumentHighlightParams (identifier , position );
205- requests = LanguageServers .forDocument (document )
206- .withCapability (ServerCapabilities ::getDocumentHighlightProvider )
207- .computeAll (languageServer -> languageServer .getTextDocumentService ().documentHighlight (params ));
208- requests .forEach (request -> request .thenAcceptAsync (highlights -> {
230+
231+ // Use cache to deduplicate requests to the same symbol for a short period.
232+ final CompletableFuture <List <DocumentHighlight >> request = HIGHLIGHT_CACHE .computeIfAbsent (document ,
233+ cacheKeyOffset , () -> {
234+ final var reqs = requests = LanguageServers .forDocument (document )
235+ .withCapability (ServerCapabilities ::getDocumentHighlightProvider )
236+ .computeAll (ls -> ls .getTextDocumentService ().documentHighlight (params ));
237+ final CompletableFuture <?> all = CompletableFuture
238+ .allOf (reqs .stream ().map (f -> (CompletableFuture <?>) f ).toArray (CompletableFuture []::new ));
239+ return all .thenApply (unused -> {
240+ final var allHighlights = new ArrayList <DocumentHighlight >();
241+ for (final var req : reqs ) {
242+ try {
243+ final List <? extends DocumentHighlight > highlight = req .join ();
244+ if (highlight != null ) {
245+ allHighlights .addAll (highlight );
246+ }
247+ } catch (Exception ex ) {
248+ // ignore single-server failures
249+ }
250+ }
251+ return allHighlights ;
252+ });
253+ });
254+
255+ request .thenAcceptAsync (highlights -> {
209256 if (monitor == null || !monitor .isCanceled ()) {
210257 updateAnnotations (highlights , sourceViewer .getAnnotationModel ());
211258 }
212- })) ;
259+ });
213260 }
214261
215262 /**
@@ -273,6 +320,27 @@ private Object getLockObject(IAnnotationModel annotationModel) {
273320 return annotationModel ;
274321 }
275322
323+ /**
324+ * Compute a stable cache key for the symbol at the given caret offset by
325+ * normalizing to the start of the Unicode identifier under the caret.
326+ */
327+ private static int normalizedOffset (final IDocument document , final int offset ) {
328+ try {
329+ // Walk left to the first non-identifier part, then move right one.
330+ int pos = Math .max (0 , offset - 1 );
331+ final int docLen = document .getLength ();
332+ while (pos >= 0 && pos < docLen ) {
333+ if (!Character .isUnicodeIdentifierPart (document .getChar (pos ))) {
334+ break ;
335+ }
336+ pos --;
337+ }
338+ return Math .min (docLen , pos + 1 );
339+ } catch (final BadLocationException ex ) {
340+ return offset ;
341+ }
342+ }
343+
276344 void removeOccurrenceAnnotations () {
277345 final var sourceViewer = this .sourceViewer ;
278346 if (sourceViewer == null )
0 commit comments