diff --git a/pom.xml b/pom.xml index fc087bed..822ee664 100644 --- a/pom.xml +++ b/pom.xml @@ -140,6 +140,11 @@ + 11 + + 2.100.2-SNAPSHOT + 0.1.0-SNAPSHOT org.scijava.ui.swing.script.Main org.scijava.ui.swing.script bsd_2 @@ -155,6 +160,11 @@ org.scijava scijava-common + + org.scijava + scijava-code-lsp + ${scijava-code-lsp.version} + org.scijava scijava-search diff --git a/src/main/java/org/scijava/ui/swing/script/EditorPane.java b/src/main/java/org/scijava/ui/swing/script/EditorPane.java index 22c3067a..8bd5185a 100644 --- a/src/main/java/org/scijava/ui/swing/script/EditorPane.java +++ b/src/main/java/org/scijava/ui/swing/script/EditorPane.java @@ -94,6 +94,9 @@ import org.scijava.script.ScriptLanguage; import org.scijava.script.ScriptService; import org.scijava.ui.swing.script.vim.VimHandler; +import org.scijava.code.lsp.LanguageServerService; +import org.scijava.code.lsp.ScriptSession; +import org.scijava.ui.swing.script.autocompletion.LanguageServerLanguageSupport; import org.scijava.util.FileUtils; /** @@ -117,9 +120,13 @@ public class EditorPane extends RSyntaxTextArea implements DocumentListener { private boolean undoInProgress; private boolean redoInProgress; private boolean autoCompletionEnabled; + private boolean hoverEnabled = true; + private boolean diagnosticsEnabled = true; private boolean autoCompletionJavaFallback; private boolean autoCompletionWithoutKey; private String supportStatus; + /** The completion support currently installed on this pane, if any. */ + private LanguageSupport installedSupport; private final ErrorParser errorHighlighter; private final JMenu noneLangSyntaxMenu; private final EditorPaneActions actions; @@ -131,6 +138,8 @@ public class EditorPane extends RSyntaxTextArea implements DocumentListener { @Parameter private LanguageSupportService languageSupportService; @Parameter + private LanguageServerService languageServerService; + @Parameter private ScriptService scriptService; @Parameter private ScriptHeaderService scriptHeaderService; @@ -718,6 +727,17 @@ protected void setLanguage(final ScriptLanguage language) { setLanguage(language, false); } + /** + * Uninstalls the language support (e.g. code completion), as the editor + * is closed. + */ + void releaseLanguageSupport() { + if (installedSupport != null) { + installedSupport.uninstall(this); + installedSupport = null; + } + } + /** * Set the language of this {@link EditorPane}, optionally adding a header. * TODO: What is this header? @@ -731,11 +751,8 @@ protected void setLanguage(final ScriptLanguage language, final boolean addHeader) { // uninstall existing language support. - LanguageSupport support = - languageSupportService.getLanguageSupport(currentLanguage); - if (support != null) { - support.uninstall(this); - } + releaseLanguageSupport(); + LanguageSupport support; String languageName; String defaultExtension; @@ -791,15 +808,26 @@ protected void setLanguage(final ScriptLanguage language, return; // no need to update console any further } String supportLevel = "SciJava supported"; - // try to get language support for current language, may be null. + // Tier 1: a heavy RSTA LanguageSupportPlugin (e.g. the Java parser). support = languageSupportService.getLanguageSupport(currentLanguage); - // that did not work. See if there is internal support for it. + // Tier 2: language servers for this language. + if (support == null && languageServerService.supports(currentLanguage)) { + final ScriptLanguage served = currentLanguage; + final LanguageServerLanguageSupport serverSupport = + new LanguageServerLanguageSupport(() -> new ScriptSession( + languageServerService, served), log, this::getFile); + serverSupport.setHoverEnabled(hoverEnabled); + serverSupport.setDiagnosticsEnabled(diagnosticsEnabled); + support = serverSupport; + } + + // Tier 3: RSTA's own built-in support for the syntax style. if (support == null) { support = LanguageSupportFactory.get().getSupportFor(styleName); supportLevel = "Legacy supported"; } - // that did not work, Fallback to Java + // Tier 4: fall back to Java completion, if requested. if (support == null && autoCompletionJavaFallback) { support = languageSupportService.getLanguageSupport(scriptService.getLanguageByName("Java")); supportLevel = "N/A. Using Java as fallback"; @@ -808,6 +836,7 @@ protected void setLanguage(final ScriptLanguage language, support.setAutoCompleteEnabled(autoCompletionEnabled); support.setAutoActivationEnabled(autoCompletionWithoutKey); support.install(this); + installedSupport = support; if (!autoCompletionEnabled) supportLevel += " but currently disabled\n"; else { @@ -840,8 +869,8 @@ public void setAutoCompletion(final boolean enabled) { * @param enabled Whether Java should be enabled as fallback language for * auto-completion */ - void setFallbackAutoCompletion(final boolean value) { - autoCompletionJavaFallback = value; + void setFallbackAutoCompletion(final boolean enabled) { + autoCompletionJavaFallback = enabled; if (autoCompletionEnabled && currentLanguage != null) setLanguage(currentLanguage); } @@ -862,6 +891,32 @@ public boolean isAutoCompletionEnabled() { return autoCompletionEnabled; } + /** + * Toggles whether what is known about the code under the mouse (e.g. a + * function's documentation) is shown, for languages whose completer knows. + */ + public void setHoverEnabled(final boolean enabled) { + hoverEnabled = enabled; + if (currentLanguage != null) setLanguage(currentLanguage); + } + + public boolean isHoverEnabled() { + return hoverEnabled; + } + + /** + * Toggles whether problems in the code (e.g. syntax errors) are shown as + * the code is edited, for languages whose completer finds them. + */ + public void setDiagnosticsEnabled(final boolean enabled) { + diagnosticsEnabled = enabled; + if (currentLanguage != null) setLanguage(currentLanguage); + } + + public boolean isDiagnosticsEnabled() { + return diagnosticsEnabled; + } + public boolean isAutoCompletionKeyless() { return autoCompletionWithoutKey; } @@ -1069,6 +1124,8 @@ public void convertSpacesToTabs() { public static final String AUTOCOMPLETE_PREFS = "script.editor.AC"; public static final String AUTOCOMPLETE_KEYLESS_PREFS = "script.editor.ACNoKey"; public static final String AUTOCOMPLETE_FALLBACK_PREFS = "script.editor.ACFallback"; + public static final String HOVER_PREFS = "script.editor.Hover"; + public static final String DIAGNOSTICS_PREFS = "script.editor.Diagnostics"; public static final String MARK_OCCURRENCES_PREFS = "script.editor.Occurrences"; public static final String VIM_PREFS = "script.editor.Vim"; public static final String FOLDERS_PREFS = "script.editor.folders"; @@ -1100,6 +1157,8 @@ public void loadPreferences() { setKeylessAutoCompletion(prefService.getBoolean(getClass(), AUTOCOMPLETE_KEYLESS_PREFS, true)); // true for backwards compatibility with ImageJ macro setFallbackAutoCompletion(prefService.getBoolean(getClass(), AUTOCOMPLETE_FALLBACK_PREFS, false)); setMarkOccurrences(prefService.getBoolean(getClass(), MARK_OCCURRENCES_PREFS, false)); + setHoverEnabled(prefService.getBoolean(getClass(), HOVER_PREFS, true)); + setDiagnosticsEnabled(prefService.getBoolean(getClass(), DIAGNOSTICS_PREFS, true)); setMarginLineEnabled(prefService.getBoolean(getClass(), MARGIN_VISIBLE_PREFS, false)); setVimEnabled(prefService.getBoolean(getClass(), VIM_PREFS, false)); applyTheme(themeName()); @@ -1156,6 +1215,8 @@ public void savePreferences(final String top_folders, final String theme) { prefService.put(getClass(), AUTOCOMPLETE_PREFS, isAutoCompletionEnabled()); prefService.put(getClass(), AUTOCOMPLETE_KEYLESS_PREFS, isAutoCompletionKeyless()); prefService.put(getClass(), AUTOCOMPLETE_FALLBACK_PREFS, isAutoCompletionFallbackEnabled()); + prefService.put(getClass(), HOVER_PREFS, isHoverEnabled()); + prefService.put(getClass(), DIAGNOSTICS_PREFS, isDiagnosticsEnabled()); prefService.put(getClass(), MARGIN_VISIBLE_PREFS, isMarginLineEnabled()); prefService.put(getClass(), MARK_OCCURRENCES_PREFS, getMarkOccurrences()); prefService.put(getClass(), VIM_PREFS, isVimEnabled()); diff --git a/src/main/java/org/scijava/ui/swing/script/InterpreterPane.java b/src/main/java/org/scijava/ui/swing/script/InterpreterPane.java index 4e5de1b5..580790cc 100644 --- a/src/main/java/org/scijava/ui/swing/script/InterpreterPane.java +++ b/src/main/java/org/scijava/ui/swing/script/InterpreterPane.java @@ -172,6 +172,7 @@ public void print(final String string) { public void dispose() { output.close(); + prompt.dispose(); } // -- UIComponent methods -- diff --git a/src/main/java/org/scijava/ui/swing/script/PromptPane.java b/src/main/java/org/scijava/ui/swing/script/PromptPane.java index 42d1a2c6..c2e1852a 100644 --- a/src/main/java/org/scijava/ui/swing/script/PromptPane.java +++ b/src/main/java/org/scijava/ui/swing/script/PromptPane.java @@ -31,18 +31,33 @@ import static java.awt.event.KeyEvent.VK_DOWN; import static java.awt.event.KeyEvent.VK_ENTER; +import static java.awt.event.KeyEvent.VK_TAB; import static java.awt.event.KeyEvent.VK_UP; import java.awt.Rectangle; import java.awt.event.KeyAdapter; import java.awt.event.KeyEvent; import java.io.PrintStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.TimeUnit; +import javax.script.ScriptEngine; import javax.swing.JTextArea; import javax.swing.text.BadLocationException; +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionList; +import org.eclipse.lsp4j.InsertTextFormat; +import org.eclipse.lsp4j.Range; import org.scijava.Context; +import org.scijava.script.ScriptInterpreter; import org.scijava.script.ScriptREPL; +import org.scijava.code.lsp.LanguageServerService; +import org.scijava.code.lsp.Positions; +import org.scijava.code.lsp.ScriptSession; import org.scijava.thread.ThreadService; import org.scijava.util.ClassUtils; import org.scijava.util.Types; @@ -56,6 +71,10 @@ */ public abstract class PromptPane implements UIComponent { + /** How long Tab waits for completions, in milliseconds. */ + private static final long COMPLETION_WAIT = 2000; + + private final ScriptREPL repl; private final VarsPane vars; private final TextArea textArea; @@ -63,6 +82,10 @@ public abstract class PromptPane implements UIComponent { private boolean executing; + /** The session about the prompt's text, and the interpreter it is for. */ + private ScriptSession session; + private ScriptInterpreter sessionInterpreter; + public PromptPane(final ScriptREPL repl, final VarsPane vars, final OutputPane output) { @@ -104,6 +127,12 @@ public void keyPressed(final KeyEvent event) { event.consume(); } break; + case VK_TAB: + if (!executing) { + complete(); + event.consume(); + } + break; } } @@ -178,11 +207,159 @@ private void execute() { } private ThreadService threadService() { + return context().service(ThreadService.class); + } + + private Context context() { // HACK: Get the SciJava context from the REPL. // This can be fixed if/when the REPL offers a getter for it. - final Context ctx = (Context) ClassUtils.getValue(// + return (Context) ClassUtils.getValue(// Types.field(repl.getClass(), "context"), repl); - return ctx.service(ThreadService.class); + } + + /** + * Tab-completion for the REPL prompt: what the language's servers know (see + * {@link LanguageServerService}), with the interpreter's live variables; or, + * for languages without servers, its live variables alone. So every + * scripting language gets at least baseline completion here. + */ + private void complete() { + final ScriptInterpreter interpreter = repl.getInterpreter(); + if (interpreter == null) return; + final int caret = textArea.getCaretPosition(); + final String text = textArea.getText(); + + final List items; + try { + items = items(interpreter, text, caret); + } + catch (final Exception exc) { + return; + } + if (items.isEmpty()) { + textArea.getToolkit().beep(); + return; + } + final int start = Math.min(Math.max(start(items.get(0), text, caret), 0), + caret); + + if (items.size() == 1) { + replaceRange(start, caret, insertion(items.get(0))); + return; + } + + // Multiple candidates: insert their longest common prefix (if it extends + // what is already typed), and list the options in the output pane. + final String lcp = longestCommonPrefix(items); + final String current = text.substring(start, caret); + if (lcp.length() > current.length()) { + replaceRange(start, caret, lcp); + } + else { + final StringBuilder sb = new StringBuilder(); + for (final CompletionItem item : items) { + if (sb.length() > 0) sb.append(" "); + sb.append(item.getLabel()); + if (item.getLabelDetails() != null && item.getLabelDetails() + .getDetail() != null) sb.append(item.getLabelDetails().getDetail()); + } + output.append(sb.append("\n").toString()); + } + } + + /** Asks the language's servers (or the live variables) for completions. */ + private List items(final ScriptInterpreter interpreter, + final String text, final int caret) throws Exception + { + final ScriptSession session = session(interpreter); + if (session == null) return Collections.emptyList(); + final CompletionList list = session.completion(text, caret).get( + COMPLETION_WAIT, TimeUnit.MILLISECONDS); + final List items = new ArrayList<>(list.getItems()); + items.sort(Comparator.comparing(item -> item.getSortText() != null ? item + .getSortText() : item.getLabel())); + return items; + } + + /** + * The session about the prompt's text, for the given interpreter: kept + * while it is the REPL's; or null if completion is unavailable. + */ + private synchronized ScriptSession session( + final ScriptInterpreter interpreter) + { + if (session != null && sessionInterpreter == interpreter) return session; + dispose(); + final LanguageServerService servers = context().service( + LanguageServerService.class); + final ScriptEngine engine = interpreter.getEngine(); + if (servers == null || interpreter.getLanguage() == null) return null; + session = engine == null ? new ScriptSession(servers, interpreter + .getLanguage()) : new ScriptSession(servers, interpreter.getLanguage(), + engine.getContext()); + sessionInterpreter = interpreter; + return session; + } + + /** Lets the language servers release what they keep for the prompt. */ + public synchronized void dispose() { + if (session != null) session.close(); + session = null; + sessionInterpreter = null; + } + + /** Where a completion's text starts: what it replaces, up to the caret. */ + private static int start(final CompletionItem item, final String text, + final int caret) + { + if (item.getTextEdit() == null) return caret; + final Range range = item.getTextEdit().isLeft() ? item.getTextEdit() + .getLeft().getRange() : item.getTextEdit().getRight().getInsert(); + return Positions.offset(text, range.getStart()); + } + + /** + * The text a completion inserts; for a callable (a snippet), up to its + * first parameter, e.g. {@code max(}. + */ + private static String insertion(final CompletionItem item) { + String text = item.getTextEdit() == null ? null : item.getTextEdit() + .isLeft() ? item.getTextEdit().getLeft().getNewText() : item + .getTextEdit().getRight().getNewText(); + if (text == null) text = item.getInsertText(); + if (text == null) text = item.getLabel(); + if (item.getInsertTextFormat() == InsertTextFormat.Snippet) { + final int stop = text.indexOf('$'); + if (stop >= 0) text = text.substring(0, stop); + text = text.replace("\\$", "$").replace("\\}", "}").replace("\\\\", + "\\"); + } + return text; + } + + private void replaceRange(final int start, final int end, + final String replacement) + { + try { + textArea.getDocument().remove(start, end - start); + textArea.getDocument().insertString(start, replacement, null); + } + catch (final BadLocationException exc) { + // ignore: the prompt changed underneath us + } + } + + private static String longestCommonPrefix(final List items) { + String prefix = insertion(items.get(0)); + for (final CompletionItem item : items) { + final String s = insertion(item); + int i = 0; + final int max = Math.min(prefix.length(), s.length()); + while (i < max && prefix.charAt(i) == s.charAt(i)) i++; + prefix = prefix.substring(0, i); + if (prefix.isEmpty()) break; + } + return prefix; } // -- Helper classes -- diff --git a/src/main/java/org/scijava/ui/swing/script/TextEditor.java b/src/main/java/org/scijava/ui/swing/script/TextEditor.java index 9a347f1e..06ebfb51 100644 --- a/src/main/java/org/scijava/ui/swing/script/TextEditor.java +++ b/src/main/java/org/scijava/ui/swing/script/TextEditor.java @@ -133,6 +133,7 @@ import org.scijava.Context; import org.scijava.app.AppService; import org.scijava.batch.BatchService; +import org.scijava.code.lsp.jvm.ClassIndex; import org.scijava.command.CommandService; import org.scijava.event.ContextDisposingEvent; import org.scijava.event.EventHandler; @@ -155,7 +156,6 @@ import org.scijava.thread.ThreadService; import org.scijava.ui.CloseConfirmable; import org.scijava.ui.UIService; -import org.scijava.ui.swing.script.autocompletion.ClassUtil; import org.scijava.ui.swing.script.commands.ChooseFontSize; import org.scijava.ui.swing.script.commands.ChooseTabSize; import org.scijava.ui.swing.script.commands.GitGrep; @@ -230,6 +230,7 @@ public class TextEditor extends JFrame implements ActionListener, private FindAndReplaceDialog findDialog; private JCheckBoxMenuItem autoSave, wrapLines, tabsEmulated, autoImport, autocompletion, fallbackAutocompletion, keylessAutocompletion, + hoverDocs, diagnostics, markOccurences, paintTabs, whiteSpace, marginLine, lockPane, vimMode; private ButtonGroup themeRadioGroup; private JTextArea errorScreen = new ConsoleArea(ConsoleArea.Content.ERRORS); @@ -647,6 +648,8 @@ public TextEditor(final Context context) { options.add(autocompletion); options.add(keylessAutocompletion); options.add(fallbackAutocompletion); + options.add(hoverDocs); + options.add(diagnostics); GuiUtils.addMenubarSeparator(options, "Key Bindings:"); options.add(vimMode); @@ -1133,6 +1136,20 @@ private void initializeDynamicMenuComponents() { fallbackAutocompletion.setToolTipText("If selected, Java completions will be used when scripting
" + "a language for which auto-completions are not available"); fallbackAutocompletion.addItemListener(e -> setFallbackAutoCompletion(fallbackAutocompletion.getState())); + hoverDocs = new JCheckBoxMenuItem("Show Documentation on Hover", true); + hoverDocs.setToolTipText("If selected, resting the mouse on code shows what is known
" + + "about it, e.g. a function's documentation. NB: Not all languages support this feature"); + hoverDocs.addItemListener(e -> { + for (int i = 0; i < tabbed.getTabCount(); i++) + getEditorPane(i).setHoverEnabled(hoverDocs.getState()); + }); + diagnostics = new JCheckBoxMenuItem("Show Problems While Editing", true); + diagnostics.setToolTipText("If selected, problems in the code (e.g. syntax errors) are
" + + "underlined as you edit. NB: Not all languages support this feature"); + diagnostics.addItemListener(e -> { + for (int i = 0; i < tabbed.getTabCount(); i++) + getEditorPane(i).setDiagnosticsEnabled(diagnostics.getState()); + }); vimMode = new JCheckBoxMenuItem("Vim Mode", false); vimMode.setToolTipText("Edit with vim-style modal key bindings.
" + "Press Esc for normal mode; :w saves, :q closes the tab"); @@ -2544,6 +2561,8 @@ else if (tabSize == Integer.parseInt(item.getText())) { autocompletion.setState(pane.isAutoCompletionEnabled()); fallbackAutocompletion.setState(pane.isAutoCompletionFallbackEnabled()); keylessAutocompletion.setState(pane.isAutoCompletionKeyless()); + hoverDocs.setState(pane.isHoverEnabled()); + diagnostics.setState(pane.isDiagnosticsEnabled()); sourceTreePanel.rebuildSourceTree(pane); } @@ -3152,6 +3171,8 @@ private void switchTabRelative(final int delta) { private void removeTab(final int index) { final int menuItemIndex = index + tabsMenuTabsStart; try { + // NB: Let the completer release what it keeps for the script. + getEditorPane(index).releaseLanguageSupport(); tabbed.remove(index); tabsMenuItems.remove(tabsMenu.getItem(menuItemIndex)); tabsMenu.remove(menuItemIndex); @@ -3288,7 +3309,7 @@ public void run() { setCursor(Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR)); final HashMap> matches; try { - matches = ClassUtil.findDocumentationForClass(text); + matches = ClassIndex.findDocumentationForClass(text); } finally { setCursor(Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR)); } diff --git a/src/main/java/org/scijava/ui/swing/script/autocompletion/AdditionalEdits.java b/src/main/java/org/scijava/ui/swing/script/autocompletion/AdditionalEdits.java new file mode 100644 index 00000000..04ef7671 --- /dev/null +++ b/src/main/java/org/scijava/ui/swing/script/autocompletion/AdditionalEdits.java @@ -0,0 +1,74 @@ +/* + * #%L + * Script Editor and Interpreter for SciJava script languages. + * %% + * Copyright (C) 2009 - 2026 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.scijava.ui.swing.script.autocompletion; + +import java.util.List; + +/** + * Implemented by RSTA completions that carry additional edits (e.g. + * auto-imports, a completion's {@code additionalTextEdits}) to apply when + * accepted. Both the + * {@code BasicCompletion}-based and {@code FunctionCompletion}-based SciJava + * completions implement this, so {@link SciJavaAutoCompletion} can apply the + * edits regardless of the underlying RSTA completion type. + * + * @author Curtis Rueden + */ +public interface AdditionalEdits { + + /** Extra edits to apply when the completion is accepted; never null. */ + List getAdditionalEdits(); + + /** Replaces the text between two offsets. */ + final class Edit { + + private final int start; + private final int end; + private final String newText; + + public Edit(final int start, final int end, final String newText) { + this.start = start; + this.end = end; + this.newText = newText; + } + + public int start() { + return start; + } + + public int end() { + return end; + } + + public String newText() { + return newText; + } + } +} diff --git a/src/main/java/org/scijava/ui/swing/script/autocompletion/ClassUtil.java b/src/main/java/org/scijava/ui/swing/script/autocompletion/ClassUtil.java index a0a45ef4..095503f3 100644 --- a/src/main/java/org/scijava/ui/swing/script/autocompletion/ClassUtil.java +++ b/src/main/java/org/scijava/ui/swing/script/autocompletion/ClassUtil.java @@ -28,450 +28,101 @@ */ package org.scijava.ui.swing.script.autocompletion; -import java.io.File; -import java.io.IOException; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.Method; -import java.lang.reflect.Parameter; -import java.net.URL; -import java.net.URLConnection; import java.util.ArrayList; -import java.util.Enumeration; import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedList; import java.util.List; -import java.util.Map; -import java.util.Scanner; -import java.util.jar.JarEntry; -import java.util.jar.JarFile; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import java.util.stream.Stream; import org.fife.ui.autocomplete.BasicCompletion; import org.fife.ui.autocomplete.Completion; import org.fife.ui.autocomplete.CompletionProvider; +import org.scijava.code.lsp.jvm.ClassIndex; +/** + * Thin facade over the toolkit-agnostic {@link ClassIndex} in scijava-code-lsp, + * retained for the script editor's Swing-specific completion code. New code + * should use {@link ClassIndex} directly; only the RSTA-specific + * {@link #classUnavailableCompletions} helper is genuinely editor-side. + * + * @author Albert Cardona + * @author Tiago Ferreira + * @deprecated Use {@link ClassIndex} for class discovery and documentation; this + * facade exists to ease migration. + */ +@Deprecated public class ClassUtil { - - static final String scijava_javadoc_URL = "https://javadoc.scijava.org/"; // with ending slash - - /** Cache of class names vs list of URLs found in the pom.xml files of their contaning jar files, if any. */ - static private final Map class_urls = new HashMap<>(); - - static private final Map package_urls = new HashMap<>(); - - static private boolean ready = false; - - /** Cache of subURL javadoc at https://javadoc.scijava.org */ - static private final HashMap scijava_javadoc_URLs = new HashMap<>(); - - static public final void ensureCache() { - synchronized (class_urls) { - if (class_urls.isEmpty()) { - final ArrayList dirs = new ArrayList<>(); - dirs.add(System.getProperty("java.home")); - dirs.add(System.getProperty("ij.dir")); - class_urls.putAll(findAllClasses(dirs)); - // Soft attempt at getting all packages (will get them wrong if multiple jars have the same packages) - for (final Map.Entry entry: class_urls.entrySet()) { - final int idot = entry.getKey().lastIndexOf('.'); - if (-1 == idot) continue; // no package - final String package_name = entry.getKey().substring(0, idot); - if (package_urls.containsKey(package_name)) continue; - package_urls.put(package_name, entry.getValue()); - } - ready = true; - } - } - } - - static public final boolean isCacheReady() { - return ready; - } - - static public final void ensureSciJavaSubURLCache() { - synchronized (scijava_javadoc_URLs) { - if (!scijava_javadoc_URLs.isEmpty()) return; - Scanner scanner = null; - try { - final Pattern pattern = Pattern.compile("
"); - final URLConnection connection = new URL(scijava_javadoc_URL).openConnection(); - scanner = new Scanner(connection.getInputStream()); - while (scanner.hasNext()) { - final Matcher matcher = pattern.matcher(scanner.nextLine()); - if (matcher.find()) { - String name = matcher.group(1).toLowerCase(); - if (name.endsWith("/")) name = name.substring(0, name.length() -1); - scijava_javadoc_URLs.put(name, scijava_javadoc_URL + matcher.group(1)); - } - } - scanner.close(); - } catch ( Exception e ) { - e.printStackTrace(); - } finally { - if (null != scanner) scanner.close(); - } - } - } - - static public HashMap findClassDocumentationURLs(final String s) { - ensureCache(); - final HashMap matches = new HashMap<>(); - for (final Map.Entry entry: class_urls.entrySet()) { - if (entry.getKey().contains(s)) { - final JarProperties props = entry.getValue(); - matches.put(entry.getKey(), new JarProperties(props.name, new ArrayList(props.urls))); - } - } - return matches; - } - - static public HashMap> findDocumentationForClass(final String s) { - final HashMap matches = findClassDocumentationURLs(s); - ensureSciJavaSubURLCache(); - - final Pattern javaPackages = Pattern.compile("^(java|javax|org\\.omg|org\\.w3c|org\\.xml|org\\.ietf\\.jgss)\\..*$"); - final String version = System.getProperty("java.version"); - final String majorVersion = version.startsWith("1.") ? - version.substring(2, version.indexOf('.', 2)) - : version.substring(0, version.indexOf('.')); - final String javaDoc = "java" + majorVersion; - - final HashMap> class_urls = new HashMap<>(); - - for (final Map.Entry entry: matches.entrySet()) { - final String classname = entry.getKey(); - final ArrayList urls = new ArrayList<>(); - class_urls.put(classname, urls); - if (javaPackages.matcher(classname).matches()) { - urls.add(scijava_javadoc_URLs.get(javaDoc) + classname.replace('.', '/') + ".html"); - } else { - final JarProperties props = entry.getValue(); - // Find the first URL with git in it - for (final String url : props.urls) { - final boolean github = url.contains("/github.com"), - gitlab = url.contains("/gitlab.com"); - if (github || gitlab) { - // Find the 5th slash, e.g. https://github.com/imglib/imglib2/ - int count = 0; - int last = 0; - while (count < 5) { - last = url.indexOf('/', last + 1); - if (-1 == last) break; // less than 5 found - ++count; - } - String urlbase = url; - if (5 == count) urlbase = url.substring(0, last); // without the ending slash - // Assume maven, since these URLs were found in a pom.xml: src/main/java/ - urls.add(urlbase + (gitlab ? "/-" : "") + "/blob/master/src/main/java/" + classname.replace('.', '/') + ".java"); - break; - } - } - // Try to find a javadoc in the scijava website - if (null != props.name) { - String scijava_javadoc_url = scijava_javadoc_URLs.get(props.name.toLowerCase()); - if (null == scijava_javadoc_url) { - // Try cropping name at the first whitespace if any (e.g. "ImgLib2 Core Library" to "ImgLib2") - for (final String word: props.name.split(" ")) { - scijava_javadoc_url = scijava_javadoc_URLs.get(word.toLowerCase()); - if (null != scijava_javadoc_url) break; // found a valid one - } - } - if (null != scijava_javadoc_url) { - urls.add(scijava_javadoc_url + classname.replace('.', '/') + ".html"); - } else { - // Try Fiji: could be a plugin - Scanner scanner = null; - try { - final String url = scijava_javadoc_URL + "Fiji/" + classname.replace('.', '/') + ".html"; - final URLConnection c = new URL(url).openConnection(); - scanner = new Scanner(c.getInputStream()); - while (scanner.hasNext()) { - final String line = scanner.nextLine(); - if (line.contains("")) { - if (!line.contains("<title>404")) { - urls.add(url); - } - break; - } - } - } catch (Exception e) { - // Ignore: 404 that wasn't redirected to an error page - } finally { - if (null != scanner) scanner.close(); - } - } - } - } - } - return class_urls; - } - - static public final class JarProperties { - public final ArrayList<String> urls; - public String name = null; - public JarProperties(final String name, final ArrayList<String> urls) { - this.name = name; - this.urls = urls; - } + public static void ensureCache() { + ClassIndex.ensureCache(); } - - static public final HashMap<String, JarProperties> findAllClasses(final List<String> jar_folders) { - // Find all jar files - final ArrayList<String> jarFilePaths = new ArrayList<String>(); - final LinkedList<String> dirs = new LinkedList<>(jar_folders); - final HashSet<String> seenDirs = new HashSet<>(); - while (!dirs.isEmpty()) { - final String filepath = dirs.removeFirst(); - if (null == filepath) continue; - final File file = new File(filepath); - seenDirs.add(file.getAbsolutePath()); - if (file.exists() && file.isDirectory()) { - for (final File child : file.listFiles()) { - final String childfilepath = child.getAbsolutePath(); - if (seenDirs.contains(childfilepath)) continue; - if (child.isDirectory()) dirs.add(childfilepath); - else if (childfilepath.endsWith(".jar")) jarFilePaths.add(childfilepath); - } - } - } - // Find all classes from all jar files - final HashMap<String, JarProperties> class_urls = new HashMap<>(); - final Pattern urlpattern = Pattern.compile(">(http.*?)<"); - final Pattern namepattern = Pattern.compile("<name>(.*?)<"); - for (final String jarpath : jarFilePaths) { - JarFile jar = null; - try { - jar = new JarFile(jarpath); - final Enumeration<JarEntry> entries = jar.entries(); - final ArrayList<String> urls = new ArrayList<>(); - final JarProperties props = new JarProperties(null, urls); - // For every filepath in the jar zip archive - while (entries.hasMoreElements()) { - final JarEntry entry = entries.nextElement(); - if (entry.isDirectory()) continue; - if (entry.getName().endsWith(".class")) { - String classname = entry.getName().replace('/', '.'); - final int idollar = classname.indexOf('$'); - if (-1 != idollar) { - classname = classname.substring(0, idollar); // truncate at the first dollar sign - } else { - classname = classname.substring(0, classname.length() - 6); // without .class - } - class_urls.put(classname, props); - } else if (entry.getName().endsWith("/pom.xml")) { - final Scanner scanner = new Scanner(jar.getInputStream(entry)); - while (scanner.hasNext()) { - final String line = scanner.nextLine(); - final Matcher matcher1 = urlpattern.matcher(line); - if (matcher1.find()) { - urls.add(matcher1.group(1)); - } - if (null == props.name) { - final Matcher matcher2 = namepattern.matcher(line); - if (matcher2.find()) { - props.name = matcher2.group(1); - } - } - } - scanner.close(); - } - } - } catch (IOException e) { - e.printStackTrace(); - } finally { - if (null != jar) try { - jar.close(); - } catch (IOException e) { e.printStackTrace(); } - } - } - return class_urls; + + public static boolean isCacheReady() { + return ClassIndex.isCacheReady(); } - - static public final Stream<String> findPackageNamesStartingWith(final String text) { - ensureCache(); - return package_urls.keySet().stream().filter(s -> s.startsWith(text)); + + public static Stream<String> findPackageNamesStartingWith(final String t) { + return ClassIndex.findPackageNamesStartingWith(t); } - - static public final Stream<String> findClassNamesForPackage(final String packageName) { - ensureCache(); - if (null == packageName || packageName.length() == 0) - return class_urls.keySet().stream(); - return class_urls.keySet().stream().filter(s -> s.startsWith(packageName) && -1 == s.indexOf('.', packageName.length() + 2)); + + public static Stream<String> findClassNamesForPackage(final String pkg) { + return ClassIndex.findClassNamesForPackage(pkg); } - - /** - * - * @param text A left-justified substring of a fully qualified class name, with the package. - * @return - */ - static public final Stream<String> findClassNamesStartingWith(final String text) { - ensureCache(); - if (text.length() == 0) - return class_urls.keySet().stream(); - return class_urls.keySet().stream().filter(s -> s.startsWith(text)); + + public static Stream<String> findClassNamesStartingWith(final String t) { + return ClassIndex.findClassNamesStartingWith(t); } - - /** - * - * @param text A substring of a class fully qualified name. - * @return - */ - static public final Stream<String> findClassNamesContaining(final String text) { - ensureCache(); - return class_urls.keySet().stream().filter(s -> s.contains(text)); + + public static Stream<String> findClassNamesContaining(final String t) { + return ClassIndex.findClassNamesContaining(t); } - - /** - * Find simple class names starting with "text", returning the fully qualified class names. - * @param text - * @return - */ - static public final ArrayList<String> findSimpleClassNamesStartingWith(final String text) { - ensureCache(); - final ArrayList<String> matches = new ArrayList<>(); - if (0 == text.length()) - return matches; - for (final String classname: class_urls.keySet()) { - final int idot = classname.lastIndexOf('.'); - final String simplename = -1 == idot ? classname : classname.substring(idot + 1); - if (simplename.startsWith(text)) matches.add(classname); - } - return matches; + + public static ArrayList<String> findSimpleClassNamesStartingWith( + final String t) + { + return ClassIndex.findSimpleClassNamesStartingWith(t); } - private static String getJavaDocLink(final Class<?> c) { - final String name = c.getCanonicalName(); - final String pkg = getDocPackage(name); - if (pkg == null) return name; - final String url = String.format("%s%s/index.html?%s.html", scijava_javadoc_URL, pkg, name.replace(".", "/")); - return String.format("<a href='%s';>%s</a>", url, name); + public static HashMap<String, ArrayList<String>> findDocumentationForClass( + final String s) + { + return ClassIndex.findDocumentationForClass(s); } - private static String getDocPackage(final String classCanonicalName) { - //TODO: Do this programmatically - if (classCanonicalName.startsWith("ij.")) - return "ImageJ1"; - else if (classCanonicalName.startsWith("sc.fiji")) - return "Fiji"; - else if (classCanonicalName.startsWith("net.imagej")) - return "ImageJ"; - else if (classCanonicalName.startsWith("net.imglib2")) - return "ImgLib2"; - else if (classCanonicalName.startsWith("org.scijava")) - return "SciJava"; - else if (classCanonicalName.startsWith("loci.formats")) - return "Bio-Formats"; - if (classCanonicalName.startsWith("java.")) - return "Java8"; - else if (classCanonicalName.startsWith("sc.iview")) - return "SciView"; - else if (classCanonicalName.startsWith("weka.")) - return "Weka"; - else if (classCanonicalName.startsWith("inra.ijpb")) - return "MorphoLibJ"; - return null; + protected static String getSummaryCompletion(final Field field, + final Class<?> c) + { + return ClassIndex.getSummaryCompletion(field, c); } - /** - * Assembles an HTML-formatted auto-completion summary with functional - * hyperlinks - * - * @param field the field being documented - * @param c the class being documented. Expected to be documented at the - * Scijava API documentation portal. - * @return the completion summary - */ - protected static String getSummaryCompletion(final Field field, final Class<?> c) { - final StringBuffer summary = new StringBuffer(); - summary.append("<b>").append(field.getName()).append("</b>"); - summary.append(" (").append(field.getType().getName()).append(")"); - summary.append("<DL>"); - summary.append("<DT><b>Defined in:</b>"); - summary.append("<DD>").append(getJavaDocLink(c)); - summary.append("</DL>"); - return summary.toString(); + protected static String getSummaryCompletion(final Method method, + final Class<?> c) + { + return ClassIndex.getSummaryCompletion(method, c); } - /** - * Assembles an HTML-formatted auto-completion summary with functional - * hyperlinks - * - * @param method the method being documented - * @param c the class being documented. Expected to be documented at the - * Scijava API documentation portal. - * @return the completion summary - */ - protected static String getSummaryCompletion(final Method method, final Class<?> c) { - final StringBuffer summary = new StringBuffer(); - final StringBuffer replacementHeader = new StringBuffer(method.getName()); - final int bIndex = replacementHeader.length(); // remember '(' position - replacementHeader.append("("); - final Parameter[] params = method.getParameters(); - if (params.length > 0) { - for (final Parameter parameter : params) { - replacementHeader.append(parameter.getType().getSimpleName()).append(" ").append(parameter.getName()).append(", "); - } - replacementHeader.setLength(replacementHeader.length() - 2); // remove trailing ', '; - } - replacementHeader.append(")"); - replacementHeader.replace(bIndex, bIndex + 1, "</b>("); // In header, highlight only method name for extra contrast - summary.append("<b>").append(replacementHeader); - summary.append("<DL>"); - summary.append("<DT><b>Returns:</b>"); - summary.append("<DD>").append(method.getReturnType().getSimpleName()); - summary.append("<DT><b>Defined in:</b>"); - summary.append("<DD>").append(getJavaDocLink(c)); - summary.append("</DL>"); - return summary.toString(); + protected static String getSummaryCompletion(final Constructor<?> constructor, + final Class<?> c) + { + return ClassIndex.getSummaryCompletion(constructor, c); } - + /** - * Assembles an HTML-formatted auto-completion summary with functional - * hyperlinks - * - * @param constructor the constructor being documented - * @param c the class being documented. Expected to be documented at the - * Scijava API documentation portal. - * @return the completion summary + * Placeholder RSTA completions warning that a class was unavailable. This is + * the one genuinely Swing/RSTA-specific helper; it stays editor-side. */ - protected static String getSummaryCompletion(final Constructor<?> constructor, final Class<?> c) { - final StringBuffer summary = new StringBuffer(); - final StringBuffer replacementHeader = new StringBuffer(c.getSimpleName()); - final int bIndex = replacementHeader.length(); // remember '(' position - replacementHeader.append("("); - final Parameter[] params = constructor.getParameters(); - if (params.length > 0) { - for (final Parameter parameter : params) { - replacementHeader.append(parameter.getType().getSimpleName()).append(" ").append(parameter.getName()).append(", "); - } - replacementHeader.setLength(replacementHeader.length() - 2); // remove trailing ', '; - } - replacementHeader.append(")"); - replacementHeader.replace(bIndex, bIndex + 1, "</b>("); // In header, highlight only method name for extra contrast - summary.append("<b>").append(replacementHeader); - summary.append("<DL>"); - summary.append("<DT><b>Intantiates:</b>"); - summary.append("<DD>").append(c.getSimpleName()); - summary.append("<DT><b>Defined in:</b>"); - summary.append("<DD>").append(getJavaDocLink(c)); - summary.append("</DL>"); - return summary.toString(); - } - - static List<Completion> classUnavailableCompletions(final CompletionProvider provider, final String pre) { - // placeholder completions to warn users class was not available (repeated to force pop-up display) + static List<Completion> classUnavailableCompletions( + final CompletionProvider provider, final String pre) + { final List<Completion> list = new ArrayList<>(); - final String summary = "Class not found or invalid import. See " - + String.format("<a href='%s';>SciJavaDocs</a>", scijava_javadoc_URL) - + " or <a href='https://search.imagej.net/';>search</a> for help"; + final String summary = "Class not found or invalid import. See " + String + .format("<a href='%s';>SciJavaDocs</a>", ClassIndex.SCIJAVA_JAVADOC_URL) + + " or <a href='https://search.imagej.net/';>search</a> for help"; + // Repeated to force pop-up display. list.add(new BasicCompletion(provider, pre + "?", null, summary)); list.add(new BasicCompletion(provider, pre + "?", null, summary)); return list; } - } diff --git a/src/main/java/org/scijava/ui/swing/script/autocompletion/CompletionText.java b/src/main/java/org/scijava/ui/swing/script/autocompletion/CompletionText.java index e06f31f7..985aa9a5 100644 --- a/src/main/java/org/scijava/ui/swing/script/autocompletion/CompletionText.java +++ b/src/main/java/org/scijava/ui/swing/script/autocompletion/CompletionText.java @@ -39,6 +39,7 @@ import org.fife.ui.autocomplete.AbstractCompletion; import org.fife.ui.autocomplete.BasicCompletion; import org.fife.ui.autocomplete.CompletionProvider; +import org.scijava.code.lsp.jvm.ClassIndex; public class CompletionText { @@ -59,17 +60,17 @@ public CompletionText(final String replacementText, final String summary, final } public CompletionText(final String replacementText, final Class<?> c, final Field f) { - this(replacementText, ClassUtil.getSummaryCompletion(f, c), null); + this(replacementText, ClassIndex.getSummaryCompletion(f, c), null); } public CompletionText(final String replacementText, final Class<?> c, final Method m) { - this(replacementText, ClassUtil.getSummaryCompletion(m, c), null); + this(replacementText, ClassIndex.getSummaryCompletion(m, c), null); this.method_args = Arrays.asList(m.getParameters()); this.method_returnType = m.getReturnType().getCanonicalName(); } public CompletionText(final String replacementText, final Class<?> c, final Constructor<?> constructor) { - this(replacementText, ClassUtil.getSummaryCompletion(constructor, c), null); + this(replacementText, ClassIndex.getSummaryCompletion(constructor, c), null); this.method_args = Arrays.asList(constructor.getParameters()); this.method_returnType = c.getCanonicalName(); } diff --git a/src/main/java/org/scijava/ui/swing/script/autocompletion/DiagnosticsParser.java b/src/main/java/org/scijava/ui/swing/script/autocompletion/DiagnosticsParser.java new file mode 100644 index 00000000..0466a7a8 --- /dev/null +++ b/src/main/java/org/scijava/ui/swing/script/autocompletion/DiagnosticsParser.java @@ -0,0 +1,119 @@ +/* + * #%L + * Script Editor and Interpreter for SciJava script languages. + * %% + * Copyright (C) 2009 - 2026 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.scijava.ui.swing.script.autocompletion; + +import java.util.Collections; +import java.util.List; + +import javax.swing.SwingUtilities; +import javax.swing.text.BadLocationException; +import javax.swing.text.Element; + +import org.eclipse.lsp4j.Diagnostic; +import org.eclipse.lsp4j.DiagnosticSeverity; +import org.fife.ui.rsyntaxtextarea.RSyntaxDocument; +import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; +import org.fife.ui.rsyntaxtextarea.parser.AbstractParser; +import org.fife.ui.rsyntaxtextarea.parser.DefaultParseResult; +import org.fife.ui.rsyntaxtextarea.parser.DefaultParserNotice; +import org.fife.ui.rsyntaxtextarea.parser.ParseResult; +import org.fife.ui.rsyntaxtextarea.parser.ParserNotice; +import org.scijava.code.lsp.Positions; + +/** + * Shows the problems language servers publish for the script (e.g. syntax + * errors), as squiggles, with the problem as tooltip. + * <p> + * Servers publish problems when they like (e.g. a moment after each change): + * each time, the text area parses anew to show them. + * </p> + * + * @author Gabriel Selzer + */ +public class DiagnosticsParser extends AbstractParser { + + private final RSyntaxTextArea textArea; + + /** The problems last published. */ + private volatile List<Diagnostic> problems = Collections.emptyList(); + + public DiagnosticsParser(final RSyntaxTextArea textArea) { + this.textArea = textArea; + } + + /** Shows the given problems (from any thread). */ + public void accept(final List<Diagnostic> found) { + SwingUtilities.invokeLater(() -> { + problems = found == null ? Collections.emptyList() : found; + textArea.forceReparsing(this); + }); + } + + @Override + public ParseResult parse(final RSyntaxDocument doc, final String style) { + final DefaultParseResult result = new DefaultParseResult(this); + final String text; + try { + text = doc.getText(0, doc.getLength()); + } + catch (final BadLocationException exc) { + return result; + } + final Element root = doc.getDefaultRootElement(); + for (final Diagnostic d : problems) { + int start = Positions.offset(text, d.getRange().getStart()); + final int end = Positions.offset(text, d.getRange().getEnd()); + if (start >= text.length() && text.length() > 0) { + start = text.length() - 1; // NB: E.g. "unexpected end of file". + } + start = Math.max(0, start); + final int length = Math.max(1, Math.min(end, text.length()) - start); + final DefaultParserNotice notice = new DefaultParserNotice(this, + message(d), root.getElementIndex(start), start, length); + notice.setLevel(level(d.getSeverity())); + result.addNotice(notice); + } + return result; + } + + private static String message(final Diagnostic d) { + if (d.getMessage() == null) return ""; + return d.getMessage().isLeft() ? d.getMessage().getLeft() : d.getMessage() + .getRight().getValue(); + } + + private static ParserNotice.Level level(final DiagnosticSeverity s) { + if (s == null || s == DiagnosticSeverity.Error) { + return ParserNotice.Level.ERROR; + } + return s == DiagnosticSeverity.Warning ? ParserNotice.Level.WARNING + : ParserNotice.Level.INFO; + } +} diff --git a/src/main/java/org/scijava/ui/swing/script/autocompletion/HoverToolTipSupplier.java b/src/main/java/org/scijava/ui/swing/script/autocompletion/HoverToolTipSupplier.java new file mode 100644 index 00000000..35c02959 --- /dev/null +++ b/src/main/java/org/scijava/ui/swing/script/autocompletion/HoverToolTipSupplier.java @@ -0,0 +1,144 @@ +/* + * #%L + * Script Editor and Interpreter for SciJava script languages. + * %% + * Copyright (C) 2009 - 2026 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.scijava.ui.swing.script.autocompletion; + +import java.awt.event.MouseEvent; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.lsp4j.Hover; +import org.eclipse.lsp4j.MarkedString; +import org.eclipse.lsp4j.MarkupContent; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import org.fife.ui.rtextarea.RTextArea; +import org.fife.ui.rtextarea.ToolTipSupplier; +import org.scijava.code.lsp.ScriptSession; +import org.scijava.log.Logger; + +/** + * Shows what the language server knows about the code under the mouse + * ({@code textDocument/hover}), e.g. a function's documentation, as a tooltip. + * <p> + * RSyntaxTextArea asks for tooltips as the mouse moves, on the event dispatch + * thread: so the server is asked once per word, and awaited only briefly; + * until it answers, there is no tooltip. + * </p> + * + * @author Gabriel Selzer + */ +public class HoverToolTipSupplier implements ToolTipSupplier { + + /** How long to wait for an answer, in milliseconds. */ + private static final long BUDGET = 100; + + /** How much of an answer to show, at most, in lines. */ + private static final int MAX_LINES = 25; + + private final ScriptSession session; + private final Logger log; + + /** The last question (text and word), and its answer. */ + private String askedText; + private int askedWord = -1; + private CompletableFuture<Hover> answer; + + public HoverToolTipSupplier(final ScriptSession session, final Logger log) { + this.session = session; + this.log = log; + } + + @Override + public String getToolTipText(final RTextArea textArea, final MouseEvent e) { + return toolTip(textArea.getText(), textArea.viewToModel2D(e.getPoint())); + } + + /** Gets the tooltip for an offset of a text, or null if none. */ + String toolTip(final String text, final int offset) { + if (offset < 0 || offset >= text.length() || !Character + .isJavaIdentifierPart(text.charAt(offset))) return null; + int word = offset; + while (word > 0 && Character.isJavaIdentifierPart(text.charAt(word - 1))) { + word--; + } + if (word != askedWord || !text.equals(askedText)) { + askedText = text; + askedWord = word; + answer = ask(text, offset); + } + try { + final String html = html(answer.get(BUDGET, TimeUnit.MILLISECONDS)); + return html == null ? null : "<html>" + truncate(html) + "</html>"; + } + catch (final Exception exc) { + return null; // NB: Not yet; or failed. + } + } + + private CompletableFuture<Hover> ask(final String text, final int offset) { + try { + return session.hover(text, offset); + } + catch (final Exception | LinkageError exc) { + // NB: Never let a misbehaving server break the editor. + if (log != null) log.debug("Hover failed", exc); + return CompletableFuture.completedFuture(null); + } + } + + /** A hover's contents, as HTML; or null if none. */ + static String html(final Hover hover) { + if (hover == null || hover.getContents() == null) return null; + final Either<java.util.List<Either<String, MarkedString>>, MarkupContent> contents = + hover.getContents(); + if (contents.isRight()) { + return SciJavaCompletionProvider.html(Either.forRight(contents + .getRight())); + } + final StringBuilder sb = new StringBuilder(); + for (final Either<String, MarkedString> part : contents.getLeft()) { + if (sb.length() > 0) sb.append("\n\n"); + sb.append(part.isLeft() ? part.getLeft() : part.getRight().getValue()); + } + return SciJavaCompletionProvider.html(Either.forLeft(sb.toString())); + } + + /** Keeps the first lines of long answers (e.g. docstrings). */ + static String truncate(final String html) { + int end = -1; + for (int i = 0; i < MAX_LINES; i++) { + end = html.indexOf('\n', end + 1); + if (end < 0) return html; + } + // NB: Close an open <pre>, lest the rest of the tooltip be preformatted. + final String head = html.substring(0, end); + final boolean pre = head.lastIndexOf("<pre>") > head.lastIndexOf("</pre>"); + return head + "\n…" + (pre ? "</pre>" : ""); + } +} diff --git a/src/main/java/org/scijava/ui/swing/script/autocompletion/LanguageServerLanguageSupport.java b/src/main/java/org/scijava/ui/swing/script/autocompletion/LanguageServerLanguageSupport.java new file mode 100644 index 00000000..f9633012 --- /dev/null +++ b/src/main/java/org/scijava/ui/swing/script/autocompletion/LanguageServerLanguageSupport.java @@ -0,0 +1,204 @@ +/* + * #%L + * Script Editor and Interpreter for SciJava script languages. + * %% + * Copyright (C) 2009 - 2026 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.scijava.ui.swing.script.autocompletion; + +import java.io.File; +import java.util.Map; +import java.util.WeakHashMap; +import java.util.function.Supplier; + +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; + +import org.fife.rsta.ac.AbstractLanguageSupport; +import org.fife.ui.autocomplete.AutoCompletion; +import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; +import org.scijava.code.lsp.ScriptSession; +import org.scijava.log.Logger; + +/** + * RSyntaxTextArea language support from language servers, via a + * {@link ScriptSession} per text area (usually from + * {@code org.scijava.code.lsp.LanguageServerService}): completion with + * parameter assistance, documentation on hover, problems as squiggles, and + * signatures as a call is typed. The session is told the script's text on + * install and with each edit, and closed on uninstall. + * + * @author Curtis Rueden + * @author Gabriel Selzer + */ +public class LanguageServerLanguageSupport extends AbstractLanguageSupport { + + private final Supplier<ScriptSession> sessions; + private final Logger log; + private final Supplier<File> file; + private boolean hoverEnabled = true; + private boolean diagnosticsEnabled = true; + + /** What was installed in each text area, to uninstall. */ + private final Map<RSyntaxTextArea, Installed> installed = + new WeakHashMap<>(); + + /** + * @param sessions Starts a session about the script, for each text area + * this is installed in (e.g. + * {@code () -> new ScriptSession(servers, language)}). + * @param log Where to report problems, or null. + * @param file Supplies the file of the script being edited (or null if + * unsaved). + */ + public LanguageServerLanguageSupport(final Supplier<ScriptSession> sessions, + final Logger log, final Supplier<File> file) + { + this.sessions = sessions; + this.log = log; + this.file = file; + setAutoCompleteEnabled(true); + setParameterAssistanceEnabled(true); + setShowDescWindow(true); + } + + /** + * Sets whether to show what the server knows about the code under the + * mouse. Applies to later installs. + */ + public void setHoverEnabled(final boolean enabled) { + hoverEnabled = enabled; + } + + /** + * Sets whether to show the problems the server finds. Applies to later + * installs. + */ + public void setDiagnosticsEnabled(final boolean enabled) { + diagnosticsEnabled = enabled; + } + + @Override + public void install(final RSyntaxTextArea textArea) { + final ScriptSession session = sessions.get(); + session.setFile(file); + final SciJavaCompletionProvider provider = new SciJavaCompletionProvider( + session); + provider.setLogger(log); + final AutoCompletion ac = new SciJavaAutoCompletion(provider); + ac.setAutoCompleteEnabled(isAutoCompleteEnabled()); + ac.setAutoActivationEnabled(isAutoActivationEnabled()); + ac.setParameterAssistanceEnabled(isParameterAssistanceEnabled()); + ac.setShowDescWindow(getShowDescWindow()); + ac.install(textArea); + installImpl(textArea, ac); + + // Documentation on hover, problems as squiggles, signatures as typed. + final Installed extras = new Installed(session); + if (hoverEnabled) { + textArea.setToolTipSupplier(new HoverToolTipSupplier(session, log)); + } + if (diagnosticsEnabled) { + extras.parser = new DiagnosticsParser(textArea); + textArea.addParser(extras.parser); + session.setDiagnosticsListener(extras.parser::accept); + } + if (isParameterAssistanceEnabled()) { + extras.popup = new SignaturePopup(textArea, provider::signatureHelp); + extras.popup.install(); + } + + // Keep the server's copy of the script up to date: opened now (which + // lets the servers get ready for it), changed with each edit. + extras.listener = new DocumentListener() { + + @Override + public void insertUpdate(final DocumentEvent e) { + changed(); + } + + @Override + public void removeUpdate(final DocumentEvent e) { + changed(); + } + + @Override + public void changedUpdate(final DocumentEvent e) { + // NB: Attributes only. + } + + private void changed() { + try { + session.update(textArea.getText()); + } + catch (final Exception | LinkageError exc) { + // NB: Never let a misbehaving server break the editor. + if (log != null) log.debug("Document sync failed", exc); + } + } + }; + textArea.getDocument().addDocumentListener(extras.listener); + installed.put(textArea, extras); + try { + session.update(textArea.getText()); + } + catch (final Exception | LinkageError exc) { + if (log != null) log.debug("Document sync failed", exc); + } + } + + @Override + public void uninstall(final RSyntaxTextArea textArea) { + uninstallImpl(textArea); + final Installed extras = installed.remove(textArea); + if (extras == null) return; + textArea.getDocument().removeDocumentListener(extras.listener); + if (textArea.getToolTipSupplier() instanceof HoverToolTipSupplier) { + textArea.setToolTipSupplier(null); + } + if (extras.parser != null) textArea.removeParser(extras.parser); + if (extras.popup != null) extras.popup.uninstall(); + // Let the servers release what they keep for this script. + try { + extras.session.close(); + } + catch (final Exception | LinkageError exc) { + if (log != null) log.debug("Document close failed", exc); + } + } + + private static final class Installed { + + private final ScriptSession session; + private DiagnosticsParser parser; + private SignaturePopup popup; + private DocumentListener listener; + + private Installed(final ScriptSession session) { + this.session = session; + } + } +} diff --git a/src/main/java/org/scijava/ui/swing/script/autocompletion/SciJavaAutoCompletion.java b/src/main/java/org/scijava/ui/swing/script/autocompletion/SciJavaAutoCompletion.java new file mode 100644 index 00000000..6f0b2a2f --- /dev/null +++ b/src/main/java/org/scijava/ui/swing/script/autocompletion/SciJavaAutoCompletion.java @@ -0,0 +1,133 @@ +/* + * #%L + * Script Editor and Interpreter for SciJava script languages. + * %% + * Copyright (C) 2009 - 2026 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.scijava.ui.swing.script.autocompletion; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import javax.swing.text.BadLocationException; +import javax.swing.text.JTextComponent; + +import org.fife.ui.autocomplete.Completion; +import org.fife.ui.autocomplete.CompletionProvider; +import org.fife.ui.rtextarea.RTextArea; +import org.scijava.ui.swing.script.autocompletion.AdditionalEdits.Edit; + +/** + * An {@link org.fife.ui.autocomplete.AutoCompletion} that, in addition to + * inserting the chosen completion, applies any additional edits the + * completion carries (see {@link SciJavaCompletion}). This is how + * language-neutral auto-imports are realized in the Swing editor. + * + * @author Curtis Rueden + */ +public class SciJavaAutoCompletion extends + org.fife.ui.autocomplete.AutoCompletion +{ + + public SciJavaAutoCompletion(final CompletionProvider provider) { + super(provider); + // Expand threshold to describe other overloads + // (2000 is just a magic number) + setParameterDescriptionTruncateThreshold(2000); + if (provider instanceof SciJavaCompletionProvider) { + ((SciJavaCompletionProvider) provider).setUpdateListener( + this::refreshPopup); + } + } + + /** + * Shows the current (just improved) completions: in the popup, if it is + * showing, or in a new one, if the previous result had nothing to show. + */ + private void refreshPopup(final boolean previousWasEmpty) { + final JTextComponent comp = getTextComponent(); + if (comp == null || !comp.isFocusOwner()) return; + if (!isPopupVisible() && !previousWasEmpty) return; // NB: User closed it. + // NB: Never insert a lone completion the user has not chosen. + final boolean single = getAutoCompleteSingleChoices(); + setAutoCompleteSingleChoices(false); + try { + doCompletion(); + } + finally { + setAutoCompleteSingleChoices(single); + } + } + + @Override + protected void insertCompletion(final Completion c, + final boolean typedParamListStartChar) + { + if (!(c instanceof AdditionalEdits)) { + super.insertCompletion(c, typedParamListStartChar); + return; + } + final List<Edit> edits = ((AdditionalEdits) c).getAdditionalEdits(); + if (edits.isEmpty()) { + super.insertCompletion(c, typedParamListStartChar); + return; + } + final JTextComponent comp = getTextComponent(); + final RTextArea area = comp instanceof RTextArea ? (RTextArea) comp : null; + + if (area != null) area.beginAtomicEdit(); + try { + // Insert the primary completion first (at the completion point). + super.insertCompletion(c, typedParamListStartChar); + + // Then apply additional edits. They lie before the completion point, + // so applying them from highest to lowest offset keeps each offset + // valid, and the caret (tracked by the document) shifts accordingly. + final List<Edit> sorted = new ArrayList<>(edits); + sorted.sort(Comparator.comparingInt(Edit::start).reversed()); + for (final Edit edit : sorted) { + applyEdit(comp, edit); + } + } + finally { + if (area != null) area.endAtomicEdit(); + } + } + + private void applyEdit(final JTextComponent comp, final Edit edit) { + try { + final int len = edit.end() - edit.start(); + if (len > 0) comp.getDocument().remove(edit.start(), len); + if (edit.newText() != null && !edit.newText().isEmpty()) { + comp.getDocument().insertString(edit.start(), edit.newText(), null); + } + } + catch (final BadLocationException exc) { + // Document changed underneath us; skip this edit rather than fail. + } + } +} diff --git a/src/main/java/org/scijava/ui/swing/script/autocompletion/SciJavaCompletion.java b/src/main/java/org/scijava/ui/swing/script/autocompletion/SciJavaCompletion.java new file mode 100644 index 00000000..b91bd5c3 --- /dev/null +++ b/src/main/java/org/scijava/ui/swing/script/autocompletion/SciJavaCompletion.java @@ -0,0 +1,82 @@ +/* + * #%L + * Script Editor and Interpreter for SciJava script languages. + * %% + * Copyright (C) 2009 - 2026 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.scijava.ui.swing.script.autocompletion; + +import java.util.List; +import java.util.function.Supplier; + +import org.fife.ui.autocomplete.BasicCompletion; +import org.fife.ui.autocomplete.CompletionProvider; + +/** + * An RSTA {@link BasicCompletion} that also carries additional edits to apply + * on acceptance (e.g. auto-imports). The edits are applied by + * {@link SciJavaAutoCompletion}. + * + * @author Curtis Rueden + */ +public class SciJavaCompletion extends BasicCompletion implements + AdditionalEdits +{ + + private final List<Edit> additionalEdits; + private final Supplier<String> description; + + public SciJavaCompletion(final CompletionProvider provider, + final String replacementText, final String shortDesc, final String summary, + final List<Edit> additionalEdits) + { + this(provider, replacementText, shortDesc, () -> summary, + additionalEdits); + } + + /** + * @param description Supplies the description shown beside the completion + * list, asked for only when the completion is selected. + */ + public SciJavaCompletion(final CompletionProvider provider, + final String replacementText, final String shortDesc, + final Supplier<String> description, final List<Edit> additionalEdits) + { + super(provider, replacementText, shortDesc, null); + this.description = description; + this.additionalEdits = additionalEdits; + } + + @Override + public String getSummary() { + return description == null ? null : description.get(); + } + + @Override + public List<Edit> getAdditionalEdits() { + return additionalEdits; + } +} diff --git a/src/main/java/org/scijava/ui/swing/script/autocompletion/SciJavaCompletionProvider.java b/src/main/java/org/scijava/ui/swing/script/autocompletion/SciJavaCompletionProvider.java new file mode 100644 index 00000000..778fedfb --- /dev/null +++ b/src/main/java/org/scijava/ui/swing/script/autocompletion/SciJavaCompletionProvider.java @@ -0,0 +1,623 @@ +/* + * #%L + * Script Editor and Interpreter for SciJava script languages. + * %% + * Copyright (C) 2009 - 2026 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.scijava.ui.swing.script.autocompletion; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; +import java.util.function.Supplier; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.swing.SwingUtilities; +import javax.swing.text.BadLocationException; +import javax.swing.text.JTextComponent; + +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionItemKind; +import org.eclipse.lsp4j.CompletionList; +import org.eclipse.lsp4j.InsertTextFormat; +import org.eclipse.lsp4j.MarkupContent; +import org.eclipse.lsp4j.MarkupKind; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.SignatureHelp; +import org.eclipse.lsp4j.SignatureInformation; +import org.eclipse.lsp4j.TextEdit; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import org.fife.ui.autocomplete.BasicCompletion; +import org.fife.ui.autocomplete.Completion; +import org.fife.ui.autocomplete.CompletionCellRenderer; +import org.fife.ui.autocomplete.DefaultCompletionProvider; +import org.fife.ui.autocomplete.ParameterizedCompletion; +import org.scijava.code.lsp.Positions; +import org.scijava.code.lsp.ScriptSession; +import org.scijava.log.Logger; + +/** + * The bridge between a language server (usually one standing for all of a + * script language's, see {@code org.scijava.code.lsp.LanguageServerService}) + * and RSyntaxTextArea's completion machinery. + * <p> + * Completions with a snippet of parameters (e.g. {@code max(${1:a}, + * ${2:b})}) become function completions, for parameter assistance; their + * parameters' types come from the label details (e.g. {@code (double a, + * double b)}). The parameter tooltip lists the other overloads, in signature + * help's order. Documentation is resolved when shown. Completions slower than + * a moment are shown as they arrive. + * </p> + * + * @author Curtis Rueden + * @author Gabriel Selzer + */ +public class SciJavaCompletionProvider extends DefaultCompletionProvider { + + /** How long to wait for completions, in milliseconds. */ + private static final long REQUEST_TIMEOUT = 2000; + + /** + * How long the editor waits for completions as the user types, in + * milliseconds; those slower are shown as they arrive. + */ + private static final long ANSWER_WAIT = 50; + + /** How long the parameter tooltip waits for signature help, in ms. */ + private static final long HELP_BUDGET = 150; + + /** How long to wait for documentation, in milliseconds. */ + private static final long DESCRIBE_TIMEOUT = 1000; + + /** How many choices to offer for a parameter, at most. */ + private static final int CHOICES = 10; + + /** A snippet placeholder: ${1:name}, ${1} or $1. */ + private static final Pattern PLACEHOLDER = Pattern.compile( + "\\$\\{(\\d+)(?::((?:[^}\\\\]|\\\\.)*))?\\}|\\$(\\d+)"); + + private final ScriptSession session; + + /** Optional logger, for reporting server failures. */ + private Logger log; + + // Cache so getAlreadyEnteredText() and getCompletionsImpl(), which RSTA + // calls in quick succession for the same caret, stay consistent. + private int cachedCaret = -1; + private String cachedText; + private CompletionList cachedList; + private int cachedStart; + + // The signature help for the current text and caret. + private String helpText; + private int helpCaret = -1; + private volatile SignatureHelp help; + + /** Notified when better completions arrive for the current request. */ + private volatile Consumer<Boolean> updateListener; + + /** @param session Where to ask about the script being edited. */ + public SciJavaCompletionProvider(final ScriptSession session) { + this.session = session; + setParameterizedCompletionParams('(', ", ", ')'); + // Show each callable's parameters and return type in the popup list. + setListCellRenderer(new CompletionCellRenderer()); + // Auto-activate after a letter, digit, '.' or '_'. + setAutoActivationRules(true, "."); + setParameterChoicesProvider(this::parameterChoices); + } + + /** Where this asks about the script being edited. */ + public ScriptSession session() { + return session; + } + + /** Sets a logger with which to report server failures. */ + public void setLogger(final Logger log) { + this.log = log; + } + + @Override + public boolean isValidChar(final char c) { + return Character.isLetterOrDigit(c) || c == '.' || c == '_'; + } + + @Override + public String getAlreadyEnteredText(final JTextComponent comp) { + compute(comp); + final int caret = comp.getCaretPosition(); + final int start = Math.min(Math.max(cachedStart, 0), caret); + try { + return comp.getText(start, caret - start); + } + catch (final BadLocationException exc) { + return ""; + } + } + + @Override + public List<Completion> getCompletionsImpl(final JTextComponent comp) { + compute(comp); + return convert(comp, cachedList, cachedText, cachedCaret, cachedStart); + } + + /** + * Sets a listener to notify when completions have arrived late for the + * current request, e.g. to refresh the completion popup. Called on the + * event dispatch thread. + * + * @param listener Accepts whether the previous completions were none (so + * that no popup may be showing for them). + */ + public void setUpdateListener(final Consumer<Boolean> listener) { + this.updateListener = listener; + } + + /** + * Gets choices for a parameter being filled in: what the server completes + * there, variables first (the server ranks those fitting best first). + */ + List<Completion> parameterChoices(final JTextComponent comp, + final ParameterizedCompletion.Parameter param) + { + final String text = comp.getText(); + final CompletionList list = request(text, comp.getCaretPosition()); + final List<Completion> out = new ArrayList<>(); + for (final CompletionItem item : sorted(list)) { + final CompletionItemKind k = item.getKind(); + if (k != null && k != CompletionItemKind.Variable && + k != CompletionItemKind.Field && k != CompletionItemKind.Constant && + k != CompletionItemKind.Value) continue; + final String name = plain(newText(item)); + out.add(new BasicCompletion(this, name, item.getDetail())); + if (out.size() >= CHOICES) break; + } + return out; + } + + /** + * Gets signature help at the caret, waiting briefly. Remembered per text + * and caret; an answer arriving late is used when asked again. + */ + SignatureHelp signatureHelp(final JTextComponent comp) { + final String text; + try { + text = comp.getText(0, comp.getDocument().getLength()); + } + catch (final BadLocationException exc) { + return null; + } + final int caret = comp.getCaretPosition(); + if (caret == helpCaret && text.equals(helpText) && help != null) { + return help; + } + SignatureHelp h = null; + try { + final CompletableFuture<SignatureHelp> answer = session.signatureHelp( + text, caret); + try { + h = answer.get(HELP_BUDGET, TimeUnit.MILLISECONDS); + } + catch (final TimeoutException exc) { + // NB: Not in time: keep it for when RSTA asks again. + answer.thenAccept(late -> { + if (late != null && text.equals(helpText) && caret == helpCaret) { + help = late; + } + }); + } + } + catch (final Exception | LinkageError exc) { + // NB: Never let a misbehaving server break the editor. + if (log != null) log.debug("Signature help failed", exc); + } + helpText = text; + helpCaret = caret; + help = h; + return h; + } + + /** + * Lists the parameters of a callable's other overloads as HTML, one + * overload per line, for RSTA's parameter tooltip; or returns null if there + * are none. + * <p> + * If the signature help is for this call, its signatures are listed in its + * order (servers list those fitting the arguments typed so far first). + * Otherwise, {@code overloads} are listed. + * </p> + * + * @param name The callable's name. + * @param own Its parameters (e.g. {@code double a}). + * @param overloads The parameters of its overloads in the completions. + * @param help The signature help at the caret, or null. + */ + static String otherOverloads(final String name, final List<String> own, + final List<List<String>> overloads, final SignatureHelp help) + { + final String ownList = parameterList(own); + // NB: A set, since reflection can report the same signature twice. + final Set<String> lines = new LinkedHashSet<>(); + if (help != null && isFor(help, name)) { + for (final SignatureInformation s : help.getSignatures()) { + lines.add(parameterList(SignatureLabel.of(s).parameters)); + } + } + else if (overloads != null) { + for (final List<String> o : overloads) lines.add(parameterList(o)); + } + lines.remove(ownList); + if (lines.isEmpty()) return null; + final StringBuilder sb = new StringBuilder(); + for (final String line : lines) { + // NB: A rule between overloads (and after RSTA's own line). + sb.append("<hr>").append(escapeHTML(line)); + } + return sb.toString(); + } + + /** Converts documentation to HTML: Markdown as is (it allows HTML). */ + static String html(final Either<String, MarkupContent> doc) { + if (doc == null) return null; + final String text; + if (doc.isLeft()) text = "<pre>" + escapeHTML(doc.getLeft()) + "</pre>"; + else if (MarkupKind.MARKDOWN.equals(doc.getRight().getKind())) { + text = doc.getRight().getValue(); + } + else text = "<pre>" + escapeHTML(doc.getRight().getValue()) + "</pre>"; + return text == null || text.isEmpty() || text.equals("<pre></pre>") ? null + : text; + } + + // -- Helper methods -- + + private void compute(final JTextComponent comp) { + final int caret = comp.getCaretPosition(); + final String text; + try { + text = comp.getText(0, comp.getDocument().getLength()); + } + catch (final BadLocationException exc) { + return; + } + if (caret == cachedCaret && text.equals(cachedText) && cachedList != null) { + return; + } + final CompletableFuture<CompletionList> answer = ask(text, caret); + cachedCaret = caret; + cachedText = text; + final CompletionList now = await(answer, ANSWER_WAIT); + adopt(now == null ? new CompletionList() : now); + if (now != null) return; + // NB: Not in time: show the completions when they arrive, unless + // anything has changed since the request. + final CompletionList pending = cachedList; + answer.thenAccept(later -> SwingUtilities.invokeLater(() -> { + if (cachedList != pending || caret != comp.getCaretPosition() || !text + .equals(comp.getText())) return; + adopt(later); + final Consumer<Boolean> listener = updateListener; + if (listener != null) listener.accept(true); + })); + } + + /** Asks the server for completions, waiting for them; none if it fails. */ + private CompletionList request(final String text, final int caret) { + final CompletionList list = await(ask(text, caret), REQUEST_TIMEOUT); + return list == null ? new CompletionList() : list; + } + + /** Asks the server for completions; none if it fails. */ + private CompletableFuture<CompletionList> ask(final String text, + final int caret) + { + try { + return session.completion(text, caret).exceptionally(exc -> { + failed(exc); + return new CompletionList(); + }); + } + catch (final Exception | LinkageError exc) { + failed(exc); + return CompletableFuture.completedFuture(new CompletionList()); + } + } + + /** Waits a while for completions; null if they are not done by then. */ + private static CompletionList await( + final CompletableFuture<CompletionList> answer, final long timeout) + { + try { + return answer.get(timeout, TimeUnit.MILLISECONDS); + } + catch (final Exception exc) { + return null; + } + } + + private void failed(final Throwable exc) { + // NB: Never let a misbehaving server break the editor. + if (log != null) log.debug("Code completion failed", exc); + } + + /** Makes the given completions current. */ + private void adopt(final CompletionList list) { + cachedList = list; + cachedStart = replaceStart(list, cachedText, cachedCaret); + } + + /** + * Where the completions' replacement starts: the earliest of their edits' + * starts (completions without an edit replace the word before the caret). + */ + private static int replaceStart(final CompletionList list, final String text, + final int caret) + { + int start = caret; + for (final CompletionItem item : items(list)) { + start = Math.min(start, itemStart(item, text, caret)); + } + return start; + } + + private static int itemStart(final CompletionItem item, final String text, + final int caret) + { + if (item.getTextEdit() != null) { + final Range r = item.getTextEdit().isLeft() ? item.getTextEdit() + .getLeft().getRange() : item.getTextEdit().getRight().getInsert(); + return Math.min(caret, Positions.offset(text, r.getStart())); + } + int start = Math.min(caret, text.length()); + while (start > 0 && Character.isJavaIdentifierPart(text.charAt(start - + 1))) start--; + return start; + } + + /** Converts the server's completions to RSTA's. */ + private List<Completion> convert(final JTextComponent comp, + final CompletionList list, final String text, final int caret, + final int start) + { + final List<CompletionItem> items = sorted(list); + // Group callables by name, so each can list its sibling overloads. + final Map<String, List<List<String>>> overloads = new HashMap<>(); + final List<Object[]> converted = new ArrayList<>(); + for (final CompletionItem item : items) { + final String prefix = text.substring(start, Math.max(start, itemStart( + item, text, caret))); + final String inserted = newText(item); + final boolean callable = isSnippet(item) && inserted.indexOf('(') > 0; + if (callable) { + final String name = prefix + inserted.substring(0, inserted.indexOf( + '(')); + final List<String> params = parameters(item, inserted); + overloads.computeIfAbsent(name, k -> new ArrayList<>()).add(params); + converted.add(new Object[] { item, name, params }); + } + else converted.add(new Object[] { item, prefix + plain(inserted), null }); + } + final int n = converted.size(); + final List<Completion> out = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + final CompletionItem item = (CompletionItem) converted.get(i)[0]; + final String name = (String) converted.get(i)[1]; + @SuppressWarnings("unchecked") + final List<String> params = (List<String>) converted.get(i)[2]; + final Supplier<String> description = () -> describe(item); + final List<AdditionalEdits.Edit> edits = edits(item, + text); + final Completion c; + if (params != null) { + final String returnType = item.getLabelDetails() == null ? null : item + .getLabelDetails().getDescription(); + final SciJavaFunctionCompletion fc = new SciJavaFunctionCompletion(this, + name, returnType == null ? "" : returnType, edits); + fc.setReturnValueDescription(returnType == null ? "" : returnType); + fc.setShortDescription(item.getDetail()); + fc.setDescription(description); + fc.setParams(rstaParams(comp, name, params, overloads.get(name))); + fc.setRelevance(n - i); + c = fc; + } + else { + final SciJavaCompletion sc = new SciJavaCompletion(this, name, item + .getDetail(), description, edits); + sc.setRelevance(n - i); + c = sc; + } + out.add(c); + } + return out; + } + + /** + * Converts parameters (e.g. {@code double a}) to RSTA's. RSTA's parameter + * tooltip shows the current parameter's description, as HTML, below the + * signature: there, we list the other overloads. + */ + private List<ParameterizedCompletion.Parameter> rstaParams( + final JTextComponent comp, final String name, final List<String> params, + final List<List<String>> overloads) + { + final List<ParameterizedCompletion.Parameter> out = new ArrayList<>(); + for (final String p : params) { + final String[] typeAndName = SignatureLabel.typeAndName(p); + out.add(new ParameterizedCompletion.Parameter(typeAndName[0], + typeAndName[1]) + { + + /** + * Computed when RSTA asks, so the list reflects the arguments typed + * so far. RSTA asks when the tooltip opens (before the argument list + * is inserted) and whenever the caret moves to another parameter. + */ + @Override + public String getDescription() { + return otherOverloads(name, params, overloads, signatureHelp(comp)); + } + }); + } + return out; + } + + /** + * A callable's parameters: from its label details (typed, e.g. + * {@code (double a, double b)}), if they agree with its snippet's + * placeholders; otherwise the placeholders' names. + */ + private static List<String> parameters(final CompletionItem item, + final String snippet) + { + final List<String> names = new ArrayList<>(); + final Matcher m = PLACEHOLDER.matcher(snippet); + while (m.find()) { + final String name = m.group(2) != null ? m.group(2) : "arg" + names + .size(); + names.add(unescape(name)); + } + final String detail = item.getLabelDetails() == null ? null : item + .getLabelDetails().getDetail(); + if (detail != null) { + final List<String> typed = SignatureLabel.parameters(detail); + if (typed.size() == names.size()) return typed; + } + return names; + } + + /** Gets an item's documentation (resolving it first), as HTML. */ + private String describe(final CompletionItem item) { + if (item.getDocumentation() != null) return html(item.getDocumentation()); + try { + final CompletionItem resolved = session.resolve(item).get( + DESCRIBE_TIMEOUT, TimeUnit.MILLISECONDS); + return resolved == null ? null : html(resolved.getDocumentation()); + } + catch (final Exception exc) { + return null; // NB: Not in time, or failed. + } + } + + private static List<AdditionalEdits.Edit> edits( + final CompletionItem item, final String text) + { + if (item.getAdditionalTextEdits() == null) return Collections.emptyList(); + final List<AdditionalEdits.Edit> out = + new ArrayList<>(); + for (final TextEdit e : item.getAdditionalTextEdits()) { + out.add(new AdditionalEdits.Edit(Positions.offset(text, e.getRange() + .getStart()), Positions.offset(text, e.getRange().getEnd()), e + .getNewText())); + } + return out; + } + + private static List<CompletionItem> items(final CompletionList list) { + return list == null || list.getItems() == null ? Collections.emptyList() + : list.getItems(); + } + + /** The items in the server's order (by sortText, or else label). */ + private static List<CompletionItem> sorted(final CompletionList list) { + final List<CompletionItem> items = new ArrayList<>(items(list)); + items.sort(Comparator.comparing(i -> i.getSortText() != null ? i + .getSortText() : i.getLabel())); + return items; + } + + private static String newText(final CompletionItem item) { + if (item.getTextEdit() != null) { + return item.getTextEdit().isLeft() ? item.getTextEdit().getLeft() + .getNewText() : item.getTextEdit().getRight().getNewText(); + } + return item.getInsertText() != null ? item.getInsertText() : item + .getLabel(); + } + + private static boolean isSnippet(final CompletionItem item) { + return item.getInsertTextFormat() == InsertTextFormat.Snippet; + } + + /** A snippet's text without its placeholders' markup. */ + private static String plain(final String snippet) { + final Matcher m = PLACEHOLDER.matcher(snippet); + final StringBuffer sb = new StringBuffer(); + while (m.find()) { + m.appendReplacement(sb, Matcher.quoteReplacement(m.group(2) == null ? "" + : unescape(m.group(2)))); + } + m.appendTail(sb); + return sb.toString(); + } + + private static String unescape(final String s) { + return s.replaceAll("\\\\(.)", "$1"); + } + + /** + * True iff the help is about calls to the named callable, rather than, + * e.g., a call nested in its arguments. + */ + private static boolean isFor(final SignatureHelp help, final String name) { + if (help.getSignatures() == null || help.getSignatures().isEmpty()) { + return false; + } + final String simple = name.substring(name.lastIndexOf('.') + 1); + for (final SignatureInformation s : help.getSignatures()) { + final String n = SignatureLabel.of(s).name; + if (!n.substring(n.lastIndexOf('.') + 1).equals(simple)) return false; + } + return true; + } + + /** + * A short parameter list such as {@code long[] pos, int d}, or {@code ()} if + * there are no parameters. + */ + private static String parameterList(final List<String> params) { + if (params.isEmpty()) return "()"; + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < params.size(); i++) { + if (i > 0) sb.append(", "); + sb.append(SignatureLabel.simple(params.get(i))); + } + return sb.toString(); + } + + private static String escapeHTML(final String s) { + return s.replace("&", "&").replace("<", "<").replace(">", ">"); + } +} diff --git a/src/main/java/org/scijava/ui/swing/script/autocompletion/SciJavaFunctionCompletion.java b/src/main/java/org/scijava/ui/swing/script/autocompletion/SciJavaFunctionCompletion.java new file mode 100644 index 00000000..610f5602 --- /dev/null +++ b/src/main/java/org/scijava/ui/swing/script/autocompletion/SciJavaFunctionCompletion.java @@ -0,0 +1,120 @@ +/* + * #%L + * Script Editor and Interpreter for SciJava script languages. + * %% + * Copyright (C) 2009 - 2026 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.scijava.ui.swing.script.autocompletion; + +import java.util.Collections; +import java.util.List; +import java.util.function.Supplier; + +import org.fife.ui.autocomplete.CompletionProvider; +import org.fife.ui.autocomplete.FunctionCompletion; + +/** + * An RSTA {@link FunctionCompletion} for callable completions (methods, + * functions, constructors), so parameter assistance and parameter choices + * engage. It also carries optional additional edits (e.g. the auto-import + * needed by a constructor completion), applied by + * {@link SciJavaAutoCompletion}. + * + * @author Curtis Rueden + */ +public class SciJavaFunctionCompletion extends FunctionCompletion implements + AdditionalEdits +{ + + private final List<Edit> additionalEdits; + private Supplier<String> description; + + public SciJavaFunctionCompletion(final CompletionProvider provider, + final String name, final String returnType, + final List<Edit> additionalEdits) + { + super(provider, name, returnType); + this.additionalEdits = additionalEdits == null ? Collections.emptyList() + : additionalEdits; + } + + @Override + public List<Edit> getAdditionalEdits() { + return additionalEdits; + } + + /** + * Sets a supplier of the description (e.g. documentation) shown beside the + * completion list, asked for only when the completion is selected. + */ + public void setDescription(final Supplier<String> description) { + this.description = description; + } + + /** + * Adds the description, if any, below RSTA's short description. (RSTA's + * {@link FunctionCompletion#getSummary} would not show it otherwise.) + */ + @Override + protected boolean possiblyAddDescription(final StringBuilder sb) { + final boolean added = super.possiblyAddDescription(sb); + final String desc = description == null ? null : description.get(); + if (desc == null || desc.isEmpty()) return added; + if (!added) sb.append("<hr><br>"); + sb.append(desc).append("<br><br><br>"); + return true; + } + + /** + * As {@link FunctionCompletion#addParameters}, which fills in the side + * description window, but without the parameter descriptions: those list + * the other overloads, for the parameter tooltip only (see + * {@link SciJavaCompletionProvider#otherOverloads}). + */ + @Override + protected void addParameters(final StringBuilder sb) { + final int count = getParamCount(); + if (count > 0) { + sb.append("<b>Parameters:</b><br>"); + sb.append("<center><table width='90%'><tr><td>"); + for (int i = 0; i < count; i++) { + final Parameter param = getParam(i); + sb.append("<b>"); + sb.append(param.getName() != null ? param.getName() : param + .getType()); + sb.append("</b><br>"); + } + sb.append("</td></tr></table></center><br><br>"); + } + final String returnDesc = getReturnValueDescription(); + if (returnDesc != null) { + sb.append("<b>Returns:</b><br>"); + sb.append("<center><table width='90%'><tr><td>"); + sb.append(returnDesc); + sb.append("</td></tr></table></center><br><br>"); + } + } +} diff --git a/src/main/java/org/scijava/ui/swing/script/autocompletion/SignatureLabel.java b/src/main/java/org/scijava/ui/swing/script/autocompletion/SignatureLabel.java new file mode 100644 index 00000000..90f92e9c --- /dev/null +++ b/src/main/java/org/scijava/ui/swing/script/autocompletion/SignatureLabel.java @@ -0,0 +1,144 @@ +/* + * #%L + * Script Editor and Interpreter for SciJava script languages. + * %% + * Copyright (C) 2009 - 2026 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.scijava.ui.swing.script.autocompletion; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.eclipse.lsp4j.ParameterInformation; +import org.eclipse.lsp4j.SignatureInformation; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import org.eclipse.lsp4j.jsonrpc.messages.Tuple; + +/** + * A callable's signature as the editor shows it, read from a server's text: + * its name, its parameters (e.g. {@code double a}) and its return type; from + * a {@link SignatureInformation} (e.g. {@code max(double a, double b) -> + * double}) or a completion's label details (e.g. {@code (double a, double b)}). + * + * @author Gabriel Selzer + */ +final class SignatureLabel { + + final String name; + final List<String> parameters; + final String returnType; + + private SignatureLabel(final String name, final List<String> parameters, + final String returnType) + { + this.name = name; + this.parameters = parameters; + this.returnType = returnType; + } + + /** Reads a signature (e.g. {@code def max(a, b) -> int}). */ + static SignatureLabel of(final SignatureInformation s) { + final String label = s.getLabel(); + final int open = label.indexOf('('); + final String head = (open < 0 ? label : label.substring(0, open)).trim(); + final String name = head.substring(head.lastIndexOf(' ') + 1); + final List<String> params = new ArrayList<>(); + if (s.getParameters() != null && !s.getParameters().isEmpty()) { + for (final ParameterInformation p : s.getParameters()) { + params.add(label(label, p.getLabel())); + } + } + else if (open >= 0) params.addAll(parameters(label.substring(open))); + final int close = open < 0 ? -1 : closing(label, open); + String returns = close < 0 ? "" : label.substring(close + 1).trim(); + if (returns.startsWith("->")) returns = returns.substring(2).trim(); + else if (returns.startsWith(":")) returns = returns.substring(1).trim(); + return new SignatureLabel(name, params, returns.isEmpty() ? null : returns); + } + + /** + * Reads a parameter list, e.g. {@code (double a, Map<K, V> m)}: its + * parameters, split at the commas outside brackets. + */ + static List<String> parameters(final String list) { + final String s = list.trim(); + if (!s.startsWith("(")) return Collections.emptyList(); + final int close = closing(s, 0); + final String inner = s.substring(1, close < 0 ? s.length() : close); + final List<String> out = new ArrayList<>(); + int depth = 0, start = 0; + for (int i = 0; i < inner.length(); i++) { + final char c = inner.charAt(i); + if (c == '(' || c == '[' || c == '{' || c == '<') depth++; + else if (c == ')' || c == ']' || c == '}' || c == '>') depth--; + else if (c == ',' && depth == 0) { + out.add(inner.substring(start, i).trim()); + start = i + 1; + } + } + final String last = inner.substring(start).trim(); + if (!last.isEmpty() || !out.isEmpty()) out.add(last); + return out; + } + + /** + * A parameter (e.g. {@code java.lang.String name}) with simple type names + * ({@code String name}). + */ + static String simple(final String parameter) { + final int space = parameter.lastIndexOf(' '); + if (space < 0) return parameter; + final String type = parameter.substring(0, space); + return type.substring(type.lastIndexOf('.') + 1) + parameter.substring( + space); + } + + /** Splits a parameter into its type (or null) and its name. */ + static String[] typeAndName(final String parameter) { + final int space = parameter.lastIndexOf(' '); + return space < 0 ? new String[] { null, parameter } : new String[] { + parameter.substring(0, space).trim(), parameter.substring(space + 1) }; + } + + private static String label(final String signature, + final Either<String, Tuple.Two<Integer, Integer>> label) + { + if (label.isLeft()) return label.getLeft(); + return signature.substring(label.getRight().getFirst(), label.getRight() + .getSecond()); + } + + private static int closing(final String s, final int open) { + int depth = 0; + for (int i = open; i < s.length(); i++) { + final char c = s.charAt(i); + if (c == '(') depth++; + else if (c == ')' && --depth == 0) return i; + } + return -1; + } +} diff --git a/src/main/java/org/scijava/ui/swing/script/autocompletion/SignaturePopup.java b/src/main/java/org/scijava/ui/swing/script/autocompletion/SignaturePopup.java new file mode 100644 index 00000000..06082995 --- /dev/null +++ b/src/main/java/org/scijava/ui/swing/script/autocompletion/SignaturePopup.java @@ -0,0 +1,243 @@ +/* + * #%L + * Script Editor and Interpreter for SciJava script languages. + * %% + * Copyright (C) 2009 - 2026 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.scijava.ui.swing.script.autocompletion; + +import java.awt.Color; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.Window; +import java.awt.event.FocusAdapter; +import java.awt.event.FocusEvent; +import java.awt.event.FocusListener; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import java.awt.event.KeyListener; +import java.awt.geom.Rectangle2D; +import java.util.List; +import java.util.function.Function; + +import javax.swing.BorderFactory; +import javax.swing.JLabel; +import javax.swing.JWindow; +import javax.swing.SwingUtilities; +import javax.swing.Timer; +import javax.swing.UIManager; +import javax.swing.event.CaretListener; +import javax.swing.text.BadLocationException; +import javax.swing.text.JTextComponent; + +import org.eclipse.lsp4j.SignatureHelp; +import org.eclipse.lsp4j.SignatureInformation; +import org.scijava.code.lsp.jvm.Signatures; + +/** + * Shows the signatures of the call being typed (the language server's + * signature help), e.g. all + * overloads of {@code max} once {@code Math.max(} is typed: best fits first, + * the parameter being typed in bold. It follows the caret through the call, + * and closes once the caret leaves it, or on Escape. + * <p> + * (Accepting a completion shows RSyntaxTextArea's own parameter assistance + * instead; this popup is for calls typed by hand.) + * </p> + * + * @author Gabriel Selzer + */ +public class SignaturePopup { + + /** How long to wait before asking again for slow help, in milliseconds. */ + private static final int RETRY_DELAY = 400; + + private final JTextComponent textArea; + private final Function<JTextComponent, SignatureHelp> help; + private final KeyListener keys; + private final CaretListener caret; + private final FocusListener focus; + private final Timer retry; + private JWindow window; + private JLabel label; + + /** + * @param textArea Where calls are typed. + * @param help Gets the signature help at the caret. + */ + public SignaturePopup(final JTextComponent textArea, + final Function<JTextComponent, SignatureHelp> help) + { + this.textArea = textArea; + this.help = help; + retry = new Timer(RETRY_DELAY, e -> refresh(false)); + retry.setRepeats(false); + keys = new KeyAdapter() { + + @Override + public void keyTyped(final KeyEvent e) { + final char c = e.getKeyChar(); + // NB: After the character is inserted. + if (c == '(') SwingUtilities.invokeLater(() -> refresh(true)); + } + + @Override + public void keyPressed(final KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_ESCAPE && isShowing()) hide(); + } + }; + caret = e -> { + if (isShowing()) SwingUtilities.invokeLater(() -> refresh(false)); + }; + focus = new FocusAdapter() { + + @Override + public void focusLost(final FocusEvent e) { + hide(); + } + }; + } + + /** Starts following the typing. */ + public void install() { + textArea.addKeyListener(keys); + textArea.addCaretListener(caret); + textArea.addFocusListener(focus); + } + + /** Stops following the typing. */ + public void uninstall() { + textArea.removeKeyListener(keys); + textArea.removeCaretListener(caret); + textArea.removeFocusListener(focus); + retry.stop(); + hide(); + if (window != null) window.dispose(); + window = null; + } + + public boolean isShowing() { + return window != null && window.isVisible(); + } + + public void hide() { + if (window != null) window.setVisible(false); + } + + /** + * Shows the help at the caret, or hides the popup if there is none. + * + * @param opening Whether a call was just opened (so that slow help is + * asked for again, shortly). + */ + void refresh(final boolean opening) { + if (!textArea.isShowing()) return; + final SignatureHelp h = help.apply(textArea); + if (h == null || h.getSignatures() == null || h.getSignatures() + .isEmpty()) + { + hide(); + if (opening) retry.restart(); + return; + } + show(html(h), Signatures.callStart(textArea.getText(), textArea + .getCaretPosition())); + } + + /** Renders signatures as HTML, one per line, in the server's order. */ + static String html(final SignatureHelp help) { + final StringBuilder sb = new StringBuilder("<html>"); + boolean first = true; + for (final SignatureInformation s : help.getSignatures()) { + if (!first) sb.append("<br>"); + first = false; + final Integer active = s.getActiveParameter() != null ? s + .getActiveParameter() : help.getActiveParameter(); + sb.append(signature(SignatureLabel.of(s), active == null ? -1 + : active)); + } + return sb.append("</html>").toString(); + } + + /** Renders one signature, e.g. {@code max(double a, <b>double b</b>)}. */ + private static String signature(final SignatureLabel s, final int active) { + final StringBuilder sb = new StringBuilder(escape(s.name)).append('('); + final List<String> params = s.parameters; + for (int i = 0; i < params.size(); i++) { + if (i > 0) sb.append(", "); + final String text = escape(SignatureLabel.simple(params.get(i))); + sb.append(i == active ? "<b>" + text + "</b>" : text); + } + sb.append(')'); + if (s.returnType != null) sb.append(" \u2192 ").append(escape( + s.returnType)); + return sb.toString(); + } + + private void show(final String html, final int callStart) { + final Window owner = SwingUtilities.getWindowAncestor(textArea); + if (owner == null) return; + if (window == null || window.getOwner() != owner) { + if (window != null) window.dispose(); + window = new JWindow(owner); + window.setFocusableWindowState(false); + label = new JLabel(); + label.setOpaque(true); + final Color bg = UIManager.getColor("ToolTip.background"); + final Color fg = UIManager.getColor("ToolTip.foreground"); + if (bg != null) label.setBackground(bg); + if (fg != null) label.setForeground(fg); + label.setFont(textArea.getFont()); + label.setBorder(BorderFactory.createCompoundBorder(UIManager.getBorder( + "ToolTip.border") != null ? UIManager.getBorder("ToolTip.border") + : BorderFactory.createLineBorder(Color.GRAY), BorderFactory + .createEmptyBorder(2, 4, 2, 4))); + window.getContentPane().add(label); + } + label.setText(html); + window.pack(); + try { + // Above the call's line, starting at its opening parenthesis. + final Rectangle2D r = textArea.modelToView2D(Math.max(0, Math.min( + callStart, textArea.getDocument().getLength()))); + final Point p = new Point((int) r.getX(), (int) r.getY()); + SwingUtilities.convertPointToScreen(p, textArea); + int y = p.y - window.getHeight() - 2; + if (y < 0) y = p.y + (int) r.getHeight() + 2; + window.setLocation(p.x, y); + } + catch (final BadLocationException exc) { + return; + } + final Rectangle visible = textArea.getVisibleRect(); + if (visible.isEmpty()) return; + window.setVisible(true); + } + + private static String escape(final String s) { + return s.replace("&", "&").replace("<", "<").replace(">", ">"); + } +} diff --git a/src/main/java/org/scijava/ui/swing/script/highliters/PythonHighlighter.java b/src/main/java/org/scijava/ui/swing/script/highliters/PythonHighlighter.java new file mode 100644 index 00000000..eb83df42 --- /dev/null +++ b/src/main/java/org/scijava/ui/swing/script/highliters/PythonHighlighter.java @@ -0,0 +1,51 @@ +/* + * #%L + * Script Editor and Interpreter for SciJava script languages. + * %% + * Copyright (C) 2009 - 2026 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.scijava.ui.swing.script.highliters; + +import org.fife.ui.rsyntaxtextarea.modes.PythonTokenMaker; +import org.scijava.plugin.Plugin; +import org.scijava.ui.swing.script.SyntaxHighlighter; + +/** + * SyntaxHighlighter for "Jython". + * <p> + * Syntax highlighting requires an RSyntaxTextArea {@code TokenMaker}, which is + * inherently editor-side, so the script editor provides it here on behalf of the + * toolkit-agnostic Jython scripting plugin. + * </p> + * + * @author Karl Duderstadt + */ +@Plugin(type = SyntaxHighlighter.class, name = "Jython") +public class PythonHighlighter extends PythonTokenMaker implements + SyntaxHighlighter +{ + // Everything implemented in PythonTokenMaker +} diff --git a/src/test/java/org/scijava/ui/swing/script/autocompletion/CodeIntelligenceUITest.java b/src/test/java/org/scijava/ui/swing/script/autocompletion/CodeIntelligenceUITest.java new file mode 100644 index 00000000..60fa930a --- /dev/null +++ b/src/test/java/org/scijava/ui/swing/script/autocompletion/CodeIntelligenceUITest.java @@ -0,0 +1,169 @@ +/* + * #%L + * Script Editor and Interpreter for SciJava script languages. + * %% + * Copyright (C) 2009 - 2026 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + + +package org.scijava.ui.swing.script.autocompletion; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; + +import javax.swing.SwingUtilities; + +import org.eclipse.lsp4j.CompletionList; +import org.eclipse.lsp4j.Hover; +import org.eclipse.lsp4j.MarkupContent; +import org.eclipse.lsp4j.MarkupKind; +import org.fife.ui.rsyntaxtextarea.RSyntaxDocument; +import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; +import org.fife.ui.rsyntaxtextarea.parser.ParseResult; +import org.fife.ui.rsyntaxtextarea.parser.ParserNotice; +import org.junit.Test; +import org.scijava.code.lsp.Environment; +import org.scijava.code.lsp.jvm.AbstractLanguageServer; + +/** + * Tests {@link HoverToolTipSupplier}, {@link DiagnosticsParser} and + * {@link SignaturePopup}, with a fake server. + * + * @author Gabriel Selzer + */ +public class CodeIntelligenceUITest { + + @Test + public void testHover() { + final AtomicInteger asked = new AtomicInteger(); + final AbstractLanguageServer server = new FakeServer() { + + @Override + protected Hover hover(final Document doc, final int at) { + asked.incrementAndGet(); + final String text = doc.text(); + if (!text.startsWith("dumps", at - 1) && !text.startsWith("dumps", at - + 2)) return null; + final Hover hover = new Hover(new MarkupContent(MarkupKind.MARKDOWN, + "<pre>Serialize.</pre>")); + hover.setRange(doc.range(0, 5)); + return hover; + } + }; + final HoverToolTipSupplier supplier = new HoverToolTipSupplier( + FakeServers.session(server), null); + final String text = "x.dumps(1)"; + assertEquals("<html><pre>Serialize.</pre></html>", supplier.toolTip(text, + 3)); + // Elsewhere in the same word: the same answer, not asked again. + assertEquals("<html><pre>Serialize.</pre></html>", supplier.toolTip(text, + 4)); + assertEquals(1, asked.get()); + // Not on a word: nothing, without asking. + assertNull(supplier.toolTip(text, 7)); + assertNull(supplier.toolTip(text, -1)); + assertEquals(1, asked.get()); + } + + @Test + public void testTruncate() { + final StringBuilder long_ = new StringBuilder("<pre>"); + for (int i = 0; i < 40; i++) long_.append("line ").append(i).append('\n'); + final String cut = HoverToolTipSupplier.truncate(long_.append("</pre>") + .toString()); + assertTrue(cut.contains("line 24")); + assertTrue(!cut.contains("line 25")); + assertTrue(cut.endsWith("…</pre>")); + assertEquals("<pre>short</pre>", HoverToolTipSupplier.truncate( + "<pre>short</pre>")); + } + + @Test + public void testDiagnostics() throws Exception { + final RSyntaxTextArea[] area = { null }; + SwingUtilities.invokeAndWait(() -> area[0] = new RSyntaxTextArea( + "x = 1\ny = (1 +\n")); + final DiagnosticsParser parser = new DiagnosticsParser(area[0]); + final RSyntaxDocument doc = (RSyntaxDocument) area[0].getDocument(); + // None published yet: no problems. + assertTrue(parser.parse(doc, null).getNotices().isEmpty()); + // Published: a problem on the second line. + final org.eclipse.lsp4j.Diagnostic d = new org.eclipse.lsp4j.Diagnostic( + new org.eclipse.lsp4j.Range(new org.eclipse.lsp4j.Position(1, 4), + new org.eclipse.lsp4j.Position(1, 6)), "Unclosed ("); + d.setSeverity(org.eclipse.lsp4j.DiagnosticSeverity.Error); + parser.accept(Collections.singletonList(d)); + SwingUtilities.invokeAndWait(() -> {}); + final ParseResult result = parser.parse(doc, null); + assertEquals(1, result.getNotices().size()); + final ParserNotice notice = result.getNotices().get(0); + assertEquals("Unclosed (", notice.getMessage()); + assertEquals(1, notice.getLine()); + assertEquals(10, notice.getOffset()); + assertEquals(2, notice.getLength()); + assertEquals(ParserNotice.Level.ERROR, notice.getLevel()); + } + + @Test + public void testSignaturesHTML() { + final String html = SignaturePopup.html(new org.eclipse.lsp4j.SignatureHelp( + Arrays.asList(signature("max(double a, double b) -> double", "double a", + "double b"), signature("max(int a, int b)", "int a", "int b")), 0, 1)); + // One per line, in the server's order; the parameter being typed in bold. + assertEquals("<html>max(double a, <b>double b</b>) → double<br>" + + "max(int a, <b>int b</b>)</html>", html); + } + + private static org.eclipse.lsp4j.SignatureInformation signature( + final String label, final String... params) + { + final List<org.eclipse.lsp4j.ParameterInformation> ps = + new java.util.ArrayList<>(); + for (final String p : params) { + ps.add(new org.eclipse.lsp4j.ParameterInformation(p)); + } + return new org.eclipse.lsp4j.SignatureInformation(label, (String) null, ps); + } + + /** A server completing nothing. */ + private static class FakeServer extends AbstractLanguageServer { + + FakeServer() { + super(new Environment()); + } + + @Override + protected CompletionList complete(final Document doc, final int offset) { + return new CompletionList(); + } + } +} diff --git a/src/test/java/org/scijava/ui/swing/script/autocompletion/FakeServers.java b/src/test/java/org/scijava/ui/swing/script/autocompletion/FakeServers.java new file mode 100644 index 00000000..7b80fc73 --- /dev/null +++ b/src/test/java/org/scijava/ui/swing/script/autocompletion/FakeServers.java @@ -0,0 +1,140 @@ +/* + * #%L + * Script Editor and Interpreter for SciJava script languages. + * %% + * Copyright (C) 2009 - 2026 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.scijava.ui.swing.script.autocompletion; + +import java.util.Collections; +import java.util.Map; + +import javax.script.ScriptEngine; + +import org.eclipse.lsp4j.services.LanguageServer; +import org.scijava.Context; +import org.scijava.code.lsp.Environment; +import org.scijava.code.lsp.LanguageServerService; +import org.scijava.code.lsp.ScriptDialect; +import org.scijava.code.lsp.ScriptDocument; +import org.scijava.code.lsp.ScriptSession; +import org.scijava.script.AbstractScriptLanguage; +import org.scijava.script.ScriptLanguage; +import org.scijava.service.AbstractService; + +/** + * A {@link LanguageServerService} serving every script with one fake server, + * which sees scripts as they are: for tests of what editors do with sessions. + * + * @author Gabriel Selzer + */ +final class FakeServers extends AbstractService implements + LanguageServerService +{ + + /** A script language: any will do, as all are served alike here. */ + private static final ScriptLanguage LANGUAGE = + new AbstractScriptLanguage() + { + + @Override + public ScriptEngine getScriptEngine() { + return null; + } + }; + + /** Shows scripts as they are. */ + private static final ScriptDialect AS_IS = new ScriptDialect() { + + @Override + public boolean supports(final ScriptLanguage language) { + return true; + } + + @Override + public String languageId() { + return "plaintext"; + } + + @Override + public Header header(final Map<String, Class<?>> parameters) { + return new Header("", Collections.emptySet()); + } + }; + + /** Where sessions look for a log service: there is none. */ + private static final Context EMPTY = new Context(true); + + private final LanguageServer server; + + private FakeServers(final LanguageServer server) { + this.server = server; + } + + @Override + public Context context() { + return EMPTY; + } + + @Override + public Context getContext() { + return EMPTY; + } + + /** A session whose server is the given one. */ + static ScriptSession session(final LanguageServer server) { + return new ScriptSession(new FakeServers(server), LANGUAGE); + } + + @Override + public ScriptDialect dialect(final ScriptLanguage language) { + return AS_IS; + } + + @Override + public ScriptDocument document(final String script, + final ScriptDialect dialect) + { + return ScriptDocument.of(script); + } + + @Override + public boolean supports(final ScriptLanguage language) { + return true; + } + + @Override + public boolean supports(final String languageId) { + return true; + } + + @Override + public LanguageServer launch(final String languageId, + final Environment environment) + { + return server; + } +} diff --git a/src/test/java/org/scijava/ui/swing/script/autocompletion/SciJavaCompletionProviderTest.java b/src/test/java/org/scijava/ui/swing/script/autocompletion/SciJavaCompletionProviderTest.java new file mode 100644 index 00000000..9fae7b8e --- /dev/null +++ b/src/test/java/org/scijava/ui/swing/script/autocompletion/SciJavaCompletionProviderTest.java @@ -0,0 +1,504 @@ +/* + * #%L + * Script Editor and Interpreter for SciJava script languages. + * %% + * Copyright (C) 2009 - 2026 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.scijava.ui.swing.script.autocompletion; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiFunction; +import java.util.function.Supplier; + +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.SwingUtilities; + +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionItemKind; +import org.eclipse.lsp4j.CompletionList; +import org.eclipse.lsp4j.CompletionParams; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.SignatureHelp; +import org.eclipse.lsp4j.TextEdit; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import org.fife.ui.autocomplete.Completion; +import org.fife.ui.autocomplete.ParameterizedCompletion; +import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; +import org.junit.Test; +import org.scijava.code.lsp.jvm.Callable; +import org.scijava.code.lsp.Environment; +import org.scijava.code.lsp.Positions; +import org.scijava.code.lsp.jvm.AbstractLanguageServer; + +/** + * Tests that {@link SciJavaCompletionProvider} faithfully bridges a language + * server's completions to RSTA completions, including honoring the range each + * completion replaces. + * + * @author Curtis Rueden + * @author Gabriel Selzer + */ +public class SciJavaCompletionProviderTest { + + @Test + public void testBridgesCompletions() { + final RSyntaxTextArea area = new RSyntaxTextArea(); + area.setText("foo.ba"); + area.setCaretPosition(area.getDocument().getLength()); // offset 6 + + // A server that, regardless of input, suggests two members of "foo", + // replacing the partial token "ba" (which starts at offset 4). + final FakeServer server = new FakeServer(); + server.complete = (doc, caret) -> { + final CompletionItem bar = new CompletionItem("bar"); + bar.setDetail("the bar member"); + return FakeServer.items(doc, 4, caret, bar, new CompletionItem("baz")); + }; + final SciJavaCompletionProvider provider = new SciJavaCompletionProvider( + FakeServers.session(server)); + + // The provider must report the already-entered text as the replaced span. + assertEquals("ba", provider.getAlreadyEnteredText(area)); + + // And it must surface both suggestions as RSTA completions, in order. + final List<Completion> completions = provider.getCompletionsImpl(area); + assertEquals(2, completions.size()); + assertEquals("bar", completions.get(0).getReplacementText()); + assertEquals("baz", completions.get(1).getReplacementText()); + } + + @Test + public void testAdditionalEditsSurfaceAsSciJavaCompletion() { + final RSyntaxTextArea area = new RSyntaxTextArea(); + area.setText("Roi"); + area.setCaretPosition(area.getDocument().getLength()); + + // A class-name completion that also auto-imports at the top of the file. + final FakeServer server = new FakeServer(); + server.complete = (doc, caret) -> { + final CompletionItem roi = new CompletionItem("Roi"); + roi.setAdditionalTextEdits(Collections.singletonList(new TextEdit( + doc.range(0, 0), "from ij.gui import Roi\n"))); + return FakeServer.items(doc, 0, caret, roi); + }; + final SciJavaCompletionProvider provider = new SciJavaCompletionProvider( + FakeServers.session(server)); + + final List<Completion> completions = provider.getCompletionsImpl(area); + assertEquals(1, completions.size()); + // Completions carrying extra edits must be SciJavaCompletions so the + // SciJavaAutoCompletion applies their auto-import on acceptance. + final Completion c = completions.get(0); + assertTrue(c instanceof SciJavaCompletion); + final List<AdditionalEdits.Edit> edits = ((SciJavaCompletion) c) + .getAdditionalEdits(); + assertEquals(1, edits.size()); + assertEquals(0, edits.get(0).start()); + assertEquals("from ij.gui import Roi\n", edits.get(0).newText()); + } + + @Test + public void testCallablesBecomeFunctionCompletionsWithChoices() { + final RSyntaxTextArea area = new RSyntaxTextArea(); + area.setText("addR"); + area.setCaretPosition(area.getDocument().getLength()); + + // A callable completion taking one ij.gui.Roi parameter; and, inside its + // argument list, an in-scope variable fitting it (and a method). + final Callable addRoi = new Callable("addRoi", Collections.singletonList( + new Callable.Param("roi", "ij.gui.Roi")), "void").kind( + CompletionItemKind.Method); + final FakeServer server = new FakeServer(); + server.complete = (doc, caret) -> { + if (!doc.lineTo(caret).endsWith("(")) { + return FakeServer.items(doc, 0, caret, addRoi.toItem()); + } + final CompletionItem myRoi = new CompletionItem("myRoi"); + myRoi.setKind(CompletionItemKind.Variable); + return FakeServer.items(doc, caret, caret, myRoi, addRoi.toItem()); + }; + final SciJavaCompletionProvider provider = new SciJavaCompletionProvider( + FakeServers.session(server)); + + // Callables surface as FunctionCompletions so parameter assistance works. + final List<Completion> completions = provider.getCompletionsImpl(area); + assertEquals(1, completions.size()); + assertTrue(completions.get(0) instanceof SciJavaFunctionCompletion); + + // Parameter choices: the variables completed at the parameter. + area.setText("addRoi("); + area.setCaretPosition(area.getDocument().getLength()); + final List<Completion> roiChoices = provider.parameterChoices(area, + new ParameterizedCompletion.Parameter("ij.gui.Roi", "roi")); + assertEquals(1, roiChoices.size()); + assertEquals("myRoi", roiChoices.get(0).getReplacementText()); + } + + @Test + public void testCallablesRenderWithSignatures() { + final String completionText = "img.getAt"; + final RSyntaxTextArea area = new RSyntaxTextArea(); + area.setText(completionText); + area.setCaretPosition(area.getDocument().getLength()); + + final FakeServer server = new FakeServer(); + server.complete = (doc, caret) -> FakeServer.items(doc, 0, caret, method( + completionText, "long[]").toItem()); + final SciJavaCompletionProvider provider = new SciJavaCompletionProvider( + FakeServers.session(server)); + + // The popup list must show a callable's parameters, not just its name. + final Completion c = provider.getCompletionsImpl(area).get(0); + final JLabel label = (JLabel) provider.getListCellRenderer() + .getListCellRendererComponent(new JList<>(), c, 0, false, false); + final String text = label.getText(); + assertTrue(text, text.contains("long[]")); + assertTrue(text, text.contains("p0")); + } + + @Test + public void testParameterTooltipListsOtherOverloads() { + final RSyntaxTextArea area = new RSyntaxTextArea(); + area.setText("img.getAt"); + area.setCaretPosition(area.getDocument().getLength()); + + final FakeServer server = new FakeServer(); + server.complete = (doc, caret) -> FakeServer.items(doc, 0, caret, // + method("img.getAt", "int[]").toItem(), // + method("img.getAt", "net.imglib2.Localizable").toItem(), // + method("img.getAt", "int[]").toItem(), // e.g. a bridge method + method("img.getAtX", "long[]").toItem()); + final SciJavaCompletionProvider provider = new SciJavaCompletionProvider( + FakeServers.session(server)); + final List<Completion> completions = provider.getCompletionsImpl(area); + + // Each overload's tooltip lists the others, once each, even before RSTA + // inserts the argument list... + assertEquals("<hr>Localizable p0", ((ParameterizedCompletion) completions + .get(0)).getParam(0).getDescription()); + // ...but the side description window does not. + final String summary = completions.get(0).getSummary(); + assertFalse(summary, summary != null && summary.contains("Localizable")); + + // Inside the argument list, likewise. + area.setText("img.getAt(p0)"); + area.setCaretPosition(10); + final String desc = ((ParameterizedCompletion) completions.get(0)) + .getParam(0).getDescription(); + assertEquals("<hr>Localizable p0", desc); + // ...and a callable with no other overloads gets no description. + assertNull(((ParameterizedCompletion) completions.get(completions.size() - + 1)).getParam(0).getDescription()); + } + + @Test + public void testParameterTooltipShowsSignatureHelp() { + final RSyntaxTextArea area = new RSyntaxTextArea(); + area.setText("a"); + area.setCaretPosition(1); + + final Callable ints = method("a", "int", "int"); + final Callable strings = method("a", "java.lang.String"); + final Callable floats = method("a", "float", "float"); + final Callable longs = method("a", "long", "long"); + final String[] askedText = { null }; + final int[] askedCaret = { -1 }; + final FakeServer server = new FakeServer(); + server.complete = (doc, caret) -> FakeServer.items(doc, 0, caret, ints + .toItem(), strings.toItem(), floats.toItem(), longs.toItem()); + server.help = (doc, caret) -> { + askedText[0] = doc.text(); + askedCaret[0] = caret; + // The server orders the signatures, best fits first: here, as for a + // float typed as the first argument. + return new SignatureHelp(Arrays.asList(floats.toSignature(), ints + .toSignature(), longs.toSignature(), strings.toSignature()), 0, 1); + }; + final SciJavaCompletionProvider provider = new SciJavaCompletionProvider( + FakeServers.session(server)); + final List<Completion> completions = provider.getCompletionsImpl(area); + final ParameterizedCompletion intOverload = + (ParameterizedCompletion) completions.get(0); + + // Accepted a(int, int), typed a float, and tabbed onward: the server's + // order, without the accepted overload. + area.setText("a(1.5, p1)"); + area.setCaretPosition(7); + assertEquals("<hr>float p0, float p1" + // + "<hr>long p0, long p1" + // + "<hr>String p0", // + intOverload.getParam(1).getDescription()); + // The server was asked about the caret. + assertEquals(7, askedCaret[0]); + assertEquals("a(1.5, p1)", askedText[0]); + } + + @Test + public void testParameterTooltipIgnoresOtherCalls() { + final RSyntaxTextArea area = new RSyntaxTextArea(); + area.setText("a"); + area.setCaretPosition(1); + final Callable ints = method("a", "int", "int"); + final Callable floats = method("a", "float", "float"); + final FakeServer server = new FakeServer(); + server.complete = (doc, caret) -> FakeServer.items(doc, 0, caret, ints + .toItem(), floats.toItem()); + // The caret is in a call nested in a's arguments: b's signatures. + server.help = (doc, caret) -> new SignatureHelp(Collections.singletonList( + method("b", "double").toSignature()), 0, 0); + final SciJavaCompletionProvider provider = new SciJavaCompletionProvider( + FakeServers.session(server)); + final ParameterizedCompletion intOverload = + (ParameterizedCompletion) provider.getCompletionsImpl(area).get(0); + area.setText("a(b(|), p1)"); + area.setCaretPosition(4); + // Not b's: a's other overloads, unranked. + assertEquals("<hr>float p0, float p1", intOverload.getParam(0) + .getDescription()); + } + + @Test + public void testLazyDescriptions() { + final RSyntaxTextArea area = new RSyntaxTextArea(); + area.setText("np.ze"); + area.setCaretPosition(5); + final AtomicInteger asked = new AtomicInteger(); + final FakeServer server = new FakeServer(); + server.complete = (doc, caret) -> FakeServer.items(doc, 3, caret, // + server.described(new Callable("zeros", Collections.singletonList( + new Callable.Param("shape", "int")), null).kind( + CompletionItemKind.Method).toItem(), () -> { + asked.incrementAndGet(); + return "Return a new array of zeros."; + }), // + server.described(new CompletionItem("zeta"), () -> { + asked.incrementAndGet(); + return "The zeta function."; + })); + final SciJavaCompletionProvider provider = new SciJavaCompletionProvider( + FakeServers.session(server)); + final List<Completion> completions = provider.getCompletionsImpl(area); + // Nothing is described until RSTA shows a completion's description... + assertEquals(0, asked.get()); + // ...and then it is shown, for functions as for everything else. + assertTrue(completions.get(0).getSummary().contains( + "Return a new array of zeros.")); + assertTrue(completions.get(1).getSummary().contains("The zeta function.")); + assertEquals(2, asked.get()); + } + + @Test + public void testResultUpdates() throws Exception { + final RSyntaxTextArea area = new RSyntaxTextArea(); + area.setText("np.z"); + area.setCaretPosition(4); + final CompletableFuture<CompletionList> later = new CompletableFuture<>(); + final SciJavaCompletionProvider provider = new SciJavaCompletionProvider( + FakeServers.session(new LateServer(later))); + final Boolean[] notified = { null }; + provider.setUpdateListener(wasEmpty -> notified[0] = wasEmpty); + + // At first, nothing (not in time); then the result arrives. + assertEquals(0, provider.getCompletionsImpl(area).size()); + later.complete(zeros("np.z")); + SwingUtilities.invokeAndWait(() -> {}); + assertEquals(Boolean.TRUE, notified[0]); + final List<Completion> completions = provider.getCompletionsImpl(area); + assertEquals(1, completions.size()); + assertEquals("zeros", completions.get(0).getReplacementText()); + + // A result for a request the user has typed past is ignored. + final CompletableFuture<CompletionList> stale = new CompletableFuture<>(); + final SciJavaCompletionProvider provider2 = new SciJavaCompletionProvider( + FakeServers.session(new LateServer(stale))); + notified[0] = null; + provider2.setUpdateListener(wasEmpty -> notified[0] = wasEmpty); + area.setText("np.z"); + area.setCaretPosition(4); + provider2.getCompletionsImpl(area); + area.setText("np.ze"); + area.setCaretPosition(5); + stale.complete(zeros("np.z")); + SwingUtilities.invokeAndWait(() -> {}); + assertNull(notified[0]); + } + + @Test + public void testOpenOnInstall() { + final RSyntaxTextArea area = new RSyntaxTextArea(); + area.setText("import numpy\n"); + final File file = new File("scripts", "blur.py"); + final FakeServer server = new FakeServer(); + final LanguageServerLanguageSupport support = + new LanguageServerLanguageSupport(() -> FakeServers.session(server), + null, () -> file); + support.install(area); + // NB: Sessions tell their servers off the caller's thread. + for (int i = 0; i < 500 && server.opened.isEmpty(); i++) { + try { + Thread.sleep(10); + } + catch (final InterruptedException exc) { + Thread.currentThread().interrupt(); + } + } + assertEquals(1, server.opened.size()); + assertEquals("import numpy\n", server.opened.get(0).text()); + assertEquals(file.getAbsolutePath(), server.opened.get(0).path()); + support.uninstall(area); + assertEquals(1, server.closed.size()); + } + + @Test + public void testRequestCarriesScriptPath() { + final RSyntaxTextArea area = new RSyntaxTextArea(); + area.setText("x"); + area.setCaretPosition(1); + final String[] path = { "unset" }; + final FakeServer server = new FakeServer(); + server.complete = (doc, caret) -> { + path[0] = doc.path(); + return new CompletionList(); + }; + final SciJavaCompletionProvider provider = new SciJavaCompletionProvider( + FakeServers.session(server)); + + // Without a file supplier (e.g. in an interpreter): no path. + provider.getCompletionsImpl(area); + assertNull(path[0]); + + // With one: the script's path, even if it changes (e.g. Save As). + final File[] file = { new File("scripts", "a.py") }; + provider.session().setFile(() -> file[0]); + area.setText("xy"); + area.setCaretPosition(2); + provider.getCompletionsImpl(area); + assertEquals(file[0].getAbsolutePath(), path[0]); + } + + // -- Helper methods -- + + private static Callable method(final String name, + final String... paramTypes) + { + final List<Callable.Param> params = new ArrayList<>(); + for (int i = 0; i < paramTypes.length; i++) { + params.add(new Callable.Param("p" + i, paramTypes[i])); + } + return new Callable(name, params, "java.lang.Object").kind( + CompletionItemKind.Method); + } + + /** "zeros", replacing what follows the dot, up to the end of the text. */ + private static CompletionList zeros(final String text) { + final CompletionItem zeros = new CompletionItem("zeros"); + zeros.setTextEdit(Either.forLeft(new TextEdit(new Range(Positions.position( + text, text.indexOf('.') + 1), Positions.position(text, text.length())), + "zeros"))); + return new CompletionList(false, Collections.singletonList(zeros)); + } + + // -- Helper classes -- + + /** A server answering as its functions say. */ + private static class FakeServer extends AbstractLanguageServer { + + BiFunction<Document, Integer, CompletionList> complete = (doc, + caret) -> new CompletionList(); + BiFunction<Document, Integer, SignatureHelp> help = (doc, caret) -> null; + final List<Document> opened = new ArrayList<>(); + final List<Document> closed = new ArrayList<>(); + + FakeServer() { + super(new Environment()); + } + + /** Completions replacing the text from {@code start} to the caret. */ + static CompletionList items(final Document doc, final int start, + final int caret, final CompletionItem... items) + { + return list(doc, start, caret, new ArrayList<>(Arrays.asList(items))); + } + + /** A completion documented on demand. */ + CompletionItem described(final CompletionItem item, + final Supplier<String> html) + { + return describe(item, html); + } + + @Override + protected CompletionList complete(final Document doc, final int offset) { + return complete.apply(doc, offset); + } + + @Override + protected SignatureHelp signatureHelp(final Document doc, + final int offset) + { + return help.apply(doc, offset); + } + + @Override + protected void opened(final Document doc) { + opened.add(doc); + } + + @Override + protected void closed(final Document doc) { + closed.add(doc); + } + } + + /** A server whose completions come when they are completed. */ + private static class LateServer extends FakeServer { + + private final CompletableFuture<CompletionList> later; + + LateServer(final CompletableFuture<CompletionList> later) { + this.later = later; + } + + @Override + public CompletableFuture<Either<List<CompletionItem>, CompletionList>> + completion(final CompletionParams params) + { + return later.thenApply(Either::forRight); + } + } +}