diff --git a/README.md b/README.md index c46d24b..5005c2a 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,25 @@ in addition to being usable directly as a `javax.script.ScriptEngineFactory`, it also provides some functionality on top, such as the ability to generate lines of script code based on SciJava events. +## Code completion + +The Script Editor completes code in Jython scripts: Java classes, their +members and constructors (with their overloads), imports, the script's `#@` +parameters and, in a live interpreter, its variables. This is Jython's own +completion, which knows Java exactly, by `scijava-code-lsp-jython`'s language +server; this plugin shows it the scripts as plain Jython (`JythonDialect`), +their parameters declared. + +A Python language server (jedi-language-server, offered by +`scijava-code-lsp-jedi`, which runs it in a small environment of its own, +built the first time it is needed) adds its completions too: for Python +modules, and for Java classes, from Python type stubs (`.pyi`) of the Java +packages the script imports, written in the background +to `~/.cache/scijava/jython-stubs` (or `-Dscijava.jython.stubs=`). The +server analyzes the script as CPython (Jython's standard library is Python +2's, but mostly the same); so it is asked for completions only, not for +problems. + For a complete list of scripting languages available as part of the SciJava platform, see the [Scripting](https://github.com/scijava/scijava-common/wiki/Scripting) page on diff --git a/pom.xml b/pom.xml index 8ef0f03..16d6bdf 100644 --- a/pom.xml +++ b/pom.xml @@ -84,6 +84,14 @@ org.scijava.plugins.scripting.jython + 2.101.0 + 0.1.0-SNAPSHOT + 0.1.0-SNAPSHOT + + 2.14.0 + + 2.19.2 + bsd_2 SciJava developers. **/script_templates/** @@ -98,6 +106,18 @@ org.scijava scijava-common + + org.scijava + scijava-code-lsp + ${scijava-code-lsp.version} + + + org.scijava + scijava-code-lsp-jython + ${scijava-code-lsp-jython.version} + + test + @@ -112,4 +132,5 @@ test + diff --git a/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDialect.java b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDialect.java new file mode 100644 index 0000000..d92949b --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/jython/completion/JythonDialect.java @@ -0,0 +1,140 @@ +/*- + * #%L + * JSR-223-compliant Jython scripting language plugin. + * %% + * Copyright (C) 2020 - 2025 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ +package org.scijava.plugins.scripting.jython.completion; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +import org.scijava.code.lsp.ScriptDialect; +import org.scijava.plugin.Plugin; +import org.scijava.plugins.scripting.jython.JythonScriptLanguage; +import org.scijava.script.ScriptLanguage; + +/** + * Describes how to turn Jython scripts into plain Jython code. Scripts see + * their parameters as the Python values Jython converts them to (e.g. + * {@code str} for strings, {@code int} for integers, {@code array.array} for + * arrays), or else as the Java objects themselves (e.g. + * {@code net.imglib2.img.Img}). + *

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

