diff --git a/src/main/java/com/redhat/devtools/lsp4ij/LSPIJUtils.java b/src/main/java/com/redhat/devtools/lsp4ij/LSPIJUtils.java index f95e037b5..5be5882ba 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/LSPIJUtils.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/LSPIJUtils.java @@ -885,6 +885,11 @@ public static TextRange getWordRangeAt(@NotNull Document document, } } + if (offset < 0) { + // No word at a negative offset; also avoids charAt(-1) in getRightOffsetOfPart + // (offset can be -1 here for an empty document via the clamp above). + return null; + } int start = getLeftOffsetOfPart(document, offset); int end = getRightOffsetOfPart(document, offset); return (start < end) ? new TextRange(start, end) : null; diff --git a/src/main/java/com/redhat/devtools/lsp4ij/LanguageServiceAccessor.java b/src/main/java/com/redhat/devtools/lsp4ij/LanguageServiceAccessor.java index f93258f2b..50f86a66e 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/LanguageServiceAccessor.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/LanguageServiceAccessor.java @@ -40,6 +40,7 @@ import java.util.*; import java.util.concurrent.CancellationException; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.function.Consumer; @@ -59,7 +60,11 @@ public class LanguageServiceAccessor implements Disposable { private final Project project; private final SimpleModificationTracker modificationTracker = new SimpleModificationTracker(); - private final Set startedServers = new HashSet<>(); + // Thread-safe set: readers (hasAny, processLanguageServers, getStartedServers) iterate it without + // locking, so a plain HashSet would throw ConcurrentModificationException when a writer mutates it + // concurrently. newKeySet() gives weakly-consistent iteration; writers still synchronize on it to + // keep their check-then-add/remove operations atomic. + private final Set startedServers = ConcurrentHashMap.newKeySet(); private final LanguageServerDefinitionListener serverDefinitionListener = new LanguageServerDefinitionListener() { @Override diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticTokensStructurelessFileViewProvider.java b/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticTokensStructurelessFileViewProvider.java index d9e84e758..e81958125 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticTokensStructurelessFileViewProvider.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticTokensStructurelessFileViewProvider.java @@ -12,11 +12,14 @@ package com.redhat.devtools.lsp4ij.features.semanticTokens.viewProvider; import com.intellij.lang.Language; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.util.TextRange; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.psi.PsiElement; import com.intellij.psi.PsiFile; import com.intellij.psi.PsiManager; import com.intellij.psi.PsiReference; +import com.redhat.devtools.lsp4ij.LSPIJUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -57,45 +60,70 @@ public boolean supportsIncrementalReparse(@NotNull Language rootLanguage) { @Nullable private PsiElement getSemanticTokenElement(int offset) { LSPSemanticToken semanticToken = isEnabled() ? getSemanticToken(offset) : null; - // Skip the file-level fallback stub returned when semantic tokens haven't loaded yet + // Only a concrete (non file-level) token carries the reference/declaration info needed by + // hover and go-to-declaration. The file-level stub spans the whole file, so it's handled by + // the narrow-element fallback below instead. return (semanticToken != null && !semanticToken.isFileLevel()) ? semanticToken.getElement() : null; } /** - * Filters out whole-file elements returned by super.findElementAt() for structureless files. + * Returns the element to expose at {@code offset} when no concrete semantic token is available. *

- * TextMate uses an {@code EmptyLexer} and a trivial parser, so its PSI tree consists of a single - * leaf element spanning the entire file. Returning that element as the "element at offset" would - * cause callers (e.g., {@code UsagePreviewPanel.getNameElementTextRange}) to treat the whole file - * as a single named element and highlight everything. Return {@code null} instead so callers fall - * back to their own range computation (e.g., the usage element's own text range). + * TextMate/plain-text files are structureless: {@code super.findElementAt()} returns a single leaf + * element spanning the ENTIRE file (empty lexer + trivial parser), and the file-level stub token + * does the same. Neither can be exposed verbatim: + *

+ * So we synthesize a NON-null element with the word range at the offset (or {@code null} on + * whitespace/punctuation, where hover/navigation shouldn't trigger anyway). */ @Nullable - private PsiElement filterStructurelessSuperElement(@Nullable PsiElement superElement) { - if (superElement == null) return null; + private PsiElement narrowElementAt(int offset, @Nullable PsiElement superElement) { PsiFile psiFile = getPsi(getBaseLanguage()); - if (psiFile != null && superElement.getTextRange().equals(psiFile.getTextRange())) { + // A real (sub-file) super element is fine to return as-is. + if (superElement != null && (psiFile == null || !superElement.getTextRange().equals(psiFile.getTextRange()))) { + return superElement; + } + if (psiFile == null) { + return null; + } + Document document = LSPIJUtils.getDocument(psiFile.getVirtualFile()); + if (document == null) { + return null; + } + TextRange wordRange = LSPIJUtils.getWordRangeAt(document, psiFile, offset); + if (wordRange == null) { return null; } - return superElement; + // Build a synthetic (unknown-type) semantic-token element scoped to the word at the offset. + // Using a real LSPSemanticTokenPsiElement (rather than a whole-file stub or a bare + // LSPPsiElement) keeps it consistent with the platform symbol model and LSP4IJ's own + // documentation provider, so the hover popup is built while Find Usages highlights only the + // word — its range always contains the offset, so DeclarationOrReference#rangeWithOffset holds. + return new LSPSemanticToken(psiFile, wordRange, null, null).getElement(); } @Override public PsiElement findElementAt(int offset) { PsiElement element = getSemanticTokenElement(offset); - return element != null ? element : filterStructurelessSuperElement(super.findElementAt(offset)); + return element != null ? element : narrowElementAt(offset, super.findElementAt(offset)); } @Override public PsiElement findElementAt(int offset, @NotNull Class lang) { PsiElement element = getSemanticTokenElement(offset); - return element != null ? element : filterStructurelessSuperElement(super.findElementAt(offset, lang)); + return element != null ? element : narrowElementAt(offset, super.findElementAt(offset, lang)); } @Override public PsiElement findElementAt(int offset, @NotNull Language language) { PsiElement element = getSemanticTokenElement(offset); - return element != null ? element : filterStructurelessSuperElement(super.findElementAt(offset, language)); + return element != null ? element : narrowElementAt(offset, super.findElementAt(offset, language)); } @Override