From 6baa061daa5fa08d738449d3ad9afeae08125a4f Mon Sep 17 00:00:00 2001 From: Aleksandr Eliseev Date: Wed, 2 Sep 2026 12:32:45 +0300 Subject: [PATCH] Add feature toggle for completion word range fix --- .../redhat/devtools/lsp4ij/LSPIJUtils.java | 33 +++- .../client/features/LSPCompletionFeature.java | 18 +++ .../features/LSPCompletionProposal.java | 10 +- .../lsp4ij/internal/CompletionUtils.java | 145 ++++++++++-------- .../lsp4ij/usages/LSPUsageTargetProvider.java | 2 +- 5 files changed, 132 insertions(+), 76 deletions(-) diff --git a/src/main/java/com/redhat/devtools/lsp4ij/LSPIJUtils.java b/src/main/java/com/redhat/devtools/lsp4ij/LSPIJUtils.java index c78bfc641..a2e706d77 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/LSPIJUtils.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/LSPIJUtils.java @@ -874,13 +874,38 @@ public static TextRange getWordRangeAt(@NotNull Document document, public static TextRange getWordRangeAt(@NotNull Document document, @Nullable PsiFile file, int offset) { + return getWordRangeAt(document, file, offset, false); + } + + /** + * Returns the word range from the document at given offset and null otherwise. + * + *
+     *  - fo|o bar -> [foo]
+     *  - fo|o.bar() -> [foo]
+     *  - foo.b|ar() -> [bar]
+     *  - foo.bar(|) -> null
+     *  - foo  |  bar -> null
+     * 
+ * + * @param document the document. + * @param file the PsiFile or null otherwise. + * @param offset the offset. + * @param excludeWhitespace if {@link PsiWhiteSpace whitespace} should not be returned + * @return the word range from the document at given offset and null otherwise. + */ + @Nullable + public static TextRange getWordRangeAt(@NotNull Document document, + @Nullable PsiFile file, + int offset, + boolean excludeWhitespace) { if (offset > document.getTextLength()) { offset = document.getTextLength() - 1; } if (file != null && !SimpleLanguageUtils.isSupported(file.getLanguage())) { // It is not TextMate, TEXT file (since those language doesn't tokenize the file) // Try to use the PsiElement text range found at the given offset - TextRange textRange = findBestTextRangeAt(file, offset); + TextRange textRange = findBestTextRangeAt(file, offset, excludeWhitespace); if (textRange != null) { return textRange; } @@ -896,11 +921,11 @@ public static TextRange getWordRangeAt(@NotNull Document document, return (start < end) ? new TextRange(start, end) : null; } - private static TextRange findBestTextRangeAt(@Nullable PsiFile file, int offset) { + private static TextRange findBestTextRangeAt(@Nullable PsiFile file, int offset, boolean excludeWhitespace) { PsiElement element = file != null ? file.findElementAt(Math.max(offset - 1, 0)) : null; if (element != null) { - // When element is whitespace, completion should never replace it - that's not prefix - if (element instanceof PsiWhiteSpace) { + // When element is whitespace - exclude it if we need + if (excludeWhitespace && element instanceof PsiWhiteSpace) { return null; } TextRange textRange = element.getTextRange(); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPCompletionFeature.java b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPCompletionFeature.java index 8a8745f67..14d719201 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPCompletionFeature.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPCompletionFeature.java @@ -335,4 +335,22 @@ public boolean isMultipleCaretsSupported(@NotNull PsiFile file) { // Default to enabled return true; } + + /** + * Whether to exclude {@link com.intellij.psi.PsiWhiteSpace whitespace token} from completion prefix. + * + *

+ * When disabled (the default), invoking a completion inside whitespace token + * will make this token a completion prefix, which means it will be replaced by {@code insertText} + * when user accept completion. + * When disabled, whitespace token will not be prefix, so when user accept completion, + * it will be just inserted at the caret, leaving whitespace as-is. + *

+ * @param file the file + * @return {@code true} if {@link com.intellij.psi.PsiWhiteSpace whitespace token} should not be completion prefix + */ + public boolean excludeWhitespaceFromCompletionPrefix(@NotNull PsiFile file) { + // Disabled by default to leave original behavior + return false; + } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPCompletionProposal.java b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPCompletionProposal.java index 232af119e..f12212aae 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPCompletionProposal.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPCompletionProposal.java @@ -102,7 +102,11 @@ public LSPCompletionProposal(@NotNull CompletionItem item, this.editor = completionContext.getParameters().getEditor(); this.completionContext = completionContext; this.completionOffset = completionContext.getParameters().getOffset(); - this.prefixStartOffset = getPrefixStartOffset(editor.getDocument(), completionOffset); + this.prefixStartOffset = getPrefixStartOffset( + editor.getDocument(), + completionOffset, + completionFeature.excludeWhitespaceFromCompletionPrefix(completionContext.getParameters().getOriginalFile()) + ); this.completionFeature = completionFeature; putUserData(CodeCompletionHandlerBase.DIRECT_INSERTION, true); } @@ -663,7 +667,7 @@ private boolean isSelectedCompletionItem() { // --------------- Prefix start offset - private int getPrefixStartOffset(@NotNull Document document, int completionOffset) { + private int getPrefixStartOffset(@NotNull Document document, int completionOffset, boolean excludeWhitespace) { Either textEdit = this.item.getTextEdit(); if (textEdit != null) { // case 1: text edit is defined, @@ -673,7 +677,7 @@ private int getPrefixStartOffset(@NotNull Document document, int completionOffse // case 2: text edit is undefined, try to compute the prefix start offset by using insertText String insertText = getInsertText(); - Integer prefixStartOffset = computePrefixStartFromInsertText(document, file, completionOffset, insertText); + Integer prefixStartOffset = computePrefixStartFromInsertText(document, file, completionOffset, insertText, excludeWhitespace); return Objects.requireNonNullElse(prefixStartOffset, completionOffset); } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/internal/CompletionUtils.java b/src/main/java/com/redhat/devtools/lsp4ij/internal/CompletionUtils.java index 21aca9705..8e45ccc0e 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/internal/CompletionUtils.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/internal/CompletionUtils.java @@ -1,68 +1,77 @@ -/******************************************************************************* - * Copyright (c) 2025 Red Hat, Inc. - * Distributed under license by Red Hat, Inc. All rights reserved. - * This program is made available under the terms of the - * Eclipse Public License v2.0 which accompanies this distribution, - * and is available at https://www.eclipse.org/legal/epl-v20.html - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - *****************************************************************************/ -package com.redhat.devtools.lsp4ij.internal; - -import com.intellij.openapi.editor.Document; -import com.intellij.openapi.util.TextRange; -import com.intellij.psi.PsiFile; -import com.redhat.devtools.lsp4ij.LSPIJUtils; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -/** - * LSP/DAP Completion utilities. - */ -public class CompletionUtils { - - @Nullable - public static Integer computePrefixStartFromInsertText(@NotNull Document document, - @Nullable PsiFile file, - int completionOffset, - String insertText) { - - // case 2.1: first strategy, we collect word range at - // ex : - // insertText= '(let [${1:binding} ${2:value}])' - // document= le - // we have to return |le as prefix start offset - - TextRange wordRange = LSPIJUtils.getWordRangeAt(document, file, completionOffset); - if (wordRange != null) { - return wordRange.getStartOffset(); - } - - // case 2.2: second strategy, we check if the left content of the completion offset - // matches the full insertText left content - // ex : - // insertText= 'foo.bar' - // document= {foo.b|} - // we have to return {| as prefix start offset - - return getPrefixStartOffsetWhichMatchesLeftContent(document, completionOffset, insertText); - } - - @Nullable - private static Integer getPrefixStartOffsetWhichMatchesLeftContent(@NotNull Document document, - int completionOffset, - @NotNull String insertText) { - int startOffset = Math.max(0, completionOffset - insertText.length()); - int endOffset = startOffset + Math.min(insertText.length(), completionOffset); - String subDoc = document.getText(new TextRange(startOffset, endOffset)); // "".ch - for (int i = 0; i < insertText.length() && i < completionOffset; i++) { - String tentativeCommonString = subDoc.substring(i); - if (insertText.startsWith(tentativeCommonString)) { - return completionOffset - tentativeCommonString.length(); - } - } - return null; - } - -} +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + *****************************************************************************/ +package com.redhat.devtools.lsp4ij.internal; + +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.util.TextRange; +import com.intellij.psi.PsiFile; +import com.redhat.devtools.lsp4ij.LSPIJUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * LSP/DAP Completion utilities. + */ +public class CompletionUtils { + + @Nullable + public static Integer computePrefixStartFromInsertText(@NotNull Document document, + @Nullable PsiFile file, + int completionOffset, + String insertText) { + return computePrefixStartFromInsertText(document, file, completionOffset, insertText, false); + } + + @Nullable + public static Integer computePrefixStartFromInsertText(@NotNull Document document, + @Nullable PsiFile file, + int completionOffset, + String insertText, + boolean excludeWhitespace) { + + // case 2.1: first strategy, we collect word range at + // ex : + // insertText= '(let [${1:binding} ${2:value}])' + // document= le + // we have to return |le as prefix start offset + + TextRange wordRange = LSPIJUtils.getWordRangeAt(document, file, completionOffset, excludeWhitespace); + if (wordRange != null) { + return wordRange.getStartOffset(); + } + + // case 2.2: second strategy, we check if the left content of the completion offset + // matches the full insertText left content + // ex : + // insertText= 'foo.bar' + // document= {foo.b|} + // we have to return {| as prefix start offset + + return getPrefixStartOffsetWhichMatchesLeftContent(document, completionOffset, insertText); + } + + @Nullable + private static Integer getPrefixStartOffsetWhichMatchesLeftContent(@NotNull Document document, + int completionOffset, + @NotNull String insertText) { + int startOffset = Math.max(0, completionOffset - insertText.length()); + int endOffset = startOffset + Math.min(insertText.length(), completionOffset); + String subDoc = document.getText(new TextRange(startOffset, endOffset)); // "".ch + for (int i = 0; i < insertText.length() && i < completionOffset; i++) { + String tentativeCommonString = subDoc.substring(i); + if (insertText.startsWith(tentativeCommonString)) { + return completionOffset - tentativeCommonString.length(); + } + } + return null; + } + +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/usages/LSPUsageTargetProvider.java b/src/main/java/com/redhat/devtools/lsp4ij/usages/LSPUsageTargetProvider.java index 97530cc84..030a5b456 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/usages/LSPUsageTargetProvider.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/usages/LSPUsageTargetProvider.java @@ -143,7 +143,7 @@ public class LSPUsageTargetProvider implements UsageTargetProvider { */ @NotNull private static UsageTarget[] getLSPTargets(@NotNull Editor editor, @NotNull PsiFile file) { - TextRange targetTextRange = LSPIJUtils.getWordRangeAt(editor.getDocument(), file, editor.getCaretModel().getOffset()); + TextRange targetTextRange = LSPIJUtils.getWordRangeAt(editor.getDocument(), file, editor.getCaretModel().getOffset(), false); if (targetTextRange == null) { return UsageTarget.EMPTY_ARRAY; }