From d0e14f2b33652fb90de6bef9d2d91c93daa995f1 Mon Sep 17 00:00:00 2001
From: Gabriel Selzer
Date: Tue, 6 Oct 2026 10:49:28 -0500
Subject: [PATCH 01/21] Provide Groovy code completion
Add a CodeCompleterPlugin for Groovy, driven by Groovy's own compiler:
the script up to the caret is compiled, with errors tolerated, and run
through the static type checker, which infers expression types.
* Members after a dot, including GDK methods, static members of classes,
members of classes defined in the script, and classes of packages.
* Variables in scope, keywords, class names (imported on acceptance) and
import statements.
* Script parameters (#@) and live interpreter bindings, typed.
* Signature help: overloads of methods and constructors, rated against
the arguments' inferred types.
* Nothing in strings and comments; a syntax error above the caret falls
back to analyzing the caret's line.
Co-Authored-By: Claude Opus 5.5
---
pom.xml | 9 +
.../scripting/groovy/GroovyAnalysis.java | 617 +++++++++++++++++
.../scripting/groovy/GroovyCodeCompleter.java | 642 ++++++++++++++++++
.../groovy/GroovyCompletionTest.java | 292 ++++++++
4 files changed, 1560 insertions(+)
create mode 100644 src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java
create mode 100644 src/main/java/org/scijava/plugins/scripting/groovy/GroovyCodeCompleter.java
create mode 100644 src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java
diff --git a/pom.xml b/pom.xml
index 0101349..9cd6a9d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -95,6 +95,10 @@
**/script_templates/**
1.2.1
+
+
+ 2.101.0
+ 0.1.0-SNAPSHOT
@@ -103,6 +107,11 @@
org.scijava
scijava-common
+
+ org.scijava
+ scijava-code-api
+ ${scijava-code-api.version}
+
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java
new file mode 100644
index 0000000..3ad0479
--- /dev/null
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java
@@ -0,0 +1,617 @@
+/*
+ * #%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;
+
+import groovy.lang.GroovyClassLoader;
+
+import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.codehaus.groovy.ast.ASTNode;
+import org.codehaus.groovy.ast.ClassCodeVisitorSupport;
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.MethodNode;
+import org.codehaus.groovy.ast.ModuleNode;
+import org.codehaus.groovy.ast.Parameter;
+import org.codehaus.groovy.ast.Variable;
+import org.codehaus.groovy.ast.expr.ClassExpression;
+import org.codehaus.groovy.ast.expr.ClosureExpression;
+import org.codehaus.groovy.ast.expr.ConstantExpression;
+import org.codehaus.groovy.ast.expr.ConstructorCallExpression;
+import org.codehaus.groovy.ast.expr.DeclarationExpression;
+import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.expr.GStringExpression;
+import org.codehaus.groovy.ast.expr.MethodCallExpression;
+import org.codehaus.groovy.ast.expr.PropertyExpression;
+import org.codehaus.groovy.ast.expr.StaticMethodCallExpression;
+import org.codehaus.groovy.ast.expr.TupleExpression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
+import org.codehaus.groovy.ast.stmt.BlockStatement;
+import org.codehaus.groovy.ast.stmt.CatchStatement;
+import org.codehaus.groovy.ast.stmt.ForStatement;
+import org.codehaus.groovy.control.CompilationUnit;
+import org.codehaus.groovy.control.CompilerConfiguration;
+import org.codehaus.groovy.control.Phases;
+import org.codehaus.groovy.control.SourceUnit;
+import org.codehaus.groovy.transform.stc.StaticTypeCheckingVisitor;
+import org.codehaus.groovy.transform.stc.StaticTypesMarker;
+import org.scijava.util.Types;
+
+/**
+ * Static analysis of a Groovy script for code completion, using Groovy's own
+ * compiler: the script is parsed and its names resolved, then it is run
+ * through the static type checker, which infers the types of its expressions.
+ *
+ * Scripts being edited are usually incomplete, and usually dynamically typed,
+ * so compilation errors are expected and ignored: completion uses whatever the
+ * compiler could work out. Only a syntax error prevents analysis; see
+ * {@link #isParsed()}.
+ *
+ *
+ * The analyzed source is built by {@link #prepare}, which marks the caret with
+ * the identifier {@link #CARET}, so that it can be found in the syntax tree.
+ *
+ *
+ * @author Gabriel Selzer
+ */
+final class GroovyAnalysis {
+
+ /** Identifier marking the caret position in the analyzed source. */
+ static final String CARET = "__scijava_caret__";
+
+ /** Recent analyses, by source: typing a word reuses the same analysis. */
+ private static final Map CACHE =
+ new LinkedHashMap(16, 0.75f, true)
+ {
+
+ @Override
+ protected boolean removeEldestEntry(
+ final Map.Entry eldest)
+ {
+ return size() > 16;
+ }
+ };
+
+ private final SourceUnit source;
+ private final ModuleNode module;
+ private final ClassLoader loader;
+
+ private GroovyAnalysis(final SourceUnit source, final ClassLoader loader) {
+ this.source = source;
+ this.module = source.getAST();
+ this.loader = loader;
+ }
+
+ // -- Source preparation --
+
+ /**
+ * Builds the source to analyze: one line declaring the predefined variables
+ * (script parameters, interpreter bindings), then the script up to the
+ * caret, then {@link #CARET} and the brackets needed to close any left open.
+ *
+ * @param predefined Variables defined before the script runs: name to
+ * (Groovy) type name.
+ * @param textBeforeCaret The script up to the caret, without the partial
+ * token being completed.
+ * @return The source, or null if the caret is in a string or comment.
+ */
+ static String prepare(final Map predefined,
+ final String textBeforeCaret)
+ {
+ return prepare(predefined, textBeforeCaret, CARET);
+ }
+
+ /**
+ * As {@link #prepare(Map, String)}, appending {@code tail} where the caret
+ * is.
+ */
+ static String prepare(final Map predefined,
+ final String textBeforeCaret, final String tail)
+ {
+ final String body = blankDirectives(textBeforeCaret);
+ final String closers = closers(body);
+ if (closers == null) return null;
+ final StringBuilder sb = new StringBuilder();
+ for (final Map.Entry var : predefined.entrySet()) {
+ sb.append(var.getValue()).append(' ').append(var.getKey()).append("; ");
+ }
+ return sb.append('\n').append(body).append(tail).append(closers)
+ .toString();
+ }
+
+ /**
+ * Blanks the lines starting with {@code #}: SciJava script parameters
+ * ({@code #@}) and shebangs, which are not Groovy. Keeps line numbers.
+ */
+ private static String blankDirectives(final String text) {
+ final StringBuilder sb = new StringBuilder(text.length());
+ int start = 0;
+ while (start <= text.length()) {
+ int end = text.indexOf('\n', start);
+ if (end < 0) end = text.length();
+ final String line = text.substring(start, end);
+ if (!line.trim().startsWith("#")) sb.append(line);
+ if (end < text.length()) sb.append('\n');
+ start = end + 1;
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Gets the closing brackets for those left open at the end of
+ * {@code text}, or null if the text ends inside a string or comment.
+ */
+ static String closers(final String text) {
+ final Deque open = new ArrayDeque<>();
+ int i = 0;
+ final int n = text.length();
+ while (i < n) {
+ final char c = text.charAt(i);
+ if (text.startsWith("//", i)) {
+ final int end = text.indexOf('\n', i);
+ if (end < 0) return null;
+ i = end + 1;
+ }
+ else if (text.startsWith("/*", i)) {
+ final int end = text.indexOf("*/", i + 2);
+ if (end < 0) return null;
+ i = end + 2;
+ }
+ else if (text.startsWith("'''", i) || text.startsWith("\"\"\"", i)) {
+ final int end = text.indexOf(text.substring(i, i + 3), i + 3);
+ if (end < 0) return null;
+ i = end + 3;
+ }
+ else if (c == '\'' || c == '"') {
+ int j = i + 1;
+ while (j < n && text.charAt(j) != c && text.charAt(j) != '\n') {
+ if (text.charAt(j) == '\\') j++;
+ j++;
+ }
+ if (j >= n || text.charAt(j) != c) return null;
+ i = j + 1;
+ }
+ else {
+ if (c == '(') open.push(')');
+ else if (c == '[') open.push(']');
+ else if (c == '{') open.push('}');
+ else if ((c == ')' || c == ']' || c == '}') && !open.isEmpty() && open
+ .peek() == c) open.pop();
+ i++;
+ }
+ }
+ final StringBuilder sb = new StringBuilder();
+ for (final char c : open) {
+ sb.append(c == '}' ? "\n}" : String.valueOf(c));
+ }
+ return sb.toString();
+ }
+
+ // -- Analysis --
+
+ /** Analyzes the given (prepared) source, or reuses a recent analysis. */
+ static GroovyAnalysis of(final String code, final ClassLoader loader) {
+ synchronized (CACHE) {
+ final GroovyAnalysis cached = CACHE.get(code);
+ if (cached != null) return cached;
+ }
+ final GroovyAnalysis analysis = analyze(code, loader);
+ synchronized (CACHE) {
+ CACHE.put(code, analysis);
+ }
+ return analysis;
+ }
+
+ private static GroovyAnalysis analyze(final String code,
+ final ClassLoader loader)
+ {
+ final CompilerConfiguration config = new CompilerConfiguration();
+ // NB: Collect all errors, rather than stopping at the first few.
+ config.setTolerance(Integer.MAX_VALUE);
+ final GroovyClassLoader gcl = new GroovyClassLoader(loader, config);
+ try {
+ final CompilationUnit unit = new CompilationUnit(config, null, gcl);
+ final SourceUnit source = unit.addSource("CompletionScript.groovy",
+ code);
+ try {
+ // Parse, resolve names and canonicalize; stop before bytecode.
+ unit.compile(Phases.CANONICALIZATION);
+ }
+ catch (final RuntimeException | LinkageError exc) {
+ // NB: Compilation errors are expected. Use what we have.
+ }
+ final ModuleNode module = source.getAST();
+ if (module != null) {
+ // NB: Unresolvable classes stop compilation before the type checker
+ // would run, and dynamic code fails it, so run it ourselves.
+ for (final ClassNode c : module.getClasses()) {
+ try {
+ new StaticTypeCheckingVisitor(source, c).visitClass(c);
+ }
+ catch (final RuntimeException | LinkageError | AssertionError exc) {
+ // NB: Keep whatever types were inferred before the failure.
+ }
+ }
+ }
+ return new GroovyAnalysis(source, loader);
+ }
+ finally {
+ try {
+ gcl.close();
+ }
+ catch (final IOException exc) {
+ // NB: Nothing to clean up, then.
+ }
+ }
+ }
+
+ /** True iff the source parsed (syntax errors prevent any analysis). */
+ boolean isParsed() {
+ return module != null;
+ }
+
+ /**
+ * Gets the expression whose member is being completed, e.g. {@code img} in
+ * {@code img.CARET}, or null if the caret is not after a dot.
+ */
+ Expression receiverAtCaret() {
+ final Expression[] found = { null };
+ visit(new Visitor() {
+
+ @Override
+ public void visitPropertyExpression(final PropertyExpression e) {
+ if (CARET.equals(e.getPropertyAsString())) found[0] = e
+ .getObjectExpression();
+ super.visitPropertyExpression(e);
+ }
+
+ @Override
+ public void visitMethodCallExpression(final MethodCallExpression e) {
+ if (CARET.equals(e.getMethodAsString()) && !e.isImplicitThis()) {
+ found[0] = e.getObjectExpression();
+ }
+ super.visitMethodCallExpression(e);
+ }
+ });
+ return found[0];
+ }
+
+ /**
+ * Gets the variables visible at the caret, with their types (null if
+ * unknown), innermost last.
+ */
+ Map variablesAtCaret() {
+ final ASTNode caret = caretNode();
+ if (caret == null) return Collections.emptyMap();
+ final int line = caret.getLineNumber(), column = caret.getColumnNumber();
+ final Map vars = new LinkedHashMap<>();
+ visit(new Visitor() {
+
+ private boolean contains(final ASTNode node) {
+ if (node.getLineNumber() < 0) return true; // synthetic: unknown span
+ return before(node.getLineNumber(), node.getColumnNumber()) && //
+ !before(node.getLastLineNumber(), node.getLastColumnNumber());
+ }
+
+ private boolean before(final int l, final int c) {
+ return l < line || l == line && c <= column;
+ }
+
+ private void add(final Variable v) {
+ if (CARET.equals(v.getName())) return;
+ vars.remove(v.getName()); // NB: Re-insert, so innermost comes last.
+ vars.put(v.getName(), v.isDynamicTyped() ? null : v.getOriginType());
+ }
+
+ @Override
+ protected void visitConstructorOrMethod(final MethodNode node,
+ final boolean isConstructor)
+ {
+ if (!node.isScriptBody() && !contains(node)) return;
+ for (final Parameter p : node.getParameters()) add(p);
+ super.visitConstructorOrMethod(node, isConstructor);
+ }
+
+ @Override
+ public void visitClosureExpression(final ClosureExpression e) {
+ if (!contains(e)) return;
+ if (e.getParameters() != null) {
+ for (final Parameter p : e.getParameters()) add(p);
+ }
+ super.visitClosureExpression(e);
+ }
+
+ @Override
+ public void visitBlockStatement(final BlockStatement block) {
+ if (contains(block)) super.visitBlockStatement(block);
+ }
+
+ @Override
+ public void visitForLoop(final ForStatement loop) {
+ if (!contains(loop)) return;
+ final Parameter v = loop.getVariable();
+ if (v != null && v != ForStatement.FOR_LOOP_DUMMY) add(v);
+ super.visitForLoop(loop);
+ }
+
+ @Override
+ public void visitCatchStatement(final CatchStatement statement) {
+ if (!contains(statement)) return;
+ add(statement.getVariable());
+ super.visitCatchStatement(statement);
+ }
+
+ @Override
+ public void visitDeclarationExpression(final DeclarationExpression e) {
+ if (before(e.getLineNumber(), e.getColumnNumber())) {
+ if (e.isMultipleAssignmentDeclaration()) {
+ for (final Expression v : e.getTupleExpression()
+ .getExpressions())
+ {
+ if (v instanceof VariableExpression) {
+ addDeclared((VariableExpression) v);
+ }
+ }
+ }
+ else addDeclared(e.getVariableExpression());
+ }
+ super.visitDeclarationExpression(e);
+ }
+
+ private void addDeclared(final VariableExpression v) {
+ if (CARET.equals(v.getName())) return;
+ vars.remove(v.getName());
+ vars.put(v.getName(), typeOf(v));
+ }
+ });
+ return vars;
+ }
+
+ /** The call whose arguments contain the caret marker. */
+ static final class Call {
+
+ /**
+ * The call: a {@link MethodCallExpression},
+ * {@link StaticMethodCallExpression} or
+ * {@link ConstructorCallExpression}.
+ */
+ final Expression expression;
+
+ /** Its arguments, up to the one containing the caret. */
+ final List arguments;
+
+ /** Index of the argument containing the caret. */
+ final int active;
+
+ private Call(final Expression expression,
+ final List arguments, final int active)
+ {
+ this.expression = expression;
+ this.arguments = arguments;
+ this.active = active;
+ }
+ }
+
+ /**
+ * Gets the innermost call whose arguments contain the caret marker, e.g.
+ * {@code max} in {@code Math.max(1.5, CARET)}; or null if there is none.
+ */
+ Call callAtCaret() {
+ final Call[] found = { null };
+ visit(new Visitor() {
+
+ @Override
+ public void visitMethodCallExpression(final MethodCallExpression e) {
+ consider(e, e.getArguments());
+ super.visitMethodCallExpression(e);
+ }
+
+ @Override
+ public void visitStaticMethodCallExpression(
+ final StaticMethodCallExpression e)
+ {
+ consider(e, e.getArguments());
+ super.visitStaticMethodCallExpression(e);
+ }
+
+ @Override
+ public void visitConstructorCallExpression(
+ final ConstructorCallExpression e)
+ {
+ consider(e, e.getArguments());
+ super.visitConstructorCallExpression(e);
+ }
+
+ private void consider(final Expression call, final Expression args) {
+ if (!(args instanceof TupleExpression)) return;
+ final List list = ((TupleExpression) args)
+ .getExpressions();
+ for (int i = 0; i < list.size(); i++) {
+ if (!containsCaret(list.get(i))) continue;
+ // NB: Visited outside in, so the innermost call is found last.
+ found[0] = new Call(call, list.subList(0, i + 1), i);
+ return;
+ }
+ }
+ });
+ return found[0];
+ }
+
+ /**
+ * Gets the type of an expression, e.g. an argument, as a type name for
+ * {@link org.scijava.code.api.Signatures}; or null if unknown.
+ */
+ String typeName(final Expression e) {
+ if (e instanceof GStringExpression) return String.class.getName();
+ if (e instanceof ConstantExpression) {
+ final Object value = ((ConstantExpression) e).getValue();
+ if (value == null) return null;
+ if (value instanceof Integer) return "int";
+ if (value instanceof Long) return "long";
+ if (value instanceof Double) return "double";
+ if (value instanceof Float) return "float";
+ if (value instanceof Boolean) return "boolean";
+ return value.getClass().getName(); // e.g. String, BigDecimal
+ }
+ final Class> c = toClass(typeOf(e));
+ if (c == null || c == Object.class) return null; // dynamic: unknown
+ final String name = c.getCanonicalName();
+ return name == null ? c.getName() : name;
+ }
+
+ /** Gets the script's own methods (e.g. {@code def area(w, h)}) by name. */
+ List scriptMethods(final String name) {
+ final List methods = new ArrayList<>();
+ if (module == null) return methods;
+ for (final ClassNode c : module.getClasses()) {
+ if (c.isScript()) methods.addAll(c.getMethods(name));
+ }
+ return methods;
+ }
+
+ /** True iff the expression is, or contains, the caret marker. */
+ private boolean containsCaret(final Expression e) {
+ final boolean[] found = { false };
+ e.visit(new Visitor() {
+
+ @Override
+ public void visitVariableExpression(final VariableExpression v) {
+ if (CARET.equals(v.getName())) found[0] = true;
+ }
+ });
+ return found[0];
+ }
+
+ // -- Types --
+
+ /** Gets the (inferred or declared) type of an expression, or null. */
+ static ClassNode typeOf(final Expression e) {
+ if (e == null) return null;
+ // NB: The type checker infers Class for a class reference; we want T.
+ if (e instanceof ClassExpression) return e.getType();
+ final Object inferred = e.getNodeMetaData(StaticTypesMarker.INFERRED_TYPE);
+ if (inferred instanceof ClassNode) return (ClassNode) inferred;
+ if (e instanceof VariableExpression) {
+ final Variable v = ((VariableExpression) e).getAccessedVariable();
+ if (v != null && v != e && !v.isDynamicTyped()) return v.getOriginType();
+ final VariableExpression ve = (VariableExpression) e;
+ return ve.isDynamicTyped() ? null : ve.getOriginType();
+ }
+ final ClassNode type = e.getType();
+ if (type == null || "java.lang.Object".equals(type.getName())) return null;
+ return type;
+ }
+
+ /**
+ * Gets the Java class of a type, or null if it is unknown or defined in the
+ * script itself.
+ */
+ Class> toClass(final ClassNode type) {
+ if (type == null) return null;
+ final ClassNode t = type.redirect();
+ if (t.isPrimaryClassNode()) return null; // NB: Not compiled.
+ try {
+ return t.getTypeClass();
+ }
+ catch (final RuntimeException | LinkageError | AssertionError exc) {
+ // NB: Not loaded yet; load it by name.
+ }
+ return load(t.getName(), loader);
+ }
+
+ /**
+ * Loads a type by name, e.g. {@code int}, {@code long[]}, {@code a.b.C} or
+ * {@code a.b.Outer.Inner}, or returns null if it cannot be loaded.
+ */
+ static Class> load(final String name, final ClassLoader loader) {
+ if (name == null) return null;
+ final Class> c = Types.load(name, loader);
+ if (c != null) return c;
+ // NB: Perhaps a nested class, named canonically: a.b.Outer.Inner.
+ final int dot = name.lastIndexOf('.');
+ return dot < 0 ? null : load(name.substring(0, dot) + "$" + name
+ .substring(dot + 1), loader);
+ }
+
+ // -- Helper methods --
+
+ /** Gets the node of the caret marker. */
+ private ASTNode caretNode() {
+ final ASTNode[] found = { null };
+ visit(new Visitor() {
+
+ @Override
+ public void visitVariableExpression(final VariableExpression e) {
+ if (CARET.equals(e.getName())) found[0] = e;
+ super.visitVariableExpression(e);
+ }
+
+ @Override
+ public void visitPropertyExpression(final PropertyExpression e) {
+ if (CARET.equals(e.getPropertyAsString())) found[0] = e.getProperty();
+ super.visitPropertyExpression(e);
+ }
+
+ @Override
+ public void visitMethodCallExpression(final MethodCallExpression e) {
+ if (CARET.equals(e.getMethodAsString())) found[0] = e.getMethod();
+ super.visitMethodCallExpression(e);
+ }
+ });
+ return found[0];
+ }
+
+ /** Visits every class (including the script itself) of the source. */
+ private void visit(final Visitor visitor) {
+ if (module == null) return;
+ for (final ClassNode c : module.getClasses()) {
+ try {
+ visitor.visitClass(c);
+ }
+ catch (final RuntimeException exc) {
+ // NB: Malformed tree; skip this class.
+ }
+ }
+ }
+
+ private abstract class Visitor extends ClassCodeVisitorSupport {
+
+ @Override
+ protected SourceUnit getSourceUnit() {
+ return source;
+ }
+ }
+}
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyCodeCompleter.java b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyCodeCompleter.java
new file mode 100644
index 0000000..272e263
--- /dev/null
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyCodeCompleter.java
@@ -0,0 +1,642 @@
+/*
+ * #%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;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import javax.script.Bindings;
+import javax.script.ScriptContext;
+
+import groovy.lang.GroovySystem;
+import groovy.lang.MetaMethod;
+
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.ConstructorNode;
+import org.codehaus.groovy.ast.FieldNode;
+import org.codehaus.groovy.ast.MethodNode;
+import org.codehaus.groovy.ast.PropertyNode;
+import org.codehaus.groovy.ast.expr.ClassExpression;
+import org.codehaus.groovy.ast.expr.ConstructorCallExpression;
+import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.expr.MethodCallExpression;
+import org.codehaus.groovy.ast.expr.StaticMethodCallExpression;
+import org.scijava.Context;
+import org.scijava.Priority;
+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.Completion;
+import org.scijava.code.api.Completion.TextEdit;
+import org.scijava.code.api.CompletionRequest;
+import org.scijava.code.api.CompletionResult;
+import org.scijava.code.api.Completions;
+import org.scijava.code.api.ParameterChoices;
+import org.scijava.code.api.SignatureHelp;
+import org.scijava.code.api.Signatures;
+import org.scijava.plugin.Plugin;
+import org.scijava.util.Types;
+
+/**
+ * Code completion for Groovy, driven by Groovy's own compiler (see
+ * {@link GroovyAnalysis}): members of the expression before a dot, using the
+ * types the static type checker infers; variables in scope; keywords; class
+ * names, with automatic imports; and import statements.
+ *
+ * @author Gabriel Selzer
+ */
+@Plugin(type = CodeCompleterPlugin.class, name = "Groovy",
+ priority = Priority.HIGH)
+public class GroovyCodeCompleter extends AbstractCodeCompleterPlugin {
+
+ private static final List KEYWORDS = Arrays.asList("as", "assert",
+ "boolean", "break", "byte", "case", "catch", "char", "class", "continue",
+ "def", "default", "do", "double", "else", "enum", "extends", "false",
+ "final", "finally", "float", "for", "if", "implements", "import", "in",
+ "instanceof", "int", "interface", "long", "new", "null", "private",
+ "protected", "public", "return", "short", "static", "super", "switch",
+ "this", "throw", "throws", "trait", "true", "try", "var", "void",
+ "while");
+
+ /** Packages Groovy imports by default. */
+ private static final List DEFAULT_IMPORTS = Arrays.asList(
+ "java.lang", "java.util", "java.io", "java.net", "groovy.lang",
+ "groovy.util");
+
+ /** Upper bound on class-name suggestions, which can be very numerous. */
+ private static final int MAX_CLASSES = 500;
+
+ /** An import statement being typed: "import a.b.C". */
+ private static final Pattern IMPORT = Pattern.compile(
+ "^\\s*import\\s+(static\\s+)?([\\w$.]*)$");
+
+ /** The identifier (possibly empty) being typed at the caret. */
+ private static final Pattern IDENTIFIER = Pattern.compile("[\\w$]*$");
+
+ /** A number literal followed by a dot, e.g. "1." (not a member access). */
+ private static final Pattern NUMBER_DOT = Pattern.compile(
+ "(? predefined = predefinedVariables(request);
+ final String textBefore = request.text().substring(0, replaceStart);
+ final GroovyAnalysis analysis = analyze(predefined, textBefore, line
+ .substring(0, line.length() - prefix.length()));
+ if (analysis == null) return CompletionResult.EMPTY; // string or comment
+
+ final List completions = new ArrayList<>();
+ if (member) {
+ completions.addAll(memberCompletions(analysis, before, prefix));
+ }
+ else {
+ final Map vars = analysis.variablesAtCaret();
+ completions.addAll(variableCompletions(vars, predefined, prefix));
+ completions.addAll(keywordCompletions(prefix));
+ completions.addAll(classCompletions(request.text(), prefix));
+ }
+ return new CompletionResult(completions, replaceStart, choices(analysis,
+ predefined));
+ }
+
+ @Override
+ public SignatureHelp signatureHelp(final CompletionRequest request) {
+ final String line = request.lineToCaret();
+ if (line.trim().startsWith("#")) return SignatureHelp.NONE;
+ // NB: The argument being typed, if a name, is replaced by the marker.
+ final Matcher id = IDENTIFIER.matcher(line);
+ id.find();
+ final String prefix = id.group();
+ final int start = request.offset() - prefix.length();
+ final Map predefined = predefinedVariables(request);
+ final GroovyAnalysis analysis = analyze(predefined, request.text()
+ .substring(0, start), line.substring(0, line.length() - prefix
+ .length()));
+ if (analysis == null) return SignatureHelp.NONE; // string or comment
+ final GroovyAnalysis.Call call = analysis.callAtCaret();
+ if (call == null) return SignatureHelp.NONE;
+ final List callables = callables(analysis, call);
+ if (callables.isEmpty()) return SignatureHelp.NONE;
+ // NB: Reflection lists methods in no particular order: sort them by
+ // their parameter types, so that equally good fits come out the same.
+ callables.sort(Comparator.comparing(c -> c.parameters().stream().map(
+ p -> String.valueOf(p.type())).collect(Collectors.joining(","))));
+
+ // The types of the arguments before the caret's (which is being typed).
+ final List argTypes = new ArrayList<>();
+ for (int i = 0; i < call.arguments.size(); i++) {
+ argTypes.add(i == call.active ? null : analysis.typeName(call.arguments
+ .get(i)));
+ }
+ final ClassLoader loader = loader();
+ return new SignatureHelp(Signatures.rateAll(callables, argTypes, (a,
+ p) -> Signatures.fit(a, p, loader)), call.active, Signatures.callStart(
+ request.text(), request.offset()));
+ }
+
+ // -- Helper methods --
+
+ /**
+ * Analyzes the script up to the caret. If it does not parse (e.g. due to a
+ * syntax error above the caret), falls back to the caret's line alone.
+ *
+ * @return The analysis, or null if the caret is in a string or comment.
+ */
+ private GroovyAnalysis analyze(final Map predefined,
+ final String textBefore, final String lineBefore)
+ {
+ final String code = GroovyAnalysis.prepare(predefined, textBefore);
+ if (code == null) return null;
+ final GroovyAnalysis analysis = GroovyAnalysis.of(code, loader());
+ if (analysis.isParsed()) return analysis;
+ final String lineCode = GroovyAnalysis.prepare(predefined, lineBefore);
+ return lineCode == null ? analysis : GroovyAnalysis.of(lineCode, loader());
+ }
+
+ private List memberCompletions(final GroovyAnalysis analysis,
+ final String before, final String prefix)
+ {
+ final Expression receiver = analysis.receiverAtCaret();
+ final ClassNode type = GroovyAnalysis.typeOf(receiver);
+ if (type == null) {
+ // Perhaps a package name, e.g. "java.util."
+ final Matcher q = QUALIFIER.matcher(before);
+ if (q.find()) return packageMemberCompletions(q.group(1), prefix);
+ return Collections.emptyList();
+ }
+ final boolean staticOnly = receiver instanceof ClassExpression;
+ if (type.redirect().isPrimaryClassNode()) {
+ return scriptClassMembers(analysis, type.redirect(), prefix, staticOnly);
+ }
+ final Class> c = Types.box(analysis.toClass(type));
+ if (c == null) return Collections.emptyList();
+ final List out = sorted(Completions.membersOf(c, prefix,
+ staticOnly));
+ if (!staticOnly) out.addAll(gdkCompletions(c, prefix));
+ return out;
+ }
+
+ /** Members of a class defined in the script itself. */
+ private List scriptClassMembers(final GroovyAnalysis analysis,
+ final ClassNode type, final String prefix, final boolean staticOnly)
+ {
+ final List out = new ArrayList<>();
+ for (final PropertyNode p : type.getProperties()) {
+ if (staticOnly && !p.isStatic() || !matches(p.getName(), prefix)) {
+ continue;
+ }
+ out.add(Completion.builder(p.getName()).kind(Completion.Kind.FIELD)
+ .returnType(p.getType().toString(false)).summary(p.getType().toString(
+ false) + " " + p.getName()).build());
+ }
+ for (final FieldNode f : type.getFields()) {
+ if (!f.isPublic() || f.isSynthetic() || staticOnly && !f.isStatic() ||
+ !matches(f.getName(), prefix) || type.getProperty(f
+ .getName()) != null) continue;
+ out.add(Completion.builder(f.getName()).kind(Completion.Kind.FIELD)
+ .returnType(f.getType().toString(false)).build());
+ }
+ for (final MethodNode m : type.getMethods()) {
+ if (!m.isPublic() || m.isSynthetic() || staticOnly && !m.isStatic() ||
+ !matches(m.getName(), prefix) || m.getName().contains("$")) continue;
+ final List params = new ArrayList<>();
+ for (final org.codehaus.groovy.ast.Parameter p : m.getParameters()) {
+ params.add(new Completion.Parameter(p.getName(), p.getType()
+ .toString(false)));
+ }
+ out.add(Completion.builder(m.getName()).kind(Completion.Kind.METHOD)
+ .parameters(params).returnType(m.getReturnType().toString(false))
+ .summary(m.getTypeDescriptor()).build());
+ }
+ final List sorted = sorted(out);
+ // Plus what it inherits from (compiled) superclasses.
+ final Class> superclass = analysis.toClass(type.getSuperClass());
+ if (superclass != null && superclass != Object.class) {
+ sorted.addAll(sorted(Completions.membersOf(superclass, prefix,
+ staticOnly)));
+ }
+ return sorted;
+ }
+
+ /** Classes and subpackages of a package, e.g. after "java.util." */
+ private List packageMemberCompletions(final String qualifier,
+ final String prefix)
+ {
+ if (!ClassIndex.isCacheReady()) return Collections.emptyList();
+ final String pkg = qualifier.substring(0, qualifier.length() - 1);
+ return ClassIndex.findClassNamesForPackage(pkg) //
+ .filter(s -> s.lastIndexOf('.') == pkg.length()) //
+ .map(s -> s.substring(pkg.length() + 1)) //
+ .filter(s -> matches(s, prefix) && !s.contains("$")) //
+ .sorted().limit(MAX_CLASSES) //
+ .map(s -> Completion.builder(s).kind(Completion.Kind.CLASS).summary(
+ pkg + "." + s).build()) //
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * The GDK methods Groovy adds to {@code c}, e.g. {@code each} and
+ * {@code collect} for collections.
+ */
+ private static List gdkCompletions(final Class> c,
+ final String prefix)
+ {
+ final Map out = new LinkedHashMap<>();
+ for (final MetaMethod m : GroovySystem.getMetaClassRegistry()
+ .getMetaClass(c).getMetaMethods())
+ {
+ if (m.isStatic() || !matches(m.getName(), prefix)) continue;
+ final List params = new ArrayList<>();
+ final Class>[] types = m.getNativeParameterTypes();
+ for (int i = 0; i < types.length; i++) {
+ params.add(new Completion.Parameter("arg" + i, typeName(types[i])));
+ }
+ final String signature = typeName(m.getReturnType()) + " " + m
+ .getName() + params.stream().map(Completion.Parameter::type).collect(
+ Collectors.joining(", ", "(", ")"));
+ // NB: Groovy lists some methods more than once.
+ out.putIfAbsent(signature, Completion.builder(m.getName()).kind(
+ Completion.Kind.METHOD).parameters(params).returnType(typeName(m
+ .getReturnType())).declaringClass(m.getDeclaringClass().getName())
+ .summary("GDK: " + signature).build());
+ }
+ return sorted(new ArrayList<>(out.values()));
+ }
+
+ private static List variableCompletions(
+ final Map vars, final Map predefined,
+ final String prefix)
+ {
+ final Set names = new HashSet<>();
+ final List out = new ArrayList<>();
+ // Innermost (most recently declared) variables first.
+ final List order = new ArrayList<>(vars.keySet());
+ Collections.reverse(order);
+ order.addAll(predefined.keySet());
+ for (final String name : order) {
+ if (!matches(name, prefix) || name.equals(prefix) || !names.add(name)) {
+ continue;
+ }
+ final ClassNode type = vars.get(name);
+ final String typeName = type != null ? type.toString(false) : predefined
+ .get(name);
+ out.add(Completion.builder(name).kind(Completion.Kind.VARIABLE).summary(
+ typeName == null ? name : typeName + " " + name).build());
+ }
+ return out;
+ }
+
+ private static List keywordCompletions(final String prefix) {
+ return KEYWORDS.stream().filter(k -> k.startsWith(prefix) && !k.equals(
+ prefix)).map(k -> Completion.builder(k).kind(Completion.Kind.KEYWORD)
+ .build()).collect(Collectors.toList());
+ }
+
+ /** Class names starting with the prefix, imported on acceptance. */
+ private static List classCompletions(final String text,
+ final String prefix)
+ {
+ if (!Character.isUpperCase(prefix.charAt(0)) || !ClassIndex
+ .isCacheReady()) return Collections.emptyList();
+ final List classNames = ClassIndex.findSimpleClassNamesStartingWith(
+ prefix);
+ // Closest matches first: shortest simple name, then alphabetical.
+ classNames.sort(Comparator.comparingInt((String cn) -> cn.length() - cn
+ .lastIndexOf('.')).thenComparing(Comparator.naturalOrder()));
+ final List out = new ArrayList<>();
+ for (final String className : classNames) {
+ if (className.contains("$")) continue;
+ final String simpleName = className.substring(className.lastIndexOf(
+ '.') + 1);
+ final Completion.Builder b = Completion.builder(simpleName).kind(
+ Completion.Kind.CLASS).summary(className);
+ final TextEdit edit = importEdit(text, className);
+ if (edit != null) b.additionalEdits(Collections.singletonList(edit));
+ out.add(b.build());
+ if (out.size() >= MAX_CLASSES) break;
+ }
+ return out;
+ }
+
+ /**
+ * An edit importing the given class, after the script's last import (or at
+ * the top), or null if no import is needed.
+ */
+ static TextEdit importEdit(final String text, final String className) {
+ final String pkg = className.substring(0, Math.max(0, className
+ .lastIndexOf('.')));
+ if (DEFAULT_IMPORTS.contains(pkg) || className.equals(
+ "java.math.BigDecimal") || className.equals("java.math.BigInteger"))
+ {
+ return null;
+ }
+ final Pattern imports = Pattern.compile("^\\s*import\\s+(?:static\\s+)?" +
+ "([\\w$.]+?)(\\.\\*)?\\s*(?:as\\s+\\w+\\s*)?;?\\s*$", Pattern.MULTILINE);
+ final Matcher m = imports.matcher(text);
+ int insertAt = 0;
+ while (m.find()) {
+ final String imported = m.group(1);
+ if (imported.equals(className) || m.group(2) != null && imported.equals(
+ pkg)) return null;
+ insertAt = m.end() + 1;
+ }
+ if (insertAt == 0 && text.startsWith("#!")) {
+ insertAt = text.indexOf('\n') + 1; // NB: After the shebang.
+ }
+ insertAt = Math.min(insertAt, text.length());
+ return TextEdit.insert(insertAt, "import " + className + "\n");
+ }
+
+ private static List importCompletions(final String token) {
+ if (token.isEmpty() || !ClassIndex.isCacheReady()) {
+ return Collections.emptyList();
+ }
+ return ClassIndex.findClassNamesStartingWith(token) //
+ .filter(s -> !s.contains("$")) //
+ .sorted().limit(MAX_CLASSES) //
+ .map(s -> Completion.builder(s).kind(Completion.Kind.IMPORT).build()) //
+ .collect(Collectors.toList());
+ }
+
+ /** Suggests in-scope variables whose type suits a parameter. */
+ private ParameterChoices choices(final GroovyAnalysis analysis,
+ final Map predefined)
+ {
+ return parameter -> {
+ final Class> target = Types.box(GroovyAnalysis.load(parameter.type(),
+ loader()));
+ if (target == null || target == Object.class) {
+ return Collections.emptyList();
+ }
+ final Map vars = analysis.variablesAtCaret();
+ final List out = new ArrayList<>();
+ for (final Map.Entry var : vars.entrySet()) {
+ final Class> c = Types.box(analysis.toClass(var.getValue()));
+ if (c != null && target.isAssignableFrom(c)) {
+ out.add(Completion.of(var.getKey()));
+ }
+ }
+ for (final Map.Entry var : predefined.entrySet()) {
+ if (vars.containsKey(var.getKey())) continue;
+ final Class> c = Types.box(GroovyAnalysis.load(var.getValue(), loader()));
+ if (c != null && target.isAssignableFrom(c)) {
+ out.add(Completion.of(var.getKey()));
+ }
+ }
+ return out;
+ };
+ }
+
+ /** The signatures the call may be calling: methods or constructors. */
+ private List callables(final GroovyAnalysis analysis,
+ final GroovyAnalysis.Call call)
+ {
+ final List out = new ArrayList<>();
+ if (call.expression instanceof ConstructorCallExpression) {
+ final ClassNode type = call.expression.getType().redirect();
+ final Class> c = analysis.toClass(type);
+ if (c != null) {
+ for (final Constructor> ctor : c.getConstructors()) {
+ out.add(constructor(ctor));
+ }
+ }
+ else if (type.isPrimaryClassNode()) {
+ for (final ConstructorNode ctor : type.getDeclaredConstructors()) {
+ out.add(methodNode(type.getNameWithoutPackage(), ctor));
+ }
+ }
+ return out;
+ }
+ if (call.expression instanceof StaticMethodCallExpression) {
+ final StaticMethodCallExpression e =
+ (StaticMethodCallExpression) call.expression;
+ return named(Completions.membersOf(analysis.toClass(e.getOwnerType()),
+ e.getMethod(), true), e.getMethod());
+ }
+ final MethodCallExpression e = (MethodCallExpression) call.expression;
+ final String name = e.getMethodAsString();
+ if (name == null) return out;
+ if (e.isImplicitThis()) {
+ // A function defined in the script, or a GDK method such as println.
+ for (final MethodNode m : analysis.scriptMethods(name)) {
+ out.add(methodNode(name, m));
+ }
+ out.addAll(named(gdkCompletions(Object.class, name), name));
+ return out;
+ }
+ final Expression receiver = e.getObjectExpression();
+ final ClassNode type = GroovyAnalysis.typeOf(receiver);
+ if (type == null) return out;
+ final boolean staticOnly = receiver instanceof ClassExpression;
+ if (type.redirect().isPrimaryClassNode()) {
+ return named(scriptClassMembers(analysis, type.redirect(), name,
+ staticOnly), name);
+ }
+ final Class> c = Types.box(analysis.toClass(type));
+ if (c == null) return out;
+ out.addAll(named(Completions.membersOf(c, name, staticOnly), name));
+ if (!staticOnly) out.addAll(named(gdkCompletions(c, name), name));
+ return out;
+ }
+
+ /** The callables among {@code completions} with exactly the given name. */
+ private static List named(final List completions,
+ final String name)
+ {
+ final List out = new ArrayList<>();
+ for (final Completion c : completions) {
+ if (c.kind() == Completion.Kind.METHOD && c.insertionText().equals(
+ name)) out.add(c);
+ }
+ return out;
+ }
+
+ private static Completion constructor(final Constructor> ctor) {
+ final List params = new ArrayList<>();
+ for (final java.lang.reflect.Parameter p : ctor.getParameters()) {
+ params.add(new Completion.Parameter(p.getName(), typeName(p
+ .getType())));
+ }
+ return Completion.builder(ctor.getDeclaringClass().getSimpleName()).kind(
+ Completion.Kind.METHOD).parameters(params).returnType(typeName(ctor
+ .getDeclaringClass())).build();
+ }
+
+ /** A callable defined in the script itself. */
+ private static Completion methodNode(final String name, final MethodNode m) {
+ final List params = new ArrayList<>();
+ for (final org.codehaus.groovy.ast.Parameter p : m.getParameters()) {
+ params.add(new Completion.Parameter(p.getName(), p.isDynamicTyped()
+ ? null : p.getType().getName()));
+ }
+ return Completion.builder(name).kind(Completion.Kind.METHOD).parameters(
+ params).returnType(m.getReturnType().toString(false)).build();
+ }
+
+ /**
+ * Gets the variables defined before the script runs: its parameters (e.g.
+ * {@code #@ String name}) and, in a live interpreter, the engine's bindings.
+ *
+ * @return Map of variable name to type name.
+ */
+ private Map predefinedVariables(
+ final CompletionRequest request)
+ {
+ final Map vars = new LinkedHashMap<>();
+ final Context context = getContext();
+ final CodeCompletionService service = context == null ? null : context
+ .getService(CodeCompletionService.class);
+ if (service != null) {
+ service.scriptParameters(request.text()).forEach((name, type) -> {
+ if (isIdentifier(name)) vars.put(name, typeName(type));
+ });
+ }
+ final ScriptContext scriptContext = request.context();
+ final Bindings bindings = scriptContext == null ? null : scriptContext
+ .getBindings(ScriptContext.ENGINE_SCOPE);
+ if (bindings != null) {
+ for (final Map.Entry entry : bindings.entrySet()) {
+ final String name = entry.getKey();
+ if (!isIdentifier(name) || name.startsWith("__")) continue;
+ final Object value = entry.getValue();
+ vars.put(name, value == null ? "Object" : typeName(
+ publicType(value.getClass())));
+ }
+ }
+ return vars;
+ }
+
+ private static ClassLoader loader() {
+ final ClassLoader loader = Thread.currentThread().getContextClassLoader();
+ return loader != null ? loader : GroovyCodeCompleter.class
+ .getClassLoader();
+ }
+
+ private static boolean isIdentifier(final String name) {
+ if (name == null || name.isEmpty() || !Character.isJavaIdentifierStart(
+ name.charAt(0))) return false;
+ for (int i = 1; i < name.length(); i++) {
+ if (!Character.isJavaIdentifierPart(name.charAt(i))) return false;
+ }
+ return !KEYWORDS.contains(name);
+ }
+
+ private static boolean matches(final String name, final String prefix) {
+ return name.toLowerCase(Locale.ROOT).startsWith(prefix.toLowerCase(
+ Locale.ROOT));
+ }
+
+ /** Sorts completions by name, case-insensitively (stably). */
+ private static List sorted(final List completions) {
+ final List out = new ArrayList<>(completions);
+ out.sort(Comparator.comparing(c -> c.insertionText().toLowerCase(
+ Locale.ROOT)));
+ return out;
+ }
+
+ /**
+ * Gets the name by which Groovy code can refer to a class, e.g.
+ * {@code java.util.List} or {@code int[]}; or {@code Object} if it has none
+ * (e.g. an anonymous class).
+ */
+ private static String typeName(final Class> c) {
+ final String name = c == null ? null : c.getCanonicalName();
+ return name == null ? "Object" : name;
+ }
+
+ /**
+ * Gets the most specific public class or interface of {@code c}, by which
+ * code can refer to its instances.
+ */
+ private static Class> publicType(final Class> c) {
+ for (Class> t = c; t != null; t = t.getSuperclass()) {
+ if (isAccessible(t)) return t;
+ }
+ for (final Class> i : c.getInterfaces()) {
+ if (isAccessible(i)) return i;
+ }
+ return Object.class;
+ }
+
+ private static boolean isAccessible(final Class> c) {
+ for (Class> t = c; t != null; t = t.getEnclosingClass()) {
+ if (!Modifier.isPublic(t.getModifiers())) return false;
+ }
+ return !c.isAnonymousClass() && !c.isLocalClass();
+ }
+}
diff --git a/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java
new file mode 100644
index 0000000..65947dc
--- /dev/null
+++ b/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java
@@ -0,0 +1,292 @@
+/*
+ * #%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;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import javax.script.ScriptEngine;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.scijava.Context;
+import org.scijava.code.api.ClassIndex;
+import org.scijava.code.api.CodeCompletionService;
+import org.scijava.code.api.Completion;
+import org.scijava.code.api.CompletionRequest;
+import org.scijava.code.api.CompletionResult;
+import org.scijava.code.api.SignatureHelp;
+import org.scijava.script.ScriptLanguage;
+import org.scijava.script.ScriptService;
+
+/**
+ * Tests {@link GroovyCodeCompleter}.
+ *
+ * @author Gabriel Selzer
+ */
+public class GroovyCompletionTest {
+
+ private Context context;
+ private ScriptLanguage groovy;
+ private CodeCompletionService service;
+
+ @Before
+ public void setUp() {
+ context = new Context(ScriptService.class, CodeCompletionService.class);
+ groovy = context.service(ScriptService.class).getLanguageByName("Groovy");
+ service = context.service(CodeCompletionService.class);
+ }
+
+ @After
+ public void tearDown() {
+ context.dispose();
+ }
+
+ @Test
+ public void testCompleterIsDiscovered() {
+ assertTrue(service.getCompleterPlugin(
+ groovy) instanceof GroovyCodeCompleter);
+ }
+
+ @Test
+ public void testMembersOfInferredTypes() {
+ assertTrue(complete("def s = 'hello'\ns.toUpp").contains("toUpperCase"));
+ // Types flow through method calls...
+ assertTrue(complete("def x = new ArrayList()\n" +
+ "def y = x.subList(0, 1)\ny.isEm").contains("isEmpty"));
+ assertTrue(complete("'abc'.toUpperCase().trim().len").contains("length"));
+ // ...and through closures.
+ assertTrue(complete("[1, 2].collect { it * 2 }.isEm").contains(
+ "isEmpty"));
+ // Declared types count too.
+ assertTrue(complete("List items = null\nitems.isEm").contains("isEmpty"));
+ // Inherited members are included.
+ assertTrue(complete("def x = new ArrayList()\nx.containsA").contains(
+ "containsAll"));
+ }
+
+ @Test
+ public void testScriptParameters() {
+ final String header = "#@ String name\n#@ int count\n";
+ assertTrue(complete(header + "name.toUpp").contains("toUpperCase"));
+ // Primitive parameters complete as their wrapper type.
+ assertTrue(complete(header + "count.intV").contains("intValue"));
+ assertTrue(complete(header + "def x = na").contains("name"));
+ // Nothing is offered on the parameter lines themselves.
+ assertTrue(complete("#@ String na").isEmpty());
+ }
+
+ @Test
+ public void testStaticMembers() {
+ final List texts = complete("Math.ma");
+ assertTrue(texts.contains("max"));
+ // Only static members of a class.
+ assertFalse(complete("Integer.intV").contains("intValue"));
+ assertTrue(complete("Integer.parseI").contains("parseInt"));
+ }
+
+ @Test
+ public void testGroovyMethods() {
+ // The GDK adds methods to Java classes.
+ assertTrue(complete("[1, 2, 3].coll").contains("collect"));
+ assertTrue(complete("def s = 'abc'\ns.rev").contains("reverse"));
+ assertTrue(complete("new File('x').eachL").contains("eachLine"));
+ final Completion each = completions("[1, 2, 3].eac").stream().filter(
+ c -> c.insertionText().equals("each")).findFirst().orElse(null);
+ assertTrue(each != null && each.summary().startsWith("GDK"));
+ }
+
+ @Test
+ public void testScriptClasses() {
+ final List texts = complete("class Foo {\n" + //
+ " int bar\n" + //
+ " String baz() { 'x' }\n" + //
+ "}\n" + //
+ "def f = new Foo()\n" + //
+ "f.ba");
+ assertTrue(texts.toString(), texts.contains("bar"));
+ assertTrue(texts.toString(), texts.contains("baz"));
+ }
+
+ @Test
+ public void testVariableScopes() {
+ assertTrue(complete("def greeting = 'hi'\ndef x = gree").contains(
+ "greeting"));
+ // Closure parameters are visible inside the closure...
+ assertTrue(complete("[1].each { item ->\n ite").contains("item"));
+ // ...but not variables declared in a closure that has ended.
+ assertFalse(complete("[1].each { def inner = it }\ninn").contains(
+ "inner"));
+ // Nor variables declared after the caret.
+ assertFalse(complete("def a = lat").contains("later"));
+ // For loop variables are visible in the loop.
+ assertTrue(complete("for (element in [1, 2]) {\n elem").contains(
+ "element"));
+ }
+
+ @Test
+ public void testIncompleteCode() {
+ // Unclosed brackets before the caret.
+ assertTrue(complete("def s = 'x'\nif (true) {\n s.toUpp").contains(
+ "toUpperCase"));
+ assertTrue(complete("def s = 'x'\nprintln(s.toUpp").contains(
+ "toUpperCase"));
+ // Unresolvable classes do not prevent type inference.
+ assertTrue(complete("def z = new NoSuchClass()\ndef s = 'x'\ns.toUpp")
+ .contains("toUpperCase"));
+ // A syntax error above the caret: the caret's line is still analyzed.
+ assertTrue(complete("def a = (]\n'abc'.toUpp").contains("toUpperCase"));
+ }
+
+ @Test
+ public void testKeywords() {
+ assertTrue(complete("ret").contains("return"));
+ assertFalse(complete("def x = 1.").contains("return"));
+ }
+
+ @Test
+ public void testClassNamesWithAutoImport() {
+ ClassIndex.ensureCache();
+ final Completion atomic = completions("def a = new AtomicInt").stream()
+ .filter(c -> c.insertionText().equals("AtomicInteger")).findFirst()
+ .orElse(null);
+ assertTrue(atomic != null);
+ assertEquals(1, atomic.additionalEdits().size());
+ assertEquals("import java.util.concurrent.atomic.AtomicInteger\n", atomic
+ .additionalEdits().get(0).newText());
+ // Already imported (or imported by default): no edit.
+ final Completion list = completions("def a = new ArrayLis").stream()
+ .filter(c -> c.insertionText().equals("ArrayList")).findFirst().orElse(
+ null);
+ assertTrue(list != null && list.additionalEdits().isEmpty());
+ assertNull(GroovyCodeCompleter.importEdit(
+ "import java.util.concurrent.atomic.*\n", "java.util.concurrent.atomic.AtomicInteger"));
+ // Inserted after the last import.
+ assertEquals(20, GroovyCodeCompleter.importEdit("import java.io.File\n" +
+ "x = 1\n", "java.util.concurrent.atomic.AtomicInteger").start());
+ }
+
+ @Test
+ public void testImports() {
+ ClassIndex.ensureCache();
+ assertTrue(complete("import java.util.concurrent.atomic.AtomicInt")
+ .contains("java.util.concurrent.atomic.AtomicInteger"));
+ assertTrue(complete("java.util.concurrent.atomic.AtomicInt").contains(
+ "AtomicInteger"));
+ }
+
+ @Test
+ public void testStringsAndComments() {
+ assertTrue(complete("def s = 'a.b.").isEmpty());
+ assertTrue(complete("// s.toUpp").isEmpty());
+ assertTrue(complete("/* x.").isEmpty());
+ }
+
+ @Test
+ public void testInterpreterBindings() throws Exception {
+ final ScriptEngine engine = groovy.getScriptEngine();
+ engine.put("injected", new ArrayList());
+ final String code = "injected.si";
+ final List texts = service.complete(new CompletionRequest(code,
+ code.length(), groovy, engine, engine.getContext())).completions()
+ .stream().map(Completion::insertionText).collect(Collectors.toList());
+ assertTrue(texts.toString(), texts.contains("size"));
+ }
+
+ @Test
+ public void testSignatureHelp() {
+ // A decimal (a BigDecimal) fits the floating-point overloads, which come
+ // first; the integral ones do not. The caret is in the second argument.
+ final SignatureHelp max = help("Math.max(1.5, ");
+ assertEquals(1, max.activeParameter());
+ assertEquals("[double:MATCH, float:MATCH, int:MISMATCH, long:MISMATCH]",
+ describe(max));
+ // An integer fits the integral overloads, and converts to the others.
+ assertEquals("[int:MATCH, long:MATCH, double:CONVERSION, " +
+ "float:CONVERSION]", describe(help("Math.max(1, 2")));
+ // Nothing typed yet: unrated.
+ assertTrue(describe(help("Math.max(")).contains("UNKNOWN"));
+ // Arguments' types come from the analysis, e.g. of variables.
+ final SignatureHelp indexOf = help("def s = 'abc'\ndef c = 'b'\n" +
+ "s.indexOf(c, ");
+ assertEquals("java.lang.String:MATCH", describe(indexOf).replaceAll(
+ "^\\[([^,]*),.*", "$1"));
+ // Constructors, and the script's own functions.
+ assertTrue(describe(help("new StringBuilder(")).contains(
+ "java.lang.String"));
+ final SignatureHelp area = help("def area(int w, int h) { w * h }\n" +
+ "area(3, ");
+ assertEquals("[int:MATCH]", describe(area));
+ // The innermost call, and none outside calls.
+ assertEquals(0, help("Math.max(Math.abs(").activeParameter());
+ assertTrue(help("def x = 1").isEmpty());
+ }
+
+ private SignatureHelp help(final String code) {
+ return service.getCompleter(groovy).signatureHelp(new CompletionRequest(
+ code, groovy, null));
+ }
+
+ /** Each signature's first parameter type and fit, in order. */
+ private static String describe(final SignatureHelp help) {
+ return help.signatures().stream().map(s -> (s.callable().parameters()
+ .isEmpty() ? "()" : s.callable().parameters().get(0).type()) + ":" + s
+ .fit()).collect(Collectors.toList()).toString();
+ }
+
+ @Test
+ public void testParameterChoices() {
+ final CompletionResult result = service.complete(new CompletionRequest(
+ "def a = new ArrayList()\ndef s = 'x'\nCollections.so", groovy, null));
+ final List choices = result.parameterChoices().choicesFor(
+ new Completion.Parameter("list", "java.util.List")).stream().map(
+ Completion::insertionText).collect(Collectors.toList());
+ assertEquals("[a]", choices.toString());
+ }
+
+ // -- Helper methods --
+
+ private List completions(final String code) {
+ return service.complete(new CompletionRequest(code, groovy, null))
+ .completions();
+ }
+
+ private List complete(final String code) {
+ return completions(code).stream().map(Completion::insertionText).collect(
+ Collectors.toList());
+ }
+}
From 4e1b586ca70b8278d880987fb77502d074928572 Mon Sep 17 00:00:00 2001
From: Gabriel Selzer
Date: Tue, 6 Oct 2026 12:51:51 -0500
Subject: [PATCH 02/21] Build the analyzed source from a ScriptDocument
GroovyDialect declares predefined variables as Java types and blanks # lines (now with spaces, keeping offsets) in place of GroovyAnalysis's own preamble.
Co-Authored-By: Claude Opus 5.5
---
.../scripting/groovy/GroovyAnalysis.java | 55 +++++-------------
.../scripting/groovy/GroovyCodeCompleter.java | 56 +++++++++++-------
.../scripting/groovy/GroovyDialect.java | 58 +++++++++++++++++++
3 files changed, 106 insertions(+), 63 deletions(-)
create mode 100644 src/main/java/org/scijava/plugins/scripting/groovy/GroovyDialect.java
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java
index 3ad0479..7e1a5a6 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java
@@ -68,6 +68,7 @@
import org.codehaus.groovy.control.SourceUnit;
import org.codehaus.groovy.transform.stc.StaticTypeCheckingVisitor;
import org.codehaus.groovy.transform.stc.StaticTypesMarker;
+import org.scijava.code.api.ScriptDocument;
import org.scijava.util.Types;
/**
@@ -118,56 +119,26 @@ private GroovyAnalysis(final SourceUnit source, final ClassLoader loader) {
// -- Source preparation --
/**
- * Builds the source to analyze: one line declaring the predefined variables
- * (script parameters, interpreter bindings), then the script up to the
- * caret, then {@link #CARET} and the brackets needed to close any left open.
+ * Builds the source to analyze: the script up to the caret as plain Groovy
+ * (its predefined variables declared, see {@link GroovyDialect}), then
+ * {@link #CARET} and the brackets needed to close any left open.
*
- * @param predefined Variables defined before the script runs: name to
- * (Groovy) type name.
- * @param textBeforeCaret The script up to the caret, without the partial
- * token being completed.
+ * @param doc The script up to the caret, without the partial token being
+ * completed.
* @return The source, or null if the caret is in a string or comment.
*/
- static String prepare(final Map predefined,
- final String textBeforeCaret)
- {
- return prepare(predefined, textBeforeCaret, CARET);
+ static String prepare(final ScriptDocument doc) {
+ return prepare(doc, CARET);
}
/**
- * As {@link #prepare(Map, String)}, appending {@code tail} where the caret
- * is.
+ * As {@link #prepare(ScriptDocument)}, appending {@code tail} where the
+ * caret is.
*/
- static String prepare(final Map predefined,
- final String textBeforeCaret, final String tail)
- {
- final String body = blankDirectives(textBeforeCaret);
- final String closers = closers(body);
+ static String prepare(final ScriptDocument doc, final String tail) {
+ final String closers = closers(doc.text());
if (closers == null) return null;
- final StringBuilder sb = new StringBuilder();
- for (final Map.Entry var : predefined.entrySet()) {
- sb.append(var.getValue()).append(' ').append(var.getKey()).append("; ");
- }
- return sb.append('\n').append(body).append(tail).append(closers)
- .toString();
- }
-
- /**
- * Blanks the lines starting with {@code #}: SciJava script parameters
- * ({@code #@}) and shebangs, which are not Groovy. Keeps line numbers.
- */
- private static String blankDirectives(final String text) {
- final StringBuilder sb = new StringBuilder(text.length());
- int start = 0;
- while (start <= text.length()) {
- int end = text.indexOf('\n', start);
- if (end < 0) end = text.length();
- final String line = text.substring(start, end);
- if (!line.trim().startsWith("#")) sb.append(line);
- if (end < text.length()) sb.append('\n');
- start = end + 1;
- }
- return sb.toString();
+ return doc.text() + tail + closers;
}
/**
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyCodeCompleter.java b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyCodeCompleter.java
index 272e263..e5dd9da 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyCodeCompleter.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyCodeCompleter.java
@@ -73,6 +73,8 @@
import org.scijava.code.api.CompletionResult;
import org.scijava.code.api.Completions;
import org.scijava.code.api.ParameterChoices;
+import org.scijava.code.api.ScriptDialect;
+import org.scijava.code.api.ScriptDocument;
import org.scijava.code.api.SignatureHelp;
import org.scijava.code.api.Signatures;
import org.scijava.plugin.Plugin;
@@ -107,6 +109,9 @@ public class GroovyCodeCompleter extends AbstractCodeCompleterPlugin {
/** Upper bound on class-name suggestions, which can be very numerous. */
private static final int MAX_CLASSES = 500;
+ /** How scripts see their predefined variables: as declared Java objects. */
+ private static final GroovyDialect DIALECT = new GroovyDialect();
+
/** An import statement being typed: "import a.b.C". */
private static final Pattern IMPORT = Pattern.compile(
"^\\s*import\\s+(static\\s+)?([\\w$.]*)$");
@@ -154,10 +159,11 @@ public CompletionResult complete(final CompletionRequest request) {
return CompletionResult.EMPTY;
}
- final Map predefined = predefinedVariables(request);
- final String textBefore = request.text().substring(0, replaceStart);
- final GroovyAnalysis analysis = analyze(predefined, textBefore, line
- .substring(0, line.length() - prefix.length()));
+ final Map> predefined = predefinedVariables(request);
+ final ScriptDocument doc = ScriptDocument.of(request.text().substring(0,
+ replaceStart), predefined, DIALECT, null);
+ final GroovyAnalysis analysis = analyze(doc, predefined, line.substring(0,
+ line.length() - prefix.length()));
if (analysis == null) return CompletionResult.EMPTY; // string or comment
final List completions = new ArrayList<>();
@@ -166,12 +172,13 @@ public CompletionResult complete(final CompletionRequest request) {
}
else {
final Map vars = analysis.variablesAtCaret();
- completions.addAll(variableCompletions(vars, predefined, prefix));
+ completions.addAll(variableCompletions(vars, doc.parameters(),
+ prefix));
completions.addAll(keywordCompletions(prefix));
completions.addAll(classCompletions(request.text(), prefix));
}
return new CompletionResult(completions, replaceStart, choices(analysis,
- predefined));
+ doc.parameters()));
}
@Override
@@ -183,10 +190,10 @@ public SignatureHelp signatureHelp(final CompletionRequest request) {
id.find();
final String prefix = id.group();
final int start = request.offset() - prefix.length();
- final Map predefined = predefinedVariables(request);
- final GroovyAnalysis analysis = analyze(predefined, request.text()
- .substring(0, start), line.substring(0, line.length() - prefix
- .length()));
+ final Map> predefined = predefinedVariables(request);
+ final GroovyAnalysis analysis = analyze(ScriptDocument.of(request.text()
+ .substring(0, start), predefined, DIALECT, null), predefined, line
+ .substring(0, line.length() - prefix.length()));
if (analysis == null) return SignatureHelp.NONE; // string or comment
final GroovyAnalysis.Call call = analysis.callAtCaret();
if (call == null) return SignatureHelp.NONE;
@@ -217,14 +224,15 @@ public SignatureHelp signatureHelp(final CompletionRequest request) {
*
* @return The analysis, or null if the caret is in a string or comment.
*/
- private GroovyAnalysis analyze(final Map predefined,
- final String textBefore, final String lineBefore)
+ private GroovyAnalysis analyze(final ScriptDocument doc,
+ final Map> predefined, final String lineBefore)
{
- final String code = GroovyAnalysis.prepare(predefined, textBefore);
+ final String code = GroovyAnalysis.prepare(doc);
if (code == null) return null;
final GroovyAnalysis analysis = GroovyAnalysis.of(code, loader());
if (analysis.isParsed()) return analysis;
- final String lineCode = GroovyAnalysis.prepare(predefined, lineBefore);
+ final String lineCode = GroovyAnalysis.prepare(ScriptDocument.of(
+ lineBefore, predefined, DIALECT, null));
return lineCode == null ? analysis : GroovyAnalysis.of(lineCode, loader());
}
@@ -552,18 +560,18 @@ private static Completion methodNode(final String name, final MethodNode m) {
* Gets the variables defined before the script runs: its parameters (e.g.
* {@code #@ String name}) and, in a live interpreter, the engine's bindings.
*
- * @return Map of variable name to type name.
+ * @return Map of variable name to type.
*/
- private Map predefinedVariables(
+ private Map> predefinedVariables(
final CompletionRequest request)
{
- final Map vars = new LinkedHashMap<>();
+ final Map> vars = new LinkedHashMap<>();
final Context context = getContext();
final CodeCompletionService service = context == null ? null : context
.getService(CodeCompletionService.class);
if (service != null) {
service.scriptParameters(request.text()).forEach((name, type) -> {
- if (isIdentifier(name)) vars.put(name, typeName(type));
+ if (isIdentifier(name)) vars.put(name, type);
});
}
final ScriptContext scriptContext = request.context();
@@ -574,8 +582,8 @@ private Map predefinedVariables(
final String name = entry.getKey();
if (!isIdentifier(name) || name.startsWith("__")) continue;
final Object value = entry.getValue();
- vars.put(name, value == null ? "Object" : typeName(
- publicType(value.getClass())));
+ vars.put(name, value == null ? Object.class : publicType(value
+ .getClass()));
}
}
return vars;
@@ -614,11 +622,17 @@ private static List sorted(final List completions) {
* {@code java.util.List} or {@code int[]}; or {@code Object} if it has none
* (e.g. an anonymous class).
*/
- private static String typeName(final Class> c) {
+ /** Gets the name by which Groovy code refers to {@code c}. */
+ static String typeName(final Class> c) {
final String name = c == null ? null : c.getCanonicalName();
return name == null ? "Object" : name;
}
+ @Override
+ public ScriptDialect dialect() {
+ return DIALECT;
+ }
+
/**
* Gets the most specific public class or interface of {@code c}, by which
* code can refer to its instances.
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyDialect.java b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyDialect.java
new file mode 100644
index 0000000..49b90e9
--- /dev/null
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyDialect.java
@@ -0,0 +1,58 @@
+/*
+ * #%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;
+
+import org.scijava.code.api.ScriptDialect;
+
+/**
+ * How Groovy scripts see their predefined variables (script parameters,
+ * interpreter bindings): as the Java objects themselves, declared e.g.
+ * {@code net.imglib2.img.Img image}. Lines starting with {@code #} (script
+ * parameters, shebangs) are not Groovy.
+ *
+ * @author Gabriel Selzer
+ */
+class GroovyDialect implements ScriptDialect {
+
+ @Override
+ public String runtimeType(final Class> type) {
+ return GroovyCodeCompleter.typeName(type);
+ }
+
+ @Override
+ public String declare(final String name, final String type) {
+ return type + " " + name;
+ }
+
+ @Override
+ public boolean blanks(final String line) {
+ return line.trim().startsWith("#");
+ }
+}
From 12446b735f48dfda44a165b3f83fd13dc93e9e9a Mon Sep 17 00:00:00 2001
From: Gabriel Selzer
Date: Wed, 7 Oct 2026 10:40:04 -0500
Subject: [PATCH 03/21] Serve Groovy as an in-process language server
GroovyLanguageServer (offered by GroovyServerPlugin) replaces GroovyCodeCompleter, with the same analysis: callables become snippets with typed label details, signatures are rated as before. In a call's arguments, with nothing typed, it now offers the variables fitting the parameter first (replacing parameter choices); live bindings come from the server's Environment.
Co-Authored-By: Claude Opus 5.5
---
pom.xml | 7 +
.../scripting/groovy/GroovyDialect.java | 2 +-
...mpleter.java => GroovyLanguageServer.java} | 519 ++++++++++--------
.../scripting/groovy/GroovyServerPlugin.java | 63 +++
.../groovy/GroovyCompletionTest.java | 191 +++++--
5 files changed, 493 insertions(+), 289 deletions(-)
rename src/main/java/org/scijava/plugins/scripting/groovy/{GroovyCodeCompleter.java => GroovyLanguageServer.java} (55%)
create mode 100644 src/main/java/org/scijava/plugins/scripting/groovy/GroovyServerPlugin.java
diff --git a/pom.xml b/pom.xml
index 9cd6a9d..0ed7a36 100644
--- a/pom.xml
+++ b/pom.xml
@@ -99,6 +99,8 @@
2.101.0
0.1.0-SNAPSHOT
+
+ 2.14.0
@@ -112,6 +114,11 @@
scijava-code-api
${scijava-code-api.version}
+
+ org.scijava
+ scijava-code-lsp
+ ${scijava-code-api.version}
+
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyDialect.java b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyDialect.java
index 49b90e9..77064a8 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyDialect.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyDialect.java
@@ -43,7 +43,7 @@ class GroovyDialect implements ScriptDialect {
@Override
public String runtimeType(final Class> type) {
- return GroovyCodeCompleter.typeName(type);
+ return GroovyLanguageServer.typeName(type);
}
@Override
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyCodeCompleter.java b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java
similarity index 55%
rename from src/main/java/org/scijava/plugins/scripting/groovy/GroovyCodeCompleter.java
rename to src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java
index e5dd9da..6fd8963 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyCodeCompleter.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java
@@ -29,7 +29,6 @@
package org.scijava.plugins.scripting.groovy;
-import java.lang.reflect.Constructor;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Arrays;
@@ -61,36 +60,33 @@
import org.codehaus.groovy.ast.expr.Expression;
import org.codehaus.groovy.ast.expr.MethodCallExpression;
import org.codehaus.groovy.ast.expr.StaticMethodCallExpression;
-import org.scijava.Context;
-import org.scijava.Priority;
-import org.scijava.code.api.AbstractCodeCompleterPlugin;
+import org.eclipse.lsp4j.CompletionItem;
+import org.eclipse.lsp4j.CompletionItemKind;
+import org.eclipse.lsp4j.CompletionItemLabelDetails;
+import org.eclipse.lsp4j.CompletionList;
+import org.eclipse.lsp4j.SignatureHelp;
+import org.eclipse.lsp4j.TextEdit;
import org.scijava.code.api.ClassIndex;
-import org.scijava.code.api.CodeCompleterPlugin;
import org.scijava.code.api.CodeCompletionService;
-import org.scijava.code.api.Completion;
-import org.scijava.code.api.Completion.TextEdit;
-import org.scijava.code.api.CompletionRequest;
-import org.scijava.code.api.CompletionResult;
-import org.scijava.code.api.Completions;
-import org.scijava.code.api.ParameterChoices;
-import org.scijava.code.api.ScriptDialect;
import org.scijava.code.api.ScriptDocument;
-import org.scijava.code.api.SignatureHelp;
-import org.scijava.code.api.Signatures;
-import org.scijava.plugin.Plugin;
+import org.scijava.code.lsp.Callable;
+import org.scijava.code.lsp.Environment;
+import org.scijava.code.lsp.Members;
+import org.scijava.code.lsp.ScriptLanguageServer;
+import org.scijava.code.lsp.Signatures;
import org.scijava.util.Types;
/**
- * Code completion for Groovy, driven by Groovy's own compiler (see
- * {@link GroovyAnalysis}): members of the expression before a dot, using the
- * types the static type checker infers; variables in scope; keywords; class
- * names, with automatic imports; and import statements.
+ * An in-process language server for Groovy, driven by Groovy's own compiler
+ * (see {@link GroovyAnalysis}): members of the expression before a dot, using
+ * the types the static type checker infers; variables in scope; keywords;
+ * class names, with automatic imports; import statements; and, in a call's
+ * arguments, the variables fitting the parameter first. Signature help rates
+ * the overloads against the arguments typed.
*
* @author Gabriel Selzer
*/
-@Plugin(type = CodeCompleterPlugin.class, name = "Groovy",
- priority = Priority.HIGH)
-public class GroovyCodeCompleter extends AbstractCodeCompleterPlugin {
+public class GroovyLanguageServer extends ScriptLanguageServer {
private static final List KEYWORDS = Arrays.asList("as", "assert",
"boolean", "break", "byte", "case", "catch", "char", "class", "continue",
@@ -110,7 +106,7 @@ public class GroovyCodeCompleter extends AbstractCodeCompleterPlugin {
private static final int MAX_CLASSES = 500;
/** How scripts see their predefined variables: as declared Java objects. */
- private static final GroovyDialect DIALECT = new GroovyDialect();
+ static final GroovyDialect DIALECT = new GroovyDialect();
/** An import statement being typed: "import a.b.C". */
private static final Pattern IMPORT = Pattern.compile(
@@ -127,24 +123,38 @@ public class GroovyCodeCompleter extends AbstractCodeCompleterPlugin {
private static final Pattern QUALIFIER = Pattern.compile(
"((?:[a-z_][\\w$]*\\.)+)\\s*$");
- public GroovyCodeCompleter() {
+ static {
// Warm the class index in the background, as completion needs it.
new Thread(ClassIndex::ensureCache, "Groovy-ClassIndex-warmup").start();
}
+ private final CodeCompletionService completion;
+
+ /**
+ * @param completion Reads the scripts' parameters.
+ * @param environment Where the scripts run (e.g. a live interpreter).
+ */
+ public GroovyLanguageServer(final CodeCompletionService completion,
+ final Environment environment)
+ {
+ super(environment);
+ this.completion = completion;
+ }
+
@Override
- public CompletionResult complete(final CompletionRequest request) {
- final String line = request.lineToCaret();
- final int caret = request.offset();
+ protected CompletionList complete(final Document doc, final int caret) {
+ final String text = doc.text();
+ final String line = doc.lineTo(caret);
+ final List none = new ArrayList<>();
// NB: Lines starting with '#' are SciJava script parameters (or shebangs).
- if (line.trim().startsWith("#")) return CompletionResult.EMPTY;
+ if (line.trim().startsWith("#")) return list(doc, caret, caret, none);
final Matcher imp = IMPORT.matcher(line);
if (imp.find()) {
- if (imp.group(1) != null) return CompletionResult.EMPTY; // static
+ if (imp.group(1) != null) return list(doc, caret, caret, none);
final String token = imp.group(2);
- return new CompletionResult(importCompletions(token), caret - token
- .length());
+ return list(doc, caret - token.length(), caret, importCompletions(
+ token));
}
final Matcher id = IDENTIFIER.matcher(line);
@@ -154,70 +164,133 @@ public CompletionResult complete(final CompletionRequest request) {
final String before = line.substring(0, line.length() - prefix.length());
final boolean member = before.trim().endsWith(".") && !before.trim()
.endsWith("..") && !NUMBER_DOT.matcher(before).find();
- if (!member && prefix.isEmpty()) return CompletionResult.EMPTY;
+ if (!member && prefix.isEmpty()) {
+ return list(doc, caret, caret, argumentChoices(text, caret, line));
+ }
if (!member && Character.isDigit(prefix.charAt(0))) {
- return CompletionResult.EMPTY;
+ return list(doc, caret, caret, none);
}
- final Map> predefined = predefinedVariables(request);
- final ScriptDocument doc = ScriptDocument.of(request.text().substring(0,
+ final Map> predefined = predefinedVariables(text);
+ final ScriptDocument sd = ScriptDocument.of(text.substring(0,
replaceStart), predefined, DIALECT, null);
- final GroovyAnalysis analysis = analyze(doc, predefined, line.substring(0,
+ final GroovyAnalysis analysis = analyze(sd, predefined, line.substring(0,
line.length() - prefix.length()));
- if (analysis == null) return CompletionResult.EMPTY; // string or comment
+ if (analysis == null) return list(doc, caret, caret, none); // string, comment
- final List completions = new ArrayList<>();
- if (member) {
- completions.addAll(memberCompletions(analysis, before, prefix));
- }
+ final List items = new ArrayList<>();
+ if (member) items.addAll(memberCompletions(analysis, before, prefix));
else {
- final Map vars = analysis.variablesAtCaret();
- completions.addAll(variableCompletions(vars, doc.parameters(),
- prefix));
- completions.addAll(keywordCompletions(prefix));
- completions.addAll(classCompletions(request.text(), prefix));
+ items.addAll(variableCompletions(analysis.variablesAtCaret(), sd
+ .parameters(), prefix));
+ items.addAll(keywordCompletions(prefix));
+ items.addAll(classCompletions(doc, prefix));
}
- return new CompletionResult(completions, replaceStart, choices(analysis,
- doc.parameters()));
+ return list(doc, replaceStart, caret, items);
}
@Override
- public SignatureHelp signatureHelp(final CompletionRequest request) {
- final String line = request.lineToCaret();
- if (line.trim().startsWith("#")) return SignatureHelp.NONE;
- // NB: The argument being typed, if a name, is replaced by the marker.
- final Matcher id = IDENTIFIER.matcher(line);
- id.find();
- final String prefix = id.group();
- final int start = request.offset() - prefix.length();
- final Map> predefined = predefinedVariables(request);
- final GroovyAnalysis analysis = analyze(ScriptDocument.of(request.text()
- .substring(0, start), predefined, DIALECT, null), predefined, line
- .substring(0, line.length() - prefix.length()));
- if (analysis == null) return SignatureHelp.NONE; // string or comment
- final GroovyAnalysis.Call call = analysis.callAtCaret();
- if (call == null) return SignatureHelp.NONE;
- final List callables = callables(analysis, call);
- if (callables.isEmpty()) return SignatureHelp.NONE;
+ protected SignatureHelp signatureHelp(final Document doc, final int caret) {
+ final String text = doc.text();
+ final String line = doc.lineTo(caret);
+ if (line.trim().startsWith("#")) return null;
+ final CallContext call = callContext(text, caret, line);
+ if (call == null) return null;
+ final List callables = callables(call.analysis, call.call);
+ if (callables.isEmpty()) return null;
// NB: Reflection lists methods in no particular order: sort them by
// their parameter types, so that equally good fits come out the same.
- callables.sort(Comparator.comparing(c -> c.parameters().stream().map(
- p -> String.valueOf(p.type())).collect(Collectors.joining(","))));
+ callables.sort(Comparator.comparing(Callable::parameterTypes));
// The types of the arguments before the caret's (which is being typed).
final List argTypes = new ArrayList<>();
- for (int i = 0; i < call.arguments.size(); i++) {
- argTypes.add(i == call.active ? null : analysis.typeName(call.arguments
- .get(i)));
+ for (int i = 0; i < call.call.arguments.size(); i++) {
+ argTypes.add(i == call.call.active ? null : call.analysis.typeName(
+ call.call.arguments.get(i)));
}
final ClassLoader loader = loader();
return new SignatureHelp(Signatures.rateAll(callables, argTypes, (a,
- p) -> Signatures.fit(a, p, loader)), call.active, Signatures.callStart(
- request.text(), request.offset()));
+ p) -> Signatures.fit(a, p, loader)), 0, call.call.active);
}
// -- Helper methods --
+ /** A call around the caret, as analyzed. */
+ private static final class CallContext {
+
+ private final GroovyAnalysis analysis;
+ private final GroovyAnalysis.Call call;
+
+ private CallContext(final GroovyAnalysis analysis,
+ final GroovyAnalysis.Call call)
+ {
+ this.analysis = analysis;
+ this.call = call;
+ }
+ }
+
+ /** Analyzes the call around the caret, or returns null if none. */
+ private CallContext callContext(final String text, final int caret,
+ final String line)
+ {
+ // NB: The argument being typed, if a name, is replaced by the marker.
+ final Matcher id = IDENTIFIER.matcher(line);
+ id.find();
+ final String prefix = id.group();
+ final int start = caret - prefix.length();
+ final Map> predefined = predefinedVariables(text);
+ final GroovyAnalysis analysis = analyze(ScriptDocument.of(text.substring(
+ 0, start), predefined, DIALECT, null), predefined, line.substring(0, line
+ .length() - prefix.length()));
+ if (analysis == null) return null; // string or comment
+ final GroovyAnalysis.Call call = analysis.callAtCaret();
+ return call == null ? null : new CallContext(analysis, call);
+ }
+
+ /**
+ * In a call's arguments, with nothing typed yet: the variables in scope,
+ * those fitting the parameter first (as preselected).
+ */
+ private List argumentChoices(final String text,
+ final int caret, final String line)
+ {
+ if (Signatures.callStart(text, caret) < 0) return new ArrayList<>();
+ final CallContext call = callContext(text, caret, line);
+ if (call == null) return new ArrayList<>();
+ final Set> targets = new HashSet<>();
+ for (final Callable c : callables(call.analysis, call.call)) {
+ if (call.call.active >= c.params().size()) continue;
+ final String type = c.params().get(call.call.active).type();
+ final Class> t = type == null ? null : Types.box(GroovyAnalysis.load(
+ type, loader()));
+ if (t != null && t != Object.class) targets.add(t);
+ }
+ final Map> vars = new LinkedHashMap<>();
+ final Map local = call.analysis.variablesAtCaret();
+ final List order = new ArrayList<>(local.keySet());
+ Collections.reverse(order);
+ for (final String name : order) {
+ vars.put(name, Types.box(call.analysis.toClass(local.get(name))));
+ }
+ predefinedVariables(text).forEach(vars::putIfAbsent);
+ final List fitting = new ArrayList<>();
+ final List others = new ArrayList<>();
+ for (final Map.Entry> v : vars.entrySet()) {
+ final Class> c = Types.box(v.getValue());
+ final boolean fits = c != null && targets.stream().anyMatch(t -> t
+ .isAssignableFrom(c));
+ final CompletionItem item = variable(v.getKey(), c == null ? null
+ : typeName(c));
+ if (fits) {
+ item.setPreselect(fitting.isEmpty());
+ fitting.add(item);
+ }
+ else others.add(item);
+ }
+ fitting.addAll(others);
+ return fitting;
+ }
+
/**
* Analyzes the script up to the caret. If it does not parse (e.g. due to a
* syntax error above the caret), falls back to the caret's line alone.
@@ -236,8 +309,8 @@ private GroovyAnalysis analyze(final ScriptDocument doc,
return lineCode == null ? analysis : GroovyAnalysis.of(lineCode, loader());
}
- private List memberCompletions(final GroovyAnalysis analysis,
- final String before, final String prefix)
+ private List memberCompletions(
+ final GroovyAnalysis analysis, final String before, final String prefix)
{
final Expression receiver = analysis.receiverAtCaret();
final ClassNode type = GroovyAnalysis.typeOf(receiver);
@@ -245,113 +318,118 @@ private List memberCompletions(final GroovyAnalysis analysis,
// Perhaps a package name, e.g. "java.util."
final Matcher q = QUALIFIER.matcher(before);
if (q.find()) return packageMemberCompletions(q.group(1), prefix);
- return Collections.emptyList();
+ return new ArrayList<>();
}
final boolean staticOnly = receiver instanceof ClassExpression;
if (type.redirect().isPrimaryClassNode()) {
return scriptClassMembers(analysis, type.redirect(), prefix, staticOnly);
}
final Class> c = Types.box(analysis.toClass(type));
- if (c == null) return Collections.emptyList();
- final List out = sorted(Completions.membersOf(c, prefix,
+ if (c == null) return new ArrayList<>();
+ final List out = sorted(Members.of(c, prefix,
staticOnly));
- if (!staticOnly) out.addAll(gdkCompletions(c, prefix));
+ if (!staticOnly) {
+ for (final Callable m : gdkMethods(c, prefix)) out.add(m.toItem());
+ }
return out;
}
/** Members of a class defined in the script itself. */
- private List scriptClassMembers(final GroovyAnalysis analysis,
- final ClassNode type, final String prefix, final boolean staticOnly)
+ private List scriptClassMembers(
+ final GroovyAnalysis analysis, final ClassNode type, final String prefix,
+ final boolean staticOnly)
{
- final List out = new ArrayList<>();
+ final List out = new ArrayList<>();
for (final PropertyNode p : type.getProperties()) {
if (staticOnly && !p.isStatic() || !matches(p.getName(), prefix)) {
continue;
}
- out.add(Completion.builder(p.getName()).kind(Completion.Kind.FIELD)
- .returnType(p.getType().toString(false)).summary(p.getType().toString(
- false) + " " + p.getName()).build());
+ out.add(field(p.getName(), p.getType().toString(false)));
}
for (final FieldNode f : type.getFields()) {
if (!f.isPublic() || f.isSynthetic() || staticOnly && !f.isStatic() ||
!matches(f.getName(), prefix) || type.getProperty(f
.getName()) != null) continue;
- out.add(Completion.builder(f.getName()).kind(Completion.Kind.FIELD)
- .returnType(f.getType().toString(false)).build());
+ out.add(field(f.getName(), f.getType().toString(false)));
}
- for (final MethodNode m : type.getMethods()) {
- if (!m.isPublic() || m.isSynthetic() || staticOnly && !m.isStatic() ||
- !matches(m.getName(), prefix) || m.getName().contains("$")) continue;
- final List params = new ArrayList<>();
- for (final org.codehaus.groovy.ast.Parameter p : m.getParameters()) {
- params.add(new Completion.Parameter(p.getName(), p.getType()
- .toString(false)));
- }
- out.add(Completion.builder(m.getName()).kind(Completion.Kind.METHOD)
- .parameters(params).returnType(m.getReturnType().toString(false))
- .summary(m.getTypeDescriptor()).build());
+ for (final Callable m : scriptMethods(type, prefix, staticOnly, false)) {
+ out.add(m.toItem());
}
- final List sorted = sorted(out);
+ final List sorted = sorted(out);
// Plus what it inherits from (compiled) superclasses.
final Class> superclass = analysis.toClass(type.getSuperClass());
if (superclass != null && superclass != Object.class) {
- sorted.addAll(sorted(Completions.membersOf(superclass, prefix,
- staticOnly)));
+ sorted.addAll(sorted(Members.of(superclass, prefix, staticOnly)));
}
return sorted;
}
+ /** Methods of a class defined in the script: by prefix, or exact name. */
+ private static List scriptMethods(final ClassNode type,
+ final String name, final boolean staticOnly, final boolean exact)
+ {
+ final List out = new ArrayList<>();
+ for (final MethodNode m : type.getMethods()) {
+ if (!m.isPublic() || m.isSynthetic() || staticOnly && !m.isStatic() || m
+ .getName().contains("$")) continue;
+ if (exact ? !m.getName().equals(name) : !matches(m.getName(), name)) {
+ continue;
+ }
+ out.add(methodNode(m.getName(), m).detail(m.getTypeDescriptor()));
+ }
+ return out;
+ }
+
/** Classes and subpackages of a package, e.g. after "java.util." */
- private List packageMemberCompletions(final String qualifier,
- final String prefix)
+ private static List packageMemberCompletions(
+ final String qualifier, final String prefix)
{
- if (!ClassIndex.isCacheReady()) return Collections.emptyList();
+ if (!ClassIndex.isCacheReady()) return new ArrayList<>();
final String pkg = qualifier.substring(0, qualifier.length() - 1);
return ClassIndex.findClassNamesForPackage(pkg) //
.filter(s -> s.lastIndexOf('.') == pkg.length()) //
.map(s -> s.substring(pkg.length() + 1)) //
.filter(s -> matches(s, prefix) && !s.contains("$")) //
.sorted().limit(MAX_CLASSES) //
- .map(s -> Completion.builder(s).kind(Completion.Kind.CLASS).summary(
- pkg + "." + s).build()) //
+ .map(s -> item(s, CompletionItemKind.Class, pkg + "." + s)) //
.collect(Collectors.toList());
}
/**
* The GDK methods Groovy adds to {@code c}, e.g. {@code each} and
- * {@code collect} for collections.
+ * {@code collect} for collections; by prefix.
*/
- private static List gdkCompletions(final Class> c,
+ private static List gdkMethods(final Class> c,
final String prefix)
{
- final Map out = new LinkedHashMap<>();
+ final Map out = new LinkedHashMap<>();
for (final MetaMethod m : GroovySystem.getMetaClassRegistry()
.getMetaClass(c).getMetaMethods())
{
if (m.isStatic() || !matches(m.getName(), prefix)) continue;
- final List params = new ArrayList<>();
+ final List params = new ArrayList<>();
final Class>[] types = m.getNativeParameterTypes();
for (int i = 0; i < types.length; i++) {
- params.add(new Completion.Parameter("arg" + i, typeName(types[i])));
+ params.add(new Callable.Param("arg" + i, typeName(types[i])));
}
final String signature = typeName(m.getReturnType()) + " " + m
- .getName() + params.stream().map(Completion.Parameter::type).collect(
+ .getName() + params.stream().map(Callable.Param::type).collect(
Collectors.joining(", ", "(", ")"));
// NB: Groovy lists some methods more than once.
- out.putIfAbsent(signature, Completion.builder(m.getName()).kind(
- Completion.Kind.METHOD).parameters(params).returnType(typeName(m
- .getReturnType())).declaringClass(m.getDeclaringClass().getName())
- .summary("GDK: " + signature).build());
+ out.putIfAbsent(signature, new Callable(m.getName(), params, typeName(m
+ .getReturnType())).detail("GDK: " + signature));
}
- return sorted(new ArrayList<>(out.values()));
+ final List sorted = new ArrayList<>(out.values());
+ sorted.sort(Comparator.comparing(m -> m.name().toLowerCase(Locale.ROOT)));
+ return sorted;
}
- private static List variableCompletions(
+ private static List variableCompletions(
final Map vars, final Map predefined,
final String prefix)
{
final Set names = new HashSet<>();
- final List out = new ArrayList<>();
+ final List out = new ArrayList<>();
// Innermost (most recently declared) variables first.
final List order = new ArrayList<>(vars.keySet());
Collections.reverse(order);
@@ -361,57 +439,58 @@ private static List variableCompletions(
continue;
}
final ClassNode type = vars.get(name);
- final String typeName = type != null ? type.toString(false) : predefined
- .get(name);
- out.add(Completion.builder(name).kind(Completion.Kind.VARIABLE).summary(
- typeName == null ? name : typeName + " " + name).build());
+ out.add(variable(name, type != null ? type.toString(false) : predefined
+ .get(name)));
}
return out;
}
- private static List keywordCompletions(final String prefix) {
+ private static List keywordCompletions(final String prefix) {
return KEYWORDS.stream().filter(k -> k.startsWith(prefix) && !k.equals(
- prefix)).map(k -> Completion.builder(k).kind(Completion.Kind.KEYWORD)
- .build()).collect(Collectors.toList());
+ prefix)).map(k -> item(k, CompletionItemKind.Keyword, null)).collect(
+ Collectors.toList());
}
/** Class names starting with the prefix, imported on acceptance. */
- private static List classCompletions(final String text,
+ private static List classCompletions(final Document doc,
final String prefix)
{
if (!Character.isUpperCase(prefix.charAt(0)) || !ClassIndex
- .isCacheReady()) return Collections.emptyList();
+ .isCacheReady()) return new ArrayList<>();
final List classNames = ClassIndex.findSimpleClassNamesStartingWith(
prefix);
// Closest matches first: shortest simple name, then alphabetical.
classNames.sort(Comparator.comparingInt((String cn) -> cn.length() - cn
.lastIndexOf('.')).thenComparing(Comparator.naturalOrder()));
- final List out = new ArrayList<>();
+ final List out = new ArrayList<>();
for (final String className : classNames) {
if (className.contains("$")) continue;
final String simpleName = className.substring(className.lastIndexOf(
'.') + 1);
- final Completion.Builder b = Completion.builder(simpleName).kind(
- Completion.Kind.CLASS).summary(className);
- final TextEdit edit = importEdit(text, className);
- if (edit != null) b.additionalEdits(Collections.singletonList(edit));
- out.add(b.build());
+ final CompletionItem item = item(simpleName, CompletionItemKind.Class,
+ className);
+ final int at = importOffset(doc.text(), className);
+ if (at >= 0) {
+ item.setAdditionalTextEdits(Collections.singletonList(insert(doc, at,
+ "import " + className + "\n")));
+ }
+ out.add(item);
if (out.size() >= MAX_CLASSES) break;
}
return out;
}
/**
- * An edit importing the given class, after the script's last import (or at
- * the top), or null if no import is needed.
+ * Where to import the given class: after the script's last import (or at
+ * the top); or -1 if no import is needed.
*/
- static TextEdit importEdit(final String text, final String className) {
+ static int importOffset(final String text, final String className) {
final String pkg = className.substring(0, Math.max(0, className
.lastIndexOf('.')));
if (DEFAULT_IMPORTS.contains(pkg) || className.equals(
"java.math.BigDecimal") || className.equals("java.math.BigInteger"))
{
- return null;
+ return -1;
}
final Pattern imports = Pattern.compile("^\\s*import\\s+(?:static\\s+)?" +
"([\\w$.]+?)(\\.\\*)?\\s*(?:as\\s+\\w+\\s*)?;?\\s*$", Pattern.MULTILINE);
@@ -420,69 +499,35 @@ static TextEdit importEdit(final String text, final String className) {
while (m.find()) {
final String imported = m.group(1);
if (imported.equals(className) || m.group(2) != null && imported.equals(
- pkg)) return null;
+ pkg)) return -1;
insertAt = m.end() + 1;
}
if (insertAt == 0 && text.startsWith("#!")) {
insertAt = text.indexOf('\n') + 1; // NB: After the shebang.
}
- insertAt = Math.min(insertAt, text.length());
- return TextEdit.insert(insertAt, "import " + className + "\n");
+ return Math.min(insertAt, text.length());
}
- private static List importCompletions(final String token) {
+ private static List importCompletions(final String token) {
if (token.isEmpty() || !ClassIndex.isCacheReady()) {
- return Collections.emptyList();
+ return new ArrayList<>();
}
return ClassIndex.findClassNamesStartingWith(token) //
.filter(s -> !s.contains("$")) //
.sorted().limit(MAX_CLASSES) //
- .map(s -> Completion.builder(s).kind(Completion.Kind.IMPORT).build()) //
+ .map(s -> item(s, CompletionItemKind.Module, null)) //
.collect(Collectors.toList());
}
- /** Suggests in-scope variables whose type suits a parameter. */
- private ParameterChoices choices(final GroovyAnalysis analysis,
- final Map predefined)
- {
- return parameter -> {
- final Class> target = Types.box(GroovyAnalysis.load(parameter.type(),
- loader()));
- if (target == null || target == Object.class) {
- return Collections.emptyList();
- }
- final Map vars = analysis.variablesAtCaret();
- final List out = new ArrayList<>();
- for (final Map.Entry var : vars.entrySet()) {
- final Class> c = Types.box(analysis.toClass(var.getValue()));
- if (c != null && target.isAssignableFrom(c)) {
- out.add(Completion.of(var.getKey()));
- }
- }
- for (final Map.Entry var : predefined.entrySet()) {
- if (vars.containsKey(var.getKey())) continue;
- final Class> c = Types.box(GroovyAnalysis.load(var.getValue(), loader()));
- if (c != null && target.isAssignableFrom(c)) {
- out.add(Completion.of(var.getKey()));
- }
- }
- return out;
- };
- }
-
/** The signatures the call may be calling: methods or constructors. */
- private List callables(final GroovyAnalysis analysis,
+ private List callables(final GroovyAnalysis analysis,
final GroovyAnalysis.Call call)
{
- final List out = new ArrayList<>();
+ final List out = new ArrayList<>();
if (call.expression instanceof ConstructorCallExpression) {
final ClassNode type = call.expression.getType().redirect();
final Class> c = analysis.toClass(type);
- if (c != null) {
- for (final Constructor> ctor : c.getConstructors()) {
- out.add(constructor(ctor));
- }
- }
+ if (c != null) out.addAll(Members.constructors(c));
else if (type.isPrimaryClassNode()) {
for (final ConstructorNode ctor : type.getDeclaredConstructors()) {
out.add(methodNode(type.getNameWithoutPackage(), ctor));
@@ -493,8 +538,8 @@ else if (type.isPrimaryClassNode()) {
if (call.expression instanceof StaticMethodCallExpression) {
final StaticMethodCallExpression e =
(StaticMethodCallExpression) call.expression;
- return named(Completions.membersOf(analysis.toClass(e.getOwnerType()),
- e.getMethod(), true), e.getMethod());
+ return Members.methods(analysis.toClass(e.getOwnerType()), e.getMethod(),
+ true);
}
final MethodCallExpression e = (MethodCallExpression) call.expression;
final String name = e.getMethodAsString();
@@ -504,7 +549,7 @@ else if (type.isPrimaryClassNode()) {
for (final MethodNode m : analysis.scriptMethods(name)) {
out.add(methodNode(name, m));
}
- out.addAll(named(gdkCompletions(Object.class, name), name));
+ out.addAll(named(gdkMethods(Object.class, name), name));
return out;
}
final Expression receiver = e.getObjectExpression();
@@ -512,71 +557,75 @@ else if (type.isPrimaryClassNode()) {
if (type == null) return out;
final boolean staticOnly = receiver instanceof ClassExpression;
if (type.redirect().isPrimaryClassNode()) {
- return named(scriptClassMembers(analysis, type.redirect(), name,
- staticOnly), name);
+ return scriptMethods(type.redirect(), name, staticOnly, true);
}
final Class> c = Types.box(analysis.toClass(type));
if (c == null) return out;
- out.addAll(named(Completions.membersOf(c, name, staticOnly), name));
- if (!staticOnly) out.addAll(named(gdkCompletions(c, name), name));
+ out.addAll(Members.methods(c, name, staticOnly));
+ if (!staticOnly) out.addAll(named(gdkMethods(c, name), name));
return out;
}
- /** The callables among {@code completions} with exactly the given name. */
- private static List named(final List completions,
+ /** The callables with exactly the given name. */
+ private static List named(final List callables,
final String name)
{
- final List out = new ArrayList<>();
- for (final Completion c : completions) {
- if (c.kind() == Completion.Kind.METHOD && c.insertionText().equals(
- name)) out.add(c);
- }
- return out;
- }
-
- private static Completion constructor(final Constructor> ctor) {
- final List params = new ArrayList<>();
- for (final java.lang.reflect.Parameter p : ctor.getParameters()) {
- params.add(new Completion.Parameter(p.getName(), typeName(p
- .getType())));
- }
- return Completion.builder(ctor.getDeclaringClass().getSimpleName()).kind(
- Completion.Kind.METHOD).parameters(params).returnType(typeName(ctor
- .getDeclaringClass())).build();
+ return callables.stream().filter(c -> c.name().equals(name)).collect(
+ Collectors.toList());
}
/** A callable defined in the script itself. */
- private static Completion methodNode(final String name, final MethodNode m) {
- final List params = new ArrayList<>();
+ private static Callable methodNode(final String name, final MethodNode m) {
+ final List params = new ArrayList<>();
for (final org.codehaus.groovy.ast.Parameter p : m.getParameters()) {
- params.add(new Completion.Parameter(p.getName(), p.isDynamicTyped()
- ? null : p.getType().getName()));
+ params.add(new Callable.Param(p.getName(), p.isDynamicTyped() ? null : p
+ .getType().getName()));
}
- return Completion.builder(name).kind(Completion.Kind.METHOD).parameters(
- params).returnType(m.getReturnType().toString(false)).build();
+ return new Callable(name, params, m.getReturnType().toString(false));
+ }
+
+ private static CompletionItem item(final String text,
+ final CompletionItemKind kind, final String detail)
+ {
+ final CompletionItem item = new CompletionItem(text);
+ item.setKind(kind);
+ item.setInsertText(text);
+ item.setDetail(detail);
+ return item;
+ }
+
+ private static CompletionItem variable(final String name,
+ final String type)
+ {
+ return item(name, CompletionItemKind.Variable, type == null ? name : type +
+ " " + name);
+ }
+
+ private static CompletionItem field(final String name, final String type) {
+ final CompletionItem item = item(name, CompletionItemKind.Field, type +
+ " " + name);
+ final CompletionItemLabelDetails details = new CompletionItemLabelDetails();
+ details.setDescription(type);
+ item.setLabelDetails(details);
+ return item;
}
/**
* Gets the variables defined before the script runs: its parameters (e.g.
- * {@code #@ String name}) and, in a live interpreter, the engine's bindings.
+ * {@code #@ String name}) and, in a live interpreter, its bindings.
*
* @return Map of variable name to type.
*/
- private Map> predefinedVariables(
- final CompletionRequest request)
- {
+ private Map> predefinedVariables(final String text) {
final Map> vars = new LinkedHashMap<>();
- final Context context = getContext();
- final CodeCompletionService service = context == null ? null : context
- .getService(CodeCompletionService.class);
- if (service != null) {
- service.scriptParameters(request.text()).forEach((name, type) -> {
+ if (completion != null) {
+ completion.scriptParameters(text).forEach((name, type) -> {
if (isIdentifier(name)) vars.put(name, type);
});
}
- final ScriptContext scriptContext = request.context();
- final Bindings bindings = scriptContext == null ? null : scriptContext
- .getBindings(ScriptContext.ENGINE_SCOPE);
+ final ScriptContext live = environment().live();
+ final Bindings bindings = live == null ? null : live.getBindings(
+ ScriptContext.ENGINE_SCOPE);
if (bindings != null) {
for (final Map.Entry entry : bindings.entrySet()) {
final String name = entry.getKey();
@@ -591,7 +640,7 @@ private Map> predefinedVariables(
private static ClassLoader loader() {
final ClassLoader loader = Thread.currentThread().getContextClassLoader();
- return loader != null ? loader : GroovyCodeCompleter.class
+ return loader != null ? loader : GroovyLanguageServer.class
.getClassLoader();
}
@@ -610,29 +659,25 @@ private static boolean matches(final String name, final String prefix) {
}
/** Sorts completions by name, case-insensitively (stably). */
- private static List sorted(final List completions) {
- final List out = new ArrayList<>(completions);
- out.sort(Comparator.comparing(c -> c.insertionText().toLowerCase(
+ private static List sorted(
+ final List items)
+ {
+ final List out = new ArrayList<>(items);
+ out.sort(Comparator.comparing(c -> c.getLabel().toLowerCase(
Locale.ROOT)));
return out;
}
/**
- * Gets the name by which Groovy code can refer to a class, e.g.
+ * Gets the name by which Groovy code refers to a class, e.g.
* {@code java.util.List} or {@code int[]}; or {@code Object} if it has none
* (e.g. an anonymous class).
*/
- /** Gets the name by which Groovy code refers to {@code c}. */
static String typeName(final Class> c) {
final String name = c == null ? null : c.getCanonicalName();
return name == null ? "Object" : name;
}
- @Override
- public ScriptDialect dialect() {
- return DIALECT;
- }
-
/**
* Gets the most specific public class or interface of {@code c}, by which
* code can refer to its instances.
@@ -653,4 +698,10 @@ private static boolean isAccessible(final Class> c) {
}
return !c.isAnonymousClass() && !c.isLocalClass();
}
+
+ /** An edit importing a class (for tests). */
+ static TextEdit importEdit(final Document doc, final String className) {
+ final int at = importOffset(doc.text(), className);
+ return at < 0 ? null : insert(doc, at, "import " + className + "\n");
+ }
}
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyServerPlugin.java b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyServerPlugin.java
new file mode 100644
index 0000000..83333be
--- /dev/null
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyServerPlugin.java
@@ -0,0 +1,63 @@
+/*
+ * #%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;
+
+import org.eclipse.lsp4j.services.LanguageServer;
+import org.scijava.Priority;
+import org.scijava.code.api.CodeCompletionService;
+import org.scijava.code.lsp.Environment;
+import org.scijava.code.lsp.LanguageServerPlugin;
+import org.scijava.plugin.Parameter;
+import org.scijava.plugin.Plugin;
+import org.scijava.script.ScriptLanguage;
+
+/**
+ * Offers Groovy's in-process language server (see
+ * {@link GroovyLanguageServer}).
+ *
+ * @author Gabriel Selzer
+ */
+@Plugin(type = LanguageServerPlugin.class, name = "Groovy",
+ priority = Priority.HIGH)
+public class GroovyServerPlugin implements LanguageServerPlugin {
+
+ @Parameter(required = false)
+ private CodeCompletionService completion;
+
+ @Override
+ public boolean supports(final ScriptLanguage language) {
+ return language instanceof GroovyScriptLanguage;
+ }
+
+ @Override
+ public LanguageServer create(final Environment environment) {
+ return new GroovyLanguageServer(completion, environment);
+ }
+}
diff --git a/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java
index 65947dc..8f2e3fc 100644
--- a/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java
+++ b/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java
@@ -36,39 +36,53 @@
import java.util.ArrayList;
import java.util.List;
+import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import javax.script.ScriptEngine;
+import org.eclipse.lsp4j.CompletionItem;
+import org.eclipse.lsp4j.CompletionList;
+import org.eclipse.lsp4j.CompletionParams;
+import org.eclipse.lsp4j.DidCloseTextDocumentParams;
+import org.eclipse.lsp4j.DidOpenTextDocumentParams;
+import org.eclipse.lsp4j.SignatureHelp;
+import org.eclipse.lsp4j.SignatureHelpParams;
+import org.eclipse.lsp4j.TextDocumentIdentifier;
+import org.eclipse.lsp4j.TextDocumentItem;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.scijava.Context;
import org.scijava.code.api.ClassIndex;
import org.scijava.code.api.CodeCompletionService;
-import org.scijava.code.api.Completion;
-import org.scijava.code.api.CompletionRequest;
-import org.scijava.code.api.CompletionResult;
-import org.scijava.code.api.SignatureHelp;
+import org.scijava.code.lsp.LanguageServerService;
+import org.scijava.code.lsp.LspClient;
+import org.scijava.code.lsp.MergedLanguageServer;
+import org.scijava.code.lsp.RatedSignatureInformation;
import org.scijava.script.ScriptLanguage;
import org.scijava.script.ScriptService;
/**
- * Tests {@link GroovyCodeCompleter}.
+ * Tests {@link GroovyLanguageServer}, through the
+ * {@link LanguageServerService}, as editors use it.
*
* @author Gabriel Selzer
*/
public class GroovyCompletionTest {
+ private static final String URI = "untitled:/test.groovy";
+
private Context context;
private ScriptLanguage groovy;
- private CodeCompletionService service;
+ private LanguageServerService servers;
@Before
public void setUp() {
- context = new Context(ScriptService.class, CodeCompletionService.class);
+ context = new Context(ScriptService.class, CodeCompletionService.class,
+ LanguageServerService.class);
groovy = context.service(ScriptService.class).getLanguageByName("Groovy");
- service = context.service(CodeCompletionService.class);
+ servers = context.service(LanguageServerService.class);
}
@After
@@ -77,9 +91,11 @@ public void tearDown() {
}
@Test
- public void testCompleterIsDiscovered() {
- assertTrue(service.getCompleterPlugin(
- groovy) instanceof GroovyCodeCompleter);
+ public void testServerIsDiscovered() {
+ assertTrue(servers.supports(groovy));
+ // NB: Groovy's code-api completer is gone.
+ assertNull(context.service(CodeCompletionService.class)
+ .getCompleterPlugin(groovy));
}
@Test
@@ -99,6 +115,24 @@ public void testMembersOfInferredTypes() {
"containsAll"));
}
+ @Test
+ public void testCallablesAreSnippets() {
+ final CompletionItem upper = items("def s = 'x'\ns.toUpp").stream().filter(
+ c -> c.getLabelDetails() != null && "()".equals(c.getLabelDetails()
+ .getDetail())).findFirst().orElse(null);
+ assertTrue(upper != null);
+ assertEquals("toUpperCase()", upper.getTextEdit().getLeft().getNewText());
+ final CompletionItem locale = items("def s = 'x'\ns.toUpp").stream().filter(
+ c -> "(java.util.Locale arg0)".equals(c.getLabelDetails().getDetail()) ||
+ c.getLabelDetails().getDetail().startsWith("(java.util.Locale "))
+ .findFirst().orElse(null);
+ assertTrue(locale != null);
+ assertTrue(locale.getTextEdit().getLeft().getNewText().startsWith(
+ "toUpperCase(${1:"));
+ assertEquals("java.lang.String", locale.getLabelDetails()
+ .getDescription());
+ }
+
@Test
public void testScriptParameters() {
final String header = "#@ String name\n#@ int count\n";
@@ -125,9 +159,9 @@ public void testGroovyMethods() {
assertTrue(complete("[1, 2, 3].coll").contains("collect"));
assertTrue(complete("def s = 'abc'\ns.rev").contains("reverse"));
assertTrue(complete("new File('x').eachL").contains("eachLine"));
- final Completion each = completions("[1, 2, 3].eac").stream().filter(
- c -> c.insertionText().equals("each")).findFirst().orElse(null);
- assertTrue(each != null && each.summary().startsWith("GDK"));
+ final CompletionItem each = items("[1, 2, 3].eac").stream().filter(
+ c -> c.getLabel().equals("each")).findFirst().orElse(null);
+ assertTrue(each != null && each.getDetail().startsWith("GDK"));
}
@Test
@@ -181,23 +215,23 @@ public void testKeywords() {
@Test
public void testClassNamesWithAutoImport() {
ClassIndex.ensureCache();
- final Completion atomic = completions("def a = new AtomicInt").stream()
- .filter(c -> c.insertionText().equals("AtomicInteger")).findFirst()
- .orElse(null);
+ final CompletionItem atomic = items("def a = new AtomicInt").stream()
+ .filter(c -> c.getLabel().equals("AtomicInteger")).findFirst().orElse(
+ null);
assertTrue(atomic != null);
- assertEquals(1, atomic.additionalEdits().size());
+ assertEquals(1, atomic.getAdditionalTextEdits().size());
assertEquals("import java.util.concurrent.atomic.AtomicInteger\n", atomic
- .additionalEdits().get(0).newText());
+ .getAdditionalTextEdits().get(0).getNewText());
// Already imported (or imported by default): no edit.
- final Completion list = completions("def a = new ArrayLis").stream()
- .filter(c -> c.insertionText().equals("ArrayList")).findFirst().orElse(
- null);
- assertTrue(list != null && list.additionalEdits().isEmpty());
- assertNull(GroovyCodeCompleter.importEdit(
- "import java.util.concurrent.atomic.*\n", "java.util.concurrent.atomic.AtomicInteger"));
+ final CompletionItem list = items("def a = new ArrayLis").stream().filter(
+ c -> c.getLabel().equals("ArrayList")).findFirst().orElse(null);
+ assertTrue(list != null && list.getAdditionalTextEdits() == null);
+ assertEquals(-1, GroovyLanguageServer.importOffset(
+ "import java.util.concurrent.atomic.*\n",
+ "java.util.concurrent.atomic.AtomicInteger"));
// Inserted after the last import.
- assertEquals(20, GroovyCodeCompleter.importEdit("import java.io.File\n" +
- "x = 1\n", "java.util.concurrent.atomic.AtomicInteger").start());
+ assertEquals(20, GroovyLanguageServer.importOffset("import java.io.File\n" +
+ "x = 1\n", "java.util.concurrent.atomic.AtomicInteger"));
}
@Test
@@ -220,10 +254,8 @@ public void testStringsAndComments() {
public void testInterpreterBindings() throws Exception {
final ScriptEngine engine = groovy.getScriptEngine();
engine.put("injected", new ArrayList());
- final String code = "injected.si";
- final List texts = service.complete(new CompletionRequest(code,
- code.length(), groovy, engine, engine.getContext())).completions()
- .stream().map(Completion::insertionText).collect(Collectors.toList());
+ final List texts = labels(items(servers.server(groovy, engine
+ .getContext()), "injected.si"));
assertTrue(texts.toString(), texts.contains("size"));
}
@@ -232,7 +264,7 @@ public void testSignatureHelp() {
// A decimal (a BigDecimal) fits the floating-point overloads, which come
// first; the integral ones do not. The caret is in the second argument.
final SignatureHelp max = help("Math.max(1.5, ");
- assertEquals(1, max.activeParameter());
+ assertEquals(Integer.valueOf(1), max.getActiveParameter());
assertEquals("[double:MATCH, float:MATCH, int:MISMATCH, long:MISMATCH]",
describe(max));
// An integer fits the integral overloads, and converts to the others.
@@ -252,41 +284,92 @@ public void testSignatureHelp() {
"area(3, ");
assertEquals("[int:MATCH]", describe(area));
// The innermost call, and none outside calls.
- assertEquals(0, help("Math.max(Math.abs(").activeParameter());
- assertTrue(help("def x = 1").isEmpty());
+ assertEquals(Integer.valueOf(0), help("Math.max(Math.abs(")
+ .getActiveParameter());
+ assertNull(help("def x = 1"));
}
+ @Test
+ public void testArgumentChoices() {
+ // In a call's arguments: the variables fitting the parameter first.
+ final List items = items(
+ "def a = new ArrayList()\ndef s = 'x'\nCollections.sort(");
+ assertEquals("a", items.get(0).getLabel());
+ assertEquals(Boolean.TRUE, items.get(0).getPreselect());
+ assertTrue(labels(items).contains("s"));
+ }
+
+ // -- Helper methods --
+
private SignatureHelp help(final String code) {
- return service.getCompleter(groovy).signatureHelp(new CompletionRequest(
- code, groovy, null));
+ final MergedLanguageServer server = servers.server(groovy);
+ open(server, code);
+ try {
+ return server.signatureHelp(new SignatureHelpParams(
+ new TextDocumentIdentifier(URI), LspClient.position(code, code
+ .length()))).get(30, TimeUnit.SECONDS);
+ }
+ catch (final Exception exc) {
+ throw new RuntimeException(exc);
+ }
+ finally {
+ close(server);
+ }
}
/** Each signature's first parameter type and fit, in order. */
private static String describe(final SignatureHelp help) {
- return help.signatures().stream().map(s -> (s.callable().parameters()
- .isEmpty() ? "()" : s.callable().parameters().get(0).type()) + ":" + s
- .fit()).collect(Collectors.toList()).toString();
+ return help.getSignatures().stream().map(s -> {
+ final String first = s.getParameters().isEmpty() ? "()" : s
+ .getParameters().get(0).getLabel().getLeft();
+ final String type = first.contains(" ") ? first.substring(0, first
+ .lastIndexOf(' ')) : first;
+ return type + ":" + RatedSignatureInformation.fitOf(s);
+ }).collect(Collectors.toList()).toString();
}
- @Test
- public void testParameterChoices() {
- final CompletionResult result = service.complete(new CompletionRequest(
- "def a = new ArrayList()\ndef s = 'x'\nCollections.so", groovy, null));
- final List choices = result.parameterChoices().choicesFor(
- new Completion.Parameter("list", "java.util.List")).stream().map(
- Completion::insertionText).collect(Collectors.toList());
- assertEquals("[a]", choices.toString());
+ private List items(final String code) {
+ return items(servers.server(groovy), code);
}
- // -- Helper methods --
-
- private List completions(final String code) {
- return service.complete(new CompletionRequest(code, groovy, null))
- .completions();
+ private static List items(final MergedLanguageServer server,
+ final String code)
+ {
+ open(server, code);
+ try {
+ final CompletionList list = server.completion(new CompletionParams(
+ new TextDocumentIdentifier(URI), LspClient.position(code, code
+ .length()))).get(30, TimeUnit.SECONDS).getRight();
+ final List items = new ArrayList<>(list.getItems());
+ items.sort((a, b) -> a.getSortText().compareTo(b.getSortText()));
+ return items;
+ }
+ catch (final Exception exc) {
+ throw new RuntimeException(exc);
+ }
+ finally {
+ close(server);
+ }
}
private List complete(final String code) {
- return completions(code).stream().map(Completion::insertionText).collect(
- Collectors.toList());
+ return labels(items(code));
+ }
+
+ private static List labels(final List items) {
+ return items.stream().map(CompletionItem::getLabel).collect(Collectors
+ .toList());
+ }
+
+ private static void open(final MergedLanguageServer server,
+ final String code)
+ {
+ server.didOpen(new DidOpenTextDocumentParams(new TextDocumentItem(URI,
+ "groovy", 1, code)));
+ }
+
+ private static void close(final MergedLanguageServer server) {
+ server.didClose(new DidCloseTextDocumentParams(new TextDocumentIdentifier(
+ URI)));
}
}
From 8b757bad11f176bfcc046ee615673efc21c0eb4f Mon Sep 17 00:00:00 2001
From: Gabriel Selzer
Date: Wed, 7 Oct 2026 13:22:09 -0500
Subject: [PATCH 04/21] Move off scijava-code-api, now removed
Its remaining types (ScriptDocument, ScriptDialect, ClassIndex,
Signatures, Environment) come from scijava-code-lsp, and script
parameters from the LanguageServerService.
Co-Authored-By: Claude Opus 5.5
---
pom.xml | 11 +++--------
.../scripting/groovy/GroovyAnalysis.java | 4 ++--
.../scripting/groovy/GroovyDialect.java | 2 +-
.../scripting/groovy/GroovyLanguageServer.java | 18 +++++++++---------
.../scripting/groovy/GroovyServerPlugin.java | 6 +++---
.../scripting/groovy/GroovyCompletionTest.java | 15 +++++----------
6 files changed, 23 insertions(+), 33 deletions(-)
diff --git a/pom.xml b/pom.xml
index 0ed7a36..b218332 100644
--- a/pom.xml
+++ b/pom.xml
@@ -96,9 +96,9 @@
1.2.1
-
+
2.101.0
- 0.1.0-SNAPSHOT
+ 0.1.0-SNAPSHOT
2.14.0
@@ -109,15 +109,10 @@
org.scijava
scijava-common
-
- org.scijava
- scijava-code-api
- ${scijava-code-api.version}
-
org.scijava
scijava-code-lsp
- ${scijava-code-api.version}
+ ${scijava-code-lsp.version}
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java
index 7e1a5a6..e8ad7d0 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java
@@ -68,7 +68,7 @@
import org.codehaus.groovy.control.SourceUnit;
import org.codehaus.groovy.transform.stc.StaticTypeCheckingVisitor;
import org.codehaus.groovy.transform.stc.StaticTypesMarker;
-import org.scijava.code.api.ScriptDocument;
+import org.scijava.code.lsp.ScriptDocument;
import org.scijava.util.Types;
/**
@@ -443,7 +443,7 @@ private void consider(final Expression call, final Expression args) {
/**
* Gets the type of an expression, e.g. an argument, as a type name for
- * {@link org.scijava.code.api.Signatures}; or null if unknown.
+ * {@link Signatures}; or null if unknown.
*/
String typeName(final Expression e) {
if (e instanceof GStringExpression) return String.class.getName();
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyDialect.java b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyDialect.java
index 77064a8..ab5d286 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyDialect.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyDialect.java
@@ -29,7 +29,7 @@
package org.scijava.plugins.scripting.groovy;
-import org.scijava.code.api.ScriptDialect;
+import org.scijava.code.lsp.ScriptDialect;
/**
* How Groovy scripts see their predefined variables (script parameters,
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java
index 6fd8963..44bfeac 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java
@@ -66,9 +66,9 @@
import org.eclipse.lsp4j.CompletionList;
import org.eclipse.lsp4j.SignatureHelp;
import org.eclipse.lsp4j.TextEdit;
-import org.scijava.code.api.ClassIndex;
-import org.scijava.code.api.CodeCompletionService;
-import org.scijava.code.api.ScriptDocument;
+import org.scijava.code.lsp.ClassIndex;
+import org.scijava.code.lsp.LanguageServerService;
+import org.scijava.code.lsp.ScriptDocument;
import org.scijava.code.lsp.Callable;
import org.scijava.code.lsp.Environment;
import org.scijava.code.lsp.Members;
@@ -128,17 +128,17 @@ public class GroovyLanguageServer extends ScriptLanguageServer {
new Thread(ClassIndex::ensureCache, "Groovy-ClassIndex-warmup").start();
}
- private final CodeCompletionService completion;
+ private final LanguageServerService servers;
/**
- * @param completion Reads the scripts' parameters.
+ * @param servers Reads the scripts' parameters.
* @param environment Where the scripts run (e.g. a live interpreter).
*/
- public GroovyLanguageServer(final CodeCompletionService completion,
+ public GroovyLanguageServer(final LanguageServerService servers,
final Environment environment)
{
super(environment);
- this.completion = completion;
+ this.servers = servers;
}
@Override
@@ -618,8 +618,8 @@ private static CompletionItem field(final String name, final String type) {
*/
private Map> predefinedVariables(final String text) {
final Map> vars = new LinkedHashMap<>();
- if (completion != null) {
- completion.scriptParameters(text).forEach((name, type) -> {
+ if (servers != null) {
+ servers.scriptParameters(text).forEach((name, type) -> {
if (isIdentifier(name)) vars.put(name, type);
});
}
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyServerPlugin.java b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyServerPlugin.java
index 83333be..7375429 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyServerPlugin.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyServerPlugin.java
@@ -31,7 +31,7 @@
import org.eclipse.lsp4j.services.LanguageServer;
import org.scijava.Priority;
-import org.scijava.code.api.CodeCompletionService;
+import org.scijava.code.lsp.LanguageServerService;
import org.scijava.code.lsp.Environment;
import org.scijava.code.lsp.LanguageServerPlugin;
import org.scijava.plugin.Parameter;
@@ -49,7 +49,7 @@
public class GroovyServerPlugin implements LanguageServerPlugin {
@Parameter(required = false)
- private CodeCompletionService completion;
+ private LanguageServerService servers;
@Override
public boolean supports(final ScriptLanguage language) {
@@ -58,6 +58,6 @@ public boolean supports(final ScriptLanguage language) {
@Override
public LanguageServer create(final Environment environment) {
- return new GroovyLanguageServer(completion, environment);
+ return new GroovyLanguageServer(servers, environment);
}
}
diff --git a/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java
index 8f2e3fc..c19c58d 100644
--- a/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java
+++ b/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java
@@ -54,10 +54,9 @@
import org.junit.Before;
import org.junit.Test;
import org.scijava.Context;
-import org.scijava.code.api.ClassIndex;
-import org.scijava.code.api.CodeCompletionService;
+import org.scijava.code.lsp.ClassIndex;
import org.scijava.code.lsp.LanguageServerService;
-import org.scijava.code.lsp.LspClient;
+import org.scijava.code.lsp.Positions;
import org.scijava.code.lsp.MergedLanguageServer;
import org.scijava.code.lsp.RatedSignatureInformation;
import org.scijava.script.ScriptLanguage;
@@ -79,8 +78,7 @@ public class GroovyCompletionTest {
@Before
public void setUp() {
- context = new Context(ScriptService.class, CodeCompletionService.class,
- LanguageServerService.class);
+ context = new Context(ScriptService.class, LanguageServerService.class);
groovy = context.service(ScriptService.class).getLanguageByName("Groovy");
servers = context.service(LanguageServerService.class);
}
@@ -93,9 +91,6 @@ public void tearDown() {
@Test
public void testServerIsDiscovered() {
assertTrue(servers.supports(groovy));
- // NB: Groovy's code-api completer is gone.
- assertNull(context.service(CodeCompletionService.class)
- .getCompleterPlugin(groovy));
}
@Test
@@ -306,7 +301,7 @@ private SignatureHelp help(final String code) {
open(server, code);
try {
return server.signatureHelp(new SignatureHelpParams(
- new TextDocumentIdentifier(URI), LspClient.position(code, code
+ new TextDocumentIdentifier(URI), Positions.position(code, code
.length()))).get(30, TimeUnit.SECONDS);
}
catch (final Exception exc) {
@@ -338,7 +333,7 @@ private static List items(final MergedLanguageServer server,
open(server, code);
try {
final CompletionList list = server.completion(new CompletionParams(
- new TextDocumentIdentifier(URI), LspClient.position(code, code
+ new TextDocumentIdentifier(URI), Positions.position(code, code
.length()))).get(30, TimeUnit.SECONDS).getRight();
final List items = new ArrayList<>(list.getItems());
items.sort((a, b) -> a.getSortText().compareTo(b.getSortText()));
From 2d98f0b58106392c864a7d8cd5e4582e5b991b13 Mon Sep 17 00:00:00 2001
From: Gabriel Selzer
Date: Wed, 7 Oct 2026 13:41:20 -0500
Subject: [PATCH 05/21] Type script bindings as last assigned
Variables assigned without a declaration (e.g. z = "abc") are script
bindings, which Groovy's type checker leaves untyped. A type-checking
extension now types each use of one as last assigned, in source order,
so that members complete (z.toU), and types flow on through calls
(y = z.subList(0, 1)). Bindings are offered with their types, too.
Co-Authored-By: Claude Opus 5.5
---
.../scripting/groovy/GroovyAnalysis.java | 66 ++++++++++++++++++-
.../groovy/GroovyCompletionTest.java | 18 +++++
2 files changed, 83 insertions(+), 1 deletion(-)
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java
index e8ad7d0..f501666 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java
@@ -36,6 +36,7 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
+import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -43,10 +44,12 @@
import org.codehaus.groovy.ast.ASTNode;
import org.codehaus.groovy.ast.ClassCodeVisitorSupport;
import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.DynamicVariable;
import org.codehaus.groovy.ast.MethodNode;
import org.codehaus.groovy.ast.ModuleNode;
import org.codehaus.groovy.ast.Parameter;
import org.codehaus.groovy.ast.Variable;
+import org.codehaus.groovy.ast.expr.BinaryExpression;
import org.codehaus.groovy.ast.expr.ClassExpression;
import org.codehaus.groovy.ast.expr.ClosureExpression;
import org.codehaus.groovy.ast.expr.ConstantExpression;
@@ -68,6 +71,7 @@
import org.codehaus.groovy.control.SourceUnit;
import org.codehaus.groovy.transform.stc.StaticTypeCheckingVisitor;
import org.codehaus.groovy.transform.stc.StaticTypesMarker;
+import org.codehaus.groovy.transform.stc.TypeCheckingExtension;
import org.scijava.code.lsp.ScriptDocument;
import org.scijava.util.Types;
@@ -230,7 +234,7 @@ private static GroovyAnalysis analyze(final String code,
// would run, and dynamic code fails it, so run it ourselves.
for (final ClassNode c : module.getClasses()) {
try {
- new StaticTypeCheckingVisitor(source, c).visitClass(c);
+ typeCheck(source, c);
}
catch (final RuntimeException | LinkageError | AssertionError exc) {
// NB: Keep whatever types were inferred before the failure.
@@ -249,6 +253,54 @@ private static GroovyAnalysis analyze(final String code,
}
}
+ /**
+ * Runs the static type checker over a class of the script, typing the
+ * script's bindings (variables assigned without a declaration, e.g.
+ * {@code z = "abc"}), which it would leave untyped: each use of one has the
+ * type last assigned to it, in source order (the source ends at the caret).
+ */
+ private static void typeCheck(final SourceUnit source, final ClassNode c) {
+ final Map bindings = new HashMap<>();
+ final StaticTypeCheckingVisitor checker = new StaticTypeCheckingVisitor(
+ source, c)
+ {
+
+ @Override
+ public void visitBinaryExpression(final BinaryExpression e) {
+ super.visitBinaryExpression(e);
+ if (!isBindingAssignment(e)) return;
+ final String name = ((VariableExpression) e.getLeftExpression())
+ .getName();
+ final ClassNode type = getType(e.getRightExpression());
+ if (type == null || "java.lang.Object".equals(type.getName())) {
+ bindings.remove(name);
+ }
+ else bindings.put(name, type);
+ }
+ };
+ checker.addTypeCheckingExtension(new TypeCheckingExtension(checker) {
+
+ @Override
+ public boolean handleUnresolvedVariableExpression(
+ final VariableExpression v)
+ {
+ final ClassNode type = bindings.get(v.getName());
+ if (type == null) return false;
+ storeType(v, type);
+ return true;
+ }
+ });
+ checker.visitClass(c);
+ }
+
+ /** True iff the expression assigns a script binding, e.g. {@code z = 1}. */
+ private static boolean isBindingAssignment(final BinaryExpression e) {
+ return e.getOperation().getType() == org.codehaus.groovy.syntax.Types.ASSIGN &&
+ e.getLeftExpression() instanceof VariableExpression &&
+ ((VariableExpression) e.getLeftExpression())
+ .getAccessedVariable() instanceof DynamicVariable;
+ }
+
/** True iff the source parsed (syntax errors prevent any analysis). */
boolean isParsed() {
return module != null;
@@ -362,6 +414,18 @@ public void visitDeclarationExpression(final DeclarationExpression e) {
super.visitDeclarationExpression(e);
}
+ @Override
+ public void visitBinaryExpression(final BinaryExpression e) {
+ // NB: A binding assigned, e.g. z = "abc".
+ if (isBindingAssignment(e)) {
+ final String name = ((VariableExpression) e.getLeftExpression())
+ .getName();
+ vars.remove(name);
+ vars.put(name, typeOf(e.getRightExpression()));
+ }
+ super.visitBinaryExpression(e);
+ }
+
private void addDeclared(final VariableExpression v) {
if (CARET.equals(v.getName())) return;
vars.remove(v.getName());
diff --git a/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java
index c19c58d..0ffd7b9 100644
--- a/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java
+++ b/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java
@@ -110,6 +110,24 @@ public void testMembersOfInferredTypes() {
"containsAll"));
}
+ @Test
+ public void testMembersOfBindings() {
+ // Variables assigned without a declaration are typed as assigned...
+ assertTrue(complete("z = \"abc\"\nz.toU").contains("toUpperCase"));
+ assertTrue(complete("z = new ArrayList()\ny = z.subList(0, 1)\ny.isEm")
+ .contains("isEmpty"));
+ // ...as last assigned.
+ final List reassigned = complete(
+ "z = 'abc'\nz = new File('a')\nz.getN");
+ assertTrue(reassigned.contains("getName"));
+ // ...and offered with their type.
+ final List items = items("zed = 'abc'\nx = ze");
+ final CompletionItem zed = items.stream().filter(c -> "zed".equals(c
+ .getLabel())).findFirst().orElse(null);
+ assertTrue(String.valueOf(labels(items)), zed != null);
+ assertEquals("java.lang.String zed", zed.getDetail());
+ }
+
@Test
public void testCallablesAreSnippets() {
final CompletionItem upper = items("def s = 'x'\ns.toUpp").stream().filter(
From 62b2a6abe013d319554a034c8a1c9b89751ffe7b Mon Sep 17 00:00:00 2001
From: Gabriel Selzer
Date: Wed, 7 Oct 2026 13:45:40 -0500
Subject: [PATCH 06/21] Report problems in Groovy scripts
The Groovy language server now publishes the problems Groovy's compiler
and type checker find that also trouble a script when it runs: syntax
errors and unresolvable classes, as errors; and methods or properties a
known type lacks (e.g. "abc".fooBar()), as warnings. Complaints about
dynamically typed code, which Groovy resolves at run time, are left
out. Positions are those of the script as written.
Co-Authored-By: Claude Opus 5.5
---
.../scripting/groovy/GroovyAnalysis.java | 87 +++++++++++++++++++
.../groovy/GroovyLanguageServer.java | 48 +++++++++-
.../groovy/GroovyCompletionTest.java | 43 +++++++++
3 files changed, 175 insertions(+), 3 deletions(-)
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java
index f501666..5da2285 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java
@@ -69,6 +69,9 @@
import org.codehaus.groovy.control.CompilerConfiguration;
import org.codehaus.groovy.control.Phases;
import org.codehaus.groovy.control.SourceUnit;
+import org.codehaus.groovy.control.messages.Message;
+import org.codehaus.groovy.control.messages.SyntaxErrorMessage;
+import org.codehaus.groovy.syntax.SyntaxException;
import org.codehaus.groovy.transform.stc.StaticTypeCheckingVisitor;
import org.codehaus.groovy.transform.stc.StaticTypesMarker;
import org.codehaus.groovy.transform.stc.TypeCheckingExtension;
@@ -301,6 +304,90 @@ private static boolean isBindingAssignment(final BinaryExpression e) {
.getAccessedVariable() instanceof DynamicVariable;
}
+ /** A problem the compiler found in the analyzed source. */
+ static final class Problem {
+
+ /** Where it starts and ends: 1-based lines and columns. */
+ final int line, column, endLine, endColumn;
+
+ final String message;
+
+ /** Whether it may yet work at run time (else it fails for sure). */
+ final boolean warning;
+
+ private Problem(final SyntaxException e, final String message,
+ final boolean warning)
+ {
+ line = e.getStartLine();
+ column = e.getStartColumn();
+ endLine = e.getEndLine();
+ endColumn = e.getEndColumn();
+ this.message = message;
+ this.warning = warning;
+ }
+ }
+
+ /** The type checker's messages, e.g. about a call it cannot resolve. */
+ private static final String TYPE_CHECKING = "[Static type checking] - ";
+
+ /**
+ * Gets the problems the compiler found that would also trouble the script
+ * when it runs: syntax errors and unresolvable classes; and, as warnings,
+ * methods and properties the type checker cannot find on a known type
+ * (e.g. {@code "abc".fooBar()}). The type checker's other complaints, e.g.
+ * about dynamically typed code, are left out: Groovy resolves those when
+ * the script runs.
+ */
+ List problems() {
+ final List problems = new ArrayList<>();
+ final List extends Message> errors = source.getErrorCollector()
+ .getErrors();
+ if (errors == null) return problems;
+ for (final Message m : errors) {
+ if (!(m instanceof SyntaxErrorMessage)) continue;
+ final SyntaxException e = ((SyntaxErrorMessage) m).getCause();
+ final String message = e.getOriginalMessage() == null ? null : e
+ .getOriginalMessage().trim();
+ if (message == null || message.contains(CARET)) continue;
+ if (!message.startsWith(TYPE_CHECKING)) {
+ problems.add(new Problem(e, message, false));
+ continue;
+ }
+ final String complaint = message.substring(TYPE_CHECKING.length())
+ .replace(" Please check if the declared type is correct and if the " +
+ "method exists.", "");
+ if (isKnownTypeMissing(complaint)) {
+ problems.add(new Problem(e, complaint, true));
+ }
+ }
+ return problems;
+ }
+
+ /**
+ * True iff the type checker's complaint is that a known, specific type
+ * lacks a method or property: rather than, e.g., that a variable's type is
+ * unknown ({@code Object}), or that the script calls methods it does not
+ * declare (which bindings may provide when it runs).
+ */
+ private static boolean isKnownTypeMissing(final String complaint) {
+ final String type;
+ if (complaint.startsWith("Cannot find matching method ")) {
+ final String call = complaint.substring(
+ "Cannot find matching method ".length());
+ final int hash = call.indexOf('#');
+ if (hash < 0) return false;
+ type = call.substring(0, hash);
+ }
+ else if (complaint.startsWith("No such property: ")) {
+ final int at = complaint.indexOf(" for class: ");
+ if (at < 0) return false;
+ type = complaint.substring(at + " for class: ".length()).trim();
+ }
+ else return false;
+ return type.contains(".") && !type.equals("java.lang.Object") && !type
+ .startsWith("groovy.lang.");
+ }
+
/** True iff the source parsed (syntax errors prevent any analysis). */
boolean isParsed() {
return module != null;
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java
index 44bfeac..c0c9bf3 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java
@@ -64,14 +64,18 @@
import org.eclipse.lsp4j.CompletionItemKind;
import org.eclipse.lsp4j.CompletionItemLabelDetails;
import org.eclipse.lsp4j.CompletionList;
+import org.eclipse.lsp4j.Diagnostic;
+import org.eclipse.lsp4j.DiagnosticSeverity;
+import org.eclipse.lsp4j.Position;
import org.eclipse.lsp4j.SignatureHelp;
import org.eclipse.lsp4j.TextEdit;
-import org.scijava.code.lsp.ClassIndex;
-import org.scijava.code.lsp.LanguageServerService;
-import org.scijava.code.lsp.ScriptDocument;
import org.scijava.code.lsp.Callable;
+import org.scijava.code.lsp.ClassIndex;
import org.scijava.code.lsp.Environment;
+import org.scijava.code.lsp.LanguageServerService;
import org.scijava.code.lsp.Members;
+import org.scijava.code.lsp.Positions;
+import org.scijava.code.lsp.ScriptDocument;
import org.scijava.code.lsp.ScriptLanguageServer;
import org.scijava.code.lsp.Signatures;
import org.scijava.util.Types;
@@ -213,6 +217,36 @@ protected SignatureHelp signatureHelp(final Document doc, final int caret) {
p) -> Signatures.fit(a, p, loader)), 0, call.call.active);
}
+ /**
+ * Finds the problems Groovy's compiler and type checker would report
+ * that also trouble the script when it runs (see
+ * {@link GroovyAnalysis#problems()}), in the script as written.
+ */
+ @Override
+ protected List diagnose(final Document doc) {
+ final String text = doc.text();
+ final ScriptDocument sd = ScriptDocument.of(text, predefinedVariables(
+ text), DIALECT, null);
+ final GroovyAnalysis analysis = GroovyAnalysis.of(sd.text(), loader());
+ final List found = new ArrayList<>();
+ for (final GroovyAnalysis.Problem p : analysis.problems()) {
+ final int start = sd.map().toOriginal(offset(sd.text(), p.line,
+ p.column));
+ // NB: Not in the declarations prepended.
+ if (start < 0) continue;
+ int end = sd.map().toOriginal(offset(sd.text(), p.endLine,
+ p.endColumn));
+ // NB: At least one character, on the line where it starts.
+ final int lineEnd = text.indexOf('\n', start);
+ if (end <= start) end = Math.min(start + 1, text.length());
+ if (lineEnd >= 0 && end > lineEnd) end = Math.max(lineEnd, start);
+ found.add(diagnostic(doc, start, end, p.warning
+ ? DiagnosticSeverity.Warning : DiagnosticSeverity.Error, p.message,
+ "groovy"));
+ }
+ return found;
+ }
+
// -- Helper methods --
/** A call around the caret, as analyzed. */
@@ -644,6 +678,14 @@ private static ClassLoader loader() {
.getClassLoader();
}
+ /** The offset of a 1-based line and column, as Groovy reports them. */
+ private static int offset(final String text, final int line,
+ final int column)
+ {
+ return Positions.offset(text, new Position(Math.max(line - 1, 0), Math
+ .max(column - 1, 0)));
+ }
+
private static boolean isIdentifier(final String name) {
if (name == null || name.isEmpty() || !Character.isJavaIdentifierStart(
name.charAt(0))) return false;
diff --git a/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java
index 0ffd7b9..730d21d 100644
--- a/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java
+++ b/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java
@@ -35,6 +35,7 @@
import static org.junit.Assert.assertTrue;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@@ -128,6 +129,39 @@ public void testMembersOfBindings() {
assertEquals("java.lang.String zed", zed.getDetail());
}
+ @Test
+ public void testDiagnostics() {
+ // Syntax errors, and unresolvable classes: errors, where they are in the
+ // script (after its parameter lines, too).
+ assertEquals(Collections.singletonList("Error@1:11-12 Unexpected input: '+'"),
+ problems("#@ double sigma\ndef x = (1 +\n"));
+ assertEquals(Collections.singletonList(
+ "Error@0:0-14 unable to resolve class foo.Bar"), problems(
+ "import foo.Bar\nx = 1\n"));
+ assertEquals(Collections.singletonList(
+ "Error@0:0-9 unable to resolve class Foo"), problems("new Foo()\n"));
+ // Members a known type lacks: warnings, also through calls and bindings.
+ assertEquals(Collections.singletonList(
+ "Warning@1:0-24 Cannot find matching method java.lang.String#fooBar()."),
+ problems("def s = 'abc'\ns.toUpperCase().fooBar()\n"));
+ assertEquals(Collections.singletonList(
+ "Warning@1:0-10 Cannot find matching method java.lang.String#fooBar()."),
+ problems("z = 'abc'\nz.fooBar()\n"));
+ assertEquals(Collections.singletonList(
+ "Warning@1:0-5 No such property: nme for class: java.io.File"), problems(
+ "def f = new File('a')\nf.nme\n"));
+ // Dynamic code, which Groovy resolves when the script runs: nothing.
+ for (final String fine : new String[] { "x = 1\nprintln x\n",
+ "#@ String name\nprintln name.toUpperCase()\n",
+ "def m = [:]\nm.foo = 1\nprintln m.foo\n",
+ "def o = something()\no.foo()\n", "def c = { it * 2 }\nc(3)\n",
+ "def x\nx = 'a'\nx.size()\n", "[1, 2].collect { it }.sum()\n",
+ "println undefinedThing\n", "class A { def go() { bar() } }\n" })
+ {
+ assertEquals(fine, Collections.emptyList(), problems(fine));
+ }
+ }
+
@Test
public void testCallablesAreSnippets() {
final CompletionItem upper = items("def s = 'x'\ns.toUpp").stream().filter(
@@ -314,6 +348,15 @@ public void testArgumentChoices() {
// -- Helper methods --
+ /** The problems found in a script, e.g. {@code Error@0:4-6 message}. */
+ private List problems(final String code) {
+ return servers.diagnose(groovy, "untitled:/check.groovy", code, 20_000)
+ .stream().map(d -> d.getSeverity() + "@" + d.getRange().getStart()
+ .getLine() + ":" + d.getRange().getStart().getCharacter() + "-" + d
+ .getRange().getEnd().getCharacter() + " " + d.getMessage().getLeft())
+ .collect(Collectors.toList());
+ }
+
private SignatureHelp help(final String code) {
final MergedLanguageServer server = servers.server(groovy);
open(server, code);
From 910870eb1eb0456a7edc152601f572cafe6941a5 Mon Sep 17 00:00:00 2001
From: Gabriel Selzer
Date: Wed, 7 Oct 2026 15:56:36 -0500
Subject: [PATCH 07/21] Ask the language servers through script sessions in
tests
Co-Authored-By: Claude Opus 5.5
---
.../groovy/GroovyCompletionTest.java | 56 ++++---------------
1 file changed, 11 insertions(+), 45 deletions(-)
diff --git a/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java
index 730d21d..ea6b687 100644
--- a/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java
+++ b/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java
@@ -43,23 +43,15 @@
import javax.script.ScriptEngine;
import org.eclipse.lsp4j.CompletionItem;
-import org.eclipse.lsp4j.CompletionList;
-import org.eclipse.lsp4j.CompletionParams;
-import org.eclipse.lsp4j.DidCloseTextDocumentParams;
-import org.eclipse.lsp4j.DidOpenTextDocumentParams;
import org.eclipse.lsp4j.SignatureHelp;
-import org.eclipse.lsp4j.SignatureHelpParams;
-import org.eclipse.lsp4j.TextDocumentIdentifier;
-import org.eclipse.lsp4j.TextDocumentItem;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.scijava.Context;
import org.scijava.code.lsp.ClassIndex;
import org.scijava.code.lsp.LanguageServerService;
-import org.scijava.code.lsp.Positions;
-import org.scijava.code.lsp.MergedLanguageServer;
import org.scijava.code.lsp.RatedSignatureInformation;
+import org.scijava.code.lsp.ScriptSession;
import org.scijava.script.ScriptLanguage;
import org.scijava.script.ScriptService;
@@ -71,8 +63,6 @@
*/
public class GroovyCompletionTest {
- private static final String URI = "untitled:/test.groovy";
-
private Context context;
private ScriptLanguage groovy;
private LanguageServerService servers;
@@ -301,7 +291,7 @@ public void testStringsAndComments() {
public void testInterpreterBindings() throws Exception {
final ScriptEngine engine = groovy.getScriptEngine();
engine.put("injected", new ArrayList());
- final List texts = labels(items(servers.server(groovy, engine
+ final List texts = labels(items(servers.session(groovy, engine
.getContext()), "injected.si"));
assertTrue(texts.toString(), texts.contains("size"));
}
@@ -350,7 +340,7 @@ public void testArgumentChoices() {
/** The problems found in a script, e.g. {@code Error@0:4-6 message}. */
private List problems(final String code) {
- return servers.diagnose(groovy, "untitled:/check.groovy", code, 20_000)
+ return servers.diagnose(groovy, code, 20_000)
.stream().map(d -> d.getSeverity() + "@" + d.getRange().getStart()
.getLine() + ":" + d.getRange().getStart().getCharacter() + "-" + d
.getRange().getEnd().getCharacter() + " " + d.getMessage().getLeft())
@@ -358,19 +348,13 @@ private List problems(final String code) {
}
private SignatureHelp help(final String code) {
- final MergedLanguageServer server = servers.server(groovy);
- open(server, code);
- try {
- return server.signatureHelp(new SignatureHelpParams(
- new TextDocumentIdentifier(URI), Positions.position(code, code
- .length()))).get(30, TimeUnit.SECONDS);
+ try (ScriptSession session = servers.session(groovy)) {
+ return session.signatureHelp(code, code.length()).get(30,
+ TimeUnit.SECONDS);
}
catch (final Exception exc) {
throw new RuntimeException(exc);
}
- finally {
- close(server);
- }
}
/** Each signature's first parameter type and fit, in order. */
@@ -385,27 +369,21 @@ private static String describe(final SignatureHelp help) {
}
private List items(final String code) {
- return items(servers.server(groovy), code);
+ return items(servers.session(groovy), code);
}
- private static List items(final MergedLanguageServer server,
+ private static List items(final ScriptSession session,
final String code)
{
- open(server, code);
- try {
- final CompletionList list = server.completion(new CompletionParams(
- new TextDocumentIdentifier(URI), Positions.position(code, code
- .length()))).get(30, TimeUnit.SECONDS).getRight();
- final List items = new ArrayList<>(list.getItems());
+ try (ScriptSession s = session) {
+ final List items = new ArrayList<>(s.completion(
+ code, code.length()).get(30, TimeUnit.SECONDS).getItems());
items.sort((a, b) -> a.getSortText().compareTo(b.getSortText()));
return items;
}
catch (final Exception exc) {
throw new RuntimeException(exc);
}
- finally {
- close(server);
- }
}
private List complete(final String code) {
@@ -416,16 +394,4 @@ private static List labels(final List items) {
return items.stream().map(CompletionItem::getLabel).collect(Collectors
.toList());
}
-
- private static void open(final MergedLanguageServer server,
- final String code)
- {
- server.didOpen(new DidOpenTextDocumentParams(new TextDocumentItem(URI,
- "groovy", 1, code)));
- }
-
- private static void close(final MergedLanguageServer server) {
- server.didClose(new DidCloseTextDocumentParams(new TextDocumentIdentifier(
- URI)));
- }
}
From b6710f9190fd7745eaafa07a0776f4c36dd40d0d Mon Sep 17 00:00:00 2001
From: Gabriel Selzer
Date: Thu, 8 Oct 2026 13:10:41 -0500
Subject: [PATCH 08/21] Follow code-lsp's packages by purpose
Co-Authored-By: Claude Opus 5.5
---
.../plugins/scripting/groovy/GroovyAnalysis.java | 4 ++--
.../plugins/scripting/groovy/GroovyDialect.java | 2 +-
.../scripting/groovy/GroovyLanguageServer.java | 12 ++++++------
.../scripting/groovy/GroovyCompletionTest.java | 2 +-
4 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java
index 5da2285..565b6e4 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java
@@ -75,7 +75,7 @@
import org.codehaus.groovy.transform.stc.StaticTypeCheckingVisitor;
import org.codehaus.groovy.transform.stc.StaticTypesMarker;
import org.codehaus.groovy.transform.stc.TypeCheckingExtension;
-import org.scijava.code.lsp.ScriptDocument;
+import org.scijava.code.lsp.script.ScriptDocument;
import org.scijava.util.Types;
/**
@@ -594,7 +594,7 @@ private void consider(final Expression call, final Expression args) {
/**
* Gets the type of an expression, e.g. an argument, as a type name for
- * {@link Signatures}; or null if unknown.
+ * {@link org.scijava.code.lsp.jvm.Signatures}; or null if unknown.
*/
String typeName(final Expression e) {
if (e instanceof GStringExpression) return String.class.getName();
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyDialect.java b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyDialect.java
index ab5d286..31f0570 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyDialect.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyDialect.java
@@ -29,7 +29,7 @@
package org.scijava.plugins.scripting.groovy;
-import org.scijava.code.lsp.ScriptDialect;
+import org.scijava.code.lsp.script.ScriptDialect;
/**
* How Groovy scripts see their predefined variables (script parameters,
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java
index c0c9bf3..a3c61c6 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java
@@ -69,15 +69,15 @@
import org.eclipse.lsp4j.Position;
import org.eclipse.lsp4j.SignatureHelp;
import org.eclipse.lsp4j.TextEdit;
-import org.scijava.code.lsp.Callable;
-import org.scijava.code.lsp.ClassIndex;
+import org.scijava.code.lsp.jvm.Callable;
+import org.scijava.code.lsp.jvm.ClassIndex;
import org.scijava.code.lsp.Environment;
import org.scijava.code.lsp.LanguageServerService;
-import org.scijava.code.lsp.Members;
+import org.scijava.code.lsp.jvm.Members;
import org.scijava.code.lsp.Positions;
-import org.scijava.code.lsp.ScriptDocument;
-import org.scijava.code.lsp.ScriptLanguageServer;
-import org.scijava.code.lsp.Signatures;
+import org.scijava.code.lsp.script.ScriptDocument;
+import org.scijava.code.lsp.jvm.ScriptLanguageServer;
+import org.scijava.code.lsp.jvm.Signatures;
import org.scijava.util.Types;
/**
diff --git a/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java
index ea6b687..a69ce0e 100644
--- a/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java
+++ b/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java
@@ -48,7 +48,7 @@
import org.junit.Before;
import org.junit.Test;
import org.scijava.Context;
-import org.scijava.code.lsp.ClassIndex;
+import org.scijava.code.lsp.jvm.ClassIndex;
import org.scijava.code.lsp.LanguageServerService;
import org.scijava.code.lsp.RatedSignatureInformation;
import org.scijava.code.lsp.ScriptSession;
From de1354bb997dd4c58c29c1c6f837addc0935132b Mon Sep 17 00:00:00 2001
From: Gabriel Selzer
Date: Thu, 8 Oct 2026 14:27:47 -0500
Subject: [PATCH 09/21] Test signature order, not fit
code-lsp's signatures no longer carry their fit; the best fits still
come first.
Co-Authored-By: Claude Opus 5.5
---
.../groovy/GroovyCompletionTest.java | 20 +++++++++----------
1 file changed, 9 insertions(+), 11 deletions(-)
diff --git a/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java
index a69ce0e..b8a861c 100644
--- a/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java
+++ b/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java
@@ -50,7 +50,6 @@
import org.scijava.Context;
import org.scijava.code.lsp.jvm.ClassIndex;
import org.scijava.code.lsp.LanguageServerService;
-import org.scijava.code.lsp.RatedSignatureInformation;
import org.scijava.code.lsp.ScriptSession;
import org.scijava.script.ScriptLanguage;
import org.scijava.script.ScriptService;
@@ -302,24 +301,23 @@ public void testSignatureHelp() {
// first; the integral ones do not. The caret is in the second argument.
final SignatureHelp max = help("Math.max(1.5, ");
assertEquals(Integer.valueOf(1), max.getActiveParameter());
- assertEquals("[double:MATCH, float:MATCH, int:MISMATCH, long:MISMATCH]",
- describe(max));
+ assertEquals("[double, float, int, long]", describe(max));
// An integer fits the integral overloads, and converts to the others.
- assertEquals("[int:MATCH, long:MATCH, double:CONVERSION, " +
- "float:CONVERSION]", describe(help("Math.max(1, 2")));
- // Nothing typed yet: unrated.
- assertTrue(describe(help("Math.max(")).contains("UNKNOWN"));
+ assertEquals("[int, long, double, float]", describe(help(
+ "Math.max(1, 2")));
+ // Nothing typed yet: all overloads.
+ assertEquals(4, help("Math.max(").getSignatures().size());
// Arguments' types come from the analysis, e.g. of variables.
final SignatureHelp indexOf = help("def s = 'abc'\ndef c = 'b'\n" +
"s.indexOf(c, ");
- assertEquals("java.lang.String:MATCH", describe(indexOf).replaceAll(
+ assertEquals("java.lang.String", describe(indexOf).replaceAll(
"^\\[([^,]*),.*", "$1"));
// Constructors, and the script's own functions.
assertTrue(describe(help("new StringBuilder(")).contains(
"java.lang.String"));
final SignatureHelp area = help("def area(int w, int h) { w * h }\n" +
"area(3, ");
- assertEquals("[int:MATCH]", describe(area));
+ assertEquals("[int]", describe(area));
// The innermost call, and none outside calls.
assertEquals(Integer.valueOf(0), help("Math.max(Math.abs(")
.getActiveParameter());
@@ -357,14 +355,14 @@ private SignatureHelp help(final String code) {
}
}
- /** Each signature's first parameter type and fit, in order. */
+ /** Each signature's first parameter type, in order. */
private static String describe(final SignatureHelp help) {
return help.getSignatures().stream().map(s -> {
final String first = s.getParameters().isEmpty() ? "()" : s
.getParameters().get(0).getLabel().getLeft();
final String type = first.contains(" ") ? first.substring(0, first
.lastIndexOf(' ')) : first;
- return type + ":" + RatedSignatureInformation.fitOf(s);
+ return type;
}).collect(Collectors.toList()).toString();
}
From ff2938a9d0859c884d1bb92be78b4036c36dde19 Mon Sep 17 00:00:00 2001
From: Gabriel Selzer
Date: Fri, 9 Oct 2026 11:08:21 -0500
Subject: [PATCH 10/21] Map diagnostics with ScriptDocument directly
Co-Authored-By: Claude Opus 5.5
---
.../plugins/scripting/groovy/GroovyLanguageServer.java | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java
index a3c61c6..9cfd51f 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java
@@ -230,11 +230,11 @@ protected List diagnose(final Document doc) {
final GroovyAnalysis analysis = GroovyAnalysis.of(sd.text(), loader());
final List found = new ArrayList<>();
for (final GroovyAnalysis.Problem p : analysis.problems()) {
- final int start = sd.map().toOriginal(offset(sd.text(), p.line,
+ final int start = sd.toOriginal(offset(sd.text(), p.line,
p.column));
// NB: Not in the declarations prepended.
if (start < 0) continue;
- int end = sd.map().toOriginal(offset(sd.text(), p.endLine,
+ int end = sd.toOriginal(offset(sd.text(), p.endLine,
p.endColumn));
// NB: At least one character, on the line where it starts.
final int lineEnd = text.indexOf('\n', start);
From b75a1d250d01ed9475e4b8ac5d14546fe326cee1 Mon Sep 17 00:00:00 2001
From: Gabriel Selzer
Date: Fri, 9 Oct 2026 13:23:21 -0500
Subject: [PATCH 11/21] Serve plain Groovy: sessions declare the parameters
The server no longer reads #@ lines: the session declares the
parameters (in GroovyDialect), and blanks those lines. It still
declares a live interpreter's variables itself.
Co-Authored-By: Claude Opus 5.5
---
.../groovy/GroovyLanguageServer.java | 44 +++++++------------
.../scripting/groovy/GroovyServerPlugin.java | 16 ++++---
2 files changed, 25 insertions(+), 35 deletions(-)
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java
index 9cfd51f..36ea9d0 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java
@@ -72,7 +72,6 @@
import org.scijava.code.lsp.jvm.Callable;
import org.scijava.code.lsp.jvm.ClassIndex;
import org.scijava.code.lsp.Environment;
-import org.scijava.code.lsp.LanguageServerService;
import org.scijava.code.lsp.jvm.Members;
import org.scijava.code.lsp.Positions;
import org.scijava.code.lsp.script.ScriptDocument;
@@ -109,7 +108,10 @@ public class GroovyLanguageServer extends ScriptLanguageServer {
/** Upper bound on class-name suggestions, which can be very numerous. */
private static final int MAX_CLASSES = 500;
- /** How scripts see their predefined variables: as declared Java objects. */
+ /**
+ * How scripts see their predefined variables (script parameters, an
+ * interpreter's bindings): as declared Java objects.
+ */
static final GroovyDialect DIALECT = new GroovyDialect();
/** An import statement being typed: "import a.b.C". */
@@ -132,17 +134,11 @@ public class GroovyLanguageServer extends ScriptLanguageServer {
new Thread(ClassIndex::ensureCache, "Groovy-ClassIndex-warmup").start();
}
- private final LanguageServerService servers;
-
/**
- * @param servers Reads the scripts' parameters.
* @param environment Where the scripts run (e.g. a live interpreter).
*/
- public GroovyLanguageServer(final LanguageServerService servers,
- final Environment environment)
- {
+ public GroovyLanguageServer(final Environment environment) {
super(environment);
- this.servers = servers;
}
@Override
@@ -150,8 +146,6 @@ protected CompletionList complete(final Document doc, final int caret) {
final String text = doc.text();
final String line = doc.lineTo(caret);
final List none = new ArrayList<>();
- // NB: Lines starting with '#' are SciJava script parameters (or shebangs).
- if (line.trim().startsWith("#")) return list(doc, caret, caret, none);
final Matcher imp = IMPORT.matcher(line);
if (imp.find()) {
@@ -175,9 +169,9 @@ protected CompletionList complete(final Document doc, final int caret) {
return list(doc, caret, caret, none);
}
- final Map> predefined = predefinedVariables(text);
+ final Map> predefined = liveVariables();
final ScriptDocument sd = ScriptDocument.of(text.substring(0,
- replaceStart), predefined, DIALECT, null);
+ replaceStart), predefined, DIALECT);
final GroovyAnalysis analysis = analyze(sd, predefined, line.substring(0,
line.length() - prefix.length()));
if (analysis == null) return list(doc, caret, caret, none); // string, comment
@@ -197,7 +191,6 @@ protected CompletionList complete(final Document doc, final int caret) {
protected SignatureHelp signatureHelp(final Document doc, final int caret) {
final String text = doc.text();
final String line = doc.lineTo(caret);
- if (line.trim().startsWith("#")) return null;
final CallContext call = callContext(text, caret, line);
if (call == null) return null;
final List callables = callables(call.analysis, call.call);
@@ -225,8 +218,8 @@ protected SignatureHelp signatureHelp(final Document doc, final int caret) {
@Override
protected List diagnose(final Document doc) {
final String text = doc.text();
- final ScriptDocument sd = ScriptDocument.of(text, predefinedVariables(
- text), DIALECT, null);
+ final ScriptDocument sd = ScriptDocument.of(text, liveVariables(),
+ DIALECT);
final GroovyAnalysis analysis = GroovyAnalysis.of(sd.text(), loader());
final List found = new ArrayList<>();
for (final GroovyAnalysis.Problem p : analysis.problems()) {
@@ -272,9 +265,9 @@ private CallContext callContext(final String text, final int caret,
id.find();
final String prefix = id.group();
final int start = caret - prefix.length();
- final Map> predefined = predefinedVariables(text);
+ final Map> predefined = liveVariables();
final GroovyAnalysis analysis = analyze(ScriptDocument.of(text.substring(
- 0, start), predefined, DIALECT, null), predefined, line.substring(0, line
+ 0, start), predefined, DIALECT), predefined, line.substring(0, line
.length() - prefix.length()));
if (analysis == null) return null; // string or comment
final GroovyAnalysis.Call call = analysis.callAtCaret();
@@ -306,7 +299,7 @@ private List argumentChoices(final String text,
for (final String name : order) {
vars.put(name, Types.box(call.analysis.toClass(local.get(name))));
}
- predefinedVariables(text).forEach(vars::putIfAbsent);
+ liveVariables().forEach(vars::putIfAbsent);
final List fitting = new ArrayList<>();
final List others = new ArrayList<>();
for (final Map.Entry> v : vars.entrySet()) {
@@ -339,7 +332,7 @@ private GroovyAnalysis analyze(final ScriptDocument doc,
final GroovyAnalysis analysis = GroovyAnalysis.of(code, loader());
if (analysis.isParsed()) return analysis;
final String lineCode = GroovyAnalysis.prepare(ScriptDocument.of(
- lineBefore, predefined, DIALECT, null));
+ lineBefore, predefined, DIALECT));
return lineCode == null ? analysis : GroovyAnalysis.of(lineCode, loader());
}
@@ -645,18 +638,13 @@ private static CompletionItem field(final String name, final String type) {
}
/**
- * Gets the variables defined before the script runs: its parameters (e.g.
- * {@code #@ String name}) and, in a live interpreter, its bindings.
+ * Gets the variables defined before the code runs, in a live interpreter:
+ * its bindings. (A script's parameters are declared in its text.)
*
* @return Map of variable name to type.
*/
- private Map> predefinedVariables(final String text) {
+ private Map> liveVariables() {
final Map> vars = new LinkedHashMap<>();
- if (servers != null) {
- servers.scriptParameters(text).forEach((name, type) -> {
- if (isIdentifier(name)) vars.put(name, type);
- });
- }
final ScriptContext live = environment().live();
final Bindings bindings = live == null ? null : live.getBindings(
ScriptContext.ENGINE_SCOPE);
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyServerPlugin.java b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyServerPlugin.java
index 7375429..f7ffe7d 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyServerPlugin.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyServerPlugin.java
@@ -31,16 +31,16 @@
import org.eclipse.lsp4j.services.LanguageServer;
import org.scijava.Priority;
-import org.scijava.code.lsp.LanguageServerService;
import org.scijava.code.lsp.Environment;
import org.scijava.code.lsp.LanguageServerPlugin;
-import org.scijava.plugin.Parameter;
+import org.scijava.code.lsp.script.ScriptDialect;
import org.scijava.plugin.Plugin;
import org.scijava.script.ScriptLanguage;
/**
* Offers Groovy's in-process language server (see
- * {@link GroovyLanguageServer}).
+ * {@link GroovyLanguageServer}), seeing scripts as plain Groovy (see
+ * {@link GroovyDialect}).
*
* @author Gabriel Selzer
*/
@@ -48,9 +48,6 @@
priority = Priority.HIGH)
public class GroovyServerPlugin implements LanguageServerPlugin {
- @Parameter(required = false)
- private LanguageServerService servers;
-
@Override
public boolean supports(final ScriptLanguage language) {
return language instanceof GroovyScriptLanguage;
@@ -58,6 +55,11 @@ public boolean supports(final ScriptLanguage language) {
@Override
public LanguageServer create(final Environment environment) {
- return new GroovyLanguageServer(servers, environment);
+ return new GroovyLanguageServer(environment);
+ }
+
+ @Override
+ public ScriptDialect dialect() {
+ return GroovyLanguageServer.DIALECT;
}
}
From 9df4493fc652bccfd815ec1968bf69b8e9a45a59 Mon Sep 17 00:00:00 2001
From: Gabriel Selzer
Date: Fri, 9 Oct 2026 15:50:50 -0500
Subject: [PATCH 12/21] Follow ScriptDialect and ScriptDocument into code-lsp's
core package
Co-Authored-By: Claude Opus 5.5
---
.../org/scijava/plugins/scripting/groovy/GroovyAnalysis.java | 2 +-
.../org/scijava/plugins/scripting/groovy/GroovyDialect.java | 2 +-
.../scijava/plugins/scripting/groovy/GroovyLanguageServer.java | 2 +-
.../scijava/plugins/scripting/groovy/GroovyServerPlugin.java | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java
index 565b6e4..fc0a22c 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java
@@ -75,7 +75,7 @@
import org.codehaus.groovy.transform.stc.StaticTypeCheckingVisitor;
import org.codehaus.groovy.transform.stc.StaticTypesMarker;
import org.codehaus.groovy.transform.stc.TypeCheckingExtension;
-import org.scijava.code.lsp.script.ScriptDocument;
+import org.scijava.code.lsp.ScriptDocument;
import org.scijava.util.Types;
/**
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyDialect.java b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyDialect.java
index 31f0570..ab5d286 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyDialect.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyDialect.java
@@ -29,7 +29,7 @@
package org.scijava.plugins.scripting.groovy;
-import org.scijava.code.lsp.script.ScriptDialect;
+import org.scijava.code.lsp.ScriptDialect;
/**
* How Groovy scripts see their predefined variables (script parameters,
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java
index 36ea9d0..917391f 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java
@@ -74,7 +74,7 @@
import org.scijava.code.lsp.Environment;
import org.scijava.code.lsp.jvm.Members;
import org.scijava.code.lsp.Positions;
-import org.scijava.code.lsp.script.ScriptDocument;
+import org.scijava.code.lsp.ScriptDocument;
import org.scijava.code.lsp.jvm.ScriptLanguageServer;
import org.scijava.code.lsp.jvm.Signatures;
import org.scijava.util.Types;
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyServerPlugin.java b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyServerPlugin.java
index f7ffe7d..db7f73d 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyServerPlugin.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/GroovyServerPlugin.java
@@ -33,7 +33,7 @@
import org.scijava.Priority;
import org.scijava.code.lsp.Environment;
import org.scijava.code.lsp.LanguageServerPlugin;
-import org.scijava.code.lsp.script.ScriptDialect;
+import org.scijava.code.lsp.ScriptDialect;
import org.scijava.plugin.Plugin;
import org.scijava.script.ScriptLanguage;
From fa206954059c90c96df1139d2e69277bb661760e Mon Sep 17 00:00:00 2001
From: Gabriel Selzer
Date: Thu, 15 Oct 2026 17:57:39 -0500
Subject: [PATCH 13/21] Gather completion into its package, and follow code-lsp
The language server, its plugin, its dialect, and the analysis move to
the completion package, with their tests.
GroovyAnalysis becomes TypedAST (the typed syntax tree of a script up to
the caret, and questions about it); its source is built by markCaret
and closeBrackets, and the tree by build.
The plugin serves the groovy LSP language too, so that
LanguageServerService.launch("groovy", ...) gives a GroovyLanguageServer
(see GroovyServerPluginTest).
Co-Authored-By: Claude Opus 5.5
---
.../{ => completion}/GroovyDialect.java | 7 +-
.../GroovyLanguageServer.java | 121 +++++++++---------
.../{ => completion}/GroovyServerPlugin.java | 12 +-
.../TypedAST.java} | 106 ++++++++-------
.../GroovyCompletionTest.java | 23 ++--
.../completion/GroovyServerPluginTest.java | 52 ++++++++
6 files changed, 195 insertions(+), 126 deletions(-)
rename src/main/java/org/scijava/plugins/scripting/groovy/{ => completion}/GroovyDialect.java (85%)
rename src/main/java/org/scijava/plugins/scripting/groovy/{ => completion}/GroovyLanguageServer.java (86%)
rename src/main/java/org/scijava/plugins/scripting/groovy/{ => completion}/GroovyServerPlugin.java (88%)
rename src/main/java/org/scijava/plugins/scripting/groovy/{GroovyAnalysis.java => completion/TypedAST.java} (89%)
rename src/test/java/org/scijava/plugins/scripting/groovy/{ => completion}/GroovyCompletionTest.java (96%)
create mode 100644 src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyServerPluginTest.java
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyDialect.java b/src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialect.java
similarity index 85%
rename from src/main/java/org/scijava/plugins/scripting/groovy/GroovyDialect.java
rename to src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialect.java
index ab5d286..6650f9f 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyDialect.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialect.java
@@ -27,16 +27,11 @@
* #L%
*/
-package org.scijava.plugins.scripting.groovy;
+package org.scijava.plugins.scripting.groovy.completion;
import org.scijava.code.lsp.ScriptDialect;
/**
- * How Groovy scripts see their predefined variables (script parameters,
- * interpreter bindings): as the Java objects themselves, declared e.g.
- * {@code net.imglib2.img.Img image}. Lines starting with {@code #} (script
- * parameters, shebangs) are not Groovy.
- *
* @author Gabriel Selzer
*/
class GroovyDialect implements ScriptDialect {
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java b/src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyLanguageServer.java
similarity index 86%
rename from src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java
rename to src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyLanguageServer.java
index 917391f..f0b9d3b 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyLanguageServer.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyLanguageServer.java
@@ -27,7 +27,7 @@
* #L%
*/
-package org.scijava.plugins.scripting.groovy;
+package org.scijava.plugins.scripting.groovy.completion;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
@@ -75,21 +75,17 @@
import org.scijava.code.lsp.jvm.Members;
import org.scijava.code.lsp.Positions;
import org.scijava.code.lsp.ScriptDocument;
-import org.scijava.code.lsp.jvm.ScriptLanguageServer;
+import org.scijava.code.lsp.jvm.AbstractLanguageServer;
import org.scijava.code.lsp.jvm.Signatures;
import org.scijava.util.Types;
/**
* An in-process language server for Groovy, driven by Groovy's own compiler
- * (see {@link GroovyAnalysis}): members of the expression before a dot, using
- * the types the static type checker infers; variables in scope; keywords;
- * class names, with automatic imports; import statements; and, in a call's
- * arguments, the variables fitting the parameter first. Signature help rates
- * the overloads against the arguments typed.
+ * (see {@link TypedAST}).
*
* @author Gabriel Selzer
*/
-public class GroovyLanguageServer extends ScriptLanguageServer {
+public class GroovyLanguageServer extends AbstractLanguageServer {
private static final List KEYWORDS = Arrays.asList("as", "assert",
"boolean", "break", "byte", "case", "catch", "char", "class", "continue",
@@ -141,6 +137,7 @@ public GroovyLanguageServer(final Environment environment) {
super(environment);
}
+
@Override
protected CompletionList complete(final Document doc, final int caret) {
final String text = doc.text();
@@ -163,7 +160,7 @@ protected CompletionList complete(final Document doc, final int caret) {
final boolean member = before.trim().endsWith(".") && !before.trim()
.endsWith("..") && !NUMBER_DOT.matcher(before).find();
if (!member && prefix.isEmpty()) {
- return list(doc, caret, caret, argumentChoices(text, caret, line));
+ return list(doc, caret, caret, argumentChoices(doc, caret, line));
}
if (!member && Character.isDigit(prefix.charAt(0))) {
return list(doc, caret, caret, none);
@@ -172,14 +169,14 @@ protected CompletionList complete(final Document doc, final int caret) {
final Map> predefined = liveVariables();
final ScriptDocument sd = ScriptDocument.of(text.substring(0,
replaceStart), predefined, DIALECT);
- final GroovyAnalysis analysis = analyze(sd, predefined, line.substring(0,
+ final TypedAST ast = typedAST(sd, predefined, line.substring(0,
line.length() - prefix.length()));
- if (analysis == null) return list(doc, caret, caret, none); // string, comment
+ if (ast == null) return list(doc, caret, caret, none); // string, comment
final List items = new ArrayList<>();
- if (member) items.addAll(memberCompletions(analysis, before, prefix));
+ if (member) items.addAll(memberCompletions(ast, before, prefix));
else {
- items.addAll(variableCompletions(analysis.variablesAtCaret(), sd
+ items.addAll(variableCompletions(ast.variablesAtCaret(), sd
.parameters(), prefix));
items.addAll(keywordCompletions(prefix));
items.addAll(classCompletions(doc, prefix));
@@ -189,11 +186,10 @@ protected CompletionList complete(final Document doc, final int caret) {
@Override
protected SignatureHelp signatureHelp(final Document doc, final int caret) {
- final String text = doc.text();
final String line = doc.lineTo(caret);
- final CallContext call = callContext(text, caret, line);
+ final CallContext call = callContext(doc, caret, line);
if (call == null) return null;
- final List callables = callables(call.analysis, call.call);
+ final List callables = callables(call.ast, call.call);
if (callables.isEmpty()) return null;
// NB: Reflection lists methods in no particular order: sort them by
// their parameter types, so that equally good fits come out the same.
@@ -202,7 +198,7 @@ protected SignatureHelp signatureHelp(final Document doc, final int caret) {
// The types of the arguments before the caret's (which is being typed).
final List argTypes = new ArrayList<>();
for (int i = 0; i < call.call.arguments.size(); i++) {
- argTypes.add(i == call.call.active ? null : call.analysis.typeName(
+ argTypes.add(i == call.call.active ? null : call.ast.typeName(
call.call.arguments.get(i)));
}
final ClassLoader loader = loader();
@@ -213,16 +209,16 @@ protected SignatureHelp signatureHelp(final Document doc, final int caret) {
/**
* Finds the problems Groovy's compiler and type checker would report
* that also trouble the script when it runs (see
- * {@link GroovyAnalysis#problems()}), in the script as written.
+ * {@link TypedAST#problems()}), in the script as written.
*/
@Override
protected List diagnose(final Document doc) {
final String text = doc.text();
final ScriptDocument sd = ScriptDocument.of(text, liveVariables(),
DIALECT);
- final GroovyAnalysis analysis = GroovyAnalysis.of(sd.text(), loader());
+ final TypedAST ast = TypedAST.of(sd.text(), loader());
final List found = new ArrayList<>();
- for (final GroovyAnalysis.Problem p : analysis.problems()) {
+ for (final TypedAST.Problem p : ast.problems()) {
final int start = sd.toOriginal(offset(sd.text(), p.line,
p.column));
// NB: Not in the declarations prepended.
@@ -245,59 +241,60 @@ protected List diagnose(final Document doc) {
/** A call around the caret, as analyzed. */
private static final class CallContext {
- private final GroovyAnalysis analysis;
- private final GroovyAnalysis.Call call;
+ private final TypedAST ast;
+ private final TypedAST.Call call;
- private CallContext(final GroovyAnalysis analysis,
- final GroovyAnalysis.Call call)
+ private CallContext(final TypedAST ast,
+ final TypedAST.Call call)
{
- this.analysis = analysis;
+ this.ast = ast;
this.call = call;
}
}
/** Analyzes the call around the caret, or returns null if none. */
- private CallContext callContext(final String text, final int caret,
+ private CallContext callContext(final Document doc, final int caret,
final String line)
{
+ final String text = doc.text();
// NB: The argument being typed, if a name, is replaced by the marker.
final Matcher id = IDENTIFIER.matcher(line);
id.find();
final String prefix = id.group();
final int start = caret - prefix.length();
final Map> predefined = liveVariables();
- final GroovyAnalysis analysis = analyze(ScriptDocument.of(text.substring(
+ final TypedAST ast = typedAST(ScriptDocument.of(text.substring(
0, start), predefined, DIALECT), predefined, line.substring(0, line
.length() - prefix.length()));
- if (analysis == null) return null; // string or comment
- final GroovyAnalysis.Call call = analysis.callAtCaret();
- return call == null ? null : new CallContext(analysis, call);
+ if (ast == null) return null; // string or comment
+ final TypedAST.Call call = ast.callAtCaret();
+ return call == null ? null : new CallContext(ast, call);
}
/**
* In a call's arguments, with nothing typed yet: the variables in scope,
* those fitting the parameter first (as preselected).
*/
- private List argumentChoices(final String text,
+ private List argumentChoices(final Document doc,
final int caret, final String line)
{
- if (Signatures.callStart(text, caret) < 0) return new ArrayList<>();
- final CallContext call = callContext(text, caret, line);
+ if (Signatures.callStart(doc.text(), caret) < 0) return new ArrayList<>();
+ final CallContext call = callContext(doc, caret, line);
if (call == null) return new ArrayList<>();
final Set> targets = new HashSet<>();
- for (final Callable c : callables(call.analysis, call.call)) {
+ for (final Callable c : callables(call.ast, call.call)) {
if (call.call.active >= c.params().size()) continue;
final String type = c.params().get(call.call.active).type();
- final Class> t = type == null ? null : Types.box(GroovyAnalysis.load(
+ final Class> t = type == null ? null : Types.box(TypedAST.load(
type, loader()));
if (t != null && t != Object.class) targets.add(t);
}
final Map> vars = new LinkedHashMap<>();
- final Map local = call.analysis.variablesAtCaret();
+ final Map local = call.ast.variablesAtCaret();
final List order = new ArrayList<>(local.keySet());
Collections.reverse(order);
for (final String name : order) {
- vars.put(name, Types.box(call.analysis.toClass(local.get(name))));
+ vars.put(name, Types.box(call.ast.toClass(local.get(name))));
}
liveVariables().forEach(vars::putIfAbsent);
final List fitting = new ArrayList<>();
@@ -319,28 +316,30 @@ private List argumentChoices(final String text,
}
/**
- * Analyzes the script up to the caret. If it does not parse (e.g. due to a
- * syntax error above the caret), falls back to the caret's line alone.
+ * Builds the typed syntax tree of the script up to the caret. If it does not
+ * parse (e.g. due to a syntax error above the caret), falls back to the
+ * caret's line alone.
*
- * @return The analysis, or null if the caret is in a string or comment.
+ * @return The tree, or null if the caret is in a string or comment.
*/
- private GroovyAnalysis analyze(final ScriptDocument doc,
+ private TypedAST typedAST(final ScriptDocument doc,
final Map> predefined, final String lineBefore)
{
- final String code = GroovyAnalysis.prepare(doc);
+ final String code = TypedAST.closeBrackets(TypedAST.markCaret(
+ doc));
if (code == null) return null;
- final GroovyAnalysis analysis = GroovyAnalysis.of(code, loader());
- if (analysis.isParsed()) return analysis;
- final String lineCode = GroovyAnalysis.prepare(ScriptDocument.of(
- lineBefore, predefined, DIALECT));
- return lineCode == null ? analysis : GroovyAnalysis.of(lineCode, loader());
+ final TypedAST ast = TypedAST.of(code, loader());
+ if (ast.isParsed()) return ast;
+ final String lineCode = TypedAST.closeBrackets(TypedAST
+ .markCaret(ScriptDocument.of(lineBefore, predefined, DIALECT)));
+ return lineCode == null ? ast : TypedAST.of(lineCode, loader());
}
private List memberCompletions(
- final GroovyAnalysis analysis, final String before, final String prefix)
+ final TypedAST ast, final String before, final String prefix)
{
- final Expression receiver = analysis.receiverAtCaret();
- final ClassNode type = GroovyAnalysis.typeOf(receiver);
+ final Expression receiver = ast.receiverAtCaret();
+ final ClassNode type = TypedAST.typeOf(receiver);
if (type == null) {
// Perhaps a package name, e.g. "java.util."
final Matcher q = QUALIFIER.matcher(before);
@@ -349,9 +348,9 @@ private List memberCompletions(
}
final boolean staticOnly = receiver instanceof ClassExpression;
if (type.redirect().isPrimaryClassNode()) {
- return scriptClassMembers(analysis, type.redirect(), prefix, staticOnly);
+ return scriptClassMembers(ast, type.redirect(), prefix, staticOnly);
}
- final Class> c = Types.box(analysis.toClass(type));
+ final Class> c = Types.box(ast.toClass(type));
if (c == null) return new ArrayList<>();
final List out = sorted(Members.of(c, prefix,
staticOnly));
@@ -363,7 +362,7 @@ private List memberCompletions(
/** Members of a class defined in the script itself. */
private List scriptClassMembers(
- final GroovyAnalysis analysis, final ClassNode type, final String prefix,
+ final TypedAST ast, final ClassNode type, final String prefix,
final boolean staticOnly)
{
final List out = new ArrayList<>();
@@ -384,7 +383,7 @@ private List scriptClassMembers(
}
final List sorted = sorted(out);
// Plus what it inherits from (compiled) superclasses.
- final Class> superclass = analysis.toClass(type.getSuperClass());
+ final Class> superclass = ast.toClass(type.getSuperClass());
if (superclass != null && superclass != Object.class) {
sorted.addAll(sorted(Members.of(superclass, prefix, staticOnly)));
}
@@ -547,13 +546,13 @@ private static List importCompletions(final String token) {
}
/** The signatures the call may be calling: methods or constructors. */
- private List callables(final GroovyAnalysis analysis,
- final GroovyAnalysis.Call call)
+ private List callables(final TypedAST ast,
+ final TypedAST.Call call)
{
final List out = new ArrayList<>();
if (call.expression instanceof ConstructorCallExpression) {
final ClassNode type = call.expression.getType().redirect();
- final Class> c = analysis.toClass(type);
+ final Class> c = ast.toClass(type);
if (c != null) out.addAll(Members.constructors(c));
else if (type.isPrimaryClassNode()) {
for (final ConstructorNode ctor : type.getDeclaredConstructors()) {
@@ -565,7 +564,7 @@ else if (type.isPrimaryClassNode()) {
if (call.expression instanceof StaticMethodCallExpression) {
final StaticMethodCallExpression e =
(StaticMethodCallExpression) call.expression;
- return Members.methods(analysis.toClass(e.getOwnerType()), e.getMethod(),
+ return Members.methods(ast.toClass(e.getOwnerType()), e.getMethod(),
true);
}
final MethodCallExpression e = (MethodCallExpression) call.expression;
@@ -573,20 +572,20 @@ else if (type.isPrimaryClassNode()) {
if (name == null) return out;
if (e.isImplicitThis()) {
// A function defined in the script, or a GDK method such as println.
- for (final MethodNode m : analysis.scriptMethods(name)) {
+ for (final MethodNode m : ast.scriptMethods(name)) {
out.add(methodNode(name, m));
}
out.addAll(named(gdkMethods(Object.class, name), name));
return out;
}
final Expression receiver = e.getObjectExpression();
- final ClassNode type = GroovyAnalysis.typeOf(receiver);
+ final ClassNode type = TypedAST.typeOf(receiver);
if (type == null) return out;
final boolean staticOnly = receiver instanceof ClassExpression;
if (type.redirect().isPrimaryClassNode()) {
return scriptMethods(type.redirect(), name, staticOnly, true);
}
- final Class> c = Types.box(analysis.toClass(type));
+ final Class> c = Types.box(ast.toClass(type));
if (c == null) return out;
out.addAll(Members.methods(c, name, staticOnly));
if (!staticOnly) out.addAll(named(gdkMethods(c, name), name));
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyServerPlugin.java b/src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyServerPlugin.java
similarity index 88%
rename from src/main/java/org/scijava/plugins/scripting/groovy/GroovyServerPlugin.java
rename to src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyServerPlugin.java
index db7f73d..42f4ff9 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyServerPlugin.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyServerPlugin.java
@@ -27,7 +27,7 @@
* #L%
*/
-package org.scijava.plugins.scripting.groovy;
+package org.scijava.plugins.scripting.groovy.completion;
import org.eclipse.lsp4j.services.LanguageServer;
import org.scijava.Priority;
@@ -35,6 +35,7 @@
import org.scijava.code.lsp.LanguageServerPlugin;
import org.scijava.code.lsp.ScriptDialect;
import org.scijava.plugin.Plugin;
+import org.scijava.plugins.scripting.groovy.GroovyScriptLanguage;
import org.scijava.script.ScriptLanguage;
/**
@@ -48,13 +49,20 @@
priority = Priority.HIGH)
public class GroovyServerPlugin implements LanguageServerPlugin {
+ @Override
+ public boolean supports(final String languageId) {
+ return "groovy".equals(languageId);
+ }
+
@Override
public boolean supports(final ScriptLanguage language) {
return language instanceof GroovyScriptLanguage;
}
@Override
- public LanguageServer create(final Environment environment) {
+ public LanguageServer create(final String languageId,
+ final Environment environment)
+ {
return new GroovyLanguageServer(environment);
}
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java b/src/main/java/org/scijava/plugins/scripting/groovy/completion/TypedAST.java
similarity index 89%
rename from src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java
rename to src/main/java/org/scijava/plugins/scripting/groovy/completion/TypedAST.java
index fc0a22c..832c94b 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/GroovyAnalysis.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/completion/TypedAST.java
@@ -27,7 +27,7 @@
* #L%
*/
-package org.scijava.plugins.scripting.groovy;
+package org.scijava.plugins.scripting.groovy.completion;
import groovy.lang.GroovyClassLoader;
@@ -79,35 +79,46 @@
import org.scijava.util.Types;
/**
- * Static analysis of a Groovy script for code completion, using Groovy's own
- * compiler: the script is parsed and its names resolved, then it is run
- * through the static type checker, which infers the types of its expressions.
+ * The typed syntax tree of a Groovy script, up to the caret. Useful for
+ * answering LSP-related questions.
+ *
+ * Built by Groovy's own compiler: the script is
+ * parsed and its names resolved, then it is run through the static type
+ * checker, which infers the types of its expressions.
+ *
*
* Scripts being edited are usually incomplete, and usually dynamically typed,
- * so compilation errors are expected and ignored: completion uses whatever the
- * compiler could work out. Only a syntax error prevents analysis; see
- * {@link #isParsed()}.
+ * so compilation errors are expected and ignored: the tree has whatever types
+ * the compiler could work out, and none elsewhere. Only a syntax error leaves
+ * no tree; see {@link #isParsed()}.
*
*
- * The analyzed source is built by {@link #prepare}, which marks the caret with
- * the identifier {@link #CARET}, so that it can be found in the syntax tree.
+ * The source actually parsed is the provided source, with the
+ * following modifications:
+ *
+ * - Text after the caret is omitted
+ * - The identifier {@link #CARET} (see {@link #markCaret}) is inserted
+ * at the caret position, so that it can be found in the syntax tree
+ *
+ * - Any brackets left open are closed to pass parsing
+ *
*
*
* @author Gabriel Selzer
*/
-final class GroovyAnalysis {
+final class TypedAST {
- /** Identifier marking the caret position in the analyzed source. */
+ /** Identifier marking the caret position in the source. */
static final String CARET = "__scijava_caret__";
- /** Recent analyses, by source: typing a word reuses the same analysis. */
- private static final Map CACHE =
- new LinkedHashMap(16, 0.75f, true)
+ /** Cache recent trees for each source file. */
+ private static final Map CACHE =
+ new LinkedHashMap(16, 0.75f, true)
{
@Override
protected boolean removeEldestEntry(
- final Map.Entry eldest)
+ final Map.Entry eldest)
{
return size() > 16;
}
@@ -117,7 +128,7 @@ protected boolean removeEldestEntry(
private final ModuleNode module;
private final ClassLoader loader;
- private GroovyAnalysis(final SourceUnit source, final ClassLoader loader) {
+ private TypedAST(final SourceUnit source, final ClassLoader loader) {
this.source = source;
this.module = source.getAST();
this.loader = loader;
@@ -126,33 +137,25 @@ private GroovyAnalysis(final SourceUnit source, final ClassLoader loader) {
// -- Source preparation --
/**
- * Builds the source to analyze: the script up to the caret as plain Groovy
- * (its predefined variables declared, see {@link GroovyDialect}), then
- * {@link #CARET} and the brackets needed to close any left open.
+ * Marks the caret: the script up to the caret as plain Groovy (its
+ * predefined variables declared, see {@link GroovyDialect}), then
+ * {@link #CARET}.
*
* @param doc The script up to the caret, without the partial token being
* completed.
- * @return The source, or null if the caret is in a string or comment.
- */
- static String prepare(final ScriptDocument doc) {
- return prepare(doc, CARET);
- }
-
- /**
- * As {@link #prepare(ScriptDocument)}, appending {@code tail} where the
- * caret is.
*/
- static String prepare(final ScriptDocument doc, final String tail) {
- final String closers = closers(doc.text());
- if (closers == null) return null;
- return doc.text() + tail + closers;
+ static String markCaret(final ScriptDocument doc) {
+ return doc.text() + CARET;
}
/**
- * Gets the closing brackets for those left open at the end of
- * {@code text}, or null if the text ends inside a string or comment.
+ * Closes the brackets left open at the end of {@code text}, appending the
+ * closing ones (e.g. {@code foo(bar.} becomes {@code foo(bar.)}), so that
+ * it parses.
+ *
+ * @return The text, closed; or null if it ends inside a string or comment.
*/
- static String closers(final String text) {
+ static String closeBrackets(final String text) {
final Deque open = new ArrayDeque<>();
int i = 0;
final int n = text.length();
@@ -191,29 +194,38 @@ else if ((c == ')' || c == ']' || c == '}') && !open.isEmpty() && open
i++;
}
}
- final StringBuilder sb = new StringBuilder();
+ final StringBuilder sb = new StringBuilder(text);
for (final char c : open) {
sb.append(c == '}' ? "\n}" : String.valueOf(c));
}
return sb.toString();
}
- // -- Analysis --
+ // -- Building --
- /** Analyzes the given (prepared) source, or reuses a recent analysis. */
- static GroovyAnalysis of(final String code, final ClassLoader loader) {
+ /**
+ * Builds the typed syntax tree of the given source (its caret marked, its
+ * brackets closed), or reuses a recent one.
+ */
+ static TypedAST of(final String code, final ClassLoader loader) {
synchronized (CACHE) {
- final GroovyAnalysis cached = CACHE.get(code);
+ final TypedAST cached = CACHE.get(code);
if (cached != null) return cached;
}
- final GroovyAnalysis analysis = analyze(code, loader);
+ final TypedAST ast = build(code, loader);
synchronized (CACHE) {
- CACHE.put(code, analysis);
+ CACHE.put(code, ast);
}
- return analysis;
+ return ast;
}
- private static GroovyAnalysis analyze(final String code,
+ /**
+ * Builds the typed syntax tree of the given source, with Groovy's
+ * compiler: parses it, resolves its names, and runs the static type checker
+ * over it (see {@link #typeCheck}), typing what it can; but makes no
+ * bytecode.
+ */
+ private static TypedAST build(final String code,
final ClassLoader loader)
{
final CompilerConfiguration config = new CompilerConfiguration();
@@ -244,7 +256,7 @@ private static GroovyAnalysis analyze(final String code,
}
}
}
- return new GroovyAnalysis(source, loader);
+ return new TypedAST(source, loader);
}
finally {
try {
@@ -304,7 +316,7 @@ private static boolean isBindingAssignment(final BinaryExpression e) {
.getAccessedVariable() instanceof DynamicVariable;
}
- /** A problem the compiler found in the analyzed source. */
+ /** A problem the compiler found in the source. */
static final class Problem {
/** Where it starts and ends: 1-based lines and columns. */
@@ -388,7 +400,7 @@ else if (complaint.startsWith("No such property: ")) {
.startsWith("groovy.lang.");
}
- /** True iff the source parsed (syntax errors prevent any analysis). */
+ /** True iff the source parsed (a syntax error leaves no tree). */
boolean isParsed() {
return module != null;
}
diff --git a/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyCompletionTest.java
similarity index 96%
rename from src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java
rename to src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyCompletionTest.java
index b8a861c..c27c3d3 100644
--- a/src/test/java/org/scijava/plugins/scripting/groovy/GroovyCompletionTest.java
+++ b/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyCompletionTest.java
@@ -27,7 +27,7 @@
* #L%
*/
-package org.scijava.plugins.scripting.groovy;
+package org.scijava.plugins.scripting.groovy.completion;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@@ -43,6 +43,7 @@
import javax.script.ScriptEngine;
import org.eclipse.lsp4j.CompletionItem;
+import org.eclipse.lsp4j.Diagnostic;
import org.eclipse.lsp4j.SignatureHelp;
import org.junit.After;
import org.junit.Before;
@@ -55,8 +56,7 @@
import org.scijava.script.ScriptService;
/**
- * Tests {@link GroovyLanguageServer}, through the
- * {@link LanguageServerService}, as editors use it.
+ * Tests {@link GroovyServerPlugin}
*
* @author Gabriel Selzer
*/
@@ -78,11 +78,6 @@ public void tearDown() {
context.dispose();
}
- @Test
- public void testServerIsDiscovered() {
- assertTrue(servers.supports(groovy));
- }
-
@Test
public void testMembersOfInferredTypes() {
assertTrue(complete("def s = 'hello'\ns.toUpp").contains("toUpperCase"));
@@ -100,6 +95,11 @@ public void testMembersOfInferredTypes() {
"containsAll"));
}
+ /**
+ * Variables assigned without a declaration ({@code x = ...}) are not local
+ * variables but script bindings. The static type checker sees as
+ * dynamic (of unknown type) so {@link TypedAST} types them itself.
+ */
@Test
public void testMembersOfBindings() {
// Variables assigned without a declaration are typed as assigned...
@@ -338,8 +338,11 @@ public void testArgumentChoices() {
/** The problems found in a script, e.g. {@code Error@0:4-6 message}. */
private List problems(final String code) {
- return servers.diagnose(groovy, code, 20_000)
- .stream().map(d -> d.getSeverity() + "@" + d.getRange().getStart()
+ final List found;
+ try (ScriptSession session = servers.session(groovy)) {
+ found = session.update(code).diagnostics(20_000);
+ }
+ return found.stream().map(d -> d.getSeverity() + "@" + d.getRange().getStart()
.getLine() + ":" + d.getRange().getStart().getCharacter() + "-" + d
.getRange().getEnd().getCharacter() + " " + d.getMessage().getLeft())
.collect(Collectors.toList());
diff --git a/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyServerPluginTest.java b/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyServerPluginTest.java
new file mode 100644
index 0000000..1a365d3
--- /dev/null
+++ b/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyServerPluginTest.java
@@ -0,0 +1,52 @@
+package org.scijava.plugins.scripting.groovy.completion;
+
+import org.eclipse.lsp4j.services.LanguageServer;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.scijava.Context;
+import org.scijava.code.lsp.Environment;
+import org.scijava.code.lsp.LanguageServerService;
+import org.scijava.script.ScriptLanguage;
+import org.scijava.script.ScriptService;
+
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Tests {@link GroovyServerPlugin}
+ *
+ * @author Gabriel Selzer
+ */
+public class GroovyServerPluginTest {
+
+ private Context context;
+ private ScriptLanguage groovy;
+ private LanguageServerService servers;
+
+ @Before
+ public void setUp() {
+ context = new Context(ScriptService.class, LanguageServerService.class);
+ groovy = context.service(ScriptService.class).getLanguageByName("Groovy");
+ servers = context.service(LanguageServerService.class);
+ }
+
+ @After
+ public void tearDown() {
+ context.dispose();
+ }
+
+ @Test
+ public void testServerIsDiscovered() {
+ assertTrue(servers.supports(groovy));
+ }
+
+ @Test
+ public void testServerLaunch() {
+ LanguageServer server = servers.launch(
+ groovy.getLanguageName().toLowerCase(),
+ Environment.NONE
+ );
+ assertTrue(server instanceof GroovyLanguageServer);
+ }
+}
+
From e63763c3a4fabfa516050068e049b043d0554aad Mon Sep 17 00:00:00 2001
From: Gabriel Selzer
Date: Fri, 16 Oct 2026 00:26:50 -0500
Subject: [PATCH 14/21] Serve the script language by its name, as code-lsp now
asks
code-lsp's sessions ask for the LSP language named as the script's
language, in lower case, so the plugin no longer declares the script
language it serves separately.
Co-Authored-By: Claude Opus 5.5
---
.../groovy/completion/GroovyServerPlugin.java | 11 ++---------
1 file changed, 2 insertions(+), 9 deletions(-)
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyServerPlugin.java b/src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyServerPlugin.java
index 42f4ff9..1f8bd51 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyServerPlugin.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyServerPlugin.java
@@ -34,10 +34,7 @@
import org.scijava.code.lsp.Environment;
import org.scijava.code.lsp.LanguageServerPlugin;
import org.scijava.code.lsp.ScriptDialect;
-import org.scijava.plugin.Plugin;
-import org.scijava.plugins.scripting.groovy.GroovyScriptLanguage;
-import org.scijava.script.ScriptLanguage;
-
+import org.scijava.plugin
/**
* Offers Groovy's in-process language server (see
* {@link GroovyLanguageServer}), seeing scripts as plain Groovy (see
@@ -51,14 +48,10 @@ public class GroovyServerPlugin implements LanguageServerPlugin {
@Override
public boolean supports(final String languageId) {
+ // NB: Groovy scripts too, by their language's name.
return "groovy".equals(languageId);
}
- @Override
- public boolean supports(final ScriptLanguage language) {
- return language instanceof GroovyScriptLanguage;
- }
-
@Override
public LanguageServer create(final String languageId,
final Environment environment)
From 495b9b901a81d02ff776811f1f85ce9502b31fb1 Mon Sep 17 00:00:00 2001
From: Gabriel Selzer
Date: Fri, 16 Oct 2026 10:26:20 -0500
Subject: [PATCH 15/21] Offer only a Groovy dialect: the server moves to
code-lsp-groovy
GroovyDialect is now a ScriptDialect plugin, showing Groovy scripts to
scijava-code-lsp-groovy's server as plain Groovy.
Co-Authored-By: Claude Opus 5.5
---
README.md | 7 +
pom.xml | 8 +
.../groovy/completion/GroovyDialect.java | 23 +-
.../completion/GroovyLanguageServer.java | 736 -----------------
.../scripting/groovy/completion/TypedAST.java | 751 ------------------
.../completion/GroovyCompletionTest.java | 9 +-
.../groovy/completion/GroovyDialectTest.java} | 65 +-
.../completion/GroovyServerPluginTest.java | 52 --
8 files changed, 79 insertions(+), 1572 deletions(-)
delete mode 100644 src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyLanguageServer.java
delete mode 100644 src/main/java/org/scijava/plugins/scripting/groovy/completion/TypedAST.java
rename src/{main/java/org/scijava/plugins/scripting/groovy/completion/GroovyServerPlugin.java => test/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialectTest.java} (52%)
delete mode 100644 src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyServerPluginTest.java
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 b218332..f6de2fa 100644
--- a/pom.xml
+++ b/pom.xml
@@ -99,6 +99,7 @@
2.101.0
0.1.0-SNAPSHOT
+ 0.1.0-SNAPSHOT
2.14.0
@@ -114,6 +115,13 @@
scijava-code-lsp
${scijava-code-lsp.version}
+
+ org.scijava
+ scijava-code-lsp-groovy
+ ${scijava-code-lsp-groovy.version}
+
+ runtime
+
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
index 6650f9f..768f105 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialect.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialect.java
@@ -30,15 +30,34 @@
package org.scijava.plugins.scripting.groovy.completion;
import org.scijava.code.lsp.ScriptDialect;
+import org.scijava.plugin.Plugin;
+import org.scijava.plugins.scripting.groovy.GroovyScriptLanguage;
+import org.scijava.script.ScriptLanguage;
/**
+ * Turns Groovy scripts into plain Groovy, for {@code groovy} language servers
+ * (e.g. scijava-code-lsp-groovy's): their parameters declared as Java objects,
+ * their {@code #@} lines blanked.
+ *
* @author Gabriel Selzer
*/
-class GroovyDialect implements ScriptDialect {
+@Plugin(type = ScriptDialect.class, name = "Groovy")
+public class GroovyDialect implements ScriptDialect {
+
+ @Override
+ public boolean supports(final ScriptLanguage language) {
+ return language instanceof GroovyScriptLanguage;
+ }
+
+ @Override
+ public String languageId() {
+ return "groovy";
+ }
@Override
public String runtimeType(final Class> type) {
- return GroovyLanguageServer.typeName(type);
+ final String name = type == null ? null : type.getCanonicalName();
+ return name == null ? "Object" : name;
}
@Override
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyLanguageServer.java b/src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyLanguageServer.java
deleted file mode 100644
index f0b9d3b..0000000
--- a/src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyLanguageServer.java
+++ /dev/null
@@ -1,736 +0,0 @@
-/*
- * #%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.lang.reflect.Modifier;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.HashSet;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Set;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import java.util.stream.Collectors;
-
-import javax.script.Bindings;
-import javax.script.ScriptContext;
-
-import groovy.lang.GroovySystem;
-import groovy.lang.MetaMethod;
-
-import org.codehaus.groovy.ast.ClassNode;
-import org.codehaus.groovy.ast.ConstructorNode;
-import org.codehaus.groovy.ast.FieldNode;
-import org.codehaus.groovy.ast.MethodNode;
-import org.codehaus.groovy.ast.PropertyNode;
-import org.codehaus.groovy.ast.expr.ClassExpression;
-import org.codehaus.groovy.ast.expr.ConstructorCallExpression;
-import org.codehaus.groovy.ast.expr.Expression;
-import org.codehaus.groovy.ast.expr.MethodCallExpression;
-import org.codehaus.groovy.ast.expr.StaticMethodCallExpression;
-import org.eclipse.lsp4j.CompletionItem;
-import org.eclipse.lsp4j.CompletionItemKind;
-import org.eclipse.lsp4j.CompletionItemLabelDetails;
-import org.eclipse.lsp4j.CompletionList;
-import org.eclipse.lsp4j.Diagnostic;
-import org.eclipse.lsp4j.DiagnosticSeverity;
-import org.eclipse.lsp4j.Position;
-import org.eclipse.lsp4j.SignatureHelp;
-import org.eclipse.lsp4j.TextEdit;
-import org.scijava.code.lsp.jvm.Callable;
-import org.scijava.code.lsp.jvm.ClassIndex;
-import org.scijava.code.lsp.Environment;
-import org.scijava.code.lsp.jvm.Members;
-import org.scijava.code.lsp.Positions;
-import org.scijava.code.lsp.ScriptDocument;
-import org.scijava.code.lsp.jvm.AbstractLanguageServer;
-import org.scijava.code.lsp.jvm.Signatures;
-import org.scijava.util.Types;
-
-/**
- * An in-process language server for Groovy, driven by Groovy's own compiler
- * (see {@link TypedAST}).
- *
- * @author Gabriel Selzer
- */
-public class GroovyLanguageServer extends AbstractLanguageServer {
-
- private static final List KEYWORDS = Arrays.asList("as", "assert",
- "boolean", "break", "byte", "case", "catch", "char", "class", "continue",
- "def", "default", "do", "double", "else", "enum", "extends", "false",
- "final", "finally", "float", "for", "if", "implements", "import", "in",
- "instanceof", "int", "interface", "long", "new", "null", "private",
- "protected", "public", "return", "short", "static", "super", "switch",
- "this", "throw", "throws", "trait", "true", "try", "var", "void",
- "while");
-
- /** Packages Groovy imports by default. */
- private static final List DEFAULT_IMPORTS = Arrays.asList(
- "java.lang", "java.util", "java.io", "java.net", "groovy.lang",
- "groovy.util");
-
- /** Upper bound on class-name suggestions, which can be very numerous. */
- private static final int MAX_CLASSES = 500;
-
- /**
- * How scripts see their predefined variables (script parameters, an
- * interpreter's bindings): as declared Java objects.
- */
- static final GroovyDialect DIALECT = new GroovyDialect();
-
- /** An import statement being typed: "import a.b.C". */
- private static final Pattern IMPORT = Pattern.compile(
- "^\\s*import\\s+(static\\s+)?([\\w$.]*)$");
-
- /** The identifier (possibly empty) being typed at the caret. */
- private static final Pattern IDENTIFIER = Pattern.compile("[\\w$]*$");
-
- /** A number literal followed by a dot, e.g. "1." (not a member access). */
- private static final Pattern NUMBER_DOT = Pattern.compile(
- "(? none = new ArrayList<>();
-
- final Matcher imp = IMPORT.matcher(line);
- if (imp.find()) {
- if (imp.group(1) != null) return list(doc, caret, caret, none);
- final String token = imp.group(2);
- return list(doc, caret - token.length(), caret, importCompletions(
- token));
- }
-
- final Matcher id = IDENTIFIER.matcher(line);
- id.find();
- final String prefix = id.group();
- final int replaceStart = caret - prefix.length();
- final String before = line.substring(0, line.length() - prefix.length());
- final boolean member = before.trim().endsWith(".") && !before.trim()
- .endsWith("..") && !NUMBER_DOT.matcher(before).find();
- if (!member && prefix.isEmpty()) {
- return list(doc, caret, caret, argumentChoices(doc, caret, line));
- }
- if (!member && Character.isDigit(prefix.charAt(0))) {
- return list(doc, caret, caret, none);
- }
-
- final Map> predefined = liveVariables();
- final ScriptDocument sd = ScriptDocument.of(text.substring(0,
- replaceStart), predefined, DIALECT);
- final TypedAST ast = typedAST(sd, predefined, line.substring(0,
- line.length() - prefix.length()));
- if (ast == null) return list(doc, caret, caret, none); // string, comment
-
- final List items = new ArrayList<>();
- if (member) items.addAll(memberCompletions(ast, before, prefix));
- else {
- items.addAll(variableCompletions(ast.variablesAtCaret(), sd
- .parameters(), prefix));
- items.addAll(keywordCompletions(prefix));
- items.addAll(classCompletions(doc, prefix));
- }
- return list(doc, replaceStart, caret, items);
- }
-
- @Override
- protected SignatureHelp signatureHelp(final Document doc, final int caret) {
- final String line = doc.lineTo(caret);
- final CallContext call = callContext(doc, caret, line);
- if (call == null) return null;
- final List callables = callables(call.ast, call.call);
- if (callables.isEmpty()) return null;
- // NB: Reflection lists methods in no particular order: sort them by
- // their parameter types, so that equally good fits come out the same.
- callables.sort(Comparator.comparing(Callable::parameterTypes));
-
- // The types of the arguments before the caret's (which is being typed).
- final List argTypes = new ArrayList<>();
- for (int i = 0; i < call.call.arguments.size(); i++) {
- argTypes.add(i == call.call.active ? null : call.ast.typeName(
- call.call.arguments.get(i)));
- }
- final ClassLoader loader = loader();
- return new SignatureHelp(Signatures.rateAll(callables, argTypes, (a,
- p) -> Signatures.fit(a, p, loader)), 0, call.call.active);
- }
-
- /**
- * Finds the problems Groovy's compiler and type checker would report
- * that also trouble the script when it runs (see
- * {@link TypedAST#problems()}), in the script as written.
- */
- @Override
- protected List diagnose(final Document doc) {
- final String text = doc.text();
- final ScriptDocument sd = ScriptDocument.of(text, liveVariables(),
- DIALECT);
- final TypedAST ast = TypedAST.of(sd.text(), loader());
- final List found = new ArrayList<>();
- for (final TypedAST.Problem p : ast.problems()) {
- final int start = sd.toOriginal(offset(sd.text(), p.line,
- p.column));
- // NB: Not in the declarations prepended.
- if (start < 0) continue;
- int end = sd.toOriginal(offset(sd.text(), p.endLine,
- p.endColumn));
- // NB: At least one character, on the line where it starts.
- final int lineEnd = text.indexOf('\n', start);
- if (end <= start) end = Math.min(start + 1, text.length());
- if (lineEnd >= 0 && end > lineEnd) end = Math.max(lineEnd, start);
- found.add(diagnostic(doc, start, end, p.warning
- ? DiagnosticSeverity.Warning : DiagnosticSeverity.Error, p.message,
- "groovy"));
- }
- return found;
- }
-
- // -- Helper methods --
-
- /** A call around the caret, as analyzed. */
- private static final class CallContext {
-
- private final TypedAST ast;
- private final TypedAST.Call call;
-
- private CallContext(final TypedAST ast,
- final TypedAST.Call call)
- {
- this.ast = ast;
- this.call = call;
- }
- }
-
- /** Analyzes the call around the caret, or returns null if none. */
- private CallContext callContext(final Document doc, final int caret,
- final String line)
- {
- final String text = doc.text();
- // NB: The argument being typed, if a name, is replaced by the marker.
- final Matcher id = IDENTIFIER.matcher(line);
- id.find();
- final String prefix = id.group();
- final int start = caret - prefix.length();
- final Map> predefined = liveVariables();
- final TypedAST ast = typedAST(ScriptDocument.of(text.substring(
- 0, start), predefined, DIALECT), predefined, line.substring(0, line
- .length() - prefix.length()));
- if (ast == null) return null; // string or comment
- final TypedAST.Call call = ast.callAtCaret();
- return call == null ? null : new CallContext(ast, call);
- }
-
- /**
- * In a call's arguments, with nothing typed yet: the variables in scope,
- * those fitting the parameter first (as preselected).
- */
- private List argumentChoices(final Document doc,
- final int caret, final String line)
- {
- if (Signatures.callStart(doc.text(), caret) < 0) return new ArrayList<>();
- final CallContext call = callContext(doc, caret, line);
- if (call == null) return new ArrayList<>();
- final Set> targets = new HashSet<>();
- for (final Callable c : callables(call.ast, call.call)) {
- if (call.call.active >= c.params().size()) continue;
- final String type = c.params().get(call.call.active).type();
- final Class> t = type == null ? null : Types.box(TypedAST.load(
- type, loader()));
- if (t != null && t != Object.class) targets.add(t);
- }
- final Map> vars = new LinkedHashMap<>();
- final Map local = call.ast.variablesAtCaret();
- final List order = new ArrayList<>(local.keySet());
- Collections.reverse(order);
- for (final String name : order) {
- vars.put(name, Types.box(call.ast.toClass(local.get(name))));
- }
- liveVariables().forEach(vars::putIfAbsent);
- final List fitting = new ArrayList<>();
- final List others = new ArrayList<>();
- for (final Map.Entry> v : vars.entrySet()) {
- final Class> c = Types.box(v.getValue());
- final boolean fits = c != null && targets.stream().anyMatch(t -> t
- .isAssignableFrom(c));
- final CompletionItem item = variable(v.getKey(), c == null ? null
- : typeName(c));
- if (fits) {
- item.setPreselect(fitting.isEmpty());
- fitting.add(item);
- }
- else others.add(item);
- }
- fitting.addAll(others);
- return fitting;
- }
-
- /**
- * Builds the typed syntax tree of the script up to the caret. If it does not
- * parse (e.g. due to a syntax error above the caret), falls back to the
- * caret's line alone.
- *
- * @return The tree, or null if the caret is in a string or comment.
- */
- private TypedAST typedAST(final ScriptDocument doc,
- final Map> predefined, final String lineBefore)
- {
- final String code = TypedAST.closeBrackets(TypedAST.markCaret(
- doc));
- if (code == null) return null;
- final TypedAST ast = TypedAST.of(code, loader());
- if (ast.isParsed()) return ast;
- final String lineCode = TypedAST.closeBrackets(TypedAST
- .markCaret(ScriptDocument.of(lineBefore, predefined, DIALECT)));
- return lineCode == null ? ast : TypedAST.of(lineCode, loader());
- }
-
- private List memberCompletions(
- final TypedAST ast, final String before, final String prefix)
- {
- final Expression receiver = ast.receiverAtCaret();
- final ClassNode type = TypedAST.typeOf(receiver);
- if (type == null) {
- // Perhaps a package name, e.g. "java.util."
- final Matcher q = QUALIFIER.matcher(before);
- if (q.find()) return packageMemberCompletions(q.group(1), prefix);
- return new ArrayList<>();
- }
- final boolean staticOnly = receiver instanceof ClassExpression;
- if (type.redirect().isPrimaryClassNode()) {
- return scriptClassMembers(ast, type.redirect(), prefix, staticOnly);
- }
- final Class> c = Types.box(ast.toClass(type));
- if (c == null) return new ArrayList<>();
- final List out = sorted(Members.of(c, prefix,
- staticOnly));
- if (!staticOnly) {
- for (final Callable m : gdkMethods(c, prefix)) out.add(m.toItem());
- }
- return out;
- }
-
- /** Members of a class defined in the script itself. */
- private List scriptClassMembers(
- final TypedAST ast, final ClassNode type, final String prefix,
- final boolean staticOnly)
- {
- final List out = new ArrayList<>();
- for (final PropertyNode p : type.getProperties()) {
- if (staticOnly && !p.isStatic() || !matches(p.getName(), prefix)) {
- continue;
- }
- out.add(field(p.getName(), p.getType().toString(false)));
- }
- for (final FieldNode f : type.getFields()) {
- if (!f.isPublic() || f.isSynthetic() || staticOnly && !f.isStatic() ||
- !matches(f.getName(), prefix) || type.getProperty(f
- .getName()) != null) continue;
- out.add(field(f.getName(), f.getType().toString(false)));
- }
- for (final Callable m : scriptMethods(type, prefix, staticOnly, false)) {
- out.add(m.toItem());
- }
- final List sorted = sorted(out);
- // Plus what it inherits from (compiled) superclasses.
- final Class> superclass = ast.toClass(type.getSuperClass());
- if (superclass != null && superclass != Object.class) {
- sorted.addAll(sorted(Members.of(superclass, prefix, staticOnly)));
- }
- return sorted;
- }
-
- /** Methods of a class defined in the script: by prefix, or exact name. */
- private static List scriptMethods(final ClassNode type,
- final String name, final boolean staticOnly, final boolean exact)
- {
- final List out = new ArrayList<>();
- for (final MethodNode m : type.getMethods()) {
- if (!m.isPublic() || m.isSynthetic() || staticOnly && !m.isStatic() || m
- .getName().contains("$")) continue;
- if (exact ? !m.getName().equals(name) : !matches(m.getName(), name)) {
- continue;
- }
- out.add(methodNode(m.getName(), m).detail(m.getTypeDescriptor()));
- }
- return out;
- }
-
- /** Classes and subpackages of a package, e.g. after "java.util." */
- private static List packageMemberCompletions(
- final String qualifier, final String prefix)
- {
- if (!ClassIndex.isCacheReady()) return new ArrayList<>();
- final String pkg = qualifier.substring(0, qualifier.length() - 1);
- return ClassIndex.findClassNamesForPackage(pkg) //
- .filter(s -> s.lastIndexOf('.') == pkg.length()) //
- .map(s -> s.substring(pkg.length() + 1)) //
- .filter(s -> matches(s, prefix) && !s.contains("$")) //
- .sorted().limit(MAX_CLASSES) //
- .map(s -> item(s, CompletionItemKind.Class, pkg + "." + s)) //
- .collect(Collectors.toList());
- }
-
- /**
- * The GDK methods Groovy adds to {@code c}, e.g. {@code each} and
- * {@code collect} for collections; by prefix.
- */
- private static List gdkMethods(final Class> c,
- final String prefix)
- {
- final Map out = new LinkedHashMap<>();
- for (final MetaMethod m : GroovySystem.getMetaClassRegistry()
- .getMetaClass(c).getMetaMethods())
- {
- if (m.isStatic() || !matches(m.getName(), prefix)) continue;
- final List params = new ArrayList<>();
- final Class>[] types = m.getNativeParameterTypes();
- for (int i = 0; i < types.length; i++) {
- params.add(new Callable.Param("arg" + i, typeName(types[i])));
- }
- final String signature = typeName(m.getReturnType()) + " " + m
- .getName() + params.stream().map(Callable.Param::type).collect(
- Collectors.joining(", ", "(", ")"));
- // NB: Groovy lists some methods more than once.
- out.putIfAbsent(signature, new Callable(m.getName(), params, typeName(m
- .getReturnType())).detail("GDK: " + signature));
- }
- final List sorted = new ArrayList<>(out.values());
- sorted.sort(Comparator.comparing(m -> m.name().toLowerCase(Locale.ROOT)));
- return sorted;
- }
-
- private static List variableCompletions(
- final Map vars, final Map predefined,
- final String prefix)
- {
- final Set names = new HashSet<>();
- final List out = new ArrayList<>();
- // Innermost (most recently declared) variables first.
- final List order = new ArrayList<>(vars.keySet());
- Collections.reverse(order);
- order.addAll(predefined.keySet());
- for (final String name : order) {
- if (!matches(name, prefix) || name.equals(prefix) || !names.add(name)) {
- continue;
- }
- final ClassNode type = vars.get(name);
- out.add(variable(name, type != null ? type.toString(false) : predefined
- .get(name)));
- }
- return out;
- }
-
- private static List keywordCompletions(final String prefix) {
- return KEYWORDS.stream().filter(k -> k.startsWith(prefix) && !k.equals(
- prefix)).map(k -> item(k, CompletionItemKind.Keyword, null)).collect(
- Collectors.toList());
- }
-
- /** Class names starting with the prefix, imported on acceptance. */
- private static List classCompletions(final Document doc,
- final String prefix)
- {
- if (!Character.isUpperCase(prefix.charAt(0)) || !ClassIndex
- .isCacheReady()) return new ArrayList<>();
- final List classNames = ClassIndex.findSimpleClassNamesStartingWith(
- prefix);
- // Closest matches first: shortest simple name, then alphabetical.
- classNames.sort(Comparator.comparingInt((String cn) -> cn.length() - cn
- .lastIndexOf('.')).thenComparing(Comparator.naturalOrder()));
- final List out = new ArrayList<>();
- for (final String className : classNames) {
- if (className.contains("$")) continue;
- final String simpleName = className.substring(className.lastIndexOf(
- '.') + 1);
- final CompletionItem item = item(simpleName, CompletionItemKind.Class,
- className);
- final int at = importOffset(doc.text(), className);
- if (at >= 0) {
- item.setAdditionalTextEdits(Collections.singletonList(insert(doc, at,
- "import " + className + "\n")));
- }
- out.add(item);
- if (out.size() >= MAX_CLASSES) break;
- }
- return out;
- }
-
- /**
- * Where to import the given class: after the script's last import (or at
- * the top); or -1 if no import is needed.
- */
- static int importOffset(final String text, final String className) {
- final String pkg = className.substring(0, Math.max(0, className
- .lastIndexOf('.')));
- if (DEFAULT_IMPORTS.contains(pkg) || className.equals(
- "java.math.BigDecimal") || className.equals("java.math.BigInteger"))
- {
- return -1;
- }
- final Pattern imports = Pattern.compile("^\\s*import\\s+(?:static\\s+)?" +
- "([\\w$.]+?)(\\.\\*)?\\s*(?:as\\s+\\w+\\s*)?;?\\s*$", Pattern.MULTILINE);
- final Matcher m = imports.matcher(text);
- int insertAt = 0;
- while (m.find()) {
- final String imported = m.group(1);
- if (imported.equals(className) || m.group(2) != null && imported.equals(
- pkg)) return -1;
- insertAt = m.end() + 1;
- }
- if (insertAt == 0 && text.startsWith("#!")) {
- insertAt = text.indexOf('\n') + 1; // NB: After the shebang.
- }
- return Math.min(insertAt, text.length());
- }
-
- private static List importCompletions(final String token) {
- if (token.isEmpty() || !ClassIndex.isCacheReady()) {
- return new ArrayList<>();
- }
- return ClassIndex.findClassNamesStartingWith(token) //
- .filter(s -> !s.contains("$")) //
- .sorted().limit(MAX_CLASSES) //
- .map(s -> item(s, CompletionItemKind.Module, null)) //
- .collect(Collectors.toList());
- }
-
- /** The signatures the call may be calling: methods or constructors. */
- private List callables(final TypedAST ast,
- final TypedAST.Call call)
- {
- final List out = new ArrayList<>();
- if (call.expression instanceof ConstructorCallExpression) {
- final ClassNode type = call.expression.getType().redirect();
- final Class> c = ast.toClass(type);
- if (c != null) out.addAll(Members.constructors(c));
- else if (type.isPrimaryClassNode()) {
- for (final ConstructorNode ctor : type.getDeclaredConstructors()) {
- out.add(methodNode(type.getNameWithoutPackage(), ctor));
- }
- }
- return out;
- }
- if (call.expression instanceof StaticMethodCallExpression) {
- final StaticMethodCallExpression e =
- (StaticMethodCallExpression) call.expression;
- return Members.methods(ast.toClass(e.getOwnerType()), e.getMethod(),
- true);
- }
- final MethodCallExpression e = (MethodCallExpression) call.expression;
- final String name = e.getMethodAsString();
- if (name == null) return out;
- if (e.isImplicitThis()) {
- // A function defined in the script, or a GDK method such as println.
- for (final MethodNode m : ast.scriptMethods(name)) {
- out.add(methodNode(name, m));
- }
- out.addAll(named(gdkMethods(Object.class, name), name));
- return out;
- }
- final Expression receiver = e.getObjectExpression();
- final ClassNode type = TypedAST.typeOf(receiver);
- if (type == null) return out;
- final boolean staticOnly = receiver instanceof ClassExpression;
- if (type.redirect().isPrimaryClassNode()) {
- return scriptMethods(type.redirect(), name, staticOnly, true);
- }
- final Class> c = Types.box(ast.toClass(type));
- if (c == null) return out;
- out.addAll(Members.methods(c, name, staticOnly));
- if (!staticOnly) out.addAll(named(gdkMethods(c, name), name));
- return out;
- }
-
- /** The callables with exactly the given name. */
- private static List named(final List callables,
- final String name)
- {
- return callables.stream().filter(c -> c.name().equals(name)).collect(
- Collectors.toList());
- }
-
- /** A callable defined in the script itself. */
- private static Callable methodNode(final String name, final MethodNode m) {
- final List params = new ArrayList<>();
- for (final org.codehaus.groovy.ast.Parameter p : m.getParameters()) {
- params.add(new Callable.Param(p.getName(), p.isDynamicTyped() ? null : p
- .getType().getName()));
- }
- return new Callable(name, params, m.getReturnType().toString(false));
- }
-
- private static CompletionItem item(final String text,
- final CompletionItemKind kind, final String detail)
- {
- final CompletionItem item = new CompletionItem(text);
- item.setKind(kind);
- item.setInsertText(text);
- item.setDetail(detail);
- return item;
- }
-
- private static CompletionItem variable(final String name,
- final String type)
- {
- return item(name, CompletionItemKind.Variable, type == null ? name : type +
- " " + name);
- }
-
- private static CompletionItem field(final String name, final String type) {
- final CompletionItem item = item(name, CompletionItemKind.Field, type +
- " " + name);
- final CompletionItemLabelDetails details = new CompletionItemLabelDetails();
- details.setDescription(type);
- item.setLabelDetails(details);
- return item;
- }
-
- /**
- * Gets the variables defined before the code runs, in a live interpreter:
- * its bindings. (A script's parameters are declared in its text.)
- *
- * @return Map of variable name to type.
- */
- private Map> liveVariables() {
- final Map> vars = new LinkedHashMap<>();
- final ScriptContext live = environment().live();
- final Bindings bindings = live == null ? null : live.getBindings(
- ScriptContext.ENGINE_SCOPE);
- if (bindings != null) {
- for (final Map.Entry entry : bindings.entrySet()) {
- final String name = entry.getKey();
- if (!isIdentifier(name) || name.startsWith("__")) continue;
- final Object value = entry.getValue();
- vars.put(name, value == null ? Object.class : publicType(value
- .getClass()));
- }
- }
- return vars;
- }
-
- private static ClassLoader loader() {
- final ClassLoader loader = Thread.currentThread().getContextClassLoader();
- return loader != null ? loader : GroovyLanguageServer.class
- .getClassLoader();
- }
-
- /** The offset of a 1-based line and column, as Groovy reports them. */
- private static int offset(final String text, final int line,
- final int column)
- {
- return Positions.offset(text, new Position(Math.max(line - 1, 0), Math
- .max(column - 1, 0)));
- }
-
- private static boolean isIdentifier(final String name) {
- if (name == null || name.isEmpty() || !Character.isJavaIdentifierStart(
- name.charAt(0))) return false;
- for (int i = 1; i < name.length(); i++) {
- if (!Character.isJavaIdentifierPart(name.charAt(i))) return false;
- }
- return !KEYWORDS.contains(name);
- }
-
- private static boolean matches(final String name, final String prefix) {
- return name.toLowerCase(Locale.ROOT).startsWith(prefix.toLowerCase(
- Locale.ROOT));
- }
-
- /** Sorts completions by name, case-insensitively (stably). */
- private static List sorted(
- final List items)
- {
- final List out = new ArrayList<>(items);
- out.sort(Comparator.comparing(c -> c.getLabel().toLowerCase(
- Locale.ROOT)));
- return out;
- }
-
- /**
- * Gets the name by which Groovy code refers to a class, e.g.
- * {@code java.util.List} or {@code int[]}; or {@code Object} if it has none
- * (e.g. an anonymous class).
- */
- static String typeName(final Class> c) {
- final String name = c == null ? null : c.getCanonicalName();
- return name == null ? "Object" : name;
- }
-
- /**
- * Gets the most specific public class or interface of {@code c}, by which
- * code can refer to its instances.
- */
- private static Class> publicType(final Class> c) {
- for (Class> t = c; t != null; t = t.getSuperclass()) {
- if (isAccessible(t)) return t;
- }
- for (final Class> i : c.getInterfaces()) {
- if (isAccessible(i)) return i;
- }
- return Object.class;
- }
-
- private static boolean isAccessible(final Class> c) {
- for (Class> t = c; t != null; t = t.getEnclosingClass()) {
- if (!Modifier.isPublic(t.getModifiers())) return false;
- }
- return !c.isAnonymousClass() && !c.isLocalClass();
- }
-
- /** An edit importing a class (for tests). */
- static TextEdit importEdit(final Document doc, final String className) {
- final int at = importOffset(doc.text(), className);
- return at < 0 ? null : insert(doc, at, "import " + className + "\n");
- }
-}
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/completion/TypedAST.java b/src/main/java/org/scijava/plugins/scripting/groovy/completion/TypedAST.java
deleted file mode 100644
index 832c94b..0000000
--- a/src/main/java/org/scijava/plugins/scripting/groovy/completion/TypedAST.java
+++ /dev/null
@@ -1,751 +0,0 @@
-/*
- * #%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 groovy.lang.GroovyClassLoader;
-
-import java.io.IOException;
-import java.util.ArrayDeque;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Deque;
-import java.util.HashMap;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-
-import org.codehaus.groovy.ast.ASTNode;
-import org.codehaus.groovy.ast.ClassCodeVisitorSupport;
-import org.codehaus.groovy.ast.ClassNode;
-import org.codehaus.groovy.ast.DynamicVariable;
-import org.codehaus.groovy.ast.MethodNode;
-import org.codehaus.groovy.ast.ModuleNode;
-import org.codehaus.groovy.ast.Parameter;
-import org.codehaus.groovy.ast.Variable;
-import org.codehaus.groovy.ast.expr.BinaryExpression;
-import org.codehaus.groovy.ast.expr.ClassExpression;
-import org.codehaus.groovy.ast.expr.ClosureExpression;
-import org.codehaus.groovy.ast.expr.ConstantExpression;
-import org.codehaus.groovy.ast.expr.ConstructorCallExpression;
-import org.codehaus.groovy.ast.expr.DeclarationExpression;
-import org.codehaus.groovy.ast.expr.Expression;
-import org.codehaus.groovy.ast.expr.GStringExpression;
-import org.codehaus.groovy.ast.expr.MethodCallExpression;
-import org.codehaus.groovy.ast.expr.PropertyExpression;
-import org.codehaus.groovy.ast.expr.StaticMethodCallExpression;
-import org.codehaus.groovy.ast.expr.TupleExpression;
-import org.codehaus.groovy.ast.expr.VariableExpression;
-import org.codehaus.groovy.ast.stmt.BlockStatement;
-import org.codehaus.groovy.ast.stmt.CatchStatement;
-import org.codehaus.groovy.ast.stmt.ForStatement;
-import org.codehaus.groovy.control.CompilationUnit;
-import org.codehaus.groovy.control.CompilerConfiguration;
-import org.codehaus.groovy.control.Phases;
-import org.codehaus.groovy.control.SourceUnit;
-import org.codehaus.groovy.control.messages.Message;
-import org.codehaus.groovy.control.messages.SyntaxErrorMessage;
-import org.codehaus.groovy.syntax.SyntaxException;
-import org.codehaus.groovy.transform.stc.StaticTypeCheckingVisitor;
-import org.codehaus.groovy.transform.stc.StaticTypesMarker;
-import org.codehaus.groovy.transform.stc.TypeCheckingExtension;
-import org.scijava.code.lsp.ScriptDocument;
-import org.scijava.util.Types;
-
-/**
- * The typed syntax tree of a Groovy script, up to the caret. Useful for
- * answering LSP-related questions.
- *
- * Built by Groovy's own compiler: the script is
- * parsed and its names resolved, then it is run through the static type
- * checker, which infers the types of its expressions.
- *
- *
- * Scripts being edited are usually incomplete, and usually dynamically typed,
- * so compilation errors are expected and ignored: the tree has whatever types
- * the compiler could work out, and none elsewhere. Only a syntax error leaves
- * no tree; see {@link #isParsed()}.
- *
- *
- * The source actually parsed is the provided source, with the
- * following modifications:
- *
- * - Text after the caret is omitted
- * - The identifier {@link #CARET} (see {@link #markCaret}) is inserted
- * at the caret position, so that it can be found in the syntax tree
- *
- * - Any brackets left open are closed to pass parsing
- *
- *
- *
- * @author Gabriel Selzer
- */
-final class TypedAST {
-
- /** Identifier marking the caret position in the source. */
- static final String CARET = "__scijava_caret__";
-
- /** Cache recent trees for each source file. */
- private static final Map CACHE =
- new LinkedHashMap(16, 0.75f, true)
- {
-
- @Override
- protected boolean removeEldestEntry(
- final Map.Entry eldest)
- {
- return size() > 16;
- }
- };
-
- private final SourceUnit source;
- private final ModuleNode module;
- private final ClassLoader loader;
-
- private TypedAST(final SourceUnit source, final ClassLoader loader) {
- this.source = source;
- this.module = source.getAST();
- this.loader = loader;
- }
-
- // -- Source preparation --
-
- /**
- * Marks the caret: the script up to the caret as plain Groovy (its
- * predefined variables declared, see {@link GroovyDialect}), then
- * {@link #CARET}.
- *
- * @param doc The script up to the caret, without the partial token being
- * completed.
- */
- static String markCaret(final ScriptDocument doc) {
- return doc.text() + CARET;
- }
-
- /**
- * Closes the brackets left open at the end of {@code text}, appending the
- * closing ones (e.g. {@code foo(bar.} becomes {@code foo(bar.)}), so that
- * it parses.
- *
- * @return The text, closed; or null if it ends inside a string or comment.
- */
- static String closeBrackets(final String text) {
- final Deque open = new ArrayDeque<>();
- int i = 0;
- final int n = text.length();
- while (i < n) {
- final char c = text.charAt(i);
- if (text.startsWith("//", i)) {
- final int end = text.indexOf('\n', i);
- if (end < 0) return null;
- i = end + 1;
- }
- else if (text.startsWith("/*", i)) {
- final int end = text.indexOf("*/", i + 2);
- if (end < 0) return null;
- i = end + 2;
- }
- else if (text.startsWith("'''", i) || text.startsWith("\"\"\"", i)) {
- final int end = text.indexOf(text.substring(i, i + 3), i + 3);
- if (end < 0) return null;
- i = end + 3;
- }
- else if (c == '\'' || c == '"') {
- int j = i + 1;
- while (j < n && text.charAt(j) != c && text.charAt(j) != '\n') {
- if (text.charAt(j) == '\\') j++;
- j++;
- }
- if (j >= n || text.charAt(j) != c) return null;
- i = j + 1;
- }
- else {
- if (c == '(') open.push(')');
- else if (c == '[') open.push(']');
- else if (c == '{') open.push('}');
- else if ((c == ')' || c == ']' || c == '}') && !open.isEmpty() && open
- .peek() == c) open.pop();
- i++;
- }
- }
- final StringBuilder sb = new StringBuilder(text);
- for (final char c : open) {
- sb.append(c == '}' ? "\n}" : String.valueOf(c));
- }
- return sb.toString();
- }
-
- // -- Building --
-
- /**
- * Builds the typed syntax tree of the given source (its caret marked, its
- * brackets closed), or reuses a recent one.
- */
- static TypedAST of(final String code, final ClassLoader loader) {
- synchronized (CACHE) {
- final TypedAST cached = CACHE.get(code);
- if (cached != null) return cached;
- }
- final TypedAST ast = build(code, loader);
- synchronized (CACHE) {
- CACHE.put(code, ast);
- }
- return ast;
- }
-
- /**
- * Builds the typed syntax tree of the given source, with Groovy's
- * compiler: parses it, resolves its names, and runs the static type checker
- * over it (see {@link #typeCheck}), typing what it can; but makes no
- * bytecode.
- */
- private static TypedAST build(final String code,
- final ClassLoader loader)
- {
- final CompilerConfiguration config = new CompilerConfiguration();
- // NB: Collect all errors, rather than stopping at the first few.
- config.setTolerance(Integer.MAX_VALUE);
- final GroovyClassLoader gcl = new GroovyClassLoader(loader, config);
- try {
- final CompilationUnit unit = new CompilationUnit(config, null, gcl);
- final SourceUnit source = unit.addSource("CompletionScript.groovy",
- code);
- try {
- // Parse, resolve names and canonicalize; stop before bytecode.
- unit.compile(Phases.CANONICALIZATION);
- }
- catch (final RuntimeException | LinkageError exc) {
- // NB: Compilation errors are expected. Use what we have.
- }
- final ModuleNode module = source.getAST();
- if (module != null) {
- // NB: Unresolvable classes stop compilation before the type checker
- // would run, and dynamic code fails it, so run it ourselves.
- for (final ClassNode c : module.getClasses()) {
- try {
- typeCheck(source, c);
- }
- catch (final RuntimeException | LinkageError | AssertionError exc) {
- // NB: Keep whatever types were inferred before the failure.
- }
- }
- }
- return new TypedAST(source, loader);
- }
- finally {
- try {
- gcl.close();
- }
- catch (final IOException exc) {
- // NB: Nothing to clean up, then.
- }
- }
- }
-
- /**
- * Runs the static type checker over a class of the script, typing the
- * script's bindings (variables assigned without a declaration, e.g.
- * {@code z = "abc"}), which it would leave untyped: each use of one has the
- * type last assigned to it, in source order (the source ends at the caret).
- */
- private static void typeCheck(final SourceUnit source, final ClassNode c) {
- final Map bindings = new HashMap<>();
- final StaticTypeCheckingVisitor checker = new StaticTypeCheckingVisitor(
- source, c)
- {
-
- @Override
- public void visitBinaryExpression(final BinaryExpression e) {
- super.visitBinaryExpression(e);
- if (!isBindingAssignment(e)) return;
- final String name = ((VariableExpression) e.getLeftExpression())
- .getName();
- final ClassNode type = getType(e.getRightExpression());
- if (type == null || "java.lang.Object".equals(type.getName())) {
- bindings.remove(name);
- }
- else bindings.put(name, type);
- }
- };
- checker.addTypeCheckingExtension(new TypeCheckingExtension(checker) {
-
- @Override
- public boolean handleUnresolvedVariableExpression(
- final VariableExpression v)
- {
- final ClassNode type = bindings.get(v.getName());
- if (type == null) return false;
- storeType(v, type);
- return true;
- }
- });
- checker.visitClass(c);
- }
-
- /** True iff the expression assigns a script binding, e.g. {@code z = 1}. */
- private static boolean isBindingAssignment(final BinaryExpression e) {
- return e.getOperation().getType() == org.codehaus.groovy.syntax.Types.ASSIGN &&
- e.getLeftExpression() instanceof VariableExpression &&
- ((VariableExpression) e.getLeftExpression())
- .getAccessedVariable() instanceof DynamicVariable;
- }
-
- /** A problem the compiler found in the source. */
- static final class Problem {
-
- /** Where it starts and ends: 1-based lines and columns. */
- final int line, column, endLine, endColumn;
-
- final String message;
-
- /** Whether it may yet work at run time (else it fails for sure). */
- final boolean warning;
-
- private Problem(final SyntaxException e, final String message,
- final boolean warning)
- {
- line = e.getStartLine();
- column = e.getStartColumn();
- endLine = e.getEndLine();
- endColumn = e.getEndColumn();
- this.message = message;
- this.warning = warning;
- }
- }
-
- /** The type checker's messages, e.g. about a call it cannot resolve. */
- private static final String TYPE_CHECKING = "[Static type checking] - ";
-
- /**
- * Gets the problems the compiler found that would also trouble the script
- * when it runs: syntax errors and unresolvable classes; and, as warnings,
- * methods and properties the type checker cannot find on a known type
- * (e.g. {@code "abc".fooBar()}). The type checker's other complaints, e.g.
- * about dynamically typed code, are left out: Groovy resolves those when
- * the script runs.
- */
- List problems() {
- final List problems = new ArrayList<>();
- final List extends Message> errors = source.getErrorCollector()
- .getErrors();
- if (errors == null) return problems;
- for (final Message m : errors) {
- if (!(m instanceof SyntaxErrorMessage)) continue;
- final SyntaxException e = ((SyntaxErrorMessage) m).getCause();
- final String message = e.getOriginalMessage() == null ? null : e
- .getOriginalMessage().trim();
- if (message == null || message.contains(CARET)) continue;
- if (!message.startsWith(TYPE_CHECKING)) {
- problems.add(new Problem(e, message, false));
- continue;
- }
- final String complaint = message.substring(TYPE_CHECKING.length())
- .replace(" Please check if the declared type is correct and if the " +
- "method exists.", "");
- if (isKnownTypeMissing(complaint)) {
- problems.add(new Problem(e, complaint, true));
- }
- }
- return problems;
- }
-
- /**
- * True iff the type checker's complaint is that a known, specific type
- * lacks a method or property: rather than, e.g., that a variable's type is
- * unknown ({@code Object}), or that the script calls methods it does not
- * declare (which bindings may provide when it runs).
- */
- private static boolean isKnownTypeMissing(final String complaint) {
- final String type;
- if (complaint.startsWith("Cannot find matching method ")) {
- final String call = complaint.substring(
- "Cannot find matching method ".length());
- final int hash = call.indexOf('#');
- if (hash < 0) return false;
- type = call.substring(0, hash);
- }
- else if (complaint.startsWith("No such property: ")) {
- final int at = complaint.indexOf(" for class: ");
- if (at < 0) return false;
- type = complaint.substring(at + " for class: ".length()).trim();
- }
- else return false;
- return type.contains(".") && !type.equals("java.lang.Object") && !type
- .startsWith("groovy.lang.");
- }
-
- /** True iff the source parsed (a syntax error leaves no tree). */
- boolean isParsed() {
- return module != null;
- }
-
- /**
- * Gets the expression whose member is being completed, e.g. {@code img} in
- * {@code img.CARET}, or null if the caret is not after a dot.
- */
- Expression receiverAtCaret() {
- final Expression[] found = { null };
- visit(new Visitor() {
-
- @Override
- public void visitPropertyExpression(final PropertyExpression e) {
- if (CARET.equals(e.getPropertyAsString())) found[0] = e
- .getObjectExpression();
- super.visitPropertyExpression(e);
- }
-
- @Override
- public void visitMethodCallExpression(final MethodCallExpression e) {
- if (CARET.equals(e.getMethodAsString()) && !e.isImplicitThis()) {
- found[0] = e.getObjectExpression();
- }
- super.visitMethodCallExpression(e);
- }
- });
- return found[0];
- }
-
- /**
- * Gets the variables visible at the caret, with their types (null if
- * unknown), innermost last.
- */
- Map variablesAtCaret() {
- final ASTNode caret = caretNode();
- if (caret == null) return Collections.emptyMap();
- final int line = caret.getLineNumber(), column = caret.getColumnNumber();
- final Map vars = new LinkedHashMap<>();
- visit(new Visitor() {
-
- private boolean contains(final ASTNode node) {
- if (node.getLineNumber() < 0) return true; // synthetic: unknown span
- return before(node.getLineNumber(), node.getColumnNumber()) && //
- !before(node.getLastLineNumber(), node.getLastColumnNumber());
- }
-
- private boolean before(final int l, final int c) {
- return l < line || l == line && c <= column;
- }
-
- private void add(final Variable v) {
- if (CARET.equals(v.getName())) return;
- vars.remove(v.getName()); // NB: Re-insert, so innermost comes last.
- vars.put(v.getName(), v.isDynamicTyped() ? null : v.getOriginType());
- }
-
- @Override
- protected void visitConstructorOrMethod(final MethodNode node,
- final boolean isConstructor)
- {
- if (!node.isScriptBody() && !contains(node)) return;
- for (final Parameter p : node.getParameters()) add(p);
- super.visitConstructorOrMethod(node, isConstructor);
- }
-
- @Override
- public void visitClosureExpression(final ClosureExpression e) {
- if (!contains(e)) return;
- if (e.getParameters() != null) {
- for (final Parameter p : e.getParameters()) add(p);
- }
- super.visitClosureExpression(e);
- }
-
- @Override
- public void visitBlockStatement(final BlockStatement block) {
- if (contains(block)) super.visitBlockStatement(block);
- }
-
- @Override
- public void visitForLoop(final ForStatement loop) {
- if (!contains(loop)) return;
- final Parameter v = loop.getVariable();
- if (v != null && v != ForStatement.FOR_LOOP_DUMMY) add(v);
- super.visitForLoop(loop);
- }
-
- @Override
- public void visitCatchStatement(final CatchStatement statement) {
- if (!contains(statement)) return;
- add(statement.getVariable());
- super.visitCatchStatement(statement);
- }
-
- @Override
- public void visitDeclarationExpression(final DeclarationExpression e) {
- if (before(e.getLineNumber(), e.getColumnNumber())) {
- if (e.isMultipleAssignmentDeclaration()) {
- for (final Expression v : e.getTupleExpression()
- .getExpressions())
- {
- if (v instanceof VariableExpression) {
- addDeclared((VariableExpression) v);
- }
- }
- }
- else addDeclared(e.getVariableExpression());
- }
- super.visitDeclarationExpression(e);
- }
-
- @Override
- public void visitBinaryExpression(final BinaryExpression e) {
- // NB: A binding assigned, e.g. z = "abc".
- if (isBindingAssignment(e)) {
- final String name = ((VariableExpression) e.getLeftExpression())
- .getName();
- vars.remove(name);
- vars.put(name, typeOf(e.getRightExpression()));
- }
- super.visitBinaryExpression(e);
- }
-
- private void addDeclared(final VariableExpression v) {
- if (CARET.equals(v.getName())) return;
- vars.remove(v.getName());
- vars.put(v.getName(), typeOf(v));
- }
- });
- return vars;
- }
-
- /** The call whose arguments contain the caret marker. */
- static final class Call {
-
- /**
- * The call: a {@link MethodCallExpression},
- * {@link StaticMethodCallExpression} or
- * {@link ConstructorCallExpression}.
- */
- final Expression expression;
-
- /** Its arguments, up to the one containing the caret. */
- final List arguments;
-
- /** Index of the argument containing the caret. */
- final int active;
-
- private Call(final Expression expression,
- final List arguments, final int active)
- {
- this.expression = expression;
- this.arguments = arguments;
- this.active = active;
- }
- }
-
- /**
- * Gets the innermost call whose arguments contain the caret marker, e.g.
- * {@code max} in {@code Math.max(1.5, CARET)}; or null if there is none.
- */
- Call callAtCaret() {
- final Call[] found = { null };
- visit(new Visitor() {
-
- @Override
- public void visitMethodCallExpression(final MethodCallExpression e) {
- consider(e, e.getArguments());
- super.visitMethodCallExpression(e);
- }
-
- @Override
- public void visitStaticMethodCallExpression(
- final StaticMethodCallExpression e)
- {
- consider(e, e.getArguments());
- super.visitStaticMethodCallExpression(e);
- }
-
- @Override
- public void visitConstructorCallExpression(
- final ConstructorCallExpression e)
- {
- consider(e, e.getArguments());
- super.visitConstructorCallExpression(e);
- }
-
- private void consider(final Expression call, final Expression args) {
- if (!(args instanceof TupleExpression)) return;
- final List list = ((TupleExpression) args)
- .getExpressions();
- for (int i = 0; i < list.size(); i++) {
- if (!containsCaret(list.get(i))) continue;
- // NB: Visited outside in, so the innermost call is found last.
- found[0] = new Call(call, list.subList(0, i + 1), i);
- return;
- }
- }
- });
- return found[0];
- }
-
- /**
- * Gets the type of an expression, e.g. an argument, as a type name for
- * {@link org.scijava.code.lsp.jvm.Signatures}; or null if unknown.
- */
- String typeName(final Expression e) {
- if (e instanceof GStringExpression) return String.class.getName();
- if (e instanceof ConstantExpression) {
- final Object value = ((ConstantExpression) e).getValue();
- if (value == null) return null;
- if (value instanceof Integer) return "int";
- if (value instanceof Long) return "long";
- if (value instanceof Double) return "double";
- if (value instanceof Float) return "float";
- if (value instanceof Boolean) return "boolean";
- return value.getClass().getName(); // e.g. String, BigDecimal
- }
- final Class> c = toClass(typeOf(e));
- if (c == null || c == Object.class) return null; // dynamic: unknown
- final String name = c.getCanonicalName();
- return name == null ? c.getName() : name;
- }
-
- /** Gets the script's own methods (e.g. {@code def area(w, h)}) by name. */
- List scriptMethods(final String name) {
- final List methods = new ArrayList<>();
- if (module == null) return methods;
- for (final ClassNode c : module.getClasses()) {
- if (c.isScript()) methods.addAll(c.getMethods(name));
- }
- return methods;
- }
-
- /** True iff the expression is, or contains, the caret marker. */
- private boolean containsCaret(final Expression e) {
- final boolean[] found = { false };
- e.visit(new Visitor() {
-
- @Override
- public void visitVariableExpression(final VariableExpression v) {
- if (CARET.equals(v.getName())) found[0] = true;
- }
- });
- return found[0];
- }
-
- // -- Types --
-
- /** Gets the (inferred or declared) type of an expression, or null. */
- static ClassNode typeOf(final Expression e) {
- if (e == null) return null;
- // NB: The type checker infers Class for a class reference; we want T.
- if (e instanceof ClassExpression) return e.getType();
- final Object inferred = e.getNodeMetaData(StaticTypesMarker.INFERRED_TYPE);
- if (inferred instanceof ClassNode) return (ClassNode) inferred;
- if (e instanceof VariableExpression) {
- final Variable v = ((VariableExpression) e).getAccessedVariable();
- if (v != null && v != e && !v.isDynamicTyped()) return v.getOriginType();
- final VariableExpression ve = (VariableExpression) e;
- return ve.isDynamicTyped() ? null : ve.getOriginType();
- }
- final ClassNode type = e.getType();
- if (type == null || "java.lang.Object".equals(type.getName())) return null;
- return type;
- }
-
- /**
- * Gets the Java class of a type, or null if it is unknown or defined in the
- * script itself.
- */
- Class> toClass(final ClassNode type) {
- if (type == null) return null;
- final ClassNode t = type.redirect();
- if (t.isPrimaryClassNode()) return null; // NB: Not compiled.
- try {
- return t.getTypeClass();
- }
- catch (final RuntimeException | LinkageError | AssertionError exc) {
- // NB: Not loaded yet; load it by name.
- }
- return load(t.getName(), loader);
- }
-
- /**
- * Loads a type by name, e.g. {@code int}, {@code long[]}, {@code a.b.C} or
- * {@code a.b.Outer.Inner}, or returns null if it cannot be loaded.
- */
- static Class> load(final String name, final ClassLoader loader) {
- if (name == null) return null;
- final Class> c = Types.load(name, loader);
- if (c != null) return c;
- // NB: Perhaps a nested class, named canonically: a.b.Outer.Inner.
- final int dot = name.lastIndexOf('.');
- return dot < 0 ? null : load(name.substring(0, dot) + "$" + name
- .substring(dot + 1), loader);
- }
-
- // -- Helper methods --
-
- /** Gets the node of the caret marker. */
- private ASTNode caretNode() {
- final ASTNode[] found = { null };
- visit(new Visitor() {
-
- @Override
- public void visitVariableExpression(final VariableExpression e) {
- if (CARET.equals(e.getName())) found[0] = e;
- super.visitVariableExpression(e);
- }
-
- @Override
- public void visitPropertyExpression(final PropertyExpression e) {
- if (CARET.equals(e.getPropertyAsString())) found[0] = e.getProperty();
- super.visitPropertyExpression(e);
- }
-
- @Override
- public void visitMethodCallExpression(final MethodCallExpression e) {
- if (CARET.equals(e.getMethodAsString())) found[0] = e.getMethod();
- super.visitMethodCallExpression(e);
- }
- });
- return found[0];
- }
-
- /** Visits every class (including the script itself) of the source. */
- private void visit(final Visitor visitor) {
- if (module == null) return;
- for (final ClassNode c : module.getClasses()) {
- try {
- visitor.visitClass(c);
- }
- catch (final RuntimeException exc) {
- // NB: Malformed tree; skip this class.
- }
- }
- }
-
- private abstract class Visitor extends ClassCodeVisitorSupport {
-
- @Override
- protected SourceUnit getSourceUnit() {
- return source;
- }
- }
-}
diff --git a/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyCompletionTest.java
index c27c3d3..0d4b9b9 100644
--- a/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyCompletionTest.java
+++ b/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyCompletionTest.java
@@ -56,7 +56,8 @@
import org.scijava.script.ScriptService;
/**
- * Tests {@link GroovyServerPlugin}
+ * Tests completing Groovy scripts: as {@link GroovyDialect} turns them into
+ * plain Groovy, for scijava-code-lsp-groovy's server.
*
* @author Gabriel Selzer
*/
@@ -262,12 +263,6 @@ public void testClassNamesWithAutoImport() {
final CompletionItem list = items("def a = new ArrayLis").stream().filter(
c -> c.getLabel().equals("ArrayList")).findFirst().orElse(null);
assertTrue(list != null && list.getAdditionalTextEdits() == null);
- assertEquals(-1, GroovyLanguageServer.importOffset(
- "import java.util.concurrent.atomic.*\n",
- "java.util.concurrent.atomic.AtomicInteger"));
- // Inserted after the last import.
- assertEquals(20, GroovyLanguageServer.importOffset("import java.io.File\n" +
- "x = 1\n", "java.util.concurrent.atomic.AtomicInteger"));
}
@Test
diff --git a/src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyServerPlugin.java b/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialectTest.java
similarity index 52%
rename from src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyServerPlugin.java
rename to src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialectTest.java
index 1f8bd51..7e548a9 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyServerPlugin.java
+++ b/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialectTest.java
@@ -29,38 +29,55 @@
package org.scijava.plugins.scripting.groovy.completion;
-import org.eclipse.lsp4j.services.LanguageServer;
-import org.scijava.Priority;
-import org.scijava.code.lsp.Environment;
-import org.scijava.code.lsp.LanguageServerPlugin;
-import org.scijava.code.lsp.ScriptDialect;
-import org.scijava.plugin
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+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.script.ScriptLanguage;
+import org.scijava.script.ScriptService;
+
/**
- * Offers Groovy's in-process language server (see
- * {@link GroovyLanguageServer}), seeing scripts as plain Groovy (see
- * {@link GroovyDialect}).
+ * Tests {@link GroovyDialect}.
*
* @author Gabriel Selzer
*/
-@Plugin(type = LanguageServerPlugin.class, name = "Groovy",
- priority = Priority.HIGH)
-public class GroovyServerPlugin implements LanguageServerPlugin {
+public class GroovyDialectTest {
+
+ private Context context;
+ private ScriptLanguage groovy;
+
+ @Before
+ public void setUp() {
+ context = new Context(ScriptService.class, LanguageServerService.class);
+ groovy = context.service(ScriptService.class).getLanguageByName("Groovy");
+ }
- @Override
- public boolean supports(final String languageId) {
- // NB: Groovy scripts too, by their language's name.
- return "groovy".equals(languageId);
+ @After
+ public void tearDown() {
+ context.dispose();
}
- @Override
- public LanguageServer create(final String languageId,
- final Environment environment)
- {
- return new GroovyLanguageServer(environment);
+ @Test
+ public void testScriptsAreServed() {
+ // Groovy scripts become plain Groovy, which scijava-code-lsp-groovy
+ // serves.
+ final GroovyDialect dialect = new GroovyDialect();
+ assertTrue(dialect.supports(groovy));
+ assertEquals("groovy", dialect.languageId());
+ assertTrue(context.service(LanguageServerService.class).supports(groovy));
}
- @Override
- public ScriptDialect dialect() {
- return GroovyLanguageServer.DIALECT;
+ @Test
+ public void testTypes() {
+ final GroovyDialect dialect = new GroovyDialect();
+ assertEquals("java.util.List", dialect.runtimeType(java.util.List.class));
+ assertEquals("Object", dialect.runtimeType(null));
+ assertEquals("java.util.List items", dialect.declare("items",
+ "java.util.List"));
+ assertTrue(dialect.blanks("#@ String name"));
}
}
diff --git a/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyServerPluginTest.java b/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyServerPluginTest.java
deleted file mode 100644
index 1a365d3..0000000
--- a/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyServerPluginTest.java
+++ /dev/null
@@ -1,52 +0,0 @@
-package org.scijava.plugins.scripting.groovy.completion;
-
-import org.eclipse.lsp4j.services.LanguageServer;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-import org.scijava.Context;
-import org.scijava.code.lsp.Environment;
-import org.scijava.code.lsp.LanguageServerService;
-import org.scijava.script.ScriptLanguage;
-import org.scijava.script.ScriptService;
-
-import static org.junit.Assert.assertTrue;
-
-/**
- * Tests {@link GroovyServerPlugin}
- *
- * @author Gabriel Selzer
- */
-public class GroovyServerPluginTest {
-
- private Context context;
- private ScriptLanguage groovy;
- private LanguageServerService servers;
-
- @Before
- public void setUp() {
- context = new Context(ScriptService.class, LanguageServerService.class);
- groovy = context.service(ScriptService.class).getLanguageByName("Groovy");
- servers = context.service(LanguageServerService.class);
- }
-
- @After
- public void tearDown() {
- context.dispose();
- }
-
- @Test
- public void testServerIsDiscovered() {
- assertTrue(servers.supports(groovy));
- }
-
- @Test
- public void testServerLaunch() {
- LanguageServer server = servers.launch(
- groovy.getLanguageName().toLowerCase(),
- Environment.NONE
- );
- assertTrue(server instanceof GroovyLanguageServer);
- }
-}
-
From 818f3266aec1832847d11634ca51161788e09e39 Mon Sep 17 00:00:00 2001
From: Gabriel Selzer
Date: Fri, 16 Oct 2026 12:15:25 -0500
Subject: [PATCH 16/21] Test only the dialect: the server's tests move to
code-lsp-groovy
GroovyDialectTest keeps what needs SciJava scripts (#@ parameters).
Co-Authored-By: Claude Opus 5.5
---
.../completion/GroovyCompletionTest.java | 393 ------------------
.../groovy/completion/GroovyDialectTest.java | 57 ++-
2 files changed, 56 insertions(+), 394 deletions(-)
delete mode 100644 src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyCompletionTest.java
diff --git a/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyCompletionTest.java b/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyCompletionTest.java
deleted file mode 100644
index 0d4b9b9..0000000
--- a/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyCompletionTest.java
+++ /dev/null
@@ -1,393 +0,0 @@
-/*
- * #%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.assertFalse;
-import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertTrue;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.concurrent.TimeUnit;
-import java.util.stream.Collectors;
-
-import javax.script.ScriptEngine;
-
-import org.eclipse.lsp4j.CompletionItem;
-import org.eclipse.lsp4j.Diagnostic;
-import org.eclipse.lsp4j.SignatureHelp;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-import org.scijava.Context;
-import org.scijava.code.lsp.jvm.ClassIndex;
-import org.scijava.code.lsp.LanguageServerService;
-import org.scijava.code.lsp.ScriptSession;
-import org.scijava.script.ScriptLanguage;
-import org.scijava.script.ScriptService;
-
-/**
- * Tests completing Groovy scripts: as {@link GroovyDialect} turns them into
- * plain Groovy, for scijava-code-lsp-groovy's server.
- *
- * @author Gabriel Selzer
- */
-public class GroovyCompletionTest {
-
- private Context context;
- private ScriptLanguage groovy;
- private LanguageServerService servers;
-
- @Before
- public void setUp() {
- context = new Context(ScriptService.class, LanguageServerService.class);
- groovy = context.service(ScriptService.class).getLanguageByName("Groovy");
- servers = context.service(LanguageServerService.class);
- }
-
- @After
- public void tearDown() {
- context.dispose();
- }
-
- @Test
- public void testMembersOfInferredTypes() {
- assertTrue(complete("def s = 'hello'\ns.toUpp").contains("toUpperCase"));
- // Types flow through method calls...
- assertTrue(complete("def x = new ArrayList()\n" +
- "def y = x.subList(0, 1)\ny.isEm").contains("isEmpty"));
- assertTrue(complete("'abc'.toUpperCase().trim().len").contains("length"));
- // ...and through closures.
- assertTrue(complete("[1, 2].collect { it * 2 }.isEm").contains(
- "isEmpty"));
- // Declared types count too.
- assertTrue(complete("List items = null\nitems.isEm").contains("isEmpty"));
- // Inherited members are included.
- assertTrue(complete("def x = new ArrayList()\nx.containsA").contains(
- "containsAll"));
- }
-
- /**
- * Variables assigned without a declaration ({@code x = ...}) are not local
- * variables but script bindings. The static type checker sees as
- * dynamic (of unknown type) so {@link TypedAST} types them itself.
- */
- @Test
- public void testMembersOfBindings() {
- // Variables assigned without a declaration are typed as assigned...
- assertTrue(complete("z = \"abc\"\nz.toU").contains("toUpperCase"));
- assertTrue(complete("z = new ArrayList()\ny = z.subList(0, 1)\ny.isEm")
- .contains("isEmpty"));
- // ...as last assigned.
- final List reassigned = complete(
- "z = 'abc'\nz = new File('a')\nz.getN");
- assertTrue(reassigned.contains("getName"));
- // ...and offered with their type.
- final List items = items("zed = 'abc'\nx = ze");
- final CompletionItem zed = items.stream().filter(c -> "zed".equals(c
- .getLabel())).findFirst().orElse(null);
- assertTrue(String.valueOf(labels(items)), zed != null);
- assertEquals("java.lang.String zed", zed.getDetail());
- }
-
- @Test
- public void testDiagnostics() {
- // Syntax errors, and unresolvable classes: errors, where they are in the
- // script (after its parameter lines, too).
- assertEquals(Collections.singletonList("Error@1:11-12 Unexpected input: '+'"),
- problems("#@ double sigma\ndef x = (1 +\n"));
- assertEquals(Collections.singletonList(
- "Error@0:0-14 unable to resolve class foo.Bar"), problems(
- "import foo.Bar\nx = 1\n"));
- assertEquals(Collections.singletonList(
- "Error@0:0-9 unable to resolve class Foo"), problems("new Foo()\n"));
- // Members a known type lacks: warnings, also through calls and bindings.
- assertEquals(Collections.singletonList(
- "Warning@1:0-24 Cannot find matching method java.lang.String#fooBar()."),
- problems("def s = 'abc'\ns.toUpperCase().fooBar()\n"));
- assertEquals(Collections.singletonList(
- "Warning@1:0-10 Cannot find matching method java.lang.String#fooBar()."),
- problems("z = 'abc'\nz.fooBar()\n"));
- assertEquals(Collections.singletonList(
- "Warning@1:0-5 No such property: nme for class: java.io.File"), problems(
- "def f = new File('a')\nf.nme\n"));
- // Dynamic code, which Groovy resolves when the script runs: nothing.
- for (final String fine : new String[] { "x = 1\nprintln x\n",
- "#@ String name\nprintln name.toUpperCase()\n",
- "def m = [:]\nm.foo = 1\nprintln m.foo\n",
- "def o = something()\no.foo()\n", "def c = { it * 2 }\nc(3)\n",
- "def x\nx = 'a'\nx.size()\n", "[1, 2].collect { it }.sum()\n",
- "println undefinedThing\n", "class A { def go() { bar() } }\n" })
- {
- assertEquals(fine, Collections.emptyList(), problems(fine));
- }
- }
-
- @Test
- public void testCallablesAreSnippets() {
- final CompletionItem upper = items("def s = 'x'\ns.toUpp").stream().filter(
- c -> c.getLabelDetails() != null && "()".equals(c.getLabelDetails()
- .getDetail())).findFirst().orElse(null);
- assertTrue(upper != null);
- assertEquals("toUpperCase()", upper.getTextEdit().getLeft().getNewText());
- final CompletionItem locale = items("def s = 'x'\ns.toUpp").stream().filter(
- c -> "(java.util.Locale arg0)".equals(c.getLabelDetails().getDetail()) ||
- c.getLabelDetails().getDetail().startsWith("(java.util.Locale "))
- .findFirst().orElse(null);
- assertTrue(locale != null);
- assertTrue(locale.getTextEdit().getLeft().getNewText().startsWith(
- "toUpperCase(${1:"));
- assertEquals("java.lang.String", locale.getLabelDetails()
- .getDescription());
- }
-
- @Test
- public void testScriptParameters() {
- final String header = "#@ String name\n#@ int count\n";
- assertTrue(complete(header + "name.toUpp").contains("toUpperCase"));
- // Primitive parameters complete as their wrapper type.
- assertTrue(complete(header + "count.intV").contains("intValue"));
- assertTrue(complete(header + "def x = na").contains("name"));
- // Nothing is offered on the parameter lines themselves.
- assertTrue(complete("#@ String na").isEmpty());
- }
-
- @Test
- public void testStaticMembers() {
- final List texts = complete("Math.ma");
- assertTrue(texts.contains("max"));
- // Only static members of a class.
- assertFalse(complete("Integer.intV").contains("intValue"));
- assertTrue(complete("Integer.parseI").contains("parseInt"));
- }
-
- @Test
- public void testGroovyMethods() {
- // The GDK adds methods to Java classes.
- assertTrue(complete("[1, 2, 3].coll").contains("collect"));
- assertTrue(complete("def s = 'abc'\ns.rev").contains("reverse"));
- assertTrue(complete("new File('x').eachL").contains("eachLine"));
- final CompletionItem each = items("[1, 2, 3].eac").stream().filter(
- c -> c.getLabel().equals("each")).findFirst().orElse(null);
- assertTrue(each != null && each.getDetail().startsWith("GDK"));
- }
-
- @Test
- public void testScriptClasses() {
- final List texts = complete("class Foo {\n" + //
- " int bar\n" + //
- " String baz() { 'x' }\n" + //
- "}\n" + //
- "def f = new Foo()\n" + //
- "f.ba");
- assertTrue(texts.toString(), texts.contains("bar"));
- assertTrue(texts.toString(), texts.contains("baz"));
- }
-
- @Test
- public void testVariableScopes() {
- assertTrue(complete("def greeting = 'hi'\ndef x = gree").contains(
- "greeting"));
- // Closure parameters are visible inside the closure...
- assertTrue(complete("[1].each { item ->\n ite").contains("item"));
- // ...but not variables declared in a closure that has ended.
- assertFalse(complete("[1].each { def inner = it }\ninn").contains(
- "inner"));
- // Nor variables declared after the caret.
- assertFalse(complete("def a = lat").contains("later"));
- // For loop variables are visible in the loop.
- assertTrue(complete("for (element in [1, 2]) {\n elem").contains(
- "element"));
- }
-
- @Test
- public void testIncompleteCode() {
- // Unclosed brackets before the caret.
- assertTrue(complete("def s = 'x'\nif (true) {\n s.toUpp").contains(
- "toUpperCase"));
- assertTrue(complete("def s = 'x'\nprintln(s.toUpp").contains(
- "toUpperCase"));
- // Unresolvable classes do not prevent type inference.
- assertTrue(complete("def z = new NoSuchClass()\ndef s = 'x'\ns.toUpp")
- .contains("toUpperCase"));
- // A syntax error above the caret: the caret's line is still analyzed.
- assertTrue(complete("def a = (]\n'abc'.toUpp").contains("toUpperCase"));
- }
-
- @Test
- public void testKeywords() {
- assertTrue(complete("ret").contains("return"));
- assertFalse(complete("def x = 1.").contains("return"));
- }
-
- @Test
- public void testClassNamesWithAutoImport() {
- ClassIndex.ensureCache();
- final CompletionItem atomic = items("def a = new AtomicInt").stream()
- .filter(c -> c.getLabel().equals("AtomicInteger")).findFirst().orElse(
- null);
- assertTrue(atomic != null);
- assertEquals(1, atomic.getAdditionalTextEdits().size());
- assertEquals("import java.util.concurrent.atomic.AtomicInteger\n", atomic
- .getAdditionalTextEdits().get(0).getNewText());
- // Already imported (or imported by default): no edit.
- final CompletionItem list = items("def a = new ArrayLis").stream().filter(
- c -> c.getLabel().equals("ArrayList")).findFirst().orElse(null);
- assertTrue(list != null && list.getAdditionalTextEdits() == null);
- }
-
- @Test
- public void testImports() {
- ClassIndex.ensureCache();
- assertTrue(complete("import java.util.concurrent.atomic.AtomicInt")
- .contains("java.util.concurrent.atomic.AtomicInteger"));
- assertTrue(complete("java.util.concurrent.atomic.AtomicInt").contains(
- "AtomicInteger"));
- }
-
- @Test
- public void testStringsAndComments() {
- assertTrue(complete("def s = 'a.b.").isEmpty());
- assertTrue(complete("// s.toUpp").isEmpty());
- assertTrue(complete("/* x.").isEmpty());
- }
-
- @Test
- public void testInterpreterBindings() throws Exception {
- final ScriptEngine engine = groovy.getScriptEngine();
- engine.put("injected", new ArrayList());
- final List texts = labels(items(servers.session(groovy, engine
- .getContext()), "injected.si"));
- assertTrue(texts.toString(), texts.contains("size"));
- }
-
- @Test
- public void testSignatureHelp() {
- // A decimal (a BigDecimal) fits the floating-point overloads, which come
- // first; the integral ones do not. The caret is in the second argument.
- final SignatureHelp max = help("Math.max(1.5, ");
- assertEquals(Integer.valueOf(1), max.getActiveParameter());
- assertEquals("[double, float, int, long]", describe(max));
- // An integer fits the integral overloads, and converts to the others.
- assertEquals("[int, long, double, float]", describe(help(
- "Math.max(1, 2")));
- // Nothing typed yet: all overloads.
- assertEquals(4, help("Math.max(").getSignatures().size());
- // Arguments' types come from the analysis, e.g. of variables.
- final SignatureHelp indexOf = help("def s = 'abc'\ndef c = 'b'\n" +
- "s.indexOf(c, ");
- assertEquals("java.lang.String", describe(indexOf).replaceAll(
- "^\\[([^,]*),.*", "$1"));
- // Constructors, and the script's own functions.
- assertTrue(describe(help("new StringBuilder(")).contains(
- "java.lang.String"));
- final SignatureHelp area = help("def area(int w, int h) { w * h }\n" +
- "area(3, ");
- assertEquals("[int]", describe(area));
- // The innermost call, and none outside calls.
- assertEquals(Integer.valueOf(0), help("Math.max(Math.abs(")
- .getActiveParameter());
- assertNull(help("def x = 1"));
- }
-
- @Test
- public void testArgumentChoices() {
- // In a call's arguments: the variables fitting the parameter first.
- final List items = items(
- "def a = new ArrayList()\ndef s = 'x'\nCollections.sort(");
- assertEquals("a", items.get(0).getLabel());
- assertEquals(Boolean.TRUE, items.get(0).getPreselect());
- assertTrue(labels(items).contains("s"));
- }
-
- // -- Helper methods --
-
- /** The problems found in a script, e.g. {@code Error@0:4-6 message}. */
- private List problems(final String code) {
- final List found;
- try (ScriptSession session = servers.session(groovy)) {
- found = session.update(code).diagnostics(20_000);
- }
- return found.stream().map(d -> d.getSeverity() + "@" + d.getRange().getStart()
- .getLine() + ":" + d.getRange().getStart().getCharacter() + "-" + d
- .getRange().getEnd().getCharacter() + " " + d.getMessage().getLeft())
- .collect(Collectors.toList());
- }
-
- private SignatureHelp help(final String code) {
- try (ScriptSession session = servers.session(groovy)) {
- return session.signatureHelp(code, code.length()).get(30,
- TimeUnit.SECONDS);
- }
- catch (final Exception exc) {
- throw new RuntimeException(exc);
- }
- }
-
- /** Each signature's first parameter type, in order. */
- private static String describe(final SignatureHelp help) {
- return help.getSignatures().stream().map(s -> {
- final String first = s.getParameters().isEmpty() ? "()" : s
- .getParameters().get(0).getLabel().getLeft();
- final String type = first.contains(" ") ? first.substring(0, first
- .lastIndexOf(' ')) : first;
- return type;
- }).collect(Collectors.toList()).toString();
- }
-
- private List items(final String code) {
- return items(servers.session(groovy), code);
- }
-
- private static List items(final ScriptSession session,
- final String code)
- {
- try (ScriptSession s = session) {
- final List items = new ArrayList<>(s.completion(
- code, code.length()).get(30, TimeUnit.SECONDS).getItems());
- items.sort((a, b) -> a.getSortText().compareTo(b.getSortText()));
- return items;
- }
- catch (final Exception exc) {
- throw new RuntimeException(exc);
- }
- }
-
- private List complete(final String code) {
- return labels(items(code));
- }
-
- private static List labels(final List items) {
- return items.stream().map(CompletionItem::getLabel).collect(Collectors
- .toList());
- }
-}
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
index 7e548a9..14e340d 100644
--- a/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialectTest.java
+++ b/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialectTest.java
@@ -32,16 +32,25 @@
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+import org.eclipse.lsp4j.CompletionItem;
+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.ScriptSession;
import org.scijava.script.ScriptLanguage;
import org.scijava.script.ScriptService;
/**
- * Tests {@link GroovyDialect}.
+ * Tests {@link GroovyDialect}: Groovy scripts, as plain Groovy for
+ * scijava-code-lsp-groovy's server (which its own tests test).
*
* @author Gabriel Selzer
*/
@@ -80,4 +89,50 @@ public void testTypes() {
"java.util.List"));
assertTrue(dialect.blanks("#@ String name"));
}
+
+ @Test
+ public void testScriptParameters() throws Exception {
+ final String header = "#@ String name\n#@ int count\n";
+ assertTrue(complete(header + "name.toUpp").contains("toUpperCase"));
+ // Primitive parameters complete as their wrapper type.
+ assertTrue(complete(header + "count.intV").contains("intValue"));
+ assertTrue(complete(header + "def x = na").contains("name"));
+ // Nothing is offered on the parameter lines themselves.
+ assertTrue(complete("#@ String na").isEmpty());
+ }
+
+ @Test
+ public void testDiagnostics() {
+ // Where they are in the script, after its parameter lines.
+ assertEquals(Collections.singletonList("1:11 Unexpected input: '+'"),
+ problems("#@ double sigma\ndef x = (1 +\n"));
+ // Parameters are declared, so using them is fine.
+ assertEquals(Collections.emptyList(), problems(
+ "#@ String name\nprintln name.toUpperCase()\n"));
+ }
+
+ // -- Helper methods --
+
+ private ScriptSession session() {
+ return context.service(LanguageServerService.class).session(groovy);
+ }
+
+ private List complete(final String code) throws Exception {
+ try (ScriptSession session = session()) {
+ return session.completion(code, code.length()).get(30, TimeUnit.SECONDS)
+ .getItems().stream().map(CompletionItem::getLabel).collect(Collectors
+ .toList());
+ }
+ }
+
+ /** The problems found in a script, e.g. {@code 0:4 message}. */
+ private List problems(final String code) {
+ final List found;
+ try (ScriptSession session = session()) {
+ found = session.update(code).diagnostics(20_000);
+ }
+ return found.stream().map(d -> d.getRange().getStart().getLine() + ":" + d
+ .getRange().getStart().getCharacter() + " " + d.getMessage().getLeft())
+ .collect(Collectors.toList());
+ }
}
From a06d9b624d09fc64488219abe0477ed234584fea Mon Sep 17 00:00:00 2001
From: Gabriel Selzer
Date: Fri, 16 Oct 2026 13:39:55 -0500
Subject: [PATCH 17/21] Say what GroovyDialectTest's diagnostics test checks
The server sees the parameters declared in a line of their own and the
parameter lines blanked: one assertion checks that problems still map
back to the script's lines, the other that the blanking happens.
Co-Authored-By: Claude Opus 5.5
---
.../groovy/completion/GroovyDialectTest.java | 15 ++++++++++-----
1 file changed, 10 insertions(+), 5 deletions(-)
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
index 14e340d..604fa40 100644
--- a/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialectTest.java
+++ b/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialectTest.java
@@ -49,8 +49,7 @@
import org.scijava.script.ScriptService;
/**
- * Tests {@link GroovyDialect}: Groovy scripts, as plain Groovy for
- * scijava-code-lsp-groovy's server (which its own tests test).
+ * Tests {@link GroovyDialect}
*
* @author Gabriel Selzer
*/
@@ -70,6 +69,10 @@ public void tearDown() {
context.dispose();
}
+ /**
+ * Unit tests the adherence of {@link GroovyDialect} to
+ * {@link org.scijava.plugins.scripting.groovy.GroovyScriptLanguage}
+ */
@Test
public void testScriptsAreServed() {
// Groovy scripts become plain Groovy, which scijava-code-lsp-groovy
@@ -77,12 +80,12 @@ public void testScriptsAreServed() {
final GroovyDialect dialect = new GroovyDialect();
assertTrue(dialect.supports(groovy));
assertEquals("groovy", dialect.languageId());
- assertTrue(context.service(LanguageServerService.class).supports(groovy));
}
@Test
public void testTypes() {
final GroovyDialect dialect = new GroovyDialect();
+ // Script parameters should stay the same type
assertEquals("java.util.List", dialect.runtimeType(java.util.List.class));
assertEquals("Object", dialect.runtimeType(null));
assertEquals("java.util.List items", dialect.declare("items",
@@ -103,10 +106,12 @@ public void testScriptParameters() throws Exception {
@Test
public void testDiagnostics() {
- // Where they are in the script, after its parameter lines.
+ // The server sees the parameters declared in a line of its own, above
+ // the script: problems are still where they are in the script.
assertEquals(Collections.singletonList("1:11 Unexpected input: '+'"),
problems("#@ double sigma\ndef x = (1 +\n"));
- // Parameters are declared, so using them is fine.
+ // The server sees the parameter lines blanked: as Groovy, they would be
+ // syntax errors ("Unexpected character: '#'").
assertEquals(Collections.emptyList(), problems(
"#@ String name\nprintln name.toUpperCase()\n"));
}
From d20b6f3dfc2ac43c397887de44bc8f6e5b03d4b2 Mon Sep 17 00:00:00 2001
From: Gabriel Selzer
Date: Fri, 16 Oct 2026 14:27:08 -0500
Subject: [PATCH 18/21] Test the dialect itself, and need the server only to
test
Follow scijava-code-lsp's rename of runtimeType to parameterType. The
dialect's tests now test each of its methods; what the server and
scijava-code-lsp do is theirs to test, but for one integration test
that the rewritten scripts are valid Groovy. So scijava-code-lsp-groovy
is needed only to test: applications choose their servers.
Co-Authored-By: Claude Opus 5.5
---
pom.xml | 4 +-
.../groovy/completion/GroovyDialect.java | 5 +-
.../groovy/completion/GroovyDialectTest.java | 91 +++++++------------
3 files changed, 37 insertions(+), 63 deletions(-)
diff --git a/pom.xml b/pom.xml
index f6de2fa..143b4b2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -119,8 +119,8 @@
org.scijava
scijava-code-lsp-groovy
${scijava-code-lsp-groovy.version}
-
- runtime
+
+ 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
index 768f105..578f432 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialect.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialect.java
@@ -55,8 +55,9 @@ public String languageId() {
}
@Override
- public String runtimeType(final Class> type) {
- final String name = type == null ? null : type.getCanonicalName();
+ public String parameterType(final Class> type) {
+ // NB: Anonymous and local classes have no name to write.
+ final String name = type.getCanonicalName();
return name == null ? "Object" : name;
}
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
index 604fa40..8585cb7 100644
--- a/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialectTest.java
+++ b/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialectTest.java
@@ -32,12 +32,8 @@
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
-import java.util.Collections;
import java.util.List;
-import java.util.concurrent.TimeUnit;
-import java.util.stream.Collectors;
-import org.eclipse.lsp4j.CompletionItem;
import org.eclipse.lsp4j.Diagnostic;
import org.junit.After;
import org.junit.Before;
@@ -55,11 +51,13 @@
*/
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");
}
@@ -69,75 +67,50 @@ public void tearDown() {
context.dispose();
}
- /**
- * Unit tests the adherence of {@link GroovyDialect} to
- * {@link org.scijava.plugins.scripting.groovy.GroovyScriptLanguage}
- */
@Test
- public void testScriptsAreServed() {
- // Groovy scripts become plain Groovy, which scijava-code-lsp-groovy
- // serves.
- final GroovyDialect dialect = new GroovyDialect();
+ public void testScriptsLanguageAdherence() {
+ // Ensure the dialect supports the Groovy Script Language
assertTrue(dialect.supports(groovy));
- assertEquals("groovy", dialect.languageId());
}
@Test
- public void testTypes() {
- final GroovyDialect dialect = new GroovyDialect();
+ public void testParameterType() {
// Script parameters should stay the same type
- assertEquals("java.util.List", dialect.runtimeType(java.util.List.class));
- assertEquals("Object", dialect.runtimeType(null));
- assertEquals("java.util.List items", dialect.declare("items",
- "java.util.List"));
- assertTrue(dialect.blanks("#@ String name"));
+ assertEquals("java.util.List", dialect.parameterType(
+ java.util.List.class));
+ // Anonymous classes have no name to write.
+ assertEquals("Object", dialect.parameterType(new Object() {
+ }
+ .getClass()));
}
@Test
- public void testScriptParameters() throws Exception {
- final String header = "#@ String name\n#@ int count\n";
- assertTrue(complete(header + "name.toUpp").contains("toUpperCase"));
- // Primitive parameters complete as their wrapper type.
- assertTrue(complete(header + "count.intV").contains("intValue"));
- assertTrue(complete(header + "def x = na").contains("name"));
- // Nothing is offered on the parameter lines themselves.
- assertTrue(complete("#@ String na").isEmpty());
+ public void testDeclare() {
+ // A SciJava Script parameter with type "java.util.List" and name "items"
+ // should be declared in Groovy code as "java.util.List items"
+ assertEquals(
+ "java.util.List items",
+ dialect.declare("items", "java.util.List")
+ );
}
@Test
- public void testDiagnostics() {
- // The server sees the parameters declared in a line of its own, above
- // the script: problems are still where they are in the script.
- assertEquals(Collections.singletonList("1:11 Unexpected input: '+'"),
- problems("#@ double sigma\ndef x = (1 +\n"));
- // The server sees the parameter lines blanked: as Groovy, they would be
- // syntax errors ("Unexpected character: '#'").
- assertEquals(Collections.emptyList(), problems(
- "#@ String name\nprintln name.toUpperCase()\n"));
- }
-
- // -- Helper methods --
-
- private ScriptSession session() {
- return context.service(LanguageServerService.class).session(groovy);
- }
-
- private List complete(final String code) throws Exception {
- try (ScriptSession session = session()) {
- return session.completion(code, code.length()).get(30, TimeUnit.SECONDS)
- .getItems().stream().map(CompletionItem::getLabel).collect(Collectors
- .toList());
- }
+ public void testBlanks() {
+ // The only lines that need to be blanked are SciJava script parameters
+ assertTrue(dialect.blanks("#@ String name"));
}
- /** The problems found in a script, e.g. {@code 0:4 message}. */
- private List problems(final String code) {
- final List found;
- try (ScriptSession session = session()) {
- found = session.update(code).diagnostics(20_000);
+ @Test
+ public void testGroovyServerIntegration() {
+ // With scijava-code-lsp-groovy's server: the parameters' declarations,
+ // and the blanked parameter lines, parse.
+ try (ScriptSession session = context.service(LanguageServerService.class)
+ .session(groovy))
+ {
+ final List problems = session.update(
+ "#@ String name\n#@ int count\nprintln name * count\n").diagnostics(
+ 20_000);
+ assertTrue(problems.toString(), problems.isEmpty());
}
- return found.stream().map(d -> d.getRange().getStart().getLine() + ":" + d
- .getRange().getStart().getCharacter() + " " + d.getMessage().getLeft())
- .collect(Collectors.toList());
}
}
From d3b34d674b7c58e23967a6fd3b0556af5e26e229 Mon Sep 17 00:00:00 2001
From: Gabriel Selzer
Date: Fri, 16 Oct 2026 17:26:03 -0500
Subject: [PATCH 19/21] Follow scijava-code-lsp's ScriptDialect.header
The dialect writes its parameters' declarations in one header line;
scijava-code-lsp now blanks the #@ lines itself, as SciJava removes them
before a script runs.
Co-Authored-By: Claude Opus 5.5
---
.../groovy/completion/GroovyDialect.java | 41 ++++++++++++-------
.../groovy/completion/GroovyDialectTest.java | 38 +++++++----------
2 files changed, 41 insertions(+), 38 deletions(-)
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
index 578f432..02c0a4b 100644
--- a/src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialect.java
+++ b/src/main/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialect.java
@@ -29,15 +29,19 @@
package org.scijava.plugins.scripting.groovy.completion;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
import org.scijava.code.lsp.ScriptDialect;
import org.scijava.plugin.Plugin;
import org.scijava.plugins.scripting.groovy.GroovyScriptLanguage;
import org.scijava.script.ScriptLanguage;
/**
- * Turns Groovy scripts into plain Groovy, for {@code groovy} language servers
- * (e.g. scijava-code-lsp-groovy's): their parameters declared as Java objects,
- * their {@code #@} lines blanked.
+ * 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
*/
@@ -55,19 +59,26 @@ public String languageId() {
}
@Override
- public String parameterType(final Class> type) {
- // NB: Anonymous and local classes have no name to write.
- final String name = type.getCanonicalName();
- return name == null ? "Object" : name;
+ public Header header(final Map> parameters) {
+ // Declare the types of each parameter for type reasoning; Groovy gets
+ // the very Java objects, named in full, so nothing needs importing
+ final List declarations = new ArrayList<>();
+ parameters.forEach((name, type) -> declarations.add(parameterType(
+ type) + " " + name));
+ // Combine into a Header
+ return new Header(String.join("; ", declarations), Collections
+ .emptySet());
}
- @Override
- public String declare(final String name, final String type) {
- return type + " " + name;
- }
-
- @Override
- public boolean blanks(final String line) {
- return line.trim().startsWith("#");
+ /**
+ * @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;
}
}
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
index 8585cb7..203db31 100644
--- a/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialectTest.java
+++ b/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialectTest.java
@@ -32,7 +32,9 @@
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 org.eclipse.lsp4j.Diagnostic;
import org.junit.After;
@@ -40,6 +42,7 @@
import org.junit.Test;
import org.scijava.Context;
import org.scijava.code.lsp.LanguageServerService;
+import org.scijava.code.lsp.ScriptDialect.Header;
import org.scijava.code.lsp.ScriptSession;
import org.scijava.script.ScriptLanguage;
import org.scijava.script.ScriptService;
@@ -74,30 +77,19 @@ public void testScriptsLanguageAdherence() {
}
@Test
- public void testParameterType() {
- // Script parameters should stay the same type
- assertEquals("java.util.List", dialect.parameterType(
- java.util.List.class));
+ 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", java.util.List.class);
+ parameters.put("count", int.class);
// Anonymous classes have no name to write.
- assertEquals("Object", dialect.parameterType(new Object() {
- }
- .getClass()));
- }
-
- @Test
- public void testDeclare() {
- // A SciJava Script parameter with type "java.util.List" and name "items"
- // should be declared in Groovy code as "java.util.List items"
- assertEquals(
- "java.util.List items",
- dialect.declare("items", "java.util.List")
- );
- }
-
- @Test
- public void testBlanks() {
- // The only lines that need to be blanked are SciJava script parameters
- assertTrue(dialect.blanks("#@ String name"));
+ parameters.put("thing", 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
From 2c4e64b0bf538d0e7b3f24918750629aa7e64bc0 Mon Sep 17 00:00:00 2001
From: Gabriel Selzer
Date: Tue, 20 Oct 2026 09:50:45 -0500
Subject: [PATCH 20/21] Follow scijava-code-lsp: sessions are made with the
service
Co-Authored-By: Claude Opus 5.5
---
.../scripting/groovy/completion/GroovyDialectTest.java | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
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
index 203db31..0648b9c 100644
--- a/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialectTest.java
+++ b/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialectTest.java
@@ -96,8 +96,8 @@ public void testHeader() {
public void testGroovyServerIntegration() {
// With scijava-code-lsp-groovy's server: the parameters' declarations,
// and the blanked parameter lines, parse.
- try (ScriptSession session = context.service(LanguageServerService.class)
- .session(groovy))
+ try (ScriptSession session = new ScriptSession(context.service(
+ LanguageServerService.class), groovy))
{
final List problems = session.update(
"#@ String name\n#@ int count\nprintln name * count\n").diagnostics(
From 19bee5b723de553a45f373f542667269ffed6e06 Mon Sep 17 00:00:00 2001
From: Gabriel Selzer
Date: Wed, 21 Oct 2026 11:27:14 -0500
Subject: [PATCH 21/21] Follow scijava-code-lsp: problems arrive through the
listener
Co-Authored-By: Claude Opus 5.5
---
.../groovy/completion/GroovyDialectTest.java | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
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
index 0648b9c..d9aafc6 100644
--- a/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialectTest.java
+++ b/src/test/java/org/scijava/plugins/scripting/groovy/completion/GroovyDialectTest.java
@@ -35,6 +35,8 @@
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;
@@ -93,15 +95,17 @@ public void testHeader() {
}
@Test
- public void testGroovyServerIntegration() {
+ 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))
{
- final List problems = session.update(
- "#@ String name\n#@ int count\nprintln name * count\n").diagnostics(
- 20_000);
+ 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());
}
}