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 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: + *

    + *
  1. Text after the caret is omitted
  2. + *
  3. The identifier {@link #CARET} (see {@link #markCaret}) is inserted + * at the caret position, so that it can be found in the syntax tree + *
  4. + *
  5. Any brackets left open are closed to pass parsing
  6. + *
*

* * @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: - *

    - *
  1. Text after the caret is omitted
  2. - *
  3. The identifier {@link #CARET} (see {@link #markCaret}) is inserted - * at the caret position, so that it can be found in the syntax tree - *
  4. - *
  5. Any brackets left open are closed to pass parsing
  6. - *
- *

- * - * @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 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()); } }