diff --git a/pom.xml b/pom.xml index 8ef0f03..04a8278 100644 --- a/pom.xml +++ b/pom.xml @@ -84,6 +84,9 @@ org.scijava.plugins.scripting.jython + 2.101.0 + 0.1.0-SNAPSHOT + bsd_2 SciJava developers. **/script_templates/** @@ -98,12 +101,22 @@ org.scijava scijava-common + + org.scijava + scijava-code-api + ${scijava-code-api.version} + 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..7eb8e4e --- /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.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.code.api.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..7d03af9 --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonAutoCompletions.java @@ -0,0 +1,534 @@ +/*- + * #%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.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.stream.Collectors; + +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; + +/** + * 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_]+)$"); + + /** 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 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(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, predefined, completions); + + // 2) Import and class-name discovery, always appended. + importCompletions(alreadyEnteredText, codeWithoutLastLine + lastLine, + completions); + + return new Result(dedupe(completions), choices); + } + + // -- AST-based completions -- + + private ParameterChoices 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(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, 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(Completion.builder((head + keyword).substring(crop)) + .kind(Completion.Kind.KEYWORD).build()); + } + } + 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, + 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 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, 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 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); + 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 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 -- + + /** + * 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) + { + 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..bc996c8 --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonCodeCompleter.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.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.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 + * 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, 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), + * 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/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..0bc5f2e --- /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.code.api.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..869f0e0 --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/jython/JythonScriptParser.java @@ -0,0 +1,447 @@ +/*- + * #%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) { + 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 + */ + 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..d83a041 --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/jython/Scope.java @@ -0,0 +1,346 @@ +/*- + * #%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.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/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..fd36a81 --- /dev/null +++ b/src/test/java/org/scijava/plugins/scripting/jython/JythonCompletionTest.java @@ -0,0 +1,228 @@ +/*- + * #%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.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.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 + * 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(); + } + } + + @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(); + } + } +}