+ * + * @author Gabriel Selzer + */ +@Plugin(type = ScriptDialect.class, name = "Jython") +public class JythonDialect implements ScriptDialect { + + @Override + public boolean supports(final ScriptLanguage language) { + return language instanceof JythonScriptLanguage; + } + + @Override + public String languageId() { + return "jython"; + } + + @Override + public Header header(final Map> parameters) { + // Declare each parameter as an instance of its type, for type reasoning + final List types = new ArrayList<>(); + final List declarations = new ArrayList<>(); + parameters.forEach((name, type) -> { + final String t = parameterType(type); + types.add(t); + declarations.add(name + " = " + t.replace('$', '.') + "()"); + }); + // And import the packages of those types, before the declarations + final List code = new ArrayList<>(); + final Set hidden = new TreeSet<>(); + for (final String p : packages(types)) { + code.add("import " + p); + // NB: "import a.b" binds a. + hidden.add(p.split("\\.")[0]); + } + code.addAll(declarations); + // Combine into a Header + return new Header(String.join("; ", code), hidden); + } + + /** + * @param type the Java type (of a SciJava Script parameter) declared in the + * script + * @return the type of the object given to the script by Jython: a Python + * type for the values Jython converts (e.g. {@code str}), or else + * the Java type itself + */ + static String parameterType(final Class type) { + final Class t = boxed(type); + // NB: As Jython converts them (Py.java2py). + if (t == String.class || t == Character.class) return "str"; + if (t == Integer.class || t == Long.class || t == Short.class || + t == Byte.class || t == BigInteger.class) return "int"; + if (t == Double.class || t == Float.class) return "float"; + if (t == Boolean.class) return "bool"; + if (t.isArray()) return "array.array"; + return t.getName(); + } + + /** + * The packages or modules of the given types that a declaration refers to + * (none for builtins, e.g. {@code str}). + */ + static Set packages(final Collection types) { + final Set packages = new TreeSet<>(); + for (final String t : types) { + if (t.lastIndexOf('.') > 0) packages.add(t.substring(0, t.lastIndexOf( + '.'))); + } + return packages; + } + + /** Maps primitive types to their wrappers. */ + private static Class boxed(final Class type) { + if (!type.isPrimitive()) return type; + if (type == int.class) return Integer.class; + 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 + } +} diff --git a/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java b/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java new file mode 100644 index 0000000..a3d1042 --- /dev/null +++ b/src/test/java/org/scijava/plugins/scripting/jython/completion/JythonDialectTest.java @@ -0,0 +1,166 @@ +/*- + * #%L + * JSR-223-compliant Jython scripting language plugin. + * %% + * Copyright (C) 2020 - 2025 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ +package org.scijava.plugins.scripting.jython.completion; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.eclipse.lsp4j.CompletionItem; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.scijava.Context; +import org.scijava.code.lsp.LanguageServerService; +import org.scijava.code.lsp.ScriptDialect.Header; +import org.scijava.code.lsp.ScriptSession; +import org.scijava.script.ScriptLanguage; +import org.scijava.script.ScriptService; + +/** + * Tests {@link JythonDialect}: Jython scripts, as plain Jython for + * scijava-code-lsp-jython's server (which its own tests test). + * + * @author Gabriel Selzer + */ +public class JythonDialectTest { + + private Context context; + private ScriptLanguage jython; + + @Before + public void setUp() { + context = new Context(ScriptService.class, LanguageServerService.class); + jython = context.service(ScriptService.class).getLanguageByName("Jython"); + } + + @After + public void tearDown() { + context.dispose(); + } + + @Test + public void testScriptsAreServed() { + // Jython scripts become plain Jython, which scijava-code-lsp-jython + // serves. + final JythonDialect dialect = new JythonDialect(); + assertTrue(dialect.supports(jython)); + assertEquals("jython", dialect.languageId()); + assertTrue(context.service(LanguageServerService.class).supports(jython)); + } + + @Test + public void testParameterType() { + // As Jython converts them: e.g. strings and numbers to Python's. + assertEquals("str", JythonDialect.parameterType(String.class)); + assertEquals("int", JythonDialect.parameterType(int.class)); + assertEquals("int", JythonDialect.parameterType(Long.class)); + assertEquals("float", JythonDialect.parameterType(double.class)); + assertEquals("bool", JythonDialect.parameterType(boolean.class)); + assertEquals("array.array", JythonDialect.parameterType( + double[].class)); + // Or else as the Java objects themselves. + assertEquals("java.math.BigDecimal", JythonDialect.parameterType( + java.math.BigDecimal.class)); + } + + @Test + public void testHeader() { + // Each parameter is declared as an instance of its type, whose package + // is imported. + final Map> parameters = new LinkedHashMap<>(); + parameters.put("d", java.math.BigDecimal.class); + parameters.put("n", int.class); + parameters.put("e", Map.Entry.class); + parameters.put("a", double[].class); + final Header header = new JythonDialect().header(parameters); + assertEquals("import array; import java.math; import java.util; " + + "d = java.math.BigDecimal(); n = int(); " + + "e = java.util.Map.Entry(); a = array.array()", header.code()); + // The names the imports bind ("import a.b" binds a) are the header's + // alone. + assertEquals(Arrays.asList("array", "java"), Arrays.asList(header + .hiddenNames().toArray())); + } + + @Test + public void testScriptParameters() { + final String header = "" + // + "#@ String (label=\"Please enter your name\") name\n" + // + "#@output String greeting\n" + // + "#@ int count\n" + // + "#@ NoSuchType mystery\n"; + assertTrue(complete(header + "x = na").contains("name")); + assertTrue(complete(header + "myst").contains("mystery")); + // As the Python values Jython converts them to: e.g. strings are str, + // whose members are Python's, not java.lang.String's. + final List nameMembers = complete(header + "name.upp"); + assertTrue("expected upper among " + nameMembers, nameMembers.contains( + "upper")); + assertTrue(complete(header + "name.toUpp").isEmpty()); + // Outputs are known too, even before the script assigns them. + assertTrue(complete(header + "greeting.st").contains("strip")); + // Numbers are int, not java.lang.Integer. + assertFalse(complete(header + "count.intV").contains("intValue")); + // Packages imported only to declare those are not completed. + assertFalse(complete(header + "j").contains("java")); + } + + + @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( + "ensureCapacity")); + } + + + // -- Helper methods -- + + private List complete(final String code) { + try (ScriptSession session = new ScriptSession(context.service( + LanguageServerService.class), jython)) + { + return session.completion(code, code.length()).get(30, TimeUnit.SECONDS) + .getItems().stream().map(CompletionItem::getLabel).collect(Collectors + .toList()); + } + catch (final Exception exc) { + throw new RuntimeException(exc); + } + } +}