diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/foldingRange/LSPFoldingRangeBuilder.java b/src/main/java/com/redhat/devtools/lsp4ij/features/foldingRange/LSPFoldingRangeBuilder.java index 08d007825..c689e77e2 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/foldingRange/LSPFoldingRangeBuilder.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/foldingRange/LSPFoldingRangeBuilder.java @@ -13,6 +13,7 @@ import com.intellij.lang.ASTNode; import com.intellij.lang.folding.CustomFoldingBuilder; import com.intellij.lang.folding.FoldingDescriptor; +import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.editor.Document; import com.intellij.openapi.progress.ProcessCanceledException; import com.intellij.openapi.util.TextRange; @@ -62,8 +63,8 @@ public class LSPFoldingRangeBuilder extends CustomFoldingBuilder { protected void buildLanguageFoldRegions(@NotNull List descriptors, @NotNull PsiElement root, @NotNull Document document, boolean quick) { - // if quick flag is set, we do nothing here - if (quick) { + // if quick flag is set and not testing, we do nothing here + if (quick && !ApplicationManager.getApplication().isUnitTestMode()) { return; } @@ -219,6 +220,7 @@ protected boolean isRegionCollapsedByDefault(@NotNull ASTNode node) { @Override public boolean isDumbAware() { - return false; + // Allow folding in dumb mode only during unit testing + return ApplicationManager.getApplication().isUnitTestMode(); } } diff --git a/src/test/java/com/redhat/devtools/lsp4ij/features/foldingRange/TypeScriptCodeBlockProviderTest.java b/src/test/java/com/redhat/devtools/lsp4ij/features/foldingRange/TypeScriptCodeBlockProviderTest.java new file mode 100644 index 000000000..f6b6d4b4d --- /dev/null +++ b/src/test/java/com/redhat/devtools/lsp4ij/features/foldingRange/TypeScriptCodeBlockProviderTest.java @@ -0,0 +1,124 @@ +/******************************************************************************* + * Copyright (c) 2024 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 http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ + +package com.redhat.devtools.lsp4ij.features.foldingRange; + +import com.redhat.devtools.lsp4ij.fixtures.LSPCodeBlockProviderFixtureTestCase; + +/** + * Selection range tests by emulating LSP 'textDocument/foldingRange' responses from the typescript-language-server. + */ +public class TypeScriptCodeBlockProviderTest extends LSPCodeBlockProviderFixtureTestCase { + + public TypeScriptCodeBlockProviderTest() { + super("*.ts"); + } + + public void testCodeBlocks() { + // language=json + String mockFoldingRangesJson = """ + [ + { + "startLine": 0, + "endLine": 3 + }, + { + "startLine": 1, + "endLine": 2 + } + ] + """; + + assertCodeBlock( + "demo.ts", + """ + export class Demo { + demo() { + console.log('demo'); + } + } + """, + mockFoldingRangesJson + ); + + assertCodeBlock( + "demo.ts", + """ + export class Demo { + demo() { + console.log('demo'); + } + } + """, + mockFoldingRangesJson + ); + + assertCodeBlock( + "demo.ts", + """ + export class Demo { + demo() { + console.log('demo'); + } + } + """, + mockFoldingRangesJson + ); + + assertCodeBlock( + "demo.ts", + """ + export class Demo { + demo() { + console.log('demo'); + } + } + """, + mockFoldingRangesJson + ); + + assertCodeBlock( + "demo.ts", + """ + export class Demo { + demo() { + console.log('demo'); + } + } + """, + mockFoldingRangesJson + ); + + assertCodeBlock( + "demo.ts", + """ + export class Demo { + demo() { + console.log('demo'); + } + } + """, + mockFoldingRangesJson + ); + + assertCodeBlock( + "demo.ts", + """ + export class Demo { + demo() { + console.log('demo'); + } + } + """, + mockFoldingRangesJson + ); + } +} diff --git a/src/test/java/com/redhat/devtools/lsp4ij/features/foldingRange/TypeScriptFoldingRangeTest.java b/src/test/java/com/redhat/devtools/lsp4ij/features/foldingRange/TypeScriptFoldingRangeTest.java new file mode 100644 index 000000000..f942ba0af --- /dev/null +++ b/src/test/java/com/redhat/devtools/lsp4ij/features/foldingRange/TypeScriptFoldingRangeTest.java @@ -0,0 +1,78 @@ +/******************************************************************************* + * Copyright (c) 2024 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 http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ + +package com.redhat.devtools.lsp4ij.features.foldingRange; + +import com.redhat.devtools.lsp4ij.fixtures.LSPFoldingRangeFixtureTestCase; + +/** + * Selection range tests by emulating LSP 'textDocument/foldingRange' responses from the typescript-language-server. + */ +public class TypeScriptFoldingRangeTest extends LSPFoldingRangeFixtureTestCase { + + public TypeScriptFoldingRangeTest() { + super("*.ts"); + } + + public void testFoldingRanges() { + assertFoldingRanges( + "demo.ts", + """ + export class Demo { + demo() { + console.log('demo'); + } + } + """, + // language=json + """ + [ + { + "startLine": 0, + "endLine": 3 + }, + { + "startLine": 1, + "endLine": 2 + } + ] + """ + ); + } + + public void testFoldingRanges_collapsedText() { + assertFoldingRanges( + "demo.ts", + """ + export class Demo { + demo() { + console.log('demo'); + } + } + """, + // language=json + """ + [ + { + "startLine": 0, + "endLine": 3, + "collapsedText": "classBody" + }, + { + "startLine": 1, + "endLine": 2, + "collapsedText": "methodBody" + } + ] + """ + ); + } +} diff --git a/src/test/java/com/redhat/devtools/lsp4ij/fixtures/LSPCodeBlockProviderFixtureTestCase.java b/src/test/java/com/redhat/devtools/lsp4ij/fixtures/LSPCodeBlockProviderFixtureTestCase.java new file mode 100644 index 000000000..a67ddf369 --- /dev/null +++ b/src/test/java/com/redhat/devtools/lsp4ij/fixtures/LSPCodeBlockProviderFixtureTestCase.java @@ -0,0 +1,114 @@ +/******************************************************************************* + * Copyright (c) 2024 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 http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.fixtures; + +import com.google.gson.reflect.TypeToken; +import com.intellij.codeInsight.editorActions.CodeBlockUtil; +import com.intellij.openapi.editor.CaretModel; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Trinity; +import com.intellij.psi.PsiFile; +import com.intellij.testFramework.EditorTestUtil; +import com.redhat.devtools.lsp4ij.JSONUtils; +import com.redhat.devtools.lsp4ij.LanguageServiceAccessor; +import com.redhat.devtools.lsp4ij.mock.MockLanguageServer; +import org.eclipse.lsp4j.FoldingRange; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Base class test case to test the 'codeBlockProvider' based on LSP 'textDocument/codeBlock' feature. + */ +public abstract class LSPCodeBlockProviderFixtureTestCase extends LSPCodeInsightFixtureTestCase { + + private static final String CARET_TOKEN = ""; + private static final String START_TOKEN = ""; + private static final String END_TOKEN = ""; + + protected LSPCodeBlockProviderFixtureTestCase(String... fileNamePatterns) { + super(fileNamePatterns); + } + + protected void assertCodeBlock(@NotNull String fileName, + @NotNull String fileBody, + @NotNull String mockFoldingRangesJson) { + MockLanguageServer.INSTANCE.setTimeToProceedQueries(100); + List mockFoldingRanges = JSONUtils.getLsp4jGson().fromJson(mockFoldingRangesJson, new TypeToken>() { + }.getType()); + MockLanguageServer.INSTANCE.setFoldingRanges(mockFoldingRanges); + + Project project = myFixture.getProject(); + PsiFile file = myFixture.configureByText(fileName, stripTokens(fileBody)); + Editor editor = myFixture.getEditor(); + + // Initialize the language server + try { + LanguageServiceAccessor.getInstance(project) + .getLanguageServers(file.getVirtualFile(), null, null) + .get(5000, TimeUnit.MILLISECONDS); + } catch (Exception e) { + fail(e.getMessage()); + } + + EditorTestUtil.buildInitialFoldingsInBackground(editor); + + CaretModel caretModel = editor.getCaretModel(); + + // Derive the caret, start, and end offsets from tokens in the file body + Trinity offsets = getOffsets(fileBody); + int caretOffset = offsets.getFirst(); + int startOffset = offsets.getSecond(); + int endOffset = offsets.getThird(); + + caretModel.moveToOffset(caretOffset); + CodeBlockUtil.moveCaretToCodeBlockStart(project, editor, false); + assertEquals(startOffset, caretModel.getOffset()); + + caretModel.moveToOffset(caretOffset); + CodeBlockUtil.moveCaretToCodeBlockEnd(project, editor, false); + assertEquals(endOffset, caretModel.getOffset()); + } + + @NotNull + private static String stripTokens(@NotNull String fileBody) { + return fileBody + .replace(CARET_TOKEN, "") + .replace(START_TOKEN, "") + .replace(END_TOKEN, ""); + } + + @NotNull + private static Trinity<@NotNull Integer, @NotNull Integer, @NotNull Integer> getOffsets(@NotNull String fileBody) { + // Gather the raw token offsets + int rawCaretOffset = fileBody.indexOf(CARET_TOKEN); + assertFalse("No " + CARET_TOKEN + " found.", rawCaretOffset == -1); + int rawStartOffset = fileBody.indexOf(START_TOKEN); + assertFalse("No " + START_TOKEN + " found.", rawStartOffset == -1); + int rawEndOffset = fileBody.indexOf(END_TOKEN); + assertFalse("No " + END_TOKEN + " found.", rawEndOffset == -1); + + // Adjust final offsets as appropriate based on relative token positioning + int caretOffset = rawCaretOffset; + if (rawCaretOffset > rawStartOffset) caretOffset -= START_TOKEN.length(); + if (rawCaretOffset > rawEndOffset) caretOffset -= END_TOKEN.length(); + int startOffset = rawStartOffset; + if (rawStartOffset > rawCaretOffset) startOffset -= CARET_TOKEN.length(); + if (rawStartOffset > rawEndOffset) startOffset -= END_TOKEN.length(); + int endOffset = rawEndOffset; + if (rawEndOffset > rawCaretOffset) endOffset -= CARET_TOKEN.length(); + if (rawEndOffset > rawStartOffset) endOffset -= START_TOKEN.length(); + + return Trinity.create(caretOffset, startOffset, endOffset); + } +} diff --git a/src/test/java/com/redhat/devtools/lsp4ij/fixtures/LSPFoldingRangeFixtureTestCase.java b/src/test/java/com/redhat/devtools/lsp4ij/fixtures/LSPFoldingRangeFixtureTestCase.java new file mode 100644 index 000000000..bca39c071 --- /dev/null +++ b/src/test/java/com/redhat/devtools/lsp4ij/fixtures/LSPFoldingRangeFixtureTestCase.java @@ -0,0 +1,172 @@ +/******************************************************************************* + * Copyright (c) 2024 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 http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.fixtures; + +import com.google.gson.reflect.TypeToken; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.editor.FoldRegion; +import com.intellij.openapi.editor.FoldingModel; +import com.intellij.openapi.util.TextRange; +import com.intellij.openapi.util.text.StringUtil; +import com.intellij.psi.PsiFile; +import com.intellij.testFramework.EditorTestUtil; +import com.redhat.devtools.lsp4ij.JSONUtils; +import com.redhat.devtools.lsp4ij.LanguageServiceAccessor; +import com.redhat.devtools.lsp4ij.mock.MockLanguageServer; +import org.eclipse.lsp4j.FoldingRange; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Base class test case to test LSP 'textDocument/foldingRange' feature. + */ +public abstract class LSPFoldingRangeFixtureTestCase extends LSPCodeInsightFixtureTestCase { + + private static final String START_TOKEN_TEXT = "start"; + private static final String END_TOKEN_TEXT = "end"; + // For simplicity's sake, we only support up to 10 start/end token pairs + private static final Pattern TOKEN_PATTERN = Pattern.compile("(?ms)<(" + START_TOKEN_TEXT + "|" + END_TOKEN_TEXT + ")(\\d)>"); + private static final int START_TOKEN_LENGTH = START_TOKEN_TEXT.length() + 3; + private static final int END_TOKEN_LENGTH = END_TOKEN_TEXT.length() + 3; + + protected LSPFoldingRangeFixtureTestCase(String... fileNamePatterns) { + super(fileNamePatterns); + } + + protected void assertFoldingRanges(@NotNull String fileName, + @NotNull String fileBody, + @NotNull String mockFoldingRangesJson) { + MockLanguageServer.INSTANCE.setTimeToProceedQueries(100); + List mockFoldingRanges = JSONUtils.getLsp4jGson().fromJson(mockFoldingRangesJson, new TypeToken>() { + }.getType()); + MockLanguageServer.INSTANCE.setFoldingRanges(mockFoldingRanges); + + PsiFile file = myFixture.configureByText(fileName, stripTokens(fileBody)); + Editor editor = myFixture.getEditor(); + + // Initialize the language server + try { + LanguageServiceAccessor.getInstance(file.getProject()) + .getLanguageServers(file.getVirtualFile(), null, null) + .get(5000, TimeUnit.MILLISECONDS); + } catch (Exception e) { + fail(e.getMessage()); + } + + EditorTestUtil.buildInitialFoldingsInBackground(editor); + FoldingModel foldingModel = editor.getFoldingModel(); + FoldRegion[] foldRegions = foldingModel.getAllFoldRegions(); + + // Derive the expected text ranges from the tokenized file body + List expectedTextRanges = getExpectedTextRanges(fileBody); + assertEquals(expectedTextRanges.size(), foldRegions.length); + + for (int i = 0; i < foldRegions.length; i++) { + FoldRegion actualFoldRegion = foldRegions[i]; + + // Check the text range + TextRange expectedTextRange = expectedTextRanges.get(i); + TextRange actualTextRange = actualFoldRegion.getTextRange(); + assertEquals(expectedTextRange, actualTextRange); + + // Check the placeholder text + FoldingRange mockFoldingRange = mockFoldingRanges.get(i); + String mockCollapsedText = mockFoldingRange.getCollapsedText(); + String expectedPlaceholderText = StringUtil.isNotEmpty(mockCollapsedText) ? mockCollapsedText : "..."; + String actualPlaceholderText = actualFoldRegion.getPlaceholderText(); + assertEquals(expectedPlaceholderText, actualPlaceholderText); + } + } + + @NotNull + private static String stripTokens(@NotNull String fileBody) { + return fileBody.replaceAll(TOKEN_PATTERN.pattern(), ""); + } + + @NotNull + private static List getExpectedTextRanges(@NotNull String fileBody) { + // Gather raw start and end token offsets + Map rawStartOffsetsByIndex = new LinkedHashMap<>(); + Map rawEndOffsetsByIndex = new LinkedHashMap<>(); + Matcher tokenMatcher = TOKEN_PATTERN.matcher(fileBody); + while (tokenMatcher.find()) { + String tokenText = tokenMatcher.group(1); + int tokenIndex = Integer.parseInt(tokenMatcher.group(2)); + int rawStartOffset = tokenMatcher.start(); + if (tokenText.contains(START_TOKEN_TEXT)) { + if (rawStartOffsetsByIndex.containsKey(tokenIndex)) { + fail("Multiple start tokens were found for index " + tokenIndex + "."); + } + rawStartOffsetsByIndex.put(tokenIndex, rawStartOffset); + } else { + if (rawEndOffsetsByIndex.containsKey(tokenIndex)) { + fail("Multiple end tokens were found for index " + tokenIndex + "."); + } + rawEndOffsetsByIndex.put(tokenIndex, rawStartOffset); + } + } + assertFalse("No start tokens found.", rawStartOffsetsByIndex.isEmpty()); + assertFalse("No end tokens found.", rawEndOffsetsByIndex.isEmpty()); + assertEquals("Start and end tokens do not match in length.", rawStartOffsetsByIndex.size(), rawEndOffsetsByIndex.size()); + assertEquals("Start and end tokens do not have paired indexes.", rawStartOffsetsByIndex.keySet(), rawEndOffsetsByIndex.keySet()); + + // Align the raw start and end offset collections + List rawStartOffsets = new ArrayList<>(rawStartOffsetsByIndex.values()); + List rawEndOffsets = new ArrayList<>(rawStartOffsetsByIndex.size()); + for (Integer rawStartOffsetIndex : rawStartOffsetsByIndex.keySet()) { + Integer rawEndOffset = rawEndOffsetsByIndex.get(rawStartOffsetIndex); + assertNotNull("Failed to find the end offset with index " + rawStartOffsetIndex + ".", rawEndOffset); + rawEndOffsets.add(rawEndOffset); + } + + // Adjust final offsets as appropriate based on relative token positioning + List startOffsets = new ArrayList<>(rawStartOffsets.size()); + for (int i = 0; i < rawStartOffsets.size(); i++) { + int currentRawStartOffset = rawStartOffsets.get(i); + int startOffset = currentRawStartOffset; + for (Integer rawStartOffset : rawStartOffsets) { + if (currentRawStartOffset > rawStartOffset) startOffset -= START_TOKEN_LENGTH; + } + for (int rawEndOffset : rawEndOffsets) { + if (currentRawStartOffset > rawEndOffset) startOffset -= END_TOKEN_LENGTH; + } + startOffsets.add(startOffset); + } + List endOffsets = new ArrayList<>(rawEndOffsets.size()); + for (int i = 0; i < rawEndOffsets.size(); i++) { + int currentRawEndOffset = rawEndOffsets.get(i); + int endOffset = currentRawEndOffset; + for (int rawStartOffset : rawStartOffsets) { + if (currentRawEndOffset > rawStartOffset) endOffset -= START_TOKEN_LENGTH; + } + for (Integer rawEndOffset : rawEndOffsets) { + if (currentRawEndOffset > rawEndOffset) endOffset -= END_TOKEN_LENGTH; + } + endOffsets.add(endOffset); + } + + // Create text ranges from the start and end offset pairs + List expectedTextRanges = new ArrayList<>(startOffsets.size()); + for (int i = 0; i < startOffsets.size(); i++) { + int startOffset = startOffsets.get(i); + int endOffset = endOffsets.get(i); + expectedTextRanges.add(TextRange.create(startOffset, endOffset)); + } + return expectedTextRanges; + } +} diff --git a/src/test/java/com/redhat/devtools/lsp4ij/fixtures/LSPSelectionRangeFixtureTestCase.java b/src/test/java/com/redhat/devtools/lsp4ij/fixtures/LSPSelectionRangeFixtureTestCase.java index 88afbbd85..eeff03144 100644 --- a/src/test/java/com/redhat/devtools/lsp4ij/fixtures/LSPSelectionRangeFixtureTestCase.java +++ b/src/test/java/com/redhat/devtools/lsp4ij/fixtures/LSPSelectionRangeFixtureTestCase.java @@ -31,12 +31,12 @@ */ public abstract class LSPSelectionRangeFixtureTestCase extends LSPCodeInsightFixtureTestCase { - public LSPSelectionRangeFixtureTestCase(String... fileNamePatterns) { + protected LSPSelectionRangeFixtureTestCase(String... fileNamePatterns) { super(fileNamePatterns); } protected void assertSelectionRanges(@NotNull String fileName, - @NotNull String text, + @NotNull String fileBody, @NotNull String setCaretBefore, @NotNull String mockSelectionRangesJson, @NotNull String... selections) { @@ -45,7 +45,7 @@ protected void assertSelectionRanges(@NotNull String fileName, }.getType()); MockLanguageServer.INSTANCE.setSelectionRanges(mockSelectionRanges); - PsiFile file = myFixture.configureByText(fileName, text); + PsiFile file = myFixture.configureByText(fileName, fileBody); Editor editor = myFixture.getEditor(); // Make sure there's no initial selection @@ -53,7 +53,7 @@ protected void assertSelectionRanges(@NotNull String fileName, assertFalse(selectionModel.hasSelection()); // Move the caret to the specified text - int setCaretBeforeOffset = text.indexOf(setCaretBefore); + int setCaretBeforeOffset = fileBody.indexOf(setCaretBefore); assertTrue(setCaretBeforeOffset > -1); CaretModel caretModel = editor.getCaretModel(); caretModel.moveToOffset(setCaretBeforeOffset); @@ -76,10 +76,10 @@ protected void assertSelectionRanges(@NotNull String fileName, // If the entire file is selected, extending the selection again should leave it unchanged String lastSelection = ArrayUtil.getLastElement(selections); - if (text.equals(lastSelection)) { + if (fileBody.equals(lastSelection)) { myFixture.performEditorAction(IdeActions.ACTION_EDITOR_SELECT_WORD_AT_CARET); String actualSelection = selectionModel.getSelectedText(); - assertEquals(text, actualSelection); + assertEquals(fileBody, actualSelection); } // And now do the opposite for the Shrink Selection action; start with the next-to-last selection diff --git a/src/test/java/com/redhat/devtools/lsp4ij/mock/MockLanguageServer.java b/src/test/java/com/redhat/devtools/lsp4ij/mock/MockLanguageServer.java index 5456a3092..e260780d3 100644 --- a/src/test/java/com/redhat/devtools/lsp4ij/mock/MockLanguageServer.java +++ b/src/test/java/com/redhat/devtools/lsp4ij/mock/MockLanguageServer.java @@ -130,8 +130,7 @@ public static ServerCapabilities defaultServerCapabilities() { capabilities.setDocumentLinkProvider(new DocumentLinkOptions()); capabilities.setSignatureHelpProvider(new SignatureHelpOptions()); capabilities.setDocumentHighlightProvider(Boolean.TRUE); - capabilities - .setExecuteCommandProvider(new ExecuteCommandOptions(Collections.singletonList(SUPPORTED_COMMAND_ID))); + capabilities.setExecuteCommandProvider(new ExecuteCommandOptions(Collections.singletonList(SUPPORTED_COMMAND_ID))); capabilities.setColorProvider(Boolean.TRUE); capabilities.setDocumentSymbolProvider(Boolean.TRUE); capabilities.setLinkedEditingRangeProvider(new LinkedEditingRangeRegistrationOptions());