diff --git a/pom.xml b/pom.xml
index c86ef69cc..6eaad0780 100644
--- a/pom.xml
+++ b/pom.xml
@@ -57,6 +57,10 @@
scijava-taglets
+ scijava-code-lsp
+ scijava-code-lsp-groovy
+ scijava-code-lsp-jedi
+ scijava-code-lsp-jythonscijava-collectionsscijava-common3scijava-concurrent
diff --git a/scijava-code-lsp-groovy/README.md b/scijava-code-lsp-groovy/README.md
new file mode 100644
index 000000000..db8fc23d2
--- /dev/null
+++ b/scijava-code-lsp-groovy/README.md
@@ -0,0 +1,5 @@
+# SciJava Code LSP Groovy
+
+An in-process language server for `groovy` (`GroovyLanguageServer`, offered to `scijava-code-lsp` by `GroovyServerPlugin`), driven by Groovy's own compiler, which types the code statically as far as it can (see `TypedAST`).
+
+It knows plain Groovy only. To use this language server with SciJava scripts, see [scripting-groovy](https://github.com/scijava/scripting-groovy).
diff --git a/scijava-code-lsp-groovy/pom.xml b/scijava-code-lsp-groovy/pom.xml
new file mode 100644
index 000000000..5f00f7053
--- /dev/null
+++ b/scijava-code-lsp-groovy/pom.xml
@@ -0,0 +1,138 @@
+
+
+ 4.0.0
+
+
+ org.scijava
+ pom-scijava
+ 45.1.0
+
+
+
+ scijava-code-lsp-groovy
+ 0.1.0-SNAPSHOT
+
+ SciJava Code LSP Groovy
+ Groovy code intelligence for SciJava, via Groovy's own compiler.
+ https://github.com/scijava/scijava
+ 2026
+
+ SciJava
+ https://scijava.org/
+
+
+
+ Simplified BSD License
+ repo
+
+
+
+
+
+ gselzer
+ Gabriel Selzer
+ https://imagej.net/people/gselzer
+
+ founder
+ lead
+ developer
+ debugger
+ reviewer
+ support
+ maintainer
+
+
+
+
+
+ None
+
+
+
+
+
+ Image.sc Forum
+ https://forum.image.sc/tag/scijava
+
+
+
+
+ scm:git:https://github.com/scijava/scijava
+ scm:git:git@github.com:scijava/scijava
+ HEAD
+ https://github.com/scijava/scijava
+
+
+ GitHub Issues
+ https://github.com/scijava/scijava/issues
+
+
+ GitHub Actions
+ https://github.com/scijava/scijava/actions
+
+
+
+ org.scijava.code.lsp.groovy
+ bsd_2
+ SciJava developers.
+
+
+ 2.101.0
+ 0.1.0-SNAPSHOT
+
+
+
+
+
+ org.scijava
+ scijava-common
+
+
+ org.scijava
+ scijava-code-lsp
+ ${scijava-code-lsp.version}
+
+
+
+
+ org.apache.groovy
+ groovy
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ test
+
+
+
+
+
+
+ maven-compiler-plugin
+
+
+
+ org.scijava
+ scijava-common
+ ${scijava-common.version}
+
+
+
+
+
+ maven-surefire-plugin
+
+
+ false
+
+
+
+
+
diff --git a/scijava-code-lsp-groovy/src/main/java/module-info.java b/scijava-code-lsp-groovy/src/main/java/module-info.java
new file mode 100644
index 000000000..9a03e54fb
--- /dev/null
+++ b/scijava-code-lsp-groovy/src/main/java/module-info.java
@@ -0,0 +1,5 @@
+open module org.scijava.code.lsp.groovy {
+ requires org.scijava.code.lsp;
+ requires org.apache.groovy;
+ requires java.scripting;
+}
diff --git a/scijava-code-lsp-groovy/src/main/java/org/scijava/code/lsp/groovy/GroovyLanguageServer.java b/scijava-code-lsp-groovy/src/main/java/org/scijava/code/lsp/groovy/GroovyLanguageServer.java
new file mode 100644
index 000000000..f70b3451f
--- /dev/null
+++ b/scijava-code-lsp-groovy/src/main/java/org/scijava/code/lsp/groovy/GroovyLanguageServer.java
@@ -0,0 +1,703 @@
+/*-
+ * #%L
+ * Groovy code intelligence for SciJava, via Groovy's own compiler.
+ * %%
+ * Copyright (C) 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.code.lsp.groovy;
+
+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.TreeSet;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import groovy.lang.GroovySystem;
+import groovy.lang.MetaMethod;
+import groovyjarjarantlr4.v4.runtime.Vocabulary;
+
+import org.apache.groovy.parser.antlr4.GroovyLexer;
+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.codehaus.groovy.control.ResolveVisitor;
+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.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 Set KEYWORDS = keywords();
+
+ /** Packages Groovy imports by default, e.g. "java.util." */
+ private static final List DEFAULT_IMPORTS = Arrays.asList(
+ ResolveVisitor.DEFAULT_IMPORTS);
+
+ /** 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."
+ * (There's nothing to autocomplete when typing out a number)
+ */
+ private static final Pattern NUMBER_DOT = Pattern.compile(
+ "(? none = new ArrayList<>();
+
+ // An import being typed, e.g. "import java.ut": complete the package or
+ // class name, replacing all of it. (Static imports aren't completed.)
+ 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));
+ }
+
+ // Otherwise, find the word/prefix being typed at the caret
+ 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());
+ // If a dot precedes the word, it's a member of what precedes it
+ // (e.g. "s.", "5.ti") OR it's a range ("1..") OR it's a decimal value
+ // ("1.", "1.5")
+ final boolean decimal = NUMBER_DOT.matcher(before).find() && (prefix
+ .isEmpty() || Character.isDigit(prefix.charAt(0)));
+ final boolean member = before.trim().endsWith(".") && !before.trim()
+ .endsWith("..") && !decimal;
+ // If it's not a member, there are two cases with less work to do:
+ if (!member && prefix.isEmpty()) {
+ // Nothing typed yet: only a call's arguments are worth offering, e.g.
+ // "max(" offers the variables in scope, those fitting the parameter
+ // first. (Elsewhere, including after "1." or "1..", nothing is.)
+ return list(doc, caret, caret, argumentChoices(doc, caret, line));
+ }
+ if (!member && Character.isDigit(prefix.charAt(0))) {
+ // Number or range - nothing to complete
+ return list(doc, caret, caret, none);
+ }
+
+ // Analyze the script up to the prefix, to know the types and variables
+ // there. NB: The prefix is left out, as it's incomplete code.
+ final TypedAST ast = typedAST(text.substring(0, replaceStart), line
+ .substring(0, line.length() - prefix.length()));
+ if (ast == null) return list(doc, caret, caret, none); // string, comment
+
+ // After a dot: the members of the type before it (e.g. String's for
+ // "s."). Otherwise: the names in scope, keywords, and class names.
+ final List items = new ArrayList<>();
+ if (member) items.addAll(memberCompletions(ast, before, prefix));
+ else {
+ items.addAll(variableCompletions(ast.variablesAtCaret(), 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) {
+ // The caret isn't in a call (or is in a string or comment)
+ return null;
+ }
+ final List callables = callables(call.ast, call.call);
+ if (callables.isEmpty()) {
+ // Nothing the call could be calling is known, e.g. the receiver's
+ // type is unknown, or nothing of the method's name exists
+ return null;
+ }
+ // Sort callables by their parameter types, so that equally good fits
+ // are always in the same order.
+ callables.sort(Comparator.comparing(Callable::parameterTypes));
+
+ // The types of the arguments typed so far (the one at the caret, being
+ // typed, isn't known yet)
+ 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)));
+ }
+ // Return callables in order of best match to those arguments (the
+ // first is shown)
+ 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()}).
+ */
+ @Override
+ protected List diagnose(final Document doc) {
+ final String text = doc.text();
+ final TypedAST ast = TypedAST.of(text, loader());
+ final List found = new ArrayList<>();
+ for (final TypedAST.Problem p : ast.problems()) {
+ final int start = offset(text, p.line, p.column);
+ int end = offset(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 TypedAST ast = typedAST(text.substring(0, start), 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))));
+ }
+ 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 String textBefore,
+ final String lineBefore)
+ {
+ final String code = TypedAST.closeBrackets(TypedAST.markCaret(
+ textBefore));
+ if (code == null) return null;
+ final TypedAST ast = TypedAST.of(code, loader());
+ if (ast.isParsed()) return ast;
+ final String lineCode = TypedAST.closeBrackets(TypedAST.markCaret(
+ lineBefore));
+ 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 String prefix)
+ {
+ final List out = new ArrayList<>();
+ // Innermost (most recently declared) variables first.
+ final List order = new ArrayList<>(vars.keySet());
+ Collections.reverse(order);
+ for (final String name : order) {
+ if (!matches(name, prefix) || name.equals(prefix)) continue;
+ final ClassNode type = vars.get(name);
+ out.add(variable(name, type != null ? type.toString(false) : "Object"));
+ }
+ return out;
+ }
+
+ /** Groovy's keywords */
+ private static Set keywords() {
+ // NB: The lexer literals don't contain primitives - we add those ourselves
+ final Set out = new TreeSet<>(Arrays.asList("true", "false",
+ "boolean", "byte", "char", "short", "int", "long", "float", "double"));
+ final Vocabulary v = GroovyLexer.VOCABULARY;
+ for (int i = 0; i <= v.getMaxTokenType(); i++) {
+ // NB: The vocabulary has every kind of token: keywords, operators and
+ // punctuation, which have fixed text (their literal name, quoted, e.g.
+ // "'class'"), plus identifiers, strings and numbers, which don't
+ // (null). We want just the literals that are words, i.e. keywords
+ // (with hyphens, as in "non-sealed").
+ final String literal = v.getLiteralName(i);
+ if (literal == null) continue;
+ final String word = literal.substring(1, literal.length() - 1);
+ if (word.matches("[a-z][a-z-]*")) out.add(word);
+ }
+ return out;
+ }
+
+ 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.
+ */
+ private static int importOffset(final String text,
+ final String className)
+ {
+ final String pkg = className.substring(0, Math.max(0, className
+ .lastIndexOf('.')));
+ // NB: Groovy also imports BigDecimal and BigInteger, by name.
+ 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;
+ }
+
+ 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 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;
+ }
+
+ /** 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/scijava-code-lsp-groovy/src/main/java/org/scijava/code/lsp/groovy/GroovyServerPlugin.java b/scijava-code-lsp-groovy/src/main/java/org/scijava/code/lsp/groovy/GroovyServerPlugin.java
new file mode 100644
index 000000000..cb627e1b2
--- /dev/null
+++ b/scijava-code-lsp-groovy/src/main/java/org/scijava/code/lsp/groovy/GroovyServerPlugin.java
@@ -0,0 +1,60 @@
+/*-
+ * #%L
+ * Groovy code intelligence for SciJava, via Groovy's own compiler.
+ * %%
+ * Copyright (C) 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.code.lsp.groovy;
+
+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.plugin.Plugin;
+
+/**
+ * Offers Groovy's in-process language server (see
+ * {@link GroovyLanguageServer}), for {@code groovy}: plain Groovy code, e.g.
+ * as SciJava's Groovy scripts become (see scripting-groovy).
+ *
+ * @author Gabriel Selzer
+ */
+@Plugin(type = LanguageServerPlugin.class, name = "Groovy",
+ priority = Priority.HIGH)
+public class GroovyServerPlugin implements LanguageServerPlugin {
+
+ @Override
+ public boolean supports(final String languageId) {
+ return "groovy".equals(languageId);
+ }
+
+ @Override
+ public LanguageServer create(final String languageId,
+ final Environment environment)
+ {
+ return new GroovyLanguageServer(environment);
+ }
+}
diff --git a/scijava-code-lsp-groovy/src/main/java/org/scijava/code/lsp/groovy/TypedAST.java b/scijava-code-lsp-groovy/src/main/java/org/scijava/code/lsp/groovy/TypedAST.java
new file mode 100644
index 000000000..6c06ac37d
--- /dev/null
+++ b/scijava-code-lsp-groovy/src/main/java/org/scijava/code/lsp/groovy/TypedAST.java
@@ -0,0 +1,748 @@
+/*-
+ * #%L
+ * Groovy code intelligence for SciJava, via Groovy's own compiler.
+ * %%
+ * Copyright (C) 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.code.lsp.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.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.util.Types;
+
+/**
+ * The typed syntax tree of a Groovy script, up to the caret. Useful for
+ * answering LSP-related questions.
+ *
+ * Built by Groovy's own compiler: the script is
+ * parsed and its names resolved, then it is run through the static type
+ * checker, which infers the types of its expressions.
+ *
+ *
+ * Scripts being edited are usually incomplete, and usually dynamically typed,
+ * so compilation errors are expected and ignored: the tree has whatever types
+ * the compiler could work out, and none elsewhere. Only a syntax error leaves
+ * no tree; see {@link #isParsed()}.
+ *
+ *
+ * The source actually parsed is the provided source, with the
+ * following modifications:
+ *
+ *
Text after the caret is omitted
+ *
The identifier {@link #CARET} (see {@link #markCaret}) is inserted
+ * at the caret position, so that it can be found in the syntax tree
+ *
+ *
Any brackets left open are closed to pass parsing
+ *
+ *
+ *
+ * @author Gabriel Selzer
+ */
+final class TypedAST {
+
+ /** Identifier marking the caret position in the source. */
+ static final String CARET = "__scijava_caret__";
+
+ /** Cache recent trees for each source file. */
+ private static final Map CACHE =
+ new LinkedHashMap(16, 0.75f, true)
+ {
+
+ @Override
+ protected boolean removeEldestEntry(
+ final Map.Entry eldest)
+ {
+ return size() > 16;
+ }
+ };
+
+ private final SourceUnit source;
+ private final ModuleNode module;
+ private final ClassLoader loader;
+
+ private TypedAST(final SourceUnit source, final ClassLoader loader) {
+ this.source = source;
+ this.module = source.getAST();
+ this.loader = loader;
+ }
+
+ // -- Source preparation --
+
+ /**
+ * Marks the caret: the script up to the caret, then {@link #CARET}.
+ *
+ * @param text The script up to the caret, without the partial token being
+ * completed.
+ */
+ static String markCaret(final String text) {
+ return text + CARET;
+ }
+
+ /**
+ * Closes the brackets left open at the end of {@code text}, appending the
+ * closing ones (e.g. {@code foo(bar.} becomes {@code foo(bar.)}), so that
+ * it parses.
+ *
+ * @return The text, closed; or null if it ends inside a string or comment.
+ */
+ static String closeBrackets(final String text) {
+ final Deque open = new ArrayDeque<>();
+ int i = 0;
+ final int n = text.length();
+ while (i < n) {
+ final char c = text.charAt(i);
+ if (text.startsWith("//", i)) {
+ final int end = text.indexOf('\n', i);
+ if (end < 0) return null;
+ i = end + 1;
+ }
+ else if (text.startsWith("/*", i)) {
+ final int end = text.indexOf("*/", i + 2);
+ if (end < 0) return null;
+ i = end + 2;
+ }
+ else if (text.startsWith("'''", i) || text.startsWith("\"\"\"", i)) {
+ final int end = text.indexOf(text.substring(i, i + 3), i + 3);
+ if (end < 0) return null;
+ i = end + 3;
+ }
+ else if (c == '\'' || c == '"') {
+ int j = i + 1;
+ while (j < n && text.charAt(j) != c && text.charAt(j) != '\n') {
+ if (text.charAt(j) == '\\') j++;
+ j++;
+ }
+ if (j >= n || text.charAt(j) != c) return null;
+ i = j + 1;
+ }
+ else {
+ if (c == '(') open.push(')');
+ else if (c == '[') open.push(']');
+ else if (c == '{') open.push('}');
+ else if ((c == ')' || c == ']' || c == '}') && !open.isEmpty() && open
+ .peek() == c) open.pop();
+ i++;
+ }
+ }
+ final StringBuilder sb = new StringBuilder(text);
+ for (final char c : open) {
+ sb.append(c == '}' ? "\n}" : String.valueOf(c));
+ }
+ return sb.toString();
+ }
+
+ // -- Building --
+
+ /**
+ * Builds the typed syntax tree of the given source (its caret marked, its
+ * brackets closed), or reuses a recent one.
+ */
+ static TypedAST of(final String code, final ClassLoader loader) {
+ synchronized (CACHE) {
+ final TypedAST cached = CACHE.get(code);
+ if (cached != null) return cached;
+ }
+ final TypedAST ast = build(code, loader);
+ synchronized (CACHE) {
+ CACHE.put(code, ast);
+ }
+ return ast;
+ }
+
+ /**
+ * Builds the typed syntax tree of the given source, with Groovy's
+ * compiler: parses it, resolves its names, and runs the static type checker
+ * over it (see {@link #typeCheck}), typing what it can; but makes no
+ * bytecode.
+ */
+ private static TypedAST build(final String code,
+ final ClassLoader loader)
+ {
+ final CompilerConfiguration config = new CompilerConfiguration();
+ // NB: Collect all errors, rather than stopping at the first few.
+ config.setTolerance(Integer.MAX_VALUE);
+ final GroovyClassLoader gcl = new GroovyClassLoader(loader, config);
+ try {
+ final CompilationUnit unit = new CompilationUnit(config, null, gcl);
+ final SourceUnit source = unit.addSource("CompletionScript.groovy",
+ code);
+ try {
+ // Parse, resolve names and canonicalize; stop before bytecode.
+ unit.compile(Phases.CANONICALIZATION);
+ }
+ catch (final RuntimeException | LinkageError exc) {
+ // NB: Compilation errors are expected. Use what we have.
+ }
+ final ModuleNode module = source.getAST();
+ if (module != null) {
+ // NB: Unresolvable classes stop compilation before the type checker
+ // would run, and dynamic code fails it, so run it ourselves.
+ for (final ClassNode c : module.getClasses()) {
+ try {
+ typeCheck(source, c);
+ }
+ catch (final RuntimeException | LinkageError | AssertionError exc) {
+ // NB: Keep whatever types were inferred before the failure.
+ }
+ }
+ }
+ return new TypedAST(source, loader);
+ }
+ finally {
+ try {
+ gcl.close();
+ }
+ catch (final IOException exc) {
+ // NB: Nothing to clean up, then.
+ }
+ }
+ }
+
+ /**
+ * Runs the static type checker over a class of the script, typing the
+ * script's bindings (variables assigned without a declaration, e.g.
+ * {@code z = "abc"}), which it would leave untyped: each use of one has the
+ * type last assigned to it, in source order (the source ends at the caret).
+ */
+ private static void typeCheck(final SourceUnit source, final ClassNode c) {
+ final Map bindings = new HashMap<>();
+ final StaticTypeCheckingVisitor checker = new StaticTypeCheckingVisitor(
+ source, c)
+ {
+
+ @Override
+ public void visitBinaryExpression(final BinaryExpression e) {
+ super.visitBinaryExpression(e);
+ if (!isBindingAssignment(e)) return;
+ final String name = ((VariableExpression) e.getLeftExpression())
+ .getName();
+ final ClassNode type = getType(e.getRightExpression());
+ if (type == null || "java.lang.Object".equals(type.getName())) {
+ bindings.remove(name);
+ }
+ else bindings.put(name, type);
+ }
+ };
+ checker.addTypeCheckingExtension(new TypeCheckingExtension(checker) {
+
+ @Override
+ public boolean handleUnresolvedVariableExpression(
+ final VariableExpression v)
+ {
+ final ClassNode type = bindings.get(v.getName());
+ if (type == null) return false;
+ storeType(v, type);
+ return true;
+ }
+ });
+ checker.visitClass(c);
+ }
+
+ /** True iff the expression assigns a script binding, e.g. {@code z = 1}. */
+ private static boolean isBindingAssignment(final BinaryExpression e) {
+ return e.getOperation().getType() == org.codehaus.groovy.syntax.Types.ASSIGN &&
+ e.getLeftExpression() instanceof VariableExpression &&
+ ((VariableExpression) e.getLeftExpression())
+ .getAccessedVariable() instanceof DynamicVariable;
+ }
+
+ /** A problem the compiler found in the source. */
+ static final class Problem {
+
+ /** Where it starts and ends: 1-based lines and columns. */
+ final int line, column, endLine, endColumn;
+
+ final String message;
+
+ /** Whether it may yet work at run time (else it fails for sure). */
+ final boolean warning;
+
+ private Problem(final SyntaxException e, final String message,
+ final boolean warning)
+ {
+ line = e.getStartLine();
+ column = e.getStartColumn();
+ endLine = e.getEndLine();
+ endColumn = e.getEndColumn();
+ this.message = message;
+ this.warning = warning;
+ }
+ }
+
+ /** The type checker's messages, e.g. about a call it cannot resolve. */
+ private static final String TYPE_CHECKING = "[Static type checking] - ";
+
+ /**
+ * Gets the problems the compiler found that would also trouble the script
+ * when it runs: syntax errors and unresolvable classes; and, as warnings,
+ * methods and properties the type checker cannot find on a known type
+ * (e.g. {@code "abc".fooBar()}). The type checker's other complaints, e.g.
+ * about dynamically typed code, are left out: Groovy resolves those when
+ * the script runs.
+ */
+ List problems() {
+ final List problems = new ArrayList<>();
+ final List extends Message> errors = source.getErrorCollector()
+ .getErrors();
+ if (errors == null) return problems;
+ for (final Message m : errors) {
+ if (!(m instanceof SyntaxErrorMessage)) continue;
+ final SyntaxException e = ((SyntaxErrorMessage) m).getCause();
+ final String message = e.getOriginalMessage() == null ? null : e
+ .getOriginalMessage().trim();
+ if (message == null || message.contains(CARET)) continue;
+ if (!message.startsWith(TYPE_CHECKING)) {
+ problems.add(new Problem(e, message, false));
+ continue;
+ }
+ final String complaint = message.substring(TYPE_CHECKING.length())
+ .replace(" Please check if the declared type is correct and if the " +
+ "method exists.", "");
+ if (isKnownTypeMissing(complaint)) {
+ problems.add(new Problem(e, complaint, true));
+ }
+ }
+ return problems;
+ }
+
+ /**
+ * True iff the type checker's complaint is that a known, specific type
+ * lacks a method or property: rather than, e.g., that a variable's type is
+ * unknown ({@code Object}), or that the script calls methods it does not
+ * declare (which bindings may provide when it runs).
+ */
+ private static boolean isKnownTypeMissing(final String complaint) {
+ final String type;
+ if (complaint.startsWith("Cannot find matching method ")) {
+ final String call = complaint.substring(
+ "Cannot find matching method ".length());
+ final int hash = call.indexOf('#');
+ if (hash < 0) return false;
+ type = call.substring(0, hash);
+ }
+ else if (complaint.startsWith("No such property: ")) {
+ final int at = complaint.indexOf(" for class: ");
+ if (at < 0) return false;
+ type = complaint.substring(at + " for class: ".length()).trim();
+ }
+ else return false;
+ return type.contains(".") && !type.equals("java.lang.Object") && !type
+ .startsWith("groovy.lang.");
+ }
+
+ /** True iff the source parsed (a syntax error leaves no tree). */
+ boolean isParsed() {
+ return module != null;
+ }
+
+ /**
+ * Gets the expression whose member is being completed, e.g. {@code img} in
+ * {@code img.CARET}, or null if the caret is not after a dot.
+ */
+ Expression receiverAtCaret() {
+ final Expression[] found = { null };
+ visit(new Visitor() {
+
+ @Override
+ public void visitPropertyExpression(final PropertyExpression e) {
+ if (CARET.equals(e.getPropertyAsString())) found[0] = e
+ .getObjectExpression();
+ super.visitPropertyExpression(e);
+ }
+
+ @Override
+ public void visitMethodCallExpression(final MethodCallExpression e) {
+ if (CARET.equals(e.getMethodAsString()) && !e.isImplicitThis()) {
+ found[0] = e.getObjectExpression();
+ }
+ super.visitMethodCallExpression(e);
+ }
+ });
+ return found[0];
+ }
+
+ /**
+ * Gets the variables visible at the caret, with their types (null if
+ * unknown), innermost last.
+ */
+ Map variablesAtCaret() {
+ final ASTNode caret = caretNode();
+ if (caret == null) return Collections.emptyMap();
+ final int line = caret.getLineNumber(), column = caret.getColumnNumber();
+ final Map vars = new LinkedHashMap<>();
+ visit(new Visitor() {
+
+ private boolean contains(final ASTNode node) {
+ if (node.getLineNumber() < 0) return true; // synthetic: unknown span
+ return before(node.getLineNumber(), node.getColumnNumber()) && //
+ !before(node.getLastLineNumber(), node.getLastColumnNumber());
+ }
+
+ private boolean before(final int l, final int c) {
+ return l < line || l == line && c <= column;
+ }
+
+ private void add(final Variable v) {
+ if (CARET.equals(v.getName())) return;
+ vars.remove(v.getName()); // NB: Re-insert, so innermost comes last.
+ vars.put(v.getName(), v.isDynamicTyped() ? null : v.getOriginType());
+ }
+
+ @Override
+ protected void visitConstructorOrMethod(final MethodNode node,
+ final boolean isConstructor)
+ {
+ if (!node.isScriptBody() && !contains(node)) return;
+ for (final Parameter p : node.getParameters()) add(p);
+ super.visitConstructorOrMethod(node, isConstructor);
+ }
+
+ @Override
+ public void visitClosureExpression(final ClosureExpression e) {
+ if (!contains(e)) return;
+ if (e.getParameters() != null) {
+ for (final Parameter p : e.getParameters()) add(p);
+ }
+ super.visitClosureExpression(e);
+ }
+
+ @Override
+ public void visitBlockStatement(final BlockStatement block) {
+ if (contains(block)) super.visitBlockStatement(block);
+ }
+
+ @Override
+ public void visitForLoop(final ForStatement loop) {
+ if (!contains(loop)) return;
+ final Parameter v = loop.getVariable();
+ if (v != null && v != ForStatement.FOR_LOOP_DUMMY) add(v);
+ super.visitForLoop(loop);
+ }
+
+ @Override
+ public void visitCatchStatement(final CatchStatement statement) {
+ if (!contains(statement)) return;
+ add(statement.getVariable());
+ super.visitCatchStatement(statement);
+ }
+
+ @Override
+ public void visitDeclarationExpression(final DeclarationExpression e) {
+ if (before(e.getLineNumber(), e.getColumnNumber())) {
+ if (e.isMultipleAssignmentDeclaration()) {
+ for (final Expression v : e.getTupleExpression()
+ .getExpressions())
+ {
+ if (v instanceof VariableExpression) {
+ addDeclared((VariableExpression) v);
+ }
+ }
+ }
+ else addDeclared(e.getVariableExpression());
+ }
+ super.visitDeclarationExpression(e);
+ }
+
+ @Override
+ public void visitBinaryExpression(final BinaryExpression e) {
+ // NB: A binding assigned, e.g. z = "abc".
+ if (isBindingAssignment(e)) {
+ final String name = ((VariableExpression) e.getLeftExpression())
+ .getName();
+ vars.remove(name);
+ vars.put(name, typeOf(e.getRightExpression()));
+ }
+ super.visitBinaryExpression(e);
+ }
+
+ private void addDeclared(final VariableExpression v) {
+ if (CARET.equals(v.getName())) return;
+ vars.remove(v.getName());
+ vars.put(v.getName(), typeOf(v));
+ }
+ });
+ return vars;
+ }
+
+ /** The call whose arguments contain the caret marker. */
+ static final class Call {
+
+ /**
+ * The call: a {@link MethodCallExpression},
+ * {@link StaticMethodCallExpression} or
+ * {@link ConstructorCallExpression}.
+ */
+ final Expression expression;
+
+ /** Its arguments, up to the one containing the caret. */
+ final List arguments;
+
+ /** Index of the argument containing the caret. */
+ final int active;
+
+ private Call(final Expression expression,
+ final List arguments, final int active)
+ {
+ this.expression = expression;
+ this.arguments = arguments;
+ this.active = active;
+ }
+ }
+
+ /**
+ * Gets the innermost call whose arguments contain the caret marker, e.g.
+ * {@code max} in {@code Math.max(1.5, CARET)}; or null if there is none.
+ */
+ Call callAtCaret() {
+ final Call[] found = { null };
+ visit(new Visitor() {
+
+ @Override
+ public void visitMethodCallExpression(final MethodCallExpression e) {
+ consider(e, e.getArguments());
+ super.visitMethodCallExpression(e);
+ }
+
+ @Override
+ public void visitStaticMethodCallExpression(
+ final StaticMethodCallExpression e)
+ {
+ consider(e, e.getArguments());
+ super.visitStaticMethodCallExpression(e);
+ }
+
+ @Override
+ public void visitConstructorCallExpression(
+ final ConstructorCallExpression e)
+ {
+ consider(e, e.getArguments());
+ super.visitConstructorCallExpression(e);
+ }
+
+ private void consider(final Expression call, final Expression args) {
+ if (!(args instanceof TupleExpression)) return;
+ final List list = ((TupleExpression) args)
+ .getExpressions();
+ for (int i = 0; i < list.size(); i++) {
+ if (!containsCaret(list.get(i))) continue;
+ // NB: Visited outside in, so the innermost call is found last.
+ found[0] = new Call(call, list.subList(0, i + 1), i);
+ return;
+ }
+ }
+ });
+ return found[0];
+ }
+
+ /**
+ * Gets the type of an expression, e.g. an argument, as a type name for
+ * {@link org.scijava.code.lsp.jvm.Signatures}; or null if unknown.
+ */
+ String typeName(final Expression e) {
+ if (e instanceof GStringExpression) return String.class.getName();
+ if (e instanceof ConstantExpression) {
+ final Object value = ((ConstantExpression) e).getValue();
+ if (value == null) return null;
+ if (value instanceof Integer) return "int";
+ if (value instanceof Long) return "long";
+ if (value instanceof Double) return "double";
+ if (value instanceof Float) return "float";
+ if (value instanceof Boolean) return "boolean";
+ return value.getClass().getName(); // e.g. String, BigDecimal
+ }
+ final Class> c = toClass(typeOf(e));
+ if (c == null || c == Object.class) return null; // dynamic: unknown
+ final String name = c.getCanonicalName();
+ return name == null ? c.getName() : name;
+ }
+
+ /** Gets the script's own methods (e.g. {@code def area(w, h)}) by name. */
+ List scriptMethods(final String name) {
+ final List methods = new ArrayList<>();
+ if (module == null) return methods;
+ for (final ClassNode c : module.getClasses()) {
+ if (c.isScript()) methods.addAll(c.getMethods(name));
+ }
+ return methods;
+ }
+
+ /** True iff the expression is, or contains, the caret marker. */
+ private boolean containsCaret(final Expression e) {
+ final boolean[] found = { false };
+ e.visit(new Visitor() {
+
+ @Override
+ public void visitVariableExpression(final VariableExpression v) {
+ if (CARET.equals(v.getName())) found[0] = true;
+ }
+ });
+ return found[0];
+ }
+
+ // -- Types --
+
+ /** Gets the (inferred or declared) type of an expression, or null. */
+ static ClassNode typeOf(final Expression e) {
+ if (e == null) return null;
+ // NB: The type checker infers Class for a class reference; we want T.
+ if (e instanceof ClassExpression) return e.getType();
+ final Object inferred = e.getNodeMetaData(StaticTypesMarker.INFERRED_TYPE);
+ if (inferred instanceof ClassNode) return (ClassNode) inferred;
+ if (e instanceof VariableExpression) {
+ final Variable v = ((VariableExpression) e).getAccessedVariable();
+ if (v != null && v != e && !v.isDynamicTyped()) return v.getOriginType();
+ final VariableExpression ve = (VariableExpression) e;
+ return ve.isDynamicTyped() ? null : ve.getOriginType();
+ }
+ final ClassNode type = e.getType();
+ if (type == null || "java.lang.Object".equals(type.getName())) return null;
+ return type;
+ }
+
+ /**
+ * Gets the Java class of a type, or null if it is unknown or defined in the
+ * script itself.
+ */
+ Class> toClass(final ClassNode type) {
+ if (type == null) return null;
+ final ClassNode t = type.redirect();
+ if (t.isPrimaryClassNode()) return null; // NB: Not compiled.
+ try {
+ return t.getTypeClass();
+ }
+ catch (final RuntimeException | LinkageError | AssertionError exc) {
+ // NB: Not loaded yet; load it by name.
+ }
+ return load(t.getName(), loader);
+ }
+
+ /**
+ * Loads a type by name, e.g. {@code int}, {@code long[]}, {@code a.b.C} or
+ * {@code a.b.Outer.Inner}, or returns null if it cannot be loaded.
+ */
+ static Class> load(final String name, final ClassLoader loader) {
+ if (name == null) return null;
+ final Class> c = Types.load(name, loader);
+ if (c != null) return c;
+ // NB: Perhaps a nested class, named canonically: a.b.Outer.Inner.
+ final int dot = name.lastIndexOf('.');
+ return dot < 0 ? null : load(name.substring(0, dot) + "$" + name
+ .substring(dot + 1), loader);
+ }
+
+ // -- Helper methods --
+
+ /** Gets the node of the caret marker. */
+ private ASTNode caretNode() {
+ final ASTNode[] found = { null };
+ visit(new Visitor() {
+
+ @Override
+ public void visitVariableExpression(final VariableExpression e) {
+ if (CARET.equals(e.getName())) found[0] = e;
+ super.visitVariableExpression(e);
+ }
+
+ @Override
+ public void visitPropertyExpression(final PropertyExpression e) {
+ if (CARET.equals(e.getPropertyAsString())) found[0] = e.getProperty();
+ super.visitPropertyExpression(e);
+ }
+
+ @Override
+ public void visitMethodCallExpression(final MethodCallExpression e) {
+ if (CARET.equals(e.getMethodAsString())) found[0] = e.getMethod();
+ super.visitMethodCallExpression(e);
+ }
+ });
+ return found[0];
+ }
+
+ /** Visits every class (including the script itself) of the source. */
+ private void visit(final Visitor visitor) {
+ if (module == null) return;
+ for (final ClassNode c : module.getClasses()) {
+ try {
+ visitor.visitClass(c);
+ }
+ catch (final RuntimeException exc) {
+ // NB: Malformed tree; skip this class.
+ }
+ }
+ }
+
+ private abstract class Visitor extends ClassCodeVisitorSupport {
+
+ @Override
+ protected SourceUnit getSourceUnit() {
+ return source;
+ }
+ }
+}
diff --git a/scijava-code-lsp-groovy/src/test/java/org/scijava/code/lsp/groovy/GroovyLanguageServerTest.java b/scijava-code-lsp-groovy/src/test/java/org/scijava/code/lsp/groovy/GroovyLanguageServerTest.java
new file mode 100644
index 000000000..9f2e4bf16
--- /dev/null
+++ b/scijava-code-lsp-groovy/src/test/java/org/scijava/code/lsp/groovy/GroovyLanguageServerTest.java
@@ -0,0 +1,523 @@
+/*-
+ * #%L
+ * Groovy code intelligence for SciJava, via Groovy's own compiler.
+ * %%
+ * Copyright (C) 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.code.lsp.groovy;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+import javax.script.ScriptContext;
+import javax.script.ScriptEngine;
+import javax.script.SimpleScriptContext;
+
+import org.eclipse.lsp4j.CompletionItem;
+import org.eclipse.lsp4j.Diagnostic;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.SignatureHelp;
+import org.eclipse.lsp4j.services.LanguageServer;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.scijava.Context;
+import org.scijava.code.lsp.Environment;
+import org.scijava.code.lsp.LanguageServerPlugin;
+import org.scijava.code.lsp.LanguageServerService;
+import org.scijava.code.lsp.ScriptDialect;
+import org.scijava.code.lsp.ScriptSession;
+import org.scijava.code.lsp.jvm.ClassIndex;
+import org.scijava.plugin.Plugin;
+import org.scijava.script.AbstractScriptLanguage;
+import org.scijava.script.ScriptLanguage;
+
+/**
+ * Tests {@link GroovyLanguageServer}
+ *
+ * @author Gabriel Selzer
+ */
+public class GroovyLanguageServerTest {
+
+ /** A test fixture: a script language whose scripts are plain Groovy. */
+ private static final ScriptLanguage TEST_LANGUAGE =
+ new AbstractScriptLanguage()
+ {
+
+ @Override
+ public ScriptEngine getScriptEngine() {
+ return null;
+ }
+ };
+
+ private static Context context;
+
+ @BeforeAll
+ public static void startContext() {
+ context = new Context(LanguageServerService.class);
+ }
+
+ @AfterAll
+ public static void disposeContext() {
+ context.dispose();
+ }
+
+ @Test
+ public void testCompleteMembersOfInferredTypes() {
+ 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"));
+ // Inherited members are included.
+ assertTrue(complete("def x = new ArrayList()\nx.containsA").contains(
+ "containsAll"));
+ }
+
+ @Test
+ public void testCompleteMembersOfDeclaredTypes() {
+ // Variables' declared types, whatever they're assigned...
+ assertTrue(complete("List items = null\nitems.isEm").contains("isEmpty"));
+ assertTrue(complete("CharSequence cs = 'abc'\ncs.subSeq").contains(
+ "subSequence"));
+ // ...and parameters' too.
+ assertTrue(complete("def shout(String s) {\n s.toUpp").contains(
+ "toUpperCase"));
+ }
+
+ /**
+ * 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 testCompleteMembersOfBindings() {
+ // 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(zed != null, String.valueOf(labels(items)));
+ assertEquals("java.lang.String zed", zed.getDetail());
+ }
+
+ @Test
+ public void testDiagnoseProblems() throws Exception {
+ // Syntax errors, and unresolvable classes: errors, where they are.
+ assertEquals(Collections.singletonList("Error@1:11-12 Unexpected input: '+'"),
+ problems("sigma = 1.5\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",
+ "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(Collections.emptyList(), problems(fine), fine);
+ }
+ }
+
+ /**
+ * Ensure callables identified by the language server are returned as
+ * snippets, including parameters ({@code ${1:...}})
+ */
+ @Test
+ public void testCompleteCallablesAsSnippets() {
+ // No parameters: the whole call is inserted, e.g. "toUpperCase()".
+ 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());
+ // A parameter: a tab stop for it, "toUpperCase(${1:...})", and the
+ // method's return type.
+ 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 testCompleteStaticMembers() {
+ final List texts = complete("Math.ma");
+ assertTrue(texts.contains("max"));
+ // Only static members of a class.
+ assertTrue(complete("Integer.parseI").contains("parseInt"));
+ // Integer.intValue is not static
+ assertFalse(complete("Integer.intV").contains("intValue"));
+ }
+
+ /**
+ * The Groovy SDK adds some methods - ensure a small sampling show up in
+ * autocomplete
+ */
+ @Test
+ public void testCompleteGdkMethods() {
+ 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 testCompleteMembersOfScriptClasses() {
+ final List texts = complete("class Foo {\n" + //
+ " int bar\n" + //
+ " String baz() { 'x' }\n" + //
+ "}\n" + //
+ "def f = new Foo()\n" + //
+ "f.ba");
+ assertTrue(texts.contains("bar"), texts.toString());
+ assertTrue(texts.contains("baz"), texts.toString());
+ }
+
+ @Test
+ public void testCompleteVariablesInScope() {
+ 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 testCompleteInIncompleteCode() {
+ // 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 testCompleteKeywords() {
+ // keywords get completed
+ assertTrue(complete("ret").contains("return"));
+ // primitives get completed
+ assertTrue(complete("tr").contains("true"));
+ assertTrue(complete("do").contains("double"));
+ }
+
+ @Test
+ public void testCompleteAfterNumbers() {
+ // A decimal being typed is not completed.
+ assertTrue(complete("def x = 1.").isEmpty());
+ assertTrue(complete("def x = 1.5").isEmpty());
+ // But numbers are objects, whose members are.
+ assertTrue(complete("5.ti").contains("times"));
+ assertTrue(complete("def x = 1.5.ro").contains("round"));
+ }
+
+ @Test
+ public void testCompleteClassNamesWithAutoImport() {
+ ClassIndex.ensureCache();
+ final CompletionItem atomic = items("def a = new AtomicInt").stream()
+ .filter(c -> c.getLabel().equals("AtomicInteger")).findFirst().orElseThrow();
+ 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);
+ assertEquals(null, importAt("import java.util.concurrent.atomic.*\n" +
+ "def a = new AtomicInt"));
+ // Inserted after the last import, or else at the top (after a shebang).
+ assertEquals("1:0", importAt("import java.io.File\nx = 1\n" +
+ "def a = new AtomicInt"));
+ assertEquals("0:0", importAt("x = 1\ndef a = new AtomicInt"));
+ assertEquals("1:0", importAt("#!/usr/bin/env groovy\n" +
+ "def a = new AtomicInt"));
+ }
+
+ @Test
+ public void testCompleteImports() {
+ ClassIndex.ensureCache();
+ // The whole name is completed, as it's replaced.
+ assertTrue(complete("import java.util.concurrent.atomic.AtomicInt")
+ .contains("java.util.concurrent.atomic.AtomicInteger"));
+ }
+
+ @Test
+ public void testCompleteQualifiedClassNames() {
+ ClassIndex.ensureCache();
+ // In code: a package's classes, by their names in it.
+ assertTrue(complete("java.util.concurrent.atomic.AtomicInt").contains(
+ "AtomicInteger"));
+ }
+
+ @Test
+ public void testCompleteNothingInStringsAndComments() {
+ assertTrue(complete("def s = 'a.b.").isEmpty());
+ assertTrue(complete("// s.toUpp").isEmpty());
+ assertTrue(complete("/* x.").isEmpty());
+ }
+
+ @Test
+ public void testCompleteInterpreterBindings() throws Exception {
+ // Code typed into a running interpreter: its variables, by their values'
+ // types.
+ final ScriptContext live = new SimpleScriptContext();
+ live.setAttribute("injected", new ArrayList(),
+ ScriptContext.ENGINE_SCOPE);
+ final List texts = labels(items(session(new Environment().withLive(
+ live)), "injected.si"));
+ assertTrue(texts.contains("size"), texts.toString());
+ // Variables it gains later, though the code is the same.
+ try (ScriptSession session = session(new Environment().withLive(live))) {
+ final String code = "later.si";
+ assertFalse(labels(session.completion(code, code.length()).get(30,
+ TimeUnit.SECONDS).getItems()).contains("size"));
+ live.setAttribute("later", new ArrayList(),
+ ScriptContext.ENGINE_SCOPE);
+ assertTrue(labels(session.completion(code, code.length()).get(30,
+ TimeUnit.SECONDS).getItems()).contains("size"));
+ }
+ }
+
+ @Test
+ public void testSignatureHelpRanksOverloads() {
+ // 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 testCompleteArguments() {
+ // 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());
+ // Ensure variables that do not fit show up later
+ assertTrue(labels(items).contains("s"));
+ }
+
+ // -- Helper methods --
+
+ /** A session about plain Groovy, with a server of its own. */
+ private static ScriptSession session(final Environment env) {
+ final LanguageServerService servers = context.service(
+ LanguageServerService.class);
+ return env.live().map(live -> new ScriptSession(servers, TEST_LANGUAGE,
+ live)).orElseGet(() -> new ScriptSession(servers, TEST_LANGUAGE));
+ }
+
+ /** The problems found in a script, e.g. {@code Error@0:4-6 message}. */
+ private static List problems(final String code) throws Exception {
+ final CompletableFuture> heard =
+ new CompletableFuture<>();
+ final List found;
+ try (ScriptSession session = session(new Environment())) {
+ session.setDiagnosticsListener(heard::complete);
+ session.update(code);
+ found = heard.get(20, TimeUnit.SECONDS);
+ }
+ 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());
+ }
+
+ /**
+ * Where completing {@code AtomicInteger} at the end of the code imports it
+ * ({@code line:character}), or null if it doesn't.
+ */
+ private static String importAt(final String code) {
+ ClassIndex.ensureCache();
+ final CompletionItem atomic = items(code).stream().filter(c -> c
+ .getLabel().equals("AtomicInteger")).findFirst().orElseThrow();
+ if (atomic.getAdditionalTextEdits() == null) return null;
+ final Position at = atomic.getAdditionalTextEdits().get(0).getRange()
+ .getStart();
+ return at.getLine() + ":" + at.getCharacter();
+ }
+
+ private static SignatureHelp help(final String code) {
+ try (ScriptSession session = session(new Environment())) {
+ 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 static List items(final String code) {
+ return items(session(new Environment()), 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 static 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());
+ }
+
+ @Plugin(type = ScriptDialect.class)
+ public static class TestDialect implements ScriptDialect {
+
+ @Override
+ public boolean supports(final ScriptLanguage language) {
+ return language == TEST_LANGUAGE;
+ }
+
+ @Override
+ public String languageId() {
+ return "test";
+ }
+
+ @Override
+ public Header header(final Map variables) {
+ // NB: As scripting-groovy's dialect does: full names need no imports.
+ final List declarations = new ArrayList<>();
+ variables.forEach((name, v) -> declarations.add(GroovyLanguageServer
+ .typeName(v.type()) + " " + name));
+ return new Header(String.join("; ", declarations), Collections
+ .emptySet());
+ }
+ }
+
+ @Plugin(type = LanguageServerPlugin.class)
+ public static class TestServers implements LanguageServerPlugin {
+
+ @Override
+ public boolean supports(final String languageId) {
+ return "test".equals(languageId);
+ }
+
+ @Override
+ public LanguageServer create(final String languageId,
+ final Environment environment)
+ {
+ return new GroovyLanguageServer(environment);
+ }
+ }
+}
diff --git a/scijava-code-lsp-groovy/src/test/java/org/scijava/code/lsp/groovy/GroovyServerPluginTest.java b/scijava-code-lsp-groovy/src/test/java/org/scijava/code/lsp/groovy/GroovyServerPluginTest.java
new file mode 100644
index 000000000..2a7283c2d
--- /dev/null
+++ b/scijava-code-lsp-groovy/src/test/java/org/scijava/code/lsp/groovy/GroovyServerPluginTest.java
@@ -0,0 +1,48 @@
+package org.scijava.code.lsp.groovy;
+
+import org.eclipse.lsp4j.services.LanguageServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.scijava.Context;
+import org.scijava.code.lsp.Environment;
+import org.scijava.code.lsp.LanguageServerService;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests {@link GroovyServerPlugin}
+ *
+ * @author Gabriel Selzer
+ */
+public class GroovyServerPluginTest {
+
+ private Context context;
+ private LanguageServerService servers;
+
+ @BeforeEach
+ public void setUp() {
+ context = new Context(LanguageServerService.class);
+ servers = context.service(LanguageServerService.class);
+ }
+
+ @AfterEach
+ public void tearDown() {
+ context.dispose();
+ }
+
+ @Test
+ public void testServerIsDiscovered() {
+ assertTrue(servers.supports("groovy"));
+ }
+
+ @Test
+ public void testServerLaunch() {
+ LanguageServer server = servers.launch(
+ "groovy",
+ new Environment()
+ );
+ assertTrue(server instanceof GroovyLanguageServer);
+ }
+}
+
diff --git a/scijava-code-lsp-jedi/README.md b/scijava-code-lsp-jedi/README.md
new file mode 100644
index 000000000..45459d694
--- /dev/null
+++ b/scijava-code-lsp-jedi/README.md
@@ -0,0 +1,35 @@
+# SciJava Code LSP Jedi
+
+Python code intelligence for SciJava scripts: [jedi-language-server](https://github.com/pappasam/jedi-language-server) (built on [Jedi](https://jedi.readthedocs.io/)), offered to `scijava-code-lsp` for `python` by `PythonServerPlugin`, made to work well in editors:
+
+* Completions are asked about a word's first character only, and reused as the word is typed on; questions no longer of interest are cancelled; and the first completions of a word get their signatures, as snippets with their parameters and types.
+
+Script languages whose scripts become plain Python (e.g. scripting-appose-python's) get it this way; and servers building on Python's (e.g. scijava-code-lsp-jython's, beside Jython's own analysis).
+
+The server runs as a process, one per environment scripts run in (see `JediLauncher`), in a small environment of its own (`code-lsp-jedi`: Python, Jedi, jedi-language-server, and NumPy), which [Appose](https://github.com/apposed/appose) builds next to its other environments the first time the server is needed; that takes a while only then. Its error output goes to `code-lsp-jedi-server.log`, next to the environment.
+
+Each server analyzes scripts in exactly the environment it is given: its Python (if any; otherwise the server's own) and extra search paths (e.g. stubs of Java packages).
+
+## Example
+
+```java
+Context context = new Context(LanguageServerService.class);
+// (a) Start the server: the plugin offering "python", for scripts run by the
+// server's own Python (or, e.g., new Environment(python, paths, null)).
+LanguageServer server = context.service(LanguageServerService.class)
+ .launch("python", new Environment());
+server.initialize(new InitializeParams()).get();
+server.initialized(new InitializedParams());
+
+// (b) Ask it about a script: open it, then ask, e.g. for completions.
+String script = "import json\njson.du";
+server.getTextDocumentService().didOpen(new DidOpenTextDocumentParams(
+ new TextDocumentItem("untitled:/example.py", "python", 1, script)));
+server.getTextDocumentService().completion(new CompletionParams(
+ new TextDocumentIdentifier("untitled:/example.py"),
+ Positions.position(script, script.length()))).get(); // dump, dumps
+
+context.dispose(); // Stops the server, too.
+```
+
+`JediLifecycleIntegrationTest` does all this (and asks for hover too), then checks that disposing the context stops the server's process.
diff --git a/scijava-code-lsp-jedi/pom.xml b/scijava-code-lsp-jedi/pom.xml
new file mode 100644
index 000000000..d4f9fdf32
--- /dev/null
+++ b/scijava-code-lsp-jedi/pom.xml
@@ -0,0 +1,158 @@
+
+
+ 4.0.0
+
+
+ org.scijava
+ pom-scijava
+ 45.1.0
+
+
+
+ scijava-code-lsp-jedi
+ 0.1.0-SNAPSHOT
+
+ SciJava Code LSP Jedi
+ Python code intelligence for SciJava, via jedi-language-server.
+ https://github.com/scijava/scijava
+ 2026
+
+ SciJava
+ https://scijava.org/
+
+
+
+ Simplified BSD License
+ repo
+
+
+
+
+
+ gselzer
+ Gabriel Selzer
+ https://imagej.net/people/gselzer
+
+ founder
+ lead
+ developer
+ debugger
+ reviewer
+ support
+ maintainer
+
+
+
+
+
+ None
+
+
+
+
+
+ Image.sc Forum
+ https://forum.image.sc/tag/scijava
+
+
+
+
+ scm:git:https://github.com/scijava/scijava
+ scm:git:git@github.com:scijava/scijava
+ HEAD
+ https://github.com/scijava/scijava
+
+
+ GitHub Issues
+ https://github.com/scijava/scijava/issues
+
+
+ GitHub Actions
+ https://github.com/scijava/scijava/actions
+
+
+
+ org.scijava.code.lsp.jedi
+ bsd_2
+ SciJava developers.
+
+
+ 2.101.0
+ 0.1.0-SNAPSHOT
+
+ 4.3.0
+ 0.12.0
+
+
+
+
+
+ org.scijava
+ scijava-common
+
+
+ org.scijava
+ scijava-code-lsp
+ ${scijava-code-lsp.version}
+
+
+
+
+ org.apposed
+ appose
+ ${appose.version}
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
+
+
+ com.fasterxml.jackson.dataformat
+ jackson-dataformat-toml
+ ${jackson.version}
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ test
+
+
+ org.awaitility
+ awaitility
+ ${awaitility.version}
+ test
+
+
+
+
+
+
+ maven-compiler-plugin
+
+
+
+ org.scijava
+ scijava-common
+ ${scijava-common.version}
+
+
+
+
+
+ maven-surefire-plugin
+
+
+ false
+
+
+
+
+
diff --git a/scijava-code-lsp-jedi/src/main/java/module-info.java b/scijava-code-lsp-jedi/src/main/java/module-info.java
new file mode 100644
index 000000000..34cda4801
--- /dev/null
+++ b/scijava-code-lsp-jedi/src/main/java/module-info.java
@@ -0,0 +1,37 @@
+/*-
+ * #%L
+ * Python code intelligence for SciJava, via jedi-language-server.
+ * %%
+ * Copyright (C) 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%
+ */
+
+// NB: Open, so that SciJava Common can create its plugin reflectively. Exports
+// nothing: the plugin is found by SciJava Common, not used directly.
+open module org.scijava.code.lsp.jedi {
+ requires org.scijava.code.lsp;
+ requires org.apposed.appose;
+ requires com.fasterxml.jackson.databind;
+ requires com.fasterxml.jackson.dataformat.toml;
+}
diff --git a/scijava-code-lsp-jedi/src/main/java/org/scijava/code/lsp/jedi/JediLauncher.java b/scijava-code-lsp-jedi/src/main/java/org/scijava/code/lsp/jedi/JediLauncher.java
new file mode 100644
index 000000000..608c45a56
--- /dev/null
+++ b/scijava-code-lsp-jedi/src/main/java/org/scijava/code/lsp/jedi/JediLauncher.java
@@ -0,0 +1,237 @@
+/*-
+ * #%L
+ * Python code intelligence for SciJava, via jedi-language-server.
+ * %%
+ * Copyright (C) 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.code.lsp.jedi;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.*;
+
+import org.apposed.appose.Appose;
+import org.apposed.appose.util.Environments;
+import org.apposed.appose.util.Platforms;
+import org.scijava.code.lsp.Environment;
+import org.scijava.code.lsp.server.ServerLauncher;
+
+/**
+ * Starts jedi-language-server: run in an environment of its own (built by
+ * Appose the first time it is needed), to analyze scripts against their
+ * environment's Python (if known) and extra search paths (e.g. stubs of Java
+ * packages). {@link PythonServerPlugin} serves
+ * {@code python} with it.
+ *
+ * @author Gabriel Selzer
+ */
+final class JediLauncher implements ServerLauncher {
+
+ /** Name of the environment in which the server runs. */
+ static final String ENV = "code-lsp-jedi";
+
+ /**
+ * A Python script that runs jedi-language-server, and stops it if this JVM
+ * dies without stopping it (e.g. when killed). NB: The server wouldn't
+ * notice on its own; on Windows, it would even spin, failing to write to the
+ * closed pipe.
+ *
+ * The JVM's process ID is passed as the script's argument. A background
+ * thread waits for that process to end, then exits the whole Python
+ * process, server and all:
+ *
+ *
+ *
On Windows, it waits on a handle to the JVM's process (or not at all,
+ * if the process is already gone).
+ *
Elsewhere, it checks every 2 seconds whether its parent is still the
+ * JVM: once the JVM is gone, the operating system gives the server a new
+ * parent.
+ *
+ */
+ static final String LAUNCHER = String.join("\n", //
+ "import os, sys, threading, time", //
+ "parent = int(sys.argv.pop(1))", //
+ "def watch():", //
+ " if os.name == 'nt':", //
+ " import ctypes", //
+ " k = ctypes.windll.kernel32", //
+ " h = k.OpenProcess(0x00100000, False, parent) # SYNCHRONIZE", //
+ " if h: k.WaitForSingleObject(h, 0xFFFFFFFF)", //
+ " else:", //
+ " while os.getppid() == parent: time.sleep(2)", //
+ " os._exit(0)", //
+ "threading.Thread(target=watch, daemon=True).start()", //
+ "from jedi_language_server.cli import cli", //
+ "cli()");
+
+ /** How large the error log may grow before it is started anew, in bytes. */
+ private static final long MAX_LOG = 5 * 1024 * 1024;
+
+ /** The Python of the server's environment, once built. */
+ private static String python;
+
+ /** An (empty) directory to serve as workspace root. */
+ private final File workspace;
+ /** Where to append the server's error output. */
+ private final File log;
+
+ /** Runs the server in its own environment, building it if needed. */
+ JediLauncher() {
+ final File envs = new File(Environments.apposeEnvsDir());
+ workspace = new File(envs, ENV + "-workspace");
+ log = new File(envs, ENV + "-server.log");
+ }
+
+ @Override
+ public String languageId() {
+ return "python";
+ }
+
+ @Override
+ public Process start(final Environment env) throws IOException {
+ final String exe;
+ try {
+ exe = python();
+ }
+ catch (final IOException exc) {
+ throw exc;
+ }
+ catch (final Exception exc) {
+ throw new IOException("No Python for jedi-language-server", exc);
+ }
+ log.getParentFile().mkdirs();
+ if (log.length() > MAX_LOG) log.delete();
+ final ProcessBuilder pb = new ProcessBuilder(exe, "-c", LAUNCHER, String
+ .valueOf(ProcessHandle.current().pid()));
+ pb.redirectError(ProcessBuilder.Redirect.appendTo(log));
+ return pb.start();
+ }
+
+ @Override
+ public Object initializationOptions(final Environment env) {
+ // Workspace options: where the scripts run.
+ // NB: Without an environment, Jedi uses the server's own Python.
+ final Map workspaceOptions = new LinkedHashMap<>();
+ env.interpreter().ifPresent(p -> workspaceOptions.put("environmentPath",
+ p));
+ workspaceOptions.put("extraPaths", env.paths());
+
+ // Diagnostics options: Jedi's diagnostics are syntax errors, found as the
+ // script changes. NB: These are jedi-language-server's defaults (but for
+ // didSave, which is never sent), stated as what we rely on.
+ final Map diagnostics = new LinkedHashMap<>();
+ diagnostics.put("enable", true);
+ diagnostics.put("didOpen", true);
+ diagnostics.put("didChange", true);
+ diagnostics.put("didSave", false);
+
+ // Combine them all
+ final Map options = new LinkedHashMap<>();
+ options.put("workspace", workspaceOptions);
+ options.put("diagnostics", diagnostics);
+ return options;
+ }
+
+ @Override
+ public String rootUri(final Environment env) {
+ // NB: jedi-language-server applies its workspace options only with a
+ // root. We give the server an empty one
+ workspace.mkdirs();
+ return workspace.toURI().toString();
+ }
+
+ // -- Helper methods --
+
+ /**
+ * Gets the Python of the server's environment: Python, Jedi,
+ * jedi-language-server and uv; built first if needed (once per session,
+ * which is quick if nothing changed).
+ */
+ static synchronized String python() throws Exception {
+ if (python != null && new File(python).isFile()) return python;
+ final String found = pythonExecutable(Appose.content(config()).scheme(
+ "pixi.toml").build());
+ if (found == null) throw new IOException("No Python in " + ENV);
+ python = found;
+ return python;
+ }
+
+ /** The server's environment, as a {@code pixi.toml}. */
+ static String config() {
+ return "[workspace]\n" + //
+ "name = \"" + ENV + "\"\n" + //
+ "channels = [\"conda-forge\"]\n" + //
+ "platforms = [\"" + condaPlatform() + "\"]\n" + //
+ "\n" + //
+ "[dependencies]\n" + //
+ "python = \"3.12.*\"\n" + //
+ "jedi = \">=0.19.2\"\n" + //
+ // NB: So that the types scientific scripts use most are known before
+ // their environment is (e.g. the NumPy arrays Appose scripts get images
+ // as).
+ "numpy = \"*\"\n" + //
+ "\n" + //
+ "[pypi-dependencies]\n" + //
+ "jedi-language-server = \">=0.47\"\n";
+ }
+
+ /** The conda platform (e.g. {@code linux-64}) of this machine. */
+ private static String condaPlatform() {
+ final String os = Platforms.isWindows() ? "win" : Platforms.isMacOS()
+ ? "osx" : "linux";
+ switch (Platforms.ARCH) {
+ case ARM64:
+ return os + (Platforms.isLinux() ? "-aarch64" : "-arm64");
+ case PPC64LE:
+ return os + "-ppc64le";
+ default:
+ return os + "-64";
+ }
+ }
+
+ /** Finds the Python executable of a built environment, or null. */
+ static String pythonExecutable(final org.apposed.appose.Environment env) {
+ // NB: Look in the bin paths, and in their parents: on Windows, conda-style
+ // (e.g. pixi) environments keep python.exe in the environment's root,
+ // above the bin directory that Appose reports.
+ final List dirs = new ArrayList<>();
+ for (final String dir : env.binPaths()) {
+ dirs.add(new File(dir));
+ final File parent = new File(dir).getParentFile();
+ if (parent != null) dirs.add(parent);
+ }
+ if (env.base() != null) dirs.add(new File(env.base()));
+ for (final File dir : dirs) {
+ for (final String exe : new String[] { "python.exe", "python",
+ "python3" })
+ {
+ final File f = new File(dir, exe);
+ if (f.isFile()) return f.getAbsolutePath();
+ }
+ }
+ return null;
+ }
+}
diff --git a/scijava-code-lsp-jedi/src/main/java/org/scijava/code/lsp/jedi/PythonLanguageServer.java b/scijava-code-lsp-jedi/src/main/java/org/scijava/code/lsp/jedi/PythonLanguageServer.java
new file mode 100644
index 000000000..dd5c71611
--- /dev/null
+++ b/scijava-code-lsp-jedi/src/main/java/org/scijava/code/lsp/jedi/PythonLanguageServer.java
@@ -0,0 +1,482 @@
+/*-
+ * #%L
+ * Python code intelligence for SciJava, via jedi-language-server.
+ * %%
+ * Copyright (C) 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.code.lsp.jedi;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.LongSupplier;
+import java.util.regex.Pattern;
+
+import org.eclipse.lsp4j.CompletionItem;
+import org.eclipse.lsp4j.CompletionItemKind;
+import org.eclipse.lsp4j.CompletionList;
+import org.eclipse.lsp4j.CompletionParams;
+import org.eclipse.lsp4j.DidChangeConfigurationParams;
+import org.eclipse.lsp4j.DidChangeTextDocumentParams;
+import org.eclipse.lsp4j.DidChangeWatchedFilesParams;
+import org.eclipse.lsp4j.DidCloseTextDocumentParams;
+import org.eclipse.lsp4j.DidOpenTextDocumentParams;
+import org.eclipse.lsp4j.DidSaveTextDocumentParams;
+import org.eclipse.lsp4j.Hover;
+import org.eclipse.lsp4j.HoverParams;
+import org.eclipse.lsp4j.InitializeParams;
+import org.eclipse.lsp4j.InitializeResult;
+import org.eclipse.lsp4j.InitializedParams;
+import org.eclipse.lsp4j.SignatureHelp;
+import org.eclipse.lsp4j.SignatureHelpParams;
+import org.eclipse.lsp4j.jsonrpc.messages.Either;
+import org.eclipse.lsp4j.services.LanguageClient;
+import org.eclipse.lsp4j.services.LanguageClientAware;
+import org.eclipse.lsp4j.services.LanguageServer;
+import org.eclipse.lsp4j.services.TextDocumentService;
+import org.eclipse.lsp4j.services.WorkspaceService;
+import org.scijava.code.lsp.jvm.Callable;
+import org.scijava.code.lsp.Positions;
+
+/**
+ * A {@link LanguageServer} wrapping jedi-language-server, with a few nice
+ * additions:
+ *
+ *
Asks about a word's first character only, and reuses the answer
+ * (filtered) as the word is typed on, for a while.
+ *
Cancels a question still pending once another is asked about the same
+ * document.
+ *
Once a word is begun, resolves the first completions, and makes those
+ * with signatures (e.g. {@code def dumps(obj, *, skipkeys=False) -> str})
+ * {@link Callable}s: snippets with their parameters and types. (Not in
+ * imports: an import names things, rather than calling them.)
+ *
+ *
+ * Everything else is passed on to the server as is.
+ *
+ *
+ * @author Gabriel Selzer
+ */
+class PythonLanguageServer implements LanguageServer,
+ TextDocumentService, WorkspaceService, LanguageClientAware
+{
+
+ /** How long an answer may be reused, in milliseconds. */
+ private static final long ANSWER_LIFETIME = 20_000;
+
+ /** An import statement being typed. */
+ private static final Pattern IMPORT = Pattern.compile(
+ "^\\s*(from|import)\\s");
+
+ /** The Python language server. */
+ private final LanguageServer inner;
+
+ private final int signatures;
+ private final LongSupplier clock;
+
+ /** The open documents' texts, by URI. */
+ private final Map texts = new ConcurrentHashMap<>();
+
+ /** Answers, by question; least recently used first. */
+ private final Map answers = new LinkedHashMap(
+ 16, 0.75f, true)
+ {
+
+ @Override
+ protected boolean removeEldestEntry(final Map.Entry e) {
+ return size() > 32;
+ }
+ };
+
+ /** The latest question about each document, by URI. */
+ private final Map pending = new ConcurrentHashMap<>();
+
+ /**
+ * @param inner The Python language server.
+ * @param signatures How many completions to resolve for their signatures,
+ * once a word is begun.
+ */
+ PythonLanguageServer(final LanguageServer inner,
+ final int signatures)
+ {
+ this(inner, signatures, System::currentTimeMillis);
+ }
+
+ PythonLanguageServer(final LanguageServer inner, final int signatures,
+ final LongSupplier clock)
+ {
+ this.inner = inner;
+ this.signatures = signatures;
+ this.clock = clock;
+ }
+
+ // -- LanguageClientAware --
+
+ @Override
+ public void connect(final LanguageClient client) {
+ if (inner instanceof LanguageClientAware) {
+ ((LanguageClientAware) inner).connect(client);
+ }
+ }
+
+ // -- LanguageServer --
+
+ @Override
+ public CompletableFuture initialize(
+ final InitializeParams params)
+ {
+ return inner.initialize(params);
+ }
+
+ @Override
+ public void initialized(final InitializedParams params) {
+ inner.initialized(params);
+ }
+
+ @Override
+ public CompletableFuture