From d52fab4939dcd71b8181211136f2820c7ed34e97 Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sun, 21 Jun 2026 21:27:36 -0500 Subject: [PATCH 01/29] Provide toolkit-agnostic Jython code completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the Jython completion engine from the (now-retired) script-editor-jython repo into this scripting plugin, so Jython provides both its ScriptLanguage and its CodeCompleter while depending only on scijava-common + jython — no Swing or RSyntaxTextArea. - JythonCodeCompleter: a CodeCompleterPlugin that derives the line context from a CompletionRequest and returns neutral Completions + a ParameterChoices. - JythonAutoCompletions: the analysis engine, emitting neutral org.scijava.script.complete.Completion (no RSTA), with auto-imports expressed as Completion.TextEdit and parameter assistance via ParameterChoices. - JythonScriptParser/Scope/DotAutocompletions* and an internal CompletionText: unchanged AST logic, with class discovery/javadoc via scijava-common's ClassIndex. - The legacy RSTA glue (LanguageSupportPlugin, AutocompletionProvider, AutoCompletion, FunctionCompletion) is dropped; syntax highlighting moves to script-editor. The hardcoded ImageJ ij.dir scan is replaced by locating the jython jar on the classpath. Adds an antlr-runtime dependency (Jython AST) and a temporary scijava-common version override until a release pins the new completion SPI. Co-Authored-By: Claude Opus 4.8 --- pom.xml | 11 + .../jython/ClassDotAutocompletions.java | 94 ++++ .../scripting/jython/CompletionText.java | 129 +++++ .../jython/ConstructorAutocompletions.java | 51 ++ .../jython/DefVarDotAutocompletions.java | 56 ++ .../scripting/jython/DotAutocompletions.java | 123 +++++ .../jython/EmptyDotAutocompletions.java | 45 ++ .../jython/JythonAutoCompletions.java | 483 ++++++++++++++++++ .../scripting/jython/JythonCodeCompleter.java | 101 ++++ .../plugins/scripting/jython/JythonDev.java | 65 +++ .../scripting/jython/JythonImportFormat.java | 51 ++ .../scripting/jython/JythonImports.java | 163 ++++++ .../scripting/jython/JythonScriptParser.java | 431 ++++++++++++++++ .../plugins/scripting/jython/Scope.java | 335 ++++++++++++ .../jython/StaticDotAutocompletions.java | 105 ++++ .../jython/VarDotAutocompletions.java | 77 +++ .../jython/JythonCompletionTest.java | 85 +++ 17 files changed, 2405 insertions(+) create mode 100644 src/main/java/org/scijava/plugins/scripting/jython/ClassDotAutocompletions.java create mode 100644 src/main/java/org/scijava/plugins/scripting/jython/CompletionText.java create mode 100644 src/main/java/org/scijava/plugins/scripting/jython/ConstructorAutocompletions.java create mode 100644 src/main/java/org/scijava/plugins/scripting/jython/DefVarDotAutocompletions.java create mode 100644 src/main/java/org/scijava/plugins/scripting/jython/DotAutocompletions.java create mode 100644 src/main/java/org/scijava/plugins/scripting/jython/EmptyDotAutocompletions.java create mode 100644 src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java create mode 100644 src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java create mode 100644 src/main/java/org/scijava/plugins/scripting/jython/JythonDev.java create mode 100644 src/main/java/org/scijava/plugins/scripting/jython/JythonImportFormat.java create mode 100644 src/main/java/org/scijava/plugins/scripting/jython/JythonImports.java create mode 100644 src/main/java/org/scijava/plugins/scripting/jython/JythonScriptParser.java create mode 100644 src/main/java/org/scijava/plugins/scripting/jython/Scope.java create mode 100644 src/main/java/org/scijava/plugins/scripting/jython/StaticDotAutocompletions.java create mode 100644 src/main/java/org/scijava/plugins/scripting/jython/VarDotAutocompletions.java create mode 100644 src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java diff --git a/pom.xml b/pom.xml index 8ef0f03..f2e0b78 100644 --- a/pom.xml +++ b/pom.xml @@ -84,6 +84,12 @@ org.scijava.plugins.scripting.jython + + 2.100.2-SNAPSHOT + bsd_2 SciJava developers. **/script_templates/** @@ -104,6 +110,11 @@ org.python jython-slim + + + org.antlr + antlr-runtime + diff --git a/src/main/java/org/scijava/plugins/scripting/jython/ClassDotAutocompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/ClassDotAutocompletions.java new file mode 100644 index 0000000..776e653 --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/jython/ClassDotAutocompletions.java @@ -0,0 +1,94 @@ +/*- + * #%L + * Jython language support for SciJava Script Editor. + * %% + * Copyright (C) 2020 - 2024 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.plugins.scripting.jython; + +import java.util.ArrayList; +import java.util.List; + + +public class ClassDotAutocompletions extends DefVarDotAutocompletions { + final List superclassNames; // List of superclasses + final ListdotAutocompletions; // List of class methods and fields + + /** + * + * @param fnName + * @param superclassNames + * @param argumentNames + * @param dotAutocompletions + * @param class_scope + */ + public ClassDotAutocompletions(final String fnName, List superclassNames, + final List argumentNames, final List dotAutocompletions, final Scope class_scope) { + super(fnName, null, argumentNames, class_scope); + this.superclassNames = superclassNames; + this.dotAutocompletions = dotAutocompletions; + } + + @Override + public List get() { + final List ac = new ArrayList<>(this.dotAutocompletions); + for (final String className: this.superclassNames) { + List fm = DotAutocompletions.getPublicFieldsAndMethods(className); + if (fm.isEmpty()) { + // Maybe it's a python class + // get them all via the org.python.indexer.Indexer and Builtins classes, see StaticDotAutocompletions + JythonDev.print("Don't know yet how to handle class " + className); + } + ac.addAll(fm); + } + return ac; + } + + public void put(final CompletionText entry) { + if (this.dotAutocompletions.contains(entry)) return; // list search OK: very low N + this.dotAutocompletions.add(entry); + } + + /** Make this be cda plus its own dotAutocompletions. + * + * @param cda + */ + public void mutateIntoPlus(final ClassDotAutocompletions cda) + { + this.className = cda.className; + this.fnName = cda.fnName; + this.superclassNames.clear(); + this.superclassNames.addAll(cda.superclassNames); + this.dotAutocompletions.addAll(cda.dotAutocompletions); + this.argumentNames.clear(); + this.argumentNames.addAll(cda.argumentNames); + this.scope = cda.scope; + } + + @Override + public String toString() { + return "ClassDotAutocompletions: " + this.fnName + "(" + String.join(", ", this.argumentNames) + ")" + " -- " + String.join(", ", this.get().toString()); + } +} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/CompletionText.java b/src/main/java/org/scijava/plugins/scripting/jython/CompletionText.java new file mode 100644 index 0000000..0929f7c --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/jython/CompletionText.java @@ -0,0 +1,129 @@ +/*- + * #%L + * JSR-223-compliant Jython scripting language plugin. + * %% + * Copyright (C) 2020 - 2025 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.plugins.scripting.jython; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.scijava.script.complete.ClassIndex; + +/** + * A mutable suggestion used internally by the Jython completion engine. It holds + * the replacement text plus optional documentation and (for callables) parameter + * and return-type metadata. The {@link JythonCodeCompleter} converts these into + * toolkit-agnostic {@link org.scijava.script.complete.Completion}s at the + * boundary. + * + * @author Albert Cardona + */ +public class CompletionText { + + private String replacementText; + private String description; + private String summary; + private List method_args = Collections.emptyList(); + private String method_returnType = null; + + public CompletionText(final String replacementText) { + this(replacementText, (String) null, (String) null); + } + + public CompletionText(final String replacementText, final String summary, + final String description) + { + this.replacementText = replacementText; + this.summary = summary; + this.description = description; + } + + public CompletionText(final String replacementText, final Class c, + final Field f) + { + this(replacementText, ClassIndex.getSummaryCompletion(f, c), null); + } + + public CompletionText(final String replacementText, final Class c, + final Method m) + { + 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, ClassIndex.getSummaryCompletion(constructor, c), + null); + this.method_args = Arrays.asList(constructor.getParameters()); + this.method_returnType = c.getCanonicalName(); + } + + public String getReplacementText() { + return replacementText; + } + + public String getDescription() { + return description; + } + + public String getSummary() { + return summary; + } + + public List getMethodArgs() { + return method_args; + } + + public String getReturnType() { + return method_returnType; + } + + public void setReplacementText(final String replacementText) { + this.replacementText = replacementText; + } + + public void setDescription(final String description) { + this.description = description; + } + + public void setSummary(final String summary) { + this.summary = summary; + } + + @Override + public String toString() { + return replacementText + " | " + description + " | " + summary; + } +} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/ConstructorAutocompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/ConstructorAutocompletions.java new file mode 100644 index 0000000..ad7bace --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/jython/ConstructorAutocompletions.java @@ -0,0 +1,51 @@ +/*- + * #%L + * Jython language support for SciJava Script Editor. + * %% + * Copyright (C) 2020 - 2024 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.plugins.scripting.jython; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + + +public class ConstructorAutocompletions implements DotAutocompletions +{ + private final Class c; + + public ConstructorAutocompletions(final Class c) { + this.c = c; + } + + @Override + public List get() { + return Arrays.asList(this.c.getConstructors()).stream() + .map(cons -> new CompletionText(c.getSimpleName(), c, cons)) + .collect(Collectors.toList()); + } + +} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/DefVarDotAutocompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/DefVarDotAutocompletions.java new file mode 100644 index 0000000..81a3805 --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/jython/DefVarDotAutocompletions.java @@ -0,0 +1,56 @@ +/*- + * #%L + * Jython language support for SciJava Script Editor. + * %% + * Copyright (C) 2020 - 2024 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.plugins.scripting.jython; + +import java.util.List; + +public class DefVarDotAutocompletions extends VarDotAutocompletions { + + String fnName; + final List argumentNames; + Scope scope; + + public DefVarDotAutocompletions(final String fnName, final String returnClassName, final List argumentNames, final Scope scope) { + super(returnClassName); + this.fnName = fnName; + this.argumentNames = argumentNames; + this.scope = scope; + } + + public List getArgumentNames() { + return this.argumentNames; + } + + @Override + public String toString() { + return "DefVarAutocompletions:" + + " Class: " + super.className + + " Arguments: " + String.join(", ", this.argumentNames); + } +} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/DotAutocompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/DotAutocompletions.java new file mode 100644 index 0000000..701f48e --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/jython/DotAutocompletions.java @@ -0,0 +1,123 @@ +/*- + * #%L + * Jython language support for SciJava Script Editor. + * %% + * Copyright (C) 2020 - 2024 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.plugins.scripting.jython; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; + + +public interface DotAutocompletions { + + static public final DotAutocompletions EMPTY = new EmptyDotAutocompletions(); + + default public String getClassname() { + return null; + } + + default public String getSummary() { + return null; + } + + public List get(); + + default public Stream getStream() { + return get().stream(); + } + + static public List getPublicFieldsAndMethods(final String className) { + final List ac = new ArrayList<>(); + if (null != className) { + try { + final Class c = Class.forName(className); + for (final Field f: c.getFields()) + ac.add(new CompletionText(f.getName(), c, f)); + for (final Method m: c.getMethods()) + ac.add(new CompletionText(m.getName() + "()", c, m)); // TODO could do a parameter-driven autocompletion + } catch (final Exception e) { + JythonDev.print("Could not load class " + className, e); + } + } + return ac; + } + + + /** Collect static fields and static methods from {@code c} into {@code ac}. + * + * @param c + * @param ac + */ + static public void staticFieldsAndStaticMethodsInto(final Class c, final List ac) { + for (final Field f: c.getDeclaredFields()) + if (Modifier.isStatic(f.getModifiers())) + ac.add(new CompletionText(f.getName(), c, f)); + for (final Method m: c.getDeclaredMethods()) + if (Modifier.isStatic(m.getModifiers())) + ac.add(new CompletionText(m.getName() + "()", c, m)); + } + + /** Collect non-static fields and non-static methods of class {@code c} into {@code ac}. + * + * @param c + * @param ac + */ + static public void fieldsAndMethodsInto(final Class c, final List ac) { + for (final Field f: c.getDeclaredFields()) + if (!Modifier.isStatic(f.getModifiers())) + ac.add(new CompletionText(f.getName(), c, f)); + for (final Method m: c.getDeclaredMethods()) + if (!Modifier.isStatic(m.getModifiers())) + ac.add(new CompletionText(m.getName() + "()", c, m)); + } + + static public Stream staticFieldsAndStaticMethodsStream(final Class c, final boolean staticFields, final boolean staticMethods) { + return Stream.concat( + Arrays.stream(c.getDeclaredFields()) + .filter(f -> Modifier.isStatic(f.getModifiers())) + .map(f -> new CompletionText(f.getName(), c, f)), + Arrays.stream(c.getDeclaredMethods()) + .filter(m -> Modifier.isStatic(m.getModifiers())) + .map(m -> new CompletionText(m.getName(), c, m))); + } + + static public Stream fieldsAndMethodsStream(final Class c, final boolean staticFields, final boolean staticMethods) { + return Stream.concat( + Arrays.stream(c.getFields()) + .filter(f -> !Modifier.isStatic(f.getModifiers())) + .map(f -> new CompletionText(f.getName(), c, f)), + Arrays.stream(c.getMethods()) + .filter(m -> !Modifier.isStatic(m.getModifiers())) + .map(m -> new CompletionText(m.getName(), c, m))); + } + +} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/EmptyDotAutocompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/EmptyDotAutocompletions.java new file mode 100644 index 0000000..eea9277 --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/jython/EmptyDotAutocompletions.java @@ -0,0 +1,45 @@ +/*- + * #%L + * Jython language support for SciJava Script Editor. + * %% + * Copyright (C) 2020 - 2024 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.plugins.scripting.jython; + +import java.util.Collections; +import java.util.List; + + +public class EmptyDotAutocompletions implements DotAutocompletions { + @Override + public List get() { + return Collections.emptyList(); + } + + @Override + public String toString() { + return "EMPTY"; + } +} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java new file mode 100644 index 0000000..c65a173 --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java @@ -0,0 +1,483 @@ +/*- + * #%L + * JSR-223-compliant Jython scripting language plugin. + * %% + * Copyright (C) 2020 - 2025 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.plugins.scripting.jython; + +import java.io.File; +import java.lang.reflect.Parameter; +import java.nio.file.FileVisitOption; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.python.indexer.types.NModuleType; +import org.scijava.script.complete.ClassIndex; +import org.scijava.script.complete.Completion; +import org.scijava.script.complete.Completion.TextEdit; +import org.scijava.script.complete.ParameterChoices; + +/** + * The Jython completion engine: given the code before the caret it analyzes the + * Jython AST (via {@link JythonScriptParser}) and produces toolkit-agnostic + * {@link Completion}s plus, when relevant, a {@link ParameterChoices} for + * parameter assistance. Class-name and import completions (including auto-import + * {@link TextEdit}s) are produced via {@link ClassIndex}. + * + * @author Albert Cardona + * @author Curtis Rueden + */ +public class JythonAutoCompletions { + + private static final Pattern + nameToken = Pattern.compile("^(.*?[ \\t,\\[=\\(]+)([a-zA-Z_][a-zA-Z0-9_]+)$"), + invocation = Pattern.compile("^(.*?[ \\t]+|)([a-zA-Z_][a-zA-Z0-9_]+\\()$"), + dotNameToken = Pattern.compile("^(.*?[ \\t]+|)([a-zA-Z0-9_\\.\\[\\](){}]+)\\.([a-zA-Z0-9_]*)$"), + assign = Pattern.compile("^([ \\t]*)(([a-zA-Z_][a-zA-Z0-9_ \\t,]*)[ \\t]+=[ \\t]+(.*))$"), + endingCode = Pattern.compile("^([ \\t]*)[^#]*?(.*?)[ \\t]*:[ \\t]*(#.*|)[\\n]*$"), + sysPathAppend = Pattern.compile("sys.path.append[ \\t]*[(][ \\t]*['\"](.*?)['\"][ \\t]*[)]"), + importPkg = Pattern.compile("^(import|from)[ \\t]+([a-zA-Z_][a-zA-Z0-9._]*)$"), + importMember = Pattern.compile("^from[ \\t]+([a-z_][a-zA-Z0-9_.]*)[ \\t]+import[ \\t]*([a-zA-Z0-9_]*)$"), + // Import/class-name discovery patterns (matched against already-typed text). + fromImport = Pattern.compile("^((from|import)[ \\t]+)([a-zA-Z_][a-zA-Z0-9._]*)$"), + fastImport = Pattern.compile("^(from[ \\t]+)([a-zA-Z_][a-zA-Z0-9._]*)[ \\t]+$"), + importStatement = Pattern.compile("^((from[ \\t]+([a-zA-Z0-9._]+)[ \\t]+|[ \\t]*)import[ \\t]+)([a-zA-Z0-9_., \\t]*)$"), + simpleClassName = Pattern.compile("^(.*[ \\t]+|)([A-Z_][a-zA-Z0-9_]+)$"); + + private final JythonImportFormat formatter = new JythonImportFormat(); + + /** Python standard library module names, discovered from the jython jar. */ + private static final List jython_jar_modules = findJythonModules(); + + /** Result of a completion query: the suggestions plus optional choices. */ + public static final class Result { + + public final List completions; + public final ParameterChoices parameterChoices; + + Result(final List completions, + final ParameterChoices parameterChoices) + { + this.completions = completions; + this.parameterChoices = parameterChoices; + } + } + + /** + * Computes completions for a caret positioned at the end of {@code lastLine}, + * which follows {@code codeWithoutLastLine} in the script. {@code + * alreadyEnteredText} is the text the editor will replace. + */ + public Result completionsFor(String codeWithoutLastLine, final String lastLine, + final String alreadyEnteredText) + { + final List completions = new ArrayList<>(); + final int crop = lastLine.length() - alreadyEnteredText.length(); + + // 1) AST-based completions (names, members, constructors, invocations). + final ParameterChoices choices = astCompletions(codeWithoutLastLine, + lastLine, crop, completions); + + // 2) Import and class-name discovery, always appended. + importCompletions(alreadyEnteredText, codeWithoutLastLine + lastLine, + completions); + + return new Result(completions, choices); + } + + // -- AST-based completions -- + + private ParameterChoices astCompletions(String codeWithoutLastLine, + final String lastLine, final int crop, final List completions) + { + // Precondition: can't expand when empty or ending with any of "[]{},; ". + if (lastLine.isEmpty()) return null; + final char lastChar = lastLine.charAt(lastLine.length() - 1); + if ("[]{},; ".indexOf(lastChar) > -1) return null; + + // If the prior line ends with ':', append a "pass" to make valid code. + boolean add_pass = false; + if (codeWithoutLastLine.endsWith("\n")) { + final int priorLineBreak = codeWithoutLastLine.lastIndexOf('\n', + codeWithoutLastLine.length() - 2); + final String endingLine = codeWithoutLastLine.substring(priorLineBreak + + 1); + final Matcher me = endingCode.matcher(endingLine); + if (me.find()) { + codeWithoutLastLine = codeWithoutLastLine.substring(0, priorLineBreak + + 1) + me.group(1) + me.group(2); + add_pass = true; + } + } + + // Register any sys.path.append paths so custom modules can be found. + try { + final Matcher mpath = sysPathAppend.matcher(codeWithoutLastLine); + while (mpath.find()) { + final File path = new File(mpath.group(1)); + if (path.exists() && path.isDirectory() && !Scope.indexer + .getLoadPath().stream().anyMatch(s -> path.equals(new File(s)))) + { + Scope.indexer.addPath(path.getAbsolutePath()); + } + } + } + catch (final Exception e) { + JythonDev.print("Failed to add path from sys.path.append expression.", e); + } + + // A python module import: "import x" or "from x". + final Matcher mi = importPkg.matcher(lastLine); + if (mi.find()) { + moduleNameCompletions(mi.group(1), mi.group(2), completions); + return null; + } + + // A module member: "from x import y". + final Matcher mm = importMember.matcher(lastLine); + if (mm.find()) { + moduleMemberCompletions(mm.group(1), mm.group(2) == null ? "" : mm.group( + 2), completions); + return null; + } + + // A plain name (variable, function, class) being typed. + final Matcher m1 = nameToken.matcher(lastLine); + if (m1.find()) { + final Scope scope = JythonScriptParser.parseAST(codeWithoutLastLine) + .getLast(); + final String pre = crop > -1 && crop < m1.group(1).length() ? m1.group(1) + .substring(crop) : ""; + final Map names = scope.findStartsWith2(m1.group(2)); + for (final Map.Entry e : names.entrySet()) { + final String classname = e.getValue(); + if (null != classname) { + try { + final Class c = Class.forName(classname); + for (final java.lang.reflect.Constructor cons : c + .getConstructors()) + { + completions.add(makeDotCompletion(pre, m1.group(2), + new CompletionText(e.getKey(), c, cons))); + } + } + catch (final ClassNotFoundException cnfe) { + JythonDev.printTrace("Can't load class: " + classname); + } + } + completions.add(Completion.of((lastLine + e.getKey().substring(m1 + .group(2).length())).substring(crop))); + } + return scopeChoices(scope); + } + + // A function/constructor invocation: "name(". + final Matcher m1c = invocation.matcher(lastLine); + if (m1c.find()) { + final String name = m1c.group(2).substring(0, m1c.group(2).length() - 1); + final Scope scope = JythonScriptParser.parseAST(codeWithoutLastLine) + .getLast(); + final DotAutocompletions da = scope.find(name, DotAutocompletions.EMPTY); + if (da instanceof ConstructorAutocompletions) { + for (final CompletionText ct : da.get()) { + completions.add(makeDotCompletion("", name, ct)); + } + return scopeChoices(scope); + } + // Otherwise fall through to dot-name handling below. + } + + // A field/method expansion: "var.seed" or "expr().seed". + final Matcher m2 = dotNameToken.matcher(lastLine); + if (m2.find()) { + final String seed = m2.group(3); // can be empty + final String code, varName; + final Matcher m3 = assign.matcher(lastLine); + if (m3.find()) { + final String[] assignment = lastLine.split("="); + final String[] names = assignment[0].split(","); + varName = names[names.length - 1].trim(); + code = codeWithoutLastLine + lastLine.substring(0, lastLine.length() - + 1 - seed.length()); + } + else { + int start = 0; + while (Character.isWhitespace(lastLine.charAt(start++))); + --start; + String suffix = ""; + if (add_pass) { + add_pass = false; + suffix = ":\n "; + } + varName = "____GRAB____"; + code = codeWithoutLastLine + (add_pass ? ": pass" : "") + suffix + + lastLine.substring(0, start) + varName + " = " + lastLine.substring( + start, lastLine.length() - 1 - seed.length()); + } + final Scope scope = JythonScriptParser.parseAST(code); + final DotAutocompletions da = scope.getLast().find(varName, + DotAutocompletions.EMPTY); + final String fullPre = lastLine.substring(crop); + final String pre = fullPre.substring(0, fullPre.lastIndexOf(seed)); + final String lowerCaseSeed = seed.toLowerCase(); + + final List list = da.get().stream().filter(s -> s + .getReplacementText().toLowerCase().contains(lowerCaseSeed)).map( + s -> makeDotCompletion(pre, lowerCaseSeed, s)).collect(Collectors + .toList()); + sortCompletions(list, seed); + completions.addAll(list); + return scopeChoices(scope.getLast()); + } + + return null; + } + + private void moduleNameCompletions(final String first, final String pkgName, + final List completions) + { + jython_jar_modules.stream().filter(s -> s.startsWith(pkgName)).forEach(s -> // + completions.add(Completion.builder(first + " " + s + (first.equals("from") + ? " import " : "")).description("Python standard library module") + .kind(Completion.Kind.IMPORT).build())); + final String pkgNameFile = pkgName.replace('.', '/'); + for (final String dir : Scope.indexer.getLoadPath()) { + try { + Files.walk(new File(dir).toPath(), FileVisitOption.FOLLOW_LINKS) // + .map(path -> path.toFile().getAbsolutePath()) // + .filter(s -> s.startsWith(dir + pkgNameFile) && s.endsWith(".py")) // + .map(s -> (s.endsWith("__init__.py") // + ? s.substring(dir.length(), s.length() - 12) // + : s.substring(dir.length(), s.length() - 3)).replace('/', '.')) // + .forEach(s -> completions.add(Completion.builder(first + " " + s + + (first.equals("from") ? " import " : "")).description( + "Custom python module").kind(Completion.Kind.IMPORT).build())); + } + catch (final Exception e) { + JythonDev.print("Failed to read jython module file.", e); + } + } + } + + private void moduleMemberCompletions(final String pkgName, + final String member, final List completions) + { + final NModuleType mod = Scope.loadPythonModule(pkgName); + if (null != mod && !mod.getTable().keySet().isEmpty()) { + mod.getTable().keySet().stream().filter(s -> s.startsWith(member)).forEach( + s -> completions.add(Completion.builder("from " + pkgName + " import " + + s).kind(Completion.Kind.IMPORT).build())); + return; + } + if (null != mod) { + // Module exists but its __init__.py is empty: look into its folder. + for (final String dir : Scope.indexer.getLoadPath()) { + final File fdir = new File(dir + pkgName.replace('.', '/')); + if (fdir.exists() && fdir.isDirectory()) { + final String[] names = fdir.list(); + if (names == null) continue; + for (final String filename : names) { + if (filename.startsWith(member) && (new File(fdir.getAbsolutePath() + + "/" + filename).isDirectory() || filename.endsWith(".py"))) + { + completions.add(Completion.builder("from " + pkgName + " import " + + (filename.endsWith(".py") ? filename.substring(0, filename + .length() - 3) : filename)).kind(Completion.Kind.IMPORT) + .build()); + } + } + } + } + } + } + + // -- Import / class-name discovery (with auto-import edits) -- + + private void importCompletions(final String text, final String fullCode, + final List completions) + { + if (!ClassIndex.isCacheReady()) return; // don't block + + final Matcher m1 = fromImport.matcher(text); + if (m1.find()) { + ClassIndex.findClassNamesContaining(m1.group(3)).map( + formatter::singleToImportStatement).forEach(s -> completions.add( + Completion.builder(s).kind(Completion.Kind.IMPORT).build())); + return; + } + final Matcher m1f = fastImport.matcher(text); + if (m1f.find()) { + ClassIndex.findClassNamesForPackage(m1f.group(2)).map( + formatter::singleToImportStatement).forEach(s -> completions.add( + Completion.builder(s).kind(Completion.Kind.IMPORT).build())); + return; + } + final Matcher m2 = importStatement.matcher(text); + if (m2.find()) { + final String packageName = m2.group(3); + String className = m2.group(4); + final String[] bycomma = className.split(","); + String precomma = ""; + if (bycomma.length > 1) { + className = bycomma[bycomma.length - 1].trim(); + for (int i = 0; i < bycomma.length - 1; ++i) + precomma += bycomma[0] + ", "; + } + final java.util.stream.Stream stream; + if (className.length() > 0) stream = ClassIndex.findClassNamesStartingWith( + null == packageName ? className : packageName + "." + className); + else stream = ClassIndex.findClassNamesForPackage(packageName); + final String pre = m2.group(1) + precomma; + stream.map(s -> s.substring(Math.max(0, s.lastIndexOf('.') + 1))).forEach( + s -> completions.add(Completion.builder(pre + s).kind( + Completion.Kind.IMPORT).build())); + return; + } + final Matcher m3 = simpleClassName.matcher(text); + if (m3.find()) { + final String pre = m3.group(1); + for (final String className : ClassIndex.findSimpleClassNamesStartingWith( + m3.group(2))) + { + final String simpleName = className.substring(className.lastIndexOf( + '.') + 1); + final String importStmt = formatter.singleToImportStatement(className); + // Side effect on accept: insert the import near the top of the file. + final TextEdit edit = JythonImports.autoImportEdit(fullCode, className, + importStmt); + final Completion.Builder b = Completion.builder(pre + simpleName).kind( + Completion.Kind.CLASS).summary(importStmt); + if (edit != null) b.additionalEdits(Collections.singletonList(edit)); + completions.add(b.build()); + } + } + } + + // -- Helpers -- + + /** Builds a neutral completion from a {@link CompletionText} and prefix. */ + private static Completion makeDotCompletion(final String pre, + final String seed, final CompletionText ct) + { + final List ps = ct.getMethodArgs(); + if (null != ps && null != ct.getReturnType()) { + String text = ct.getReplacementText(); + if (text.endsWith("()")) text = text.substring(0, text.length() - 2); + final List params = new ArrayList<>(); + for (final Parameter p : ps) { + params.add(new Completion.Parameter(p.getName(), p.getType() + .getCanonicalName())); + } + return Completion.builder(pre + text).kind(Completion.Kind.METHOD) + .parameters(params).returnType(ct.getReturnType()).summary(ct + .getSummary()).build(); + } + return Completion.builder(pre + ct.getReplacementText()).summary(ct + .getSummary()).build(); + } + + /** A {@link ParameterChoices} that suggests in-scope variables by type. */ + private static ParameterChoices scopeChoices(final Scope scope) { + return parameter -> { + final String type = parameter.type(); + if (type == null) return Collections.emptyList(); + Class clazz; + switch (type) { + case "int": clazz = Integer.class; break; + case "long": clazz = Long.class; break; + case "float": clazz = Float.class; break; + case "double": clazz = Double.class; break; + case "boolean": clazz = Boolean.class; break; + case "char": clazz = Character.class; break; + case "short": clazz = Short.class; break; + case "byte": clazz = Byte.class; break; + default: + try { + clazz = Class.forName(type); + } + catch (final Throwable t) { + return Collections.emptyList(); + } + } + final Class c = clazz; + final List vars = scope.findVarsByType(type, c).collect(Collectors + .toList()); + final List out = new ArrayList<>(vars.size()); + for (int i = 0; i < vars.size(); i++) { + // Innermost-scope variables come first; rank them higher. + out.add(Completion.builder(vars.get(i)).kind(Completion.Kind.VARIABLE) + .relevance(vars.size() - i).build()); + } + return out; + }; + } + + private void sortCompletions(final List completions, + final String pre) + { + completions.sort(new Comparator() { + + @Override + public int compare(final Completion o1, final Completion o2) { + final int p1 = o1.insertionText().startsWith(pre) ? 0 : Integer.MAX_VALUE; + final int p2 = o2.insertionText().startsWith(pre) ? 0 : Integer.MAX_VALUE; + if (p1 == p2) return o1.insertionText().compareTo(o2.insertionText()); + return p1 - p2; + } + }); + } + + /** Locates the jython jar on the classpath and lists its Lib/*.py modules. */ + private static List findJythonModules() { + try { + final java.security.CodeSource cs = org.python.core.PySystemState.class + .getProtectionDomain().getCodeSource(); + if (cs == null || cs.getLocation() == null) return Collections.emptyList(); + final File jar = new File(cs.getLocation().toURI()); + if (!jar.isFile()) return Collections.emptyList(); + try (final JarFile jf = new JarFile(jar)) { + return jf.stream().map(JarEntry::getName).filter(s -> s.startsWith( + "Lib/") && s.endsWith(".py")).map(s -> (s.endsWith("/__init__.py") // + ? s.substring(4, s.length() - 12) // + : s.substring(4, s.length() - 3)).replace('/', '.')).collect( + Collectors.toList()); + } + } + catch (final Exception e) { + JythonDev.print("Cannot locate the jython jar for stdlib modules", e); + return Collections.emptyList(); + } + } +} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java new file mode 100644 index 0000000..8ad1481 --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java @@ -0,0 +1,101 @@ +/*- + * #%L + * JSR-223-compliant Jython scripting language plugin. + * %% + * Copyright (C) 2020 - 2025 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.plugins.scripting.jython; + +import org.scijava.Priority; +import org.scijava.plugin.Plugin; +import org.scijava.script.ScriptLanguage; +import org.scijava.script.complete.AbstractCodeCompleterPlugin; +import org.scijava.script.complete.ClassIndex; +import org.scijava.script.complete.CodeCompleterPlugin; +import org.scijava.script.complete.CompletionRequest; +import org.scijava.script.complete.CompletionResult; + +/** + * Toolkit-agnostic code completion for the Jython language, contributed by the + * Jython scripting plugin itself. Depends only on scijava-common and jython, so + * it works in any front end that consumes the SciJava completion SPI (the Swing + * script editor, a REPL, etc.) without any editor- or RSyntaxTextArea-specific + * code. + * + * @author Albert Cardona + * @author Curtis Rueden + * @see JythonAutoCompletions + */ +@Plugin(type = CodeCompleterPlugin.class, name = "Jython", + priority = Priority.HIGH) +public class JythonCodeCompleter extends AbstractCodeCompleterPlugin { + + private final JythonAutoCompletions engine = new JythonAutoCompletions(); + + public JythonCodeCompleter() { + // Warm the class index in the background, as completion needs it. + new Thread(ClassIndex::ensureCache, "Jython-ClassIndex-warmup").start(); + } + + @Override + public boolean supports(final ScriptLanguage language) { + if (language == null) return false; + final String name = language.getLanguageName(); + // Matches "Jython" and "Python (Jython)" without hijacking CPython. + return name != null && name.toLowerCase().contains("jython"); + } + + @Override + public CompletionResult complete(final CompletionRequest request) { + final String textToCaret = request.textToCaret(); + final int caret = request.offset(); + final String lastLine = request.lineToCaret(); + final String codeWithoutLastLine = textToCaret.substring(0, textToCaret + .length() - lastLine.length()); + final String alreadyEntered = alreadyEnteredText(lastLine); + final int replaceStart = caret - alreadyEntered.length(); + + final JythonAutoCompletions.Result result = engine.completionsFor( + codeWithoutLastLine, lastLine, alreadyEntered); + return new CompletionResult(result.completions, replaceStart, + result.parameterChoices); + } + + /** + * The portion of the current line the editor should replace. Mirrors the + * legacy RSTA logic: a maximal suffix of letters, digits, {@code '.'}, + * {@code '_'} and spaces (spaces let "from x import Y" complete as a unit). + */ + private static String alreadyEnteredText(final String lastLine) { + int start = lastLine.length(); + while (start > 0) { + final char c = lastLine.charAt(start - 1); + if (Character.isLetterOrDigit(c) || c == '.' || c == '_' || c == ' ') // + start--; + else break; + } + return lastLine.substring(start); + } +} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonDev.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonDev.java new file mode 100644 index 0000000..ca8d306 --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonDev.java @@ -0,0 +1,65 @@ +/*- + * #%L + * Jython language support for SciJava Script Editor. + * %% + * Copyright (C) 2020 - 2024 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.plugins.scripting.jython; + +public class JythonDev +{ + static public int SILENT = 0, + MESSAGES = 1, + ERRORS = 2; + + /** Adjust value to see debugging information, including: + * SILENT: nothing is printed at all. + * MESSAGES: prints light messages indicating some internals and the kinds of errors. + * ERRORS: prints everything including exceptions. + */ + static public int debug = MESSAGES; + + static public final void print(Object s) { + if (debug >= MESSAGES) System.out.println(s); + } + + static public final void printError(Throwable e) { + if (debug >= ERRORS) e.printStackTrace(); + } + + static public final void printTrace(Object s) { + if (debug >= ERRORS) System.out.println(s); + } + + static public final void print(Object s, Throwable e) { + if (debug >= ERRORS) { + System.out.println(s); + e.printStackTrace(); + } else if (debug >= MESSAGES) { + System.out.println(s.toString() + " :: " + e.getMessage()); + } + } + +} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonImportFormat.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonImportFormat.java new file mode 100644 index 0000000..1560a1e --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonImportFormat.java @@ -0,0 +1,51 @@ +/*- + * #%L + * JSR-223-compliant Jython scripting language plugin. + * %% + * Copyright (C) 2020 - 2025 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.plugins.scripting.jython; + +/** + * Formats fully qualified class names as Jython import statements. + * + * @author Albert Cardona + */ +public class JythonImportFormat { + + /** Given a fully-qualified class name, returns a Jython import statement. */ + public final String singleToImportStatement(final String className) { + final int idot = className.lastIndexOf('.'); + if (-1 == idot) return "import " + className; + return dualToImportStatement(className.substring(0, idot), className + .substring(idot + 1)); + } + + public String dualToImportStatement(final String packageName, + final String simpleClassName) + { + return "from " + packageName + " import " + simpleClassName; + } +} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonImports.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonImports.java new file mode 100644 index 0000000..3ae301b --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonImports.java @@ -0,0 +1,163 @@ +/*- + * #%L + * JSR-223-compliant Jython scripting language plugin. + * %% + * Copyright (C) 2020 - 2025 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.plugins.scripting.jython; + +import java.util.HashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.scijava.script.complete.Completion.TextEdit; + +/** + * Parses the {@code import} statements in a Jython script, and computes the + * {@link TextEdit} needed to auto-insert a missing import near the top of the + * file. This is the toolkit-agnostic successor to the editor-side + * auto-import-on-accept logic. + * + * @author Albert Cardona + */ +public final class JythonImports { + + private static final Pattern importPattern = Pattern.compile( + "^(from[ \\t]+([a-zA-Z_][a-zA-Z0-9._]*)[ \\t]+|)import[ \\t]+([a-zA-Z_][a-zA-Z0-9_]*[ \\ta-zA-Z0-9_,]*)[ \\t]*([\\\\]*|)[ \\t]*(#.*|)$"); + private static final Pattern tripleQuotePattern = Pattern.compile("\"\"\""); + + private JythonImports() { + // prevent instantiation + } + + /** A single imported class, with its declaration line number. */ + public static final class Import { + + public final String className, alias; + public final int lineNumber; + + public Import(final String className, final String alias, + final int lineNumber) + { + this.className = className; + this.alias = null != alias ? alias : className.substring(className + .lastIndexOf('.') + 1); + this.lineNumber = lineNumber; + } + + public Import(final String packageName, final String[] parts, + final int lineNumber) + { + this(packageName + "." + parts[0], 3 == parts.length ? parts[2] : null, + lineNumber); + } + } + + /** Scans the whole script for imported classes, keyed by alias/simple name. */ + public static HashMap findImportedClasses(final String text) { + final HashMap importedClasses = new HashMap<>(); + String packageName = ""; + boolean endingBackslash = false; + boolean insideTripleQuotes = false; + + final String[] lines = text.split("\n"); + for (int i = 0; i < lines.length; ++i) { + final String line = lines[i]; + final String trimmed = line.trim(); + if (0 == trimmed.length() || '#' == trimmed.charAt(0)) continue; + final Matcher mq = tripleQuotePattern.matcher(line); + int n_triple_quotes = 0; + while (mq.find()) ++n_triple_quotes; + if (insideTripleQuotes) { + if (0 != n_triple_quotes % 2) insideTripleQuotes = false; + continue; + } + else if (0 != n_triple_quotes % 2) { + insideTripleQuotes = true; + continue; + } + if (endingBackslash) { + String importLine = line; + final int backslash = line.lastIndexOf('\\'); + if (backslash > -1) importLine = importLine.substring(0, backslash); + else { + final int sharp = importLine.lastIndexOf('#'); + if (sharp > -1) importLine = importLine.substring(0, sharp); + } + for (final String simpleClassName : importLine.split(",")) { + final Import im = new Import(packageName, simpleClassName.trim().split( + "\\s"), i); + importedClasses.put(im.alias, im); + } + endingBackslash = -1 != backslash; + continue; + } + final Matcher m = importPattern.matcher(line); + if (m.find()) { + packageName = null == m.group(2) ? "" : m.group(2); + for (final String simpleClassName : m.group(3).split(",")) { + final Import im = new Import(packageName, simpleClassName.trim().split( + "\\s"), i); + importedClasses.put(im.alias, im); + } + endingBackslash = null != m.group(4) && m.group(4).length() > 0 && + '\\' == m.group(4).charAt(0); + } + } + return importedClasses; + } + + /** + * Computes the edit that inserts {@code importStatement} after the last + * existing import (or at the top), or {@code null} if {@code className} is + * already imported. + */ + public static TextEdit autoImportEdit(final String text, + final String className, final String importStatement) + { + final HashMap imported = findImportedClasses(text); + for (final Import im : imported.values()) { + if (im.className.equals(className)) return null; // already imported + } + int lastImportLine = 0; + for (final Import im : imported.values()) { + if (im.lineNumber > lastImportLine) lastImportLine = im.lineNumber; + } + final int targetLine = imported.isEmpty() ? 0 : lastImportLine + 1; + final int offset = offsetOfLine(text, targetLine); + return TextEdit.insert(offset, importStatement + "\n"); + } + + /** Character offset of the start of the given (0-based) line in {@code text}. */ + private static int offsetOfLine(final String text, final int line) { + int offset = 0; + for (int l = 0; l < line; l++) { + final int nl = text.indexOf('\n', offset); + if (nl < 0) return text.length(); + offset = nl + 1; + } + return offset; + } +} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonScriptParser.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonScriptParser.java new file mode 100644 index 0000000..c8b22fe --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonScriptParser.java @@ -0,0 +1,431 @@ +/*- + * #%L + * Jython language support for SciJava Script Editor. + * %% + * Copyright (C) 2020 - 2024 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.plugins.scripting.jython; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.antlr.runtime.tree.CommonTree; +import org.python.antlr.PythonTree; +import org.python.antlr.ast.Assign; +import org.python.antlr.ast.Attribute; +import org.python.antlr.ast.BinOp; +import org.python.antlr.ast.Call; +import org.python.antlr.ast.ClassDef; +import org.python.antlr.ast.Expr; +import org.python.antlr.ast.For; +import org.python.antlr.ast.FunctionDef; +import org.python.antlr.ast.If; +import org.python.antlr.ast.Import; +import org.python.antlr.ast.ImportFrom; +import org.python.antlr.ast.Name; +import org.python.antlr.ast.Num; +import org.python.antlr.ast.Return; +import org.python.antlr.ast.Str; +import org.python.antlr.ast.TryExcept; +import org.python.antlr.ast.TryFinally; +import org.python.antlr.ast.Tuple; +import org.python.antlr.ast.While; +import org.python.antlr.ast.With; +import org.python.antlr.ast.Yield; +import org.python.antlr.ast.alias; +import org.python.antlr.ast.arguments; +import org.python.antlr.base.expr; +import org.python.antlr.base.mod; +import org.python.core.CompileMode; +import org.python.core.CompilerFlags; +import org.python.core.ParserFacade; +import org.python.core.PyFloat; +import org.python.core.PyInteger; +import org.python.core.PyObject; +import org.python.indexer.types.NModuleType; + +public class JythonScriptParser +{ + /** + * Parse valid jython code. + * + * @param code + * + * @return The top-level {@link Scope}, which is empty (see {@link Scope#isEmpty()}) when the code has errors and can't be parsed by {@link ParserFacade#parse(String, CompileMode, String, CompilerFlags)}. + */ + static public Scope parseAST(final String code) { + // The code includes from beginning of the file until the point at which an autocompletion is requested. + // Therefore, remove the last line, which would fail to parse because it is incomplete + try { + final mod m = ParserFacade.parse(code, CompileMode.exec, "", new CompilerFlags()); + final Scope scope = parseNode(m.getChildren(), null, null); + return null == scope ? new Scope(null) : scope; + } catch (Throwable t) { + JythonDev.printError(t); + return new Scope(null); + } + } + + /** + * Parse a {@code List} of {@code PythonTree} instances, each representing a python statement + * including {@code ImportFrom, Assign, FunctionDef, ClassDef}. + * + * @param children The list of statements. + * @param parent The {@code Scope} that contains these statements. + * @param className Indicates whether the containing {@code Scope} is a python class definition, otherwise null. + * @return A new {@code Scope} containing {@code DotAutocompletions} to represent each statement. + */ + static public Scope parseNode(final List children, final Scope parent, final String className) { + if (null == children) return parent; // will return null if parent is null + final Scope scope = new Scope(parent, className); + parseNode(scope, children, className); + return scope; + } + + static public void parseNode(final Scope scope, final List children, final String className) { + + for (final PythonTree child : children) { + JythonDev.printTrace(child.getClass()); + + if (child instanceof ImportFrom) + scope.imports.putAll(parseImportFromStatement( (ImportFrom)child )); + else if (child instanceof Import) + scope.imports.putAll(parseImportStatement((Import)child)); + else if (child instanceof Assign) + scope.vars.putAll(parseAssignStatement( (Assign)child, scope )); + else if (child instanceof FunctionDef) + parseFunctionDef((FunctionDef)child, scope); + else if (child instanceof ClassDef) + parseClassDef((ClassDef)child, scope); + else if (child instanceof Expr) + parseExpr((Expr)child, scope); + else if (child instanceof If || child instanceof For || child instanceof While + || child instanceof TryExcept || child instanceof TryFinally || child instanceof With) + // no new scope in if/for/while/with/try/ statements in python + parseNode(scope, child.getChildren(), null); + else + JythonDev.printTrace("IGNORING child: " + child + " -- " + (null != child.getChildren() ? + String.join("::", child.getChildren().stream().map(c -> c.toString()).collect(Collectors.toList())) + : "")); + } + } + + static public void parseExpr(final Expr child, final Scope scope) { + // child.getText() shows child is the base + JythonDev.printTrace("Expr: " + child.getText() + ", " + child.getInternalValue() + ", " + child.getValue() + ", children: " + String.join(", ", child.getChildren().stream().map(PythonTree::toString).collect(Collectors.toList()))); + } + + /** + * Parse import statements, considering aliases. + * There can be more than one if e.g. commas were used, as in "from ij import IJ, ImageJ". + * @param im + * @return A map of simple class names or their aliases as keys versus a {@code DotAutocompletions} as value. + */ + static public Map parseImportFromStatement(final ImportFrom im) { + final Map classes = new HashMap<>(); + final String module = im.getModule().toString(); + for (int i=0; i parseImportStatement(final Import im) { + final Map classes = new HashMap<>(); + final List aliases = im.getInternalNames(); + for (final alias a: aliases) { + final String as = a.getInternalAsname(); + final String name = a.getInternalName(); + classes.put(null == as || "None" == as ? name : as, new StaticDotAutocompletions(name)); + } + return classes; + } + + static private DotAutocompletions maybeStaticToDot(final PythonTree node, final DotAutocompletions da) { + JythonDev.printTrace("children count:" + node.getChildCount() + ", children: " + (null != node.getChildren() ? node.getChildren().stream().map(c -> c.toString()).collect(Collectors.toList()) : "")); + if (node.getChildCount() > 0 && da instanceof StaticDotAutocompletions) { + // It's a right expression (a constructor invocation assigned to a variable on the left) so the left is an instance of the class + return new VarDotAutocompletions(da.getClassname()); + } + return da; + } + + /** + * Parse an assignment (an equal sign) to find out the class of the left side (the variable) + * by asking the right side about what it is or returns. + * There can be more than one variable when using deconstruction statements like e.g. "width, height = imp.getWidth(), imp.getHeight()". + * + * @param assign + * @param scope + * @return A map of variable names as keys versus {@code DotAutocompletions} as values. + */ + static public Map parseAssignStatement(final Assign assign, final Scope scope) { + final Map assigns = new HashMap<>(); + //final expr right = assign.getInternalValue(); // strangely this works + final PythonTree right = assign.getChildren().get(1); + final PythonTree left = assign.getChildren().get(0); + if (null != left.getChildren() && left.getChildren().size() > 1 + && (right instanceof Tuple || right instanceof org.python.antlr.ast.List)) { // TODO are there any other possible? + for (int i=0; i attrs = new ArrayList<>(); + while (leftn instanceof Attribute) { + final Attribute attr = (Attribute)leftn; + attrs.add(attr); + leftn = attr.getValue(); + } + if (leftn instanceof Name) { + String varName = ((Name)leftn).getInternalId(); + Collections.reverse(attrs); + Scope scopeC = scope; + for (final Attribute attr: attrs) { + final DotAutocompletions ac = scopeC.find(varName, DotAutocompletions.EMPTY); // in the first iteration it finds the completions for the base Name + if (ac instanceof ClassDotAutocompletions) { + final ClassDotAutocompletions cda = (ClassDotAutocompletions)ac; + varName = attr.getInternalAttrName().getInternalId(); // in the first iteration becomes the name of the first Attribute + // Add the name of the Attribute to the list of expansions for the prior varName + scopeC = cda.scope; // prepare scope for next iteration + //scopeC.vars.put(varName, cda); // Is this needed? I think it isn't + cda.put(new CompletionText(varName)); // add varName (e.g. "width") as a possible expansion for the prior varName (e.g. "self"). + } else { + // Don't know how to handle e.g. self.doThis().that = 10 because for "doThis()" there would be a class return type stored + break; + } + } + return assigns; + } + } + + return assigns; + } + + /** + * Adds a child Scope to the given parent Scope, and also a variable to the parent scope + * with no class, just for the function name. Then populates the child scope. + * + * @param fn + * @param parent + */ + static public void parseFunctionDef(final FunctionDef fn, final Scope parent) { + // Get the function name + final String name = fn.getInternalName(); + // Get the list of argument names, if any + arguments args = fn.getInternalArgs(); + final List argumentNames = args != null && args.getChildren() != null ? // why oh why not return an empty List + args.getChildren().stream().map(arg -> arg.getNode().toString()).collect(Collectors.toList()) + : Collections.emptyList(); + // Parse the function body + final List children = fn.getChildren(); + if (null == children) return; + final Scope fn_scope = new Scope(parent, null); + // Add arguments to the scope -- must be done BEFORE parseNode + for (final String arg: argumentNames) { + // Empty. For the first argument ("self" or similar) will be replaced later if it's part of a class definition. + fn_scope.vars.put(arg, new ClassDotAutocompletions("", Collections.emptyList(), Collections.emptyList(), + new ArrayList(), fn_scope)); + } + parseNode(fn_scope, fn.getChildren(), null); + // Get the return type, if any + final PythonTree last = fn.getChildren().get(fn.getChildCount() -1); + final String returnClassName = last instanceof Return ? parseRight(last.getChildren().get(0), fn_scope).toString() : null; + parent.vars.put(name, new DefVarDotAutocompletions(name, returnClassName, argumentNames, fn_scope)); + } + + /** + * Adds an entry to the parent scope with the python classname, e.g. "Volume", with its parameters (from its __init__ method if any), + * including as well the methods and fields from any superclass, + * and adds another entry in the class scope for "self" with all the class method names. + * + * @param c + * @param parent + */ + static public void parseClassDef(final ClassDef c, final Scope parent) { + final String pyClassname = c.getInternalName(); + final Scope class_scope = parseNode(c.getChildren(), parent, pyClassname); + // Methods of the class + final List classDotAutocompletions = new ArrayList<>(); + // Iterate vars of the scope, which are those of the class only + for (final DotAutocompletions da: class_scope.vars.values()) { + if (da instanceof DefVarDotAutocompletions) { + final DefVarDotAutocompletions dda = (DefVarDotAutocompletions)da; + classDotAutocompletions.add(new CompletionText(dda.fnName)); + } + } + // Superclasses + final List superclassNames = new ArrayList<>(); + for (final expr e: c.getInternalBases()) { + final DotAutocompletions da = parent.find(e.getText(), null); + if (null == da || null == da.getClassname()) + JythonDev.print("Could not find completions and className for " + e.getText()); + else + superclassNames.add(da.getClassname()); + } + // Search for the constructor __init__ if any to get the constructor parameters + final List argumentNames = new ArrayList<>(); + final ClassDotAutocompletions cda = new ClassDotAutocompletions(pyClassname, superclassNames, argumentNames, classDotAutocompletions, class_scope); + for (final PythonTree child: c.getChildren()) { + if (!(child instanceof FunctionDef)) continue; + final FunctionDef fn = (FunctionDef)child; + final List args = fn.getInternalArgs().getChildren(); + if (args.size() > 0) { + // Populate class constructor argument list by reading them from the __init__ method + if ("__init__".equals(fn.getInternalName())) { + // Add all arguments except the first one, which is the internal reference conventionally named "self" + argumentNames.addAll(args.subList(1, args.size()).stream() + .map(arg -> arg.getNode().toString()).collect(Collectors.toList())); + } + // Add completions to the first argument (generally "self") + // TODO check annotations, shouldn't add them if the function is static + final DefVarDotAutocompletions fnda = (DefVarDotAutocompletions)class_scope.vars.get(fn.getInternalName()); + final DotAutocompletions argda = fnda.scope.vars.get(args.get(0).getNode().toString()); + if (argda instanceof ClassDotAutocompletions) { + // Replace the autocompletions for the first argument with cda plus whichever autocompletions it accumulated within the function definition. + ((ClassDotAutocompletions)argda).mutateIntoPlus(cda); + } + //.scope.vars.put(args.get(0).getNode().toString(), cda); + } + } + + // Add to the parent scope for expansion of the constructor name plus parameters + parent.vars.put(pyClassname, cda); + } + + /** Discover the class returned by the right statement in an assignment. + * + * @param right + * @param scope + */ + static public DotAutocompletions parseRight(final PyObject right, final Scope scope) { + if (right instanceof Name) { + // e.g. the name of another variable: + // imp2 = imp + // e.g. the name of a constructor or a function + // ip = ByteProcessor(512, 512) + return scope.find( ((Name)right).getInternalId(), DotAutocompletions.EMPTY); + } + if (right instanceof Num) { + // e.g. return 10 + // e.g. n = 42 + final Class c = ((Num)right).getInternalN().getClass() == PyInteger.class ? Long.TYPE : Double.TYPE; // TODO this creates trouble for Scope.findVarsByType and would be perhaps easier as Long and Double (non-primitive). + return new VarDotAutocompletions(c.toString()); // becomes "long" or "double" + } + if (right instanceof Str) { + // Literal String + return new VarDotAutocompletions(String.class.getCanonicalName()); + } + if (right instanceof Attribute) { + // e.g. a field or a method + // gray8 = IJ.getImage().GRAY8 + final Attribute attr = (Attribute)right; + final DotAutocompletions da = parseRight(attr.getValue(), scope); + if (DotAutocompletions.EMPTY == da) + return da; + final String name = attr.getInternalAttr(); + final String className = da.getClassname(); + try { + final Class c = Class.forName(className); + for (final Method m : c.getMethods()) + if (m.getName().equals(name)) + return new VarDotAutocompletions(m.getReturnType().getName()); + return new VarDotAutocompletions(c.getField(name).getType().getName()); + } catch (Exception e) { + JythonDev.print("Could not find method or field " + name + " in class " + className, e); + } + // Could also be a python module, e.g. attempting to autocomplete "os.path." + try { + final NModuleType module = Scope.indexer.loadModule(className + "." + name); // overly expensive: any way to check it exists without loading it? + if (null != module) + return new StaticDotAutocompletions(className + "." + name); + } catch (Exception e) { + JythonDev.printTrace("Not a python module: " + className + "." + name); + } + } + if (right instanceof Call) { + // e.g. a method call, in particular the last one in the chain + // imp = IJ.getImage().getProcessor() + final Call call = (Call)right; + return parseRight(call.getFunc(), scope); // getFunc() returns an Attribute or a Name + } + if (right instanceof Yield) { + final Yield yield = (Yield)right; + return parseRight(yield.getValue(), scope); + } + if (right instanceof BinOp) { + // e.g., division, multiplication, addition, subtraction + // What actual type is returned, I don't know: python allows operator overloading. + final BinOp binop = (BinOp)right; + // Weak attempt at finding out what is it (will not succeed if neither is a number) + // TODO this should be done recursively + JythonDev.printTrace("BinOp: weak attempt at finding out what kind of number it returns."); + // First, check if any is a number + final Class typeL = binop.getLeft().isNumberType() ? ((Num)binop.getLeft()).getInternalN().getClass() : null, + typeR = binop.getRight().isNumberType() ? ((Num)binop.getRight()).getInternalN().getClass() : null; + if (null != typeL || null != typeR) { + // If both are integers + if (typeL == PyInteger.class && typeR == PyInteger.class) + return new VarDotAutocompletions(Long.class.toString()); // long + // If any is a float + if (typeL == PyFloat.class || typeR == PyFloat.class) + return new VarDotAutocompletions(Double.class.toString()); // double + } + // Go by whatever left is, or if empty, whatever right is: no guarantee to be correct, but can be correct often + final DotAutocompletions da = parseRight(binop.getLeft(), scope); + if (da != DotAutocompletions.EMPTY) + return da; + return parseRight(binop.getRight(), scope); + } + + JythonDev.printTrace("Unsupported 'right' is: " + right + " " + (right != null ? right.getClass() : "")); + + return DotAutocompletions.EMPTY; + } +} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/Scope.java b/src/main/java/org/scijava/plugins/scripting/jython/Scope.java new file mode 100644 index 0000000..04bad91 --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/jython/Scope.java @@ -0,0 +1,335 @@ +/*- + * #%L + * Jython language support for SciJava Script Editor. + * %% + * Copyright (C) 2020 - 2024 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.plugins.scripting.jython; + +import java.io.File; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.nio.file.StandardWatchEventKinds; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Hashtable; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.python.indexer.Indexer; +import org.python.indexer.types.NModuleType; + + + +public class Scope { + final Scope parent; + final String className; // if any + final private List children = new ArrayList<>(); + final HashMap imports = new HashMap<>(); + final HashMap vars = new HashMap<>(); + + /** Access to jython's builtins (functions in the global scope) and default modules (array, itertools, csv, etc.) + * as well as to an other user-defined modules. + */ + static final Indexer indexer = new Indexer(); + + static private Thread module_watcher; + static private WatchService watcher; + static private Hashtable keys = new Hashtable<>(); + + static { + try { + watcher = FileSystems.getDefault().newWatchService(); + module_watcher = new Thread() { + @Override + public void run() { + while (true) { + if (isInterrupted()) return; + WatchKey key = null; + try { + key = watcher.take(); // waits until there is an event + } catch (InterruptedException x) { + return; + } + if (keys.containsKey(key)) { + JythonDev.print("Python module at:\n" + keys.get(key) + "\n ... was updated. Clearing indexer cache."); + // One of the files changed: unload all, given that parent modules would have been loaded as well + // and it gets complicated quickly to find out which need to be reloaded and which don't. + keys.clear(); + synchronized (indexer) { + indexer.clearModuleTable(); + } + } + } + } + }; + module_watcher.setPriority(Thread.NORM_PRIORITY); + module_watcher.start(); + } catch (Exception e ){ + JythonDev.print("Failed to start filesystem watcher service for python modules", e); + } + } + + /** + * Load a python module and watch its file, if any. + * When the file is updated or deleted, all loaded modules will be removed from the cache, + * because the loading of a module may trigger the loading of a parent module + * or additional modules that it links to. + * + * @param qname + * @return The python module. + */ + static NModuleType loadPythonModule(final String qname) { + synchronized (indexer) { + NModuleType mod = null; + try { + mod = indexer.loadModule(qname); + if (null == mod) return null; + } catch (Exception e) { + JythonDev.print("Could not load python module named " + qname, e); + return null; + } + try { + // Watch ALL THE FILES in the containing directly + final String qname_slash = qname.replace(".", "/"); + final String filepath = indexer.getLoadedFiles().stream() + .filter(s -> s.endsWith("/" + qname_slash + ".py") || s.endsWith("/" + qname_slash + "/__init__.py")).findFirst().orElse(null); + if (null != filepath) { + final Path path = new File(filepath).getParentFile().toPath(); // watching directories + final WatchKey key = path.register(watcher, + StandardWatchEventKinds.ENTRY_MODIFY, + StandardWatchEventKinds.ENTRY_DELETE); + keys.put(key, path); + } else { + JythonDev.print("Python module " + qname + " doesn't have an associated file path."); + } + } catch (Exception e) { + JythonDev.print("Could not load python module named " + qname, e); + } + return mod; + } + } + + public Scope(final Scope parent) { + this(parent, null); + } + + public Scope(final Scope parent, final String className) { + this.parent = parent; + if (null != parent) { + parent.children.add(this); + } + this.className = className; + } + + public boolean isEmpty() { + return imports.isEmpty() && vars.isEmpty(); + } + + public DotAutocompletions find(final String name, final DotAutocompletions default_value) { + Scope scope = this; + while (null != scope) { + DotAutocompletions da = scope.vars.get(name); + if (null == da) + da = scope.imports.get(name); + if (null != da) + return da; + scope = scope.parent; + } + // Check python builtins + final String builtin_className = "__builtin__." + name + "."; // e.g. __builtin__.str.join + final List dotAutocompletions = indexer.getBindings().keySet().stream() + .filter(s -> s.startsWith(builtin_className)) + .map(s -> new CompletionText(s.substring(builtin_className.length()))) + .collect(Collectors.toList()); + if (!dotAutocompletions.isEmpty()) + return new ClassDotAutocompletions(name, Collections.emptyList(), Collections.emptyList(), dotAutocompletions, this); + + return default_value; + } + + public List findStartsWith(final String name) { + final List completions = new ArrayList<>(); + Scope scope = this; + while (null != scope) { + for (final String varName: scope.vars.keySet()) { + if (varName.startsWith(name)) completions.add(varName); + } + for (final String importName: scope.imports.keySet()) { + if (importName.startsWith(name)) completions.add(importName); + } + for (String builtinName: indexer.getBindings().keySet()) { + if (builtinName.startsWith("__builtin__.")) + builtinName = builtinName.substring(12); // without the "__builtin__." prefix + if (builtinName.startsWith(name)) completions.add(builtinName); + } + scope = scope.parent; + } + return completions; + } + + /** Return a table of names vs classnames, with classnames being null for python builtins. + * + * @param name + * @return + */ + public Map findStartsWith2(final String name) { + final Map completions = new HashMap<>(); + Scope scope = this; + while (null != scope) { + for (final Map.Entry e: scope.vars.entrySet()) { + if (e.getKey().startsWith(name)) completions.put(e.getKey(), e.getValue().getClassname()); + } + for (final Map.Entry e: scope.imports.entrySet()) { + if (e.getKey().startsWith(name)) completions.put(e.getKey(), e.getValue().getClassname()); + } + for (String builtinName: indexer.getBindings().keySet()) { + if (builtinName.startsWith("__builtin__.")) + builtinName = builtinName.substring(12); // without the "__builtin__." prefix + if (builtinName.startsWith(name)) completions.put(builtinName, null); + } + scope = scope.parent; + } + return completions; + } + + + /** Find vars by type, recursively upstream the nested scopes, listing first those of the innermost scope. + * + * @param clazz + */ + public Stream findVarsByType(final String type, final Class clazz) { + Stream varNames = new ArrayList().stream(); + Scope scope = this; + JythonDev.printTrace("Scope.findVarsByType: searching for type " + type + " and class " + clazz.getCanonicalName()); + while (null != scope) { + varNames = Stream.concat(varNames, + scope.vars.entrySet().stream() + .filter(e -> { + try { + JythonDev.printTrace("Scope.findVarsByType, testing: " + e.getKey() + " :: " + e.getValue() + " with class " + e.getValue().getClassname()); + if (e.getKey().startsWith("____")) return false; // injected variables in JythonAutoCompletions.completionsFor + final String classname = e.getValue().getClassname(); + if (null == classname) return false; + if (type.equals(classname)) { + JythonDev.printTrace("type == classname: " + type); + return true; + } + // Handle compatible numeric arguments + if (Number.class.isAssignableFrom(clazz)) { + // Python only has long or float + if (classname.equals("float")) { + return true; // all numeric types will fit + } + if (classname.equals("long")) { + // or "long", but that was exact-matched earlier + return type.equals("int") + || type.equals("short") + || type.equals("byte") + || clazz.isAssignableFrom(Long.class) + || clazz.isAssignableFrom(Integer.class) + || clazz.isAssignableFrom(Short.class) + || clazz.isAssignableFrom(Byte.class); + } + } + // Fix class when it's a primitive number + Class c = null; + switch (classname) { + case "long": c = Long.class; break; + case "float": c = Float.class; break; + default: c = Class.forName(classname); + } + // Search for subclass or interface + return clazz.isAssignableFrom(c); + + // TODO: search for methods of non-matching classes that return a matching type + + } catch (ClassNotFoundException e1) { + JythonDev.print("Cannot load class " + e.getValue(), e1); + } + return false; + }) + .map(e -> e.getKey())); + scope = scope.parent; + } + return varNames; + } + + + public HashMap getImports() { + final HashMap imports = new HashMap<>(this.imports); + Scope parent = this.parent; + while (parent != null) { + imports.putAll(parent.getImports()); + parent = parent.parent; + } + return imports; + } + + public HashMap getVars() { + final HashMap vars = new HashMap<>(this.vars); + Scope parent = this.parent; + while (parent != null) { + vars.putAll(parent.getVars()); + parent = parent.parent; + } + return vars; + } + + public boolean isClass() { + return this.className != null; + } + + public Scope getLast() { + if (children.isEmpty()) return this; + return children.get(children.size() -1).getLast(); + } + + public void print(final String indent) { + if ("" == indent) { + System.out.println("scope global:"); + } + System.out.println(indent + "available imports: " + String.join(", ", this.getImports().keySet())); + System.out.println(indent + "declared imports:"); + for (final Map.Entry e: imports.entrySet()) + System.out.println(indent + " import :: " + e.getKey() + " --> " + e.getValue()); + System.out.println(indent + "available vars: " + String.join(", ", this.getVars().keySet())); + System.out.println(indent + "declared vars:"); + for (final Map.Entry e: vars.entrySet()) { + System.out.println(indent + " var :: " + e.getKey() + " = " + e.getValue()); + } + + int i = 0; + for (final Scope child: children) { + System.out.println(indent + "scope[" + (i++) + "]:"); + child.print(indent + " "); + } + } +} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/StaticDotAutocompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/StaticDotAutocompletions.java new file mode 100644 index 0000000..ec82aca --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/jython/StaticDotAutocompletions.java @@ -0,0 +1,105 @@ +/*- + * #%L + * Jython language support for SciJava Script Editor. + * %% + * Copyright (C) 2020 - 2024 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.plugins.scripting.jython; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import org.python.indexer.types.NModuleType; + +public class StaticDotAutocompletions implements DotAutocompletions +{ + final String className; + public StaticDotAutocompletions(final String className) { + this.className = className; + } + @Override + public String getClassname() { + return this.className; + } + @Override + public List get() { + final List ac = new ArrayList<>(); + if (null != this.className) { + String msg = ""; + try { + // Check first if it's a python module + final NModuleType module = Scope.loadPythonModule(this.className); // Scope.indexer.getBuiltinModule(this.className); + JythonDev.printTrace("module is: " + module); + if (null != module) { + module.getTable().keySet().forEach( m -> ac.add(new CompletionText(m))); + // Not need to remove: a file system watcher will do so when the module file is updated or deleted. + // Scope.indexer.moduleTable.remove(this.className); + return ac; + } else { + msg += "Not a python module: " + this.className; + } + // Or a java class: + try { + DotAutocompletions.staticFieldsAndStaticMethodsInto(Class.forName(this.className), ac); + return ac; + } catch (ClassNotFoundException cnfe) { + msg += "\nCannot find java class " + this.className; + } + // Or a static method of a java class + try { + final int idot = this.className.lastIndexOf('.'); + final String name = this.className.substring(idot + 1); + final Class c = Class.forName(this.className.substring(0, idot)); + // There could be more than one method, with more than one return type + for (final Class r: Arrays.asList(c.getMethods()).stream() + .filter(m -> m.getName().equals(name)) + .map(m -> m.getReturnType()) + .distinct() + .collect(Collectors.toList())) { + DotAutocompletions.fieldsAndMethodsInto(r, ac); + } + return ac; + } catch (ClassNotFoundException cnfe) { + msg += "\nCannot derive static method or field from " + this.className; + } + + if (null != msg) { + JythonDev.print(msg); + } + } catch (Exception e) { + JythonDev.print(msg, e); + } + } + return ac; + } + + @Override + public String toString() { + final String completions = get().stream().map(c -> c.getReplacementText()).collect(Collectors.joining(",", "[", "]")); + return "StaticDotAutocompletions: " + className + " -- " + completions; + } +} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/VarDotAutocompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/VarDotAutocompletions.java new file mode 100644 index 0000000..6d89cf9 --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/jython/VarDotAutocompletions.java @@ -0,0 +1,77 @@ +/*- + * #%L + * Jython language support for SciJava Script Editor. + * %% + * Copyright (C) 2020 - 2024 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.plugins.scripting.jython; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; + + +public class VarDotAutocompletions implements DotAutocompletions { + String className; + public VarDotAutocompletions(final String className) { + this.className = className; + } + @Override + public String getClassname() { + return this.className; + } + @Override + public List get() { + final List ac = new ArrayList<>(); + if (null != className) { + try { + final Class c = Class.forName(className); + DotAutocompletions.fieldsAndMethodsInto(c, ac); + } catch (final Exception e) { + JythonDev.print("Could not load class " + className, e); + } + } + return ac; + } + + @Override + public Stream getStream() { + try { + final Class c = Class.forName(this.className); + return Stream.concat( + Arrays.stream(c.getFields()).map(f -> new CompletionText(f.getName(), c, f)), + Arrays.stream(c.getMethods()).map(m -> new CompletionText(m.getName(), c, m))); + } catch (final Exception e) { + JythonDev.print("Could not load class " + className, e); + } + return Stream.empty(); + } + + @Override + public String toString() { + return "VarDotAutocompletions: " + this.className; + } +} diff --git a/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java new file mode 100644 index 0000000..07bd117 --- /dev/null +++ b/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java @@ -0,0 +1,85 @@ +/*- + * #%L + * JSR-223-compliant Jython scripting language plugin. + * %% + * Copyright (C) 2020 - 2025 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.plugins.scripting.jython; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.Test; +import org.scijava.Context; +import org.scijava.script.ScriptLanguage; +import org.scijava.script.ScriptService; +import org.scijava.script.complete.CodeCompletionService; +import org.scijava.script.complete.Completion; +import org.scijava.script.complete.CompletionRequest; +import org.scijava.script.complete.CompletionResult; + +/** + * Tests that the Jython {@link JythonCodeCompleter} is discovered for the Jython + * language and produces member completions via the SciJava completion SPI, with + * no dependency on the Swing script editor. + * + * @author Curtis Rueden + */ +public class JythonCompletionTest { + + @Test + public void testMemberCompletionViaService() { + final Context ctx = new Context(ScriptService.class, + CodeCompletionService.class); + try { + final ScriptService scriptService = ctx.service(ScriptService.class); + final CodeCompletionService completion = ctx.service( + CodeCompletionService.class); + + final ScriptLanguage jython = scriptService.getLanguageByName("Jython"); + assertNotNull("Jython language not found", jython); + + // The Jython completer must be discovered and claim the Jython language. + assertNotNull("JythonCodeCompleter not registered for Jython", // + completion.getCompleterPlugin(jython)); + + // Static analysis: 's' is a String, so 's.' completes to String members. + final String code = "s = \"hello\"\ns."; + final CompletionResult result = completion.complete(// + new CompletionRequest(code, jython, null)); + final List texts = result.completions().stream().map( + Completion::insertionText).collect(Collectors.toList()); + + assertTrue("expected s.length among " + texts, texts.contains("s.length")); + assertTrue("expected s.charAt among " + texts, texts.contains("s.charAt")); + } + finally { + ctx.dispose(); + } + } +} From 6da840ce4a213e2f80ffed796175bc4282984cec Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sat, 3 Oct 2026 14:50:39 -0500 Subject: [PATCH 02/29] WIP --- pom.xml | 13 +- .../scripting/jython/CompletionText.java | 4 +- .../jython/JythonAutoCompletions.java | 95 ++++++++--- .../scripting/jython/JythonCodeCompleter.java | 96 ++++++++++- .../scripting/jython/JythonImports.java | 2 +- .../scripting/jython/JythonScriptParser.java | 22 ++- .../plugins/scripting/jython/Scope.java | 31 ++-- .../jython/JythonCompletionTest.java | 151 +++++++++++++++++- 8 files changed, 361 insertions(+), 53 deletions(-) diff --git a/pom.xml b/pom.xml index f2e0b78..717dfea 100644 --- a/pom.xml +++ b/pom.xml @@ -84,11 +84,11 @@ org.scijava.plugins.scripting.jython - + 2.100.2-SNAPSHOT + 0.1.0-SNAPSHOT bsd_2 SciJava developers. @@ -104,6 +104,11 @@ org.scijava scijava-common + + org.scijava + scijava-code-api + ${scijava-code-api.version} + diff --git a/src/main/java/org/scijava/plugins/scripting/jython/CompletionText.java b/src/main/java/org/scijava/plugins/scripting/jython/CompletionText.java index 0929f7c..7eb8e4e 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/CompletionText.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/CompletionText.java @@ -36,13 +36,13 @@ import java.util.Collections; import java.util.List; -import org.scijava.script.complete.ClassIndex; +import org.scijava.code.api.ClassIndex; /** * A mutable suggestion used internally by the Jython completion engine. It holds * the replacement text plus optional documentation and (for callables) parameter * and return-type metadata. The {@link JythonCodeCompleter} converts these into - * toolkit-agnostic {@link org.scijava.script.complete.Completion}s at the + * toolkit-agnostic {@link org.scijava.code.api.Completion}s at the * boundary. * * @author Albert Cardona diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java index c65a173..7d03af9 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java @@ -33,6 +33,7 @@ import java.nio.file.FileVisitOption; import java.nio.file.Files; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.List; @@ -44,10 +45,10 @@ import java.util.stream.Collectors; import org.python.indexer.types.NModuleType; -import org.scijava.script.complete.ClassIndex; -import org.scijava.script.complete.Completion; -import org.scijava.script.complete.Completion.TextEdit; -import org.scijava.script.complete.ParameterChoices; +import org.scijava.code.api.ClassIndex; +import org.scijava.code.api.Completion; +import org.scijava.code.api.Completion.TextEdit; +import org.scijava.code.api.ParameterChoices; /** * The Jython completion engine: given the code before the caret it analyzes the @@ -62,7 +63,7 @@ public class JythonAutoCompletions { private static final Pattern - nameToken = Pattern.compile("^(.*?[ \\t,\\[=\\(]+)([a-zA-Z_][a-zA-Z0-9_]+)$"), + nameToken = Pattern.compile("^(|.*?[ \\t,\\[=\\(+\\-*/%<>!:]+)([a-zA-Z_][a-zA-Z0-9_]*)$"), invocation = Pattern.compile("^(.*?[ \\t]+|)([a-zA-Z_][a-zA-Z0-9_]+\\()$"), dotNameToken = Pattern.compile("^(.*?[ \\t]+|)([a-zA-Z0-9_\\.\\[\\](){}]+)\\.([a-zA-Z0-9_]*)$"), assign = Pattern.compile("^([ \\t]*)(([a-zA-Z_][a-zA-Z0-9_ \\t,]*)[ \\t]+=[ \\t]+(.*))$"), @@ -76,6 +77,13 @@ public class JythonAutoCompletions { importStatement = Pattern.compile("^((from[ \\t]+([a-zA-Z0-9._]+)[ \\t]+|[ \\t]*)import[ \\t]+)([a-zA-Z0-9_., \\t]*)$"), simpleClassName = Pattern.compile("^(.*[ \\t]+|)([A-Z_][a-zA-Z0-9_]+)$"); + /** Python 2 (Jython) keywords. */ + private static final List KEYWORDS = Arrays.asList("and", "as", + "assert", "break", "class", "continue", "def", "del", "elif", "else", + "except", "exec", "finally", "for", "from", "global", "if", "import", "in", + "is", "lambda", "not", "or", "pass", "print", "raise", "return", "try", + "while", "with", "yield", "None", "True", "False"); + private final JythonImportFormat formatter = new JythonImportFormat(); /** Python standard library module names, discovered from the jython jar. */ @@ -100,27 +108,42 @@ public static final class Result { * which follows {@code codeWithoutLastLine} in the script. {@code * alreadyEnteredText} is the text the editor will replace. */ - public Result completionsFor(String codeWithoutLastLine, final String lastLine, - final String alreadyEnteredText) + public Result completionsFor(final String codeWithoutLastLine, + final String lastLine, final String alreadyEnteredText) + { + return completionsFor(codeWithoutLastLine, lastLine, alreadyEnteredText, + Collections.emptyMap()); + } + + /** + * As {@link #completionsFor(String, String, String)}, for a script in which + * the given variables are already defined before the code runs (e.g. script + * parameters, or the bindings of a live interpreter). + */ + public Result completionsFor(final String codeWithoutLastLine, + final String lastLine, final String alreadyEnteredText, + final Map predefined) { final List completions = new ArrayList<>(); final int crop = lastLine.length() - alreadyEnteredText.length(); // 1) AST-based completions (names, members, constructors, invocations). final ParameterChoices choices = astCompletions(codeWithoutLastLine, - lastLine, crop, completions); + lastLine, crop, predefined, completions); // 2) Import and class-name discovery, always appended. importCompletions(alreadyEnteredText, codeWithoutLastLine + lastLine, completions); - return new Result(completions, choices); + return new Result(dedupe(completions), choices); } // -- AST-based completions -- private ParameterChoices astCompletions(String codeWithoutLastLine, - final String lastLine, final int crop, final List completions) + final String lastLine, final int crop, + final Map predefined, + final List completions) { // Precondition: can't expand when empty or ending with any of "[]{},; ". if (lastLine.isEmpty()) return null; @@ -173,32 +196,43 @@ private ParameterChoices astCompletions(String codeWithoutLastLine, return null; } - // A plain name (variable, function, class) being typed. + // A plain name (variable, function, class, keyword) being typed. final Matcher m1 = nameToken.matcher(lastLine); if (m1.find()) { - final Scope scope = JythonScriptParser.parseAST(codeWithoutLastLine) + final Scope scope = JythonScriptParser.parseAST(codeWithoutLastLine, + predefined) .getLast(); + final String seed = m1.group(2); final String pre = crop > -1 && crop < m1.group(1).length() ? m1.group(1) .substring(crop) : ""; - final Map names = scope.findStartsWith2(m1.group(2)); + final String head = lastLine.substring(0, lastLine.length() - seed + .length()); + final Map names = scope.findStartsWith2(seed); for (final Map.Entry e : names.entrySet()) { + completions.add(Completion.builder((head + e.getKey()).substring(crop)) + .kind(Completion.Kind.VARIABLE).build()); final String classname = e.getValue(); if (null != classname) { + // A class: also offer its constructors. try { final Class c = Class.forName(classname); for (final java.lang.reflect.Constructor cons : c .getConstructors()) { - completions.add(makeDotCompletion(pre, m1.group(2), + completions.add(makeDotCompletion(pre, seed, new CompletionText(e.getKey(), c, cons))); } } - catch (final ClassNotFoundException cnfe) { + catch (final ClassNotFoundException | LinkageError exc) { JythonDev.printTrace("Can't load class: " + classname); } } - completions.add(Completion.of((lastLine + e.getKey().substring(m1 - .group(2).length())).substring(crop))); + } + for (final String keyword : KEYWORDS) { + if (keyword.startsWith(seed) && !keyword.equals(seed)) { + completions.add(Completion.builder((head + keyword).substring(crop)) + .kind(Completion.Kind.KEYWORD).build()); + } } return scopeChoices(scope); } @@ -207,7 +241,8 @@ private ParameterChoices astCompletions(String codeWithoutLastLine, final Matcher m1c = invocation.matcher(lastLine); if (m1c.find()) { final String name = m1c.group(2).substring(0, m1c.group(2).length() - 1); - final Scope scope = JythonScriptParser.parseAST(codeWithoutLastLine) + final Scope scope = JythonScriptParser.parseAST(codeWithoutLastLine, + predefined) .getLast(); final DotAutocompletions da = scope.find(name, DotAutocompletions.EMPTY); if (da instanceof ConstructorAutocompletions) { @@ -246,7 +281,7 @@ private ParameterChoices astCompletions(String codeWithoutLastLine, lastLine.substring(0, start) + varName + " = " + lastLine.substring( start, lastLine.length() - 1 - seed.length()); } - final Scope scope = JythonScriptParser.parseAST(code); + final Scope scope = JythonScriptParser.parseAST(code, predefined); final DotAutocompletions da = scope.getLast().find(varName, DotAutocompletions.EMPTY); final String fullPre = lastLine.substring(crop); @@ -368,9 +403,12 @@ private void importCompletions(final String text, final String fullCode, final Matcher m3 = simpleClassName.matcher(text); if (m3.find()) { final String pre = m3.group(1); - for (final String className : ClassIndex.findSimpleClassNamesStartingWith( - m3.group(2))) - { + final List classNames = ClassIndex + .findSimpleClassNamesStartingWith(m3.group(2)); + // Closest matches first: shortest simple name, then alphabetical. + classNames.sort(Comparator.comparingInt((String cn) -> cn.length() - cn + .lastIndexOf('.')).thenComparing(Comparator.naturalOrder())); + for (final String className : classNames) { final String simpleName = className.substring(className.lastIndexOf( '.') + 1); final String importStmt = formatter.singleToImportStatement(className); @@ -387,6 +425,19 @@ private void importCompletions(final String text, final String fullCode, // -- Helpers -- + /** + * Removes duplicate completions (same text and parameters), keeping the first + * occurrence, since the AST and class-name analyses can suggest the same thing. + */ + private static List dedupe(final List completions) { + final java.util.Set seen = new java.util.HashSet<>(); + final List result = new ArrayList<>(completions.size()); + for (final Completion c : completions) { + if (seen.add(c.insertionText() + "\u0000" + c.parameters())) result.add(c); + } + return result; + } + /** Builds a neutral completion from a {@link CompletionText} and prefix. */ private static Completion makeDotCompletion(final String pre, final String seed, final CompletionText ct) diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java index 8ad1481..bc996c8 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java @@ -28,14 +28,27 @@ */ package org.scijava.plugins.scripting.jython; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import javax.script.Bindings; +import javax.script.ScriptContext; + +import org.python.core.PyJavaType; +import org.python.core.PyModule; +import org.python.core.PyObject; +import org.python.core.PyString; +import org.scijava.Context; import org.scijava.Priority; import org.scijava.plugin.Plugin; import org.scijava.script.ScriptLanguage; -import org.scijava.script.complete.AbstractCodeCompleterPlugin; -import org.scijava.script.complete.ClassIndex; -import org.scijava.script.complete.CodeCompleterPlugin; -import org.scijava.script.complete.CompletionRequest; -import org.scijava.script.complete.CompletionResult; +import org.scijava.code.api.AbstractCodeCompleterPlugin; +import org.scijava.code.api.ClassIndex; +import org.scijava.code.api.CodeCompleterPlugin; +import org.scijava.code.api.CodeCompletionService; +import org.scijava.code.api.CompletionRequest; +import org.scijava.code.api.CompletionResult; /** * Toolkit-agnostic code completion for the Jython language, contributed by the @@ -78,15 +91,82 @@ public CompletionResult complete(final CompletionRequest request) { final int replaceStart = caret - alreadyEntered.length(); final JythonAutoCompletions.Result result = engine.completionsFor( - codeWithoutLastLine, lastLine, alreadyEntered); + codeWithoutLastLine, lastLine, alreadyEntered, predefinedVariables( + request)); return new CompletionResult(result.completions, replaceStart, result.parameterChoices); } + /** + * Gets the variables defined before the script's code runs: script + * parameters declared via {@code #@} lines, and, when completing in a live + * interpreter, the engine's bindings (which reflect actual runtime values, so + * they take precedence). + */ + private Map predefinedVariables( + final CompletionRequest request) + { + final Map vars = new LinkedHashMap<>(); + final Context context = getContext(); + final CodeCompletionService completionService = context == null ? null + : context.getService(CodeCompletionService.class); + if (completionService != null) { + completionService.scriptParameters(request.text()).forEach((name, + type) -> vars.put(name, new VarDotAutocompletions(boxed(type) + .getName()))); + } + final ScriptContext scriptContext = request.context(); + final Bindings bindings = scriptContext == null ? null : scriptContext + .getBindings(ScriptContext.ENGINE_SCOPE); + if (bindings != null) { + for (final Map.Entry entry : bindings.entrySet()) { + final String name = entry.getKey(); + if (name.startsWith("__") && name.endsWith("__")) continue; + vars.put(name, bindingCompletions(entry.getValue())); + } + } + return vars.isEmpty() ? Collections.emptyMap() : vars; + } + + /** Describes a live binding value for completion purposes. */ + private static DotAutocompletions bindingCompletions(final Object value) { + if (value instanceof PyJavaType) { + // An imported Java class: offer its constructors and static members. + final Class c = ((PyJavaType) value).getProxyType(); + if (c != null) return new StaticDotAutocompletions(c.getName()); + } + if (value instanceof PyModule) { + final PyObject name = ((PyModule) value).__findattr__("__name__"); + if (name instanceof PyString) { + return new StaticDotAutocompletions(name.toString()); + } + } + if (value == null || value instanceof PyObject) { + // A python function, class or instance: known by name only. + return new VarDotAutocompletions(null); + } + return new VarDotAutocompletions(value.getClass().getName()); + } + + /** Maps primitive types to their wrappers, whose members can be listed. */ + private static Class boxed(final Class type) { + if (!type.isPrimitive()) return type; + if (type == int.class) return Integer.class; + if (type == long.class) return Long.class; + if (type == double.class) return Double.class; + if (type == float.class) return Float.class; + if (type == boolean.class) return Boolean.class; + if (type == char.class) return Character.class; + if (type == short.class) return Short.class; + if (type == byte.class) return Byte.class; + return Object.class; // void + } + /** * The portion of the current line the editor should replace. Mirrors the * legacy RSTA logic: a maximal suffix of letters, digits, {@code '.'}, - * {@code '_'} and spaces (spaces let "from x import Y" complete as a unit). + * {@code '_'} and spaces (spaces let "from x import Y" complete as a unit), + * excluding leading spaces. */ private static String alreadyEnteredText(final String lastLine) { int start = lastLine.length(); @@ -96,6 +176,8 @@ private static String alreadyEnteredText(final String lastLine) { start--; else break; } + // Leading whitespace is not part of what the user is completing. + while (start < lastLine.length() && lastLine.charAt(start) == ' ') start++; return lastLine.substring(start); } } diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonImports.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonImports.java index 3ae301b..0bc5f2e 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonImports.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonImports.java @@ -32,7 +32,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; -import org.scijava.script.complete.Completion.TextEdit; +import org.scijava.code.api.Completion.TextEdit; /** * Parses the {@code import} statements in a Jython script, and computes the diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonScriptParser.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonScriptParser.java index c8b22fe..869f0e0 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonScriptParser.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonScriptParser.java @@ -81,16 +81,32 @@ public class JythonScriptParser * @return The top-level {@link Scope}, which is empty (see {@link Scope#isEmpty()}) when the code has errors and can't be parsed by {@link ParserFacade#parse(String, CompileMode, String, CompilerFlags)}. */ static public Scope parseAST(final String code) { + return parseAST(code, Collections.emptyMap()); + } + + /** + * Parse valid jython code within a top-level {@link Scope} that already + * defines the given variables, such as script parameters injected by the + * framework or the bindings of a live interpreter. Assignments in the code + * take precedence over these. + * + * @param code + * @param predefined Variables defined before the code runs. + * + * @return The top-level {@link Scope}, holding only the predefined variables when the code has errors and can't be parsed by {@link ParserFacade#parse(String, CompileMode, String, CompilerFlags)}. + */ + static public Scope parseAST(final String code, final Map predefined) { // The code includes from beginning of the file until the point at which an autocompletion is requested. // Therefore, remove the last line, which would fail to parse because it is incomplete + final Scope scope = new Scope(null); + scope.vars.putAll(predefined); try { final mod m = ParserFacade.parse(code, CompileMode.exec, "", new CompilerFlags()); - final Scope scope = parseNode(m.getChildren(), null, null); - return null == scope ? new Scope(null) : scope; + if (null != m.getChildren()) parseNode(scope, m.getChildren(), null); } catch (Throwable t) { JythonDev.printError(t); - return new Scope(null); } + return scope; } /** diff --git a/src/main/java/org/scijava/plugins/scripting/jython/Scope.java b/src/main/java/org/scijava/plugins/scripting/jython/Scope.java index 04bad91..d83a041 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/Scope.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/Scope.java @@ -40,6 +40,7 @@ import java.util.Hashtable; import java.util.List; import java.util.Map; +import java.util.TreeMap; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -195,32 +196,42 @@ public List findStartsWith(final String name) { return completions; } - /** Return a table of names vs classnames, with classnames being null for python builtins. + /** + * Return a table of names vs classnames, sorted by name. The classname is + * non-null only for names bound to a class (e.g. an imported Java class), whose + * constructors can then be offered; it is null for variables, functions and + * python builtins. Names in inner scopes shadow those of outer scopes. * * @param name * @return */ public Map findStartsWith2(final String name) { - final Map completions = new HashMap<>(); + final Map completions = new TreeMap<>(); Scope scope = this; while (null != scope) { for (final Map.Entry e: scope.vars.entrySet()) { - if (e.getKey().startsWith(name)) completions.put(e.getKey(), e.getValue().getClassname()); + if (e.getKey().startsWith(name)) completions.putIfAbsent(e.getKey(), classnameOf(e.getValue())); } for (final Map.Entry e: scope.imports.entrySet()) { - if (e.getKey().startsWith(name)) completions.put(e.getKey(), e.getValue().getClassname()); - } - for (String builtinName: indexer.getBindings().keySet()) { - if (builtinName.startsWith("__builtin__.")) - builtinName = builtinName.substring(12); // without the "__builtin__." prefix - if (builtinName.startsWith(name)) completions.put(builtinName, null); + if (e.getKey().startsWith(name)) completions.putIfAbsent(e.getKey(), classnameOf(e.getValue())); } scope = scope.parent; } + for (String builtinName: indexer.getBindings().keySet()) { + if (builtinName.startsWith("__builtin__.")) + builtinName = builtinName.substring(12); // without the "__builtin__." prefix + // Skip members of builtin types, e.g. "tuple.__len__" + if (builtinName.indexOf('.') >= 0) continue; + if (builtinName.startsWith(name)) completions.putIfAbsent(builtinName, null); + } return completions; } - + /** The class a name refers to, if it names a class rather than an instance. */ + private static String classnameOf(final DotAutocompletions da) { + return da instanceof StaticDotAutocompletions ? da.getClassname() : null; + } + /** Find vars by type, recursively upstream the nested scopes, listing first those of the innermost scope. * * @param clazz diff --git a/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java index 07bd117..fd36a81 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java @@ -28,20 +28,25 @@ */ package org.scijava.plugins.scripting.jython; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import java.util.List; import java.util.stream.Collectors; +import javax.script.ScriptEngine; + import org.junit.Test; +import org.scijava.code.api.ClassIndex; import org.scijava.Context; import org.scijava.script.ScriptLanguage; import org.scijava.script.ScriptService; -import org.scijava.script.complete.CodeCompletionService; -import org.scijava.script.complete.Completion; -import org.scijava.script.complete.CompletionRequest; -import org.scijava.script.complete.CompletionResult; +import org.scijava.code.api.CodeCompletionService; +import org.scijava.code.api.Completion; +import org.scijava.code.api.CompletionRequest; +import org.scijava.code.api.CompletionResult; /** * Tests that the Jython {@link JythonCodeCompleter} is discovered for the Jython @@ -82,4 +87,142 @@ public void testMemberCompletionViaService() { ctx.dispose(); } } + + @Test + public void testNameAtStartOfLine() { + final List texts = complete("greeting = \"hi\"\ngree"); + assertTrue("expected greeting among " + texts, texts.contains("greeting")); + } + + @Test + public void testVariableNameNotDuplicated() { + // A String variable must not also offer String's constructors. + final List texts = complete("greeting = \"hi\"\nx = gree"); + assertEquals(texts.toString(), 1, texts.stream().filter( + "greeting"::equals).count()); + assertFalse("unexpected leading space in " + texts, texts.contains( + " greeting")); + } + + @Test + public void testKeywordsAndBuiltins() { + assertTrue(complete("imp").contains("import")); + final List texts = complete("x = le"); + assertTrue("expected len among " + texts, texts.contains("len")); + assertFalse("unexpected builtin member in " + texts, texts.stream() + .anyMatch(t -> t.contains("."))); + } + + @Test + public void testImportedClassName() { + ClassIndex.ensureCache(); + final List texts = complete( + "from java.util import ArrayList\nArrayList"); + assertTrue("expected ArrayList among " + texts, texts.contains( + "ArrayList")); + assertEquals("expected one plain ArrayList among " + texts, 1, texts + .stream().filter("ArrayList"::equals).count() - constructorCount( + "from java.util import ArrayList\nArrayList")); + } + + @Test + public void testJdkClassImport() { + ClassIndex.ensureCache(); + final List texts = complete("from java.util import ArrayLi"); + assertTrue("expected ArrayList import among " + texts, texts.contains( + "from java.util import ArrayList")); + } + + @Test + public void testScriptParameters() { + final String header = "" + // + "#@ String (label=\"Please enter your name\") name\n" + // + "#@output String greeting\n" + // + "#@ int count\n" + // + "#@ NoSuchType mystery\n"; + assertTrue(complete(header + "x = na").contains("name")); + assertTrue(complete(header + "myst").contains("mystery")); + final List nameMembers = complete(header + "name.toUpp"); + assertTrue("expected name.toUpperCase among " + nameMembers, nameMembers + .contains("name.toUpperCase")); + // Outputs are known too, even before the script assigns them. + assertTrue(complete(header + "greeting.len").contains("greeting.length")); + // Primitive parameters complete as their wrapper type. + assertTrue(complete(header + "count.intV").contains("count.intValue")); + } + + @Test + public void testAssignmentShadowsScriptParameter() { + final List texts = complete( + "#@ String name\nfrom java.util import ArrayList\nname = ArrayList()\nname.ensureCap"); + assertTrue("expected ArrayList member among " + texts, texts.contains( + "name.ensureCapacity")); + } + + @Test + public void testInterpreterBindings() throws Exception { + final Context ctx = new Context(); + try { + final ScriptLanguage jython = ctx.service(ScriptService.class) + .getLanguageByName("Jython"); + final ScriptEngine engine = jython.getScriptEngine(); + engine.put("injected", new java.util.ArrayList()); + engine.eval("from java.util import Collections, HashMap\n" + + "m = HashMap()\n" + + "def helper(): pass\n"); + final CodeCompletionService service = ctx.service( + CodeCompletionService.class); + + assertTrue(complete(service, jython, engine, "m.putIfAb").contains( + "m.putIfAbsent")); + assertTrue(complete(service, jython, engine, "injected.si").contains( + "injected.size")); + assertTrue(complete(service, jython, engine, "x = hel").contains( + "helper")); + // An imported Java class offers its static members. + assertTrue(complete(service, jython, engine, "Collections.emptyL") + .contains("Collections.emptyList")); + // Python internals are not offered. + assertFalse(complete(service, jython, engine, "x = __").contains( + "__name__")); + } + finally { + ctx.dispose(); + } + } + + // -- Helper methods -- + + private static List complete(final CodeCompletionService service, + final ScriptLanguage language, final ScriptEngine engine, + final String code) + { + return service.complete(new CompletionRequest(code, language, engine)) + .completions().stream().map(Completion::insertionText).collect(Collectors + .toList()); + } + + private static List complete(final String code) { + return completions(code).stream().map(Completion::insertionText).collect( + Collectors.toList()); + } + + private static long constructorCount(final String code) { + return completions(code).stream().filter(c -> c.kind() == + Completion.Kind.METHOD).count(); + } + + private static List completions(final String code) { + final Context ctx = new Context(ScriptService.class, + CodeCompletionService.class); + try { + final ScriptLanguage jython = ctx.service(ScriptService.class) + .getLanguageByName("Jython"); + return ctx.service(CodeCompletionService.class).complete( + new CompletionRequest(code, jython, null)).completions(); + } + finally { + ctx.dispose(); + } + } } From d6cbdf16a3e0ea7f58c8e07bfb1e500af364fb70 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Sat, 3 Oct 2026 15:43:06 -0500 Subject: [PATCH 03/29] Remove snapshot dependency Still need a version newer than pom-scijava though --- pom.xml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index 717dfea..04a8278 100644 --- a/pom.xml +++ b/pom.xml @@ -84,10 +84,7 @@ org.scijava.plugins.scripting.jython - - 2.100.2-SNAPSHOT + 2.101.0 0.1.0-SNAPSHOT bsd_2 From 5813d173bd1814c2b7a3dc27918dab7d9de89533 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Sun, 4 Oct 2026 12:28:48 -0500 Subject: [PATCH 04/29] Grab inherited methods/fields --- .../plugins/scripting/jython/DotAutocompletions.java | 4 ++-- .../plugins/scripting/jython/JythonCompletionTest.java | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/scijava/plugins/scripting/jython/DotAutocompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/DotAutocompletions.java index 701f48e..4148d6b 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/DotAutocompletions.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/DotAutocompletions.java @@ -92,10 +92,10 @@ static public void staticFieldsAndStaticMethodsInto(final Class c, final List * @param ac */ static public void fieldsAndMethodsInto(final Class c, final List ac) { - for (final Field f: c.getDeclaredFields()) + for (final Field f: c.getFields()) if (!Modifier.isStatic(f.getModifiers())) ac.add(new CompletionText(f.getName(), c, f)); - for (final Method m: c.getDeclaredMethods()) + for (final Method m: c.getMethods()) if (!Modifier.isStatic(m.getModifiers())) ac.add(new CompletionText(m.getName() + "()", c, m)); } diff --git a/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java index fd36a81..3ca0875 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java @@ -159,6 +159,16 @@ public void testAssignmentShadowsScriptParameter() { "name.ensureCapacity")); } + @Test + public void testInheritedMembers() { + final List texts = complete( + "from java.util import ArrayList\nlist = ArrayList()\nlist."); + // members come from superinterfaces + assertTrue(texts.contains("list.add")); + // members come from superclasses + assertTrue(texts.contains("list.toString")); + } + @Test public void testInterpreterBindings() throws Exception { final Context ctx = new Context(); From ed91469239ff9fabb98bffe68baa41d6483ec993 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Sun, 4 Oct 2026 17:26:16 -0500 Subject: [PATCH 05/29] WIP: AI-generated overloaded params TODO: Need to understand --- .../jython/JythonAutoCompletions.java | 28 ++++++++++++------ .../scripting/jython/JythonCodeCompleter.java | 2 +- .../scripting/jython/JythonScriptParser.java | 21 ++++++++++++++ .../jython/JythonCompletionTest.java | 29 +++++++++++++++++++ 4 files changed, 70 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java index 7d03af9..4f090dc 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java @@ -49,6 +49,7 @@ import org.scijava.code.api.Completion; import org.scijava.code.api.Completion.TextEdit; import org.scijava.code.api.ParameterChoices; +import org.scijava.code.api.TypeResolver; /** * The Jython completion engine: given the code before the caret it analyzes the @@ -89,17 +90,23 @@ public class JythonAutoCompletions { /** Python standard library module names, discovered from the jython jar. */ private static final List jython_jar_modules = findJythonModules(); - /** Result of a completion query: the suggestions plus optional choices. */ + /** + * Result of a completion query: the suggestions plus optional choices and + * argument type resolution. + */ public static final class Result { public final List completions; public final ParameterChoices parameterChoices; + public final TypeResolver typeResolver; Result(final List completions, - final ParameterChoices parameterChoices) + final ParameterChoices parameterChoices, + final TypeResolver typeResolver) { this.completions = completions; this.parameterChoices = parameterChoices; + this.typeResolver = typeResolver; } } @@ -128,19 +135,22 @@ public Result completionsFor(final String codeWithoutLastLine, final int crop = lastLine.length() - alreadyEnteredText.length(); // 1) AST-based completions (names, members, constructors, invocations). - final ParameterChoices choices = astCompletions(codeWithoutLastLine, - lastLine, crop, predefined, completions); + final Scope scope = astCompletions(codeWithoutLastLine, lastLine, crop, + predefined, completions); // 2) Import and class-name discovery, always appended. importCompletions(alreadyEnteredText, codeWithoutLastLine + lastLine, completions); - return new Result(dedupe(completions), choices); + if (scope == null) return new Result(dedupe(completions), null, null); + return new Result(dedupe(completions), scopeChoices(scope), + expression -> JythonScriptParser.typeOf(expression, scope)); } // -- AST-based completions -- - private ParameterChoices astCompletions(String codeWithoutLastLine, + /** Adds AST-based completions, returning the analyzed scope (or null). */ + private Scope astCompletions(String codeWithoutLastLine, final String lastLine, final int crop, final Map predefined, final List completions) @@ -234,7 +244,7 @@ private ParameterChoices astCompletions(String codeWithoutLastLine, .kind(Completion.Kind.KEYWORD).build()); } } - return scopeChoices(scope); + return scope; } // A function/constructor invocation: "name(". @@ -249,7 +259,7 @@ private ParameterChoices astCompletions(String codeWithoutLastLine, for (final CompletionText ct : da.get()) { completions.add(makeDotCompletion("", name, ct)); } - return scopeChoices(scope); + return scope; } // Otherwise fall through to dot-name handling below. } @@ -294,7 +304,7 @@ private ParameterChoices astCompletions(String codeWithoutLastLine, .toList()); sortCompletions(list, seed); completions.addAll(list); - return scopeChoices(scope.getLast()); + return scope.getLast(); } return null; diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java index bc996c8..a8827f9 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java @@ -94,7 +94,7 @@ public CompletionResult complete(final CompletionRequest request) { codeWithoutLastLine, lastLine, alreadyEntered, predefinedVariables( request)); return new CompletionResult(result.completions, replaceStart, - result.parameterChoices); + result.parameterChoices, result.typeResolver); } /** diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonScriptParser.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonScriptParser.java index 869f0e0..cdcf3cd 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonScriptParser.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonScriptParser.java @@ -44,6 +44,7 @@ import org.python.antlr.ast.Call; import org.python.antlr.ast.ClassDef; import org.python.antlr.ast.Expr; +import org.python.antlr.ast.Expression; import org.python.antlr.ast.For; import org.python.antlr.ast.FunctionDef; import org.python.antlr.ast.If; @@ -360,6 +361,26 @@ static public void parseClassDef(final ClassDef c, final Scope parent) { * @param right * @param scope */ + /** + * Infers the type of a single expression, such as a function argument, in + * the given scope. + * + * @param expression The source text of the expression. + * @param scope The scope in which to resolve names. + * @return A class name, a primitive type name (e.g. "long" for an integer + * literal), or null if unknown. + */ + static public String typeOf(final String expression, final Scope scope) { + try { + final mod m = ParserFacade.parse(expression, CompileMode.eval, "", new CompilerFlags()); + if (!(m instanceof Expression)) return null; + return parseRight(((Expression) m).getInternalBody(), scope).getClassname(); + } catch (final Throwable t) { + // Incomplete or invalid expression. + return null; + } + } + static public DotAutocompletions parseRight(final PyObject right, final Scope scope) { if (right instanceof Name) { // e.g. the name of another variable: diff --git a/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java index 3ca0875..8c03d14 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java @@ -47,6 +47,7 @@ import org.scijava.code.api.Completion; import org.scijava.code.api.CompletionRequest; import org.scijava.code.api.CompletionResult; +import org.scijava.code.api.TypeResolver; /** * Tests that the Jython {@link JythonCodeCompleter} is discovered for the Jython @@ -201,6 +202,34 @@ public void testInterpreterBindings() throws Exception { } } + @Test + public void testArgumentTypes() { + final Context ctx = new Context(ScriptService.class, + CodeCompletionService.class); + try { + final ScriptLanguage jython = ctx.service(ScriptService.class) + .getLanguageByName("Jython"); + final String code = "#@ String name\n" + // + "from java.util import ArrayList\n" + // + "x = 1.5\n" + // + "lst = ArrayList()\n" + // + "name."; + final TypeResolver types = ctx.service(CodeCompletionService.class) + .complete(new CompletionRequest(code, jython, null)).typeResolver(); + assertNotNull(types); + assertEquals("long", types.typeOf("1")); + assertEquals("double", types.typeOf("x")); + assertEquals("java.lang.String", types.typeOf("name")); + assertEquals("java.lang.String", types.typeOf("'hi'")); + assertEquals("java.util.ArrayList", types.typeOf("lst")); + assertEquals(null, types.typeOf("undefinedThing")); + assertEquals(null, types.typeOf("1 +")); + } + finally { + ctx.dispose(); + } + } + // -- Helper methods -- private static List complete(final CodeCompletionService service, From d8fbc6fb6529c1810ee98541006dc62f4682e2cc Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Tue, 6 Oct 2026 10:49:17 -0500 Subject: [PATCH 06/29] Provide signature help Find the call around the caret, take the callee's overloads from the completion engine (methods before the "(", constructors after it), type the arguments with the engine's own inference, and rate the overloads with code-api's Signatures. This replaces the TypeResolver, which code-api no longer has. Co-Authored-By: Claude Opus 5.5 --- .../jython/JythonAutoCompletions.java | 9 ++- .../scripting/jython/JythonCodeCompleter.java | 75 ++++++++++++++++++- .../jython/JythonCompletionTest.java | 51 ++++++++----- 3 files changed, 113 insertions(+), 22 deletions(-) diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java index 4f090dc..8ad4e01 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java @@ -42,6 +42,7 @@ import java.util.jar.JarFile; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.function.Function; import java.util.stream.Collectors; import org.python.indexer.types.NModuleType; @@ -49,7 +50,6 @@ import org.scijava.code.api.Completion; import org.scijava.code.api.Completion.TextEdit; import org.scijava.code.api.ParameterChoices; -import org.scijava.code.api.TypeResolver; /** * The Jython completion engine: given the code before the caret it analyzes the @@ -98,15 +98,16 @@ public static final class Result { public final List completions; public final ParameterChoices parameterChoices; - public final TypeResolver typeResolver; + /** The type (name) of an expression in the analyzed scope, or null. */ + public final Function types; Result(final List completions, final ParameterChoices parameterChoices, - final TypeResolver typeResolver) + final Function types) { this.completions = completions; this.parameterChoices = parameterChoices; - this.typeResolver = typeResolver; + this.types = types; } } diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java index a8827f9..787d44c 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java @@ -28,9 +28,13 @@ */ package org.scijava.plugins.scripting.jython; +import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; +import java.util.List; import java.util.LinkedHashMap; import java.util.Map; +import java.util.stream.Collectors; import javax.script.Bindings; import javax.script.ScriptContext; @@ -48,7 +52,10 @@ import org.scijava.code.api.CodeCompleterPlugin; import org.scijava.code.api.CodeCompletionService; import org.scijava.code.api.CompletionRequest; +import org.scijava.code.api.Completion; import org.scijava.code.api.CompletionResult; +import org.scijava.code.api.SignatureHelp; +import org.scijava.code.api.Signatures; /** * Toolkit-agnostic code completion for the Jython language, contributed by the @@ -94,7 +101,73 @@ public CompletionResult complete(final CompletionRequest request) { codeWithoutLastLine, lastLine, alreadyEntered, predefinedVariables( request)); return new CompletionResult(result.completions, replaceStart, - result.parameterChoices, result.typeResolver); + result.parameterChoices); + } + + @Override + public SignatureHelp signatureHelp(final CompletionRequest request) { + final String text = request.text(); + final int caret = request.offset(); + if (request.lineToCaret().trim().startsWith("#")) return SignatureHelp.NONE; + final int open = Signatures.callStart(text, caret); + if (open < 0) return SignatureHelp.NONE; + final List args = Signatures.arguments(text, open, caret); + if (args == null) return SignatureHelp.NONE; + + // The callee, e.g. "Math.max" or "ArrayList". + final String callee = alreadyEnteredText(text.substring(text.lastIndexOf( + '\n', open - 1) + 1, open)).trim(); + if (callee.isEmpty()) return SignatureHelp.NONE; + + // Its signatures: what completion offers for it, as if the caret were + // just before the "(" (methods: "Math.max"), or just after it + // (constructors: offered by their class's simple name, "ArrayList"). + final String simpleName = callee.substring(callee.lastIndexOf('.') + 1); + JythonAutoCompletions.Result result = null; + List callables = Collections.emptyList(); + for (final int at : new int[] { open, open + 1 }) { + final String name = at == open ? callee : simpleName; + result = completionsAt(request, at); + callables = result.completions.stream().filter(c -> c.isCallable() && + stripParens(c.insertionText()).equals(name)).collect(Collectors + .toList()); + if (!callables.isEmpty()) break; + } + if (callables.isEmpty()) return SignatureHelp.NONE; + + // The types of the arguments before the caret's (which is being typed). + final List argTypes = new ArrayList<>(); + for (int i = 0; i < args.size(); i++) { + final String arg = args.get(i).trim(); + argTypes.add(i == args.size() - 1 || arg.isEmpty() ? null : result.types + .apply(arg)); + } + // NB: Reflection lists methods in no particular order: sort them by + // their parameter types, so that equally good fits come out the same. + final List sorted = new ArrayList<>(callables); + sorted.sort(Comparator.comparing(c -> c.parameters().stream().map( + p -> String.valueOf(p.type())).collect(Collectors.joining(",")))); + final ClassLoader loader = Thread.currentThread().getContextClassLoader(); + return new SignatureHelp(Signatures.rateAll(sorted, argTypes, (a, + p) -> Signatures.fit(a, p, loader)), args.size() - 1, open); + } + + /** Computes completions as if the caret were at the given offset. */ + private JythonAutoCompletions.Result completionsAt( + final CompletionRequest request, final int offset) + { + final CompletionRequest there = new CompletionRequest(request.text(), + offset, request.language(), request.engine(), request.context(), request + .path()); + final String textToCaret = there.textToCaret(); + final String lastLine = there.lineToCaret(); + return engine.completionsFor(textToCaret.substring(0, textToCaret + .length() - lastLine.length()), lastLine, alreadyEnteredText(lastLine), + predefinedVariables(request)); + } + + private static String stripParens(final String name) { + return name.endsWith("()") ? name.substring(0, name.length() - 2) : name; } /** diff --git a/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java index 8c03d14..6dcfc48 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java @@ -47,7 +47,7 @@ import org.scijava.code.api.Completion; import org.scijava.code.api.CompletionRequest; import org.scijava.code.api.CompletionResult; -import org.scijava.code.api.TypeResolver; +import org.scijava.code.api.SignatureHelp; /** * Tests that the Jython {@link JythonCodeCompleter} is discovered for the Jython @@ -203,33 +203,50 @@ public void testInterpreterBindings() throws Exception { } @Test - public void testArgumentTypes() { + public void testSignatureHelp() { + // A Python float fits the floating-point overloads, which come first. + final SignatureHelp max = help("from java.lang import Math\n" + + "Math.max(1.5, "); + assertEquals(1, max.activeParameter()); + assertEquals("[double:MATCH, float:MATCH, int:MISMATCH, long:MISMATCH]", + describe(max)); + // A Python int fits the integral ones, and converts to the others. + assertEquals("[int:MATCH, long:MATCH, double:CONVERSION, " + + "float:CONVERSION]", describe(help("from java.lang import Math\n" + + "Math.max(1, 2"))); + // Arguments' types come from the analysis, e.g. of variables. + final SignatureHelp indexOf = help("s = 'abc'\nc = 'b'\n" + + "s.indexOf(c, "); + assertTrue(describe(indexOf), describe(indexOf).startsWith( + "[java.lang.String:MATCH")); + // Constructors. + assertTrue(describe(help("from java.util import ArrayList\n" + + "ArrayList(")).contains("java.util.Collection")); + // None outside calls. + assertTrue(help("x = 1").isEmpty()); + } + + private static SignatureHelp help(final String code) { final Context ctx = new Context(ScriptService.class, CodeCompletionService.class); try { final ScriptLanguage jython = ctx.service(ScriptService.class) .getLanguageByName("Jython"); - final String code = "#@ String name\n" + // - "from java.util import ArrayList\n" + // - "x = 1.5\n" + // - "lst = ArrayList()\n" + // - "name."; - final TypeResolver types = ctx.service(CodeCompletionService.class) - .complete(new CompletionRequest(code, jython, null)).typeResolver(); - assertNotNull(types); - assertEquals("long", types.typeOf("1")); - assertEquals("double", types.typeOf("x")); - assertEquals("java.lang.String", types.typeOf("name")); - assertEquals("java.lang.String", types.typeOf("'hi'")); - assertEquals("java.util.ArrayList", types.typeOf("lst")); - assertEquals(null, types.typeOf("undefinedThing")); - assertEquals(null, types.typeOf("1 +")); + return ctx.service(CodeCompletionService.class).getCompleter(jython) + .signatureHelp(new CompletionRequest(code, jython, null)); } finally { ctx.dispose(); } } + /** Each signature's first parameter type and fit, in order. */ + private static String describe(final SignatureHelp help) { + return help.signatures().stream().map(s -> (s.callable().parameters() + .isEmpty() ? "()" : s.callable().parameters().get(0).type()) + ":" + s + .fit()).collect(Collectors.toList()).toString(); + } + // -- Helper methods -- private static List complete(final CodeCompletionService service, From da5bc988f4de631297b36bb1c5307628b85081c2 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Tue, 6 Oct 2026 12:51:51 -0500 Subject: [PATCH 07/29] Type script parameters via a JythonDialect Co-Authored-By: Claude Opus 5.5 --- .../scripting/jython/JythonCodeCompleter.java | 23 +++---- .../scripting/jython/JythonDialect.java | 64 +++++++++++++++++++ 2 files changed, 73 insertions(+), 14 deletions(-) create mode 100644 src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java index 787d44c..4729cb4 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java @@ -54,6 +54,7 @@ import org.scijava.code.api.CompletionRequest; import org.scijava.code.api.Completion; import org.scijava.code.api.CompletionResult; +import org.scijava.code.api.ScriptDialect; import org.scijava.code.api.SignatureHelp; import org.scijava.code.api.Signatures; @@ -72,6 +73,9 @@ priority = Priority.HIGH) public class JythonCodeCompleter extends AbstractCodeCompleterPlugin { + /** How scripts see their parameters: as the Java objects themselves. */ + private static final JythonDialect DIALECT = new JythonDialect(); + private final JythonAutoCompletions engine = new JythonAutoCompletions(); public JythonCodeCompleter() { @@ -185,8 +189,8 @@ private Map predefinedVariables( : context.getService(CodeCompletionService.class); if (completionService != null) { completionService.scriptParameters(request.text()).forEach((name, - type) -> vars.put(name, new VarDotAutocompletions(boxed(type) - .getName()))); + type) -> vars.put(name, new VarDotAutocompletions(DIALECT.runtimeType( + type)))); } final ScriptContext scriptContext = request.context(); final Bindings bindings = scriptContext == null ? null : scriptContext @@ -221,18 +225,9 @@ private static DotAutocompletions bindingCompletions(final Object value) { return new VarDotAutocompletions(value.getClass().getName()); } - /** Maps primitive types to their wrappers, whose members can be listed. */ - private static Class boxed(final Class type) { - if (!type.isPrimitive()) return type; - if (type == int.class) return Integer.class; - if (type == long.class) return Long.class; - if (type == double.class) return Double.class; - if (type == float.class) return Float.class; - if (type == boolean.class) return Boolean.class; - if (type == char.class) return Character.class; - if (type == short.class) return Short.class; - if (type == byte.class) return Byte.class; - return Object.class; // void + @Override + public ScriptDialect dialect() { + return DIALECT; } /** diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java new file mode 100644 index 0000000..70c0df1 --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java @@ -0,0 +1,64 @@ +/*- + * #%L + * JSR-223-compliant Jython scripting language plugin. + * %% + * Copyright (C) 2020 - 2025 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.plugins.scripting.jython; + +import org.scijava.code.api.ScriptDialect; + +/** + * How Jython scripts see their script parameters: as the Java objects + * themselves (primitives boxed), e.g. {@code image: net.imglib2.img.Img}. + * + * @author Gabriel Selzer + */ +class JythonDialect implements ScriptDialect { + + @Override + public String runtimeType(final Class type) { + return boxed(type).getName(); + } + + @Override + public String declare(final String name, final String type) { + return name + ": " + type; + } + + /** Maps primitive types to their wrappers, whose members can be listed. */ + private static Class boxed(final Class type) { + if (!type.isPrimitive()) return type; + if (type == int.class) return Integer.class; + if (type == long.class) return Long.class; + if (type == double.class) return Double.class; + if (type == float.class) return Float.class; + if (type == boolean.class) return Boolean.class; + if (type == char.class) return Character.class; + if (type == short.class) return Short.class; + if (type == byte.class) return Byte.class; + return Object.class; // void + } +} From 77b2d7f7dbbac11ac851885a2a0fa5087f92ce96 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Tue, 6 Oct 2026 13:30:24 -0500 Subject: [PATCH 08/29] Add a Python language server's completions, via Java stubs If a Python language server is offered (see code-lsp's LspService), its completions follow Jython's own: for Python modules, and for Java classes from Python stubs of the packages the script imports and its parameters' types (JavaStubs, written by reflection in the background, with the packages their signatures refer to). JythonDialect declares parameters as Python types for the server. Co-Authored-By: Claude Opus 5.5 --- README.md | 16 + pom.xml | 7 + .../plugins/scripting/jython/JavaStubs.java | 460 ++++++++++++++++++ .../scripting/jython/JythonCodeCompleter.java | 194 +++++++- .../scripting/jython/JythonDialect.java | 61 ++- .../scripting/jython/JavaStubsTest.java | 197 ++++++++ .../jython/JythonServerCompletionTest.java | 173 +++++++ 7 files changed, 1104 insertions(+), 4 deletions(-) create mode 100644 src/main/java/org/scijava/plugins/scripting/jython/JavaStubs.java create mode 100644 src/test/java/org/scijava/plugins/scripting/jython/JavaStubsTest.java create mode 100644 src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java diff --git a/README.md b/README.md index c46d24b..b8f4538 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,22 @@ in addition to being usable directly as a `javax.script.ScriptEngineFactory`, it also provides some functionality on top, such as the ability to generate lines of script code based on SciJava events. +## Code completion + +The Script Editor completes code in Jython scripts: Java classes, their +members and constructors (with their overloads), imports, the script's `#@` +parameters and, in a live interpreter, its variables. This is Jython's own +completion, which knows Java exactly. + +If a Python language server is available (e.g. jedi-language-server, as +offered by the `appose-python` scripting plugin), its completions are added +too: for Python modules, and for Java classes, from Python type stubs +(`.pyi`) of the Java packages the script imports, written in the background +to `~/.cache/scijava/jython-stubs` (or `-Dscijava.jython.stubs=`). The +server analyzes the script as CPython (Jython's standard library is Python +2's, but mostly the same); so it is asked for completions only, not for +problems. + For a complete list of scripting languages available as part of the SciJava platform, see the [Scripting](https://github.com/scijava/scijava-common/wiki/Scripting) page on diff --git a/pom.xml b/pom.xml index 04a8278..13e1df0 100644 --- a/pom.xml +++ b/pom.xml @@ -86,6 +86,8 @@ 2.101.0 0.1.0-SNAPSHOT + + 2.14.0 bsd_2 SciJava developers. @@ -106,6 +108,11 @@ scijava-code-api ${scijava-code-api.version} + + org.scijava + scijava-code-lsp + ${scijava-code-api.version} + diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JavaStubs.java b/src/main/java/org/scijava/plugins/scripting/jython/JavaStubs.java new file mode 100644 index 0000000..6a1bd8a --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/jython/JavaStubs.java @@ -0,0 +1,460 @@ +/*- + * #%L + * JSR-223-compliant Jython scripting language plugin. + * %% + * Copyright (C) 2020 - 2025 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.plugins.scripting.jython; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Executable; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.Parameter; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.Deque; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.scijava.code.api.ClassIndex; + +/** + * Writes Python type stubs ({@code .pyi}) of Java packages, so that a Python + * language server (e.g. jedi-language-server) knows the Java classes a Jython + * script uses: {@code from ij import IJ} then finds {@code ij/__init__.pyi}, + * with {@code IJ}'s methods, their parameters and return types. + *

+ * Stubs are written on demand, per package, from the classes themselves (as + * the given class loader loads them), in the background; and so are the + * stubs of the packages their signatures refer to, a few steps on, within a + * limit. Java types become Python ones where Jython converts them (e.g. + * {@code String} becomes {@code str}); overloads become + * {@code typing.overload}s; getters become properties too, as Jython offers + * them. + *

+ * + * @author Gabriel Selzer + */ +public class JavaStubs { + + /** How many packages to write stubs for, at most, per session. */ + private static final int MAX_PACKAGES = 400; + + /** How many steps to follow the packages signatures refer to. */ + private static final int DEPTH = 2; + + /** Names Python reserves (and so cannot be members' names in stubs). */ + private static final Set KEYWORDS = new HashSet<>(Arrays.asList( + "False", "None", "True", "and", "as", "assert", "async", "await", "break", + "class", "continue", "def", "del", "elif", "else", "except", "finally", + "for", "from", "global", "if", "import", "in", "is", "lambda", "nonlocal", + "not", "or", "pass", "raise", "return", "try", "while", "with", "yield")); + + private final File dir; + private final ClassLoader loader; + private final Function> classNames; + + /** The packages done (or being done). */ + private final Set done = ConcurrentHashMap.newKeySet(); + + private final ExecutorService writer = Executors.newSingleThreadExecutor( + r -> { + final Thread t = new Thread(r, "Jython-stubs"); + t.setDaemon(true); + return t; + }); + + /** + * @param dir Where to write the stubs. + * @param loader Loads the classes. + */ + public JavaStubs(final File dir, final ClassLoader loader) { + this(dir, loader, JavaStubs::indexedClassNames); + } + + /** + * @param dir Where to write the stubs. + * @param loader Loads the classes. + * @param classNames Lists the (fully qualified) names of the classes in a + * package. + */ + JavaStubs(final File dir, final ClassLoader loader, + final Function> classNames) + { + this.dir = dir; + this.loader = loader; + this.classNames = classNames; + } + + /** Where the stubs are written: a Python search path. */ + public File dir() { + return dir; + } + + /** + * Writes the stubs of the given packages, if not done yet; then, of the + * packages they refer to. Returns at once. + * + * @return Done once the given packages' stubs are written. + */ + public CompletableFuture ensure(final Collection packages) { + final List todo = packages.stream().filter(p -> !done.contains(p)) + .collect(Collectors.toList()); + if (todo.isEmpty()) return CompletableFuture.completedFuture(null); + final CompletableFuture written = new CompletableFuture<>(); + writer.execute(() -> { + final Deque queue = new ArrayDeque<>(todo); + final Map depth = new LinkedHashMap<>(); + for (final String p : todo) depth.put(p, 0); + int first = todo.size(); + while (!queue.isEmpty()) { + final String pkg = queue.poll(); + if (first-- == 0) written.complete(null); + if (done.size() >= MAX_PACKAGES || !done.add(pkg)) continue; + final Set refs = new TreeSet<>(); + try { + write(pkg, stub(pkg, refs)); + } + catch (final IOException | RuntimeException | LinkageError exc) { + // NB: No stubs for this package, then. + } + final int d = depth.get(pkg); + if (d >= DEPTH) continue; + for (final String r : refs) { + if (!done.contains(r) && !depth.containsKey(r)) { + depth.put(r, d + 1); + queue.add(r); + } + } + } + written.complete(null); + }); + return written; + } + + /** + * Gets the Java packages a Jython script imports from, e.g. {@code ij} for + * {@code from ij import IJ}, and {@code java.util} for + * {@code import java.util}. + */ + public static Set imports(final String script) { + final Set packages = new TreeSet<>(); + for (final String line : script.split("\n")) { + final String l = line.trim(); + String module = null; + if (l.startsWith("from ")) { + final int imp = l.indexOf(" import"); + module = imp < 0 ? l.substring(5) : l.substring(5, imp); + } + else if (l.startsWith("import ")) { + module = l.substring(7).split("[ ,]")[0]; + } + if (module == null) continue; + module = module.trim(); + if (module.matches("[\\w.]+") && !module.endsWith(".")) { + packages.add(module); + } + } + return packages; + } + + // -- Stub writing -- + + /** Writes a package's stub, and its parents' (empty, if missing). */ + private void write(final String pkg, final String stub) throws IOException { + if (stub == null) return; // NB: Not a Java package. + File d = dir; + for (final String part : pkg.split("\\.")) { + d = new File(d, part); + d.mkdirs(); + final File init = new File(d, "__init__.pyi"); + if (!init.exists()) Files.write(init.toPath(), new byte[0]); + } + final File init = new File(d, "__init__.pyi"); + final byte[] bytes = stub.getBytes(StandardCharsets.UTF_8); + // NB: Unchanged files stay as they are, so the server need not reload. + if (!Arrays.equals(Files.readAllBytes(init.toPath()), bytes)) { + Files.write(init.toPath(), bytes); + } + } + + /** + * Builds the stub of a package, noting the other packages it refers to; + * or returns null if the package has no (public) classes. + */ + String stub(final String pkg, final Set refs) { + final List> classes = new ArrayList<>(); + for (final String name : classNames.apply(pkg)) { + if (name.indexOf('$') >= 0) continue; // NB: Nested ones, in their outer. + try { + final Class c = Class.forName(name, false, loader); + if (Modifier.isPublic(c.getModifiers()) && !c.isSynthetic()) { + classes.add(c); + } + } + catch (final ClassNotFoundException | LinkageError exc) { + // NB: Not loadable here; skip it. + } + } + if (classes.isEmpty()) return null; + classes.sort(Comparator.comparing(Class::getSimpleName)); + final StringBuilder body = new StringBuilder(); + for (final Class c : classes) { + try { + classStub(c, pkg, refs, "", body); + } + catch (final LinkageError | SecurityException exc) { + // NB: Its members refer to classes not loadable here; skip it. + } + } + refs.remove(pkg); + final StringBuilder sb = new StringBuilder("# Stubs of the Java package " + + pkg + ", for code completion. Generated; do not edit.\n"); + sb.append("import typing\n"); + for (final String r : refs) sb.append("import ").append(r).append('\n'); + return sb.append(body).toString(); + } + + private void classStub(final Class c, final String pkg, + final Set refs, final String indent, final StringBuilder sb) + { + final List bases = new ArrayList<>(); + final Class sup = c.getSuperclass(); + if (sup != null && sup != Object.class && Modifier.isPublic(sup + .getModifiers())) bases.add(type(sup, pkg, refs)); + for (final Class i : c.getInterfaces()) { + if (Modifier.isPublic(i.getModifiers())) bases.add(type(i, pkg, refs)); + } + if (bases.isEmpty() && c != Object.class) { + bases.add(type(Object.class, pkg, refs)); + } + sb.append('\n').append(indent).append("class ").append(c.getSimpleName()); + if (!bases.isEmpty()) sb.append('(').append(String.join(", ", bases)) + .append(')'); + sb.append(":\n"); + final String in = indent + " "; + final int length = sb.length(); + final Set names = new HashSet<>(); + + // Fields. + for (final Field f : sorted(c.getDeclaredFields(), Field::getName)) { + if (!Modifier.isPublic(f.getModifiers()) || f.isSynthetic() || !valid(f + .getName())) continue; + names.add(f.getName()); + final String t = type(f.getType(), pkg, refs); + sb.append(in).append(f.getName()).append(": ").append(Modifier.isStatic( + f.getModifiers()) ? "typing.ClassVar[" + t + "]" : t).append('\n'); + } + + // Constructors. + final List> ctors = new ArrayList<>(); + for (final Constructor ctor : c.getDeclaredConstructors()) { + if (Modifier.isPublic(ctor.getModifiers()) && !ctor.isSynthetic()) { + ctors.add(ctor); + } + } + ctors.sort(Comparator.comparing(JavaStubs::signatureKey)); + for (final Constructor ctor : ctors) { + if (ctors.size() > 1) sb.append(in).append("@typing.overload\n"); + sb.append(in).append("def __init__(self").append(parameters(ctor, pkg, + refs)).append(") -> None: ...\n"); + } + + // Methods, by name. + final Map> methods = new LinkedHashMap<>(); + for (final Method m : sorted(c.getDeclaredMethods(), JavaStubs::signatureKey)) { + if (!Modifier.isPublic(m.getModifiers()) || m.isSynthetic() || m + .isBridge() || !valid(m.getName())) continue; + methods.computeIfAbsent(m.getName(), k -> new ArrayList<>()).add(m); + } + for (final Map.Entry> e : methods.entrySet()) { + names.add(e.getKey()); + for (final Method m : e.getValue()) { + final boolean isStatic = Modifier.isStatic(m.getModifiers()); + if (e.getValue().size() > 1) sb.append(in).append( + "@typing.overload\n"); + if (isStatic) sb.append(in).append("@staticmethod\n"); + sb.append(in).append("def ").append(m.getName()).append('(').append( + isStatic ? parameters(m, pkg, refs).replaceFirst("^, ", "") + : "self" + parameters(m, pkg, refs)).append(") -> ").append( + type(m.getReturnType(), pkg, refs)).append(": ...\n"); + } + } + + // Bean properties, as Jython offers them: getTitle() as title. + for (final List overloads : methods.values()) { + for (final Method m : overloads) { + final String property = property(m); + if (property == null || !names.add(property)) continue; + sb.append(in).append(property).append(": ").append(type(m + .getReturnType(), pkg, refs)).append('\n'); + } + } + + // Nested classes. + for (final Class nested : sorted(c.getDeclaredClasses(), + Class::getSimpleName)) + { + if (Modifier.isPublic(nested.getModifiers()) && !nested.isSynthetic() && + valid(nested.getSimpleName()) && names.add(nested.getSimpleName())) + { + classStub(nested, pkg, refs, in, sb); + } + } + if (sb.length() == length) sb.append(in).append("...\n"); + } + + /** Renders the parameters, each preceded by ", ". */ + private String parameters(final Executable e, final String pkg, + final Set refs) + { + final StringBuilder sb = new StringBuilder(); + final Set used = new HashSet<>(); + used.add("self"); + final Parameter[] params = e.getParameters(); + for (int i = 0; i < params.length; i++) { + final Parameter p = params[i]; + String name = p.isNamePresent() ? p.getName() : derivedName(p.getType()); + if (KEYWORDS.contains(name)) name += "_"; + while (!used.add(name)) name += i; + final Class type = e.isVarArgs() && i == params.length - 1 ? p + .getType().getComponentType() : p.getType(); + sb.append(", "); + if (e.isVarArgs() && i == params.length - 1) sb.append('*'); + sb.append(name).append(": ").append(type(type, pkg, refs)); + } + return sb.toString(); + } + + /** The Python type of a Java one, as written in a stub of {@code pkg}. */ + String type(final Class c, final String pkg, final Set refs) { + if (c == void.class) return "None"; + if (c == boolean.class || c == Boolean.class) return "bool"; + if (c == int.class || c == long.class || c == short.class || + c == byte.class || c == Integer.class || c == Long.class || + c == Short.class || c == Byte.class || c == BigInteger.class) + { + return "int"; + } + if (c == float.class || c == double.class || c == Float.class || + c == Double.class || c == BigDecimal.class) return "float"; + if (c == char.class || c == Character.class || c == String.class) { + return "str"; + } + if (c.isArray()) return "list[" + type(c.getComponentType(), pkg, refs) + + "]"; + if (!Modifier.isPublic(c.getModifiers()) || c.isAnonymousClass() || c + .isLocalClass()) return "typing.Any"; + final Class top = topLevel(c); + final int dot = top.getName().lastIndexOf('.'); + final String topPkg = dot < 0 ? "" : top.getName().substring(0, dot); + if (topPkg.isEmpty()) return "typing.Any"; + // NB: Nested classes by their path from the top-level class. + final String path = c.getName().substring(topPkg.length() + 1).replace('$', + '.'); + if (topPkg.equals(pkg)) return path; + refs.add(topPkg); + return topPkg + "." + path; + } + + // -- Helper methods -- + + private static Class topLevel(Class c) { + while (c.getEnclosingClass() != null) c = c.getEnclosingClass(); + return c; + } + + /** The property a getter offers (e.g. title for getTitle()), or null. */ + private static String property(final Method m) { + if (Modifier.isStatic(m.getModifiers()) || m.getParameterCount() != 0 || m + .getReturnType() == void.class) return null; + final String name = m.getName(); + String rest = null; + if (name.startsWith("get") && name.length() > 3) rest = name.substring(3); + else if (name.startsWith("is") && name.length() > 2 && (m + .getReturnType() == boolean.class)) rest = name.substring(2); + if (rest == null || !Character.isUpperCase(rest.charAt(0))) return null; + final String property = Character.toLowerCase(rest.charAt(0)) + rest + .substring(1); + return valid(property) ? property : null; + } + + /** A parameter name from its type, e.g. imageProcessor, or values. */ + private static String derivedName(final Class type) { + if (type.isArray()) return "array"; + final String simple = type.getSimpleName(); + if (simple.isEmpty()) return "arg"; + return Character.toLowerCase(simple.charAt(0)) + simple.substring(1); + } + + /** Whether a name can be a Python identifier in a stub. */ + private static boolean valid(final String name) { + return !name.isEmpty() && !KEYWORDS.contains(name) && name.indexOf( + '$') < 0 && Character.isJavaIdentifierStart(name.charAt(0)); + } + + /** Orders overloads (and members) the same each time. */ + private static String signatureKey(final Executable e) { + return e.getName() + Arrays.stream(e.getParameterTypes()).map( + Class::getName).collect(Collectors.joining(",", "(", ")")); + } + + private static List sorted(final T[] items, + final Function key) + { + final List list = new ArrayList<>(Arrays.asList(items)); + list.sort(Comparator.comparing(key)); + return list; + } + + /** Lists a package's classes from the {@link ClassIndex}. */ + private static List indexedClassNames(final String pkg) { + final String prefix = pkg + "."; + return ClassIndex.findClassNamesForPackage(pkg).filter(n -> n.startsWith( + prefix) && n.indexOf('.', prefix.length()) < 0).collect(Collectors + .toList()); + } +} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java index 4729cb4..6a969df 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java @@ -28,12 +28,22 @@ */ package org.scijava.plugins.scripting.jython; +import java.io.File; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; +import java.util.HashSet; import java.util.List; import java.util.LinkedHashMap; +import java.util.Locale; import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.stream.Collectors; import javax.script.Bindings; @@ -54,7 +64,13 @@ import org.scijava.code.api.CompletionRequest; import org.scijava.code.api.Completion; import org.scijava.code.api.CompletionResult; +import org.scijava.code.api.EnvironmentContext; import org.scijava.code.api.ScriptDialect; +import org.scijava.code.api.ScriptDocument; +import org.scijava.code.lsp.LspClient; +import org.scijava.code.lsp.LspServers; +import org.scijava.code.lsp.LspService; +import org.scijava.code.lsp.PythonLspClient; import org.scijava.code.api.SignatureHelp; import org.scijava.code.api.Signatures; @@ -76,6 +92,20 @@ public class JythonCodeCompleter extends AbstractCodeCompleterPlugin { /** How scripts see their parameters: as the Java objects themselves. */ private static final JythonDialect DIALECT = new JythonDialect(); + /** + * How long to wait for the language server before answering without it + * (and updating the answer once its arrives), in milliseconds. + */ + private static final long BUDGET = Long.getLong( + "scijava.jython.completion.budget", 25); + + /** How many completions get signatures, once a word is begun. */ + private static final int SIGNATURES = 30; + + private LspClient lsp; + + private JavaStubs stubs; + private final JythonAutoCompletions engine = new JythonAutoCompletions(); public JythonCodeCompleter() { @@ -104,8 +134,168 @@ public CompletionResult complete(final CompletionRequest request) { final JythonAutoCompletions.Result result = engine.completionsFor( codeWithoutLastLine, lastLine, alreadyEntered, predefinedVariables( request)); - return new CompletionResult(result.completions, replaceStart, - result.parameterChoices); + final CompletionResult local = new CompletionResult(result.completions, + replaceStart, result.parameterChoices); + if (lastLine.trim().startsWith("#")) return local; + + // Also ask a Python language server, if there is one, knowing the Java + // packages from stubs: its completions follow Jython's own. + final LspClient client = lspClient(); + if (client == null) return local; + // NB: What was typed is replaced as a whole, e.g. "os.pa": the server's + // completions of the word ("path") get the qualifier ("os."). + int w = alreadyEntered.length(); + while (w > 0 && Character.isJavaIdentifierPart(alreadyEntered.charAt(w - + 1))) w--; + final String qualifier = alreadyEntered.substring(0, w); + final String word = alreadyEntered.substring(w); + if (word.isEmpty() && !qualifier.endsWith(".")) return local; + final CompletableFuture> answer; + try { + // NB: Asked about the word's first character, so that later keystrokes + // reuse the answer. + answer = client.complete(document(request), uri(request), caret - word + .length() + Math.min(1, word.length()), word.isEmpty() ? 0 + : SIGNATURES); + } + catch (final RuntimeException exc) { + return local; // NB: E.g. no stubs directory. + } + try { + return merge(local, answer.get(BUDGET, TimeUnit.MILLISECONDS), qualifier, + word); + } + catch (final TimeoutException exc) { + return local.withUpdate(answer.handle((server, error) -> server == null + ? null : merge(local, server, qualifier, word))); + } + catch (final InterruptedException exc) { + Thread.currentThread().interrupt(); + } + catch (final ExecutionException | CancellationException exc) { + // NB: The server failed; the next request asks again. + } + return local; + } + + @Override + public void prepare(final CompletionRequest request) { + // Warm up: write the stubs of the packages the script uses, and start + // the server, in the background. + final LspClient client = lspClient(); + if (client != null) client.prepare(document(request), uri(request)); + } + + @Override + public void closed(final CompletionRequest request) { + final LspClient client; + synchronized (this) { + client = lsp; + } + if (client != null) client.close(uri(request)); + } + + /** Sets the language server client, e.g. to use a fake one in tests. */ + synchronized void setLspClient(final LspClient lsp) { + this.lsp = lsp; + } + + /** Sets the stubs, e.g. to write them elsewhere in tests. */ + synchronized void setStubs(final JavaStubs stubs) { + this.stubs = stubs; + } + + /** + * Gets the language server client, if a Python language server is offered + * (see {@link LspService}); creating it on first use. + */ + private synchronized LspClient lspClient() { + if (lsp != null) return lsp; + final Context context = getContext(); + final LspService service = context == null ? null : context.getService( + LspService.class); + final LspServers servers = service == null ? null : service.servers( + "python"); + if (servers == null) return null; + lsp = new PythonLspClient(servers); + return lsp; + } + + /** Gets the stubs of Java packages, creating them on first use. */ + private synchronized JavaStubs stubs() { + if (stubs != null) return stubs; + final String dir = System.getProperty("scijava.jython.stubs"); + stubs = new JavaStubs(dir != null ? new File(dir) : new File(System + .getProperty("user.home"), ".cache" + File.separator + "scijava" + + File.separator + "jython-stubs"), Thread.currentThread() + .getContextClassLoader()); + return stubs; + } + + /** + * Gets the script as plain Python, for the server: its parameters + * declared; searching the stubs of Java packages (written for those it + * imports, and its parameters' types, in the background). + */ + private ScriptDocument document(final CompletionRequest request) { + final Map> params = scriptParameters(request.text()); + final ScriptDocument doc = ScriptDocument.of(request.text(), params, + DIALECT, null); + final JavaStubs s = stubs(); + final Set packages = new TreeSet<>(JavaStubs.imports(request + .text())); + packages.addAll(JythonDialect.packages(doc.parameters().values().stream() + .filter(t -> t != null).collect(Collectors.toList()))); + s.ensure(packages); + // NB: The server's own Python (CPython): Jython's standard library is + // Python 2's, but most of it is the same. + return ScriptDocument.of(request.text(), params, DIALECT, + new EnvironmentContext(null, Collections.singletonList(s.dir() + .getAbsolutePath()), null)); + } + + private Map> scriptParameters(final String text) { + final Context context = getContext(); + final CodeCompletionService service = context == null ? null : context + .getService(CodeCompletionService.class); + return service == null ? Collections.emptyMap() : service + .scriptParameters(text); + } + + /** The URI of the script, for the language server. */ + private static String uri(final CompletionRequest request) { + return LspClient.uri(request.path(), "jython-script.py"); + } + + /** + * Merges the server's completions (of the word being typed) after Jython's + * own: those Jython does not know already, with the qualifier typed. + */ + private static CompletionResult merge(final CompletionResult local, + final List server, final String qualifier, final String word) + { + if (server == null || server.isEmpty()) return local; + final List out = new ArrayList<>(local.completions()); + final Set seen = new HashSet<>(); + for (final Completion c : out) seen.add(stripParens(c.insertionText())); + final boolean importing = qualifier.trim().startsWith("from ") || qualifier + .trim().startsWith("import "); + for (final Completion c : server) { + final String name = c.insertionText(); + if (!name.toLowerCase(Locale.ROOT).startsWith(word.toLowerCase( + Locale.ROOT)) || name.equals(word)) continue; + if (name.startsWith("_") && !word.startsWith("_")) continue; + if (!seen.add(qualifier + name)) continue; + // NB: An import names things rather than calling them. + final Completion.Kind kind = importing && c.isCallable() + ? Completion.Kind.OTHER : c.kind(); + out.add(Completion.builder(qualifier + name).kind(kind) + .parameters(importing ? Collections.emptyList() : c.parameters()) + .returnType(c.returnType()).summary(c.summary()).lazyDescription( + c::description).build()); + } + return new CompletionResult(out, local.replaceStart(), local + .parameterChoices()); } @Override diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java index 70c0df1..91d0cf0 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java @@ -28,11 +28,25 @@ */ package org.scijava.plugins.scripting.jython; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + import org.scijava.code.api.ScriptDialect; /** * How Jython scripts see their script parameters: as the Java objects - * themselves (primitives boxed), e.g. {@code image: net.imglib2.img.Img}. + * themselves (primitives boxed), e.g. {@code image: net.imglib2.img.Img}; or + * as Python values, where Jython converts them (e.g. strings). + *

+ * Run-time types are Java class names (e.g. {@code java.lang.String}), as + * Jython's own completion needs them; declarations are Python, for a Python + * language server knowing the Java packages from stubs (see + * {@link JavaStubs}), e.g. {@code import net.imglib2.img; image: + * net.imglib2.img.Img; name: str}. + *

* * @author Gabriel Selzer */ @@ -45,7 +59,50 @@ public String runtimeType(final Class type) { @Override public String declare(final String name, final String type) { - return name + ": " + type; + return name + ": " + pythonType(type); + } + + @Override + public List imports(final Collection types) { + final List imports = new ArrayList<>(); + for (final String p : packages(types)) imports.add("import " + p); + return imports; + } + + /** The Java packages of the given types that a declaration refers to. */ + static Set packages(final Collection types) { + final Set packages = new TreeSet<>(); + for (final String t : types) { + if (pythonType(t).equals(t.replace('$', '.')) && t.lastIndexOf( + '.') > 0) packages.add(t.substring(0, t.lastIndexOf('.'))); + } + return packages; + } + + /** + * The Python type of a Java class name, as Jython converts its values: + * e.g. {@code str} for {@code java.lang.String}. + */ + static String pythonType(final String type) { + switch (type) { + case "java.lang.String": + case "java.lang.Character": + return "str"; + case "java.lang.Integer": + case "java.lang.Long": + case "java.lang.Short": + case "java.lang.Byte": + case "java.math.BigInteger": + return "int"; + case "java.lang.Double": + case "java.lang.Float": + case "java.math.BigDecimal": + return "float"; + case "java.lang.Boolean": + return "bool"; + default: + return type.startsWith("[") ? "list" : type.replace('$', '.'); + } } /** Maps primitive types to their wrappers, whose members can be listed. */ diff --git a/src/test/java/org/scijava/plugins/scripting/jython/JavaStubsTest.java b/src/test/java/org/scijava/plugins/scripting/jython/JavaStubsTest.java new file mode 100644 index 0000000..3fecd83 --- /dev/null +++ b/src/test/java/org/scijava/plugins/scripting/jython/JavaStubsTest.java @@ -0,0 +1,197 @@ +/*- + * #%L + * JSR-223-compliant Jython scripting language plugin. + * %% + * Copyright (C) 2020 - 2025 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.plugins.scripting.jython; + +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.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +import org.junit.Test; + +/** + * Tests {@link JavaStubs}. + * + * @author Gabriel Selzer + */ +public class JavaStubsTest { + + private static final String PKG = "org.scijava.plugins.scripting.jython"; + + /** Lists just the example classes. */ + private static final Function> CLASSES = pkg -> PKG + .equals(pkg) ? Collections.singletonList(PKG + ".JavaStubsTest") : pkg + .equals("java.util") ? Collections.singletonList("java.util.Locale") + : Collections.emptyList(); + + @Test + public void testStub() { + final JavaStubs stubs = new JavaStubs(new File("unused"), getClass() + .getClassLoader(), pkg -> Collections.singletonList(PKG + + ".JavaStubsTest")); + final Set refs = new TreeSet<>(); + final String stub = stubs.stub(PKG, refs); + // The test class: its nested classes, inside. + assertTrue(stub, stub.contains("\nclass JavaStubsTest(java.lang.Object):\n")); + assertTrue(stub, stub.contains( + "\n class Picture(JavaStubsTest.Base, java.lang.Cloneable):\n")); + // Fields; constant ones as class variables. + assertTrue(stub, stub.contains(" MAX: typing.ClassVar[int]\n")); + assertTrue(stub, stub.contains(" title: str\n")); + // Overloads, constructors too; static methods; Java types as Python's. + // NB: Parameter names are the Java ones only if compiled with them. + has(stub, " @typing.overload\n" + + " def __init__\\(self, \\w+: str\\) -> None: \\.\\.\\.\n"); + has(stub, " @typing.overload\n" + + " def resize\\(self, \\w+: int, \\w+: int\\) -> " + + "JavaStubsTest\\.Picture: \\.\\.\\.\n"); + has(stub, " @typing.overload\n" + + " def resize\\(self, \\w+: float\\) -> JavaStubsTest\\.Picture"); + has(stub, " @staticmethod\n" + + " def open\\(\\w+: str, \\*\\w+: str\\) -> " + + "JavaStubsTest\\.Picture: \\.\\.\\.\n"); + assertTrue(stub, stub.contains(" def pixels(self) -> list[float]: ...\n")); + assertTrue(stub, stub.contains( + " def locale(self) -> java.util.Locale: ...\n")); + // Getters as properties, as Jython offers them. + assertTrue(stub, stub.contains(" width: int\n")); + assertTrue(stub, stub.contains(" opaque: bool\n")); + // Not Python: left out. + assertFalse(stub, stub.contains("def lambda")); + // Other packages: imported, and noted. + assertTrue(stub, stub.contains("\nimport java.util\n")); + assertEquals(new TreeSet<>(Arrays.asList("java.lang", "java.util")), refs); + // No classes: no stub. + assertNull(new JavaStubs(new File("unused"), getClass().getClassLoader(), + pkg -> Collections.emptyList()).stub("os", new TreeSet<>())); + } + + @Test + public void testEnsure() throws Exception { + final File dir = Files.createTempDirectory("jython-stubs").toFile(); + try { + final JavaStubs stubs = new JavaStubs(dir, getClass().getClassLoader(), + CLASSES); + stubs.ensure(Collections.singleton(PKG)).get(30, TimeUnit.SECONDS); + final File stub = new File(dir, PKG.replace('.', File.separatorChar) + + File.separator + "__init__.pyi"); + assertTrue(stub.isFile()); + // Parents are packages too. + assertTrue(new File(dir, "org" + File.separator + "__init__.pyi") + .isFile()); + // The packages referred to follow (here, java.util; java.lang has no + // classes listed). + for (int i = 0; i < 100 && !new File(dir, "java/util/__init__.pyi") + .isFile(); i++) Thread.sleep(50); + assertTrue(new String(Files.readAllBytes(new File(dir, + "java/util/__init__.pyi").toPath()), StandardCharsets.UTF_8).contains( + "class Locale(")); + assertFalse(new File(dir, "java/lang/__init__.pyi").exists()); + } + finally { + try (java.util.stream.Stream paths = Files.walk(dir + .toPath())) + { + paths.sorted(java.util.Comparator.reverseOrder()).forEach(p -> p + .toFile().delete()); + } + } + } + + @Test + public void testImports() { + assertEquals(new TreeSet<>(Arrays.asList("ij", "java.util", "os", + "ij.process")), JavaStubs.imports("from ij import IJ, ImagePlus\n" + + "import java.util\nimport os, sys\n from ij.process import *\n" + + "x = 1 # import nothing\nfrom . import y\n")); + } + + private static void has(final String stub, final String regex) { + assertTrue(regex + " in:\n" + stub, java.util.regex.Pattern.compile(regex) + .matcher(stub).find()); + } + + // -- Example classes -- + + public static class Base { + + public boolean isOpaque() { + return true; + } + } + + public static class Picture extends Base implements Cloneable { + + public static final int MAX = 1 << 16; + public String title; + + public Picture(final String title) { + this.title = title; + } + + public Picture(final String title, final int width) { + this(title); + } + + public Picture resize(final int width, final int height) { + return this; + } + + public Picture resize(final double scale) { + return this; + } + + public static Picture open(final String path, final String... options) { + return null; + } + + public double[] pixels() { + return null; + } + + public java.util.Locale locale() { + return null; + } + + public int getWidth() { + return 0; + } + } +} diff --git a/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java new file mode 100644 index 0000000..ac58b90 --- /dev/null +++ b/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java @@ -0,0 +1,173 @@ +/*- + * #%L + * JSR-223-compliant Jython scripting language plugin. + * %% + * Copyright (C) 2020 - 2025 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.plugins.scripting.jython; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.scijava.Context; +import org.scijava.code.api.CodeCompletionService; +import org.scijava.code.api.Completion; +import org.scijava.code.api.CompletionRequest; +import org.scijava.code.api.ScriptDocument; +import org.scijava.code.lsp.LspClient; +import org.scijava.script.ScriptLanguage; +import org.scijava.script.ScriptService; + +/** + * Tests {@link JythonCodeCompleter}'s use of a Python language server, with a + * fake one. + * + * @author Gabriel Selzer + */ +public class JythonServerCompletionTest { + + private Context context; + private ScriptLanguage jython; + private CodeCompletionService service; + private JythonCodeCompleter completer; + private File stubsDir; + private final List asked = new ArrayList<>(); + + @Before + public void setUp() throws Exception { + context = new Context(ScriptService.class, CodeCompletionService.class); + jython = context.service(ScriptService.class).getLanguageByName("Jython"); + service = context.service(CodeCompletionService.class); + completer = (JythonCodeCompleter) service.getCompleterPlugin(jython); + stubsDir = Files.createTempDirectory("jython-stubs").toFile(); + completer.setStubs(new JavaStubs(stubsDir, getClass().getClassLoader(), + pkg -> Collections.emptyList())); + } + + @After + public void tearDown() { + context.dispose(); + stubsDir.delete(); + } + + @Test + public void testServerCompletionsMerged() { + final Completion dumps = Completion.builder("dumps").kind( + Completion.Kind.METHOD).parameters(Collections.singletonList( + new Completion.Parameter("obj", null))).build(); + fake(doc -> Arrays.asList(dumps, Completion.of("dump"), Completion.of( + "loads"))); + final List completions = completions("import json\njson.du"); + final List texts = completions.stream().map( + Completion::insertionText).collect(Collectors.toList()); + // With what was typed before the word; filtered by the word. + assertTrue(texts.toString(), texts.contains("json.dumps")); + assertTrue(texts.contains("json.dump")); + assertFalse(texts.contains("json.loads")); + final Completion merged = completions.stream().filter(c -> c + .insertionText().equals("json.dumps")).findFirst().get(); + assertEquals(1, merged.parameters().size()); + + // The server sees the script as Python, with the stubs to search. + final ScriptDocument doc = asked.get(asked.size() - 1); + assertEquals(Collections.singletonList(stubsDir.getAbsolutePath()), doc + .environment().searchPaths()); + } + + @Test + public void testJythonFirst() { + // Jython knows s is a String: its own completion comes once. + fake(doc -> Collections.singletonList(Completion.of("length"))); + final String code = "#@ String s\ns.len"; + final List texts = completions(code).stream().map( + Completion::insertionText).collect(Collectors.toList()); + assertEquals(texts.toString(), 1, texts.stream().filter(t -> t.equals( + "s.length")).count()); + // Parameters declared for the server, as Python types. + assertTrue(asked.get(0).text().startsWith("s: str\n")); + } + + @Test + public void testImportsAreNames() { + final Completion arrayList = Completion.builder("ArrayList").kind( + Completion.Kind.METHOD).parameters(Collections.singletonList( + new Completion.Parameter("capacity", "int"))).build(); + fake(doc -> Collections.singletonList(arrayList)); + final Completion imported = completions("from java.util import ArrayLi") + .stream().filter(c -> c.insertionText().endsWith("ArrayList")).findFirst() + .get(); + assertFalse(imported.parameters().size() > 0 && imported.kind() == + Completion.Kind.METHOD); + } + + @Test + public void testDeclarations() { + final JythonDialect dialect = new JythonDialect(); + assertEquals("img: net.imglib2.img.Img", dialect.declare("img", + "net.imglib2.img.Img")); + assertEquals("n: int", dialect.declare("n", dialect.runtimeType( + int.class))); + assertEquals("e: java.util.Map.Entry", dialect.declare("e", + "java.util.Map$Entry")); + assertEquals(Arrays.asList("import java.util", "import net.imglib2.img"), + dialect.imports(Arrays.asList("net.imglib2.img.Img", "java.lang.String", + "java.util.Map$Entry"))); + } + + // -- Helper methods -- + + private void fake(final Function> answers) { + completer.setLspClient(new LspClient(null) { + + @Override + public CompletableFuture> complete( + final ScriptDocument doc, final String uri, final int offset, + final int resolve) + { + asked.add(doc); + return CompletableFuture.completedFuture(answers.apply(doc)); + } + }); + } + + private List completions(final String code) { + return service.complete(new CompletionRequest(code, jython, null)) + .completions(); + } +} From a8dd8c0b912d78eccaa122506e3a95f2d4828d5f Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Wed, 7 Oct 2026 10:46:36 -0500 Subject: [PATCH 09/29] Serve Jython as in-process language servers JythonLanguageServer (JythonServerPlugin) replaces JythonCodeCompleter, with the same engine, now producing LSP completions (methods and constructors as snippets with typed label details). In a call's arguments, with nothing typed, the variables of the parameter's type come first. JythonPythonServerPlugin offers a Python language server (if one is, e.g. by scripting-appose-python) for Jython scripts, with the Java stubs as search path and without its problems; the language server service merges its completions after Jython's own. Co-Authored-By: Claude Opus 5.5 --- .../jython/JythonAutoCompletions.java | 174 +++---- .../scripting/jython/JythonCodeCompleter.java | 441 ------------------ .../scripting/jython/JythonImports.java | 9 +- .../jython/JythonLanguageServer.java | 288 ++++++++++++ .../jython/JythonPythonServerPlugin.java | 183 ++++++++ .../scripting/jython/JythonServerPlugin.java | 69 +++ .../jython/JythonCompletionTest.java | 182 +++++--- .../jython/JythonServerCompletionTest.java | 110 ++--- 8 files changed, 786 insertions(+), 670 deletions(-) delete mode 100644 src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java create mode 100644 src/main/java/org/scijava/plugins/scripting/jython/JythonLanguageServer.java create mode 100644 src/main/java/org/scijava/plugins/scripting/jython/JythonPythonServerPlugin.java create mode 100644 src/main/java/org/scijava/plugins/scripting/jython/JythonServerPlugin.java diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java index 8ad4e01..930bbe1 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java @@ -47,16 +47,20 @@ import org.python.indexer.types.NModuleType; import org.scijava.code.api.ClassIndex; -import org.scijava.code.api.Completion; -import org.scijava.code.api.Completion.TextEdit; -import org.scijava.code.api.ParameterChoices; +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionItemKind; +import org.eclipse.lsp4j.MarkupContent; +import org.eclipse.lsp4j.MarkupKind; +import org.eclipse.lsp4j.TextEdit; +import org.scijava.code.lsp.Callable; /** * The Jython completion engine: given the code before the caret it analyzes the - * Jython AST (via {@link JythonScriptParser}) and produces toolkit-agnostic - * {@link Completion}s plus, when relevant, a {@link ParameterChoices} for - * parameter assistance. Class-name and import completions (including auto-import - * {@link TextEdit}s) are produced via {@link ClassIndex}. + * Jython AST (via {@link JythonScriptParser}) and produces completions (LSP + * {@link CompletionItem}s; methods and constructors as {@link Callable}s), + * plus the scope's variables by type, for arguments. Class-name and import + * completions (including auto-import {@link TextEdit}s) are produced via + * {@link ClassIndex}. * * @author Albert Cardona * @author Curtis Rueden @@ -91,22 +95,23 @@ public class JythonAutoCompletions { private static final List jython_jar_modules = findJythonModules(); /** - * Result of a completion query: the suggestions plus optional choices and - * argument type resolution. + * Result of a completion query: the suggestions, plus the analyzed scope's + * variables by type, and argument type resolution. */ public static final class Result { - public final List completions; - public final ParameterChoices parameterChoices; + public final List completions; + /** The variables of a type (e.g. {@code double}) in scope; or null. */ + public final Function> variablesOfType; /** The type (name) of an expression in the analyzed scope, or null. */ public final Function types; - Result(final List completions, - final ParameterChoices parameterChoices, + Result(final List completions, + final Function> variablesOfType, final Function types) { this.completions = completions; - this.parameterChoices = parameterChoices; + this.variablesOfType = variablesOfType; this.types = types; } } @@ -132,7 +137,7 @@ public Result completionsFor(final String codeWithoutLastLine, final String lastLine, final String alreadyEnteredText, final Map predefined) { - final List completions = new ArrayList<>(); + final List completions = new ArrayList<>(); final int crop = lastLine.length() - alreadyEnteredText.length(); // 1) AST-based completions (names, members, constructors, invocations). @@ -144,7 +149,7 @@ public Result completionsFor(final String codeWithoutLastLine, completions); if (scope == null) return new Result(dedupe(completions), null, null); - return new Result(dedupe(completions), scopeChoices(scope), + return new Result(dedupe(completions), type -> variablesOfType(scope, type), expression -> JythonScriptParser.typeOf(expression, scope)); } @@ -154,7 +159,7 @@ public Result completionsFor(final String codeWithoutLastLine, private Scope astCompletions(String codeWithoutLastLine, final String lastLine, final int crop, final Map predefined, - final List completions) + final List completions) { // Precondition: can't expand when empty or ending with any of "[]{},; ". if (lastLine.isEmpty()) return null; @@ -220,8 +225,8 @@ private Scope astCompletions(String codeWithoutLastLine, .length()); final Map names = scope.findStartsWith2(seed); for (final Map.Entry e : names.entrySet()) { - completions.add(Completion.builder((head + e.getKey()).substring(crop)) - .kind(Completion.Kind.VARIABLE).build()); + completions.add(item((head + e.getKey()).substring(crop), + CompletionItemKind.Variable, null)); final String classname = e.getValue(); if (null != classname) { // A class: also offer its constructors. @@ -241,8 +246,8 @@ private Scope astCompletions(String codeWithoutLastLine, } for (final String keyword : KEYWORDS) { if (keyword.startsWith(seed) && !keyword.equals(seed)) { - completions.add(Completion.builder((head + keyword).substring(crop)) - .kind(Completion.Kind.KEYWORD).build()); + completions.add(item((head + keyword).substring(crop), + CompletionItemKind.Keyword, null)); } } return scope; @@ -299,7 +304,7 @@ private Scope astCompletions(String codeWithoutLastLine, final String pre = fullPre.substring(0, fullPre.lastIndexOf(seed)); final String lowerCaseSeed = seed.toLowerCase(); - final List list = da.get().stream().filter(s -> s + final List list = da.get().stream().filter(s -> s .getReplacementText().toLowerCase().contains(lowerCaseSeed)).map( s -> makeDotCompletion(pre, lowerCaseSeed, s)).collect(Collectors .toList()); @@ -312,12 +317,12 @@ private Scope astCompletions(String codeWithoutLastLine, } private void moduleNameCompletions(final String first, final String pkgName, - final List completions) + final List completions) { jython_jar_modules.stream().filter(s -> s.startsWith(pkgName)).forEach(s -> // - completions.add(Completion.builder(first + " " + s + (first.equals("from") - ? " import " : "")).description("Python standard library module") - .kind(Completion.Kind.IMPORT).build())); + completions.add(documented(item(first + " " + s + (first.equals("from") + ? " import " : ""), CompletionItemKind.Module, null), + "Python standard library module"))); final String pkgNameFile = pkgName.replace('.', '/'); for (final String dir : Scope.indexer.getLoadPath()) { try { @@ -327,9 +332,9 @@ private void moduleNameCompletions(final String first, final String pkgName, .map(s -> (s.endsWith("__init__.py") // ? s.substring(dir.length(), s.length() - 12) // : s.substring(dir.length(), s.length() - 3)).replace('/', '.')) // - .forEach(s -> completions.add(Completion.builder(first + " " + s + - (first.equals("from") ? " import " : "")).description( - "Custom python module").kind(Completion.Kind.IMPORT).build())); + .forEach(s -> completions.add(documented(item(first + " " + s + + (first.equals("from") ? " import " : ""), CompletionItemKind.Module, + null), "Custom python module"))); } catch (final Exception e) { JythonDev.print("Failed to read jython module file.", e); @@ -338,13 +343,13 @@ private void moduleNameCompletions(final String first, final String pkgName, } private void moduleMemberCompletions(final String pkgName, - final String member, final List completions) + final String member, final List completions) { final NModuleType mod = Scope.loadPythonModule(pkgName); if (null != mod && !mod.getTable().keySet().isEmpty()) { mod.getTable().keySet().stream().filter(s -> s.startsWith(member)).forEach( - s -> completions.add(Completion.builder("from " + pkgName + " import " + - s).kind(Completion.Kind.IMPORT).build())); + s -> completions.add(item("from " + pkgName + " import " + s, + CompletionItemKind.Module, null))); return; } if (null != mod) { @@ -358,10 +363,9 @@ private void moduleMemberCompletions(final String pkgName, if (filename.startsWith(member) && (new File(fdir.getAbsolutePath() + "/" + filename).isDirectory() || filename.endsWith(".py"))) { - completions.add(Completion.builder("from " + pkgName + " import " + - (filename.endsWith(".py") ? filename.substring(0, filename - .length() - 3) : filename)).kind(Completion.Kind.IMPORT) - .build()); + completions.add(item("from " + pkgName + " import " + (filename + .endsWith(".py") ? filename.substring(0, filename.length() - 3) + : filename), CompletionItemKind.Module, null)); } } } @@ -372,7 +376,7 @@ private void moduleMemberCompletions(final String pkgName, // -- Import / class-name discovery (with auto-import edits) -- private void importCompletions(final String text, final String fullCode, - final List completions) + final List completions) { if (!ClassIndex.isCacheReady()) return; // don't block @@ -380,14 +384,14 @@ private void importCompletions(final String text, final String fullCode, if (m1.find()) { ClassIndex.findClassNamesContaining(m1.group(3)).map( formatter::singleToImportStatement).forEach(s -> completions.add( - Completion.builder(s).kind(Completion.Kind.IMPORT).build())); + item(s, CompletionItemKind.Module, null))); return; } final Matcher m1f = fastImport.matcher(text); if (m1f.find()) { ClassIndex.findClassNamesForPackage(m1f.group(2)).map( formatter::singleToImportStatement).forEach(s -> completions.add( - Completion.builder(s).kind(Completion.Kind.IMPORT).build())); + item(s, CompletionItemKind.Module, null))); return; } final Matcher m2 = importStatement.matcher(text); @@ -407,8 +411,7 @@ private void importCompletions(final String text, final String fullCode, else stream = ClassIndex.findClassNamesForPackage(packageName); final String pre = m2.group(1) + precomma; stream.map(s -> s.substring(Math.max(0, s.lastIndexOf('.') + 1))).forEach( - s -> completions.add(Completion.builder(pre + s).kind( - Completion.Kind.IMPORT).build())); + s -> completions.add(item(pre + s, CompletionItemKind.Module, null))); return; } final Matcher m3 = simpleClassName.matcher(text); @@ -426,10 +429,11 @@ private void importCompletions(final String text, final String fullCode, // Side effect on accept: insert the import near the top of the file. final TextEdit edit = JythonImports.autoImportEdit(fullCode, className, importStmt); - final Completion.Builder b = Completion.builder(pre + simpleName).kind( - Completion.Kind.CLASS).summary(importStmt); - if (edit != null) b.additionalEdits(Collections.singletonList(edit)); - completions.add(b.build()); + final CompletionItem c = item(pre + simpleName, CompletionItemKind.Class, + importStmt); + if (edit != null) c.setAdditionalTextEdits(Collections.singletonList( + edit)); + completions.add(c); } } } @@ -440,40 +444,62 @@ private void importCompletions(final String text, final String fullCode, * Removes duplicate completions (same text and parameters), keeping the first * occurrence, since the AST and class-name analyses can suggest the same thing. */ - private static List dedupe(final List completions) { + private static List dedupe( + final List completions) + { final java.util.Set seen = new java.util.HashSet<>(); - final List result = new ArrayList<>(completions.size()); - for (final Completion c : completions) { - if (seen.add(c.insertionText() + "\u0000" + c.parameters())) result.add(c); + final List result = new ArrayList<>(completions.size()); + for (final CompletionItem c : completions) { + final String params = c.getLabelDetails() == null ? null : c + .getLabelDetails().getDetail(); + if (seen.add(c.getLabel() + "\u0000" + params)) result.add(c); } return result; } - /** Builds a neutral completion from a {@link CompletionText} and prefix. */ - private static Completion makeDotCompletion(final String pre, + /** A completion of the given text. */ + private static CompletionItem item(final String text, + final CompletionItemKind kind, final String detail) + { + final CompletionItem item = new CompletionItem(text); + item.setKind(kind); + item.setInsertText(text); + item.setDetail(detail); + return item; + } + + private static CompletionItem documented(final CompletionItem item, + final String doc) + { + item.setDocumentation(new MarkupContent(MarkupKind.MARKDOWN, doc)); + return item; + } + + /** Builds a completion from a {@link CompletionText} and prefix. */ + private static CompletionItem makeDotCompletion(final String pre, final String seed, final CompletionText ct) { final List ps = ct.getMethodArgs(); if (null != ps && null != ct.getReturnType()) { String text = ct.getReplacementText(); if (text.endsWith("()")) text = text.substring(0, text.length() - 2); - final List params = new ArrayList<>(); + final List params = new ArrayList<>(); for (final Parameter p : ps) { - params.add(new Completion.Parameter(p.getName(), p.getType() + params.add(new Callable.Param(p.getName(), p.getType() .getCanonicalName())); } - return Completion.builder(pre + text).kind(Completion.Kind.METHOD) - .parameters(params).returnType(ct.getReturnType()).summary(ct - .getSummary()).build(); + return new Callable(pre + text, params, ct.getReturnType()).detail(ct + .getSummary()).toItem(); } - return Completion.builder(pre + ct.getReplacementText()).summary(ct - .getSummary()).build(); + return item(pre + ct.getReplacementText(), CompletionItemKind.Field, ct + .getSummary()); } - /** A {@link ParameterChoices} that suggests in-scope variables by type. */ - private static ParameterChoices scopeChoices(final Scope scope) { - return parameter -> { - final String type = parameter.type(); + /** The variables of the given type in scope, innermost first. */ + private static List variablesOfType(final Scope scope, + final String type) + { + { if (type == null) return Collections.emptyList(); Class clazz; switch (type) { @@ -494,28 +520,20 @@ private static ParameterChoices scopeChoices(final Scope scope) { } } final Class c = clazz; - final List vars = scope.findVarsByType(type, c).collect(Collectors - .toList()); - final List out = new ArrayList<>(vars.size()); - for (int i = 0; i < vars.size(); i++) { - // Innermost-scope variables come first; rank them higher. - out.add(Completion.builder(vars.get(i)).kind(Completion.Kind.VARIABLE) - .relevance(vars.size() - i).build()); - } - return out; - }; + return scope.findVarsByType(type, c).collect(Collectors.toList()); + } } - private void sortCompletions(final List completions, + private void sortCompletions(final List completions, final String pre) { - completions.sort(new Comparator() { + completions.sort(new Comparator() { @Override - public int compare(final Completion o1, final Completion o2) { - final int p1 = o1.insertionText().startsWith(pre) ? 0 : Integer.MAX_VALUE; - final int p2 = o2.insertionText().startsWith(pre) ? 0 : Integer.MAX_VALUE; - if (p1 == p2) return o1.insertionText().compareTo(o2.insertionText()); + public int compare(final CompletionItem o1, final CompletionItem o2) { + final int p1 = o1.getLabel().startsWith(pre) ? 0 : Integer.MAX_VALUE; + final int p2 = o2.getLabel().startsWith(pre) ? 0 : Integer.MAX_VALUE; + if (p1 == p2) return o1.getLabel().compareTo(o2.getLabel()); return p1 - p2; } }); diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java deleted file mode 100644 index 6a969df..0000000 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.java +++ /dev/null @@ -1,441 +0,0 @@ -/*- - * #%L - * JSR-223-compliant Jython scripting language plugin. - * %% - * Copyright (C) 2020 - 2025 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.plugins.scripting.jython; - -import java.io.File; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashSet; -import java.util.List; -import java.util.LinkedHashMap; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.TreeSet; -import java.util.concurrent.CancellationException; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import java.util.stream.Collectors; - -import javax.script.Bindings; -import javax.script.ScriptContext; - -import org.python.core.PyJavaType; -import org.python.core.PyModule; -import org.python.core.PyObject; -import org.python.core.PyString; -import org.scijava.Context; -import org.scijava.Priority; -import org.scijava.plugin.Plugin; -import org.scijava.script.ScriptLanguage; -import org.scijava.code.api.AbstractCodeCompleterPlugin; -import org.scijava.code.api.ClassIndex; -import org.scijava.code.api.CodeCompleterPlugin; -import org.scijava.code.api.CodeCompletionService; -import org.scijava.code.api.CompletionRequest; -import org.scijava.code.api.Completion; -import org.scijava.code.api.CompletionResult; -import org.scijava.code.api.EnvironmentContext; -import org.scijava.code.api.ScriptDialect; -import org.scijava.code.api.ScriptDocument; -import org.scijava.code.lsp.LspClient; -import org.scijava.code.lsp.LspServers; -import org.scijava.code.lsp.LspService; -import org.scijava.code.lsp.PythonLspClient; -import org.scijava.code.api.SignatureHelp; -import org.scijava.code.api.Signatures; - -/** - * Toolkit-agnostic code completion for the Jython language, contributed by the - * Jython scripting plugin itself. Depends only on scijava-common and jython, so - * it works in any front end that consumes the SciJava completion SPI (the Swing - * script editor, a REPL, etc.) without any editor- or RSyntaxTextArea-specific - * code. - * - * @author Albert Cardona - * @author Curtis Rueden - * @see JythonAutoCompletions - */ -@Plugin(type = CodeCompleterPlugin.class, name = "Jython", - priority = Priority.HIGH) -public class JythonCodeCompleter extends AbstractCodeCompleterPlugin { - - /** How scripts see their parameters: as the Java objects themselves. */ - private static final JythonDialect DIALECT = new JythonDialect(); - - /** - * How long to wait for the language server before answering without it - * (and updating the answer once its arrives), in milliseconds. - */ - private static final long BUDGET = Long.getLong( - "scijava.jython.completion.budget", 25); - - /** How many completions get signatures, once a word is begun. */ - private static final int SIGNATURES = 30; - - private LspClient lsp; - - private JavaStubs stubs; - - private final JythonAutoCompletions engine = new JythonAutoCompletions(); - - public JythonCodeCompleter() { - // Warm the class index in the background, as completion needs it. - new Thread(ClassIndex::ensureCache, "Jython-ClassIndex-warmup").start(); - } - - @Override - public boolean supports(final ScriptLanguage language) { - if (language == null) return false; - final String name = language.getLanguageName(); - // Matches "Jython" and "Python (Jython)" without hijacking CPython. - return name != null && name.toLowerCase().contains("jython"); - } - - @Override - public CompletionResult complete(final CompletionRequest request) { - final String textToCaret = request.textToCaret(); - final int caret = request.offset(); - final String lastLine = request.lineToCaret(); - final String codeWithoutLastLine = textToCaret.substring(0, textToCaret - .length() - lastLine.length()); - final String alreadyEntered = alreadyEnteredText(lastLine); - final int replaceStart = caret - alreadyEntered.length(); - - final JythonAutoCompletions.Result result = engine.completionsFor( - codeWithoutLastLine, lastLine, alreadyEntered, predefinedVariables( - request)); - final CompletionResult local = new CompletionResult(result.completions, - replaceStart, result.parameterChoices); - if (lastLine.trim().startsWith("#")) return local; - - // Also ask a Python language server, if there is one, knowing the Java - // packages from stubs: its completions follow Jython's own. - final LspClient client = lspClient(); - if (client == null) return local; - // NB: What was typed is replaced as a whole, e.g. "os.pa": the server's - // completions of the word ("path") get the qualifier ("os."). - int w = alreadyEntered.length(); - while (w > 0 && Character.isJavaIdentifierPart(alreadyEntered.charAt(w - - 1))) w--; - final String qualifier = alreadyEntered.substring(0, w); - final String word = alreadyEntered.substring(w); - if (word.isEmpty() && !qualifier.endsWith(".")) return local; - final CompletableFuture> answer; - try { - // NB: Asked about the word's first character, so that later keystrokes - // reuse the answer. - answer = client.complete(document(request), uri(request), caret - word - .length() + Math.min(1, word.length()), word.isEmpty() ? 0 - : SIGNATURES); - } - catch (final RuntimeException exc) { - return local; // NB: E.g. no stubs directory. - } - try { - return merge(local, answer.get(BUDGET, TimeUnit.MILLISECONDS), qualifier, - word); - } - catch (final TimeoutException exc) { - return local.withUpdate(answer.handle((server, error) -> server == null - ? null : merge(local, server, qualifier, word))); - } - catch (final InterruptedException exc) { - Thread.currentThread().interrupt(); - } - catch (final ExecutionException | CancellationException exc) { - // NB: The server failed; the next request asks again. - } - return local; - } - - @Override - public void prepare(final CompletionRequest request) { - // Warm up: write the stubs of the packages the script uses, and start - // the server, in the background. - final LspClient client = lspClient(); - if (client != null) client.prepare(document(request), uri(request)); - } - - @Override - public void closed(final CompletionRequest request) { - final LspClient client; - synchronized (this) { - client = lsp; - } - if (client != null) client.close(uri(request)); - } - - /** Sets the language server client, e.g. to use a fake one in tests. */ - synchronized void setLspClient(final LspClient lsp) { - this.lsp = lsp; - } - - /** Sets the stubs, e.g. to write them elsewhere in tests. */ - synchronized void setStubs(final JavaStubs stubs) { - this.stubs = stubs; - } - - /** - * Gets the language server client, if a Python language server is offered - * (see {@link LspService}); creating it on first use. - */ - private synchronized LspClient lspClient() { - if (lsp != null) return lsp; - final Context context = getContext(); - final LspService service = context == null ? null : context.getService( - LspService.class); - final LspServers servers = service == null ? null : service.servers( - "python"); - if (servers == null) return null; - lsp = new PythonLspClient(servers); - return lsp; - } - - /** Gets the stubs of Java packages, creating them on first use. */ - private synchronized JavaStubs stubs() { - if (stubs != null) return stubs; - final String dir = System.getProperty("scijava.jython.stubs"); - stubs = new JavaStubs(dir != null ? new File(dir) : new File(System - .getProperty("user.home"), ".cache" + File.separator + "scijava" + - File.separator + "jython-stubs"), Thread.currentThread() - .getContextClassLoader()); - return stubs; - } - - /** - * Gets the script as plain Python, for the server: its parameters - * declared; searching the stubs of Java packages (written for those it - * imports, and its parameters' types, in the background). - */ - private ScriptDocument document(final CompletionRequest request) { - final Map> params = scriptParameters(request.text()); - final ScriptDocument doc = ScriptDocument.of(request.text(), params, - DIALECT, null); - final JavaStubs s = stubs(); - final Set packages = new TreeSet<>(JavaStubs.imports(request - .text())); - packages.addAll(JythonDialect.packages(doc.parameters().values().stream() - .filter(t -> t != null).collect(Collectors.toList()))); - s.ensure(packages); - // NB: The server's own Python (CPython): Jython's standard library is - // Python 2's, but most of it is the same. - return ScriptDocument.of(request.text(), params, DIALECT, - new EnvironmentContext(null, Collections.singletonList(s.dir() - .getAbsolutePath()), null)); - } - - private Map> scriptParameters(final String text) { - final Context context = getContext(); - final CodeCompletionService service = context == null ? null : context - .getService(CodeCompletionService.class); - return service == null ? Collections.emptyMap() : service - .scriptParameters(text); - } - - /** The URI of the script, for the language server. */ - private static String uri(final CompletionRequest request) { - return LspClient.uri(request.path(), "jython-script.py"); - } - - /** - * Merges the server's completions (of the word being typed) after Jython's - * own: those Jython does not know already, with the qualifier typed. - */ - private static CompletionResult merge(final CompletionResult local, - final List server, final String qualifier, final String word) - { - if (server == null || server.isEmpty()) return local; - final List out = new ArrayList<>(local.completions()); - final Set seen = new HashSet<>(); - for (final Completion c : out) seen.add(stripParens(c.insertionText())); - final boolean importing = qualifier.trim().startsWith("from ") || qualifier - .trim().startsWith("import "); - for (final Completion c : server) { - final String name = c.insertionText(); - if (!name.toLowerCase(Locale.ROOT).startsWith(word.toLowerCase( - Locale.ROOT)) || name.equals(word)) continue; - if (name.startsWith("_") && !word.startsWith("_")) continue; - if (!seen.add(qualifier + name)) continue; - // NB: An import names things rather than calling them. - final Completion.Kind kind = importing && c.isCallable() - ? Completion.Kind.OTHER : c.kind(); - out.add(Completion.builder(qualifier + name).kind(kind) - .parameters(importing ? Collections.emptyList() : c.parameters()) - .returnType(c.returnType()).summary(c.summary()).lazyDescription( - c::description).build()); - } - return new CompletionResult(out, local.replaceStart(), local - .parameterChoices()); - } - - @Override - public SignatureHelp signatureHelp(final CompletionRequest request) { - final String text = request.text(); - final int caret = request.offset(); - if (request.lineToCaret().trim().startsWith("#")) return SignatureHelp.NONE; - final int open = Signatures.callStart(text, caret); - if (open < 0) return SignatureHelp.NONE; - final List args = Signatures.arguments(text, open, caret); - if (args == null) return SignatureHelp.NONE; - - // The callee, e.g. "Math.max" or "ArrayList". - final String callee = alreadyEnteredText(text.substring(text.lastIndexOf( - '\n', open - 1) + 1, open)).trim(); - if (callee.isEmpty()) return SignatureHelp.NONE; - - // Its signatures: what completion offers for it, as if the caret were - // just before the "(" (methods: "Math.max"), or just after it - // (constructors: offered by their class's simple name, "ArrayList"). - final String simpleName = callee.substring(callee.lastIndexOf('.') + 1); - JythonAutoCompletions.Result result = null; - List callables = Collections.emptyList(); - for (final int at : new int[] { open, open + 1 }) { - final String name = at == open ? callee : simpleName; - result = completionsAt(request, at); - callables = result.completions.stream().filter(c -> c.isCallable() && - stripParens(c.insertionText()).equals(name)).collect(Collectors - .toList()); - if (!callables.isEmpty()) break; - } - if (callables.isEmpty()) return SignatureHelp.NONE; - - // The types of the arguments before the caret's (which is being typed). - final List argTypes = new ArrayList<>(); - for (int i = 0; i < args.size(); i++) { - final String arg = args.get(i).trim(); - argTypes.add(i == args.size() - 1 || arg.isEmpty() ? null : result.types - .apply(arg)); - } - // NB: Reflection lists methods in no particular order: sort them by - // their parameter types, so that equally good fits come out the same. - final List sorted = new ArrayList<>(callables); - sorted.sort(Comparator.comparing(c -> c.parameters().stream().map( - p -> String.valueOf(p.type())).collect(Collectors.joining(",")))); - final ClassLoader loader = Thread.currentThread().getContextClassLoader(); - return new SignatureHelp(Signatures.rateAll(sorted, argTypes, (a, - p) -> Signatures.fit(a, p, loader)), args.size() - 1, open); - } - - /** Computes completions as if the caret were at the given offset. */ - private JythonAutoCompletions.Result completionsAt( - final CompletionRequest request, final int offset) - { - final CompletionRequest there = new CompletionRequest(request.text(), - offset, request.language(), request.engine(), request.context(), request - .path()); - final String textToCaret = there.textToCaret(); - final String lastLine = there.lineToCaret(); - return engine.completionsFor(textToCaret.substring(0, textToCaret - .length() - lastLine.length()), lastLine, alreadyEnteredText(lastLine), - predefinedVariables(request)); - } - - private static String stripParens(final String name) { - return name.endsWith("()") ? name.substring(0, name.length() - 2) : name; - } - - /** - * Gets the variables defined before the script's code runs: script - * parameters declared via {@code #@} lines, and, when completing in a live - * interpreter, the engine's bindings (which reflect actual runtime values, so - * they take precedence). - */ - private Map predefinedVariables( - final CompletionRequest request) - { - final Map vars = new LinkedHashMap<>(); - final Context context = getContext(); - final CodeCompletionService completionService = context == null ? null - : context.getService(CodeCompletionService.class); - if (completionService != null) { - completionService.scriptParameters(request.text()).forEach((name, - type) -> vars.put(name, new VarDotAutocompletions(DIALECT.runtimeType( - type)))); - } - final ScriptContext scriptContext = request.context(); - final Bindings bindings = scriptContext == null ? null : scriptContext - .getBindings(ScriptContext.ENGINE_SCOPE); - if (bindings != null) { - for (final Map.Entry entry : bindings.entrySet()) { - final String name = entry.getKey(); - if (name.startsWith("__") && name.endsWith("__")) continue; - vars.put(name, bindingCompletions(entry.getValue())); - } - } - return vars.isEmpty() ? Collections.emptyMap() : vars; - } - - /** Describes a live binding value for completion purposes. */ - private static DotAutocompletions bindingCompletions(final Object value) { - if (value instanceof PyJavaType) { - // An imported Java class: offer its constructors and static members. - final Class c = ((PyJavaType) value).getProxyType(); - if (c != null) return new StaticDotAutocompletions(c.getName()); - } - if (value instanceof PyModule) { - final PyObject name = ((PyModule) value).__findattr__("__name__"); - if (name instanceof PyString) { - return new StaticDotAutocompletions(name.toString()); - } - } - if (value == null || value instanceof PyObject) { - // A python function, class or instance: known by name only. - return new VarDotAutocompletions(null); - } - return new VarDotAutocompletions(value.getClass().getName()); - } - - @Override - public ScriptDialect dialect() { - return DIALECT; - } - - /** - * The portion of the current line the editor should replace. Mirrors the - * legacy RSTA logic: a maximal suffix of letters, digits, {@code '.'}, - * {@code '_'} and spaces (spaces let "from x import Y" complete as a unit), - * excluding leading spaces. - */ - private static String alreadyEnteredText(final String lastLine) { - int start = lastLine.length(); - while (start > 0) { - final char c = lastLine.charAt(start - 1); - if (Character.isLetterOrDigit(c) || c == '.' || c == '_' || c == ' ') // - start--; - else break; - } - // Leading whitespace is not part of what the user is completing. - while (start < lastLine.length() && lastLine.charAt(start) == ' ') start++; - return lastLine.substring(start); - } -} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonImports.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonImports.java index 0bc5f2e..6e7eda1 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonImports.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonImports.java @@ -32,7 +32,10 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; -import org.scijava.code.api.Completion.TextEdit; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.TextEdit; +import org.scijava.code.lsp.LspClient; /** * Parses the {@code import} statements in a Jython script, and computes the @@ -147,7 +150,9 @@ public static TextEdit autoImportEdit(final String text, } final int targetLine = imported.isEmpty() ? 0 : lastImportLine + 1; final int offset = offsetOfLine(text, targetLine); - return TextEdit.insert(offset, importStatement + "\n"); + final Position at = LspClient.position(text, Math.min(offset, text + .length())); + return new TextEdit(new Range(at, at), importStatement + "\n"); } /** Character offset of the start of the given (0-based) line in {@code text}. */ diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonLanguageServer.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonLanguageServer.java new file mode 100644 index 0000000..93009e8 --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonLanguageServer.java @@ -0,0 +1,288 @@ +/*- + * #%L + * JSR-223-compliant Jython scripting language plugin. + * %% + * Copyright (C) 2020 - 2025 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.plugins.scripting.jython; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.script.Bindings; +import javax.script.ScriptContext; + +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionItemKind; +import org.eclipse.lsp4j.CompletionList; +import org.eclipse.lsp4j.SignatureHelp; +import org.python.core.PyJavaType; +import org.python.core.PyModule; +import org.python.core.PyObject; +import org.python.core.PyString; +import org.scijava.code.api.ClassIndex; +import org.scijava.code.api.CodeCompletionService; +import org.scijava.code.lsp.Callable; +import org.scijava.code.lsp.Environment; +import org.scijava.code.lsp.ScriptLanguageServer; +import org.scijava.code.lsp.Signatures; + +/** + * An in-process language server for Jython, driven by Jython's own analysis + * (see {@link JythonAutoCompletions}): Java classes, their members and + * constructors, Python names, keywords and imports; script parameters and, in + * a live interpreter, its variables. In a call's arguments, with nothing + * typed, the variables of the parameter's type come first. Signature help + * rates the overloads against the arguments typed. + * + * @author Albert Cardona + * @author Curtis Rueden + * @author Gabriel Selzer + */ +public class JythonLanguageServer extends ScriptLanguageServer { + + /** How scripts see their parameters: as the Java objects themselves. */ + static final JythonDialect DIALECT = new JythonDialect(); + + static { + // Warm the class index in the background, as completion needs it. + new Thread(ClassIndex::ensureCache, "Jython-ClassIndex-warmup").start(); + } + + private final CodeCompletionService completion; + private final JythonAutoCompletions engine = new JythonAutoCompletions(); + + /** + * @param completion Reads the scripts' parameters. + * @param environment Where the scripts run (e.g. a live interpreter). + */ + public JythonLanguageServer(final CodeCompletionService completion, + final Environment environment) + { + super(environment); + this.completion = completion; + } + + @Override + protected CompletionList complete(final Document doc, final int caret) { + final String text = doc.text(); + final String lastLine = doc.lineTo(caret); + if (lastLine.trim().startsWith("#")) { + return list(doc, caret, caret, new ArrayList<>()); + } + final String alreadyEntered = alreadyEnteredText(lastLine); + final int replaceStart = caret - alreadyEntered.length(); + final JythonAutoCompletions.Result result = completionsAt(text, caret); + final List items = new ArrayList<>(); + if (alreadyEntered.isEmpty()) items.addAll(argumentChoices(text, caret)); + items.addAll(result.completions); + return list(doc, replaceStart, caret, items); + } + + @Override + protected SignatureHelp signatureHelp(final Document doc, final int caret) { + final String text = doc.text(); + if (doc.lineTo(caret).trim().startsWith("#")) return null; + final Call call = call(text, caret); + if (call == null || call.callables.isEmpty()) return null; + + // The types of the arguments before the caret's (which is being typed). + final List argTypes = new ArrayList<>(); + for (int i = 0; i < call.args.size(); i++) { + final String arg = call.args.get(i).trim(); + argTypes.add(i == call.args.size() - 1 || arg.isEmpty() ? null + : call.result.types.apply(arg)); + } + final ClassLoader loader = Thread.currentThread().getContextClassLoader(); + return new SignatureHelp(Signatures.rateAll(call.callables, argTypes, (a, + p) -> Signatures.fit(a, p, loader)), 0, call.args.size() - 1); + } + + // -- Helper methods -- + + /** The call around the caret: its signatures, and the arguments typed. */ + private static final class Call { + + private final JythonAutoCompletions.Result result; + private final List callables; + private final List args; + + private Call(final JythonAutoCompletions.Result result, + final List callables, final List args) + { + this.result = result; + this.callables = callables; + this.args = args; + } + } + + /** Finds the call around the caret, or returns null if none. */ + private Call call(final String text, final int caret) { + final int open = Signatures.callStart(text, caret); + if (open < 0) return null; + final List args = Signatures.arguments(text, open, caret); + if (args == null) return null; + + // The callee, e.g. "Math.max" or "ArrayList". + final String callee = alreadyEnteredText(text.substring(text.lastIndexOf( + '\n', open - 1) + 1, open)).trim(); + if (callee.isEmpty()) return null; + + // Its signatures: what completion offers for it, as if the caret were + // just before the "(" (methods: "Math.max"), or just after it + // (constructors: offered by their class's simple name, "ArrayList"). + final String simpleName = callee.substring(callee.lastIndexOf('.') + 1); + JythonAutoCompletions.Result result = null; + List callables = Collections.emptyList(); + for (final int at : new int[] { open, open + 1 }) { + final String name = at == open ? callee : simpleName; + result = completionsAt(text, at); + callables = new ArrayList<>(); + for (final CompletionItem item : result.completions) { + final Callable c = Callable.of(item); + if (c != null && c.name().equals(name)) callables.add(c); + } + if (!callables.isEmpty()) break; + } + // NB: Reflection lists methods in no particular order: sort them by + // their parameter types, so that equally good fits come out the same. + callables.sort(Comparator.comparing(Callable::parameterTypes)); + return new Call(result, callables, args); + } + + /** + * In a call's arguments, with nothing typed: the variables of the + * parameter's type (the first preselected). + */ + private List argumentChoices(final String text, + final int caret) + { + final Call call = call(text, caret); + if (call == null || call.result.variablesOfType == null) { + return Collections.emptyList(); + } + final int active = call.args.size() - 1; + final Set names = new LinkedHashSet<>(); + for (final Callable c : call.callables) { + if (active >= c.params().size()) continue; + names.addAll(call.result.variablesOfType.apply(c.params().get(active) + .type())); + } + final List out = new ArrayList<>(); + for (final String name : names) { + final CompletionItem item = new CompletionItem(name); + item.setKind(CompletionItemKind.Variable); + item.setInsertText(name); + item.setPreselect(out.isEmpty()); + out.add(item); + } + return out; + } + + /** Computes completions as if the caret were at the given offset. */ + private JythonAutoCompletions.Result completionsAt(final String text, + final int offset) + { + final String textToCaret = text.substring(0, Math.min(offset, text + .length())); + final String lastLine = textToCaret.substring(textToCaret.lastIndexOf( + '\n') + 1); + return engine.completionsFor(textToCaret.substring(0, textToCaret + .length() - lastLine.length()), lastLine, alreadyEnteredText(lastLine), + predefinedVariables(text)); + } + + /** + * Gets the variables defined before the script's code runs: script + * parameters declared via {@code #@} lines, and, when completing in a live + * interpreter, its bindings (which reflect actual runtime values, so they + * take precedence). + */ + private Map predefinedVariables( + final String text) + { + final Map vars = new LinkedHashMap<>(); + if (completion != null) { + completion.scriptParameters(text).forEach((name, + type) -> vars.put(name, new VarDotAutocompletions(DIALECT.runtimeType( + type)))); + } + final ScriptContext live = environment().live(); + final Bindings bindings = live == null ? null : live.getBindings( + ScriptContext.ENGINE_SCOPE); + if (bindings != null) { + for (final Map.Entry entry : bindings.entrySet()) { + final String name = entry.getKey(); + if (name.startsWith("__") && name.endsWith("__")) continue; + vars.put(name, bindingCompletions(entry.getValue())); + } + } + return vars.isEmpty() ? Collections.emptyMap() : vars; + } + + /** Describes a live binding value for completion purposes. */ + private static DotAutocompletions bindingCompletions(final Object value) { + if (value instanceof PyJavaType) { + // An imported Java class: offer its constructors and static members. + final Class c = ((PyJavaType) value).getProxyType(); + if (c != null) return new StaticDotAutocompletions(c.getName()); + } + if (value instanceof PyModule) { + final PyObject name = ((PyModule) value).__findattr__("__name__"); + if (name instanceof PyString) { + return new StaticDotAutocompletions(name.toString()); + } + } + if (value == null || value instanceof PyObject) { + // A python function, class or instance: known by name only. + return new VarDotAutocompletions(null); + } + return new VarDotAutocompletions(value.getClass().getName()); + } + + /** + * The portion of the current line to replace: a maximal suffix of letters, + * digits, {@code '.'}, {@code '_'} and spaces (spaces let "from x import Y" + * complete as a unit), excluding leading spaces. + */ + static String alreadyEnteredText(final String lastLine) { + int start = lastLine.length(); + while (start > 0) { + final char c = lastLine.charAt(start - 1); + if (Character.isLetterOrDigit(c) || c == '.' || c == '_' || c == ' ') // + start--; + else break; + } + // Leading whitespace is not part of what the user is completing. + while (start < lastLine.length() && lastLine.charAt(start) == ' ') start++; + return lastLine.substring(start); + } +} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonPythonServerPlugin.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonPythonServerPlugin.java new file mode 100644 index 0000000..84270c8 --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonPythonServerPlugin.java @@ -0,0 +1,183 @@ +/*- + * #%L + * JSR-223-compliant Jython scripting language plugin. + * %% + * Copyright (C) 2020 - 2025 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.plugins.scripting.jython; + +import java.io.File; +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +import org.eclipse.lsp4j.MessageActionItem; +import org.eclipse.lsp4j.MessageParams; +import org.eclipse.lsp4j.PublishDiagnosticsParams; +import org.eclipse.lsp4j.ShowMessageRequestParams; +import org.eclipse.lsp4j.TextDocumentItem; +import org.eclipse.lsp4j.services.LanguageClient; +import org.eclipse.lsp4j.services.LanguageServer; +import org.scijava.Priority; +import org.scijava.code.api.CodeCompletionService; +import org.scijava.code.api.ScriptDocument; +import org.scijava.code.lsp.Environment; +import org.scijava.code.lsp.LanguageServerPlugin; +import org.scijava.code.lsp.LanguageServerService; +import org.scijava.code.lsp.TransformingLanguageServer; +import org.scijava.plugin.Parameter; +import org.scijava.plugin.Plugin; +import org.scijava.script.ScriptLanguage; + +/** + * Offers a Python language server for Jython scripts, if one is offered for + * Python (e.g. jedi-language-server, by the {@code appose-python} scripting + * plugin): for Python modules, and for Java classes, from Python stubs of the + * Java packages the script imports and its parameters' types (see + * {@link JavaStubs}, written in the background). Its completions follow + * Jython's own. + *

+ * The server analyzes the script as CPython (Jython's standard library is + * Python 2's, but mostly the same): so it is asked for completions only, not + * for problems. + *

+ * + * @author Gabriel Selzer + */ +@Plugin(type = LanguageServerPlugin.class, name = "Jython via Python", + priority = Priority.LOW) +public class JythonPythonServerPlugin implements LanguageServerPlugin { + + @Parameter + private LanguageServerService servers; + + @Parameter(required = false) + private CodeCompletionService completion; + + private JavaStubs stubs; + + @Override + public boolean supports(final ScriptLanguage language) { + return JythonServerPlugin.isJython(language) && servers.launch("python", + Environment.NONE) != null; + } + + @Override + public Environment environment(final TextDocumentItem document) { + // NB: Writes the stubs the script needs, in the background. + final JavaStubs s = stubs(); + final Set packages = new TreeSet<>(JavaStubs.imports(document + .getText())); + final ScriptDocument doc = ScriptDocument.of(document.getText(), + parameters(document.getText()), JythonLanguageServer.DIALECT, null); + packages.addAll(JythonDialect.packages(doc.parameters().values().stream() + .filter(t -> t != null).collect(Collectors.toList()))); + s.ensure(packages); + // NB: The server's own Python (CPython). + return new Environment(null, Collections.singletonList(s.dir() + .getAbsolutePath()), null, null); + } + + @Override + public LanguageServer create(final Environment environment) { + final LanguageServer python = servers.launch("python", environment); + return new TransformingLanguageServer(python, text -> ScriptDocument.of( + text, parameters(text), JythonLanguageServer.DIALECT, environment + .toContext())) + { + + @Override + public void connect(final LanguageClient client) { + super.connect(new NoProblems(client)); + } + }; + } + + /** Sets the stubs, e.g. to write them elsewhere in tests. */ + synchronized void setStubs(final JavaStubs stubs) { + this.stubs = stubs; + } + + /** Gets the stubs of Java packages, creating them on first use. */ + synchronized JavaStubs stubs() { + if (stubs != null) return stubs; + final String dir = System.getProperty("scijava.jython.stubs"); + stubs = new JavaStubs(dir != null ? new File(dir) : new File(System + .getProperty("user.home"), ".cache" + File.separator + "scijava" + + File.separator + "jython-stubs"), Thread.currentThread() + .getContextClassLoader()); + return stubs; + } + + private Map> parameters(final String text) { + return completion == null ? Collections.emptyMap() : completion + .scriptParameters(text); + } + + /** + * Passes a client the server's messages, but no problems: the server reads + * Jython's Python 2 as Python 3. (An empty list still says the server is + * done looking.) + */ + private static final class NoProblems implements LanguageClient { + + private final LanguageClient client; + + private NoProblems(final LanguageClient client) { + this.client = client; + } + + @Override + public void publishDiagnostics(final PublishDiagnosticsParams p) { + client.publishDiagnostics(new PublishDiagnosticsParams(p.getUri(), + Collections.emptyList(), p.getVersion())); + } + + @Override + public void telemetryEvent(final Object object) { + client.telemetryEvent(object); + } + + @Override + public void showMessage(final MessageParams m) { + client.showMessage(m); + } + + @Override + public CompletableFuture showMessageRequest( + final ShowMessageRequestParams r) + { + return client.showMessageRequest(r); + } + + @Override + public void logMessage(final MessageParams m) { + client.logMessage(m); + } + } +} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonServerPlugin.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonServerPlugin.java new file mode 100644 index 0000000..de58366 --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonServerPlugin.java @@ -0,0 +1,69 @@ +/*- + * #%L + * JSR-223-compliant Jython scripting language plugin. + * %% + * Copyright (C) 2020 - 2025 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.plugins.scripting.jython; + +import org.eclipse.lsp4j.services.LanguageServer; +import org.scijava.Priority; +import org.scijava.code.api.CodeCompletionService; +import org.scijava.code.lsp.Environment; +import org.scijava.code.lsp.LanguageServerPlugin; +import org.scijava.plugin.Parameter; +import org.scijava.plugin.Plugin; +import org.scijava.script.ScriptLanguage; + +/** + * Offers Jython's in-process language server (see + * {@link JythonLanguageServer}). + * + * @author Gabriel Selzer + */ +@Plugin(type = LanguageServerPlugin.class, name = "Jython", + priority = Priority.HIGH) +public class JythonServerPlugin implements LanguageServerPlugin { + + @Parameter(required = false) + private CodeCompletionService completion; + + @Override + public boolean supports(final ScriptLanguage language) { + return isJython(language); + } + + @Override + public LanguageServer create(final Environment environment) { + return new JythonLanguageServer(completion, environment); + } + + /** Matches "Jython" and "Python (Jython)", without claiming CPython. */ + static boolean isJython(final ScriptLanguage language) { + if (language == null) return false; + final String name = language.getLanguageName(); + return name != null && name.toLowerCase().contains("jython"); + } +} diff --git a/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java index 6dcfc48..cb5f3a2 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java @@ -31,56 +31,61 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import java.util.List; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import javax.script.ScriptEngine; +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionParams; +import org.eclipse.lsp4j.DidCloseTextDocumentParams; +import org.eclipse.lsp4j.DidOpenTextDocumentParams; +import org.eclipse.lsp4j.SignatureHelp; +import org.eclipse.lsp4j.SignatureHelpParams; +import org.eclipse.lsp4j.TextDocumentIdentifier; +import org.eclipse.lsp4j.TextDocumentItem; import org.junit.Test; -import org.scijava.code.api.ClassIndex; import org.scijava.Context; +import org.scijava.code.api.ClassIndex; +import org.scijava.code.api.CodeCompletionService; +import org.scijava.code.lsp.LanguageServerService; +import org.scijava.code.lsp.LspClient; +import org.scijava.code.lsp.MergedLanguageServer; +import org.scijava.code.lsp.RatedSignatureInformation; import org.scijava.script.ScriptLanguage; import org.scijava.script.ScriptService; -import org.scijava.code.api.CodeCompletionService; -import org.scijava.code.api.Completion; -import org.scijava.code.api.CompletionRequest; -import org.scijava.code.api.CompletionResult; -import org.scijava.code.api.SignatureHelp; /** - * Tests that the Jython {@link JythonCodeCompleter} is discovered for the Jython - * language and produces member completions via the SciJava completion SPI, with - * no dependency on the Swing script editor. + * Tests {@link JythonLanguageServer}, through the + * {@link LanguageServerService}, as editors use it. * * @author Curtis Rueden */ public class JythonCompletionTest { + private static final String URI = "untitled:/test.py"; + @Test public void testMemberCompletionViaService() { final Context ctx = new Context(ScriptService.class, - CodeCompletionService.class); + CodeCompletionService.class, LanguageServerService.class); try { - final ScriptService scriptService = ctx.service(ScriptService.class); - final CodeCompletionService completion = ctx.service( - CodeCompletionService.class); - - final ScriptLanguage jython = scriptService.getLanguageByName("Jython"); + final ScriptLanguage jython = ctx.service(ScriptService.class) + .getLanguageByName("Jython"); assertNotNull("Jython language not found", jython); - // The Jython completer must be discovered and claim the Jython language. - assertNotNull("JythonCodeCompleter not registered for Jython", // - completion.getCompleterPlugin(jython)); + // A Jython server must be offered for the Jython language. + final LanguageServerService servers = ctx.service( + LanguageServerService.class); + assertTrue(servers.supports(jython)); // Static analysis: 's' is a String, so 's.' completes to String members. - final String code = "s = \"hello\"\ns."; - final CompletionResult result = completion.complete(// - new CompletionRequest(code, jython, null)); - final List texts = result.completions().stream().map( - Completion::insertionText).collect(Collectors.toList()); - + final List texts = labels(items(servers.server(jython), + "s = \"hello\"\ns.")); assertTrue("expected s.length among " + texts, texts.contains("s.length")); assertTrue("expected s.charAt among " + texts, texts.contains("s.charAt")); } @@ -117,13 +122,15 @@ public void testKeywordsAndBuiltins() { @Test public void testImportedClassName() { ClassIndex.ensureCache(); - final List texts = complete( + final List items = completions( "from java.util import ArrayList\nArrayList"); + final List texts = labels(items); assertTrue("expected ArrayList among " + texts, texts.contains( "ArrayList")); - assertEquals("expected one plain ArrayList among " + texts, 1, texts - .stream().filter("ArrayList"::equals).count() - constructorCount( - "from java.util import ArrayList\nArrayList")); + // Once as a name; otherwise as its constructors. + assertEquals("expected one plain ArrayList among " + texts, 1, items + .stream().filter(c -> c.getLabel().equals("ArrayList") && c + .getLabelDetails() == null).count()); } @Test @@ -170,6 +177,27 @@ public void testInheritedMembers() { assertTrue(texts.contains("list.toString")); } + @Test + public void testCallablesAreSnippets() { + final CompletionItem charAt = completions("s = 'x'\ns.charA").stream() + .filter(c -> c.getLabel().equals("s.charAt")).findFirst().orElse(null); + assertNotNull(charAt); + assertEquals("(int arg0)", charAt.getLabelDetails().getDetail().replaceAll( + " \\w+\\)$", " arg0)")); + assertTrue(charAt.getTextEdit().getLeft().getNewText().startsWith( + "s.charAt(${1:")); + } + + @Test + public void testArgumentChoices() { + // In a call's arguments: the variables of the parameter's type first. + final List items = completions( + "from java.lang import Math\nx = 1.5\ny = 'a'\nMath.abs("); + assertTrue(items.toString(), !items.isEmpty()); + assertEquals("x", items.get(0).getLabel()); + assertEquals(Boolean.TRUE, items.get(0).getPreselect()); + } + @Test public void testInterpreterBindings() throws Exception { final Context ctx = new Context(); @@ -181,21 +209,19 @@ public void testInterpreterBindings() throws Exception { engine.eval("from java.util import Collections, HashMap\n" + "m = HashMap()\n" + "def helper(): pass\n"); - final CodeCompletionService service = ctx.service( - CodeCompletionService.class); + final MergedLanguageServer server = ctx.service( + LanguageServerService.class).server(jython, engine.getContext()); - assertTrue(complete(service, jython, engine, "m.putIfAb").contains( + assertTrue(labels(items(server, "m.putIfAb")).contains( "m.putIfAbsent")); - assertTrue(complete(service, jython, engine, "injected.si").contains( + assertTrue(labels(items(server, "injected.si")).contains( "injected.size")); - assertTrue(complete(service, jython, engine, "x = hel").contains( - "helper")); + assertTrue(labels(items(server, "x = hel")).contains("helper")); // An imported Java class offers its static members. - assertTrue(complete(service, jython, engine, "Collections.emptyL") - .contains("Collections.emptyList")); + assertTrue(labels(items(server, "Collections.emptyL")).contains( + "Collections.emptyList")); // Python internals are not offered. - assertFalse(complete(service, jython, engine, "x = __").contains( - "__name__")); + assertFalse(labels(items(server, "x = __")).contains("__name__")); } finally { ctx.dispose(); @@ -207,7 +233,7 @@ public void testSignatureHelp() { // A Python float fits the floating-point overloads, which come first. final SignatureHelp max = help("from java.lang import Math\n" + "Math.max(1.5, "); - assertEquals(1, max.activeParameter()); + assertEquals(Integer.valueOf(1), max.getActiveParameter()); assertEquals("[double:MATCH, float:MATCH, int:MISMATCH, long:MISMATCH]", describe(max)); // A Python int fits the integral ones, and converts to the others. @@ -223,17 +249,27 @@ public void testSignatureHelp() { assertTrue(describe(help("from java.util import ArrayList\n" + "ArrayList(")).contains("java.util.Collection")); // None outside calls. - assertTrue(help("x = 1").isEmpty()); + assertNull(help("x = 1")); } + // -- Helper methods -- + private static SignatureHelp help(final String code) { final Context ctx = new Context(ScriptService.class, - CodeCompletionService.class); + CodeCompletionService.class, LanguageServerService.class); try { final ScriptLanguage jython = ctx.service(ScriptService.class) .getLanguageByName("Jython"); - return ctx.service(CodeCompletionService.class).getCompleter(jython) - .signatureHelp(new CompletionRequest(code, jython, null)); + final MergedLanguageServer server = ctx.service( + LanguageServerService.class).server(jython); + server.didOpen(new DidOpenTextDocumentParams(new TextDocumentItem(URI, + "python", 1, code))); + return server.signatureHelp(new SignatureHelpParams( + new TextDocumentIdentifier(URI), LspClient.position(code, code + .length()))).get(30, TimeUnit.SECONDS); + } + catch (final Exception exc) { + throw new RuntimeException(exc); } finally { ctx.dispose(); @@ -242,43 +278,57 @@ private static SignatureHelp help(final String code) { /** Each signature's first parameter type and fit, in order. */ private static String describe(final SignatureHelp help) { - return help.signatures().stream().map(s -> (s.callable().parameters() - .isEmpty() ? "()" : s.callable().parameters().get(0).type()) + ":" + s - .fit()).collect(Collectors.toList()).toString(); - } - - // -- Helper methods -- - - private static List complete(final CodeCompletionService service, - final ScriptLanguage language, final ScriptEngine engine, - final String code) - { - return service.complete(new CompletionRequest(code, language, engine)) - .completions().stream().map(Completion::insertionText).collect(Collectors - .toList()); + return help.getSignatures().stream().map(s -> { + final String first = s.getParameters().isEmpty() ? "()" : s + .getParameters().get(0).getLabel().getLeft(); + final String type = first.contains(" ") ? first.substring(0, first + .lastIndexOf(' ')) : first; + return type + ":" + RatedSignatureInformation.fitOf(s); + }).collect(Collectors.toList()).toString(); } private static List complete(final String code) { - return completions(code).stream().map(Completion::insertionText).collect( - Collectors.toList()); + return labels(completions(code)); } - private static long constructorCount(final String code) { - return completions(code).stream().filter(c -> c.kind() == - Completion.Kind.METHOD).count(); + private static List labels(final List items) { + return items.stream().map(CompletionItem::getLabel).collect(Collectors + .toList()); } - private static List completions(final String code) { + private static List completions(final String code) { final Context ctx = new Context(ScriptService.class, - CodeCompletionService.class); + CodeCompletionService.class, LanguageServerService.class); try { final ScriptLanguage jython = ctx.service(ScriptService.class) .getLanguageByName("Jython"); - return ctx.service(CodeCompletionService.class).complete( - new CompletionRequest(code, jython, null)).completions(); + return items(ctx.service(LanguageServerService.class).server(jython), + code); } finally { ctx.dispose(); } } + + private static List items(final MergedLanguageServer server, + final String code) + { + server.didOpen(new DidOpenTextDocumentParams(new TextDocumentItem(URI, + "python", 1, code))); + try { + final List items = new java.util.ArrayList<>(server + .completion(new CompletionParams(new TextDocumentIdentifier(URI), + LspClient.position(code, code.length()))).get(30, TimeUnit.SECONDS) + .getRight().getItems()); + items.sort((a, b) -> a.getSortText().compareTo(b.getSortText())); + return items; + } + catch (final Exception exc) { + throw new RuntimeException(exc); + } + finally { + server.didClose(new DidCloseTextDocumentParams( + new TextDocumentIdentifier(URI))); + } + } } diff --git a/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java index ac58b90..2fae722 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java @@ -34,29 +34,25 @@ import java.io.File; import java.nio.file.Files; -import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.function.Function; -import java.util.stream.Collectors; +import org.eclipse.lsp4j.TextDocumentItem; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.scijava.Context; import org.scijava.code.api.CodeCompletionService; -import org.scijava.code.api.Completion; -import org.scijava.code.api.CompletionRequest; -import org.scijava.code.api.ScriptDocument; -import org.scijava.code.lsp.LspClient; +import org.scijava.code.lsp.Environment; +import org.scijava.code.lsp.LanguageServerPlugin; +import org.scijava.code.lsp.LanguageServerService; +import org.scijava.plugin.PluginService; import org.scijava.script.ScriptLanguage; import org.scijava.script.ScriptService; /** - * Tests {@link JythonCodeCompleter}'s use of a Python language server, with a - * fake one. + * Tests {@link JythonPythonServerPlugin}: a Python language server's + * environment for Jython scripts (Java stubs); and {@link JythonDialect}. * * @author Gabriel Selzer */ @@ -64,19 +60,20 @@ public class JythonServerCompletionTest { private Context context; private ScriptLanguage jython; - private CodeCompletionService service; - private JythonCodeCompleter completer; + private JythonPythonServerPlugin plugin; private File stubsDir; - private final List asked = new ArrayList<>(); @Before public void setUp() throws Exception { - context = new Context(ScriptService.class, CodeCompletionService.class); + context = new Context(ScriptService.class, CodeCompletionService.class, + LanguageServerService.class); jython = context.service(ScriptService.class).getLanguageByName("Jython"); - service = context.service(CodeCompletionService.class); - completer = (JythonCodeCompleter) service.getCompleterPlugin(jython); + plugin = context.service(PluginService.class).createInstancesOfType( + LanguageServerPlugin.class).stream().filter( + p -> p instanceof JythonPythonServerPlugin).map( + p -> (JythonPythonServerPlugin) p).findFirst().get(); stubsDir = Files.createTempDirectory("jython-stubs").toFile(); - completer.setStubs(new JavaStubs(stubsDir, getClass().getClassLoader(), + plugin.setStubs(new JavaStubs(stubsDir, getClass().getClassLoader(), pkg -> Collections.emptyList())); } @@ -87,53 +84,20 @@ public void tearDown() { } @Test - public void testServerCompletionsMerged() { - final Completion dumps = Completion.builder("dumps").kind( - Completion.Kind.METHOD).parameters(Collections.singletonList( - new Completion.Parameter("obj", null))).build(); - fake(doc -> Arrays.asList(dumps, Completion.of("dump"), Completion.of( - "loads"))); - final List completions = completions("import json\njson.du"); - final List texts = completions.stream().map( - Completion::insertionText).collect(Collectors.toList()); - // With what was typed before the word; filtered by the word. - assertTrue(texts.toString(), texts.contains("json.dumps")); - assertTrue(texts.contains("json.dump")); - assertFalse(texts.contains("json.loads")); - final Completion merged = completions.stream().filter(c -> c - .insertionText().equals("json.dumps")).findFirst().get(); - assertEquals(1, merged.parameters().size()); - - // The server sees the script as Python, with the stubs to search. - final ScriptDocument doc = asked.get(asked.size() - 1); - assertEquals(Collections.singletonList(stubsDir.getAbsolutePath()), doc - .environment().searchPaths()); + public void testOnlyWithAPythonServer() { + // NB: No plugin offers a Python server here (scripting-appose-python + // does): Jython is served by its own server alone. + assertFalse(plugin.supports(jython)); } @Test - public void testJythonFirst() { - // Jython knows s is a String: its own completion comes once. - fake(doc -> Collections.singletonList(Completion.of("length"))); - final String code = "#@ String s\ns.len"; - final List texts = completions(code).stream().map( - Completion::insertionText).collect(Collectors.toList()); - assertEquals(texts.toString(), 1, texts.stream().filter(t -> t.equals( - "s.length")).count()); - // Parameters declared for the server, as Python types. - assertTrue(asked.get(0).text().startsWith("s: str\n")); - } - - @Test - public void testImportsAreNames() { - final Completion arrayList = Completion.builder("ArrayList").kind( - Completion.Kind.METHOD).parameters(Collections.singletonList( - new Completion.Parameter("capacity", "int"))).build(); - fake(doc -> Collections.singletonList(arrayList)); - final Completion imported = completions("from java.util import ArrayLi") - .stream().filter(c -> c.insertionText().endsWith("ArrayList")).findFirst() - .get(); - assertFalse(imported.parameters().size() > 0 && imported.kind() == - Completion.Kind.METHOD); + public void testEnvironment() { + // The server's own Python, searching the Java stubs. + final Environment env = plugin.environment(new TextDocumentItem( + "untitled:/a.py", "python", 1, "from java.util import ArrayList\n")); + assertEquals(null, env.interpreter()); + assertEquals(Collections.singletonList(stubsDir.getAbsolutePath()), env + .searchPaths()); } @Test @@ -148,26 +112,6 @@ public void testDeclarations() { assertEquals(Arrays.asList("import java.util", "import net.imglib2.img"), dialect.imports(Arrays.asList("net.imglib2.img.Img", "java.lang.String", "java.util.Map$Entry"))); - } - - // -- Helper methods -- - - private void fake(final Function> answers) { - completer.setLspClient(new LspClient(null) { - - @Override - public CompletableFuture> complete( - final ScriptDocument doc, final String uri, final int offset, - final int resolve) - { - asked.add(doc); - return CompletableFuture.completedFuture(answers.apply(doc)); - } - }); - } - - private List completions(final String code) { - return service.complete(new CompletionRequest(code, jython, null)) - .completions(); + assertTrue(JythonServerPlugin.isJython(jython)); } } From 0dd6198c48d0a2abdf3e61dfb79af4278a31d6f2 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Wed, 7 Oct 2026 13:22:10 -0500 Subject: [PATCH 10/29] Move off scijava-code-api, now removed Its remaining types (ScriptDocument, ScriptDialect, ClassIndex, Signatures, Environment) come from scijava-code-lsp, and script parameters from the LanguageServerService. Co-Authored-By: Claude Opus 5.5 --- pom.xml | 9 ++------- .../plugins/scripting/jython/CompletionText.java | 7 +++---- .../plugins/scripting/jython/JavaStubs.java | 2 +- .../scripting/jython/JythonAutoCompletions.java | 2 +- .../plugins/scripting/jython/JythonDialect.java | 2 +- .../plugins/scripting/jython/JythonImports.java | 4 ++-- .../scripting/jython/JythonLanguageServer.java | 16 ++++++++-------- .../jython/JythonPythonServerPlugin.java | 11 ++++------- .../scripting/jython/JythonServerPlugin.java | 6 +++--- .../scripting/jython/JythonCompletionTest.java | 15 +++++++-------- .../jython/JythonServerCompletionTest.java | 6 ++---- 11 files changed, 34 insertions(+), 46 deletions(-) diff --git a/pom.xml b/pom.xml index 13e1df0..d15ced0 100644 --- a/pom.xml +++ b/pom.xml @@ -85,7 +85,7 @@ org.scijava.plugins.scripting.jython 2.101.0 - 0.1.0-SNAPSHOT + 0.1.0-SNAPSHOT 2.14.0 @@ -103,15 +103,10 @@ org.scijava scijava-common
- - org.scijava - scijava-code-api - ${scijava-code-api.version} - org.scijava scijava-code-lsp - ${scijava-code-api.version} + ${scijava-code-lsp.version} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/CompletionText.java b/src/main/java/org/scijava/plugins/scripting/jython/CompletionText.java index 7eb8e4e..d17d20c 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/CompletionText.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/CompletionText.java @@ -36,14 +36,13 @@ import java.util.Collections; import java.util.List; -import org.scijava.code.api.ClassIndex; +import org.scijava.code.lsp.ClassIndex; /** * A mutable suggestion used internally by the Jython completion engine. It holds * the replacement text plus optional documentation and (for callables) parameter - * and return-type metadata. The {@link JythonCodeCompleter} converts these into - * toolkit-agnostic {@link org.scijava.code.api.Completion}s at the - * boundary. + * and return-type metadata. {@link JythonAutoCompletions} converts these into + * LSP completion items at the boundary. * * @author Albert Cardona */ diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JavaStubs.java b/src/main/java/org/scijava/plugins/scripting/jython/JavaStubs.java index 6a1bd8a..8eb314a 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JavaStubs.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JavaStubs.java @@ -59,7 +59,7 @@ import java.util.function.Function; import java.util.stream.Collectors; -import org.scijava.code.api.ClassIndex; +import org.scijava.code.lsp.ClassIndex; /** * Writes Python type stubs ({@code .pyi}) of Java packages, so that a Python diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java index 930bbe1..9101c0e 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java @@ -46,7 +46,7 @@ import java.util.stream.Collectors; import org.python.indexer.types.NModuleType; -import org.scijava.code.api.ClassIndex; +import org.scijava.code.lsp.ClassIndex; import org.eclipse.lsp4j.CompletionItem; import org.eclipse.lsp4j.CompletionItemKind; import org.eclipse.lsp4j.MarkupContent; diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java index 91d0cf0..210882d 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java @@ -34,7 +34,7 @@ import java.util.Set; import java.util.TreeSet; -import org.scijava.code.api.ScriptDialect; +import org.scijava.code.lsp.ScriptDialect; /** * How Jython scripts see their script parameters: as the Java objects diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonImports.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonImports.java index 6e7eda1..f4599d9 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonImports.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonImports.java @@ -35,7 +35,7 @@ import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; import org.eclipse.lsp4j.TextEdit; -import org.scijava.code.lsp.LspClient; +import org.scijava.code.lsp.Positions; /** * Parses the {@code import} statements in a Jython script, and computes the @@ -150,7 +150,7 @@ public static TextEdit autoImportEdit(final String text, } final int targetLine = imported.isEmpty() ? 0 : lastImportLine + 1; final int offset = offsetOfLine(text, targetLine); - final Position at = LspClient.position(text, Math.min(offset, text + final Position at = Positions.position(text, Math.min(offset, text .length())); return new TextEdit(new Range(at, at), importStatement + "\n"); } diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonLanguageServer.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonLanguageServer.java index 93009e8..7c927ae 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonLanguageServer.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonLanguageServer.java @@ -48,8 +48,8 @@ import org.python.core.PyModule; import org.python.core.PyObject; import org.python.core.PyString; -import org.scijava.code.api.ClassIndex; -import org.scijava.code.api.CodeCompletionService; +import org.scijava.code.lsp.ClassIndex; +import org.scijava.code.lsp.LanguageServerService; import org.scijava.code.lsp.Callable; import org.scijava.code.lsp.Environment; import org.scijava.code.lsp.ScriptLanguageServer; @@ -77,18 +77,18 @@ public class JythonLanguageServer extends ScriptLanguageServer { new Thread(ClassIndex::ensureCache, "Jython-ClassIndex-warmup").start(); } - private final CodeCompletionService completion; + private final LanguageServerService servers; private final JythonAutoCompletions engine = new JythonAutoCompletions(); /** - * @param completion Reads the scripts' parameters. + * @param servers Reads the scripts' parameters. * @param environment Where the scripts run (e.g. a live interpreter). */ - public JythonLanguageServer(final CodeCompletionService completion, + public JythonLanguageServer(final LanguageServerService servers, final Environment environment) { super(environment); - this.completion = completion; + this.servers = servers; } @Override @@ -230,8 +230,8 @@ private Map predefinedVariables( final String text) { final Map vars = new LinkedHashMap<>(); - if (completion != null) { - completion.scriptParameters(text).forEach((name, + if (servers != null) { + servers.scriptParameters(text).forEach((name, type) -> vars.put(name, new VarDotAutocompletions(DIALECT.runtimeType( type)))); } diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonPythonServerPlugin.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonPythonServerPlugin.java index 84270c8..609b586 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonPythonServerPlugin.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonPythonServerPlugin.java @@ -44,11 +44,10 @@ import org.eclipse.lsp4j.services.LanguageClient; import org.eclipse.lsp4j.services.LanguageServer; import org.scijava.Priority; -import org.scijava.code.api.CodeCompletionService; -import org.scijava.code.api.ScriptDocument; +import org.scijava.code.lsp.LanguageServerService; +import org.scijava.code.lsp.ScriptDocument; import org.scijava.code.lsp.Environment; import org.scijava.code.lsp.LanguageServerPlugin; -import org.scijava.code.lsp.LanguageServerService; import org.scijava.code.lsp.TransformingLanguageServer; import org.scijava.plugin.Parameter; import org.scijava.plugin.Plugin; @@ -76,8 +75,6 @@ public class JythonPythonServerPlugin implements LanguageServerPlugin { @Parameter private LanguageServerService servers; - @Parameter(required = false) - private CodeCompletionService completion; private JavaStubs stubs; @@ -108,7 +105,7 @@ public LanguageServer create(final Environment environment) { final LanguageServer python = servers.launch("python", environment); return new TransformingLanguageServer(python, text -> ScriptDocument.of( text, parameters(text), JythonLanguageServer.DIALECT, environment - .toContext())) + )) { @Override @@ -135,7 +132,7 @@ synchronized JavaStubs stubs() { } private Map> parameters(final String text) { - return completion == null ? Collections.emptyMap() : completion + return servers == null ? Collections.emptyMap() : servers .scriptParameters(text); } diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonServerPlugin.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonServerPlugin.java index de58366..4f1475e 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonServerPlugin.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonServerPlugin.java @@ -30,7 +30,7 @@ import org.eclipse.lsp4j.services.LanguageServer; import org.scijava.Priority; -import org.scijava.code.api.CodeCompletionService; +import org.scijava.code.lsp.LanguageServerService; import org.scijava.code.lsp.Environment; import org.scijava.code.lsp.LanguageServerPlugin; import org.scijava.plugin.Parameter; @@ -48,7 +48,7 @@ public class JythonServerPlugin implements LanguageServerPlugin { @Parameter(required = false) - private CodeCompletionService completion; + private LanguageServerService servers; @Override public boolean supports(final ScriptLanguage language) { @@ -57,7 +57,7 @@ public boolean supports(final ScriptLanguage language) { @Override public LanguageServer create(final Environment environment) { - return new JythonLanguageServer(completion, environment); + return new JythonLanguageServer(servers, environment); } /** Matches "Jython" and "Python (Jython)", without claiming CPython. */ diff --git a/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java index cb5f3a2..95f170c 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java @@ -50,10 +50,9 @@ import org.eclipse.lsp4j.TextDocumentItem; import org.junit.Test; import org.scijava.Context; -import org.scijava.code.api.ClassIndex; -import org.scijava.code.api.CodeCompletionService; +import org.scijava.code.lsp.ClassIndex; import org.scijava.code.lsp.LanguageServerService; -import org.scijava.code.lsp.LspClient; +import org.scijava.code.lsp.Positions; import org.scijava.code.lsp.MergedLanguageServer; import org.scijava.code.lsp.RatedSignatureInformation; import org.scijava.script.ScriptLanguage; @@ -72,7 +71,7 @@ public class JythonCompletionTest { @Test public void testMemberCompletionViaService() { final Context ctx = new Context(ScriptService.class, - CodeCompletionService.class, LanguageServerService.class); + LanguageServerService.class); try { final ScriptLanguage jython = ctx.service(ScriptService.class) .getLanguageByName("Jython"); @@ -256,7 +255,7 @@ public void testSignatureHelp() { private static SignatureHelp help(final String code) { final Context ctx = new Context(ScriptService.class, - CodeCompletionService.class, LanguageServerService.class); + LanguageServerService.class); try { final ScriptLanguage jython = ctx.service(ScriptService.class) .getLanguageByName("Jython"); @@ -265,7 +264,7 @@ private static SignatureHelp help(final String code) { server.didOpen(new DidOpenTextDocumentParams(new TextDocumentItem(URI, "python", 1, code))); return server.signatureHelp(new SignatureHelpParams( - new TextDocumentIdentifier(URI), LspClient.position(code, code + new TextDocumentIdentifier(URI), Positions.position(code, code .length()))).get(30, TimeUnit.SECONDS); } catch (final Exception exc) { @@ -298,7 +297,7 @@ private static List labels(final List items) { private static List completions(final String code) { final Context ctx = new Context(ScriptService.class, - CodeCompletionService.class, LanguageServerService.class); + LanguageServerService.class); try { final ScriptLanguage jython = ctx.service(ScriptService.class) .getLanguageByName("Jython"); @@ -318,7 +317,7 @@ private static List items(final MergedLanguageServer server, try { final List items = new java.util.ArrayList<>(server .completion(new CompletionParams(new TextDocumentIdentifier(URI), - LspClient.position(code, code.length()))).get(30, TimeUnit.SECONDS) + Positions.position(code, code.length()))).get(30, TimeUnit.SECONDS) .getRight().getItems()); items.sort((a, b) -> a.getSortText().compareTo(b.getSortText())); return items; diff --git a/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java index 2fae722..028e11e 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java @@ -42,10 +42,9 @@ import org.junit.Before; import org.junit.Test; import org.scijava.Context; -import org.scijava.code.api.CodeCompletionService; +import org.scijava.code.lsp.LanguageServerService; import org.scijava.code.lsp.Environment; import org.scijava.code.lsp.LanguageServerPlugin; -import org.scijava.code.lsp.LanguageServerService; import org.scijava.plugin.PluginService; import org.scijava.script.ScriptLanguage; import org.scijava.script.ScriptService; @@ -65,8 +64,7 @@ public class JythonServerCompletionTest { @Before public void setUp() throws Exception { - context = new Context(ScriptService.class, CodeCompletionService.class, - LanguageServerService.class); + context = new Context(ScriptService.class, LanguageServerService.class); jython = context.service(ScriptService.class).getLanguageByName("Jython"); plugin = context.service(PluginService.class).createInstancesOfType( LanguageServerPlugin.class).stream().filter( From 2134858dc1300506f337d206a09f11c3756b8dc2 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Wed, 7 Oct 2026 15:56:37 -0500 Subject: [PATCH 11/29] Ask the language servers through script sessions in tests Co-Authored-By: Claude Opus 5.5 --- .../jython/JythonCompletionTest.java | 61 +++++++------------ 1 file changed, 23 insertions(+), 38 deletions(-) diff --git a/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java index 95f170c..a2e7f82 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java @@ -35,25 +35,19 @@ import static org.junit.Assert.assertTrue; import java.util.List; +import java.util.function.Supplier; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import javax.script.ScriptEngine; import org.eclipse.lsp4j.CompletionItem; -import org.eclipse.lsp4j.CompletionParams; -import org.eclipse.lsp4j.DidCloseTextDocumentParams; -import org.eclipse.lsp4j.DidOpenTextDocumentParams; import org.eclipse.lsp4j.SignatureHelp; -import org.eclipse.lsp4j.SignatureHelpParams; -import org.eclipse.lsp4j.TextDocumentIdentifier; -import org.eclipse.lsp4j.TextDocumentItem; import org.junit.Test; import org.scijava.Context; import org.scijava.code.lsp.ClassIndex; import org.scijava.code.lsp.LanguageServerService; -import org.scijava.code.lsp.Positions; -import org.scijava.code.lsp.MergedLanguageServer; +import org.scijava.code.lsp.ScriptSession; import org.scijava.code.lsp.RatedSignatureInformation; import org.scijava.script.ScriptLanguage; import org.scijava.script.ScriptService; @@ -66,8 +60,6 @@ */ public class JythonCompletionTest { - private static final String URI = "untitled:/test.py"; - @Test public void testMemberCompletionViaService() { final Context ctx = new Context(ScriptService.class, @@ -83,7 +75,7 @@ public void testMemberCompletionViaService() { assertTrue(servers.supports(jython)); // Static analysis: 's' is a String, so 's.' completes to String members. - final List texts = labels(items(servers.server(jython), + final List texts = labels(items(servers.session(jython), "s = \"hello\"\ns.")); assertTrue("expected s.length among " + texts, texts.contains("s.length")); assertTrue("expected s.charAt among " + texts, texts.contains("s.charAt")); @@ -208,19 +200,21 @@ public void testInterpreterBindings() throws Exception { engine.eval("from java.util import Collections, HashMap\n" + "m = HashMap()\n" + "def helper(): pass\n"); - final MergedLanguageServer server = ctx.service( - LanguageServerService.class).server(jython, engine.getContext()); + final LanguageServerService servers = ctx.service( + LanguageServerService.class); + final Supplier live = () -> servers.session(jython, engine + .getContext()); - assertTrue(labels(items(server, "m.putIfAb")).contains( + assertTrue(labels(items(live.get(), "m.putIfAb")).contains( "m.putIfAbsent")); - assertTrue(labels(items(server, "injected.si")).contains( + assertTrue(labels(items(live.get(), "injected.si")).contains( "injected.size")); - assertTrue(labels(items(server, "x = hel")).contains("helper")); + assertTrue(labels(items(live.get(), "x = hel")).contains("helper")); // An imported Java class offers its static members. - assertTrue(labels(items(server, "Collections.emptyL")).contains( + assertTrue(labels(items(live.get(), "Collections.emptyL")).contains( "Collections.emptyList")); // Python internals are not offered. - assertFalse(labels(items(server, "x = __")).contains("__name__")); + assertFalse(labels(items(live.get(), "x = __")).contains("__name__")); } finally { ctx.dispose(); @@ -259,13 +253,12 @@ private static SignatureHelp help(final String code) { try { final ScriptLanguage jython = ctx.service(ScriptService.class) .getLanguageByName("Jython"); - final MergedLanguageServer server = ctx.service( - LanguageServerService.class).server(jython); - server.didOpen(new DidOpenTextDocumentParams(new TextDocumentItem(URI, - "python", 1, code))); - return server.signatureHelp(new SignatureHelpParams( - new TextDocumentIdentifier(URI), Positions.position(code, code - .length()))).get(30, TimeUnit.SECONDS); + try (ScriptSession session = ctx.service(LanguageServerService.class) + .session(jython)) + { + return session.signatureHelp(code, code.length()).get(30, + TimeUnit.SECONDS); + } } catch (final Exception exc) { throw new RuntimeException(exc); @@ -301,7 +294,7 @@ private static List completions(final String code) { try { final ScriptLanguage jython = ctx.service(ScriptService.class) .getLanguageByName("Jython"); - return items(ctx.service(LanguageServerService.class).server(jython), + return items(ctx.service(LanguageServerService.class).session(jython), code); } finally { @@ -309,25 +302,17 @@ private static List completions(final String code) { } } - private static List items(final MergedLanguageServer server, + private static List items(final ScriptSession session, final String code) { - server.didOpen(new DidOpenTextDocumentParams(new TextDocumentItem(URI, - "python", 1, code))); - try { - final List items = new java.util.ArrayList<>(server - .completion(new CompletionParams(new TextDocumentIdentifier(URI), - Positions.position(code, code.length()))).get(30, TimeUnit.SECONDS) - .getRight().getItems()); + try (ScriptSession s = session) { + final List items = new java.util.ArrayList<>(s.completion( + code, code.length()).get(30, TimeUnit.SECONDS).getItems()); items.sort((a, b) -> a.getSortText().compareTo(b.getSortText())); return items; } catch (final Exception exc) { throw new RuntimeException(exc); } - finally { - server.didClose(new DidCloseTextDocumentParams( - new TextDocumentIdentifier(URI))); - } } } From f724d7fd84a54c3525037e5ccc9278ae5c2d5186 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Thu, 8 Oct 2026 13:10:42 -0500 Subject: [PATCH 12/29] Follow code-lsp's packages by purpose Co-Authored-By: Claude Opus 5.5 --- .../scijava/plugins/scripting/jython/CompletionText.java | 2 +- .../org/scijava/plugins/scripting/jython/JavaStubs.java | 2 +- .../plugins/scripting/jython/JythonAutoCompletions.java | 4 ++-- .../scijava/plugins/scripting/jython/JythonDialect.java | 2 +- .../plugins/scripting/jython/JythonLanguageServer.java | 8 ++++---- .../scripting/jython/JythonPythonServerPlugin.java | 4 ++-- .../plugins/scripting/jython/JythonCompletionTest.java | 2 +- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/scijava/plugins/scripting/jython/CompletionText.java b/src/main/java/org/scijava/plugins/scripting/jython/CompletionText.java index d17d20c..ee8c4e7 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/CompletionText.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/CompletionText.java @@ -36,7 +36,7 @@ import java.util.Collections; import java.util.List; -import org.scijava.code.lsp.ClassIndex; +import org.scijava.code.lsp.jvm.ClassIndex; /** * A mutable suggestion used internally by the Jython completion engine. It holds diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JavaStubs.java b/src/main/java/org/scijava/plugins/scripting/jython/JavaStubs.java index 8eb314a..5ec2ba4 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JavaStubs.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JavaStubs.java @@ -59,7 +59,7 @@ import java.util.function.Function; import java.util.stream.Collectors; -import org.scijava.code.lsp.ClassIndex; +import org.scijava.code.lsp.jvm.ClassIndex; /** * Writes Python type stubs ({@code .pyi}) of Java packages, so that a Python diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java index 9101c0e..ca9eb46 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java @@ -46,13 +46,13 @@ import java.util.stream.Collectors; import org.python.indexer.types.NModuleType; -import org.scijava.code.lsp.ClassIndex; +import org.scijava.code.lsp.jvm.ClassIndex; import org.eclipse.lsp4j.CompletionItem; import org.eclipse.lsp4j.CompletionItemKind; import org.eclipse.lsp4j.MarkupContent; import org.eclipse.lsp4j.MarkupKind; import org.eclipse.lsp4j.TextEdit; -import org.scijava.code.lsp.Callable; +import org.scijava.code.lsp.jvm.Callable; /** * The Jython completion engine: given the code before the caret it analyzes the diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java index 210882d..9225ec9 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java @@ -34,7 +34,7 @@ import java.util.Set; import java.util.TreeSet; -import org.scijava.code.lsp.ScriptDialect; +import org.scijava.code.lsp.script.ScriptDialect; /** * How Jython scripts see their script parameters: as the Java objects diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonLanguageServer.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonLanguageServer.java index 7c927ae..47d88d0 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonLanguageServer.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonLanguageServer.java @@ -48,12 +48,12 @@ import org.python.core.PyModule; import org.python.core.PyObject; import org.python.core.PyString; -import org.scijava.code.lsp.ClassIndex; +import org.scijava.code.lsp.jvm.ClassIndex; import org.scijava.code.lsp.LanguageServerService; -import org.scijava.code.lsp.Callable; +import org.scijava.code.lsp.jvm.Callable; import org.scijava.code.lsp.Environment; -import org.scijava.code.lsp.ScriptLanguageServer; -import org.scijava.code.lsp.Signatures; +import org.scijava.code.lsp.jvm.ScriptLanguageServer; +import org.scijava.code.lsp.jvm.Signatures; /** * An in-process language server for Jython, driven by Jython's own analysis diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonPythonServerPlugin.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonPythonServerPlugin.java index 609b586..a74b630 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonPythonServerPlugin.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonPythonServerPlugin.java @@ -45,10 +45,10 @@ import org.eclipse.lsp4j.services.LanguageServer; import org.scijava.Priority; import org.scijava.code.lsp.LanguageServerService; -import org.scijava.code.lsp.ScriptDocument; +import org.scijava.code.lsp.script.ScriptDocument; import org.scijava.code.lsp.Environment; import org.scijava.code.lsp.LanguageServerPlugin; -import org.scijava.code.lsp.TransformingLanguageServer; +import org.scijava.code.lsp.script.TransformingLanguageServer; import org.scijava.plugin.Parameter; import org.scijava.plugin.Plugin; import org.scijava.script.ScriptLanguage; diff --git a/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java index a2e7f82..336e329 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java @@ -45,7 +45,7 @@ import org.eclipse.lsp4j.SignatureHelp; import org.junit.Test; import org.scijava.Context; -import org.scijava.code.lsp.ClassIndex; +import org.scijava.code.lsp.jvm.ClassIndex; import org.scijava.code.lsp.LanguageServerService; import org.scijava.code.lsp.ScriptSession; import org.scijava.code.lsp.RatedSignatureInformation; From 9413b081b3c321f2f54cb545913f7c921a634568 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Thu, 8 Oct 2026 14:27:47 -0500 Subject: [PATCH 13/29] Test signature order, not fit code-lsp's signatures no longer carry their fit; the best fits still come first. Co-Authored-By: Claude Opus 5.5 --- .../scripting/jython/JythonCompletionTest.java | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java index 336e329..764d0f8 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java @@ -48,7 +48,6 @@ import org.scijava.code.lsp.jvm.ClassIndex; import org.scijava.code.lsp.LanguageServerService; import org.scijava.code.lsp.ScriptSession; -import org.scijava.code.lsp.RatedSignatureInformation; import org.scijava.script.ScriptLanguage; import org.scijava.script.ScriptService; @@ -227,17 +226,15 @@ public void testSignatureHelp() { final SignatureHelp max = help("from java.lang import Math\n" + "Math.max(1.5, "); assertEquals(Integer.valueOf(1), max.getActiveParameter()); - assertEquals("[double:MATCH, float:MATCH, int:MISMATCH, long:MISMATCH]", - describe(max)); + assertEquals("[double, float, int, long]", describe(max)); // A Python int fits the integral ones, and converts to the others. - assertEquals("[int:MATCH, long:MATCH, double:CONVERSION, " + - "float:CONVERSION]", describe(help("from java.lang import Math\n" + - "Math.max(1, 2"))); + assertEquals("[int, long, double, float]", describe(help( + "from java.lang import Math\nMath.max(1, 2"))); // Arguments' types come from the analysis, e.g. of variables. final SignatureHelp indexOf = help("s = 'abc'\nc = 'b'\n" + "s.indexOf(c, "); assertTrue(describe(indexOf), describe(indexOf).startsWith( - "[java.lang.String:MATCH")); + "[java.lang.String")); // Constructors. assertTrue(describe(help("from java.util import ArrayList\n" + "ArrayList(")).contains("java.util.Collection")); @@ -268,14 +265,14 @@ private static SignatureHelp help(final String code) { } } - /** Each signature's first parameter type and fit, in order. */ + /** Each signature's first parameter type, in order. */ private static String describe(final SignatureHelp help) { return help.getSignatures().stream().map(s -> { final String first = s.getParameters().isEmpty() ? "()" : s .getParameters().get(0).getLabel().getLeft(); final String type = first.contains(" ") ? first.substring(0, first .lastIndexOf(' ')) : first; - return type + ":" + RatedSignatureInformation.fitOf(s); + return type; }).collect(Collectors.toList()).toString(); } From dcfcc5466347a1448c83bf58f964f122468a8b1a Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Thu, 8 Oct 2026 17:18:54 -0500 Subject: [PATCH 14/29] Combine Jython's analysis and jedi in one server code-lsp no longer merges several plugins' servers. JythonServerPlugin now gives JythonLanguageServer a PythonCompanion (jedi, if offered): Jython's completions first, then jedi's for other names; jedi's signatures and hover where Jython has none; Jython's problems only. Stubs are written as scripts open and change, rather than whenever where they run was asked; and jedi's environment is the stubs alone, so interpreters share the editor's jedi. Co-Authored-By: Claude Opus 5.5 --- .../jython/JythonLanguageServer.java | 93 ++++++ .../jython/JythonPythonServerPlugin.java | 180 ------------ .../scripting/jython/JythonServerPlugin.java | 26 +- .../scripting/jython/PythonCompanion.java | 274 ++++++++++++++++++ .../jython/JythonServerCompletionTest.java | 53 ++-- 5 files changed, 426 insertions(+), 200 deletions(-) delete mode 100644 src/main/java/org/scijava/plugins/scripting/jython/JythonPythonServerPlugin.java create mode 100644 src/main/java/org/scijava/plugins/scripting/jython/PythonCompanion.java diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonLanguageServer.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonLanguageServer.java index 47d88d0..1addcd6 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonLanguageServer.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonLanguageServer.java @@ -36,6 +36,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.CompletableFuture; import javax.script.Bindings; import javax.script.ScriptContext; @@ -43,7 +44,15 @@ import org.eclipse.lsp4j.CompletionItem; import org.eclipse.lsp4j.CompletionItemKind; import org.eclipse.lsp4j.CompletionList; +import org.eclipse.lsp4j.CompletionParams; +import org.eclipse.lsp4j.DidChangeTextDocumentParams; +import org.eclipse.lsp4j.DidCloseTextDocumentParams; +import org.eclipse.lsp4j.DidOpenTextDocumentParams; +import org.eclipse.lsp4j.Hover; +import org.eclipse.lsp4j.HoverParams; import org.eclipse.lsp4j.SignatureHelp; +import org.eclipse.lsp4j.SignatureHelpParams; +import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.python.core.PyJavaType; import org.python.core.PyModule; import org.python.core.PyObject; @@ -62,6 +71,11 @@ * a live interpreter, its variables. In a call's arguments, with nothing * typed, the variables of the parameter's type come first. Signature help * rates the overloads against the arguments typed. + *

+ * If a Python language server is offered (see {@link PythonCompanion}), its + * completions follow Jython's own; and its signatures and hover serve where + * Jython has none. + *

* * @author Albert Cardona * @author Curtis Rueden @@ -80,17 +94,96 @@ public class JythonLanguageServer extends ScriptLanguageServer { private final LanguageServerService servers; private final JythonAutoCompletions engine = new JythonAutoCompletions(); + /** A Python language server beside Jython's analysis, or null if none. */ + private final PythonCompanion python; + /** * @param servers Reads the scripts' parameters. * @param environment Where the scripts run (e.g. a live interpreter). */ public JythonLanguageServer(final LanguageServerService servers, final Environment environment) + { + this(servers, environment, null); + } + + /** + * @param servers Reads the scripts' parameters. + * @param environment Where the scripts run (e.g. a live interpreter). + * @param python A Python language server beside Jython's analysis, or null. + */ + JythonLanguageServer(final LanguageServerService servers, + final Environment environment, final PythonCompanion python) { super(environment); this.servers = servers; + this.python = python; } + // -- LanguageServer, with the Python server beside -- + + @Override + public void didOpen(final DidOpenTextDocumentParams params) { + super.didOpen(params); + if (python != null) python.didOpen(params); + } + + @Override + public void didChange(final DidChangeTextDocumentParams params) { + super.didChange(params); + if (python != null) python.didChange(params); + } + + @Override + public void didClose(final DidCloseTextDocumentParams params) { + super.didClose(params); + if (python != null) python.didClose(params); + } + + @Override + public CompletableFuture, CompletionList>> + completion(final CompletionParams params) + { + final CompletableFuture, CompletionList>> own = + super.completion(params); + if (python == null) return own; + return own.thenCompose(answer -> python.completion(params, answer + .getRight())); + } + + @Override + public CompletableFuture resolveCompletionItem( + final CompletionItem item) + { + if (python == null) return super.resolveCompletionItem(item); + return python.resolveCompletionItem(item, + super::resolveCompletionItem); + } + + @Override + public CompletableFuture signatureHelp( + final SignatureHelpParams params) + { + final CompletableFuture own = super.signatureHelp(params); + if (python == null) return own; + return own.thenCompose(help -> python.signatureHelp(params, help)); + } + + @Override + public CompletableFuture hover(final HoverParams params) { + final CompletableFuture own = super.hover(params); + if (python == null) return own; + return own.thenCompose(h -> python.hover(params, h)); + } + + @Override + public CompletableFuture shutdown() { + if (python != null) python.shutdown(); + return super.shutdown(); + } + + // -- ScriptLanguageServer -- + @Override protected CompletionList complete(final Document doc, final int caret) { final String text = doc.text(); diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonPythonServerPlugin.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonPythonServerPlugin.java deleted file mode 100644 index a74b630..0000000 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonPythonServerPlugin.java +++ /dev/null @@ -1,180 +0,0 @@ -/*- - * #%L - * JSR-223-compliant Jython scripting language plugin. - * %% - * Copyright (C) 2020 - 2025 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.plugins.scripting.jython; - -import java.io.File; -import java.util.Collections; -import java.util.Map; -import java.util.Set; -import java.util.TreeSet; -import java.util.concurrent.CompletableFuture; -import java.util.stream.Collectors; - -import org.eclipse.lsp4j.MessageActionItem; -import org.eclipse.lsp4j.MessageParams; -import org.eclipse.lsp4j.PublishDiagnosticsParams; -import org.eclipse.lsp4j.ShowMessageRequestParams; -import org.eclipse.lsp4j.TextDocumentItem; -import org.eclipse.lsp4j.services.LanguageClient; -import org.eclipse.lsp4j.services.LanguageServer; -import org.scijava.Priority; -import org.scijava.code.lsp.LanguageServerService; -import org.scijava.code.lsp.script.ScriptDocument; -import org.scijava.code.lsp.Environment; -import org.scijava.code.lsp.LanguageServerPlugin; -import org.scijava.code.lsp.script.TransformingLanguageServer; -import org.scijava.plugin.Parameter; -import org.scijava.plugin.Plugin; -import org.scijava.script.ScriptLanguage; - -/** - * Offers a Python language server for Jython scripts, if one is offered for - * Python (e.g. jedi-language-server, by the {@code appose-python} scripting - * plugin): for Python modules, and for Java classes, from Python stubs of the - * Java packages the script imports and its parameters' types (see - * {@link JavaStubs}, written in the background). Its completions follow - * Jython's own. - *

- * The server analyzes the script as CPython (Jython's standard library is - * Python 2's, but mostly the same): so it is asked for completions only, not - * for problems. - *

- * - * @author Gabriel Selzer - */ -@Plugin(type = LanguageServerPlugin.class, name = "Jython via Python", - priority = Priority.LOW) -public class JythonPythonServerPlugin implements LanguageServerPlugin { - - @Parameter - private LanguageServerService servers; - - - private JavaStubs stubs; - - @Override - public boolean supports(final ScriptLanguage language) { - return JythonServerPlugin.isJython(language) && servers.launch("python", - Environment.NONE) != null; - } - - @Override - public Environment environment(final TextDocumentItem document) { - // NB: Writes the stubs the script needs, in the background. - final JavaStubs s = stubs(); - final Set packages = new TreeSet<>(JavaStubs.imports(document - .getText())); - final ScriptDocument doc = ScriptDocument.of(document.getText(), - parameters(document.getText()), JythonLanguageServer.DIALECT, null); - packages.addAll(JythonDialect.packages(doc.parameters().values().stream() - .filter(t -> t != null).collect(Collectors.toList()))); - s.ensure(packages); - // NB: The server's own Python (CPython). - return new Environment(null, Collections.singletonList(s.dir() - .getAbsolutePath()), null, null); - } - - @Override - public LanguageServer create(final Environment environment) { - final LanguageServer python = servers.launch("python", environment); - return new TransformingLanguageServer(python, text -> ScriptDocument.of( - text, parameters(text), JythonLanguageServer.DIALECT, environment - )) - { - - @Override - public void connect(final LanguageClient client) { - super.connect(new NoProblems(client)); - } - }; - } - - /** Sets the stubs, e.g. to write them elsewhere in tests. */ - synchronized void setStubs(final JavaStubs stubs) { - this.stubs = stubs; - } - - /** Gets the stubs of Java packages, creating them on first use. */ - synchronized JavaStubs stubs() { - if (stubs != null) return stubs; - final String dir = System.getProperty("scijava.jython.stubs"); - stubs = new JavaStubs(dir != null ? new File(dir) : new File(System - .getProperty("user.home"), ".cache" + File.separator + "scijava" + - File.separator + "jython-stubs"), Thread.currentThread() - .getContextClassLoader()); - return stubs; - } - - private Map> parameters(final String text) { - return servers == null ? Collections.emptyMap() : servers - .scriptParameters(text); - } - - /** - * Passes a client the server's messages, but no problems: the server reads - * Jython's Python 2 as Python 3. (An empty list still says the server is - * done looking.) - */ - private static final class NoProblems implements LanguageClient { - - private final LanguageClient client; - - private NoProblems(final LanguageClient client) { - this.client = client; - } - - @Override - public void publishDiagnostics(final PublishDiagnosticsParams p) { - client.publishDiagnostics(new PublishDiagnosticsParams(p.getUri(), - Collections.emptyList(), p.getVersion())); - } - - @Override - public void telemetryEvent(final Object object) { - client.telemetryEvent(object); - } - - @Override - public void showMessage(final MessageParams m) { - client.showMessage(m); - } - - @Override - public CompletableFuture showMessageRequest( - final ShowMessageRequestParams r) - { - return client.showMessageRequest(r); - } - - @Override - public void logMessage(final MessageParams m) { - client.logMessage(m); - } - } -} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonServerPlugin.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonServerPlugin.java index 4f1475e..bb68078 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonServerPlugin.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonServerPlugin.java @@ -28,6 +28,8 @@ */ package org.scijava.plugins.scripting.jython; +import java.io.File; + import org.eclipse.lsp4j.services.LanguageServer; import org.scijava.Priority; import org.scijava.code.lsp.LanguageServerService; @@ -39,7 +41,8 @@ /** * Offers Jython's in-process language server (see - * {@link JythonLanguageServer}). + * {@link JythonLanguageServer}), with a Python language server beside it if + * one is offered (see {@link PythonCompanion}). * * @author Gabriel Selzer */ @@ -50,6 +53,8 @@ public class JythonServerPlugin implements LanguageServerPlugin { @Parameter(required = false) private LanguageServerService servers; + private JavaStubs stubs; + @Override public boolean supports(final ScriptLanguage language) { return isJython(language); @@ -57,7 +62,24 @@ public boolean supports(final ScriptLanguage language) { @Override public LanguageServer create(final Environment environment) { - return new JythonLanguageServer(servers, environment); + return new JythonLanguageServer(servers, environment, PythonCompanion + .launch(servers, stubs())); + } + + /** Sets the stubs, e.g. to write them elsewhere in tests. */ + synchronized void setStubs(final JavaStubs stubs) { + this.stubs = stubs; + } + + /** Gets the stubs of Java packages, creating them on first use. */ + synchronized JavaStubs stubs() { + if (stubs != null) return stubs; + final String dir = System.getProperty("scijava.jython.stubs"); + stubs = new JavaStubs(dir != null ? new File(dir) : new File(System + .getProperty("user.home"), ".cache" + File.separator + "scijava" + + File.separator + "jython-stubs"), Thread.currentThread() + .getContextClassLoader()); + return stubs; } /** Matches "Jython" and "Python (Jython)", without claiming CPython. */ diff --git a/src/main/java/org/scijava/plugins/scripting/jython/PythonCompanion.java b/src/main/java/org/scijava/plugins/scripting/jython/PythonCompanion.java new file mode 100644 index 0000000..fd7cb16 --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/jython/PythonCompanion.java @@ -0,0 +1,274 @@ +/*- + * #%L + * JSR-223-compliant Jython scripting language plugin. + * %% + * Copyright (C) 2020 - 2025 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.plugins.scripting.jython; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionList; +import org.eclipse.lsp4j.CompletionParams; +import org.eclipse.lsp4j.DidChangeTextDocumentParams; +import org.eclipse.lsp4j.DidCloseTextDocumentParams; +import org.eclipse.lsp4j.DidOpenTextDocumentParams; +import org.eclipse.lsp4j.Hover; +import org.eclipse.lsp4j.HoverParams; +import org.eclipse.lsp4j.InitializeParams; +import org.eclipse.lsp4j.InitializedParams; +import org.eclipse.lsp4j.SignatureHelp; +import org.eclipse.lsp4j.SignatureHelpParams; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import org.eclipse.lsp4j.services.LanguageServer; +import org.eclipse.lsp4j.services.TextDocumentService; +import org.scijava.code.lsp.Environment; +import org.scijava.code.lsp.LanguageServerService; +import org.scijava.code.lsp.script.ScriptDocument; +import org.scijava.code.lsp.script.TransformingLanguageServer; + +/** + * A Python language server (e.g. jedi-language-server, if the + * {@code appose-python} scripting plugin offers one) beside Jython's own + * analysis (see {@link JythonLanguageServer}): for Python modules, and for + * Java classes, from Python stubs of the Java packages a script imports and + * its parameters' types (see {@link JavaStubs}, written in the background). + *

+ * The server analyzes scripts as CPython (Jython's standard library is Python + * 2's, but mostly the same): so its completions follow Jython's own, it is + * asked for signatures and hover only where Jython has none, and never for + * problems. + *

+ * + * @author Gabriel Selzer + */ +final class PythonCompanion { + + /** The server, seeing scripts as plain Python. */ + private final LanguageServer server; + private final JavaStubs stubs; + private final Function>> parameters; + + private PythonCompanion(final LanguageServer python, final JavaStubs stubs, + final Function>> parameters) + { + this.stubs = stubs; + this.parameters = parameters; + // NB: No client: the server's problems are not asked for. + server = new TransformingLanguageServer(python, text -> ScriptDocument.of( + text, parameters.apply(text), JythonLanguageServer.DIALECT, null)); + server.initialize(new InitializeParams()).thenRun(() -> server + .initialized(new InitializedParams())); + } + + /** + * Gets a Python server beside Jython's, or null if none is offered. + * + * @param servers Offers the server, and reads the scripts' parameters. + * @param stubs The stubs of Java packages, for the server to search. + */ + static PythonCompanion launch(final LanguageServerService servers, + final JavaStubs stubs) + { + if (servers == null) return null; + final LanguageServer python = servers.launch("python", environment(stubs)); + return python == null ? null : new PythonCompanion(python, stubs, + servers::scriptParameters); + } + + /** Where the server runs: its own Python, searching the Java stubs. */ + static Environment environment(final JavaStubs stubs) { + return new Environment(null, Collections.singletonList(stubs.dir() + .getAbsolutePath()), null, null); + } + + // -- Documents -- + + void didOpen(final DidOpenTextDocumentParams params) { + ensureStubs(params.getTextDocument().getText()); + documents().didOpen(params); + } + + void didChange(final DidChangeTextDocumentParams params) { + if (params.getContentChanges().isEmpty()) return; + ensureStubs(params.getContentChanges().get(params.getContentChanges() + .size() - 1).getText()); + documents().didChange(params); + } + + void didClose(final DidCloseTextDocumentParams params) { + documents().didClose(params); + } + + CompletableFuture shutdown() { + return server.shutdown(); + } + + // -- Requests: Jython's answers first -- + + /** + * Jython's completions, then the server's that complete other names. Each + * keeps its own order. + */ + CompletableFuture, CompletionList>> completion( + final CompletionParams params, final CompletionList own) + { + return orNull(documents().completion(params)).thenApply(theirs -> Either + .forRight(merge(own, list(theirs)))); + } + + /** Resolves a completion: the server's with the server, others with own. */ + CompletableFuture resolveCompletionItem( + final CompletionItem item, + final Function> own) + { + if (!(item.getData() instanceof FromPython)) return own.apply(item); + final FromPython from = (FromPython) item.getData(); + final String sortText = item.getSortText(); + item.setData(from.data); + item.setSortText(from.sortText); + return orNull(documents().resolveCompletionItem(item)).thenApply( + resolved -> { + final CompletionItem out = resolved != null ? resolved : item; + out.setData(from); + out.setSortText(sortText); + return out; + }); + } + + /** Jython's signatures, if any; otherwise the server's. */ + CompletableFuture signatureHelp( + final SignatureHelpParams params, final SignatureHelp own) + { + if (own != null && own.getSignatures() != null && !own.getSignatures() + .isEmpty()) return CompletableFuture.completedFuture(own); + return orNull(documents().signatureHelp(params)); + } + + /** Jython's hover, if any; otherwise the server's. */ + CompletableFuture hover(final HoverParams params, final Hover own) { + if (own != null && own.getContents() != null) { + return CompletableFuture.completedFuture(own); + } + return orNull(documents().hover(params)); + } + + // -- Helper methods -- + + private TextDocumentService documents() { + return server.getTextDocumentService(); + } + + /** Writes the stubs the script needs, in the background. */ + private void ensureStubs(final String text) { + final Set packages = new TreeSet<>(JavaStubs.imports(text)); + final ScriptDocument doc = ScriptDocument.of(text, parameters.apply(text), + JythonLanguageServer.DIALECT, null); + packages.addAll(JythonDialect.packages(doc.parameters().values().stream() + .filter(t -> t != null).collect(Collectors.toList()))); + stubs.ensure(packages); + } + + /** + * Jython's completions, then the server's whose names Jython has not + * completed (e.g. Python modules), each in its own order. + */ + static CompletionList merge(final CompletionList own, + final CompletionList theirs) + { + if (theirs == null || theirs.getItems() == null || theirs.getItems() + .isEmpty()) return own; + final List out = new ArrayList<>(); + final Set names = new HashSet<>(); + int i = 0; + for (final CompletionItem item : sorted(own)) { + names.add(name(item)); + item.setSortText(String.format("0%06d", i++)); + out.add(item); + } + for (final CompletionItem item : sorted(theirs)) { + if (names.contains(name(item))) continue; + item.setData(new FromPython(item.getData(), item.getSortText())); + item.setSortText(String.format("1%06d", i++)); + out.add(item); + } + return new CompletionList(own.isIncomplete() || theirs.isIncomplete(), + out); + } + + private static List sorted(final CompletionList list) { + final List items = new ArrayList<>(); + if (list != null && list.getItems() != null) items.addAll(list.getItems()); + items.sort(Comparator.comparing(c -> c.getSortText() != null ? c + .getSortText() : c.getLabel())); + return items; + } + + /** What a completion completes: its label, without any parameters. */ + private static String name(final CompletionItem item) { + final String label = item.getLabel(); + final int paren = label.indexOf('('); + return (paren < 0 ? label : label.substring(0, paren)).trim(); + } + + private static CompletionList list( + final Either, CompletionList> answer) + { + if (answer == null) return null; + return answer.isRight() ? answer.getRight() : new CompletionList(answer + .getLeft()); + } + + /** The answer; or null, if the server failed (e.g. it stopped). */ + private static CompletableFuture orNull( + final CompletableFuture answer) + { + return answer == null ? CompletableFuture.completedFuture(null) : answer + .exceptionally(error -> null); + } + + /** Marks a completion as the server's, keeping what it came with. */ + private static final class FromPython { + + private final Object data; + private final String sortText; + + private FromPython(final Object data, final String sortText) { + this.data = data; + this.sortText = sortText; + } + } +} diff --git a/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java index 028e11e..f6cb2c0 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java @@ -29,29 +29,31 @@ package org.scijava.plugins.scripting.jython; 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.nio.file.Files; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; -import org.eclipse.lsp4j.TextDocumentItem; +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionList; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.scijava.Context; -import org.scijava.code.lsp.LanguageServerService; import org.scijava.code.lsp.Environment; -import org.scijava.code.lsp.LanguageServerPlugin; -import org.scijava.plugin.PluginService; +import org.scijava.code.lsp.LanguageServerService; import org.scijava.script.ScriptLanguage; import org.scijava.script.ScriptService; /** - * Tests {@link JythonPythonServerPlugin}: a Python language server's - * environment for Jython scripts (Java stubs); and {@link JythonDialect}. + * Tests {@link PythonCompanion}: a Python language server's environment for + * Jython scripts (Java stubs); and {@link JythonDialect}. * * @author Gabriel Selzer */ @@ -59,20 +61,16 @@ public class JythonServerCompletionTest { private Context context; private ScriptLanguage jython; - private JythonPythonServerPlugin plugin; private File stubsDir; + private JavaStubs stubs; @Before public void setUp() throws Exception { context = new Context(ScriptService.class, LanguageServerService.class); jython = context.service(ScriptService.class).getLanguageByName("Jython"); - plugin = context.service(PluginService.class).createInstancesOfType( - LanguageServerPlugin.class).stream().filter( - p -> p instanceof JythonPythonServerPlugin).map( - p -> (JythonPythonServerPlugin) p).findFirst().get(); stubsDir = Files.createTempDirectory("jython-stubs").toFile(); - plugin.setStubs(new JavaStubs(stubsDir, getClass().getClassLoader(), - pkg -> Collections.emptyList())); + stubs = new JavaStubs(stubsDir, getClass().getClassLoader(), + pkg -> Collections.emptyList()); } @After @@ -84,20 +82,39 @@ public void tearDown() { @Test public void testOnlyWithAPythonServer() { // NB: No plugin offers a Python server here (scripting-appose-python - // does): Jython is served by its own server alone. - assertFalse(plugin.supports(jython)); + // does): Jython is served by its own analysis alone. + assertNull(PythonCompanion.launch(context.service( + LanguageServerService.class), stubs)); } @Test public void testEnvironment() { // The server's own Python, searching the Java stubs. - final Environment env = plugin.environment(new TextDocumentItem( - "untitled:/a.py", "python", 1, "from java.util import ArrayList\n")); + final Environment env = PythonCompanion.environment(stubs); assertEquals(null, env.interpreter()); assertEquals(Collections.singletonList(stubsDir.getAbsolutePath()), env .searchPaths()); } + @Test + public void testMerge() { + final CompletionItem own = new CompletionItem("max"); + own.setData(1); + final CompletionList merged = PythonCompanion.merge(new CompletionList( + Arrays.asList(new CompletionItem("min"), own)), new CompletionList(Arrays + .asList(new CompletionItem("max"), new CompletionItem("math")))); + // Jython's first, each in its own order; the server's only for names + // Jython has not completed. + assertEquals(Arrays.asList("max", "min", "math"), merged.getItems().stream() + .map(CompletionItem::getLabel).collect(Collectors.toList())); + assertEquals(1, merged.getItems().get(0).getData()); + final List sortTexts = merged.getItems().stream().map( + CompletionItem::getSortText).collect(Collectors.toList()); + final List sorted = new ArrayList<>(sortTexts); + Collections.sort(sorted); + assertEquals(sorted, sortTexts); + } + @Test public void testDeclarations() { final JythonDialect dialect = new JythonDialect(); From be381ca17706608e0a66b4d4fa5fb2a75040aa15 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Fri, 9 Oct 2026 10:28:03 -0500 Subject: [PATCH 15/29] Unify Environment's search paths and classpath Both are ordered places imports are resolved from; each server reads them as its language does. Nothing read the classpath. Co-Authored-By: Claude Opus 5.5 --- .../org/scijava/plugins/scripting/jython/PythonCompanion.java | 2 +- .../plugins/scripting/jython/JythonServerCompletionTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/scijava/plugins/scripting/jython/PythonCompanion.java b/src/main/java/org/scijava/plugins/scripting/jython/PythonCompanion.java index fd7cb16..9f1d20a 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/PythonCompanion.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/PythonCompanion.java @@ -112,7 +112,7 @@ static PythonCompanion launch(final LanguageServerService servers, /** Where the server runs: its own Python, searching the Java stubs. */ static Environment environment(final JavaStubs stubs) { return new Environment(null, Collections.singletonList(stubs.dir() - .getAbsolutePath()), null, null); + .getAbsolutePath()), null); } // -- Documents -- diff --git a/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java index f6cb2c0..56d4882 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java @@ -93,7 +93,7 @@ public void testEnvironment() { final Environment env = PythonCompanion.environment(stubs); assertEquals(null, env.interpreter()); assertEquals(Collections.singletonList(stubsDir.getAbsolutePath()), env - .searchPaths()); + .paths()); } @Test From 657bb213a51e99d6c1588487676ba1e250a05af1 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Fri, 9 Oct 2026 13:23:21 -0500 Subject: [PATCH 16/29] Serve plain Python: sessions declare the parameters Jython's analysis and the Python server beside it now read the same text, which the session makes in JythonDialect: parameters are declared as instances of their classes (e.g. img = net.imglib2.img.Img()), which both understand. So the server no longer reads #@ lines, and PythonCompanion no longer transforms scripts for jedi. Jython's analysis now resolves classes by their full names, and the stubs follow imports separated by semicolons. Co-Authored-By: Claude Opus 5.5 --- .../plugins/scripting/jython/JavaStubs.java | 3 +- .../scripting/jython/JythonDialect.java | 45 ++++--------------- .../jython/JythonLanguageServer.java | 38 ++++++---------- .../scripting/jython/JythonScriptParser.java | 35 +++++++++++++++ .../scripting/jython/JythonServerPlugin.java | 13 ++++-- .../scripting/jython/PythonCompanion.java | 35 +++++---------- .../jython/JythonServerCompletionTest.java | 17 ++++--- 7 files changed, 89 insertions(+), 97 deletions(-) diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JavaStubs.java b/src/main/java/org/scijava/plugins/scripting/jython/JavaStubs.java index 5ec2ba4..3960ca6 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JavaStubs.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JavaStubs.java @@ -182,7 +182,8 @@ public CompletableFuture ensure(final Collection packages) { */ public static Set imports(final String script) { final Set packages = new TreeSet<>(); - for (final String line : script.split("\n")) { + // NB: Statements on one line are separated by semicolons. + for (final String line : script.split("[\n;]")) { final String l = line.trim(); String module = null; if (l.startsWith("from ")) { diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java index 9225ec9..09a533d 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java @@ -38,14 +38,12 @@ /** * How Jython scripts see their script parameters: as the Java objects - * themselves (primitives boxed), e.g. {@code image: net.imglib2.img.Img}; or - * as Python values, where Jython converts them (e.g. strings). + * themselves (primitives boxed), e.g. {@code net.imglib2.img.Img}. *

- * Run-time types are Java class names (e.g. {@code java.lang.String}), as - * Jython's own completion needs them; declarations are Python, for a Python - * language server knowing the Java packages from stubs (see - * {@link JavaStubs}), e.g. {@code import net.imglib2.img; image: - * net.imglib2.img.Img; name: str}. + * Each is declared as an instance of its class, in code both Jython's own + * analysis (Python 2) and a Python language server knowing the Java packages + * from stubs (see {@link JavaStubs}) read: e.g. {@code import net.imglib2.img; + * image = net.imglib2.img.Img()}. (Arrays are lists.) *

* * @author Gabriel Selzer @@ -59,7 +57,8 @@ public String runtimeType(final Class type) { @Override public String declare(final String name, final String type) { - return name + ": " + pythonType(type); + return name + " = " + (type.startsWith("[") ? "list" : type.replace('$', + '.')) + "()"; } @Override @@ -73,38 +72,12 @@ public List imports(final Collection types) { static Set packages(final Collection types) { final Set packages = new TreeSet<>(); for (final String t : types) { - if (pythonType(t).equals(t.replace('$', '.')) && t.lastIndexOf( - '.') > 0) packages.add(t.substring(0, t.lastIndexOf('.'))); + if (!t.startsWith("[") && t.lastIndexOf('.') > 0) packages.add(t + .substring(0, t.lastIndexOf('.'))); } return packages; } - /** - * The Python type of a Java class name, as Jython converts its values: - * e.g. {@code str} for {@code java.lang.String}. - */ - static String pythonType(final String type) { - switch (type) { - case "java.lang.String": - case "java.lang.Character": - return "str"; - case "java.lang.Integer": - case "java.lang.Long": - case "java.lang.Short": - case "java.lang.Byte": - case "java.math.BigInteger": - return "int"; - case "java.lang.Double": - case "java.lang.Float": - case "java.math.BigDecimal": - return "float"; - case "java.lang.Boolean": - return "bool"; - default: - return type.startsWith("[") ? "list" : type.replace('$', '.'); - } - } - /** Maps primitive types to their wrappers, whose members can be listed. */ private static Class boxed(final Class type) { if (!type.isPrimitive()) return type; diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonLanguageServer.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonLanguageServer.java index 1addcd6..1e9ec67 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonLanguageServer.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonLanguageServer.java @@ -58,7 +58,6 @@ import org.python.core.PyObject; import org.python.core.PyString; import org.scijava.code.lsp.jvm.ClassIndex; -import org.scijava.code.lsp.LanguageServerService; import org.scijava.code.lsp.jvm.Callable; import org.scijava.code.lsp.Environment; import org.scijava.code.lsp.jvm.ScriptLanguageServer; @@ -83,7 +82,10 @@ */ public class JythonLanguageServer extends ScriptLanguageServer { - /** How scripts see their parameters: as the Java objects themselves. */ + /** + * How scripts see their parameters: as the Java objects themselves, + * declared in the text (see {@link JythonServerPlugin#dialect()}). + */ static final JythonDialect DIALECT = new JythonDialect(); static { @@ -91,32 +93,26 @@ public class JythonLanguageServer extends ScriptLanguageServer { new Thread(ClassIndex::ensureCache, "Jython-ClassIndex-warmup").start(); } - private final LanguageServerService servers; private final JythonAutoCompletions engine = new JythonAutoCompletions(); /** A Python language server beside Jython's analysis, or null if none. */ private final PythonCompanion python; /** - * @param servers Reads the scripts' parameters. * @param environment Where the scripts run (e.g. a live interpreter). */ - public JythonLanguageServer(final LanguageServerService servers, - final Environment environment) - { - this(servers, environment, null); + public JythonLanguageServer(final Environment environment) { + this(environment, null); } /** - * @param servers Reads the scripts' parameters. * @param environment Where the scripts run (e.g. a live interpreter). * @param python A Python language server beside Jython's analysis, or null. */ - JythonLanguageServer(final LanguageServerService servers, - final Environment environment, final PythonCompanion python) + JythonLanguageServer(final Environment environment, + final PythonCompanion python) { super(environment); - this.servers = servers; this.python = python; } @@ -310,24 +306,16 @@ private JythonAutoCompletions.Result completionsAt(final String text, '\n') + 1); return engine.completionsFor(textToCaret.substring(0, textToCaret .length() - lastLine.length()), lastLine, alreadyEnteredText(lastLine), - predefinedVariables(text)); + liveVariables()); } /** - * Gets the variables defined before the script's code runs: script - * parameters declared via {@code #@} lines, and, when completing in a live - * interpreter, its bindings (which reflect actual runtime values, so they - * take precedence). + * Gets the variables defined before the code runs, when completing in a + * live interpreter: its bindings. (A script's parameters are declared in + * its text.) */ - private Map predefinedVariables( - final String text) - { + private Map liveVariables() { final Map vars = new LinkedHashMap<>(); - if (servers != null) { - servers.scriptParameters(text).forEach((name, - type) -> vars.put(name, new VarDotAutocompletions(DIALECT.runtimeType( - type)))); - } final ScriptContext live = environment().live(); final Bindings bindings = live == null ? null : live.getBindings( ScriptContext.ENGINE_SCOPE); diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonScriptParser.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonScriptParser.java index cdcf3cd..5a792b9 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonScriptParser.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonScriptParser.java @@ -404,6 +404,13 @@ static public DotAutocompletions parseRight(final PyObject right, final Scope sc // gray8 = IJ.getImage().GRAY8 final Attribute attr = (Attribute)right; final DotAutocompletions da = parseRight(attr.getValue(), scope); + // e.g. a class by its full name, after a package (or nothing known) + // img = net.imglib2.img.Img() + if (DotAutocompletions.EMPTY == da || da instanceof StaticDotAutocompletions) { + final String fullName = javaClass(attr); + if (null != fullName) + return new StaticDotAutocompletions(fullName); + } if (DotAutocompletions.EMPTY == da) return da; final String name = attr.getInternalAttr(); @@ -465,4 +472,32 @@ static public DotAutocompletions parseRight(final PyObject right, final Scope sc return DotAutocompletions.EMPTY; } + + /** + * The Java class a dotted name (e.g. {@code java.util.Map.Entry}) is, by + * its full name; or null if none (e.g. it starts with a variable). + */ + static private String javaClass(final Attribute attr) { + final StringBuilder name = new StringBuilder(attr.getInternalAttr()); + PyObject node = attr.getValue(); + while (node instanceof Attribute) { + name.insert(0, ((Attribute)node).getInternalAttr() + "."); + node = ((Attribute)node).getValue(); + } + if (!(node instanceof Name)) return null; + name.insert(0, ((Name)node).getInternalId() + "."); + // NB: A package starts lowercase; a class, uppercase. + if (!Character.isLowerCase(name.charAt(0))) return null; + String candidate = name.toString(); + while (true) { + try { + return Class.forName(candidate).getName(); + } catch (ClassNotFoundException | LinkageError e) { + // Perhaps a nested class: e.g. java.util.Map$Entry. + final int dot = candidate.lastIndexOf('.'); + if (dot < 0 || !Character.isUpperCase(candidate.charAt(candidate.lastIndexOf('.', dot - 1) + 1))) return null; + candidate = candidate.substring(0, dot) + "$" + candidate.substring(dot + 1); + } + } + } } diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonServerPlugin.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonServerPlugin.java index bb68078..2e307d1 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonServerPlugin.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonServerPlugin.java @@ -35,6 +35,7 @@ import org.scijava.code.lsp.LanguageServerService; import org.scijava.code.lsp.Environment; import org.scijava.code.lsp.LanguageServerPlugin; +import org.scijava.code.lsp.script.ScriptDialect; import org.scijava.plugin.Parameter; import org.scijava.plugin.Plugin; import org.scijava.script.ScriptLanguage; @@ -42,7 +43,8 @@ /** * Offers Jython's in-process language server (see * {@link JythonLanguageServer}), with a Python language server beside it if - * one is offered (see {@link PythonCompanion}). + * one is offered (see {@link PythonCompanion}); both see scripts as plain + * Python (see {@link JythonDialect}). * * @author Gabriel Selzer */ @@ -62,8 +64,13 @@ public boolean supports(final ScriptLanguage language) { @Override public LanguageServer create(final Environment environment) { - return new JythonLanguageServer(servers, environment, PythonCompanion - .launch(servers, stubs())); + return new JythonLanguageServer(environment, PythonCompanion.launch( + servers, stubs())); + } + + @Override + public ScriptDialect dialect() { + return JythonLanguageServer.DIALECT; } /** Sets the stubs, e.g. to write them elsewhere in tests. */ diff --git a/src/main/java/org/scijava/plugins/scripting/jython/PythonCompanion.java b/src/main/java/org/scijava/plugins/scripting/jython/PythonCompanion.java index 9f1d20a..1f32064 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/PythonCompanion.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/PythonCompanion.java @@ -33,12 +33,9 @@ import java.util.Comparator; import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.Set; -import java.util.TreeSet; import java.util.concurrent.CompletableFuture; import java.util.function.Function; -import java.util.stream.Collectors; import org.eclipse.lsp4j.CompletionItem; import org.eclipse.lsp4j.CompletionList; @@ -57,15 +54,14 @@ import org.eclipse.lsp4j.services.TextDocumentService; import org.scijava.code.lsp.Environment; import org.scijava.code.lsp.LanguageServerService; -import org.scijava.code.lsp.script.ScriptDocument; -import org.scijava.code.lsp.script.TransformingLanguageServer; /** * A Python language server (e.g. jedi-language-server, if the * {@code appose-python} scripting plugin offers one) beside Jython's own * analysis (see {@link JythonLanguageServer}): for Python modules, and for - * Java classes, from Python stubs of the Java packages a script imports and - * its parameters' types (see {@link JavaStubs}, written in the background). + * Java classes, from Python stubs of the Java packages a script imports + * (including those of its parameters' types, declared in its text; see + * {@link JavaStubs}, written in the background). *

* The server analyzes scripts as CPython (Jython's standard library is Python * 2's, but mostly the same): so its completions follow Jython's own, it is @@ -77,19 +73,14 @@ */ final class PythonCompanion { - /** The server, seeing scripts as plain Python. */ + /** The server. */ private final LanguageServer server; private final JavaStubs stubs; - private final Function>> parameters; - private PythonCompanion(final LanguageServer python, final JavaStubs stubs, - final Function>> parameters) - { + private PythonCompanion(final LanguageServer python, final JavaStubs stubs) { this.stubs = stubs; - this.parameters = parameters; // NB: No client: the server's problems are not asked for. - server = new TransformingLanguageServer(python, text -> ScriptDocument.of( - text, parameters.apply(text), JythonLanguageServer.DIALECT, null)); + server = python; server.initialize(new InitializeParams()).thenRun(() -> server .initialized(new InitializedParams())); } @@ -97,7 +88,7 @@ private PythonCompanion(final LanguageServer python, final JavaStubs stubs, /** * Gets a Python server beside Jython's, or null if none is offered. * - * @param servers Offers the server, and reads the scripts' parameters. + * @param servers Offers the server. * @param stubs The stubs of Java packages, for the server to search. */ static PythonCompanion launch(final LanguageServerService servers, @@ -105,8 +96,7 @@ static PythonCompanion launch(final LanguageServerService servers, { if (servers == null) return null; final LanguageServer python = servers.launch("python", environment(stubs)); - return python == null ? null : new PythonCompanion(python, stubs, - servers::scriptParameters); + return python == null ? null : new PythonCompanion(python, stubs); } /** Where the server runs: its own Python, searching the Java stubs. */ @@ -192,14 +182,9 @@ private TextDocumentService documents() { return server.getTextDocumentService(); } - /** Writes the stubs the script needs, in the background. */ + /** Writes the stubs of the packages the script imports, in the background. */ private void ensureStubs(final String text) { - final Set packages = new TreeSet<>(JavaStubs.imports(text)); - final ScriptDocument doc = ScriptDocument.of(text, parameters.apply(text), - JythonLanguageServer.DIALECT, null); - packages.addAll(JythonDialect.packages(doc.parameters().values().stream() - .filter(t -> t != null).collect(Collectors.toList()))); - stubs.ensure(packages); + stubs.ensure(JavaStubs.imports(text)); } /** diff --git a/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java index 56d4882..9b93d1b 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java @@ -118,15 +118,18 @@ public void testMerge() { @Test public void testDeclarations() { final JythonDialect dialect = new JythonDialect(); - assertEquals("img: net.imglib2.img.Img", dialect.declare("img", + assertEquals("img = net.imglib2.img.Img()", dialect.declare("img", "net.imglib2.img.Img")); - assertEquals("n: int", dialect.declare("n", dialect.runtimeType( - int.class))); - assertEquals("e: java.util.Map.Entry", dialect.declare("e", + assertEquals("n = java.lang.Integer()", dialect.declare("n", dialect + .runtimeType(int.class))); + assertEquals("e = java.util.Map.Entry()", dialect.declare("e", "java.util.Map$Entry")); - assertEquals(Arrays.asList("import java.util", "import net.imglib2.img"), - dialect.imports(Arrays.asList("net.imglib2.img.Img", "java.lang.String", - "java.util.Map$Entry"))); + assertEquals("a = list()", dialect.declare("a", dialect.runtimeType( + double[].class))); + assertEquals(Arrays.asList("import java.lang", "import java.util", + "import net.imglib2.img"), dialect.imports(Arrays.asList( + "net.imglib2.img.Img", "java.lang.String", "java.util.Map$Entry", + "[D"))); assertTrue(JythonServerPlugin.isJython(jython)); } } From 8a9ac66fb166f99b061f8a85158bee6e19a2fcdf Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Fri, 9 Oct 2026 13:24:39 -0500 Subject: [PATCH 17/29] Declare parameters with the types Jython converts them to Jython hands scripts Python values for strings, numbers and booleans (e.g. a java.lang.String becomes a unicode, whose Java methods fail), and array.array for arrays. Declaring them so, Jython's analysis no longer offers Java members that fail at run time (e.g. name.toUpperCase), and jedi sees the same types as before. Co-Authored-By: Claude Opus 5.5 --- .../scripting/jython/JythonDialect.java | 35 +++++++++++++------ .../jython/JythonCompletionTest.java | 15 ++++---- .../jython/JythonServerCompletionTest.java | 19 ++++++---- 3 files changed, 45 insertions(+), 24 deletions(-) diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java index 09a533d..4ca81dc 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java @@ -28,6 +28,7 @@ */ package org.scijava.plugins.scripting.jython; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -37,13 +38,15 @@ import org.scijava.code.lsp.script.ScriptDialect; /** - * How Jython scripts see their script parameters: as the Java objects - * themselves (primitives boxed), e.g. {@code net.imglib2.img.Img}. + * How Jython scripts see their script parameters: as the Python values Jython + * converts them to (e.g. {@code str} for strings, {@code int} for integers, + * {@code array.array} for arrays), or else as the Java objects themselves + * (e.g. {@code net.imglib2.img.Img}). *

- * Each is declared as an instance of its class, in code both Jython's own + * Each is declared as an instance of its type, in code both Jython's own * analysis (Python 2) and a Python language server knowing the Java packages * from stubs (see {@link JavaStubs}) read: e.g. {@code import net.imglib2.img; - * image = net.imglib2.img.Img()}. (Arrays are lists.) + * image = net.imglib2.img.Img(); name = str()}. *

* * @author Gabriel Selzer @@ -52,13 +55,20 @@ class JythonDialect implements ScriptDialect { @Override public String runtimeType(final Class type) { - return boxed(type).getName(); + final Class t = boxed(type); + // NB: As Jython converts them (Py.java2py). + if (t == String.class || t == Character.class) return "str"; + if (t == Integer.class || t == Long.class || t == Short.class || + t == Byte.class || t == BigInteger.class) return "int"; + if (t == Double.class || t == Float.class) return "float"; + if (t == Boolean.class) return "bool"; + if (t.isArray()) return "array.array"; + return t.getName(); } @Override public String declare(final String name, final String type) { - return name + " = " + (type.startsWith("[") ? "list" : type.replace('$', - '.')) + "()"; + return name + " = " + type.replace('$', '.') + "()"; } @Override @@ -68,17 +78,20 @@ public List imports(final Collection types) { return imports; } - /** The Java packages of the given types that a declaration refers to. */ + /** + * The packages or modules of the given types that a declaration refers to + * (none for builtins, e.g. {@code str}). + */ static Set packages(final Collection types) { final Set packages = new TreeSet<>(); for (final String t : types) { - if (!t.startsWith("[") && t.lastIndexOf('.') > 0) packages.add(t - .substring(0, t.lastIndexOf('.'))); + if (t.lastIndexOf('.') > 0) packages.add(t.substring(0, t.lastIndexOf( + '.'))); } return packages; } - /** Maps primitive types to their wrappers, whose members can be listed. */ + /** Maps primitive types to their wrappers. */ private static Class boxed(final Class type) { if (!type.isPrimitive()) return type; if (type == int.class) return Integer.class; diff --git a/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java index 764d0f8..93cfda4 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java @@ -140,13 +140,16 @@ public void testScriptParameters() { "#@ NoSuchType mystery\n"; assertTrue(complete(header + "x = na").contains("name")); assertTrue(complete(header + "myst").contains("mystery")); - final List nameMembers = complete(header + "name.toUpp"); - assertTrue("expected name.toUpperCase among " + nameMembers, nameMembers - .contains("name.toUpperCase")); + // As the Python values Jython converts them to: e.g. strings are str, + // whose members are Python's, not java.lang.String's. + final List nameMembers = complete(header + "name.upp"); + assertTrue("expected name.upper among " + nameMembers, nameMembers + .contains("name.upper")); + assertTrue(complete(header + "name.toUpp").isEmpty()); // Outputs are known too, even before the script assigns them. - assertTrue(complete(header + "greeting.len").contains("greeting.length")); - // Primitive parameters complete as their wrapper type. - assertTrue(complete(header + "count.intV").contains("count.intValue")); + assertTrue(complete(header + "greeting.st").contains("greeting.strip")); + // Numbers are int, not java.lang.Integer. + assertFalse(complete(header + "count.intV").contains("count.intValue")); } @Test diff --git a/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java index 9b93d1b..eb9fe11 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java @@ -120,16 +120,21 @@ public void testDeclarations() { final JythonDialect dialect = new JythonDialect(); assertEquals("img = net.imglib2.img.Img()", dialect.declare("img", "net.imglib2.img.Img")); - assertEquals("n = java.lang.Integer()", dialect.declare("n", dialect - .runtimeType(int.class))); + // As Jython converts them: e.g. strings and numbers to Python's. + assertEquals("str", dialect.runtimeType(String.class)); + assertEquals("int", dialect.runtimeType(int.class)); + assertEquals("int", dialect.runtimeType(Long.class)); + assertEquals("float", dialect.runtimeType(double.class)); + assertEquals("bool", dialect.runtimeType(boolean.class)); + assertEquals("array.array", dialect.runtimeType(double[].class)); + assertEquals("java.math.BigDecimal", dialect.runtimeType( + java.math.BigDecimal.class)); + assertEquals("n = int()", dialect.declare("n", "int")); assertEquals("e = java.util.Map.Entry()", dialect.declare("e", "java.util.Map$Entry")); - assertEquals("a = list()", dialect.declare("a", dialect.runtimeType( - double[].class))); - assertEquals(Arrays.asList("import java.lang", "import java.util", + assertEquals(Arrays.asList("import array", "import java.util", "import net.imglib2.img"), dialect.imports(Arrays.asList( - "net.imglib2.img.Img", "java.lang.String", "java.util.Map$Entry", - "[D"))); + "net.imglib2.img.Img", "str", "java.util.Map$Entry", "array.array"))); assertTrue(JythonServerPlugin.isJython(jython)); } } From 4dd6ae9357f4ede5ffc791e71568164bb1690a61 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Fri, 9 Oct 2026 13:28:28 -0500 Subject: [PATCH 18/29] Name what the declarations import, so it is not completed E.g. a package imported only to declare a parameter's type is no longer offered (see ScriptDialect#imported). Co-Authored-By: Claude Opus 5.5 --- .../scijava/plugins/scripting/jython/JythonDialect.java | 7 +++++++ .../plugins/scripting/jython/JythonCompletionTest.java | 2 ++ .../scripting/jython/JythonServerCompletionTest.java | 1 + 3 files changed, 10 insertions(+) diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java index 4ca81dc..8bf1aae 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java @@ -78,6 +78,13 @@ public List imports(final Collection types) { return imports; } + @Override + public String imported(final String statement) { + // NB: "import a.b" binds a. + return statement.startsWith("import ") ? statement.substring(7).split( + "[.\\s]")[0] : null; + } + /** * The packages or modules of the given types that a declaration refers to * (none for builtins, e.g. {@code str}). diff --git a/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java index 93cfda4..ef1f758 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java @@ -150,6 +150,8 @@ public void testScriptParameters() { assertTrue(complete(header + "greeting.st").contains("greeting.strip")); // Numbers are int, not java.lang.Integer. assertFalse(complete(header + "count.intV").contains("count.intValue")); + // Packages imported only to declare those are not completed. + assertFalse(complete(header + "j").contains("java.lang")); } @Test diff --git a/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java index eb9fe11..cfe711b 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java @@ -135,6 +135,7 @@ public void testDeclarations() { assertEquals(Arrays.asList("import array", "import java.util", "import net.imglib2.img"), dialect.imports(Arrays.asList( "net.imglib2.img.Img", "str", "java.util.Map$Entry", "array.array"))); + assertEquals("net", dialect.imported("import net.imglib2.img")); assertTrue(JythonServerPlugin.isJython(jython)); } } From e58e4a6e033bc9161bc729543f8d3dfa8e3f4077 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Fri, 9 Oct 2026 15:50:50 -0500 Subject: [PATCH 19/29] Follow ScriptDialect and ScriptDocument into code-lsp's core package Co-Authored-By: Claude Opus 5.5 --- .../org/scijava/plugins/scripting/jython/JythonDialect.java | 2 +- .../scijava/plugins/scripting/jython/JythonServerPlugin.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java index 8bf1aae..c974124 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java @@ -35,7 +35,7 @@ import java.util.Set; import java.util.TreeSet; -import org.scijava.code.lsp.script.ScriptDialect; +import org.scijava.code.lsp.ScriptDialect; /** * How Jython scripts see their script parameters: as the Python values Jython diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonServerPlugin.java b/src/main/java/org/scijava/plugins/scripting/jython/JythonServerPlugin.java index 2e307d1..bb0b4f6 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonServerPlugin.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonServerPlugin.java @@ -35,7 +35,7 @@ import org.scijava.code.lsp.LanguageServerService; import org.scijava.code.lsp.Environment; import org.scijava.code.lsp.LanguageServerPlugin; -import org.scijava.code.lsp.script.ScriptDialect; +import org.scijava.code.lsp.ScriptDialect; import org.scijava.plugin.Parameter; import org.scijava.plugin.Plugin; import org.scijava.script.ScriptLanguage; From fca039d28b4db2a1b0e60cd1d12dc72220f0ebe9 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Thu, 15 Oct 2026 17:57:40 -0500 Subject: [PATCH 20/29] Gather completion into its package; get jedi from code-lsp-jedi The completion classes move to the completion package, with their tests. The Python server beside Jython's analysis now comes from scijava-code-lsp-jedi (a runtime dependency), rather than from scripting-appose-python; tests run without it, so that they need not build its environment. Also follow code-lsp's API: the plugin serves the jython LSP language (not python, which it builds on), and the companion asks whether python is served rather than catching its absence. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 ++++--- pom.xml | 26 +++++++++++++++++++ .../ClassDotAutocompletions.java | 2 +- .../{ => completion}/CompletionText.java | 2 +- .../ConstructorAutocompletions.java | 2 +- .../DefVarDotAutocompletions.java | 2 +- .../{ => completion}/DotAutocompletions.java | 2 +- .../EmptyDotAutocompletions.java | 2 +- .../jython/{ => completion}/JavaStubs.java | 2 +- .../JythonAutoCompletions.java | 2 +- .../jython/{ => completion}/JythonDev.java | 2 +- .../{ => completion}/JythonDialect.java | 2 +- .../{ => completion}/JythonImportFormat.java | 2 +- .../{ => completion}/JythonImports.java | 2 +- .../JythonLanguageServer.java | 26 ++++++++++--------- .../{ => completion}/JythonScriptParser.java | 2 +- .../{ => completion}/JythonServerPlugin.java | 12 +++++++-- .../{ => completion}/PythonCompanion.java | 19 +++++++------- .../jython/{ => completion}/Scope.java | 2 +- .../StaticDotAutocompletions.java | 2 +- .../VarDotAutocompletions.java | 2 +- .../{ => completion}/JavaStubsTest.java | 5 ++-- .../JythonCompletionTest.java | 2 +- .../JythonServerCompletionTest.java | 7 ++--- 24 files changed, 88 insertions(+), 50 deletions(-) rename src/main/java/org/scijava/plugins/scripting/jython/{ => completion}/ClassDotAutocompletions.java (98%) rename src/main/java/org/scijava/plugins/scripting/jython/{ => completion}/CompletionText.java (98%) rename src/main/java/org/scijava/plugins/scripting/jython/{ => completion}/ConstructorAutocompletions.java (97%) rename src/main/java/org/scijava/plugins/scripting/jython/{ => completion}/DefVarDotAutocompletions.java (97%) rename src/main/java/org/scijava/plugins/scripting/jython/{ => completion}/DotAutocompletions.java (98%) rename src/main/java/org/scijava/plugins/scripting/jython/{ => completion}/EmptyDotAutocompletions.java (96%) rename src/main/java/org/scijava/plugins/scripting/jython/{ => completion}/JavaStubs.java (99%) rename src/main/java/org/scijava/plugins/scripting/jython/{ => completion}/JythonAutoCompletions.java (99%) rename src/main/java/org/scijava/plugins/scripting/jython/{ => completion}/JythonDev.java (97%) rename src/main/java/org/scijava/plugins/scripting/jython/{ => completion}/JythonDialect.java (98%) rename src/main/java/org/scijava/plugins/scripting/jython/{ => completion}/JythonImportFormat.java (97%) rename src/main/java/org/scijava/plugins/scripting/jython/{ => completion}/JythonImports.java (99%) rename src/main/java/org/scijava/plugins/scripting/jython/{ => completion}/JythonLanguageServer.java (94%) rename src/main/java/org/scijava/plugins/scripting/jython/{ => completion}/JythonScriptParser.java (99%) rename src/main/java/org/scijava/plugins/scripting/jython/{ => completion}/JythonServerPlugin.java (91%) rename src/main/java/org/scijava/plugins/scripting/jython/{ => completion}/PythonCompanion.java (93%) rename src/main/java/org/scijava/plugins/scripting/jython/{ => completion}/Scope.java (99%) rename src/main/java/org/scijava/plugins/scripting/jython/{ => completion}/StaticDotAutocompletions.java (98%) rename src/main/java/org/scijava/plugins/scripting/jython/{ => completion}/VarDotAutocompletions.java (97%) rename src/test/java/org/scijava/plugins/scripting/jython/{ => completion}/JavaStubsTest.java (97%) rename src/test/java/org/scijava/plugins/scripting/jython/{ => completion}/JythonCompletionTest.java (99%) rename src/test/java/org/scijava/plugins/scripting/jython/{ => completion}/JythonServerCompletionTest.java (96%) diff --git a/README.md b/README.md index b8f4538..fdbb6e6 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,11 @@ members and constructors (with their overloads), imports, the script's `#@` parameters and, in a live interpreter, its variables. This is Jython's own completion, which knows Java exactly. -If a Python language server is available (e.g. jedi-language-server, as -offered by the `appose-python` scripting plugin), its completions are added -too: for Python modules, and for Java classes, from Python type stubs -(`.pyi`) of the Java packages the script imports, written in the background +A Python language server (jedi-language-server, offered by +`scijava-code-lsp-jedi`, which runs it in a small environment of its own, +built the first time it is needed) adds its completions too: for Python +modules, and for Java classes, from Python type stubs (`.pyi`) of the Java +packages the script imports, written in the background to `~/.cache/scijava/jython-stubs` (or `-Dscijava.jython.stubs=`). The server analyzes the script as CPython (Jython's standard library is Python 2's, but mostly the same); so it is asked for completions only, not for diff --git a/pom.xml b/pom.xml index d15ced0..c474126 100644 --- a/pom.xml +++ b/pom.xml @@ -86,6 +86,7 @@ 2.101.0 0.1.0-SNAPSHOT + 0.1.0-SNAPSHOT 2.14.0 @@ -108,6 +109,13 @@ scijava-code-lsp ${scijava-code-lsp.version} + + org.scijava + scijava-code-lsp-jedi + ${scijava-code-lsp-jedi.version} + + runtime + @@ -127,4 +135,22 @@ test + + + + + maven-surefire-plugin + + + + org.scijava:scijava-code-lsp-jedi + + + + + diff --git a/src/main/java/org/scijava/plugins/scripting/jython/ClassDotAutocompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/ClassDotAutocompletions.java similarity index 98% rename from src/main/java/org/scijava/plugins/scripting/jython/ClassDotAutocompletions.java rename to src/main/java/org/scijava/plugins/scripting/jython/completion/ClassDotAutocompletions.java index 776e653..876a699 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/ClassDotAutocompletions.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/completion/ClassDotAutocompletions.java @@ -26,7 +26,7 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.plugins.scripting.jython; +package org.scijava.plugins.scripting.jython.completion; import java.util.ArrayList; import java.util.List; diff --git a/src/main/java/org/scijava/plugins/scripting/jython/CompletionText.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/CompletionText.java similarity index 98% rename from src/main/java/org/scijava/plugins/scripting/jython/CompletionText.java rename to src/main/java/org/scijava/plugins/scripting/jython/completion/CompletionText.java index ee8c4e7..bca90b9 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/CompletionText.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/completion/CompletionText.java @@ -26,7 +26,7 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.plugins.scripting.jython; +package org.scijava.plugins.scripting.jython.completion; import java.lang.reflect.Constructor; import java.lang.reflect.Field; diff --git a/src/main/java/org/scijava/plugins/scripting/jython/ConstructorAutocompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/ConstructorAutocompletions.java similarity index 97% rename from src/main/java/org/scijava/plugins/scripting/jython/ConstructorAutocompletions.java rename to src/main/java/org/scijava/plugins/scripting/jython/completion/ConstructorAutocompletions.java index ad7bace..0dd91c5 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/ConstructorAutocompletions.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/completion/ConstructorAutocompletions.java @@ -26,7 +26,7 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.plugins.scripting.jython; +package org.scijava.plugins.scripting.jython.completion; import java.util.Arrays; import java.util.List; diff --git a/src/main/java/org/scijava/plugins/scripting/jython/DefVarDotAutocompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/DefVarDotAutocompletions.java similarity index 97% rename from src/main/java/org/scijava/plugins/scripting/jython/DefVarDotAutocompletions.java rename to src/main/java/org/scijava/plugins/scripting/jython/completion/DefVarDotAutocompletions.java index 81a3805..d9a206c 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/DefVarDotAutocompletions.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/completion/DefVarDotAutocompletions.java @@ -26,7 +26,7 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.plugins.scripting.jython; +package org.scijava.plugins.scripting.jython.completion; import java.util.List; diff --git a/src/main/java/org/scijava/plugins/scripting/jython/DotAutocompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/DotAutocompletions.java similarity index 98% rename from src/main/java/org/scijava/plugins/scripting/jython/DotAutocompletions.java rename to src/main/java/org/scijava/plugins/scripting/jython/completion/DotAutocompletions.java index 4148d6b..82f3d62 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/DotAutocompletions.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/completion/DotAutocompletions.java @@ -26,7 +26,7 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.plugins.scripting.jython; +package org.scijava.plugins.scripting.jython.completion; import java.lang.reflect.Field; import java.lang.reflect.Method; diff --git a/src/main/java/org/scijava/plugins/scripting/jython/EmptyDotAutocompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/EmptyDotAutocompletions.java similarity index 96% rename from src/main/java/org/scijava/plugins/scripting/jython/EmptyDotAutocompletions.java rename to src/main/java/org/scijava/plugins/scripting/jython/completion/EmptyDotAutocompletions.java index eea9277..78b5885 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/EmptyDotAutocompletions.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/completion/EmptyDotAutocompletions.java @@ -26,7 +26,7 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.plugins.scripting.jython; +package org.scijava.plugins.scripting.jython.completion; import java.util.Collections; import java.util.List; diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JavaStubs.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/JavaStubs.java similarity index 99% rename from src/main/java/org/scijava/plugins/scripting/jython/JavaStubs.java rename to src/main/java/org/scijava/plugins/scripting/jython/completion/JavaStubs.java index 3960ca6..ad44746 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JavaStubs.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/completion/JavaStubs.java @@ -26,7 +26,7 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.plugins.scripting.jython; +package org.scijava.plugins.scripting.jython.completion; import java.io.File; import java.io.IOException; diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonAutoCompletions.java similarity index 99% rename from src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java rename to src/main/java/org/scijava/plugins/scripting/jython/completion/JythonAutoCompletions.java index ca9eb46..7a7dc57 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonAutoCompletions.java @@ -26,7 +26,7 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.plugins.scripting.jython; +package org.scijava.plugins.scripting.jython.completion; import java.io.File; import java.lang.reflect.Parameter; diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonDev.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDev.java similarity index 97% rename from src/main/java/org/scijava/plugins/scripting/jython/JythonDev.java rename to src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDev.java index ca8d306..4b8f6d3 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonDev.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDev.java @@ -26,7 +26,7 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.plugins.scripting.jython; +package org.scijava.plugins.scripting.jython.completion; public class JythonDev { diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDialect.java similarity index 98% rename from src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java rename to src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDialect.java index c974124..c03a5e0 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonDialect.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDialect.java @@ -26,7 +26,7 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.plugins.scripting.jython; +package org.scijava.plugins.scripting.jython.completion; import java.math.BigInteger; import java.util.ArrayList; diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonImportFormat.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonImportFormat.java similarity index 97% rename from src/main/java/org/scijava/plugins/scripting/jython/JythonImportFormat.java rename to src/main/java/org/scijava/plugins/scripting/jython/completion/JythonImportFormat.java index 1560a1e..d26b61c 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonImportFormat.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonImportFormat.java @@ -26,7 +26,7 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.plugins.scripting.jython; +package org.scijava.plugins.scripting.jython.completion; /** * Formats fully qualified class names as Jython import statements. diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonImports.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonImports.java similarity index 99% rename from src/main/java/org/scijava/plugins/scripting/jython/JythonImports.java rename to src/main/java/org/scijava/plugins/scripting/jython/completion/JythonImports.java index f4599d9..0632093 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonImports.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonImports.java @@ -26,7 +26,7 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.plugins.scripting.jython; +package org.scijava.plugins.scripting.jython.completion; import java.util.HashMap; import java.util.regex.Matcher; diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonLanguageServer.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonLanguageServer.java similarity index 94% rename from src/main/java/org/scijava/plugins/scripting/jython/JythonLanguageServer.java rename to src/main/java/org/scijava/plugins/scripting/jython/completion/JythonLanguageServer.java index 1e9ec67..cb7ac4b 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonLanguageServer.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonLanguageServer.java @@ -26,7 +26,7 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.plugins.scripting.jython; +package org.scijava.plugins.scripting.jython.completion; import java.util.ArrayList; import java.util.Collections; @@ -60,7 +60,7 @@ import org.scijava.code.lsp.jvm.ClassIndex; import org.scijava.code.lsp.jvm.Callable; import org.scijava.code.lsp.Environment; -import org.scijava.code.lsp.jvm.ScriptLanguageServer; +import org.scijava.code.lsp.jvm.AbstractLanguageServer; import org.scijava.code.lsp.jvm.Signatures; /** @@ -80,7 +80,7 @@ * @author Curtis Rueden * @author Gabriel Selzer */ -public class JythonLanguageServer extends ScriptLanguageServer { +public class JythonLanguageServer extends AbstractLanguageServer { /** * How scripts see their parameters: as the Java objects themselves, @@ -178,7 +178,7 @@ public CompletableFuture shutdown() { return super.shutdown(); } - // -- ScriptLanguageServer -- + // -- AbstractLanguageServer -- @Override protected CompletionList complete(final Document doc, final int caret) { @@ -189,9 +189,9 @@ protected CompletionList complete(final Document doc, final int caret) { } final String alreadyEntered = alreadyEnteredText(lastLine); final int replaceStart = caret - alreadyEntered.length(); - final JythonAutoCompletions.Result result = completionsAt(text, caret); + final JythonAutoCompletions.Result result = completionsAt(doc, caret); final List items = new ArrayList<>(); - if (alreadyEntered.isEmpty()) items.addAll(argumentChoices(text, caret)); + if (alreadyEntered.isEmpty()) items.addAll(argumentChoices(doc, caret)); items.addAll(result.completions); return list(doc, replaceStart, caret, items); } @@ -200,7 +200,7 @@ protected CompletionList complete(final Document doc, final int caret) { protected SignatureHelp signatureHelp(final Document doc, final int caret) { final String text = doc.text(); if (doc.lineTo(caret).trim().startsWith("#")) return null; - final Call call = call(text, caret); + final Call call = call(doc, caret); if (call == null || call.callables.isEmpty()) return null; // The types of the arguments before the caret's (which is being typed). @@ -234,7 +234,8 @@ private Call(final JythonAutoCompletions.Result result, } /** Finds the call around the caret, or returns null if none. */ - private Call call(final String text, final int caret) { + private Call call(final Document doc, final int caret) { + final String text = doc.text(); final int open = Signatures.callStart(text, caret); if (open < 0) return null; final List args = Signatures.arguments(text, open, caret); @@ -253,7 +254,7 @@ private Call call(final String text, final int caret) { List callables = Collections.emptyList(); for (final int at : new int[] { open, open + 1 }) { final String name = at == open ? callee : simpleName; - result = completionsAt(text, at); + result = completionsAt(doc, at); callables = new ArrayList<>(); for (final CompletionItem item : result.completions) { final Callable c = Callable.of(item); @@ -271,10 +272,10 @@ private Call call(final String text, final int caret) { * In a call's arguments, with nothing typed: the variables of the * parameter's type (the first preselected). */ - private List argumentChoices(final String text, + private List argumentChoices(final Document doc, final int caret) { - final Call call = call(text, caret); + final Call call = call(doc, caret); if (call == null || call.result.variablesOfType == null) { return Collections.emptyList(); } @@ -297,9 +298,10 @@ private List argumentChoices(final String text, } /** Computes completions as if the caret were at the given offset. */ - private JythonAutoCompletions.Result completionsAt(final String text, + private JythonAutoCompletions.Result completionsAt(final Document doc, final int offset) { + final String text = doc.text(); final String textToCaret = text.substring(0, Math.min(offset, text .length())); final String lastLine = textToCaret.substring(textToCaret.lastIndexOf( diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonScriptParser.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonScriptParser.java similarity index 99% rename from src/main/java/org/scijava/plugins/scripting/jython/JythonScriptParser.java rename to src/main/java/org/scijava/plugins/scripting/jython/completion/JythonScriptParser.java index 5a792b9..34c4e73 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonScriptParser.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonScriptParser.java @@ -26,7 +26,7 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.plugins.scripting.jython; +package org.scijava.plugins.scripting.jython.completion; import java.lang.reflect.Method; import java.util.ArrayList; diff --git a/src/main/java/org/scijava/plugins/scripting/jython/JythonServerPlugin.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonServerPlugin.java similarity index 91% rename from src/main/java/org/scijava/plugins/scripting/jython/JythonServerPlugin.java rename to src/main/java/org/scijava/plugins/scripting/jython/completion/JythonServerPlugin.java index bb0b4f6..dfd2447 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/JythonServerPlugin.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonServerPlugin.java @@ -26,7 +26,7 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.plugins.scripting.jython; +package org.scijava.plugins.scripting.jython.completion; import java.io.File; @@ -63,7 +63,15 @@ public boolean supports(final ScriptLanguage language) { } @Override - public LanguageServer create(final Environment environment) { + public boolean supports(final String languageId) { + // NB: Not "python", which this builds on (see PythonCompanion). + return "jython".equals(languageId); + } + + @Override + public LanguageServer create(final String languageId, + final Environment environment) + { return new JythonLanguageServer(environment, PythonCompanion.launch( servers, stubs())); } diff --git a/src/main/java/org/scijava/plugins/scripting/jython/PythonCompanion.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/PythonCompanion.java similarity index 93% rename from src/main/java/org/scijava/plugins/scripting/jython/PythonCompanion.java rename to src/main/java/org/scijava/plugins/scripting/jython/completion/PythonCompanion.java index 1f32064..b2142d1 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/PythonCompanion.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/completion/PythonCompanion.java @@ -26,7 +26,7 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.plugins.scripting.jython; +package org.scijava.plugins.scripting.jython.completion; import java.util.ArrayList; import java.util.Collections; @@ -56,12 +56,11 @@ import org.scijava.code.lsp.LanguageServerService; /** - * A Python language server (e.g. jedi-language-server, if the - * {@code appose-python} scripting plugin offers one) beside Jython's own - * analysis (see {@link JythonLanguageServer}): for Python modules, and for - * Java classes, from Python stubs of the Java packages a script imports - * (including those of its parameters' types, declared in its text; see - * {@link JavaStubs}, written in the background). + * A Python language server (e.g. jedi-language-server, as code-lsp-jedi + * offers) beside Jython's own analysis (see {@link JythonLanguageServer}): + * for Python modules, and for Java classes, from Python stubs of the Java + * packages a script imports (including those of its parameters' types, + * declared in its text; see {@link JavaStubs}, written in the background). *

* The server analyzes scripts as CPython (Jython's standard library is Python * 2's, but mostly the same): so its completions follow Jython's own, it is @@ -94,9 +93,9 @@ private PythonCompanion(final LanguageServer python, final JavaStubs stubs) { static PythonCompanion launch(final LanguageServerService servers, final JavaStubs stubs) { - if (servers == null) return null; - final LanguageServer python = servers.launch("python", environment(stubs)); - return python == null ? null : new PythonCompanion(python, stubs); + if (servers == null || !servers.supports("python")) return null; + return new PythonCompanion(servers.launch("python", environment(stubs)), + stubs); } /** Where the server runs: its own Python, searching the Java stubs. */ diff --git a/src/main/java/org/scijava/plugins/scripting/jython/Scope.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/Scope.java similarity index 99% rename from src/main/java/org/scijava/plugins/scripting/jython/Scope.java rename to src/main/java/org/scijava/plugins/scripting/jython/completion/Scope.java index d83a041..b5db456 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/Scope.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/completion/Scope.java @@ -26,7 +26,7 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.plugins.scripting.jython; +package org.scijava.plugins.scripting.jython.completion; import java.io.File; import java.nio.file.FileSystems; diff --git a/src/main/java/org/scijava/plugins/scripting/jython/StaticDotAutocompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/StaticDotAutocompletions.java similarity index 98% rename from src/main/java/org/scijava/plugins/scripting/jython/StaticDotAutocompletions.java rename to src/main/java/org/scijava/plugins/scripting/jython/completion/StaticDotAutocompletions.java index ec82aca..cb3a19e 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/StaticDotAutocompletions.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/completion/StaticDotAutocompletions.java @@ -26,7 +26,7 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.plugins.scripting.jython; +package org.scijava.plugins.scripting.jython.completion; import java.util.ArrayList; import java.util.Arrays; diff --git a/src/main/java/org/scijava/plugins/scripting/jython/VarDotAutocompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/VarDotAutocompletions.java similarity index 97% rename from src/main/java/org/scijava/plugins/scripting/jython/VarDotAutocompletions.java rename to src/main/java/org/scijava/plugins/scripting/jython/completion/VarDotAutocompletions.java index 6d89cf9..60a0a0c 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/VarDotAutocompletions.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/completion/VarDotAutocompletions.java @@ -26,7 +26,7 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.plugins.scripting.jython; +package org.scijava.plugins.scripting.jython.completion; import java.util.ArrayList; import java.util.Arrays; diff --git a/src/test/java/org/scijava/plugins/scripting/jython/JavaStubsTest.java b/src/test/java/org/scijava/plugins/scripting/jython/completion/JavaStubsTest.java similarity index 97% rename from src/test/java/org/scijava/plugins/scripting/jython/JavaStubsTest.java rename to src/test/java/org/scijava/plugins/scripting/jython/completion/JavaStubsTest.java index 3fecd83..c073172 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/JavaStubsTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/completion/JavaStubsTest.java @@ -26,7 +26,7 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.plugins.scripting.jython; +package org.scijava.plugins.scripting.jython.completion; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -53,7 +53,8 @@ */ public class JavaStubsTest { - private static final String PKG = "org.scijava.plugins.scripting.jython"; + private static final String PKG = + "org.scijava.plugins.scripting.jython.completion"; /** Lists just the example classes. */ private static final Function> CLASSES = pkg -> PKG diff --git a/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonCompletionTest.java similarity index 99% rename from src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java rename to src/test/java/org/scijava/plugins/scripting/jython/completion/JythonCompletionTest.java index ef1f758..df55486 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonCompletionTest.java @@ -26,7 +26,7 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.plugins.scripting.jython; +package org.scijava.plugins.scripting.jython.completion; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; diff --git a/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonServerCompletionTest.java similarity index 96% rename from src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java rename to src/test/java/org/scijava/plugins/scripting/jython/completion/JythonServerCompletionTest.java index cfe711b..3b4b7ec 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/JythonServerCompletionTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonServerCompletionTest.java @@ -26,7 +26,7 @@ * POSSIBILITY OF SUCH DAMAGE. * #L% */ -package org.scijava.plugins.scripting.jython; +package org.scijava.plugins.scripting.jython.completion; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; @@ -81,8 +81,9 @@ public void tearDown() { @Test public void testOnlyWithAPythonServer() { - // NB: No plugin offers a Python server here (scripting-appose-python - // does): Jython is served by its own analysis alone. + // NB: No plugin offers a Python server here (code-lsp-jedi does, but is + // left out of the tests; see pom.xml): Jython is served by its own + // analysis alone. assertNull(PythonCompanion.launch(context.service( LanguageServerService.class), stubs)); } From ae2780454dbb77c1eb894e1a0c64c3cccd7f4a12 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Fri, 16 Oct 2026 00:26:50 -0500 Subject: [PATCH 21/29] Serve the script language by its name, as code-lsp now asks code-lsp's sessions ask for the LSP language named as the script's language, in lower case, so the plugin no longer declares the script language it serves separately. Co-Authored-By: Claude Opus 5.5 --- .../jython/completion/JythonServerPlugin.java | 15 ++------------- .../completion/JythonServerCompletionTest.java | 1 - 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonServerPlugin.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonServerPlugin.java index dfd2447..012fb98 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonServerPlugin.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonServerPlugin.java @@ -38,7 +38,6 @@ import org.scijava.code.lsp.ScriptDialect; import org.scijava.plugin.Parameter; import org.scijava.plugin.Plugin; -import org.scijava.script.ScriptLanguage; /** * Offers Jython's in-process language server (see @@ -57,14 +56,10 @@ public class JythonServerPlugin implements LanguageServerPlugin { private JavaStubs stubs; - @Override - public boolean supports(final ScriptLanguage language) { - return isJython(language); - } - @Override public boolean supports(final String languageId) { - // NB: Not "python", which this builds on (see PythonCompanion). + // NB: Jython scripts too, by their language's name. Not "python", which + // this builds on (see PythonCompanion). return "jython".equals(languageId); } @@ -97,10 +92,4 @@ synchronized JavaStubs stubs() { return stubs; } - /** Matches "Jython" and "Python (Jython)", without claiming CPython. */ - static boolean isJython(final ScriptLanguage language) { - if (language == null) return false; - final String name = language.getLanguageName(); - return name != null && name.toLowerCase().contains("jython"); - } } diff --git a/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonServerCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonServerCompletionTest.java index 3b4b7ec..46a2437 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonServerCompletionTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonServerCompletionTest.java @@ -137,6 +137,5 @@ public void testDeclarations() { "import net.imglib2.img"), dialect.imports(Arrays.asList( "net.imglib2.img.Img", "str", "java.util.Map$Entry", "array.array"))); assertEquals("net", dialect.imported("import net.imglib2.img")); - assertTrue(JythonServerPlugin.isJython(jython)); } } From bb210170ba1690f8edfa261248c3d51fca488558 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Fri, 16 Oct 2026 10:26:21 -0500 Subject: [PATCH 22/29] Offer only a Jython dialect: the server moves to code-lsp-jython JythonDialect is now a ScriptDialect plugin, showing Jython scripts to scijava-code-lsp-jython's server as plain Jython. Jackson is pinned to 2.19.2, which code-lsp-jedi now brings in. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- pom.xml | 20 +- .../completion/ClassDotAutocompletions.java | 94 --- .../jython/completion/CompletionText.java | 128 ---- .../ConstructorAutocompletions.java | 51 -- .../completion/DefVarDotAutocompletions.java | 56 -- .../jython/completion/DotAutocompletions.java | 123 ---- .../completion/EmptyDotAutocompletions.java | 45 -- .../jython/completion/JavaStubs.java | 461 -------------- .../completion/JythonAutoCompletions.java | 563 ------------------ .../jython/completion/JythonDev.java | 65 -- .../jython/completion/JythonDialect.java | 20 +- .../jython/completion/JythonImportFormat.java | 51 -- .../jython/completion/JythonImports.java | 168 ------ .../completion/JythonLanguageServer.java | 371 ------------ .../jython/completion/JythonScriptParser.java | 503 ---------------- .../jython/completion/JythonServerPlugin.java | 95 --- .../jython/completion/PythonCompanion.java | 258 -------- .../scripting/jython/completion/Scope.java | 346 ----------- .../completion/StaticDotAutocompletions.java | 105 ---- .../completion/VarDotAutocompletions.java | 77 --- .../jython/completion/JavaStubsTest.java | 198 ------ ...letionTest.java => JythonDialectTest.java} | 64 +- 23 files changed, 39 insertions(+), 3827 deletions(-) delete mode 100644 src/main/java/org/scijava/plugins/scripting/jython/completion/ClassDotAutocompletions.java delete mode 100644 src/main/java/org/scijava/plugins/scripting/jython/completion/CompletionText.java delete mode 100644 src/main/java/org/scijava/plugins/scripting/jython/completion/ConstructorAutocompletions.java delete mode 100644 src/main/java/org/scijava/plugins/scripting/jython/completion/DefVarDotAutocompletions.java delete mode 100644 src/main/java/org/scijava/plugins/scripting/jython/completion/DotAutocompletions.java delete mode 100644 src/main/java/org/scijava/plugins/scripting/jython/completion/EmptyDotAutocompletions.java delete mode 100644 src/main/java/org/scijava/plugins/scripting/jython/completion/JavaStubs.java delete mode 100644 src/main/java/org/scijava/plugins/scripting/jython/completion/JythonAutoCompletions.java delete mode 100644 src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDev.java delete mode 100644 src/main/java/org/scijava/plugins/scripting/jython/completion/JythonImportFormat.java delete mode 100644 src/main/java/org/scijava/plugins/scripting/jython/completion/JythonImports.java delete mode 100644 src/main/java/org/scijava/plugins/scripting/jython/completion/JythonLanguageServer.java delete mode 100644 src/main/java/org/scijava/plugins/scripting/jython/completion/JythonScriptParser.java delete mode 100644 src/main/java/org/scijava/plugins/scripting/jython/completion/JythonServerPlugin.java delete mode 100644 src/main/java/org/scijava/plugins/scripting/jython/completion/PythonCompanion.java delete mode 100644 src/main/java/org/scijava/plugins/scripting/jython/completion/Scope.java delete mode 100644 src/main/java/org/scijava/plugins/scripting/jython/completion/StaticDotAutocompletions.java delete mode 100644 src/main/java/org/scijava/plugins/scripting/jython/completion/VarDotAutocompletions.java delete mode 100644 src/test/java/org/scijava/plugins/scripting/jython/completion/JavaStubsTest.java rename src/test/java/org/scijava/plugins/scripting/jython/completion/{JythonServerCompletionTest.java => JythonDialectTest.java} (59%) diff --git a/README.md b/README.md index fdbb6e6..5005c2a 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,9 @@ lines of script code based on SciJava events. The Script Editor completes code in Jython scripts: Java classes, their members and constructors (with their overloads), imports, the script's `#@` parameters and, in a live interpreter, its variables. This is Jython's own -completion, which knows Java exactly. +completion, which knows Java exactly, by `scijava-code-lsp-jython`'s language +server; this plugin shows it the scripts as plain Jython (`JythonDialect`), +their parameters declared. A Python language server (jedi-language-server, offered by `scijava-code-lsp-jedi`, which runs it in a small environment of its own, diff --git a/pom.xml b/pom.xml index c474126..b8f0201 100644 --- a/pom.xml +++ b/pom.xml @@ -86,9 +86,11 @@ 2.101.0 0.1.0-SNAPSHOT - 0.1.0-SNAPSHOT + 0.1.0-SNAPSHOT 2.14.0 + + 2.19.2 bsd_2 SciJava developers. @@ -111,8 +113,8 @@ org.scijava - scijava-code-lsp-jedi - ${scijava-code-lsp-jedi.version} + scijava-code-lsp-jython + ${scijava-code-lsp-jython.version} runtime @@ -122,11 +124,6 @@ org.python jython-slim - - - org.antlr - antlr-runtime - @@ -142,9 +139,10 @@ maven-surefire-plugin org.scijava:scijava-code-lsp-jedi diff --git a/src/main/java/org/scijava/plugins/scripting/jython/completion/ClassDotAutocompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/ClassDotAutocompletions.java deleted file mode 100644 index 876a699..0000000 --- a/src/main/java/org/scijava/plugins/scripting/jython/completion/ClassDotAutocompletions.java +++ /dev/null @@ -1,94 +0,0 @@ -/*- - * #%L - * Jython language support for SciJava Script Editor. - * %% - * Copyright (C) 2020 - 2024 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.plugins.scripting.jython.completion; - -import java.util.ArrayList; -import java.util.List; - - -public class ClassDotAutocompletions extends DefVarDotAutocompletions { - final List superclassNames; // List of superclasses - final ListdotAutocompletions; // List of class methods and fields - - /** - * - * @param fnName - * @param superclassNames - * @param argumentNames - * @param dotAutocompletions - * @param class_scope - */ - public ClassDotAutocompletions(final String fnName, List superclassNames, - final List argumentNames, final List dotAutocompletions, final Scope class_scope) { - super(fnName, null, argumentNames, class_scope); - this.superclassNames = superclassNames; - this.dotAutocompletions = dotAutocompletions; - } - - @Override - public List get() { - final List ac = new ArrayList<>(this.dotAutocompletions); - for (final String className: this.superclassNames) { - List fm = DotAutocompletions.getPublicFieldsAndMethods(className); - if (fm.isEmpty()) { - // Maybe it's a python class - // get them all via the org.python.indexer.Indexer and Builtins classes, see StaticDotAutocompletions - JythonDev.print("Don't know yet how to handle class " + className); - } - ac.addAll(fm); - } - return ac; - } - - public void put(final CompletionText entry) { - if (this.dotAutocompletions.contains(entry)) return; // list search OK: very low N - this.dotAutocompletions.add(entry); - } - - /** Make this be cda plus its own dotAutocompletions. - * - * @param cda - */ - public void mutateIntoPlus(final ClassDotAutocompletions cda) - { - this.className = cda.className; - this.fnName = cda.fnName; - this.superclassNames.clear(); - this.superclassNames.addAll(cda.superclassNames); - this.dotAutocompletions.addAll(cda.dotAutocompletions); - this.argumentNames.clear(); - this.argumentNames.addAll(cda.argumentNames); - this.scope = cda.scope; - } - - @Override - public String toString() { - return "ClassDotAutocompletions: " + this.fnName + "(" + String.join(", ", this.argumentNames) + ")" + " -- " + String.join(", ", this.get().toString()); - } -} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/completion/CompletionText.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/CompletionText.java deleted file mode 100644 index bca90b9..0000000 --- a/src/main/java/org/scijava/plugins/scripting/jython/completion/CompletionText.java +++ /dev/null @@ -1,128 +0,0 @@ -/*- - * #%L - * JSR-223-compliant Jython scripting language plugin. - * %% - * Copyright (C) 2020 - 2025 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.plugins.scripting.jython.completion; - -import java.lang.reflect.Constructor; -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.lang.reflect.Parameter; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -import org.scijava.code.lsp.jvm.ClassIndex; - -/** - * A mutable suggestion used internally by the Jython completion engine. It holds - * the replacement text plus optional documentation and (for callables) parameter - * and return-type metadata. {@link JythonAutoCompletions} converts these into - * LSP completion items at the boundary. - * - * @author Albert Cardona - */ -public class CompletionText { - - private String replacementText; - private String description; - private String summary; - private List method_args = Collections.emptyList(); - private String method_returnType = null; - - public CompletionText(final String replacementText) { - this(replacementText, (String) null, (String) null); - } - - public CompletionText(final String replacementText, final String summary, - final String description) - { - this.replacementText = replacementText; - this.summary = summary; - this.description = description; - } - - public CompletionText(final String replacementText, final Class c, - final Field f) - { - this(replacementText, ClassIndex.getSummaryCompletion(f, c), null); - } - - public CompletionText(final String replacementText, final Class c, - final Method m) - { - 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, ClassIndex.getSummaryCompletion(constructor, c), - null); - this.method_args = Arrays.asList(constructor.getParameters()); - this.method_returnType = c.getCanonicalName(); - } - - public String getReplacementText() { - return replacementText; - } - - public String getDescription() { - return description; - } - - public String getSummary() { - return summary; - } - - public List getMethodArgs() { - return method_args; - } - - public String getReturnType() { - return method_returnType; - } - - public void setReplacementText(final String replacementText) { - this.replacementText = replacementText; - } - - public void setDescription(final String description) { - this.description = description; - } - - public void setSummary(final String summary) { - this.summary = summary; - } - - @Override - public String toString() { - return replacementText + " | " + description + " | " + summary; - } -} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/completion/ConstructorAutocompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/ConstructorAutocompletions.java deleted file mode 100644 index 0dd91c5..0000000 --- a/src/main/java/org/scijava/plugins/scripting/jython/completion/ConstructorAutocompletions.java +++ /dev/null @@ -1,51 +0,0 @@ -/*- - * #%L - * Jython language support for SciJava Script Editor. - * %% - * Copyright (C) 2020 - 2024 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.plugins.scripting.jython.completion; - -import java.util.Arrays; -import java.util.List; -import java.util.stream.Collectors; - - -public class ConstructorAutocompletions implements DotAutocompletions -{ - private final Class c; - - public ConstructorAutocompletions(final Class c) { - this.c = c; - } - - @Override - public List get() { - return Arrays.asList(this.c.getConstructors()).stream() - .map(cons -> new CompletionText(c.getSimpleName(), c, cons)) - .collect(Collectors.toList()); - } - -} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/completion/DefVarDotAutocompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/DefVarDotAutocompletions.java deleted file mode 100644 index d9a206c..0000000 --- a/src/main/java/org/scijava/plugins/scripting/jython/completion/DefVarDotAutocompletions.java +++ /dev/null @@ -1,56 +0,0 @@ -/*- - * #%L - * Jython language support for SciJava Script Editor. - * %% - * Copyright (C) 2020 - 2024 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.plugins.scripting.jython.completion; - -import java.util.List; - -public class DefVarDotAutocompletions extends VarDotAutocompletions { - - String fnName; - final List argumentNames; - Scope scope; - - public DefVarDotAutocompletions(final String fnName, final String returnClassName, final List argumentNames, final Scope scope) { - super(returnClassName); - this.fnName = fnName; - this.argumentNames = argumentNames; - this.scope = scope; - } - - public List getArgumentNames() { - return this.argumentNames; - } - - @Override - public String toString() { - return "DefVarAutocompletions:" + - " Class: " + super.className + - " Arguments: " + String.join(", ", this.argumentNames); - } -} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/completion/DotAutocompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/DotAutocompletions.java deleted file mode 100644 index 82f3d62..0000000 --- a/src/main/java/org/scijava/plugins/scripting/jython/completion/DotAutocompletions.java +++ /dev/null @@ -1,123 +0,0 @@ -/*- - * #%L - * Jython language support for SciJava Script Editor. - * %% - * Copyright (C) 2020 - 2024 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.plugins.scripting.jython.completion; - -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.stream.Stream; - - -public interface DotAutocompletions { - - static public final DotAutocompletions EMPTY = new EmptyDotAutocompletions(); - - default public String getClassname() { - return null; - } - - default public String getSummary() { - return null; - } - - public List get(); - - default public Stream getStream() { - return get().stream(); - } - - static public List getPublicFieldsAndMethods(final String className) { - final List ac = new ArrayList<>(); - if (null != className) { - try { - final Class c = Class.forName(className); - for (final Field f: c.getFields()) - ac.add(new CompletionText(f.getName(), c, f)); - for (final Method m: c.getMethods()) - ac.add(new CompletionText(m.getName() + "()", c, m)); // TODO could do a parameter-driven autocompletion - } catch (final Exception e) { - JythonDev.print("Could not load class " + className, e); - } - } - return ac; - } - - - /** Collect static fields and static methods from {@code c} into {@code ac}. - * - * @param c - * @param ac - */ - static public void staticFieldsAndStaticMethodsInto(final Class c, final List ac) { - for (final Field f: c.getDeclaredFields()) - if (Modifier.isStatic(f.getModifiers())) - ac.add(new CompletionText(f.getName(), c, f)); - for (final Method m: c.getDeclaredMethods()) - if (Modifier.isStatic(m.getModifiers())) - ac.add(new CompletionText(m.getName() + "()", c, m)); - } - - /** Collect non-static fields and non-static methods of class {@code c} into {@code ac}. - * - * @param c - * @param ac - */ - static public void fieldsAndMethodsInto(final Class c, final List ac) { - for (final Field f: c.getFields()) - if (!Modifier.isStatic(f.getModifiers())) - ac.add(new CompletionText(f.getName(), c, f)); - for (final Method m: c.getMethods()) - if (!Modifier.isStatic(m.getModifiers())) - ac.add(new CompletionText(m.getName() + "()", c, m)); - } - - static public Stream staticFieldsAndStaticMethodsStream(final Class c, final boolean staticFields, final boolean staticMethods) { - return Stream.concat( - Arrays.stream(c.getDeclaredFields()) - .filter(f -> Modifier.isStatic(f.getModifiers())) - .map(f -> new CompletionText(f.getName(), c, f)), - Arrays.stream(c.getDeclaredMethods()) - .filter(m -> Modifier.isStatic(m.getModifiers())) - .map(m -> new CompletionText(m.getName(), c, m))); - } - - static public Stream fieldsAndMethodsStream(final Class c, final boolean staticFields, final boolean staticMethods) { - return Stream.concat( - Arrays.stream(c.getFields()) - .filter(f -> !Modifier.isStatic(f.getModifiers())) - .map(f -> new CompletionText(f.getName(), c, f)), - Arrays.stream(c.getMethods()) - .filter(m -> !Modifier.isStatic(m.getModifiers())) - .map(m -> new CompletionText(m.getName(), c, m))); - } - -} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/completion/EmptyDotAutocompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/EmptyDotAutocompletions.java deleted file mode 100644 index 78b5885..0000000 --- a/src/main/java/org/scijava/plugins/scripting/jython/completion/EmptyDotAutocompletions.java +++ /dev/null @@ -1,45 +0,0 @@ -/*- - * #%L - * Jython language support for SciJava Script Editor. - * %% - * Copyright (C) 2020 - 2024 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.plugins.scripting.jython.completion; - -import java.util.Collections; -import java.util.List; - - -public class EmptyDotAutocompletions implements DotAutocompletions { - @Override - public List get() { - return Collections.emptyList(); - } - - @Override - public String toString() { - return "EMPTY"; - } -} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/completion/JavaStubs.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/JavaStubs.java deleted file mode 100644 index ad44746..0000000 --- a/src/main/java/org/scijava/plugins/scripting/jython/completion/JavaStubs.java +++ /dev/null @@ -1,461 +0,0 @@ -/*- - * #%L - * JSR-223-compliant Jython scripting language plugin. - * %% - * Copyright (C) 2020 - 2025 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.plugins.scripting.jython.completion; - -import java.io.File; -import java.io.IOException; -import java.lang.reflect.Constructor; -import java.lang.reflect.Executable; -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; -import java.lang.reflect.Parameter; -import java.math.BigDecimal; -import java.math.BigInteger; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Comparator; -import java.util.Deque; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.TreeSet; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.function.Function; -import java.util.stream.Collectors; - -import org.scijava.code.lsp.jvm.ClassIndex; - -/** - * Writes Python type stubs ({@code .pyi}) of Java packages, so that a Python - * language server (e.g. jedi-language-server) knows the Java classes a Jython - * script uses: {@code from ij import IJ} then finds {@code ij/__init__.pyi}, - * with {@code IJ}'s methods, their parameters and return types. - *

- * Stubs are written on demand, per package, from the classes themselves (as - * the given class loader loads them), in the background; and so are the - * stubs of the packages their signatures refer to, a few steps on, within a - * limit. Java types become Python ones where Jython converts them (e.g. - * {@code String} becomes {@code str}); overloads become - * {@code typing.overload}s; getters become properties too, as Jython offers - * them. - *

- * - * @author Gabriel Selzer - */ -public class JavaStubs { - - /** How many packages to write stubs for, at most, per session. */ - private static final int MAX_PACKAGES = 400; - - /** How many steps to follow the packages signatures refer to. */ - private static final int DEPTH = 2; - - /** Names Python reserves (and so cannot be members' names in stubs). */ - private static final Set KEYWORDS = new HashSet<>(Arrays.asList( - "False", "None", "True", "and", "as", "assert", "async", "await", "break", - "class", "continue", "def", "del", "elif", "else", "except", "finally", - "for", "from", "global", "if", "import", "in", "is", "lambda", "nonlocal", - "not", "or", "pass", "raise", "return", "try", "while", "with", "yield")); - - private final File dir; - private final ClassLoader loader; - private final Function> classNames; - - /** The packages done (or being done). */ - private final Set done = ConcurrentHashMap.newKeySet(); - - private final ExecutorService writer = Executors.newSingleThreadExecutor( - r -> { - final Thread t = new Thread(r, "Jython-stubs"); - t.setDaemon(true); - return t; - }); - - /** - * @param dir Where to write the stubs. - * @param loader Loads the classes. - */ - public JavaStubs(final File dir, final ClassLoader loader) { - this(dir, loader, JavaStubs::indexedClassNames); - } - - /** - * @param dir Where to write the stubs. - * @param loader Loads the classes. - * @param classNames Lists the (fully qualified) names of the classes in a - * package. - */ - JavaStubs(final File dir, final ClassLoader loader, - final Function> classNames) - { - this.dir = dir; - this.loader = loader; - this.classNames = classNames; - } - - /** Where the stubs are written: a Python search path. */ - public File dir() { - return dir; - } - - /** - * Writes the stubs of the given packages, if not done yet; then, of the - * packages they refer to. Returns at once. - * - * @return Done once the given packages' stubs are written. - */ - public CompletableFuture ensure(final Collection packages) { - final List todo = packages.stream().filter(p -> !done.contains(p)) - .collect(Collectors.toList()); - if (todo.isEmpty()) return CompletableFuture.completedFuture(null); - final CompletableFuture written = new CompletableFuture<>(); - writer.execute(() -> { - final Deque queue = new ArrayDeque<>(todo); - final Map depth = new LinkedHashMap<>(); - for (final String p : todo) depth.put(p, 0); - int first = todo.size(); - while (!queue.isEmpty()) { - final String pkg = queue.poll(); - if (first-- == 0) written.complete(null); - if (done.size() >= MAX_PACKAGES || !done.add(pkg)) continue; - final Set refs = new TreeSet<>(); - try { - write(pkg, stub(pkg, refs)); - } - catch (final IOException | RuntimeException | LinkageError exc) { - // NB: No stubs for this package, then. - } - final int d = depth.get(pkg); - if (d >= DEPTH) continue; - for (final String r : refs) { - if (!done.contains(r) && !depth.containsKey(r)) { - depth.put(r, d + 1); - queue.add(r); - } - } - } - written.complete(null); - }); - return written; - } - - /** - * Gets the Java packages a Jython script imports from, e.g. {@code ij} for - * {@code from ij import IJ}, and {@code java.util} for - * {@code import java.util}. - */ - public static Set imports(final String script) { - final Set packages = new TreeSet<>(); - // NB: Statements on one line are separated by semicolons. - for (final String line : script.split("[\n;]")) { - final String l = line.trim(); - String module = null; - if (l.startsWith("from ")) { - final int imp = l.indexOf(" import"); - module = imp < 0 ? l.substring(5) : l.substring(5, imp); - } - else if (l.startsWith("import ")) { - module = l.substring(7).split("[ ,]")[0]; - } - if (module == null) continue; - module = module.trim(); - if (module.matches("[\\w.]+") && !module.endsWith(".")) { - packages.add(module); - } - } - return packages; - } - - // -- Stub writing -- - - /** Writes a package's stub, and its parents' (empty, if missing). */ - private void write(final String pkg, final String stub) throws IOException { - if (stub == null) return; // NB: Not a Java package. - File d = dir; - for (final String part : pkg.split("\\.")) { - d = new File(d, part); - d.mkdirs(); - final File init = new File(d, "__init__.pyi"); - if (!init.exists()) Files.write(init.toPath(), new byte[0]); - } - final File init = new File(d, "__init__.pyi"); - final byte[] bytes = stub.getBytes(StandardCharsets.UTF_8); - // NB: Unchanged files stay as they are, so the server need not reload. - if (!Arrays.equals(Files.readAllBytes(init.toPath()), bytes)) { - Files.write(init.toPath(), bytes); - } - } - - /** - * Builds the stub of a package, noting the other packages it refers to; - * or returns null if the package has no (public) classes. - */ - String stub(final String pkg, final Set refs) { - final List> classes = new ArrayList<>(); - for (final String name : classNames.apply(pkg)) { - if (name.indexOf('$') >= 0) continue; // NB: Nested ones, in their outer. - try { - final Class c = Class.forName(name, false, loader); - if (Modifier.isPublic(c.getModifiers()) && !c.isSynthetic()) { - classes.add(c); - } - } - catch (final ClassNotFoundException | LinkageError exc) { - // NB: Not loadable here; skip it. - } - } - if (classes.isEmpty()) return null; - classes.sort(Comparator.comparing(Class::getSimpleName)); - final StringBuilder body = new StringBuilder(); - for (final Class c : classes) { - try { - classStub(c, pkg, refs, "", body); - } - catch (final LinkageError | SecurityException exc) { - // NB: Its members refer to classes not loadable here; skip it. - } - } - refs.remove(pkg); - final StringBuilder sb = new StringBuilder("# Stubs of the Java package " + - pkg + ", for code completion. Generated; do not edit.\n"); - sb.append("import typing\n"); - for (final String r : refs) sb.append("import ").append(r).append('\n'); - return sb.append(body).toString(); - } - - private void classStub(final Class c, final String pkg, - final Set refs, final String indent, final StringBuilder sb) - { - final List bases = new ArrayList<>(); - final Class sup = c.getSuperclass(); - if (sup != null && sup != Object.class && Modifier.isPublic(sup - .getModifiers())) bases.add(type(sup, pkg, refs)); - for (final Class i : c.getInterfaces()) { - if (Modifier.isPublic(i.getModifiers())) bases.add(type(i, pkg, refs)); - } - if (bases.isEmpty() && c != Object.class) { - bases.add(type(Object.class, pkg, refs)); - } - sb.append('\n').append(indent).append("class ").append(c.getSimpleName()); - if (!bases.isEmpty()) sb.append('(').append(String.join(", ", bases)) - .append(')'); - sb.append(":\n"); - final String in = indent + " "; - final int length = sb.length(); - final Set names = new HashSet<>(); - - // Fields. - for (final Field f : sorted(c.getDeclaredFields(), Field::getName)) { - if (!Modifier.isPublic(f.getModifiers()) || f.isSynthetic() || !valid(f - .getName())) continue; - names.add(f.getName()); - final String t = type(f.getType(), pkg, refs); - sb.append(in).append(f.getName()).append(": ").append(Modifier.isStatic( - f.getModifiers()) ? "typing.ClassVar[" + t + "]" : t).append('\n'); - } - - // Constructors. - final List> ctors = new ArrayList<>(); - for (final Constructor ctor : c.getDeclaredConstructors()) { - if (Modifier.isPublic(ctor.getModifiers()) && !ctor.isSynthetic()) { - ctors.add(ctor); - } - } - ctors.sort(Comparator.comparing(JavaStubs::signatureKey)); - for (final Constructor ctor : ctors) { - if (ctors.size() > 1) sb.append(in).append("@typing.overload\n"); - sb.append(in).append("def __init__(self").append(parameters(ctor, pkg, - refs)).append(") -> None: ...\n"); - } - - // Methods, by name. - final Map> methods = new LinkedHashMap<>(); - for (final Method m : sorted(c.getDeclaredMethods(), JavaStubs::signatureKey)) { - if (!Modifier.isPublic(m.getModifiers()) || m.isSynthetic() || m - .isBridge() || !valid(m.getName())) continue; - methods.computeIfAbsent(m.getName(), k -> new ArrayList<>()).add(m); - } - for (final Map.Entry> e : methods.entrySet()) { - names.add(e.getKey()); - for (final Method m : e.getValue()) { - final boolean isStatic = Modifier.isStatic(m.getModifiers()); - if (e.getValue().size() > 1) sb.append(in).append( - "@typing.overload\n"); - if (isStatic) sb.append(in).append("@staticmethod\n"); - sb.append(in).append("def ").append(m.getName()).append('(').append( - isStatic ? parameters(m, pkg, refs).replaceFirst("^, ", "") - : "self" + parameters(m, pkg, refs)).append(") -> ").append( - type(m.getReturnType(), pkg, refs)).append(": ...\n"); - } - } - - // Bean properties, as Jython offers them: getTitle() as title. - for (final List overloads : methods.values()) { - for (final Method m : overloads) { - final String property = property(m); - if (property == null || !names.add(property)) continue; - sb.append(in).append(property).append(": ").append(type(m - .getReturnType(), pkg, refs)).append('\n'); - } - } - - // Nested classes. - for (final Class nested : sorted(c.getDeclaredClasses(), - Class::getSimpleName)) - { - if (Modifier.isPublic(nested.getModifiers()) && !nested.isSynthetic() && - valid(nested.getSimpleName()) && names.add(nested.getSimpleName())) - { - classStub(nested, pkg, refs, in, sb); - } - } - if (sb.length() == length) sb.append(in).append("...\n"); - } - - /** Renders the parameters, each preceded by ", ". */ - private String parameters(final Executable e, final String pkg, - final Set refs) - { - final StringBuilder sb = new StringBuilder(); - final Set used = new HashSet<>(); - used.add("self"); - final Parameter[] params = e.getParameters(); - for (int i = 0; i < params.length; i++) { - final Parameter p = params[i]; - String name = p.isNamePresent() ? p.getName() : derivedName(p.getType()); - if (KEYWORDS.contains(name)) name += "_"; - while (!used.add(name)) name += i; - final Class type = e.isVarArgs() && i == params.length - 1 ? p - .getType().getComponentType() : p.getType(); - sb.append(", "); - if (e.isVarArgs() && i == params.length - 1) sb.append('*'); - sb.append(name).append(": ").append(type(type, pkg, refs)); - } - return sb.toString(); - } - - /** The Python type of a Java one, as written in a stub of {@code pkg}. */ - String type(final Class c, final String pkg, final Set refs) { - if (c == void.class) return "None"; - if (c == boolean.class || c == Boolean.class) return "bool"; - if (c == int.class || c == long.class || c == short.class || - c == byte.class || c == Integer.class || c == Long.class || - c == Short.class || c == Byte.class || c == BigInteger.class) - { - return "int"; - } - if (c == float.class || c == double.class || c == Float.class || - c == Double.class || c == BigDecimal.class) return "float"; - if (c == char.class || c == Character.class || c == String.class) { - return "str"; - } - if (c.isArray()) return "list[" + type(c.getComponentType(), pkg, refs) + - "]"; - if (!Modifier.isPublic(c.getModifiers()) || c.isAnonymousClass() || c - .isLocalClass()) return "typing.Any"; - final Class top = topLevel(c); - final int dot = top.getName().lastIndexOf('.'); - final String topPkg = dot < 0 ? "" : top.getName().substring(0, dot); - if (topPkg.isEmpty()) return "typing.Any"; - // NB: Nested classes by their path from the top-level class. - final String path = c.getName().substring(topPkg.length() + 1).replace('$', - '.'); - if (topPkg.equals(pkg)) return path; - refs.add(topPkg); - return topPkg + "." + path; - } - - // -- Helper methods -- - - private static Class topLevel(Class c) { - while (c.getEnclosingClass() != null) c = c.getEnclosingClass(); - return c; - } - - /** The property a getter offers (e.g. title for getTitle()), or null. */ - private static String property(final Method m) { - if (Modifier.isStatic(m.getModifiers()) || m.getParameterCount() != 0 || m - .getReturnType() == void.class) return null; - final String name = m.getName(); - String rest = null; - if (name.startsWith("get") && name.length() > 3) rest = name.substring(3); - else if (name.startsWith("is") && name.length() > 2 && (m - .getReturnType() == boolean.class)) rest = name.substring(2); - if (rest == null || !Character.isUpperCase(rest.charAt(0))) return null; - final String property = Character.toLowerCase(rest.charAt(0)) + rest - .substring(1); - return valid(property) ? property : null; - } - - /** A parameter name from its type, e.g. imageProcessor, or values. */ - private static String derivedName(final Class type) { - if (type.isArray()) return "array"; - final String simple = type.getSimpleName(); - if (simple.isEmpty()) return "arg"; - return Character.toLowerCase(simple.charAt(0)) + simple.substring(1); - } - - /** Whether a name can be a Python identifier in a stub. */ - private static boolean valid(final String name) { - return !name.isEmpty() && !KEYWORDS.contains(name) && name.indexOf( - '$') < 0 && Character.isJavaIdentifierStart(name.charAt(0)); - } - - /** Orders overloads (and members) the same each time. */ - private static String signatureKey(final Executable e) { - return e.getName() + Arrays.stream(e.getParameterTypes()).map( - Class::getName).collect(Collectors.joining(",", "(", ")")); - } - - private static List sorted(final T[] items, - final Function key) - { - final List list = new ArrayList<>(Arrays.asList(items)); - list.sort(Comparator.comparing(key)); - return list; - } - - /** Lists a package's classes from the {@link ClassIndex}. */ - private static List indexedClassNames(final String pkg) { - final String prefix = pkg + "."; - return ClassIndex.findClassNamesForPackage(pkg).filter(n -> n.startsWith( - prefix) && n.indexOf('.', prefix.length()) < 0).collect(Collectors - .toList()); - } -} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonAutoCompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonAutoCompletions.java deleted file mode 100644 index 7a7dc57..0000000 --- a/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonAutoCompletions.java +++ /dev/null @@ -1,563 +0,0 @@ -/*- - * #%L - * JSR-223-compliant Jython scripting language plugin. - * %% - * Copyright (C) 2020 - 2025 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.plugins.scripting.jython.completion; - -import java.io.File; -import java.lang.reflect.Parameter; -import java.nio.file.FileVisitOption; -import java.nio.file.Files; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; -import java.util.Map; -import java.util.jar.JarEntry; -import java.util.jar.JarFile; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.function.Function; -import java.util.stream.Collectors; - -import org.python.indexer.types.NModuleType; -import org.scijava.code.lsp.jvm.ClassIndex; -import org.eclipse.lsp4j.CompletionItem; -import org.eclipse.lsp4j.CompletionItemKind; -import org.eclipse.lsp4j.MarkupContent; -import org.eclipse.lsp4j.MarkupKind; -import org.eclipse.lsp4j.TextEdit; -import org.scijava.code.lsp.jvm.Callable; - -/** - * The Jython completion engine: given the code before the caret it analyzes the - * Jython AST (via {@link JythonScriptParser}) and produces completions (LSP - * {@link CompletionItem}s; methods and constructors as {@link Callable}s), - * plus the scope's variables by type, for arguments. Class-name and import - * completions (including auto-import {@link TextEdit}s) are produced via - * {@link ClassIndex}. - * - * @author Albert Cardona - * @author Curtis Rueden - */ -public class JythonAutoCompletions { - - private static final Pattern - nameToken = Pattern.compile("^(|.*?[ \\t,\\[=\\(+\\-*/%<>!:]+)([a-zA-Z_][a-zA-Z0-9_]*)$"), - invocation = Pattern.compile("^(.*?[ \\t]+|)([a-zA-Z_][a-zA-Z0-9_]+\\()$"), - dotNameToken = Pattern.compile("^(.*?[ \\t]+|)([a-zA-Z0-9_\\.\\[\\](){}]+)\\.([a-zA-Z0-9_]*)$"), - assign = Pattern.compile("^([ \\t]*)(([a-zA-Z_][a-zA-Z0-9_ \\t,]*)[ \\t]+=[ \\t]+(.*))$"), - endingCode = Pattern.compile("^([ \\t]*)[^#]*?(.*?)[ \\t]*:[ \\t]*(#.*|)[\\n]*$"), - sysPathAppend = Pattern.compile("sys.path.append[ \\t]*[(][ \\t]*['\"](.*?)['\"][ \\t]*[)]"), - importPkg = Pattern.compile("^(import|from)[ \\t]+([a-zA-Z_][a-zA-Z0-9._]*)$"), - importMember = Pattern.compile("^from[ \\t]+([a-z_][a-zA-Z0-9_.]*)[ \\t]+import[ \\t]*([a-zA-Z0-9_]*)$"), - // Import/class-name discovery patterns (matched against already-typed text). - fromImport = Pattern.compile("^((from|import)[ \\t]+)([a-zA-Z_][a-zA-Z0-9._]*)$"), - fastImport = Pattern.compile("^(from[ \\t]+)([a-zA-Z_][a-zA-Z0-9._]*)[ \\t]+$"), - importStatement = Pattern.compile("^((from[ \\t]+([a-zA-Z0-9._]+)[ \\t]+|[ \\t]*)import[ \\t]+)([a-zA-Z0-9_., \\t]*)$"), - simpleClassName = Pattern.compile("^(.*[ \\t]+|)([A-Z_][a-zA-Z0-9_]+)$"); - - /** Python 2 (Jython) keywords. */ - private static final List KEYWORDS = Arrays.asList("and", "as", - "assert", "break", "class", "continue", "def", "del", "elif", "else", - "except", "exec", "finally", "for", "from", "global", "if", "import", "in", - "is", "lambda", "not", "or", "pass", "print", "raise", "return", "try", - "while", "with", "yield", "None", "True", "False"); - - private final JythonImportFormat formatter = new JythonImportFormat(); - - /** Python standard library module names, discovered from the jython jar. */ - private static final List jython_jar_modules = findJythonModules(); - - /** - * Result of a completion query: the suggestions, plus the analyzed scope's - * variables by type, and argument type resolution. - */ - public static final class Result { - - public final List completions; - /** The variables of a type (e.g. {@code double}) in scope; or null. */ - public final Function> variablesOfType; - /** The type (name) of an expression in the analyzed scope, or null. */ - public final Function types; - - Result(final List completions, - final Function> variablesOfType, - final Function types) - { - this.completions = completions; - this.variablesOfType = variablesOfType; - this.types = types; - } - } - - /** - * Computes completions for a caret positioned at the end of {@code lastLine}, - * which follows {@code codeWithoutLastLine} in the script. {@code - * alreadyEnteredText} is the text the editor will replace. - */ - public Result completionsFor(final String codeWithoutLastLine, - final String lastLine, final String alreadyEnteredText) - { - return completionsFor(codeWithoutLastLine, lastLine, alreadyEnteredText, - Collections.emptyMap()); - } - - /** - * As {@link #completionsFor(String, String, String)}, for a script in which - * the given variables are already defined before the code runs (e.g. script - * parameters, or the bindings of a live interpreter). - */ - public Result completionsFor(final String codeWithoutLastLine, - final String lastLine, final String alreadyEnteredText, - final Map predefined) - { - final List completions = new ArrayList<>(); - final int crop = lastLine.length() - alreadyEnteredText.length(); - - // 1) AST-based completions (names, members, constructors, invocations). - final Scope scope = astCompletions(codeWithoutLastLine, lastLine, crop, - predefined, completions); - - // 2) Import and class-name discovery, always appended. - importCompletions(alreadyEnteredText, codeWithoutLastLine + lastLine, - completions); - - if (scope == null) return new Result(dedupe(completions), null, null); - return new Result(dedupe(completions), type -> variablesOfType(scope, type), - expression -> JythonScriptParser.typeOf(expression, scope)); - } - - // -- AST-based completions -- - - /** Adds AST-based completions, returning the analyzed scope (or null). */ - private Scope astCompletions(String codeWithoutLastLine, - final String lastLine, final int crop, - final Map predefined, - final List completions) - { - // Precondition: can't expand when empty or ending with any of "[]{},; ". - if (lastLine.isEmpty()) return null; - final char lastChar = lastLine.charAt(lastLine.length() - 1); - if ("[]{},; ".indexOf(lastChar) > -1) return null; - - // If the prior line ends with ':', append a "pass" to make valid code. - boolean add_pass = false; - if (codeWithoutLastLine.endsWith("\n")) { - final int priorLineBreak = codeWithoutLastLine.lastIndexOf('\n', - codeWithoutLastLine.length() - 2); - final String endingLine = codeWithoutLastLine.substring(priorLineBreak + - 1); - final Matcher me = endingCode.matcher(endingLine); - if (me.find()) { - codeWithoutLastLine = codeWithoutLastLine.substring(0, priorLineBreak + - 1) + me.group(1) + me.group(2); - add_pass = true; - } - } - - // Register any sys.path.append paths so custom modules can be found. - try { - final Matcher mpath = sysPathAppend.matcher(codeWithoutLastLine); - while (mpath.find()) { - final File path = new File(mpath.group(1)); - if (path.exists() && path.isDirectory() && !Scope.indexer - .getLoadPath().stream().anyMatch(s -> path.equals(new File(s)))) - { - Scope.indexer.addPath(path.getAbsolutePath()); - } - } - } - catch (final Exception e) { - JythonDev.print("Failed to add path from sys.path.append expression.", e); - } - - // A python module import: "import x" or "from x". - final Matcher mi = importPkg.matcher(lastLine); - if (mi.find()) { - moduleNameCompletions(mi.group(1), mi.group(2), completions); - return null; - } - - // A module member: "from x import y". - final Matcher mm = importMember.matcher(lastLine); - if (mm.find()) { - moduleMemberCompletions(mm.group(1), mm.group(2) == null ? "" : mm.group( - 2), completions); - return null; - } - - // A plain name (variable, function, class, keyword) being typed. - final Matcher m1 = nameToken.matcher(lastLine); - if (m1.find()) { - final Scope scope = JythonScriptParser.parseAST(codeWithoutLastLine, - predefined) - .getLast(); - final String seed = m1.group(2); - final String pre = crop > -1 && crop < m1.group(1).length() ? m1.group(1) - .substring(crop) : ""; - final String head = lastLine.substring(0, lastLine.length() - seed - .length()); - final Map names = scope.findStartsWith2(seed); - for (final Map.Entry e : names.entrySet()) { - completions.add(item((head + e.getKey()).substring(crop), - CompletionItemKind.Variable, null)); - final String classname = e.getValue(); - if (null != classname) { - // A class: also offer its constructors. - try { - final Class c = Class.forName(classname); - for (final java.lang.reflect.Constructor cons : c - .getConstructors()) - { - completions.add(makeDotCompletion(pre, seed, - new CompletionText(e.getKey(), c, cons))); - } - } - catch (final ClassNotFoundException | LinkageError exc) { - JythonDev.printTrace("Can't load class: " + classname); - } - } - } - for (final String keyword : KEYWORDS) { - if (keyword.startsWith(seed) && !keyword.equals(seed)) { - completions.add(item((head + keyword).substring(crop), - CompletionItemKind.Keyword, null)); - } - } - return scope; - } - - // A function/constructor invocation: "name(". - final Matcher m1c = invocation.matcher(lastLine); - if (m1c.find()) { - final String name = m1c.group(2).substring(0, m1c.group(2).length() - 1); - final Scope scope = JythonScriptParser.parseAST(codeWithoutLastLine, - predefined) - .getLast(); - final DotAutocompletions da = scope.find(name, DotAutocompletions.EMPTY); - if (da instanceof ConstructorAutocompletions) { - for (final CompletionText ct : da.get()) { - completions.add(makeDotCompletion("", name, ct)); - } - return scope; - } - // Otherwise fall through to dot-name handling below. - } - - // A field/method expansion: "var.seed" or "expr().seed". - final Matcher m2 = dotNameToken.matcher(lastLine); - if (m2.find()) { - final String seed = m2.group(3); // can be empty - final String code, varName; - final Matcher m3 = assign.matcher(lastLine); - if (m3.find()) { - final String[] assignment = lastLine.split("="); - final String[] names = assignment[0].split(","); - varName = names[names.length - 1].trim(); - code = codeWithoutLastLine + lastLine.substring(0, lastLine.length() - - 1 - seed.length()); - } - else { - int start = 0; - while (Character.isWhitespace(lastLine.charAt(start++))); - --start; - String suffix = ""; - if (add_pass) { - add_pass = false; - suffix = ":\n "; - } - varName = "____GRAB____"; - code = codeWithoutLastLine + (add_pass ? ": pass" : "") + suffix + - lastLine.substring(0, start) + varName + " = " + lastLine.substring( - start, lastLine.length() - 1 - seed.length()); - } - final Scope scope = JythonScriptParser.parseAST(code, predefined); - final DotAutocompletions da = scope.getLast().find(varName, - DotAutocompletions.EMPTY); - final String fullPre = lastLine.substring(crop); - final String pre = fullPre.substring(0, fullPre.lastIndexOf(seed)); - final String lowerCaseSeed = seed.toLowerCase(); - - final List list = da.get().stream().filter(s -> s - .getReplacementText().toLowerCase().contains(lowerCaseSeed)).map( - s -> makeDotCompletion(pre, lowerCaseSeed, s)).collect(Collectors - .toList()); - sortCompletions(list, seed); - completions.addAll(list); - return scope.getLast(); - } - - return null; - } - - private void moduleNameCompletions(final String first, final String pkgName, - final List completions) - { - jython_jar_modules.stream().filter(s -> s.startsWith(pkgName)).forEach(s -> // - completions.add(documented(item(first + " " + s + (first.equals("from") - ? " import " : ""), CompletionItemKind.Module, null), - "Python standard library module"))); - final String pkgNameFile = pkgName.replace('.', '/'); - for (final String dir : Scope.indexer.getLoadPath()) { - try { - Files.walk(new File(dir).toPath(), FileVisitOption.FOLLOW_LINKS) // - .map(path -> path.toFile().getAbsolutePath()) // - .filter(s -> s.startsWith(dir + pkgNameFile) && s.endsWith(".py")) // - .map(s -> (s.endsWith("__init__.py") // - ? s.substring(dir.length(), s.length() - 12) // - : s.substring(dir.length(), s.length() - 3)).replace('/', '.')) // - .forEach(s -> completions.add(documented(item(first + " " + s + - (first.equals("from") ? " import " : ""), CompletionItemKind.Module, - null), "Custom python module"))); - } - catch (final Exception e) { - JythonDev.print("Failed to read jython module file.", e); - } - } - } - - private void moduleMemberCompletions(final String pkgName, - final String member, final List completions) - { - final NModuleType mod = Scope.loadPythonModule(pkgName); - if (null != mod && !mod.getTable().keySet().isEmpty()) { - mod.getTable().keySet().stream().filter(s -> s.startsWith(member)).forEach( - s -> completions.add(item("from " + pkgName + " import " + s, - CompletionItemKind.Module, null))); - return; - } - if (null != mod) { - // Module exists but its __init__.py is empty: look into its folder. - for (final String dir : Scope.indexer.getLoadPath()) { - final File fdir = new File(dir + pkgName.replace('.', '/')); - if (fdir.exists() && fdir.isDirectory()) { - final String[] names = fdir.list(); - if (names == null) continue; - for (final String filename : names) { - if (filename.startsWith(member) && (new File(fdir.getAbsolutePath() + - "/" + filename).isDirectory() || filename.endsWith(".py"))) - { - completions.add(item("from " + pkgName + " import " + (filename - .endsWith(".py") ? filename.substring(0, filename.length() - 3) - : filename), CompletionItemKind.Module, null)); - } - } - } - } - } - } - - // -- Import / class-name discovery (with auto-import edits) -- - - private void importCompletions(final String text, final String fullCode, - final List completions) - { - if (!ClassIndex.isCacheReady()) return; // don't block - - final Matcher m1 = fromImport.matcher(text); - if (m1.find()) { - ClassIndex.findClassNamesContaining(m1.group(3)).map( - formatter::singleToImportStatement).forEach(s -> completions.add( - item(s, CompletionItemKind.Module, null))); - return; - } - final Matcher m1f = fastImport.matcher(text); - if (m1f.find()) { - ClassIndex.findClassNamesForPackage(m1f.group(2)).map( - formatter::singleToImportStatement).forEach(s -> completions.add( - item(s, CompletionItemKind.Module, null))); - return; - } - final Matcher m2 = importStatement.matcher(text); - if (m2.find()) { - final String packageName = m2.group(3); - String className = m2.group(4); - final String[] bycomma = className.split(","); - String precomma = ""; - if (bycomma.length > 1) { - className = bycomma[bycomma.length - 1].trim(); - for (int i = 0; i < bycomma.length - 1; ++i) - precomma += bycomma[0] + ", "; - } - final java.util.stream.Stream stream; - if (className.length() > 0) stream = ClassIndex.findClassNamesStartingWith( - null == packageName ? className : packageName + "." + className); - else stream = ClassIndex.findClassNamesForPackage(packageName); - final String pre = m2.group(1) + precomma; - stream.map(s -> s.substring(Math.max(0, s.lastIndexOf('.') + 1))).forEach( - s -> completions.add(item(pre + s, CompletionItemKind.Module, null))); - return; - } - final Matcher m3 = simpleClassName.matcher(text); - if (m3.find()) { - final String pre = m3.group(1); - final List classNames = ClassIndex - .findSimpleClassNamesStartingWith(m3.group(2)); - // Closest matches first: shortest simple name, then alphabetical. - classNames.sort(Comparator.comparingInt((String cn) -> cn.length() - cn - .lastIndexOf('.')).thenComparing(Comparator.naturalOrder())); - for (final String className : classNames) { - final String simpleName = className.substring(className.lastIndexOf( - '.') + 1); - final String importStmt = formatter.singleToImportStatement(className); - // Side effect on accept: insert the import near the top of the file. - final TextEdit edit = JythonImports.autoImportEdit(fullCode, className, - importStmt); - final CompletionItem c = item(pre + simpleName, CompletionItemKind.Class, - importStmt); - if (edit != null) c.setAdditionalTextEdits(Collections.singletonList( - edit)); - completions.add(c); - } - } - } - - // -- Helpers -- - - /** - * Removes duplicate completions (same text and parameters), keeping the first - * occurrence, since the AST and class-name analyses can suggest the same thing. - */ - private static List dedupe( - final List completions) - { - final java.util.Set seen = new java.util.HashSet<>(); - final List result = new ArrayList<>(completions.size()); - for (final CompletionItem c : completions) { - final String params = c.getLabelDetails() == null ? null : c - .getLabelDetails().getDetail(); - if (seen.add(c.getLabel() + "\u0000" + params)) result.add(c); - } - return result; - } - - /** A completion of the given text. */ - private static CompletionItem item(final String text, - final CompletionItemKind kind, final String detail) - { - final CompletionItem item = new CompletionItem(text); - item.setKind(kind); - item.setInsertText(text); - item.setDetail(detail); - return item; - } - - private static CompletionItem documented(final CompletionItem item, - final String doc) - { - item.setDocumentation(new MarkupContent(MarkupKind.MARKDOWN, doc)); - return item; - } - - /** Builds a completion from a {@link CompletionText} and prefix. */ - private static CompletionItem makeDotCompletion(final String pre, - final String seed, final CompletionText ct) - { - final List ps = ct.getMethodArgs(); - if (null != ps && null != ct.getReturnType()) { - String text = ct.getReplacementText(); - if (text.endsWith("()")) text = text.substring(0, text.length() - 2); - final List params = new ArrayList<>(); - for (final Parameter p : ps) { - params.add(new Callable.Param(p.getName(), p.getType() - .getCanonicalName())); - } - return new Callable(pre + text, params, ct.getReturnType()).detail(ct - .getSummary()).toItem(); - } - return item(pre + ct.getReplacementText(), CompletionItemKind.Field, ct - .getSummary()); - } - - /** The variables of the given type in scope, innermost first. */ - private static List variablesOfType(final Scope scope, - final String type) - { - { - if (type == null) return Collections.emptyList(); - Class clazz; - switch (type) { - case "int": clazz = Integer.class; break; - case "long": clazz = Long.class; break; - case "float": clazz = Float.class; break; - case "double": clazz = Double.class; break; - case "boolean": clazz = Boolean.class; break; - case "char": clazz = Character.class; break; - case "short": clazz = Short.class; break; - case "byte": clazz = Byte.class; break; - default: - try { - clazz = Class.forName(type); - } - catch (final Throwable t) { - return Collections.emptyList(); - } - } - final Class c = clazz; - return scope.findVarsByType(type, c).collect(Collectors.toList()); - } - } - - private void sortCompletions(final List completions, - final String pre) - { - completions.sort(new Comparator() { - - @Override - public int compare(final CompletionItem o1, final CompletionItem o2) { - final int p1 = o1.getLabel().startsWith(pre) ? 0 : Integer.MAX_VALUE; - final int p2 = o2.getLabel().startsWith(pre) ? 0 : Integer.MAX_VALUE; - if (p1 == p2) return o1.getLabel().compareTo(o2.getLabel()); - return p1 - p2; - } - }); - } - - /** Locates the jython jar on the classpath and lists its Lib/*.py modules. */ - private static List findJythonModules() { - try { - final java.security.CodeSource cs = org.python.core.PySystemState.class - .getProtectionDomain().getCodeSource(); - if (cs == null || cs.getLocation() == null) return Collections.emptyList(); - final File jar = new File(cs.getLocation().toURI()); - if (!jar.isFile()) return Collections.emptyList(); - try (final JarFile jf = new JarFile(jar)) { - return jf.stream().map(JarEntry::getName).filter(s -> s.startsWith( - "Lib/") && s.endsWith(".py")).map(s -> (s.endsWith("/__init__.py") // - ? s.substring(4, s.length() - 12) // - : s.substring(4, s.length() - 3)).replace('/', '.')).collect( - Collectors.toList()); - } - } - catch (final Exception e) { - JythonDev.print("Cannot locate the jython jar for stdlib modules", e); - return Collections.emptyList(); - } - } -} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDev.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDev.java deleted file mode 100644 index 4b8f6d3..0000000 --- a/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDev.java +++ /dev/null @@ -1,65 +0,0 @@ -/*- - * #%L - * Jython language support for SciJava Script Editor. - * %% - * Copyright (C) 2020 - 2024 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.plugins.scripting.jython.completion; - -public class JythonDev -{ - static public int SILENT = 0, - MESSAGES = 1, - ERRORS = 2; - - /** Adjust value to see debugging information, including: - * SILENT: nothing is printed at all. - * MESSAGES: prints light messages indicating some internals and the kinds of errors. - * ERRORS: prints everything including exceptions. - */ - static public int debug = MESSAGES; - - static public final void print(Object s) { - if (debug >= MESSAGES) System.out.println(s); - } - - static public final void printError(Throwable e) { - if (debug >= ERRORS) e.printStackTrace(); - } - - static public final void printTrace(Object s) { - if (debug >= ERRORS) System.out.println(s); - } - - static public final void print(Object s, Throwable e) { - if (debug >= ERRORS) { - System.out.println(s); - e.printStackTrace(); - } else if (debug >= MESSAGES) { - System.out.println(s.toString() + " :: " + e.getMessage()); - } - } - -} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDialect.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDialect.java index c03a5e0..ffd3354 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDialect.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDialect.java @@ -36,6 +36,9 @@ import java.util.TreeSet; import org.scijava.code.lsp.ScriptDialect; +import org.scijava.plugin.Plugin; +import org.scijava.plugins.scripting.jython.JythonScriptLanguage; +import org.scijava.script.ScriptLanguage; /** * How Jython scripts see their script parameters: as the Python values Jython @@ -45,13 +48,24 @@ *

* Each is declared as an instance of its type, in code both Jython's own * analysis (Python 2) and a Python language server knowing the Java packages - * from stubs (see {@link JavaStubs}) read: e.g. {@code import net.imglib2.img; - * image = net.imglib2.img.Img(); name = str()}. + * from stubs (see scijava-code-lsp-jython) read: e.g. + * {@code import net.imglib2.img; image = net.imglib2.img.Img(); name = str()}. *

* * @author Gabriel Selzer */ -class JythonDialect implements ScriptDialect { +@Plugin(type = ScriptDialect.class, name = "Jython") +public class JythonDialect implements ScriptDialect { + + @Override + public boolean supports(final ScriptLanguage language) { + return language instanceof JythonScriptLanguage; + } + + @Override + public String languageId() { + return "jython"; + } @Override public String runtimeType(final Class type) { diff --git a/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonImportFormat.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonImportFormat.java deleted file mode 100644 index d26b61c..0000000 --- a/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonImportFormat.java +++ /dev/null @@ -1,51 +0,0 @@ -/*- - * #%L - * JSR-223-compliant Jython scripting language plugin. - * %% - * Copyright (C) 2020 - 2025 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.plugins.scripting.jython.completion; - -/** - * Formats fully qualified class names as Jython import statements. - * - * @author Albert Cardona - */ -public class JythonImportFormat { - - /** Given a fully-qualified class name, returns a Jython import statement. */ - public final String singleToImportStatement(final String className) { - final int idot = className.lastIndexOf('.'); - if (-1 == idot) return "import " + className; - return dualToImportStatement(className.substring(0, idot), className - .substring(idot + 1)); - } - - public String dualToImportStatement(final String packageName, - final String simpleClassName) - { - return "from " + packageName + " import " + simpleClassName; - } -} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonImports.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonImports.java deleted file mode 100644 index 0632093..0000000 --- a/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonImports.java +++ /dev/null @@ -1,168 +0,0 @@ -/*- - * #%L - * JSR-223-compliant Jython scripting language plugin. - * %% - * Copyright (C) 2020 - 2025 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.plugins.scripting.jython.completion; - -import java.util.HashMap; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import org.eclipse.lsp4j.Position; -import org.eclipse.lsp4j.Range; -import org.eclipse.lsp4j.TextEdit; -import org.scijava.code.lsp.Positions; - -/** - * Parses the {@code import} statements in a Jython script, and computes the - * {@link TextEdit} needed to auto-insert a missing import near the top of the - * file. This is the toolkit-agnostic successor to the editor-side - * auto-import-on-accept logic. - * - * @author Albert Cardona - */ -public final class JythonImports { - - private static final Pattern importPattern = Pattern.compile( - "^(from[ \\t]+([a-zA-Z_][a-zA-Z0-9._]*)[ \\t]+|)import[ \\t]+([a-zA-Z_][a-zA-Z0-9_]*[ \\ta-zA-Z0-9_,]*)[ \\t]*([\\\\]*|)[ \\t]*(#.*|)$"); - private static final Pattern tripleQuotePattern = Pattern.compile("\"\"\""); - - private JythonImports() { - // prevent instantiation - } - - /** A single imported class, with its declaration line number. */ - public static final class Import { - - public final String className, alias; - public final int lineNumber; - - public Import(final String className, final String alias, - final int lineNumber) - { - this.className = className; - this.alias = null != alias ? alias : className.substring(className - .lastIndexOf('.') + 1); - this.lineNumber = lineNumber; - } - - public Import(final String packageName, final String[] parts, - final int lineNumber) - { - this(packageName + "." + parts[0], 3 == parts.length ? parts[2] : null, - lineNumber); - } - } - - /** Scans the whole script for imported classes, keyed by alias/simple name. */ - public static HashMap findImportedClasses(final String text) { - final HashMap importedClasses = new HashMap<>(); - String packageName = ""; - boolean endingBackslash = false; - boolean insideTripleQuotes = false; - - final String[] lines = text.split("\n"); - for (int i = 0; i < lines.length; ++i) { - final String line = lines[i]; - final String trimmed = line.trim(); - if (0 == trimmed.length() || '#' == trimmed.charAt(0)) continue; - final Matcher mq = tripleQuotePattern.matcher(line); - int n_triple_quotes = 0; - while (mq.find()) ++n_triple_quotes; - if (insideTripleQuotes) { - if (0 != n_triple_quotes % 2) insideTripleQuotes = false; - continue; - } - else if (0 != n_triple_quotes % 2) { - insideTripleQuotes = true; - continue; - } - if (endingBackslash) { - String importLine = line; - final int backslash = line.lastIndexOf('\\'); - if (backslash > -1) importLine = importLine.substring(0, backslash); - else { - final int sharp = importLine.lastIndexOf('#'); - if (sharp > -1) importLine = importLine.substring(0, sharp); - } - for (final String simpleClassName : importLine.split(",")) { - final Import im = new Import(packageName, simpleClassName.trim().split( - "\\s"), i); - importedClasses.put(im.alias, im); - } - endingBackslash = -1 != backslash; - continue; - } - final Matcher m = importPattern.matcher(line); - if (m.find()) { - packageName = null == m.group(2) ? "" : m.group(2); - for (final String simpleClassName : m.group(3).split(",")) { - final Import im = new Import(packageName, simpleClassName.trim().split( - "\\s"), i); - importedClasses.put(im.alias, im); - } - endingBackslash = null != m.group(4) && m.group(4).length() > 0 && - '\\' == m.group(4).charAt(0); - } - } - return importedClasses; - } - - /** - * Computes the edit that inserts {@code importStatement} after the last - * existing import (or at the top), or {@code null} if {@code className} is - * already imported. - */ - public static TextEdit autoImportEdit(final String text, - final String className, final String importStatement) - { - final HashMap imported = findImportedClasses(text); - for (final Import im : imported.values()) { - if (im.className.equals(className)) return null; // already imported - } - int lastImportLine = 0; - for (final Import im : imported.values()) { - if (im.lineNumber > lastImportLine) lastImportLine = im.lineNumber; - } - final int targetLine = imported.isEmpty() ? 0 : lastImportLine + 1; - final int offset = offsetOfLine(text, targetLine); - final Position at = Positions.position(text, Math.min(offset, text - .length())); - return new TextEdit(new Range(at, at), importStatement + "\n"); - } - - /** Character offset of the start of the given (0-based) line in {@code text}. */ - private static int offsetOfLine(final String text, final int line) { - int offset = 0; - for (int l = 0; l < line; l++) { - final int nl = text.indexOf('\n', offset); - if (nl < 0) return text.length(); - offset = nl + 1; - } - return offset; - } -} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonLanguageServer.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonLanguageServer.java deleted file mode 100644 index cb7ac4b..0000000 --- a/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonLanguageServer.java +++ /dev/null @@ -1,371 +0,0 @@ -/*- - * #%L - * JSR-223-compliant Jython scripting language plugin. - * %% - * Copyright (C) 2020 - 2025 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.plugins.scripting.jython.completion; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.CompletableFuture; - -import javax.script.Bindings; -import javax.script.ScriptContext; - -import org.eclipse.lsp4j.CompletionItem; -import org.eclipse.lsp4j.CompletionItemKind; -import org.eclipse.lsp4j.CompletionList; -import org.eclipse.lsp4j.CompletionParams; -import org.eclipse.lsp4j.DidChangeTextDocumentParams; -import org.eclipse.lsp4j.DidCloseTextDocumentParams; -import org.eclipse.lsp4j.DidOpenTextDocumentParams; -import org.eclipse.lsp4j.Hover; -import org.eclipse.lsp4j.HoverParams; -import org.eclipse.lsp4j.SignatureHelp; -import org.eclipse.lsp4j.SignatureHelpParams; -import org.eclipse.lsp4j.jsonrpc.messages.Either; -import org.python.core.PyJavaType; -import org.python.core.PyModule; -import org.python.core.PyObject; -import org.python.core.PyString; -import org.scijava.code.lsp.jvm.ClassIndex; -import org.scijava.code.lsp.jvm.Callable; -import org.scijava.code.lsp.Environment; -import org.scijava.code.lsp.jvm.AbstractLanguageServer; -import org.scijava.code.lsp.jvm.Signatures; - -/** - * An in-process language server for Jython, driven by Jython's own analysis - * (see {@link JythonAutoCompletions}): Java classes, their members and - * constructors, Python names, keywords and imports; script parameters and, in - * a live interpreter, its variables. In a call's arguments, with nothing - * typed, the variables of the parameter's type come first. Signature help - * rates the overloads against the arguments typed. - *

- * If a Python language server is offered (see {@link PythonCompanion}), its - * completions follow Jython's own; and its signatures and hover serve where - * Jython has none. - *

- * - * @author Albert Cardona - * @author Curtis Rueden - * @author Gabriel Selzer - */ -public class JythonLanguageServer extends AbstractLanguageServer { - - /** - * How scripts see their parameters: as the Java objects themselves, - * declared in the text (see {@link JythonServerPlugin#dialect()}). - */ - static final JythonDialect DIALECT = new JythonDialect(); - - static { - // Warm the class index in the background, as completion needs it. - new Thread(ClassIndex::ensureCache, "Jython-ClassIndex-warmup").start(); - } - - private final JythonAutoCompletions engine = new JythonAutoCompletions(); - - /** A Python language server beside Jython's analysis, or null if none. */ - private final PythonCompanion python; - - /** - * @param environment Where the scripts run (e.g. a live interpreter). - */ - public JythonLanguageServer(final Environment environment) { - this(environment, null); - } - - /** - * @param environment Where the scripts run (e.g. a live interpreter). - * @param python A Python language server beside Jython's analysis, or null. - */ - JythonLanguageServer(final Environment environment, - final PythonCompanion python) - { - super(environment); - this.python = python; - } - - // -- LanguageServer, with the Python server beside -- - - @Override - public void didOpen(final DidOpenTextDocumentParams params) { - super.didOpen(params); - if (python != null) python.didOpen(params); - } - - @Override - public void didChange(final DidChangeTextDocumentParams params) { - super.didChange(params); - if (python != null) python.didChange(params); - } - - @Override - public void didClose(final DidCloseTextDocumentParams params) { - super.didClose(params); - if (python != null) python.didClose(params); - } - - @Override - public CompletableFuture, CompletionList>> - completion(final CompletionParams params) - { - final CompletableFuture, CompletionList>> own = - super.completion(params); - if (python == null) return own; - return own.thenCompose(answer -> python.completion(params, answer - .getRight())); - } - - @Override - public CompletableFuture resolveCompletionItem( - final CompletionItem item) - { - if (python == null) return super.resolveCompletionItem(item); - return python.resolveCompletionItem(item, - super::resolveCompletionItem); - } - - @Override - public CompletableFuture signatureHelp( - final SignatureHelpParams params) - { - final CompletableFuture own = super.signatureHelp(params); - if (python == null) return own; - return own.thenCompose(help -> python.signatureHelp(params, help)); - } - - @Override - public CompletableFuture hover(final HoverParams params) { - final CompletableFuture own = super.hover(params); - if (python == null) return own; - return own.thenCompose(h -> python.hover(params, h)); - } - - @Override - public CompletableFuture shutdown() { - if (python != null) python.shutdown(); - return super.shutdown(); - } - - // -- AbstractLanguageServer -- - - @Override - protected CompletionList complete(final Document doc, final int caret) { - final String text = doc.text(); - final String lastLine = doc.lineTo(caret); - if (lastLine.trim().startsWith("#")) { - return list(doc, caret, caret, new ArrayList<>()); - } - final String alreadyEntered = alreadyEnteredText(lastLine); - final int replaceStart = caret - alreadyEntered.length(); - final JythonAutoCompletions.Result result = completionsAt(doc, caret); - final List items = new ArrayList<>(); - if (alreadyEntered.isEmpty()) items.addAll(argumentChoices(doc, caret)); - items.addAll(result.completions); - return list(doc, replaceStart, caret, items); - } - - @Override - protected SignatureHelp signatureHelp(final Document doc, final int caret) { - final String text = doc.text(); - if (doc.lineTo(caret).trim().startsWith("#")) return null; - final Call call = call(doc, caret); - if (call == null || call.callables.isEmpty()) return null; - - // The types of the arguments before the caret's (which is being typed). - final List argTypes = new ArrayList<>(); - for (int i = 0; i < call.args.size(); i++) { - final String arg = call.args.get(i).trim(); - argTypes.add(i == call.args.size() - 1 || arg.isEmpty() ? null - : call.result.types.apply(arg)); - } - final ClassLoader loader = Thread.currentThread().getContextClassLoader(); - return new SignatureHelp(Signatures.rateAll(call.callables, argTypes, (a, - p) -> Signatures.fit(a, p, loader)), 0, call.args.size() - 1); - } - - // -- Helper methods -- - - /** The call around the caret: its signatures, and the arguments typed. */ - private static final class Call { - - private final JythonAutoCompletions.Result result; - private final List callables; - private final List args; - - private Call(final JythonAutoCompletions.Result result, - final List callables, final List args) - { - this.result = result; - this.callables = callables; - this.args = args; - } - } - - /** Finds the call around the caret, or returns null if none. */ - private Call call(final Document doc, final int caret) { - final String text = doc.text(); - final int open = Signatures.callStart(text, caret); - if (open < 0) return null; - final List args = Signatures.arguments(text, open, caret); - if (args == null) return null; - - // The callee, e.g. "Math.max" or "ArrayList". - final String callee = alreadyEnteredText(text.substring(text.lastIndexOf( - '\n', open - 1) + 1, open)).trim(); - if (callee.isEmpty()) return null; - - // Its signatures: what completion offers for it, as if the caret were - // just before the "(" (methods: "Math.max"), or just after it - // (constructors: offered by their class's simple name, "ArrayList"). - final String simpleName = callee.substring(callee.lastIndexOf('.') + 1); - JythonAutoCompletions.Result result = null; - List callables = Collections.emptyList(); - for (final int at : new int[] { open, open + 1 }) { - final String name = at == open ? callee : simpleName; - result = completionsAt(doc, at); - callables = new ArrayList<>(); - for (final CompletionItem item : result.completions) { - final Callable c = Callable.of(item); - if (c != null && c.name().equals(name)) callables.add(c); - } - if (!callables.isEmpty()) break; - } - // NB: Reflection lists methods in no particular order: sort them by - // their parameter types, so that equally good fits come out the same. - callables.sort(Comparator.comparing(Callable::parameterTypes)); - return new Call(result, callables, args); - } - - /** - * In a call's arguments, with nothing typed: the variables of the - * parameter's type (the first preselected). - */ - private List argumentChoices(final Document doc, - final int caret) - { - final Call call = call(doc, caret); - if (call == null || call.result.variablesOfType == null) { - return Collections.emptyList(); - } - final int active = call.args.size() - 1; - final Set names = new LinkedHashSet<>(); - for (final Callable c : call.callables) { - if (active >= c.params().size()) continue; - names.addAll(call.result.variablesOfType.apply(c.params().get(active) - .type())); - } - final List out = new ArrayList<>(); - for (final String name : names) { - final CompletionItem item = new CompletionItem(name); - item.setKind(CompletionItemKind.Variable); - item.setInsertText(name); - item.setPreselect(out.isEmpty()); - out.add(item); - } - return out; - } - - /** Computes completions as if the caret were at the given offset. */ - private JythonAutoCompletions.Result completionsAt(final Document doc, - final int offset) - { - final String text = doc.text(); - final String textToCaret = text.substring(0, Math.min(offset, text - .length())); - final String lastLine = textToCaret.substring(textToCaret.lastIndexOf( - '\n') + 1); - return engine.completionsFor(textToCaret.substring(0, textToCaret - .length() - lastLine.length()), lastLine, alreadyEnteredText(lastLine), - liveVariables()); - } - - /** - * Gets the variables defined before the code runs, when completing in a - * live interpreter: its bindings. (A script's parameters are declared in - * its text.) - */ - private Map liveVariables() { - final Map vars = new LinkedHashMap<>(); - final ScriptContext live = environment().live(); - final Bindings bindings = live == null ? null : live.getBindings( - ScriptContext.ENGINE_SCOPE); - if (bindings != null) { - for (final Map.Entry entry : bindings.entrySet()) { - final String name = entry.getKey(); - if (name.startsWith("__") && name.endsWith("__")) continue; - vars.put(name, bindingCompletions(entry.getValue())); - } - } - return vars.isEmpty() ? Collections.emptyMap() : vars; - } - - /** Describes a live binding value for completion purposes. */ - private static DotAutocompletions bindingCompletions(final Object value) { - if (value instanceof PyJavaType) { - // An imported Java class: offer its constructors and static members. - final Class c = ((PyJavaType) value).getProxyType(); - if (c != null) return new StaticDotAutocompletions(c.getName()); - } - if (value instanceof PyModule) { - final PyObject name = ((PyModule) value).__findattr__("__name__"); - if (name instanceof PyString) { - return new StaticDotAutocompletions(name.toString()); - } - } - if (value == null || value instanceof PyObject) { - // A python function, class or instance: known by name only. - return new VarDotAutocompletions(null); - } - return new VarDotAutocompletions(value.getClass().getName()); - } - - /** - * The portion of the current line to replace: a maximal suffix of letters, - * digits, {@code '.'}, {@code '_'} and spaces (spaces let "from x import Y" - * complete as a unit), excluding leading spaces. - */ - static String alreadyEnteredText(final String lastLine) { - int start = lastLine.length(); - while (start > 0) { - final char c = lastLine.charAt(start - 1); - if (Character.isLetterOrDigit(c) || c == '.' || c == '_' || c == ' ') // - start--; - else break; - } - // Leading whitespace is not part of what the user is completing. - while (start < lastLine.length() && lastLine.charAt(start) == ' ') start++; - return lastLine.substring(start); - } -} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonScriptParser.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonScriptParser.java deleted file mode 100644 index 34c4e73..0000000 --- a/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonScriptParser.java +++ /dev/null @@ -1,503 +0,0 @@ -/*- - * #%L - * Jython language support for SciJava Script Editor. - * %% - * Copyright (C) 2020 - 2024 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.plugins.scripting.jython.completion; - -import java.lang.reflect.Method; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import org.antlr.runtime.tree.CommonTree; -import org.python.antlr.PythonTree; -import org.python.antlr.ast.Assign; -import org.python.antlr.ast.Attribute; -import org.python.antlr.ast.BinOp; -import org.python.antlr.ast.Call; -import org.python.antlr.ast.ClassDef; -import org.python.antlr.ast.Expr; -import org.python.antlr.ast.Expression; -import org.python.antlr.ast.For; -import org.python.antlr.ast.FunctionDef; -import org.python.antlr.ast.If; -import org.python.antlr.ast.Import; -import org.python.antlr.ast.ImportFrom; -import org.python.antlr.ast.Name; -import org.python.antlr.ast.Num; -import org.python.antlr.ast.Return; -import org.python.antlr.ast.Str; -import org.python.antlr.ast.TryExcept; -import org.python.antlr.ast.TryFinally; -import org.python.antlr.ast.Tuple; -import org.python.antlr.ast.While; -import org.python.antlr.ast.With; -import org.python.antlr.ast.Yield; -import org.python.antlr.ast.alias; -import org.python.antlr.ast.arguments; -import org.python.antlr.base.expr; -import org.python.antlr.base.mod; -import org.python.core.CompileMode; -import org.python.core.CompilerFlags; -import org.python.core.ParserFacade; -import org.python.core.PyFloat; -import org.python.core.PyInteger; -import org.python.core.PyObject; -import org.python.indexer.types.NModuleType; - -public class JythonScriptParser -{ - /** - * Parse valid jython code. - * - * @param code - * - * @return The top-level {@link Scope}, which is empty (see {@link Scope#isEmpty()}) when the code has errors and can't be parsed by {@link ParserFacade#parse(String, CompileMode, String, CompilerFlags)}. - */ - static public Scope parseAST(final String code) { - return parseAST(code, Collections.emptyMap()); - } - - /** - * Parse valid jython code within a top-level {@link Scope} that already - * defines the given variables, such as script parameters injected by the - * framework or the bindings of a live interpreter. Assignments in the code - * take precedence over these. - * - * @param code - * @param predefined Variables defined before the code runs. - * - * @return The top-level {@link Scope}, holding only the predefined variables when the code has errors and can't be parsed by {@link ParserFacade#parse(String, CompileMode, String, CompilerFlags)}. - */ - static public Scope parseAST(final String code, final Map predefined) { - // The code includes from beginning of the file until the point at which an autocompletion is requested. - // Therefore, remove the last line, which would fail to parse because it is incomplete - final Scope scope = new Scope(null); - scope.vars.putAll(predefined); - try { - final mod m = ParserFacade.parse(code, CompileMode.exec, "", new CompilerFlags()); - if (null != m.getChildren()) parseNode(scope, m.getChildren(), null); - } catch (Throwable t) { - JythonDev.printError(t); - } - return scope; - } - - /** - * Parse a {@code List} of {@code PythonTree} instances, each representing a python statement - * including {@code ImportFrom, Assign, FunctionDef, ClassDef}. - * - * @param children The list of statements. - * @param parent The {@code Scope} that contains these statements. - * @param className Indicates whether the containing {@code Scope} is a python class definition, otherwise null. - * @return A new {@code Scope} containing {@code DotAutocompletions} to represent each statement. - */ - static public Scope parseNode(final List children, final Scope parent, final String className) { - if (null == children) return parent; // will return null if parent is null - final Scope scope = new Scope(parent, className); - parseNode(scope, children, className); - return scope; - } - - static public void parseNode(final Scope scope, final List children, final String className) { - - for (final PythonTree child : children) { - JythonDev.printTrace(child.getClass()); - - if (child instanceof ImportFrom) - scope.imports.putAll(parseImportFromStatement( (ImportFrom)child )); - else if (child instanceof Import) - scope.imports.putAll(parseImportStatement((Import)child)); - else if (child instanceof Assign) - scope.vars.putAll(parseAssignStatement( (Assign)child, scope )); - else if (child instanceof FunctionDef) - parseFunctionDef((FunctionDef)child, scope); - else if (child instanceof ClassDef) - parseClassDef((ClassDef)child, scope); - else if (child instanceof Expr) - parseExpr((Expr)child, scope); - else if (child instanceof If || child instanceof For || child instanceof While - || child instanceof TryExcept || child instanceof TryFinally || child instanceof With) - // no new scope in if/for/while/with/try/ statements in python - parseNode(scope, child.getChildren(), null); - else - JythonDev.printTrace("IGNORING child: " + child + " -- " + (null != child.getChildren() ? - String.join("::", child.getChildren().stream().map(c -> c.toString()).collect(Collectors.toList())) - : "")); - } - } - - static public void parseExpr(final Expr child, final Scope scope) { - // child.getText() shows child is the base - JythonDev.printTrace("Expr: " + child.getText() + ", " + child.getInternalValue() + ", " + child.getValue() + ", children: " + String.join(", ", child.getChildren().stream().map(PythonTree::toString).collect(Collectors.toList()))); - } - - /** - * Parse import statements, considering aliases. - * There can be more than one if e.g. commas were used, as in "from ij import IJ, ImageJ". - * @param im - * @return A map of simple class names or their aliases as keys versus a {@code DotAutocompletions} as value. - */ - static public Map parseImportFromStatement(final ImportFrom im) { - final Map classes = new HashMap<>(); - final String module = im.getModule().toString(); - for (int i=0; i parseImportStatement(final Import im) { - final Map classes = new HashMap<>(); - final List aliases = im.getInternalNames(); - for (final alias a: aliases) { - final String as = a.getInternalAsname(); - final String name = a.getInternalName(); - classes.put(null == as || "None" == as ? name : as, new StaticDotAutocompletions(name)); - } - return classes; - } - - static private DotAutocompletions maybeStaticToDot(final PythonTree node, final DotAutocompletions da) { - JythonDev.printTrace("children count:" + node.getChildCount() + ", children: " + (null != node.getChildren() ? node.getChildren().stream().map(c -> c.toString()).collect(Collectors.toList()) : "")); - if (node.getChildCount() > 0 && da instanceof StaticDotAutocompletions) { - // It's a right expression (a constructor invocation assigned to a variable on the left) so the left is an instance of the class - return new VarDotAutocompletions(da.getClassname()); - } - return da; - } - - /** - * Parse an assignment (an equal sign) to find out the class of the left side (the variable) - * by asking the right side about what it is or returns. - * There can be more than one variable when using deconstruction statements like e.g. "width, height = imp.getWidth(), imp.getHeight()". - * - * @param assign - * @param scope - * @return A map of variable names as keys versus {@code DotAutocompletions} as values. - */ - static public Map parseAssignStatement(final Assign assign, final Scope scope) { - final Map assigns = new HashMap<>(); - //final expr right = assign.getInternalValue(); // strangely this works - final PythonTree right = assign.getChildren().get(1); - final PythonTree left = assign.getChildren().get(0); - if (null != left.getChildren() && left.getChildren().size() > 1 - && (right instanceof Tuple || right instanceof org.python.antlr.ast.List)) { // TODO are there any other possible? - for (int i=0; i attrs = new ArrayList<>(); - while (leftn instanceof Attribute) { - final Attribute attr = (Attribute)leftn; - attrs.add(attr); - leftn = attr.getValue(); - } - if (leftn instanceof Name) { - String varName = ((Name)leftn).getInternalId(); - Collections.reverse(attrs); - Scope scopeC = scope; - for (final Attribute attr: attrs) { - final DotAutocompletions ac = scopeC.find(varName, DotAutocompletions.EMPTY); // in the first iteration it finds the completions for the base Name - if (ac instanceof ClassDotAutocompletions) { - final ClassDotAutocompletions cda = (ClassDotAutocompletions)ac; - varName = attr.getInternalAttrName().getInternalId(); // in the first iteration becomes the name of the first Attribute - // Add the name of the Attribute to the list of expansions for the prior varName - scopeC = cda.scope; // prepare scope for next iteration - //scopeC.vars.put(varName, cda); // Is this needed? I think it isn't - cda.put(new CompletionText(varName)); // add varName (e.g. "width") as a possible expansion for the prior varName (e.g. "self"). - } else { - // Don't know how to handle e.g. self.doThis().that = 10 because for "doThis()" there would be a class return type stored - break; - } - } - return assigns; - } - } - - return assigns; - } - - /** - * Adds a child Scope to the given parent Scope, and also a variable to the parent scope - * with no class, just for the function name. Then populates the child scope. - * - * @param fn - * @param parent - */ - static public void parseFunctionDef(final FunctionDef fn, final Scope parent) { - // Get the function name - final String name = fn.getInternalName(); - // Get the list of argument names, if any - arguments args = fn.getInternalArgs(); - final List argumentNames = args != null && args.getChildren() != null ? // why oh why not return an empty List - args.getChildren().stream().map(arg -> arg.getNode().toString()).collect(Collectors.toList()) - : Collections.emptyList(); - // Parse the function body - final List children = fn.getChildren(); - if (null == children) return; - final Scope fn_scope = new Scope(parent, null); - // Add arguments to the scope -- must be done BEFORE parseNode - for (final String arg: argumentNames) { - // Empty. For the first argument ("self" or similar) will be replaced later if it's part of a class definition. - fn_scope.vars.put(arg, new ClassDotAutocompletions("", Collections.emptyList(), Collections.emptyList(), - new ArrayList(), fn_scope)); - } - parseNode(fn_scope, fn.getChildren(), null); - // Get the return type, if any - final PythonTree last = fn.getChildren().get(fn.getChildCount() -1); - final String returnClassName = last instanceof Return ? parseRight(last.getChildren().get(0), fn_scope).toString() : null; - parent.vars.put(name, new DefVarDotAutocompletions(name, returnClassName, argumentNames, fn_scope)); - } - - /** - * Adds an entry to the parent scope with the python classname, e.g. "Volume", with its parameters (from its __init__ method if any), - * including as well the methods and fields from any superclass, - * and adds another entry in the class scope for "self" with all the class method names. - * - * @param c - * @param parent - */ - static public void parseClassDef(final ClassDef c, final Scope parent) { - final String pyClassname = c.getInternalName(); - final Scope class_scope = parseNode(c.getChildren(), parent, pyClassname); - // Methods of the class - final List classDotAutocompletions = new ArrayList<>(); - // Iterate vars of the scope, which are those of the class only - for (final DotAutocompletions da: class_scope.vars.values()) { - if (da instanceof DefVarDotAutocompletions) { - final DefVarDotAutocompletions dda = (DefVarDotAutocompletions)da; - classDotAutocompletions.add(new CompletionText(dda.fnName)); - } - } - // Superclasses - final List superclassNames = new ArrayList<>(); - for (final expr e: c.getInternalBases()) { - final DotAutocompletions da = parent.find(e.getText(), null); - if (null == da || null == da.getClassname()) - JythonDev.print("Could not find completions and className for " + e.getText()); - else - superclassNames.add(da.getClassname()); - } - // Search for the constructor __init__ if any to get the constructor parameters - final List argumentNames = new ArrayList<>(); - final ClassDotAutocompletions cda = new ClassDotAutocompletions(pyClassname, superclassNames, argumentNames, classDotAutocompletions, class_scope); - for (final PythonTree child: c.getChildren()) { - if (!(child instanceof FunctionDef)) continue; - final FunctionDef fn = (FunctionDef)child; - final List args = fn.getInternalArgs().getChildren(); - if (args.size() > 0) { - // Populate class constructor argument list by reading them from the __init__ method - if ("__init__".equals(fn.getInternalName())) { - // Add all arguments except the first one, which is the internal reference conventionally named "self" - argumentNames.addAll(args.subList(1, args.size()).stream() - .map(arg -> arg.getNode().toString()).collect(Collectors.toList())); - } - // Add completions to the first argument (generally "self") - // TODO check annotations, shouldn't add them if the function is static - final DefVarDotAutocompletions fnda = (DefVarDotAutocompletions)class_scope.vars.get(fn.getInternalName()); - final DotAutocompletions argda = fnda.scope.vars.get(args.get(0).getNode().toString()); - if (argda instanceof ClassDotAutocompletions) { - // Replace the autocompletions for the first argument with cda plus whichever autocompletions it accumulated within the function definition. - ((ClassDotAutocompletions)argda).mutateIntoPlus(cda); - } - //.scope.vars.put(args.get(0).getNode().toString(), cda); - } - } - - // Add to the parent scope for expansion of the constructor name plus parameters - parent.vars.put(pyClassname, cda); - } - - /** Discover the class returned by the right statement in an assignment. - * - * @param right - * @param scope - */ - /** - * Infers the type of a single expression, such as a function argument, in - * the given scope. - * - * @param expression The source text of the expression. - * @param scope The scope in which to resolve names. - * @return A class name, a primitive type name (e.g. "long" for an integer - * literal), or null if unknown. - */ - static public String typeOf(final String expression, final Scope scope) { - try { - final mod m = ParserFacade.parse(expression, CompileMode.eval, "", new CompilerFlags()); - if (!(m instanceof Expression)) return null; - return parseRight(((Expression) m).getInternalBody(), scope).getClassname(); - } catch (final Throwable t) { - // Incomplete or invalid expression. - return null; - } - } - - static public DotAutocompletions parseRight(final PyObject right, final Scope scope) { - if (right instanceof Name) { - // e.g. the name of another variable: - // imp2 = imp - // e.g. the name of a constructor or a function - // ip = ByteProcessor(512, 512) - return scope.find( ((Name)right).getInternalId(), DotAutocompletions.EMPTY); - } - if (right instanceof Num) { - // e.g. return 10 - // e.g. n = 42 - final Class c = ((Num)right).getInternalN().getClass() == PyInteger.class ? Long.TYPE : Double.TYPE; // TODO this creates trouble for Scope.findVarsByType and would be perhaps easier as Long and Double (non-primitive). - return new VarDotAutocompletions(c.toString()); // becomes "long" or "double" - } - if (right instanceof Str) { - // Literal String - return new VarDotAutocompletions(String.class.getCanonicalName()); - } - if (right instanceof Attribute) { - // e.g. a field or a method - // gray8 = IJ.getImage().GRAY8 - final Attribute attr = (Attribute)right; - final DotAutocompletions da = parseRight(attr.getValue(), scope); - // e.g. a class by its full name, after a package (or nothing known) - // img = net.imglib2.img.Img() - if (DotAutocompletions.EMPTY == da || da instanceof StaticDotAutocompletions) { - final String fullName = javaClass(attr); - if (null != fullName) - return new StaticDotAutocompletions(fullName); - } - if (DotAutocompletions.EMPTY == da) - return da; - final String name = attr.getInternalAttr(); - final String className = da.getClassname(); - try { - final Class c = Class.forName(className); - for (final Method m : c.getMethods()) - if (m.getName().equals(name)) - return new VarDotAutocompletions(m.getReturnType().getName()); - return new VarDotAutocompletions(c.getField(name).getType().getName()); - } catch (Exception e) { - JythonDev.print("Could not find method or field " + name + " in class " + className, e); - } - // Could also be a python module, e.g. attempting to autocomplete "os.path." - try { - final NModuleType module = Scope.indexer.loadModule(className + "." + name); // overly expensive: any way to check it exists without loading it? - if (null != module) - return new StaticDotAutocompletions(className + "." + name); - } catch (Exception e) { - JythonDev.printTrace("Not a python module: " + className + "." + name); - } - } - if (right instanceof Call) { - // e.g. a method call, in particular the last one in the chain - // imp = IJ.getImage().getProcessor() - final Call call = (Call)right; - return parseRight(call.getFunc(), scope); // getFunc() returns an Attribute or a Name - } - if (right instanceof Yield) { - final Yield yield = (Yield)right; - return parseRight(yield.getValue(), scope); - } - if (right instanceof BinOp) { - // e.g., division, multiplication, addition, subtraction - // What actual type is returned, I don't know: python allows operator overloading. - final BinOp binop = (BinOp)right; - // Weak attempt at finding out what is it (will not succeed if neither is a number) - // TODO this should be done recursively - JythonDev.printTrace("BinOp: weak attempt at finding out what kind of number it returns."); - // First, check if any is a number - final Class typeL = binop.getLeft().isNumberType() ? ((Num)binop.getLeft()).getInternalN().getClass() : null, - typeR = binop.getRight().isNumberType() ? ((Num)binop.getRight()).getInternalN().getClass() : null; - if (null != typeL || null != typeR) { - // If both are integers - if (typeL == PyInteger.class && typeR == PyInteger.class) - return new VarDotAutocompletions(Long.class.toString()); // long - // If any is a float - if (typeL == PyFloat.class || typeR == PyFloat.class) - return new VarDotAutocompletions(Double.class.toString()); // double - } - // Go by whatever left is, or if empty, whatever right is: no guarantee to be correct, but can be correct often - final DotAutocompletions da = parseRight(binop.getLeft(), scope); - if (da != DotAutocompletions.EMPTY) - return da; - return parseRight(binop.getRight(), scope); - } - - JythonDev.printTrace("Unsupported 'right' is: " + right + " " + (right != null ? right.getClass() : "")); - - return DotAutocompletions.EMPTY; - } - - /** - * The Java class a dotted name (e.g. {@code java.util.Map.Entry}) is, by - * its full name; or null if none (e.g. it starts with a variable). - */ - static private String javaClass(final Attribute attr) { - final StringBuilder name = new StringBuilder(attr.getInternalAttr()); - PyObject node = attr.getValue(); - while (node instanceof Attribute) { - name.insert(0, ((Attribute)node).getInternalAttr() + "."); - node = ((Attribute)node).getValue(); - } - if (!(node instanceof Name)) return null; - name.insert(0, ((Name)node).getInternalId() + "."); - // NB: A package starts lowercase; a class, uppercase. - if (!Character.isLowerCase(name.charAt(0))) return null; - String candidate = name.toString(); - while (true) { - try { - return Class.forName(candidate).getName(); - } catch (ClassNotFoundException | LinkageError e) { - // Perhaps a nested class: e.g. java.util.Map$Entry. - final int dot = candidate.lastIndexOf('.'); - if (dot < 0 || !Character.isUpperCase(candidate.charAt(candidate.lastIndexOf('.', dot - 1) + 1))) return null; - candidate = candidate.substring(0, dot) + "$" + candidate.substring(dot + 1); - } - } - } -} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonServerPlugin.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonServerPlugin.java deleted file mode 100644 index 012fb98..0000000 --- a/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonServerPlugin.java +++ /dev/null @@ -1,95 +0,0 @@ -/*- - * #%L - * JSR-223-compliant Jython scripting language plugin. - * %% - * Copyright (C) 2020 - 2025 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.plugins.scripting.jython.completion; - -import java.io.File; - -import org.eclipse.lsp4j.services.LanguageServer; -import org.scijava.Priority; -import org.scijava.code.lsp.LanguageServerService; -import org.scijava.code.lsp.Environment; -import org.scijava.code.lsp.LanguageServerPlugin; -import org.scijava.code.lsp.ScriptDialect; -import org.scijava.plugin.Parameter; -import org.scijava.plugin.Plugin; - -/** - * Offers Jython's in-process language server (see - * {@link JythonLanguageServer}), with a Python language server beside it if - * one is offered (see {@link PythonCompanion}); both see scripts as plain - * Python (see {@link JythonDialect}). - * - * @author Gabriel Selzer - */ -@Plugin(type = LanguageServerPlugin.class, name = "Jython", - priority = Priority.HIGH) -public class JythonServerPlugin implements LanguageServerPlugin { - - @Parameter(required = false) - private LanguageServerService servers; - - private JavaStubs stubs; - - @Override - public boolean supports(final String languageId) { - // NB: Jython scripts too, by their language's name. Not "python", which - // this builds on (see PythonCompanion). - return "jython".equals(languageId); - } - - @Override - public LanguageServer create(final String languageId, - final Environment environment) - { - return new JythonLanguageServer(environment, PythonCompanion.launch( - servers, stubs())); - } - - @Override - public ScriptDialect dialect() { - return JythonLanguageServer.DIALECT; - } - - /** Sets the stubs, e.g. to write them elsewhere in tests. */ - synchronized void setStubs(final JavaStubs stubs) { - this.stubs = stubs; - } - - /** Gets the stubs of Java packages, creating them on first use. */ - synchronized JavaStubs stubs() { - if (stubs != null) return stubs; - final String dir = System.getProperty("scijava.jython.stubs"); - stubs = new JavaStubs(dir != null ? new File(dir) : new File(System - .getProperty("user.home"), ".cache" + File.separator + "scijava" + - File.separator + "jython-stubs"), Thread.currentThread() - .getContextClassLoader()); - return stubs; - } - -} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/completion/PythonCompanion.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/PythonCompanion.java deleted file mode 100644 index b2142d1..0000000 --- a/src/main/java/org/scijava/plugins/scripting/jython/completion/PythonCompanion.java +++ /dev/null @@ -1,258 +0,0 @@ -/*- - * #%L - * JSR-223-compliant Jython scripting language plugin. - * %% - * Copyright (C) 2020 - 2025 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.plugins.scripting.jython.completion; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.concurrent.CompletableFuture; -import java.util.function.Function; - -import org.eclipse.lsp4j.CompletionItem; -import org.eclipse.lsp4j.CompletionList; -import org.eclipse.lsp4j.CompletionParams; -import org.eclipse.lsp4j.DidChangeTextDocumentParams; -import org.eclipse.lsp4j.DidCloseTextDocumentParams; -import org.eclipse.lsp4j.DidOpenTextDocumentParams; -import org.eclipse.lsp4j.Hover; -import org.eclipse.lsp4j.HoverParams; -import org.eclipse.lsp4j.InitializeParams; -import org.eclipse.lsp4j.InitializedParams; -import org.eclipse.lsp4j.SignatureHelp; -import org.eclipse.lsp4j.SignatureHelpParams; -import org.eclipse.lsp4j.jsonrpc.messages.Either; -import org.eclipse.lsp4j.services.LanguageServer; -import org.eclipse.lsp4j.services.TextDocumentService; -import org.scijava.code.lsp.Environment; -import org.scijava.code.lsp.LanguageServerService; - -/** - * A Python language server (e.g. jedi-language-server, as code-lsp-jedi - * offers) beside Jython's own analysis (see {@link JythonLanguageServer}): - * for Python modules, and for Java classes, from Python stubs of the Java - * packages a script imports (including those of its parameters' types, - * declared in its text; see {@link JavaStubs}, written in the background). - *

- * The server analyzes scripts as CPython (Jython's standard library is Python - * 2's, but mostly the same): so its completions follow Jython's own, it is - * asked for signatures and hover only where Jython has none, and never for - * problems. - *

- * - * @author Gabriel Selzer - */ -final class PythonCompanion { - - /** The server. */ - private final LanguageServer server; - private final JavaStubs stubs; - - private PythonCompanion(final LanguageServer python, final JavaStubs stubs) { - this.stubs = stubs; - // NB: No client: the server's problems are not asked for. - server = python; - server.initialize(new InitializeParams()).thenRun(() -> server - .initialized(new InitializedParams())); - } - - /** - * Gets a Python server beside Jython's, or null if none is offered. - * - * @param servers Offers the server. - * @param stubs The stubs of Java packages, for the server to search. - */ - static PythonCompanion launch(final LanguageServerService servers, - final JavaStubs stubs) - { - if (servers == null || !servers.supports("python")) return null; - return new PythonCompanion(servers.launch("python", environment(stubs)), - stubs); - } - - /** Where the server runs: its own Python, searching the Java stubs. */ - static Environment environment(final JavaStubs stubs) { - return new Environment(null, Collections.singletonList(stubs.dir() - .getAbsolutePath()), null); - } - - // -- Documents -- - - void didOpen(final DidOpenTextDocumentParams params) { - ensureStubs(params.getTextDocument().getText()); - documents().didOpen(params); - } - - void didChange(final DidChangeTextDocumentParams params) { - if (params.getContentChanges().isEmpty()) return; - ensureStubs(params.getContentChanges().get(params.getContentChanges() - .size() - 1).getText()); - documents().didChange(params); - } - - void didClose(final DidCloseTextDocumentParams params) { - documents().didClose(params); - } - - CompletableFuture shutdown() { - return server.shutdown(); - } - - // -- Requests: Jython's answers first -- - - /** - * Jython's completions, then the server's that complete other names. Each - * keeps its own order. - */ - CompletableFuture, CompletionList>> completion( - final CompletionParams params, final CompletionList own) - { - return orNull(documents().completion(params)).thenApply(theirs -> Either - .forRight(merge(own, list(theirs)))); - } - - /** Resolves a completion: the server's with the server, others with own. */ - CompletableFuture resolveCompletionItem( - final CompletionItem item, - final Function> own) - { - if (!(item.getData() instanceof FromPython)) return own.apply(item); - final FromPython from = (FromPython) item.getData(); - final String sortText = item.getSortText(); - item.setData(from.data); - item.setSortText(from.sortText); - return orNull(documents().resolveCompletionItem(item)).thenApply( - resolved -> { - final CompletionItem out = resolved != null ? resolved : item; - out.setData(from); - out.setSortText(sortText); - return out; - }); - } - - /** Jython's signatures, if any; otherwise the server's. */ - CompletableFuture signatureHelp( - final SignatureHelpParams params, final SignatureHelp own) - { - if (own != null && own.getSignatures() != null && !own.getSignatures() - .isEmpty()) return CompletableFuture.completedFuture(own); - return orNull(documents().signatureHelp(params)); - } - - /** Jython's hover, if any; otherwise the server's. */ - CompletableFuture hover(final HoverParams params, final Hover own) { - if (own != null && own.getContents() != null) { - return CompletableFuture.completedFuture(own); - } - return orNull(documents().hover(params)); - } - - // -- Helper methods -- - - private TextDocumentService documents() { - return server.getTextDocumentService(); - } - - /** Writes the stubs of the packages the script imports, in the background. */ - private void ensureStubs(final String text) { - stubs.ensure(JavaStubs.imports(text)); - } - - /** - * Jython's completions, then the server's whose names Jython has not - * completed (e.g. Python modules), each in its own order. - */ - static CompletionList merge(final CompletionList own, - final CompletionList theirs) - { - if (theirs == null || theirs.getItems() == null || theirs.getItems() - .isEmpty()) return own; - final List out = new ArrayList<>(); - final Set names = new HashSet<>(); - int i = 0; - for (final CompletionItem item : sorted(own)) { - names.add(name(item)); - item.setSortText(String.format("0%06d", i++)); - out.add(item); - } - for (final CompletionItem item : sorted(theirs)) { - if (names.contains(name(item))) continue; - item.setData(new FromPython(item.getData(), item.getSortText())); - item.setSortText(String.format("1%06d", i++)); - out.add(item); - } - return new CompletionList(own.isIncomplete() || theirs.isIncomplete(), - out); - } - - private static List sorted(final CompletionList list) { - final List items = new ArrayList<>(); - if (list != null && list.getItems() != null) items.addAll(list.getItems()); - items.sort(Comparator.comparing(c -> c.getSortText() != null ? c - .getSortText() : c.getLabel())); - return items; - } - - /** What a completion completes: its label, without any parameters. */ - private static String name(final CompletionItem item) { - final String label = item.getLabel(); - final int paren = label.indexOf('('); - return (paren < 0 ? label : label.substring(0, paren)).trim(); - } - - private static CompletionList list( - final Either, CompletionList> answer) - { - if (answer == null) return null; - return answer.isRight() ? answer.getRight() : new CompletionList(answer - .getLeft()); - } - - /** The answer; or null, if the server failed (e.g. it stopped). */ - private static CompletableFuture orNull( - final CompletableFuture answer) - { - return answer == null ? CompletableFuture.completedFuture(null) : answer - .exceptionally(error -> null); - } - - /** Marks a completion as the server's, keeping what it came with. */ - private static final class FromPython { - - private final Object data; - private final String sortText; - - private FromPython(final Object data, final String sortText) { - this.data = data; - this.sortText = sortText; - } - } -} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/completion/Scope.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/Scope.java deleted file mode 100644 index b5db456..0000000 --- a/src/main/java/org/scijava/plugins/scripting/jython/completion/Scope.java +++ /dev/null @@ -1,346 +0,0 @@ -/*- - * #%L - * Jython language support for SciJava Script Editor. - * %% - * Copyright (C) 2020 - 2024 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.plugins.scripting.jython.completion; - -import java.io.File; -import java.nio.file.FileSystems; -import java.nio.file.Path; -import java.nio.file.StandardWatchEventKinds; -import java.nio.file.WatchKey; -import java.nio.file.WatchService; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.Hashtable; -import java.util.List; -import java.util.Map; -import java.util.TreeMap; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import org.python.indexer.Indexer; -import org.python.indexer.types.NModuleType; - - - -public class Scope { - final Scope parent; - final String className; // if any - final private List children = new ArrayList<>(); - final HashMap imports = new HashMap<>(); - final HashMap vars = new HashMap<>(); - - /** Access to jython's builtins (functions in the global scope) and default modules (array, itertools, csv, etc.) - * as well as to an other user-defined modules. - */ - static final Indexer indexer = new Indexer(); - - static private Thread module_watcher; - static private WatchService watcher; - static private Hashtable keys = new Hashtable<>(); - - static { - try { - watcher = FileSystems.getDefault().newWatchService(); - module_watcher = new Thread() { - @Override - public void run() { - while (true) { - if (isInterrupted()) return; - WatchKey key = null; - try { - key = watcher.take(); // waits until there is an event - } catch (InterruptedException x) { - return; - } - if (keys.containsKey(key)) { - JythonDev.print("Python module at:\n" + keys.get(key) + "\n ... was updated. Clearing indexer cache."); - // One of the files changed: unload all, given that parent modules would have been loaded as well - // and it gets complicated quickly to find out which need to be reloaded and which don't. - keys.clear(); - synchronized (indexer) { - indexer.clearModuleTable(); - } - } - } - } - }; - module_watcher.setPriority(Thread.NORM_PRIORITY); - module_watcher.start(); - } catch (Exception e ){ - JythonDev.print("Failed to start filesystem watcher service for python modules", e); - } - } - - /** - * Load a python module and watch its file, if any. - * When the file is updated or deleted, all loaded modules will be removed from the cache, - * because the loading of a module may trigger the loading of a parent module - * or additional modules that it links to. - * - * @param qname - * @return The python module. - */ - static NModuleType loadPythonModule(final String qname) { - synchronized (indexer) { - NModuleType mod = null; - try { - mod = indexer.loadModule(qname); - if (null == mod) return null; - } catch (Exception e) { - JythonDev.print("Could not load python module named " + qname, e); - return null; - } - try { - // Watch ALL THE FILES in the containing directly - final String qname_slash = qname.replace(".", "/"); - final String filepath = indexer.getLoadedFiles().stream() - .filter(s -> s.endsWith("/" + qname_slash + ".py") || s.endsWith("/" + qname_slash + "/__init__.py")).findFirst().orElse(null); - if (null != filepath) { - final Path path = new File(filepath).getParentFile().toPath(); // watching directories - final WatchKey key = path.register(watcher, - StandardWatchEventKinds.ENTRY_MODIFY, - StandardWatchEventKinds.ENTRY_DELETE); - keys.put(key, path); - } else { - JythonDev.print("Python module " + qname + " doesn't have an associated file path."); - } - } catch (Exception e) { - JythonDev.print("Could not load python module named " + qname, e); - } - return mod; - } - } - - public Scope(final Scope parent) { - this(parent, null); - } - - public Scope(final Scope parent, final String className) { - this.parent = parent; - if (null != parent) { - parent.children.add(this); - } - this.className = className; - } - - public boolean isEmpty() { - return imports.isEmpty() && vars.isEmpty(); - } - - public DotAutocompletions find(final String name, final DotAutocompletions default_value) { - Scope scope = this; - while (null != scope) { - DotAutocompletions da = scope.vars.get(name); - if (null == da) - da = scope.imports.get(name); - if (null != da) - return da; - scope = scope.parent; - } - // Check python builtins - final String builtin_className = "__builtin__." + name + "."; // e.g. __builtin__.str.join - final List dotAutocompletions = indexer.getBindings().keySet().stream() - .filter(s -> s.startsWith(builtin_className)) - .map(s -> new CompletionText(s.substring(builtin_className.length()))) - .collect(Collectors.toList()); - if (!dotAutocompletions.isEmpty()) - return new ClassDotAutocompletions(name, Collections.emptyList(), Collections.emptyList(), dotAutocompletions, this); - - return default_value; - } - - public List findStartsWith(final String name) { - final List completions = new ArrayList<>(); - Scope scope = this; - while (null != scope) { - for (final String varName: scope.vars.keySet()) { - if (varName.startsWith(name)) completions.add(varName); - } - for (final String importName: scope.imports.keySet()) { - if (importName.startsWith(name)) completions.add(importName); - } - for (String builtinName: indexer.getBindings().keySet()) { - if (builtinName.startsWith("__builtin__.")) - builtinName = builtinName.substring(12); // without the "__builtin__." prefix - if (builtinName.startsWith(name)) completions.add(builtinName); - } - scope = scope.parent; - } - return completions; - } - - /** - * Return a table of names vs classnames, sorted by name. The classname is - * non-null only for names bound to a class (e.g. an imported Java class), whose - * constructors can then be offered; it is null for variables, functions and - * python builtins. Names in inner scopes shadow those of outer scopes. - * - * @param name - * @return - */ - public Map findStartsWith2(final String name) { - final Map completions = new TreeMap<>(); - Scope scope = this; - while (null != scope) { - for (final Map.Entry e: scope.vars.entrySet()) { - if (e.getKey().startsWith(name)) completions.putIfAbsent(e.getKey(), classnameOf(e.getValue())); - } - for (final Map.Entry e: scope.imports.entrySet()) { - if (e.getKey().startsWith(name)) completions.putIfAbsent(e.getKey(), classnameOf(e.getValue())); - } - scope = scope.parent; - } - for (String builtinName: indexer.getBindings().keySet()) { - if (builtinName.startsWith("__builtin__.")) - builtinName = builtinName.substring(12); // without the "__builtin__." prefix - // Skip members of builtin types, e.g. "tuple.__len__" - if (builtinName.indexOf('.') >= 0) continue; - if (builtinName.startsWith(name)) completions.putIfAbsent(builtinName, null); - } - return completions; - } - - /** The class a name refers to, if it names a class rather than an instance. */ - private static String classnameOf(final DotAutocompletions da) { - return da instanceof StaticDotAutocompletions ? da.getClassname() : null; - } - - /** Find vars by type, recursively upstream the nested scopes, listing first those of the innermost scope. - * - * @param clazz - */ - public Stream findVarsByType(final String type, final Class clazz) { - Stream varNames = new ArrayList().stream(); - Scope scope = this; - JythonDev.printTrace("Scope.findVarsByType: searching for type " + type + " and class " + clazz.getCanonicalName()); - while (null != scope) { - varNames = Stream.concat(varNames, - scope.vars.entrySet().stream() - .filter(e -> { - try { - JythonDev.printTrace("Scope.findVarsByType, testing: " + e.getKey() + " :: " + e.getValue() + " with class " + e.getValue().getClassname()); - if (e.getKey().startsWith("____")) return false; // injected variables in JythonAutoCompletions.completionsFor - final String classname = e.getValue().getClassname(); - if (null == classname) return false; - if (type.equals(classname)) { - JythonDev.printTrace("type == classname: " + type); - return true; - } - // Handle compatible numeric arguments - if (Number.class.isAssignableFrom(clazz)) { - // Python only has long or float - if (classname.equals("float")) { - return true; // all numeric types will fit - } - if (classname.equals("long")) { - // or "long", but that was exact-matched earlier - return type.equals("int") - || type.equals("short") - || type.equals("byte") - || clazz.isAssignableFrom(Long.class) - || clazz.isAssignableFrom(Integer.class) - || clazz.isAssignableFrom(Short.class) - || clazz.isAssignableFrom(Byte.class); - } - } - // Fix class when it's a primitive number - Class c = null; - switch (classname) { - case "long": c = Long.class; break; - case "float": c = Float.class; break; - default: c = Class.forName(classname); - } - // Search for subclass or interface - return clazz.isAssignableFrom(c); - - // TODO: search for methods of non-matching classes that return a matching type - - } catch (ClassNotFoundException e1) { - JythonDev.print("Cannot load class " + e.getValue(), e1); - } - return false; - }) - .map(e -> e.getKey())); - scope = scope.parent; - } - return varNames; - } - - - public HashMap getImports() { - final HashMap imports = new HashMap<>(this.imports); - Scope parent = this.parent; - while (parent != null) { - imports.putAll(parent.getImports()); - parent = parent.parent; - } - return imports; - } - - public HashMap getVars() { - final HashMap vars = new HashMap<>(this.vars); - Scope parent = this.parent; - while (parent != null) { - vars.putAll(parent.getVars()); - parent = parent.parent; - } - return vars; - } - - public boolean isClass() { - return this.className != null; - } - - public Scope getLast() { - if (children.isEmpty()) return this; - return children.get(children.size() -1).getLast(); - } - - public void print(final String indent) { - if ("" == indent) { - System.out.println("scope global:"); - } - System.out.println(indent + "available imports: " + String.join(", ", this.getImports().keySet())); - System.out.println(indent + "declared imports:"); - for (final Map.Entry e: imports.entrySet()) - System.out.println(indent + " import :: " + e.getKey() + " --> " + e.getValue()); - System.out.println(indent + "available vars: " + String.join(", ", this.getVars().keySet())); - System.out.println(indent + "declared vars:"); - for (final Map.Entry e: vars.entrySet()) { - System.out.println(indent + " var :: " + e.getKey() + " = " + e.getValue()); - } - - int i = 0; - for (final Scope child: children) { - System.out.println(indent + "scope[" + (i++) + "]:"); - child.print(indent + " "); - } - } -} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/completion/StaticDotAutocompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/StaticDotAutocompletions.java deleted file mode 100644 index cb3a19e..0000000 --- a/src/main/java/org/scijava/plugins/scripting/jython/completion/StaticDotAutocompletions.java +++ /dev/null @@ -1,105 +0,0 @@ -/*- - * #%L - * Jython language support for SciJava Script Editor. - * %% - * Copyright (C) 2020 - 2024 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.plugins.scripting.jython.completion; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.stream.Collectors; - -import org.python.indexer.types.NModuleType; - -public class StaticDotAutocompletions implements DotAutocompletions -{ - final String className; - public StaticDotAutocompletions(final String className) { - this.className = className; - } - @Override - public String getClassname() { - return this.className; - } - @Override - public List get() { - final List ac = new ArrayList<>(); - if (null != this.className) { - String msg = ""; - try { - // Check first if it's a python module - final NModuleType module = Scope.loadPythonModule(this.className); // Scope.indexer.getBuiltinModule(this.className); - JythonDev.printTrace("module is: " + module); - if (null != module) { - module.getTable().keySet().forEach( m -> ac.add(new CompletionText(m))); - // Not need to remove: a file system watcher will do so when the module file is updated or deleted. - // Scope.indexer.moduleTable.remove(this.className); - return ac; - } else { - msg += "Not a python module: " + this.className; - } - // Or a java class: - try { - DotAutocompletions.staticFieldsAndStaticMethodsInto(Class.forName(this.className), ac); - return ac; - } catch (ClassNotFoundException cnfe) { - msg += "\nCannot find java class " + this.className; - } - // Or a static method of a java class - try { - final int idot = this.className.lastIndexOf('.'); - final String name = this.className.substring(idot + 1); - final Class c = Class.forName(this.className.substring(0, idot)); - // There could be more than one method, with more than one return type - for (final Class r: Arrays.asList(c.getMethods()).stream() - .filter(m -> m.getName().equals(name)) - .map(m -> m.getReturnType()) - .distinct() - .collect(Collectors.toList())) { - DotAutocompletions.fieldsAndMethodsInto(r, ac); - } - return ac; - } catch (ClassNotFoundException cnfe) { - msg += "\nCannot derive static method or field from " + this.className; - } - - if (null != msg) { - JythonDev.print(msg); - } - } catch (Exception e) { - JythonDev.print(msg, e); - } - } - return ac; - } - - @Override - public String toString() { - final String completions = get().stream().map(c -> c.getReplacementText()).collect(Collectors.joining(",", "[", "]")); - return "StaticDotAutocompletions: " + className + " -- " + completions; - } -} diff --git a/src/main/java/org/scijava/plugins/scripting/jython/completion/VarDotAutocompletions.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/VarDotAutocompletions.java deleted file mode 100644 index 60a0a0c..0000000 --- a/src/main/java/org/scijava/plugins/scripting/jython/completion/VarDotAutocompletions.java +++ /dev/null @@ -1,77 +0,0 @@ -/*- - * #%L - * Jython language support for SciJava Script Editor. - * %% - * Copyright (C) 2020 - 2024 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.plugins.scripting.jython.completion; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.stream.Stream; - - -public class VarDotAutocompletions implements DotAutocompletions { - String className; - public VarDotAutocompletions(final String className) { - this.className = className; - } - @Override - public String getClassname() { - return this.className; - } - @Override - public List get() { - final List ac = new ArrayList<>(); - if (null != className) { - try { - final Class c = Class.forName(className); - DotAutocompletions.fieldsAndMethodsInto(c, ac); - } catch (final Exception e) { - JythonDev.print("Could not load class " + className, e); - } - } - return ac; - } - - @Override - public Stream getStream() { - try { - final Class c = Class.forName(this.className); - return Stream.concat( - Arrays.stream(c.getFields()).map(f -> new CompletionText(f.getName(), c, f)), - Arrays.stream(c.getMethods()).map(m -> new CompletionText(m.getName(), c, m))); - } catch (final Exception e) { - JythonDev.print("Could not load class " + className, e); - } - return Stream.empty(); - } - - @Override - public String toString() { - return "VarDotAutocompletions: " + this.className; - } -} diff --git a/src/test/java/org/scijava/plugins/scripting/jython/completion/JavaStubsTest.java b/src/test/java/org/scijava/plugins/scripting/jython/completion/JavaStubsTest.java deleted file mode 100644 index c073172..0000000 --- a/src/test/java/org/scijava/plugins/scripting/jython/completion/JavaStubsTest.java +++ /dev/null @@ -1,198 +0,0 @@ -/*- - * #%L - * JSR-223-compliant Jython scripting language plugin. - * %% - * Copyright (C) 2020 - 2025 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.plugins.scripting.jython.completion; - -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.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Set; -import java.util.TreeSet; -import java.util.concurrent.TimeUnit; -import java.util.function.Function; - -import org.junit.Test; - -/** - * Tests {@link JavaStubs}. - * - * @author Gabriel Selzer - */ -public class JavaStubsTest { - - private static final String PKG = - "org.scijava.plugins.scripting.jython.completion"; - - /** Lists just the example classes. */ - private static final Function> CLASSES = pkg -> PKG - .equals(pkg) ? Collections.singletonList(PKG + ".JavaStubsTest") : pkg - .equals("java.util") ? Collections.singletonList("java.util.Locale") - : Collections.emptyList(); - - @Test - public void testStub() { - final JavaStubs stubs = new JavaStubs(new File("unused"), getClass() - .getClassLoader(), pkg -> Collections.singletonList(PKG + - ".JavaStubsTest")); - final Set refs = new TreeSet<>(); - final String stub = stubs.stub(PKG, refs); - // The test class: its nested classes, inside. - assertTrue(stub, stub.contains("\nclass JavaStubsTest(java.lang.Object):\n")); - assertTrue(stub, stub.contains( - "\n class Picture(JavaStubsTest.Base, java.lang.Cloneable):\n")); - // Fields; constant ones as class variables. - assertTrue(stub, stub.contains(" MAX: typing.ClassVar[int]\n")); - assertTrue(stub, stub.contains(" title: str\n")); - // Overloads, constructors too; static methods; Java types as Python's. - // NB: Parameter names are the Java ones only if compiled with them. - has(stub, " @typing.overload\n" + - " def __init__\\(self, \\w+: str\\) -> None: \\.\\.\\.\n"); - has(stub, " @typing.overload\n" + - " def resize\\(self, \\w+: int, \\w+: int\\) -> " + - "JavaStubsTest\\.Picture: \\.\\.\\.\n"); - has(stub, " @typing.overload\n" + - " def resize\\(self, \\w+: float\\) -> JavaStubsTest\\.Picture"); - has(stub, " @staticmethod\n" + - " def open\\(\\w+: str, \\*\\w+: str\\) -> " + - "JavaStubsTest\\.Picture: \\.\\.\\.\n"); - assertTrue(stub, stub.contains(" def pixels(self) -> list[float]: ...\n")); - assertTrue(stub, stub.contains( - " def locale(self) -> java.util.Locale: ...\n")); - // Getters as properties, as Jython offers them. - assertTrue(stub, stub.contains(" width: int\n")); - assertTrue(stub, stub.contains(" opaque: bool\n")); - // Not Python: left out. - assertFalse(stub, stub.contains("def lambda")); - // Other packages: imported, and noted. - assertTrue(stub, stub.contains("\nimport java.util\n")); - assertEquals(new TreeSet<>(Arrays.asList("java.lang", "java.util")), refs); - // No classes: no stub. - assertNull(new JavaStubs(new File("unused"), getClass().getClassLoader(), - pkg -> Collections.emptyList()).stub("os", new TreeSet<>())); - } - - @Test - public void testEnsure() throws Exception { - final File dir = Files.createTempDirectory("jython-stubs").toFile(); - try { - final JavaStubs stubs = new JavaStubs(dir, getClass().getClassLoader(), - CLASSES); - stubs.ensure(Collections.singleton(PKG)).get(30, TimeUnit.SECONDS); - final File stub = new File(dir, PKG.replace('.', File.separatorChar) + - File.separator + "__init__.pyi"); - assertTrue(stub.isFile()); - // Parents are packages too. - assertTrue(new File(dir, "org" + File.separator + "__init__.pyi") - .isFile()); - // The packages referred to follow (here, java.util; java.lang has no - // classes listed). - for (int i = 0; i < 100 && !new File(dir, "java/util/__init__.pyi") - .isFile(); i++) Thread.sleep(50); - assertTrue(new String(Files.readAllBytes(new File(dir, - "java/util/__init__.pyi").toPath()), StandardCharsets.UTF_8).contains( - "class Locale(")); - assertFalse(new File(dir, "java/lang/__init__.pyi").exists()); - } - finally { - try (java.util.stream.Stream paths = Files.walk(dir - .toPath())) - { - paths.sorted(java.util.Comparator.reverseOrder()).forEach(p -> p - .toFile().delete()); - } - } - } - - @Test - public void testImports() { - assertEquals(new TreeSet<>(Arrays.asList("ij", "java.util", "os", - "ij.process")), JavaStubs.imports("from ij import IJ, ImagePlus\n" + - "import java.util\nimport os, sys\n from ij.process import *\n" + - "x = 1 # import nothing\nfrom . import y\n")); - } - - private static void has(final String stub, final String regex) { - assertTrue(regex + " in:\n" + stub, java.util.regex.Pattern.compile(regex) - .matcher(stub).find()); - } - - // -- Example classes -- - - public static class Base { - - public boolean isOpaque() { - return true; - } - } - - public static class Picture extends Base implements Cloneable { - - public static final int MAX = 1 << 16; - public String title; - - public Picture(final String title) { - this.title = title; - } - - public Picture(final String title, final int width) { - this(title); - } - - public Picture resize(final int width, final int height) { - return this; - } - - public Picture resize(final double scale) { - return this; - } - - public static Picture open(final String path, final String... options) { - return null; - } - - public double[] pixels() { - return null; - } - - public java.util.Locale locale() { - return null; - } - - public int getWidth() { - return 0; - } - } -} diff --git a/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonServerCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java similarity index 59% rename from src/test/java/org/scijava/plugins/scripting/jython/completion/JythonServerCompletionTest.java rename to src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java index 46a2437..4872644 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonServerCompletionTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java @@ -29,91 +29,47 @@ package org.scijava.plugins.scripting.jython.completion; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; -import java.io.File; -import java.nio.file.Files; -import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; -import org.eclipse.lsp4j.CompletionItem; -import org.eclipse.lsp4j.CompletionList; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.scijava.Context; -import org.scijava.code.lsp.Environment; import org.scijava.code.lsp.LanguageServerService; import org.scijava.script.ScriptLanguage; import org.scijava.script.ScriptService; /** - * Tests {@link PythonCompanion}: a Python language server's environment for - * Jython scripts (Java stubs); and {@link JythonDialect}. + * Tests {@link JythonDialect}. * * @author Gabriel Selzer */ -public class JythonServerCompletionTest { +public class JythonDialectTest { private Context context; private ScriptLanguage jython; - private File stubsDir; - private JavaStubs stubs; @Before - public void setUp() throws Exception { + public void setUp() { context = new Context(ScriptService.class, LanguageServerService.class); jython = context.service(ScriptService.class).getLanguageByName("Jython"); - stubsDir = Files.createTempDirectory("jython-stubs").toFile(); - stubs = new JavaStubs(stubsDir, getClass().getClassLoader(), - pkg -> Collections.emptyList()); } @After public void tearDown() { context.dispose(); - stubsDir.delete(); } @Test - public void testOnlyWithAPythonServer() { - // NB: No plugin offers a Python server here (code-lsp-jedi does, but is - // left out of the tests; see pom.xml): Jython is served by its own - // analysis alone. - assertNull(PythonCompanion.launch(context.service( - LanguageServerService.class), stubs)); - } - - @Test - public void testEnvironment() { - // The server's own Python, searching the Java stubs. - final Environment env = PythonCompanion.environment(stubs); - assertEquals(null, env.interpreter()); - assertEquals(Collections.singletonList(stubsDir.getAbsolutePath()), env - .paths()); - } - - @Test - public void testMerge() { - final CompletionItem own = new CompletionItem("max"); - own.setData(1); - final CompletionList merged = PythonCompanion.merge(new CompletionList( - Arrays.asList(new CompletionItem("min"), own)), new CompletionList(Arrays - .asList(new CompletionItem("max"), new CompletionItem("math")))); - // Jython's first, each in its own order; the server's only for names - // Jython has not completed. - assertEquals(Arrays.asList("max", "min", "math"), merged.getItems().stream() - .map(CompletionItem::getLabel).collect(Collectors.toList())); - assertEquals(1, merged.getItems().get(0).getData()); - final List sortTexts = merged.getItems().stream().map( - CompletionItem::getSortText).collect(Collectors.toList()); - final List sorted = new ArrayList<>(sortTexts); - Collections.sort(sorted); - assertEquals(sorted, sortTexts); + public void testScriptsAreServed() { + // Jython scripts become plain Jython, which scijava-code-lsp-jython + // serves. + final JythonDialect dialect = new JythonDialect(); + assertTrue(dialect.supports(jython)); + assertEquals("jython", dialect.languageId()); + assertTrue(context.service(LanguageServerService.class).supports(jython)); } @Test From e28d1c240b2528ef4a1c34f34dd55dd51cbc5d69 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Fri, 16 Oct 2026 12:15:25 -0500 Subject: [PATCH 23/29] Test only the dialect: the server's tests move to code-lsp-jython JythonDialectTest keeps what needs SciJava scripts (#@ parameters). Co-Authored-By: Claude Opus 5.5 --- .../completion/JythonCompletionTest.java | 320 ------------------ .../jython/completion/JythonDialectTest.java | 57 +++- 2 files changed, 56 insertions(+), 321 deletions(-) delete mode 100644 src/test/java/org/scijava/plugins/scripting/jython/completion/JythonCompletionTest.java diff --git a/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonCompletionTest.java deleted file mode 100644 index df55486..0000000 --- a/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonCompletionTest.java +++ /dev/null @@ -1,320 +0,0 @@ -/*- - * #%L - * JSR-223-compliant Jython scripting language plugin. - * %% - * Copyright (C) 2020 - 2025 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.plugins.scripting.jython.completion; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -import java.util.List; -import java.util.function.Supplier; -import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; - -import javax.script.ScriptEngine; - -import org.eclipse.lsp4j.CompletionItem; -import org.eclipse.lsp4j.SignatureHelp; -import org.junit.Test; -import org.scijava.Context; -import org.scijava.code.lsp.jvm.ClassIndex; -import org.scijava.code.lsp.LanguageServerService; -import org.scijava.code.lsp.ScriptSession; -import org.scijava.script.ScriptLanguage; -import org.scijava.script.ScriptService; - -/** - * Tests {@link JythonLanguageServer}, through the - * {@link LanguageServerService}, as editors use it. - * - * @author Curtis Rueden - */ -public class JythonCompletionTest { - - @Test - public void testMemberCompletionViaService() { - final Context ctx = new Context(ScriptService.class, - LanguageServerService.class); - try { - final ScriptLanguage jython = ctx.service(ScriptService.class) - .getLanguageByName("Jython"); - assertNotNull("Jython language not found", jython); - - // A Jython server must be offered for the Jython language. - final LanguageServerService servers = ctx.service( - LanguageServerService.class); - assertTrue(servers.supports(jython)); - - // Static analysis: 's' is a String, so 's.' completes to String members. - final List texts = labels(items(servers.session(jython), - "s = \"hello\"\ns.")); - assertTrue("expected s.length among " + texts, texts.contains("s.length")); - assertTrue("expected s.charAt among " + texts, texts.contains("s.charAt")); - } - finally { - ctx.dispose(); - } - } - - @Test - public void testNameAtStartOfLine() { - final List texts = complete("greeting = \"hi\"\ngree"); - assertTrue("expected greeting among " + texts, texts.contains("greeting")); - } - - @Test - public void testVariableNameNotDuplicated() { - // A String variable must not also offer String's constructors. - final List texts = complete("greeting = \"hi\"\nx = gree"); - assertEquals(texts.toString(), 1, texts.stream().filter( - "greeting"::equals).count()); - assertFalse("unexpected leading space in " + texts, texts.contains( - " greeting")); - } - - @Test - public void testKeywordsAndBuiltins() { - assertTrue(complete("imp").contains("import")); - final List texts = complete("x = le"); - assertTrue("expected len among " + texts, texts.contains("len")); - assertFalse("unexpected builtin member in " + texts, texts.stream() - .anyMatch(t -> t.contains("."))); - } - - @Test - public void testImportedClassName() { - ClassIndex.ensureCache(); - final List items = completions( - "from java.util import ArrayList\nArrayList"); - final List texts = labels(items); - assertTrue("expected ArrayList among " + texts, texts.contains( - "ArrayList")); - // Once as a name; otherwise as its constructors. - assertEquals("expected one plain ArrayList among " + texts, 1, items - .stream().filter(c -> c.getLabel().equals("ArrayList") && c - .getLabelDetails() == null).count()); - } - - @Test - public void testJdkClassImport() { - ClassIndex.ensureCache(); - final List texts = complete("from java.util import ArrayLi"); - assertTrue("expected ArrayList import among " + texts, texts.contains( - "from java.util import ArrayList")); - } - - @Test - public void testScriptParameters() { - final String header = "" + // - "#@ String (label=\"Please enter your name\") name\n" + // - "#@output String greeting\n" + // - "#@ int count\n" + // - "#@ NoSuchType mystery\n"; - assertTrue(complete(header + "x = na").contains("name")); - assertTrue(complete(header + "myst").contains("mystery")); - // As the Python values Jython converts them to: e.g. strings are str, - // whose members are Python's, not java.lang.String's. - final List nameMembers = complete(header + "name.upp"); - assertTrue("expected name.upper among " + nameMembers, nameMembers - .contains("name.upper")); - assertTrue(complete(header + "name.toUpp").isEmpty()); - // Outputs are known too, even before the script assigns them. - assertTrue(complete(header + "greeting.st").contains("greeting.strip")); - // Numbers are int, not java.lang.Integer. - assertFalse(complete(header + "count.intV").contains("count.intValue")); - // Packages imported only to declare those are not completed. - assertFalse(complete(header + "j").contains("java.lang")); - } - - @Test - public void testAssignmentShadowsScriptParameter() { - final List texts = complete( - "#@ String name\nfrom java.util import ArrayList\nname = ArrayList()\nname.ensureCap"); - assertTrue("expected ArrayList member among " + texts, texts.contains( - "name.ensureCapacity")); - } - - @Test - public void testInheritedMembers() { - final List texts = complete( - "from java.util import ArrayList\nlist = ArrayList()\nlist."); - // members come from superinterfaces - assertTrue(texts.contains("list.add")); - // members come from superclasses - assertTrue(texts.contains("list.toString")); - } - - @Test - public void testCallablesAreSnippets() { - final CompletionItem charAt = completions("s = 'x'\ns.charA").stream() - .filter(c -> c.getLabel().equals("s.charAt")).findFirst().orElse(null); - assertNotNull(charAt); - assertEquals("(int arg0)", charAt.getLabelDetails().getDetail().replaceAll( - " \\w+\\)$", " arg0)")); - assertTrue(charAt.getTextEdit().getLeft().getNewText().startsWith( - "s.charAt(${1:")); - } - - @Test - public void testArgumentChoices() { - // In a call's arguments: the variables of the parameter's type first. - final List items = completions( - "from java.lang import Math\nx = 1.5\ny = 'a'\nMath.abs("); - assertTrue(items.toString(), !items.isEmpty()); - assertEquals("x", items.get(0).getLabel()); - assertEquals(Boolean.TRUE, items.get(0).getPreselect()); - } - - @Test - public void testInterpreterBindings() throws Exception { - final Context ctx = new Context(); - try { - final ScriptLanguage jython = ctx.service(ScriptService.class) - .getLanguageByName("Jython"); - final ScriptEngine engine = jython.getScriptEngine(); - engine.put("injected", new java.util.ArrayList()); - engine.eval("from java.util import Collections, HashMap\n" + - "m = HashMap()\n" + - "def helper(): pass\n"); - final LanguageServerService servers = ctx.service( - LanguageServerService.class); - final Supplier live = () -> servers.session(jython, engine - .getContext()); - - assertTrue(labels(items(live.get(), "m.putIfAb")).contains( - "m.putIfAbsent")); - assertTrue(labels(items(live.get(), "injected.si")).contains( - "injected.size")); - assertTrue(labels(items(live.get(), "x = hel")).contains("helper")); - // An imported Java class offers its static members. - assertTrue(labels(items(live.get(), "Collections.emptyL")).contains( - "Collections.emptyList")); - // Python internals are not offered. - assertFalse(labels(items(live.get(), "x = __")).contains("__name__")); - } - finally { - ctx.dispose(); - } - } - - @Test - public void testSignatureHelp() { - // A Python float fits the floating-point overloads, which come first. - final SignatureHelp max = help("from java.lang import Math\n" + - "Math.max(1.5, "); - assertEquals(Integer.valueOf(1), max.getActiveParameter()); - assertEquals("[double, float, int, long]", describe(max)); - // A Python int fits the integral ones, and converts to the others. - assertEquals("[int, long, double, float]", describe(help( - "from java.lang import Math\nMath.max(1, 2"))); - // Arguments' types come from the analysis, e.g. of variables. - final SignatureHelp indexOf = help("s = 'abc'\nc = 'b'\n" + - "s.indexOf(c, "); - assertTrue(describe(indexOf), describe(indexOf).startsWith( - "[java.lang.String")); - // Constructors. - assertTrue(describe(help("from java.util import ArrayList\n" + - "ArrayList(")).contains("java.util.Collection")); - // None outside calls. - assertNull(help("x = 1")); - } - - // -- Helper methods -- - - private static SignatureHelp help(final String code) { - final Context ctx = new Context(ScriptService.class, - LanguageServerService.class); - try { - final ScriptLanguage jython = ctx.service(ScriptService.class) - .getLanguageByName("Jython"); - try (ScriptSession session = ctx.service(LanguageServerService.class) - .session(jython)) - { - return session.signatureHelp(code, code.length()).get(30, - TimeUnit.SECONDS); - } - } - catch (final Exception exc) { - throw new RuntimeException(exc); - } - finally { - ctx.dispose(); - } - } - - /** Each signature's first parameter type, in order. */ - private static String describe(final SignatureHelp help) { - return help.getSignatures().stream().map(s -> { - final String first = s.getParameters().isEmpty() ? "()" : s - .getParameters().get(0).getLabel().getLeft(); - final String type = first.contains(" ") ? first.substring(0, first - .lastIndexOf(' ')) : first; - return type; - }).collect(Collectors.toList()).toString(); - } - - private static List complete(final String code) { - return labels(completions(code)); - } - - private static List labels(final List items) { - return items.stream().map(CompletionItem::getLabel).collect(Collectors - .toList()); - } - - private static List completions(final String code) { - final Context ctx = new Context(ScriptService.class, - LanguageServerService.class); - try { - final ScriptLanguage jython = ctx.service(ScriptService.class) - .getLanguageByName("Jython"); - return items(ctx.service(LanguageServerService.class).session(jython), - code); - } - finally { - ctx.dispose(); - } - } - - private static List items(final ScriptSession session, - final String code) - { - try (ScriptSession s = session) { - final List items = new java.util.ArrayList<>(s.completion( - code, code.length()).get(30, TimeUnit.SECONDS).getItems()); - items.sort((a, b) -> a.getSortText().compareTo(b.getSortText())); - return items; - } - catch (final Exception exc) { - throw new RuntimeException(exc); - } - } -} diff --git a/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java b/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java index 4872644..87e6c18 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java @@ -29,20 +29,27 @@ package org.scijava.plugins.scripting.jython.completion; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import org.eclipse.lsp4j.CompletionItem; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.scijava.Context; import org.scijava.code.lsp.LanguageServerService; +import org.scijava.code.lsp.ScriptSession; import org.scijava.script.ScriptLanguage; import org.scijava.script.ScriptService; /** - * Tests {@link JythonDialect}. + * Tests {@link JythonDialect}: Jython scripts, as plain Jython for + * scijava-code-lsp-jython's server (which its own tests test). * * @author Gabriel Selzer */ @@ -94,4 +101,52 @@ public void testDeclarations() { "net.imglib2.img.Img", "str", "java.util.Map$Entry", "array.array"))); assertEquals("net", dialect.imported("import net.imglib2.img")); } + + @Test + public void testScriptParameters() { + final String header = "" + // + "#@ String (label=\"Please enter your name\") name\n" + // + "#@output String greeting\n" + // + "#@ int count\n" + // + "#@ NoSuchType mystery\n"; + assertTrue(complete(header + "x = na").contains("name")); + assertTrue(complete(header + "myst").contains("mystery")); + // As the Python values Jython converts them to: e.g. strings are str, + // whose members are Python's, not java.lang.String's. + final List nameMembers = complete(header + "name.upp"); + assertTrue("expected name.upper among " + nameMembers, nameMembers + .contains("name.upper")); + assertTrue(complete(header + "name.toUpp").isEmpty()); + // Outputs are known too, even before the script assigns them. + assertTrue(complete(header + "greeting.st").contains("greeting.strip")); + // Numbers are int, not java.lang.Integer. + assertFalse(complete(header + "count.intV").contains("count.intValue")); + // Packages imported only to declare those are not completed. + assertFalse(complete(header + "j").contains("java.lang")); + } + + + @Test + public void testAssignmentShadowsScriptParameter() { + final List texts = complete( + "#@ String name\nfrom java.util import ArrayList\nname = ArrayList()\nname.ensureCap"); + assertTrue("expected ArrayList member among " + texts, texts.contains( + "name.ensureCapacity")); + } + + + // -- Helper methods -- + + private List complete(final String code) { + try (ScriptSession session = context.service(LanguageServerService.class) + .session(jython)) + { + return session.completion(code, code.length()).get(30, TimeUnit.SECONDS) + .getItems().stream().map(CompletionItem::getLabel).collect(Collectors + .toList()); + } + catch (final Exception exc) { + throw new RuntimeException(exc); + } + } } From 6a9c499158088c880b536ec845d771fb4fd0e8f9 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Fri, 16 Oct 2026 14:27:09 -0500 Subject: [PATCH 24/29] Follow scijava-code-lsp's rename of runtimeType to parameterType Co-Authored-By: Claude Opus 5.5 --- .../scripting/jython/completion/JythonDialect.java | 2 +- .../jython/completion/JythonDialectTest.java | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDialect.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDialect.java index ffd3354..6e52766 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDialect.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDialect.java @@ -68,7 +68,7 @@ public String languageId() { } @Override - public String runtimeType(final Class type) { + public String parameterType(final Class type) { final Class t = boxed(type); // NB: As Jython converts them (Py.java2py). if (t == String.class || t == Character.class) return "str"; diff --git a/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java b/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java index 87e6c18..7aea9b7 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java @@ -85,13 +85,13 @@ public void testDeclarations() { assertEquals("img = net.imglib2.img.Img()", dialect.declare("img", "net.imglib2.img.Img")); // As Jython converts them: e.g. strings and numbers to Python's. - assertEquals("str", dialect.runtimeType(String.class)); - assertEquals("int", dialect.runtimeType(int.class)); - assertEquals("int", dialect.runtimeType(Long.class)); - assertEquals("float", dialect.runtimeType(double.class)); - assertEquals("bool", dialect.runtimeType(boolean.class)); - assertEquals("array.array", dialect.runtimeType(double[].class)); - assertEquals("java.math.BigDecimal", dialect.runtimeType( + assertEquals("str", dialect.parameterType(String.class)); + assertEquals("int", dialect.parameterType(int.class)); + assertEquals("int", dialect.parameterType(Long.class)); + assertEquals("float", dialect.parameterType(double.class)); + assertEquals("bool", dialect.parameterType(boolean.class)); + assertEquals("array.array", dialect.parameterType(double[].class)); + assertEquals("java.math.BigDecimal", dialect.parameterType( java.math.BigDecimal.class)); assertEquals("n = int()", dialect.declare("n", "int")); assertEquals("e = java.util.Map.Entry()", dialect.declare("e", From 2711b87d29b02987dfea317b0530ead44e9b2708 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Fri, 16 Oct 2026 17:26:03 -0500 Subject: [PATCH 25/29] Follow scijava-code-lsp's ScriptDialect.header The dialect writes its imports and declarations in one header line, and names the packages it imports only for them, to hide from completion. Co-Authored-By: Claude Opus 5.5 --- .../jython/completion/JythonDialect.java | 60 +++++++++++-------- .../jython/completion/JythonDialectTest.java | 50 ++++++++++------ 2 files changed, 68 insertions(+), 42 deletions(-) diff --git a/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDialect.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDialect.java index 6e52766..d92949b 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDialect.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDialect.java @@ -32,6 +32,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.TreeSet; @@ -41,10 +42,11 @@ import org.scijava.script.ScriptLanguage; /** - * How Jython scripts see their script parameters: as the Python values Jython - * converts them to (e.g. {@code str} for strings, {@code int} for integers, - * {@code array.array} for arrays), or else as the Java objects themselves - * (e.g. {@code net.imglib2.img.Img}). + * Describes how to turn Jython scripts into plain Jython code. Scripts see + * their parameters as the Python values Jython converts them to (e.g. + * {@code str} for strings, {@code int} for integers, {@code array.array} for + * arrays), or else as the Java objects themselves (e.g. + * {@code net.imglib2.img.Img}). *

* Each is declared as an instance of its type, in code both Jython's own * analysis (Python 2) and a Python language server knowing the Java packages @@ -68,7 +70,36 @@ public String languageId() { } @Override - public String parameterType(final Class type) { + public Header header(final Map> parameters) { + // Declare each parameter as an instance of its type, for type reasoning + final List types = new ArrayList<>(); + final List declarations = new ArrayList<>(); + parameters.forEach((name, type) -> { + final String t = parameterType(type); + types.add(t); + declarations.add(name + " = " + t.replace('$', '.') + "()"); + }); + // And import the packages of those types, before the declarations + final List code = new ArrayList<>(); + final Set hidden = new TreeSet<>(); + for (final String p : packages(types)) { + code.add("import " + p); + // NB: "import a.b" binds a. + hidden.add(p.split("\\.")[0]); + } + code.addAll(declarations); + // Combine into a Header + return new Header(String.join("; ", code), hidden); + } + + /** + * @param type the Java type (of a SciJava Script parameter) declared in the + * script + * @return the type of the object given to the script by Jython: a Python + * type for the values Jython converts (e.g. {@code str}), or else + * the Java type itself + */ + static String parameterType(final Class type) { final Class t = boxed(type); // NB: As Jython converts them (Py.java2py). if (t == String.class || t == Character.class) return "str"; @@ -80,25 +111,6 @@ public String parameterType(final Class type) { return t.getName(); } - @Override - public String declare(final String name, final String type) { - return name + " = " + type.replace('$', '.') + "()"; - } - - @Override - public List imports(final Collection types) { - final List imports = new ArrayList<>(); - for (final String p : packages(types)) imports.add("import " + p); - return imports; - } - - @Override - public String imported(final String statement) { - // NB: "import a.b" binds a. - return statement.startsWith("import ") ? statement.substring(7).split( - "[.\\s]")[0] : null; - } - /** * The packages or modules of the given types that a declaration refers to * (none for builtins, e.g. {@code str}). diff --git a/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java b/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java index 7aea9b7..6037fba 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java @@ -33,7 +33,9 @@ import static org.junit.Assert.assertTrue; import java.util.Arrays; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @@ -43,6 +45,7 @@ import org.junit.Test; import org.scijava.Context; import org.scijava.code.lsp.LanguageServerService; +import org.scijava.code.lsp.ScriptDialect.Header; import org.scijava.code.lsp.ScriptSession; import org.scijava.script.ScriptLanguage; import org.scijava.script.ScriptService; @@ -80,26 +83,37 @@ public void testScriptsAreServed() { } @Test - public void testDeclarations() { - final JythonDialect dialect = new JythonDialect(); - assertEquals("img = net.imglib2.img.Img()", dialect.declare("img", - "net.imglib2.img.Img")); + public void testParameterType() { // As Jython converts them: e.g. strings and numbers to Python's. - assertEquals("str", dialect.parameterType(String.class)); - assertEquals("int", dialect.parameterType(int.class)); - assertEquals("int", dialect.parameterType(Long.class)); - assertEquals("float", dialect.parameterType(double.class)); - assertEquals("bool", dialect.parameterType(boolean.class)); - assertEquals("array.array", dialect.parameterType(double[].class)); - assertEquals("java.math.BigDecimal", dialect.parameterType( + assertEquals("str", JythonDialect.parameterType(String.class)); + assertEquals("int", JythonDialect.parameterType(int.class)); + assertEquals("int", JythonDialect.parameterType(Long.class)); + assertEquals("float", JythonDialect.parameterType(double.class)); + assertEquals("bool", JythonDialect.parameterType(boolean.class)); + assertEquals("array.array", JythonDialect.parameterType( + double[].class)); + // Or else as the Java objects themselves. + assertEquals("java.math.BigDecimal", JythonDialect.parameterType( java.math.BigDecimal.class)); - assertEquals("n = int()", dialect.declare("n", "int")); - assertEquals("e = java.util.Map.Entry()", dialect.declare("e", - "java.util.Map$Entry")); - assertEquals(Arrays.asList("import array", "import java.util", - "import net.imglib2.img"), dialect.imports(Arrays.asList( - "net.imglib2.img.Img", "str", "java.util.Map$Entry", "array.array"))); - assertEquals("net", dialect.imported("import net.imglib2.img")); + } + + @Test + public void testHeader() { + // Each parameter is declared as an instance of its type, whose package + // is imported. + final Map> parameters = new LinkedHashMap<>(); + parameters.put("d", java.math.BigDecimal.class); + parameters.put("n", int.class); + parameters.put("e", Map.Entry.class); + parameters.put("a", double[].class); + final Header header = new JythonDialect().header(parameters); + assertEquals("import array; import java.math; import java.util; " + + "d = java.math.BigDecimal(); n = int(); " + + "e = java.util.Map.Entry(); a = array.array()", header.code()); + // The names the imports bind ("import a.b" binds a) are the header's + // alone. + assertEquals(Arrays.asList("array", "java"), Arrays.asList(header + .hiddenNames().toArray())); } @Test From a5f35535c0d38e689f6f715d21093041f606ea79 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Fri, 16 Oct 2026 17:29:47 -0500 Subject: [PATCH 26/29] Need scijava-code-lsp-jython only to test The dialect names its server only by language id: applications choose their servers, as scripting-groovy now has them do. Co-Authored-By: Claude Opus 5.5 --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index b8f0201..bd02e91 100644 --- a/pom.xml +++ b/pom.xml @@ -115,8 +115,8 @@ org.scijava scijava-code-lsp-jython ${scijava-code-lsp-jython.version} - - runtime + + test From 7371b048b05e7e5f5e3a5dc47faae39e9169b9d6 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Tue, 20 Oct 2026 09:50:45 -0500 Subject: [PATCH 27/29] Follow scijava-code-lsp: sessions are made with the service Co-Authored-By: Claude Opus 5.5 --- .../scripting/jython/completion/JythonDialectTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java b/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java index 6037fba..b8eacb4 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java @@ -152,8 +152,8 @@ public void testAssignmentShadowsScriptParameter() { // -- Helper methods -- private List complete(final String code) { - try (ScriptSession session = context.service(LanguageServerService.class) - .session(jython)) + try (ScriptSession session = new ScriptSession(context.service( + LanguageServerService.class), jython)) { return session.completion(code, code.length()).get(30, TimeUnit.SECONDS) .getItems().stream().map(CompletionItem::getLabel).collect(Collectors From f692d17a1c2c0a2694c689aad6246afed73eb19c Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Wed, 21 Oct 2026 15:19:11 -0500 Subject: [PATCH 28/29] Test completion with scijava-code-lsp-jython's jedi scijava-code-lsp-jython now analyzes Jython with jedi-language-server, whose completions are labeled by name alone (e.g. "upper", not "name.upper"). Co-Authored-By: Claude Opus 5.5 --- pom.xml | 18 ------------------ .../jython/completion/JythonDialectTest.java | 12 ++++++------ 2 files changed, 6 insertions(+), 24 deletions(-) diff --git a/pom.xml b/pom.xml index bd02e91..16d6bdf 100644 --- a/pom.xml +++ b/pom.xml @@ -133,22 +133,4 @@ - - - - maven-surefire-plugin - - - - org.scijava:scijava-code-lsp-jedi - - - - - diff --git a/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java b/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java index b8eacb4..a3d1042 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java @@ -128,15 +128,15 @@ public void testScriptParameters() { // As the Python values Jython converts them to: e.g. strings are str, // whose members are Python's, not java.lang.String's. final List nameMembers = complete(header + "name.upp"); - assertTrue("expected name.upper among " + nameMembers, nameMembers - .contains("name.upper")); + assertTrue("expected upper among " + nameMembers, nameMembers.contains( + "upper")); assertTrue(complete(header + "name.toUpp").isEmpty()); // Outputs are known too, even before the script assigns them. - assertTrue(complete(header + "greeting.st").contains("greeting.strip")); + assertTrue(complete(header + "greeting.st").contains("strip")); // Numbers are int, not java.lang.Integer. - assertFalse(complete(header + "count.intV").contains("count.intValue")); + assertFalse(complete(header + "count.intV").contains("intValue")); // Packages imported only to declare those are not completed. - assertFalse(complete(header + "j").contains("java.lang")); + assertFalse(complete(header + "j").contains("java")); } @@ -145,7 +145,7 @@ public void testAssignmentShadowsScriptParameter() { final List texts = complete( "#@ String name\nfrom java.util import ArrayList\nname = ArrayList()\nname.ensureCap"); assertTrue("expected ArrayList member among " + texts, texts.contains( - "name.ensureCapacity")); + "ensureCapacity")); } From 848558c762e28ec37f03fc3f9c8503e4294da5e3 Mon Sep 17 00:00:00 2001 From: Gabriel Selzer Date: Fri, 23 Oct 2026 12:48:37 -0500 Subject: [PATCH 29/29] Declare a live interpreter's variables by their values Following scijava-code-lsp's ScriptDialect.header Variables: imported Java classes as the classes themselves, modules imported again (under their own names, e.g. "import ntpath as osp"), functions as functions, and other Python values as instances of their builtin types. Names Python reserves are skipped. Co-Authored-By: Claude Opus 5.5 --- .../jython/completion/JythonDialect.java | 120 +++++++++++++++++- .../jython/completion/JythonDialectTest.java | 70 +++++++++- 2 files changed, 177 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDialect.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDialect.java index d92949b..e3e703e 100644 --- a/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDialect.java +++ b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDialect.java @@ -28,14 +28,21 @@ */ package org.scijava.plugins.scripting.jython.completion; +import java.lang.reflect.Modifier; import java.math.BigInteger; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeSet; +import org.python.core.PyFunction; +import org.python.core.PyJavaType; +import org.python.core.PyModule; +import org.python.core.PyObject; import org.scijava.code.lsp.ScriptDialect; import org.scijava.plugin.Plugin; import org.scijava.plugins.scripting.jython.JythonScriptLanguage; @@ -53,12 +60,26 @@ * from stubs (see scijava-code-lsp-jython) read: e.g. * {@code import net.imglib2.img; image = net.imglib2.img.Img(); name = str()}. *

+ *

+ * A live interpreter's variables are declared by their values, where those + * say more: its imported Java classes as the classes themselves, its modules + * imported again, its functions as functions, and its other Python values as + * instances of their builtin types. + *

* * @author Gabriel Selzer */ @Plugin(type = ScriptDialect.class, name = "Jython") public class JythonDialect implements ScriptDialect { + /** Names Python reserves, which cannot be declared. */ + private static final Set KEYWORDS = new HashSet<>(Arrays.asList( + "False", "None", "True", "and", "as", "assert", "async", "await", "break", + "class", "continue", "def", "del", "elif", "else", "except", "exec", + "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", + "nonlocal", "not", "or", "pass", "print", "raise", "return", "try", + "while", "with", "yield")); + @Override public boolean supports(final ScriptLanguage language) { return language instanceof JythonScriptLanguage; @@ -70,14 +91,14 @@ public String languageId() { } @Override - public Header header(final Map> parameters) { - // Declare each parameter as an instance of its type, for type reasoning + public Header header(final Map variables) { + // Declare each variable, for type reasoning final List types = new ArrayList<>(); final List declarations = new ArrayList<>(); - parameters.forEach((name, type) -> { - final String t = parameterType(type); - types.add(t); - declarations.add(name + " = " + t.replace('$', '.') + "()"); + variables.forEach((name, v) -> { + // NB: Names Python reserves cannot be declared. + if (!KEYWORDS.contains(name)) declarations.add(declaration(name, v, + types)); }); // And import the packages of those types, before the declarations final List code = new ArrayList<>(); @@ -92,6 +113,93 @@ public Header header(final Map> parameters) { return new Header(String.join("; ", code), hidden); } + /** + * Declares a variable: by its value, if known (e.g. a live interpreter's + * imported Java class, as the class itself), or else as an instance of its + * type. + * + * @param types Where to note the types the declaration refers to (see + * {@link #packages}). + */ + static String declaration(final String name, final Variable v, + final List types) + { + final Object value = v.value().orElse(null); + // A Java class (e.g. after "from java.util import ArrayList"): itself + final Class c = value instanceof PyJavaType ? ((PyJavaType) value) + .getProxyType() : value instanceof Class ? (Class) value : null; + if (c != null) { + if (!isNamed(c)) return name + " = " + any(types); + types.add(c.getName()); + return name + " = " + c.getName().replace('$', '.'); + } + // A Python module (e.g. after "import os"): imported again + if (value instanceof PyModule) { + final PyObject module = ((PyModule) value).__findattr__("__name__"); + if (module != null && module.toString().matches("[\\w.]+")) { + return "import " + module + " as " + name; + } + } + // A function (e.g. after "def f(): ...") + if (value instanceof PyFunction) { + return name + " = lambda *args, **kwargs: None"; + } + // Another Python value (e.g. a dict): an instance of its builtin type + if (value instanceof PyObject) { + final String builtin = pythonType(((PyObject) value).getType() + .getName()); + return name + " = " + (builtin != null ? builtin + "()" : any(types)); + } + // A Java value, or a script parameter: an instance of its type + final String t = parameterType(v.type()); + types.add(t); + return name + " = " + t.replace('$', '.') + "()"; + } + + /** + * A value of any type (noting the module that says so), for variables + * without a type to declare. + */ + private static String any(final List types) { + types.add("typing.Any"); + return "typing.cast(typing.Any, None)"; + } + + /** + * The Python 3 type of a Python 2 builtin one (as the Python server knows + * Python 3's), e.g. {@code str} for {@code unicode}; or null if not a + * builtin one. + */ + private static String pythonType(final String type) { + switch (type) { + case "int": + case "long": + return "int"; + case "unicode": + return "str"; + case "bool": + case "float": + case "str": + case "list": + case "dict": + case "tuple": + case "set": + return type; + default: + return null; + } + } + + /** Whether code can refer to a class by its name. */ + private static boolean isNamed(final Class c) { + if (c.isPrimitive() || c.isArray() || c.isAnonymousClass() || c + .isLocalClass() || c.getName().indexOf('.') < 0) return false; + for (Class t = c; t != null; t = t.getEnclosingClass()) { + if (!Modifier.isPublic(t.getModifiers())) return false; + } + return true; + } + /** * @param type the Java type (of a SciJava Script parameter) declared in the * script diff --git a/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java b/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java index a3d1042..13d2294 100644 --- a/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java +++ b/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java @@ -39,13 +39,18 @@ import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import javax.script.ScriptContext; +import javax.script.ScriptEngine; + import org.eclipse.lsp4j.CompletionItem; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.scijava.Context; import org.scijava.code.lsp.LanguageServerService; +import org.python.jsr223.PyScriptEngineFactory; import org.scijava.code.lsp.ScriptDialect.Header; +import org.scijava.code.lsp.ScriptDialect.Variable; import org.scijava.code.lsp.ScriptSession; import org.scijava.script.ScriptLanguage; import org.scijava.script.ScriptService; @@ -101,11 +106,11 @@ public void testParameterType() { public void testHeader() { // Each parameter is declared as an instance of its type, whose package // is imported. - final Map> parameters = new LinkedHashMap<>(); - parameters.put("d", java.math.BigDecimal.class); - parameters.put("n", int.class); - parameters.put("e", Map.Entry.class); - parameters.put("a", double[].class); + final Map parameters = new LinkedHashMap<>(); + parameters.put("d", Variable.ofType(java.math.BigDecimal.class)); + parameters.put("n", Variable.ofType(int.class)); + parameters.put("e", Variable.ofType(Map.Entry.class)); + parameters.put("a", Variable.ofType(double[].class)); final Header header = new JythonDialect().header(parameters); assertEquals("import array; import java.math; import java.util; " + "d = java.math.BigDecimal(); n = int(); " + @@ -116,6 +121,31 @@ public void testHeader() { .hiddenNames().toArray())); } + @Test + public void testLiveHeader() throws Exception { + // A live interpreter's variables, by their values. + final ScriptEngine engine = new PyScriptEngineFactory().getScriptEngine(); + engine.put("injected", new java.util.ArrayList()); + engine.eval("import os\n" + // + "from java.util import HashMap\n" + // + "def helper(): pass\n" + // + "pairs = {1: 2}\n"); + final Map live = new LinkedHashMap<>(); + for (final String name : Arrays.asList("injected", "os", "HashMap", + "helper", "pairs")) + { + final Object value = engine.get(name); + live.put(name, Variable.ofValue(value, value.getClass())); + } + final Header header = new JythonDialect().header(live); + assertEquals("import java.util; " + // + "injected = java.util.ArrayList(); " + // + "import os as os; " + // + "HashMap = java.util.HashMap; " + // + "helper = lambda *args, **kwargs: None; " + // + "pairs = dict()", header.code()); + } + @Test public void testScriptParameters() { final String header = "" + // @@ -149,11 +179,37 @@ public void testAssignmentShadowsScriptParameter() { } + @Test + public void testInterpreterBindings() throws Exception { + // Code typed into a running interpreter: its variables. + final ScriptEngine engine = new PyScriptEngineFactory().getScriptEngine(); + engine.put("injected", new java.util.ArrayList()); + engine.eval("from java.util import Collections, HashMap\n" + + "m = HashMap()\n" + + "def helper(): pass\n" + + "import os.path as osp\n"); + final ScriptContext live = engine.getContext(); + assertTrue(complete("m.putIfAb", live).contains("putIfAbsent")); + assertTrue(complete("injected.si", live).contains("size")); + assertTrue(complete("x = hel", live).contains("helper")); + // An imported Java class offers its static members. + assertTrue(complete("Collections.emptyL", live).contains("emptyList")); + // A module bound under another name is the module itself. + assertTrue(complete("osp.joi", live).contains("join")); + } + // -- Helper methods -- private List complete(final String code) { - try (ScriptSession session = new ScriptSession(context.service( - LanguageServerService.class), jython)) + return complete(code, null); + } + + /** Completions at the end of the code, typed into the given interpreter. */ + private List complete(final String code, final ScriptContext live) { + final LanguageServerService servers = context.service( + LanguageServerService.class); + try (ScriptSession session = live == null ? new ScriptSession(servers, + jython) : new ScriptSession(servers, jython, live)) { return session.completion(code, code.length()).get(30, TimeUnit.SECONDS) .getItems().stream().map(CompletionItem::getLabel).collect(Collectors