diff --git a/README.md b/README.md index 4f898ca..b77b071 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,13 @@ 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 Groovy scripts with +`scijava-code-lsp-groovy`'s language server, driven by Groovy's own +compiler; this plugin shows it the scripts as plain Groovy (`GroovyDialect`), +their `#@` parameters declared. + 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 0101349..b4eaefd 100644 --- a/pom.xml +++ b/pom.xml @@ -95,6 +95,13 @@ **/script_templates/** 1.2.1 + + + 2.101.0 + 0.1.0-SNAPSHOT + 0.1.0-SNAPSHOT + + 2.14.0 @@ -103,6 +110,18 @@ org.scijava scijava-common + + org.scijava + scijava-code-lsp + ${scijava-code-lsp.version} + + + org.scijava + scijava-code-lsp-groovy + ${scijava-code-lsp-groovy.version} + + test + diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialect.java b/src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialect.java new file mode 100644 index 0000000..b69006c --- /dev/null +++ b/src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialect.java @@ -0,0 +1,116 @@ +/* + * #%L + * JSR-223-compliant Groovy scripting language plugin. + * %% + * Copyright (C) 2014 - 2026 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.scijava.plugins.scripting.groovy.completion; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +import groovyjarjarantlr4.v4.runtime.Vocabulary; + +import org.apache.groovy.parser.antlr4.GroovyLexer; +import org.scijava.code.lsp.ScriptDialect; +import org.scijava.plugin.Plugin; +import org.scijava.plugins.scripting.groovy.GroovyScriptLanguage; +import org.scijava.script.ScriptLanguage; + +/** + * Describes how to turn Groovy scripts into plain Groovy code, for + * {@code groovy} language servers (e.g. scijava-code-lsp-groovy's). + * + * @author Gabriel Selzer + */ +@Plugin(type = ScriptDialect.class, name = "Groovy") +public class GroovyDialect implements ScriptDialect { + + /** Names Groovy reserves, which cannot be declared. */ + private static final Set KEYWORDS = keywords(); + + @Override + public boolean supports(final ScriptLanguage language) { + return language instanceof GroovyScriptLanguage; + } + + @Override + public String languageId() { + return "groovy"; + } + + @Override + public Header header(final Map variables) { + // Declare the types of each variable for type reasoning; Groovy gets + // the very Java objects, named in full, so nothing needs importing + final List declarations = new ArrayList<>(); + variables.forEach((name, v) -> { + // NB: Names Groovy reserves cannot be declared. + if (!KEYWORDS.contains(name)) declarations.add(parameterType(v + .type()) + " " + name); + }); + // Combine into a Header + return new Header(String.join("; ", declarations), Collections + .emptySet()); + } + + /** + * @param type the Java type (of a SciJava Script parameter) declared in the + * script + * @return the Groovy type of the object given to the script: the Java type + * itself, by its full name (or {@code Object} for anonymous and + * local classes, which have no name to write) + */ + static String parameterType(final Class type) { + final String name = type.getCanonicalName(); + return name == null ? "Object" : name; + } + + /** Groovy's keywords */ + private static Set keywords() { + // NB: The lexer literals don't contain primitives - we add those ourselves + final Set out = new TreeSet<>(Arrays.asList("true", "false", + "boolean", "byte", "char", "short", "int", "long", "float", "double")); + final Vocabulary v = GroovyLexer.VOCABULARY; + for (int i = 0; i <= v.getMaxTokenType(); i++) { + // NB: The vocabulary has every kind of token: keywords, operators and + // punctuation, which have fixed text (their literal name, quoted, e.g. + // "'class'"), plus identifiers, strings and numbers, which don't + // (null). We want just the literals that are words, i.e. keywords + // (with hyphens, as in "non-sealed"). + final String literal = v.getLiteralName(i); + if (literal == null) continue; + final String word = literal.substring(1, literal.length() - 1); + if (word.matches("[a-z][a-z-]*")) out.add(word); + } + return out; + } +} diff --git a/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialectTest.java b/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialectTest.java new file mode 100644 index 0000000..9c5988a --- /dev/null +++ b/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialectTest.java @@ -0,0 +1,124 @@ +/* + * #%L + * JSR-223-compliant Groovy scripting language plugin. + * %% + * Copyright (C) 2014 - 2026 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ + +package org.scijava.plugins.scripting.groovy.completion; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.lsp4j.Diagnostic; +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.ScriptDialect.Variable; +import org.scijava.code.lsp.ScriptSession; +import org.scijava.script.ScriptLanguage; +import org.scijava.script.ScriptService; + +/** + * Tests {@link GroovyDialect} + * + * @author Gabriel Selzer + */ +public class GroovyDialectTest { + + private GroovyDialect dialect; + private Context context; + private ScriptLanguage groovy; + + @Before + public void setUp() { + dialect = new GroovyDialect(); + context = new Context(ScriptService.class, LanguageServerService.class); + groovy = context.service(ScriptService.class).getLanguageByName("Groovy"); + } + + @After + public void tearDown() { + context.dispose(); + } + + @Test + public void testScriptsLanguageAdherence() { + // Ensure the dialect supports the Groovy Script Language + assertTrue(dialect.supports(groovy)); + } + + @Test + public void testHeader() { + // SciJava Script parameters should be declared in Groovy code with + // their Java types, e.g. "java.util.List items" + final Map parameters = new LinkedHashMap<>(); + parameters.put("items", Variable.ofType(java.util.List.class)); + parameters.put("count", Variable.ofType(int.class)); + // Anonymous classes have no name to write. + parameters.put("thing", Variable.ofType(new Object() {}.getClass())); + final Header header = dialect.header(parameters); + assertEquals("java.util.List items; int count; Object thing", header + .code()); + // Nothing is imported. + assertTrue(header.hiddenNames().isEmpty()); + } + + @Test + public void testKeywordsNotDeclared() { + // Names Groovy reserves (e.g. a live interpreter's binding named "in") + // cannot be declared. + final Map variables = new LinkedHashMap<>(); + variables.put("in", Variable.ofType(String.class)); + variables.put("int", Variable.ofType(String.class)); + variables.put("name", Variable.ofType(String.class)); + assertEquals("java.lang.String name", dialect.header(variables).code()); + } + + @Test + public void testGroovyServerIntegration() throws Exception { + // With scijava-code-lsp-groovy's server: the parameters' declarations, + // and the blanked parameter lines, parse. + final CompletableFuture> heard = + new CompletableFuture<>(); + try (ScriptSession session = new ScriptSession(context.service( + LanguageServerService.class), groovy)) + { + session.setDiagnosticsListener(heard::complete); + session.update("#@ String name\n#@ int count\nprintln name * count\n"); + final List problems = heard.get(20, TimeUnit.SECONDS); + assertTrue(problems.toString(), problems.isEmpty()); + } + } +}