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-jython scijava-collections scijava-common3 scijava-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: + *

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

+ * + * @author Gabriel Selzer + */ +final class TypedAST { + + /** Identifier marking the caret position in the source. */ + static final String CARET = "__scijava_caret__"; + + /** Cache recent trees for each source file. */ + private static final Map CACHE = + new LinkedHashMap(16, 0.75f, true) + { + + @Override + protected boolean removeEldestEntry( + final Map.Entry eldest) + { + return size() > 16; + } + }; + + private final SourceUnit source; + private final ModuleNode module; + private final ClassLoader loader; + + private TypedAST(final SourceUnit source, final ClassLoader loader) { + this.source = source; + this.module = source.getAST(); + this.loader = loader; + } + + // -- Source preparation -- + + /** + * Marks the caret: the script up to the caret, 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 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 shutdown() { + return inner.shutdown(); + } + + @Override + public void exit() { + inner.exit(); + } + + @Override + public TextDocumentService getTextDocumentService() { + return this; + } + + @Override + public WorkspaceService getWorkspaceService() { + return this; + } + + // -- TextDocumentService -- + + @Override + public void didOpen(final DidOpenTextDocumentParams params) { + texts.put(params.getTextDocument().getUri(), params.getTextDocument() + .getText()); + inner.getTextDocumentService().didOpen(params); + } + + @Override + public void didChange(final DidChangeTextDocumentParams params) { + if (!params.getContentChanges().isEmpty()) { + texts.put(params.getTextDocument().getUri(), params.getContentChanges() + .get(params.getContentChanges().size() - 1).getText()); + } + inner.getTextDocumentService().didChange(params); + } + + @Override + public void didClose(final DidCloseTextDocumentParams params) { + texts.remove(params.getTextDocument().getUri()); + pending.remove(params.getTextDocument().getUri()); + inner.getTextDocumentService().didClose(params); + } + + @Override + public CompletableFuture, CompletionList>> + completion(final CompletionParams params) + { + final String uri = params.getTextDocument().getUri(); + final String text = texts.get(uri); + if (text == null) return inner.getTextDocumentService().completion( + params); + final int caret = Math.min(Positions.offset(text, params.getPosition()), + text.length()); + int start = caret; + while (start > 0 && Character.isJavaIdentifierPart(text.charAt(start - + 1))) start--; + final String word = text.substring(start, caret); + // Ask about the first character typed, if any: later keystrokes of the + // same word then reuse the answer (filtered). + final int ask = start + Math.min(1, word.length()); + final String line = text.substring(text.lastIndexOf('\n', start - 1) + 1, + start); + final boolean importing = IMPORT.matcher(line).find(); + final String key = uri + "\n" + text.substring(0, ask); + final long now = clock.getAsLong(); + Answer answer; + synchronized (answers) { + answer = answers.get(key); + // NB: Not an empty answer either: the server may know more by now (e.g. + // once the script's packages are, or its environment is built). + if (answer != null && (now - answer.time > ANSWER_LIFETIME || + answer.future.isCompletedExceptionally() || answer.isEmpty())) + { + answer = null; + } + } + if (answer == null) { + final CompletionParams p = new CompletionParams(params.getTextDocument(), + Positions.position(text, ask), params.getContext()); + final CompletableFuture, CompletionList>> raw = + inner.getTextDocumentService().completion(p); + final int n = word.isEmpty() || importing ? 0 : signatures; + answer = new Answer(raw, raw.thenCompose(list -> resolve(list, n)), now); + synchronized (answers) { + answers.put(key, answer); + } + final Answer previous = pending.put(uri, answer); + if (previous != null && previous != answer && !previous.future + .isDone()) + { + // NB: Cancelling the request tells the server ($/cancelRequest). + previous.future.cancel(true); + previous.raw.cancel(true); + } + } + return answer.future.thenApply(list -> Either.forRight(filter(list, + word))); + } + + @Override + public void didSave(final DidSaveTextDocumentParams params) { + inner.getTextDocumentService().didSave(params); + } + + @Override + public CompletableFuture resolveCompletionItem( + final CompletionItem item) + { + return inner.getTextDocumentService().resolveCompletionItem(item); + } + + @Override + public CompletableFuture signatureHelp( + final SignatureHelpParams params) + { + return inner.getTextDocumentService().signatureHelp(params); + } + + @Override + public CompletableFuture hover(final HoverParams params) { + return inner.getTextDocumentService().hover(params); + } + + // -- WorkspaceService -- + + @Override + public void didChangeConfiguration(final DidChangeConfigurationParams p) { + inner.getWorkspaceService().didChangeConfiguration(p); + } + + @Override + public void didChangeWatchedFiles(final DidChangeWatchedFilesParams p) { + inner.getWorkspaceService().didChangeWatchedFiles(p); + } + + // -- Helper methods -- + + /** Resolves the first completions, making those with signatures callables. */ + private CompletableFuture resolve( + final Either, CompletionList> answer, final int n) + { + final List items = new ArrayList<>(answer == null + ? new ArrayList<>() : answer.isRight() ? answer.getRight().getItems() + : answer.getLeft()); + items.sort(Comparator.comparing(i -> i.getSortText() != null ? i + .getSortText() : i.getLabel())); + final int count = Math.min(n, items.size()); + final List> resolved = new ArrayList<>(); + for (int i = 0; i < count; i++) { + // NB: Unresolved, should resolving fail. + resolved.add(inner.getTextDocumentService().resolveCompletionItem(items + .get(i)).handle((r, error) -> r)); + } + return CompletableFuture.allOf(resolved.toArray(new CompletableFuture[0])) + .thenApply(x -> { + for (int i = 0; i < count; i++) { + final CompletionItem r = resolved.get(i).join(); + final CompletionItem callable = r == null ? null : callable(items + .get(i), r); + if (callable != null) items.set(i, callable); + } + return new CompletionList(false, items); + }); + } + + /** + * A completion as a callable, from its resolved signature (its detail); + * or null if it has none. + */ + private static CompletionItem callable(final CompletionItem item, + final CompletionItem resolved) + { + final CompletionItemKind kind = item.getKind(); + if (kind != CompletionItemKind.Function && kind != CompletionItemKind.Method && + kind != CompletionItemKind.Class && kind != CompletionItemKind.Constructor) + { + return null; + } + final String name = item.getInsertText() != null ? item.getInsertText() + : item.getLabel(); + if (resolved.getDetail() == null) return null; + final Callable parsed = parse(name, resolved.getDetail()); + if (parsed == null) return null; + final CompletionItem out = new Callable(name, parsed.params(), parsed + .returnType()).kind(CompletionItemKind.Method).detail(resolved + .getDetail()).toItem(); + out.setSortText(item.getSortText()); + out.setDocumentation(resolved.getDocumentation()); + out.setData(item.getData()); + return out; + } + + /** The completions of the word typed so far. */ + private static CompletionList filter(final CompletionList list, + final String word) + { + final String w = word.toLowerCase(Locale.ROOT); + final List out = new ArrayList<>(); + for (final CompletionItem item : list.getItems()) { + final String name = item.getFilterText() != null ? item.getFilterText() + : item.getLabel(); + if (name.startsWith("_") && !word.startsWith("_")) continue; + if (!name.toLowerCase(Locale.ROOT).startsWith(w) || name.equals(word)) { + continue; + } + out.add(item); + } + return new CompletionList(false, out); + } + + private static final class Answer { + + private final CompletableFuture raw; + private final CompletableFuture future; + private final long time; + + private Answer(final CompletableFuture raw, + final CompletableFuture future, final long time) + { + this.raw = raw; + this.future = future; + this.time = time; + } + + /** Whether the server answered, with no completions. */ + private boolean isEmpty() { + final CompletionList list = future.getNow(null); + return list != null && list.getItems().isEmpty(); + } + } + + /** + * Parses a Python signature (as Jedi writes them): the parameters, with + * their annotations as types; and the return annotation. Returns null if it + * is not a signature. + */ + static Callable parse(final String name, + final String signature) + { + final int open = signature.indexOf('('); + if (open < 0) return null; + final int close = closing(signature, open); + if (close < 0) return null; + final List params = new ArrayList<>(); + for (final String part : split(signature.substring(open + 1, close))) { + final String p = part.trim(); + // NB: Bare * and / only mark keyword-only and positional-only ones. + if (p.isEmpty() || p.equals("*") || p.equals("/")) continue; + final int colon = topLevel(p, ':'), eq = topLevel(p, '='); + String paramName = p, type = null; + if (colon >= 0 && (eq < 0 || colon < eq)) { + paramName = p.substring(0, colon); + type = p.substring(colon + 1, eq < 0 ? p.length() : eq).trim(); + } + else if (eq >= 0) paramName = p.substring(0, eq); + paramName = paramName.trim().replaceFirst("^\\*{1,2}", ""); + params.add(new Callable.Param(paramName, type == null || type + .isEmpty() ? null : type)); + } + final String rest = signature.substring(close + 1).trim(); + final String returns = rest.startsWith("->") ? rest.substring(2).trim() + : null; + return new Callable(name, params, returns == null || returns.isEmpty() + ? null : returns); + } + + /** The index of the bracket closing the one at {@code open}, or -1. */ + private static int closing(final String s, final int open) { + int depth = 0; + char quote = 0; + for (int i = open; i < s.length(); i++) { + final char c = s.charAt(i); + if (quote != 0) { + if (c == '\\') i++; + else if (c == quote) quote = 0; + } + else if (c == '\'' || c == '"') quote = c; + else if (c == '(' || c == '[' || c == '{') depth++; + else if (c == ')' || c == ']' || c == '}') { + if (--depth == 0) return i; + } + } + return -1; + } + + /** Splits at the commas outside brackets and strings. */ + private static List split(final String s) { + final List parts = new ArrayList<>(); + int start = 0; + while (true) { + final int comma = topLevel(s.substring(start), ','); + if (comma < 0) break; + parts.add(s.substring(start, start + comma)); + start += comma + 1; + } + parts.add(s.substring(start)); + return parts; + } + + /** The index of the first {@code target} outside brackets and strings. */ + private static int topLevel(final String s, final char target) { + int depth = 0; + char quote = 0; + for (int i = 0; i < s.length(); i++) { + final char c = s.charAt(i); + if (quote != 0) { + if (c == '\\') i++; + else if (c == quote) quote = 0; + } + else if (c == '\'' || c == '"') quote = c; + else if (c == '(' || c == '[' || c == '{') depth++; + else if (c == ')' || c == ']' || c == '}') depth--; + else if (c == target && depth == 0) return i; + } + return -1; + } +} diff --git a/scijava-code-lsp-jedi/src/main/java/org/scijava/code/lsp/jedi/PythonServerPlugin.java b/scijava-code-lsp-jedi/src/main/java/org/scijava/code/lsp/jedi/PythonServerPlugin.java new file mode 100644 index 000000000..64c2f5994 --- /dev/null +++ b/scijava-code-lsp-jedi/src/main/java/org/scijava/code/lsp/jedi/PythonServerPlugin.java @@ -0,0 +1,91 @@ +/*- + * #%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 org.eclipse.lsp4j.services.LanguageServer; +import org.scijava.Disposable; +import org.scijava.code.lsp.Environment; +import org.scijava.code.lsp.LanguageServerPlugin; +import org.scijava.code.lsp.server.ServerProcesses; +import org.scijava.log.LogService; +import org.scijava.log.StderrLogService; +import org.scijava.plugin.Parameter; +import org.scijava.plugin.Plugin; + +/** + * Serves {@code python}: jedi-language-server (see {@link JediLauncher}), one + * process per environment, made to work well in an editor (see + * {@link PythonLanguageServer}). A server analyzes scripts in exactly the + * environment it is given. The processes stop when this is disposed. + * + * @author Gabriel Selzer + */ +@Plugin(type = LanguageServerPlugin.class, name = "Python") +public class PythonServerPlugin implements LanguageServerPlugin, Disposable { + + /** How many completions get signatures, once a word is begun. */ + private static final int SIGNATURES = 30; + + @Parameter(required = false) + private LogService log; + + private ServerProcesses jedi; + + @Override + public boolean supports(final String languageId) { + return "python".equals(languageId); + } + + @Override + public LanguageServer create(final String languageId, + final Environment environment) + { + return new PythonLanguageServer(jedi().server(environment), SIGNATURES); + } + + // -- Disposable methods -- + + @Override + public synchronized void dispose() { + if (jedi != null) jedi.shutdown(); + jedi = null; + } + + // -- Helper methods -- + + /** Gets the jedi-language-server processes, creating them on first use. */ + private synchronized ServerProcesses jedi() { + if (jedi == null) { + jedi = new ServerProcesses(new JediLauncher(), log != null ? log + : new StderrLogService()); + } + return jedi; + } +} diff --git a/scijava-code-lsp-jedi/src/test/java/org/scijava/code/lsp/jedi/JediLauncherTest.java b/scijava-code-lsp-jedi/src/test/java/org/scijava/code/lsp/jedi/JediLauncherTest.java new file mode 100644 index 000000000..f072c4185 --- /dev/null +++ b/scijava-code-lsp-jedi/src/test/java/org/scijava/code/lsp/jedi/JediLauncherTest.java @@ -0,0 +1,159 @@ +/*- + * #%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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.File; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import org.apposed.appose.util.Environments; +import org.junit.jupiter.api.Test; +import org.scijava.code.lsp.Environment; + +/** + * Unit tests for {@link JediLauncher}. + * + * @author Gabriel Selzer + */ +public class JediLauncherTest { + + /** + * Tests that the plugin offers servers for {@code python} documents + */ + @Test + public void testLanguageId() { + final JediLauncher plugin = new JediLauncher(); + assertEquals("python", plugin.languageId()); + } + + /** + * Tests the initialization options the plugin gives a server. + *

+ * NB This map populates the "initializationOptions" key in the first + * message to the LSP server + *

+ */ + @Test + public void testLanguageInitializationOptions() { + final JediLauncher plugin = new JediLauncher(); + // Whatever the environment, Jedi reports syntax errors as a script is + // opened and edited (rather than only when it is saved). + final Map diagnostics = Map.of("enable", true, // + "didOpen", true, "didChange", true, "didSave", false); + + // For a script running in some environment, the options tell Jedi to + // analyze it with that environment's Python, also searching its extra + // paths (e.g. a dependency overlay)... + final Environment env = new Environment().withInterpreter("/env/bin/python") + .withPaths(List.of("/overlay")); + assertEquals(Map.of( // + "workspace", Map.of("environmentPath", "/env/bin/python", // + "extraPaths", List.of("/overlay")), // + "diagnostics", diagnostics), plugin.initializationOptions(env)); + + // ...and for one in no particular environment, they name no Python, so + // Jedi uses the server's own. + assertEquals(Map.of( // + "workspace", Map.of("extraPaths", List.of()), // + "diagnostics", diagnostics), plugin.initializationOptions( + new Environment())); + } + + /** + * Tests a workspace root directory is always given. + */ + @Test + public void testRootUri() { + final JediLauncher plugin = new JediLauncher(); + final String root = plugin.rootUri(new Environment()); + assertTrue(new File(URI.create(root)).isDirectory(), root); + } + + /** + * Ensures the server closes (eventually) when the parent is killed before + * it can shut down the server (e.g. a crash): otherwise, each would leave a + * Python process behind. The server runs under the + * {@link JediLauncher#LAUNCHER}, which watches its parent. + * Skipped unless the server's environment is already built. + *

+ * This JVM cannot be killed here, so the launcher is told to watch a + * process that has already exited instead. NB: Only on Windows does it then + * really wait on that process; elsewhere, it exits at its first check, as + * its actual parent (this JVM) was never the one given. + *

+ */ + @Test + public void testServerExitsWithoutParent() throws Exception { + // NB: Only once the server's environment is built (e.g. by using it): + // building it here would need network access. + final String python = python(new File(Environments.apposeEnvsDir(), + JediLauncher.ENV)); + assumeTrue(python != null, "The server's environment is not built"); + + // A "parent" that is already gone: a process that did nothing, and exited. + final Process gone = new ProcessBuilder(python, "-c", "pass").start(); + gone.waitFor(); + + // The server, as the plugin starts it, but told to watch that process. + final Process process = new ProcessBuilder(python, "-c", + JediLauncher.LAUNCHER, String.valueOf(gone.pid())) + .redirectErrorStream(true).start(); + + // It must exit soon after, rather than run on. + final boolean exited = process.waitFor(30, TimeUnit.SECONDS); + if (!exited) process.destroyForcibly(); + assertTrue(exited); + + // And it must be the watcher that ended it (with exit code 0), not a + // failure (e.g. to import jedi-language-server), whose output (e.g. a + // traceback) then says why. NB: A failure slower than the watcher may + // still go unnoticed; JediLifecycleIntegrationTest uses the server. + final String output = new String(process.getInputStream().readAllBytes()); + assertEquals(0, process.exitValue(), output); + } + + /** The Python of a pixi environment, as Appose builds them; or null. */ + private static String python(final File envDir) { + final File env = new File(envDir, ".pixi/envs/default"); + for (final File f : new File[] { new File(env, "python.exe"), new File(env, + "bin/python") }) + { + if (f.isFile()) return f.getAbsolutePath(); + } + return null; + } +} diff --git a/scijava-code-lsp-jedi/src/test/java/org/scijava/code/lsp/jedi/JediLifecycleIntegrationTest.java b/scijava-code-lsp-jedi/src/test/java/org/scijava/code/lsp/jedi/JediLifecycleIntegrationTest.java new file mode 100644 index 000000000..5b5beb27b --- /dev/null +++ b/scijava-code-lsp-jedi/src/test/java/org/scijava/code/lsp/jedi/JediLifecycleIntegrationTest.java @@ -0,0 +1,116 @@ +/*- + * #%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 static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.awaitility.Awaitility.await; + +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.eclipse.lsp4j.*; +import org.eclipse.lsp4j.services.LanguageServer; +import org.junit.jupiter.api.Test; +import org.scijava.Context; +import org.scijava.code.lsp.Environment; +import org.scijava.code.lsp.LanguageServerService; +import org.scijava.code.lsp.Positions; + + +/** + * Integration tests for {@link PythonServerPlugin}. + * Additionally useful for understanding usage. + * + * @author Gabriel Selzer + */ +public class JediLifecycleIntegrationTest { + + @Test + public void testLifecycle() throws Exception { + try (final Context context = new Context(LanguageServerService.class)){ + // Get a LanguageServer Object + final LanguageServerService service = context.service(LanguageServerService.class); + final LanguageServer server = service.launch("python", new Environment()); + // Start it up + server.initialize(new InitializeParams()).get(); + server.initialized(new InitializedParams()); + + // Open a script... + final String uri = "untitled:/example.py"; + final String script = "import json\n" + // + "data = json.loads('[1, 2]')\n" + // + "json.du"; + server.getTextDocumentService().didOpen(new DidOpenTextDocumentParams( + new TextDocumentItem(uri, "python", 1, script))); + + // ...ask for completions at its end ("json.du|")... + final CompletionParams params = new CompletionParams( + new TextDocumentIdentifier(uri), Positions.position(script, script.length()) + ); + final List items = server.getTextDocumentService() + .completion(params) + .get(15, TimeUnit.MINUTES) + .map(list -> list, CompletionList::getItems); + final List labels = items.stream().map(CompletionItem::getLabel) + .collect(Collectors.toList()); + assertTrue(labels.contains("dumps"), labels.toString()); + assertTrue(labels.contains("dump"), labels.toString()); + + // ...and what "loads" is, by hovering over it: its signature, then its + // documentation (in Markdown). + final Hover hover = server.getTextDocumentService().hover( + new HoverParams(new TextDocumentIdentifier(uri), Positions.position( + script, script.indexOf("loads")))).get(1, TimeUnit.MINUTES); + assertNotNull(hover); + final String contents = hover.getContents().getRight().getValue(); + assertTrue(contents.startsWith("def loads("), contents); + + // The server runs as a process of this JVM's. + assertFalse(servers().isEmpty()); + + // Shut it down: the server, then the service, which stops the + // processes. + server.shutdown().get(); + + } + await().atMost(30, TimeUnit.SECONDS) + .pollInterval(100, TimeUnit.MILLISECONDS) + .until(() -> servers().isEmpty()); + } + + /** The processes this JVM has started that are still running. */ + private static List servers() { + return ProcessHandle.current().descendants().filter(ProcessHandle::isAlive) + .collect(Collectors.toList()); + } +} diff --git a/scijava-code-lsp-jedi/src/test/java/org/scijava/code/lsp/jedi/PythonLanguageServerIntegrationTest.java b/scijava-code-lsp-jedi/src/test/java/org/scijava/code/lsp/jedi/PythonLanguageServerIntegrationTest.java new file mode 100644 index 000000000..911f8c7b0 --- /dev/null +++ b/scijava-code-lsp-jedi/src/test/java/org/scijava/code/lsp/jedi/PythonLanguageServerIntegrationTest.java @@ -0,0 +1,258 @@ +/*- + * #%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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import javax.script.ScriptEngine; + +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionList; +import org.eclipse.lsp4j.Diagnostic; +import org.eclipse.lsp4j.Hover; +import org.eclipse.lsp4j.SignatureHelp; +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.LanguageServerService; +import org.scijava.code.lsp.Positions; +import org.scijava.code.lsp.ScriptDialect; +import org.scijava.code.lsp.ScriptSession; +import org.scijava.plugin.Plugin; +import org.scijava.script.AbstractScriptLanguage; +import org.scijava.script.ScriptLanguage; + +/** + * End-to-end tests of the {@code python} server (see + * {@link PythonServerPlugin}), with a real jedi-language-server, on plain + * Python, as editors use it. + *

+ * The first request builds jedi-language-server's environment, which + * requires network access. + *

+ * + * @author Gabriel Selzer + */ +public class PythonLanguageServerIntegrationTest { + + /** How long to wait for environments to build. */ + private static final long BUILD_TIMEOUT = TimeUnit.MINUTES.toMillis(15); + + /** A test fixture: a script language whose scripts are plain Python. */ + private static final ScriptLanguage TEST_LANGUAGE = + new AbstractScriptLanguage() + { + + @Override + public ScriptEngine getScriptEngine() { + return null; + } + }; + + private static Context context; + private static LanguageServerService servers; + private static ScriptSession session; + + @BeforeAll + public static void setUp() { + context = new Context(LanguageServerService.class); + servers = context.service(LanguageServerService.class); + session = session(); + } + + @AfterAll + public static void tearDown() { + if (session != null) session.close(); + if (context != null) context.dispose(); + } + + @Test + public void testStandardLibrary() throws Exception { + // The server knows the standard library, with signatures and + // documentation. + final List completions = completeUntil( + "import json\njson.du", "dumps"); + final CompletionItem dumps = completions.stream().filter(c -> c.getLabel() + .equals("dumps") && c.getLabelDetails() != null).findFirst().get(); + assertTrue(dumps.getLabelDetails().getDetail().startsWith( + "(Any obj, bool skipkeys"), dumps.getLabelDetails().getDetail()); + assertTrue(dumps.getDocumentation() != null || session.resolve(dumps).get( + 1, TimeUnit.MINUTES).getDocumentation() != null); + report("import json\njson.l", "loads", "o"); + } + + @Test + public void testSignatureHelp() throws Exception { + final String code = "import json\njson.dumps(obj, "; + final long deadline = System.currentTimeMillis() + BUILD_TIMEOUT; + SignatureHelp help = null; + while (help == null && System.currentTimeMillis() < deadline) { + help = session.signatureHelp(code, code.length()).get(1, + TimeUnit.MINUTES); + } + assertTrue(help.getSignatures().get(0).getLabel().contains("dumps(")); + assertFalse(help.getSignatures().get(0).getParameters().isEmpty()); + assertEquals(Integer.valueOf(1), help.getActiveParameter()); + } + + @Test + public void testHover() throws Exception { + final String code = "import json\njson.dumps(1)"; + final long deadline = System.currentTimeMillis() + BUILD_TIMEOUT; + Hover hover = null; + while (hover == null && System.currentTimeMillis() < deadline) { + hover = session.hover(code, code.indexOf("dumps") + 2).get(1, + TimeUnit.MINUTES); + } + assertTrue(hover.getContents().getRight().getValue().contains("JSON"), + String.valueOf(hover)); + // The word's range. + assertEquals(Positions.position(code, code.indexOf("dumps")), hover + .getRange().getStart()); + assertEquals(Positions.position(code, code.indexOf("dumps") + 5), hover + .getRange().getEnd()); + } + + @Test + public void testDiagnostics() throws Exception { + // A syntax error, on the second line. + final String code = "x = 1\ny = (1 +\n"; + final long deadline = System.currentTimeMillis() + BUILD_TIMEOUT; + List problems = Collections.emptyList(); + while (problems.isEmpty() && System.currentTimeMillis() < deadline) { + problems = problems(code); + } + assertFalse(problems.isEmpty()); + assertTrue(problems.get(0).getRange().getStart().getLine() >= 1, problems + .toString()); + // Fixed: no more problems. + assertTrue(problems(code.replace("(1 +", "(1 + 2)")).isEmpty()); + } + + // -- Helper methods -- + + /** A session about plain Python, with a server of its own. */ + private static ScriptSession session() { + return new ScriptSession(servers, TEST_LANGUAGE); + } + + @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 "python"; + } + + @Override + public Header header(final Map variables) { + return new Header("", Collections.emptySet()); + } + } + + /** The problems found in a script, in a session of its own. */ + private static List problems(final String code) + throws Exception + { + final CompletableFuture> found = + new CompletableFuture<>(); + try (ScriptSession s = session()) { + s.setDiagnosticsListener(found::complete); + s.update(code); + return found.get(120, TimeUnit.SECONDS); + } + } + + private static List completeUntil(final String code, + final String expected) throws Exception + { + return completeUntil(session, code, expected); + } + + /** Completes until the expected completion appears (environments build). */ + private static List completeUntil(final ScriptSession s, + final String code, final String expected) throws Exception + { + final long deadline = System.currentTimeMillis() + BUILD_TIMEOUT; + while (System.currentTimeMillis() < deadline) { + final List completions = complete(s, code); + if (completions.stream().anyMatch(c -> c.getLabel().equals(expected))) { + return completions; + } + Thread.sleep(1000); + } + fail("No '" + expected + "' completion for: " + code); + return null; + } + + /** Completes at the end of the code. */ + private static List complete(final ScriptSession s, + final String code) throws Exception + { + return s.completion(code, code.length()).get(1, TimeUnit.MINUTES) + .getItems(); + } + + /** + * Prints how long completions take, once warm: for another keystroke of a + * word the server has answered (cached); and for a new word. + */ + private static void report(final String code, final String expected, + final String more) throws Exception + { + completeUntil(code, expected); + long t0 = System.nanoTime(); + final int n = complete(session, code + more).size(); + final long cached = System.nanoTime() - t0; + final String other = code.substring(0, code.lastIndexOf('.') + 1) + "s"; + t0 = System.nanoTime(); + final CompletionList all = session.completion(other, other.length()).get( + 1, TimeUnit.MINUTES); + final long arrived = System.nanoTime() - t0; + System.out.println("[completion timing] same word (cached): " + cached / + 1000000 + " ms, " + n + " completions; new word: " + all.getItems() + .size() + " completions after " + arrived / 1000000 + " ms"); + } +} diff --git a/scijava-code-lsp-jedi/src/test/java/org/scijava/code/lsp/jedi/PythonLanguageServerTest.java b/scijava-code-lsp-jedi/src/test/java/org/scijava/code/lsp/jedi/PythonLanguageServerTest.java new file mode 100644 index 000000000..d1a2053b9 --- /dev/null +++ b/scijava-code-lsp-jedi/src/test/java/org/scijava/code/lsp/jedi/PythonLanguageServerTest.java @@ -0,0 +1,310 @@ +/*- + * #%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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +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.InitializeParams; +import org.eclipse.lsp4j.InitializeResult; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.ServerCapabilities; +import org.eclipse.lsp4j.TextDocumentContentChangeEvent; +import org.eclipse.lsp4j.TextDocumentIdentifier; +import org.eclipse.lsp4j.TextDocumentItem; +import org.eclipse.lsp4j.VersionedTextDocumentIdentifier; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import org.eclipse.lsp4j.services.LanguageServer; +import org.eclipse.lsp4j.services.TextDocumentService; +import org.eclipse.lsp4j.services.WorkspaceService; +import org.junit.jupiter.api.Test; +import org.scijava.code.lsp.jvm.Callable; +import org.scijava.code.lsp.Positions; + +/** + * Tests {@link PythonLanguageServer}, around a fake Python server. + * + * @author Gabriel Selzer + */ +public class PythonLanguageServerTest { + + private static final String URI = "untitled:/a.py"; + + /** Knows json's dumps (a function), dump, decoder (a module), __doc__. */ + private static final class Jedi implements LanguageServer, + TextDocumentService, WorkspaceService + { + + final List asked = new CopyOnWriteArrayList<>(); + final List resolved = new CopyOnWriteArrayList<>(); + CompletableFuture hold; + final List> held = new CopyOnWriteArrayList<>(); + /** Whether it knows nothing yet (e.g. while json is installed). */ + volatile boolean ignorant; + + @Override + public CompletableFuture initialize( + final InitializeParams params) + { + return CompletableFuture.completedFuture(new InitializeResult( + new ServerCapabilities())); + } + + @Override + public CompletableFuture shutdown() { + return CompletableFuture.completedFuture(null); + } + + @Override + public void exit() {} + + @Override + public TextDocumentService getTextDocumentService() { + return this; + } + + @Override + public WorkspaceService getWorkspaceService() { + return this; + } + + @Override + public void didOpen(final DidOpenTextDocumentParams p) {} + + @Override + public void didChange(final DidChangeTextDocumentParams p) {} + + @Override + public void didClose(final DidCloseTextDocumentParams p) {} + + @Override + public void didSave(final DidSaveTextDocumentParams p) {} + + @Override + public void didChangeConfiguration(final DidChangeConfigurationParams p) {} + + @Override + public void didChangeWatchedFiles(final DidChangeWatchedFilesParams p) {} + + @Override + public CompletableFuture, CompletionList>> + completion(final CompletionParams p) + { + asked.add(p.getPosition()); + final List items = new ArrayList<>(); + int i = 0; + for (final String name : ignorant ? new String[0] : new String[] { + "dumps", "dump", "decoder", "__doc__" }) + { + final CompletionItem item = new CompletionItem(name); + item.setKind(name.equals("decoder") ? CompletionItemKind.Module + : CompletionItemKind.Function); + item.setInsertText(name); + item.setSortText("z" + i++); + items.add(item); + } + final Either, CompletionList> answer = Either + .forRight(new CompletionList(items)); + if (hold == null) return CompletableFuture.completedFuture(answer); + final CompletableFuture, CompletionList>> f = + new CompletableFuture<>(); + held.add(f); + hold.thenRun(() -> f.complete(answer)); + return f; + } + + @Override + public CompletableFuture resolveCompletionItem( + final CompletionItem item) + { + resolved.add(item.getLabel()); + item.setDetail(item.getLabel().equals("decoder") ? "module decoder" + : "def " + item.getLabel() + "(obj, *, indent: int=None) -> str"); + return CompletableFuture.completedFuture(item); + } + } + + @Test + public void testAskOnceAndFilter() throws Exception { + final Jedi jedi = new Jedi(); + final PythonLanguageServer server = new PythonLanguageServer(jedi, 30); + open(server, "import json\njson.du"); + List items = complete(server, "import json\njson.du"); + // Asked about the word's first character. + assertEquals(new Position(1, 6), jedi.asked.get(0)); + // The word's completions, without dunders. + assertEquals(Arrays.asList("dumps", "dump"), labels(items)); + // Once a word is begun: callables, from the resolved signatures. + final CompletionItem dumps = items.get(0); + assertEquals("(obj, int indent)", dumps.getLabelDetails().getDetail()); + assertEquals("str", dumps.getLabelDetails().getDescription()); + assertEquals("dumps(${1:obj}, ${2:indent})", dumps.getInsertText()); + + // Typing on: the same answer, filtered; not asked again. + change(server, "import json\njson.dum", 2); + items = complete(server, "import json\njson.dum"); + assertEquals(Arrays.asList("dumps", "dump"), labels(items)); + // (The word typed in full is not offered again.) + change(server, "import json\njson.dumps", 3); + assertTrue(complete(server, "import json\njson.dumps").isEmpty()); + assertEquals(1, jedi.asked.size()); + + // Nothing typed: names only (nothing resolved), dunders left out. + change(server, "import json\njson.", 4); + final int resolved = jedi.resolved.size(); + items = complete(server, "import json\njson."); + assertEquals(Arrays.asList("dumps", "dump", "decoder"), labels(items)); + assertNull(items.get(0).getLabelDetails()); + assertEquals(resolved, jedi.resolved.size()); + } + + @Test + public void testEmptyAnswersAskedAgain() throws Exception { + final Jedi jedi = new Jedi(); + jedi.ignorant = true; + final PythonLanguageServer server = new PythonLanguageServer(jedi, 0); + open(server, "import json\njson.du"); + assertTrue(complete(server, "import json\njson.du").isEmpty()); + // It knows more now: asked again, rather than the empty answer reused. + jedi.ignorant = false; + assertEquals(Arrays.asList("dumps", "dump"), labels(complete(server, + "import json\njson.du"))); + assertEquals(2, jedi.asked.size()); + } + + @Test + public void testImportsAreNames() throws Exception { + final Jedi jedi = new Jedi(); + final PythonLanguageServer server = new PythonLanguageServer(jedi, 30); + open(server, "from json import du"); + final List items = complete(server, "from json import du"); + assertEquals(Arrays.asList("dumps", "dump"), labels(items)); + assertNull(items.get(0).getLabelDetails()); + } + + @Test + public void testSuperseded() throws Exception { + final Jedi jedi = new Jedi(); + jedi.hold = new CompletableFuture<>(); + final PythonLanguageServer server = new PythonLanguageServer(jedi, 0); + open(server, "x.a"); + final CompletableFuture, CompletionList>> slow = + server.completion(params("x.a")); + change(server, "x.b", 2); + final CompletableFuture, CompletionList>> next = + server.completion(params("x.b")); + // The first question is cancelled, also on the server. + assertThrows(Exception.class, () -> slow.get(10, TimeUnit.SECONDS)); + assertTrue(jedi.held.get(0).isCancelled()); + jedi.hold.complete(null); + assertTrue(next.get(10, TimeUnit.SECONDS).getRight().getItems().isEmpty()); + } + + // -- Helper methods -- + + private static CompletionParams params(final String text) { + return new CompletionParams(new TextDocumentIdentifier(URI), Positions + .position(text, text.length())); + } + + private static List complete( + final PythonLanguageServer server, final String text) throws Exception + { + return server.completion(params(text)).get(10, TimeUnit.SECONDS) + .getRight().getItems(); + } + + private static void open(final PythonLanguageServer server, + final String text) + { + server.didOpen(new DidOpenTextDocumentParams(new TextDocumentItem(URI, + "python", 1, text))); + } + + private static void change(final PythonLanguageServer server, + final String text, final int version) + { + server.didChange(new DidChangeTextDocumentParams( + new VersionedTextDocumentIdentifier(URI, version), Collections + .singletonList(new TextDocumentContentChangeEvent(text)))); + } + + private static List labels(final List items) { + return items.stream().map(CompletionItem::getLabel).collect(Collectors + .toList()); + } + + @Test + public void testParse() { + Callable c = PythonLanguageServer.parse("dumps", + "def dumps(obj, *, skipkeys: bool=False, cls=None, **kw) -> str"); + assertEquals("[obj, bool skipkeys, cls, kw]", String.valueOf(c + .params())); + assertEquals("str", c.returnType()); + + // Brackets and strings in annotations and defaults; positional-only. + c = PythonLanguageServer.parse("zeros", "def zeros(shape: tuple[int, ...], dtype: str=\"f,8\"" + + ", /, order: Literal['C', 'F']='C') -> NDArray[float64]"); + assertEquals("[tuple[int, ...] shape, str dtype, " + + "Literal['C', 'F'] order]", String.valueOf(c.params())); + assertEquals("NDArray[float64]", c.returnType()); + + // Classes (their constructors), without parameters or return type. + c = PythonLanguageServer.parse("Path", "class Path()"); + assertEquals(0, c.params().size()); + assertNull(c.returnType()); + c = PythonLanguageServer.parse("f", "def f(*args)"); + assertEquals("args", c.params().get(0).name()); + + // Not a signature. + assertNull(PythonLanguageServer.parse("x", "instance x")); + } +} diff --git a/scijava-code-lsp-jython/README.md b/scijava-code-lsp-jython/README.md new file mode 100644 index 000000000..f5cb60b04 --- /dev/null +++ b/scijava-code-lsp-jython/README.md @@ -0,0 +1,7 @@ +# SciJava Code LSP Jython + +Jython code intelligence for SciJava: a language server for `jython` (`JythonLanguageServer`, offered to `scijava-code-lsp` by `JythonLanguageServerPlugin`), which is a Python language server (jedi-language-server, by `scijava-code-lsp-jedi`) that knows Java classes from Python stubs of the Java packages a script imports (see `JavaStubs`). It completes Python and Java names and members, the variables of a running interpreter, and Java classes not imported yet (importing them as they are accepted); and it gives signature help and hover. + +Like Python scripts, it needs jedi-language-server's environment, which is built (with network access) the first time it is used. + +It knows plain Jython only. SciJava's Jython scripts become plain Jython as scripting-jython's dialect says (their `#@` parameters declared). diff --git a/scijava-code-lsp-jython/pom.xml b/scijava-code-lsp-jython/pom.xml new file mode 100644 index 000000000..9600ebac2 --- /dev/null +++ b/scijava-code-lsp-jython/pom.xml @@ -0,0 +1,147 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 45.1.0 + + + + scijava-code-lsp-jython + 0.1.0-SNAPSHOT + + SciJava Code LSP Jython + Jython code intelligence for SciJava, via Jython's own parser. + 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.jython + bsd_2 + SciJava developers. + + + 2.101.0 + 0.1.0-SNAPSHOT + 0.1.0-SNAPSHOT + + + + + + org.scijava + scijava-common + + + org.scijava + scijava-code-lsp + ${scijava-code-lsp.version} + + + + org.scijava + scijava-code-lsp-jedi + ${scijava-code-lsp-jedi.version} + + runtime + + + + + org.python + jython-slim + + + + + 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-jython/src/main/java/module-info.java b/scijava-code-lsp-jython/src/main/java/module-info.java new file mode 100644 index 000000000..6a019ce32 --- /dev/null +++ b/scijava-code-lsp-jython/src/main/java/module-info.java @@ -0,0 +1,5 @@ +open module org.scijava.code.lsp.jython { + requires org.scijava.code.lsp; + requires org.python.jython2; + requires java.scripting; +} diff --git a/scijava-code-lsp-jython/src/main/java/org/scijava/code/lsp/jython/JavaStubs.java b/scijava-code-lsp-jython/src/main/java/org/scijava/code/lsp/jython/JavaStubs.java new file mode 100644 index 000000000..8215e0680 --- /dev/null +++ b/scijava-code-lsp-jython/src/main/java/org/scijava/code/lsp/jython/JavaStubs.java @@ -0,0 +1,469 @@ +/*- + * #%L + * Jython code intelligence for SciJava, via Jython's own parser. + * %% + * Copyright (C) 2020 - 2025 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ +package org.scijava.code.lsp.jython; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Executable; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.Parameter; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.Deque; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.scijava.code.lsp.jvm.ClassIndex; + +/** + * Writes Python type stubs ({@code .pyi}) of Java packages, so that a Python + * language server (e.g. jedi-language-server) knows the Java classes a Jython + * script uses. + *

+ * For example, for {@code from ij import IJ}, the server finds + * {@code ij/__init__.pyi}, with {@code IJ}'s methods, their parameters and + * return types. + *

+ *

+ * Stubs are written on demand, per package, from the classes themselves (as + * the given class loader loads them), in the background; and so are the + * stubs of the packages their signatures refer to, a few steps on, within a + * limit. Java types become Python ones where Jython converts them (e.g. + * {@code String} becomes {@code str}); overloads become + * {@code typing.overload}s; getters become properties too, as Jython offers + * them. + *

+ *

+ * TODO: Consider scyjava's stubs instead (see + * scyjava.types). + *

+ * + * @author Gabriel Selzer + */ +class JavaStubs { + + /** How many packages to write stubs for, at most, per session. */ + private static final int MAX_PACKAGES = 400; + + /** How many steps to follow the packages signatures refer to. */ + private static final int DEPTH = 2; + + /** Names Python reserves (and so cannot be members' names in stubs). */ + private static final Set KEYWORDS = new HashSet<>(Arrays.asList( + "False", "None", "True", "and", "as", "assert", "async", "await", "break", + "class", "continue", "def", "del", "elif", "else", "except", "finally", + "for", "from", "global", "if", "import", "in", "is", "lambda", "nonlocal", + "not", "or", "pass", "raise", "return", "try", "while", "with", "yield")); + + private final File dir; + private final ClassLoader loader; + private final Function> classNames; + + /** The packages done (or being done). */ + private final Set done = ConcurrentHashMap.newKeySet(); + + private final ExecutorService writer = Executors.newSingleThreadExecutor( + r -> { + final Thread t = new Thread(r, "Jython-stubs"); + t.setDaemon(true); + return t; + }); + + /** + * @param dir Where to write the stubs. + * @param loader Loads the classes. + */ + public JavaStubs(final File dir, final ClassLoader loader) { + this(dir, loader, JavaStubs::indexedClassNames); + } + + /** + * @param dir Where to write the stubs. + * @param loader Loads the classes. + * @param classNames Lists the (fully qualified) names of the classes in a + * package. + */ + JavaStubs(final File dir, final ClassLoader loader, + final Function> classNames) + { + this.dir = dir; + this.loader = loader; + this.classNames = classNames; + } + + /** Where the stubs are written: a Python search path. */ + public File dir() { + return dir; + } + + /** + * Writes the stubs of the given packages, if not done yet; then, of the + * packages they refer to. Returns at once. + * + * @return Done once the given packages' stubs are written. + */ + public CompletableFuture ensure(final Collection packages) { + final List todo = packages.stream().filter(p -> !done.contains(p)) + .collect(Collectors.toList()); + if (todo.isEmpty()) return CompletableFuture.completedFuture(null); + final CompletableFuture written = new CompletableFuture<>(); + writer.execute(() -> { + final Deque queue = new ArrayDeque<>(todo); + final Map depth = new LinkedHashMap<>(); + for (final String p : todo) depth.put(p, 0); + int first = todo.size(); + while (!queue.isEmpty()) { + final String pkg = queue.poll(); + if (first-- == 0) written.complete(null); + if (done.size() >= MAX_PACKAGES || !done.add(pkg)) continue; + final Set refs = new TreeSet<>(); + try { + write(pkg, stub(pkg, refs)); + } + catch (final IOException | RuntimeException | LinkageError exc) { + // NB: No stubs for this package, then. + } + final int d = depth.get(pkg); + if (d >= DEPTH) continue; + for (final String r : refs) { + if (!done.contains(r) && !depth.containsKey(r)) { + depth.put(r, d + 1); + queue.add(r); + } + } + } + written.complete(null); + }); + return written; + } + + /** + * Gets the Java packages a Jython script imports from, e.g. {@code ij} for + * {@code from ij import IJ}, and {@code java.util} for + * {@code import java.util}. + */ + public static Set imports(final String script) { + final Set packages = new TreeSet<>(); + // NB: Statements on one line are separated by semicolons. + for (final String line : script.split("[\n;]")) { + final String l = line.trim(); + String module = null; + if (l.startsWith("from ")) { + final int imp = l.indexOf(" import"); + module = imp < 0 ? l.substring(5) : l.substring(5, imp); + } + else if (l.startsWith("import ")) { + module = l.substring(7).split("[ ,]")[0]; + } + if (module == null) continue; + module = module.trim(); + if (module.matches("[\\w.]+") && !module.endsWith(".")) { + packages.add(module); + } + } + return packages; + } + + // -- Stub writing -- + + /** Writes a package's stub, and its parents' (empty, if missing). */ + private void write(final String pkg, final String stub) throws IOException { + if (stub == null) return; // NB: Not a Java package. + File d = dir; + for (final String part : pkg.split("\\.")) { + d = new File(d, part); + d.mkdirs(); + final File init = new File(d, "__init__.pyi"); + if (!init.exists()) Files.write(init.toPath(), new byte[0]); + } + final File init = new File(d, "__init__.pyi"); + final byte[] bytes = stub.getBytes(StandardCharsets.UTF_8); + // NB: Unchanged files stay as they are, so the server need not reload. + if (!Arrays.equals(Files.readAllBytes(init.toPath()), bytes)) { + Files.write(init.toPath(), bytes); + } + } + + /** + * Builds the stub of a package, noting the other packages it refers to; + * or returns null if the package has no (public) classes. + */ + String stub(final String pkg, final Set refs) { + final List> classes = new ArrayList<>(); + for (final String name : classNames.apply(pkg)) { + if (name.indexOf('$') >= 0) continue; // NB: Nested ones, in their outer. + try { + final Class c = Class.forName(name, false, loader); + if (Modifier.isPublic(c.getModifiers()) && !c.isSynthetic()) { + classes.add(c); + } + } + catch (final ClassNotFoundException | LinkageError exc) { + // NB: Not loadable here; skip it. + } + } + if (classes.isEmpty()) return null; + classes.sort(Comparator.comparing(Class::getSimpleName)); + final StringBuilder body = new StringBuilder(); + for (final Class c : classes) { + try { + classStub(c, pkg, refs, "", body); + } + catch (final LinkageError | SecurityException exc) { + // NB: Its members refer to classes not loadable here; skip it. + } + } + refs.remove(pkg); + final StringBuilder sb = new StringBuilder("# Stubs of the Java package " + + pkg + ", for code completion. Generated; do not edit.\n"); + sb.append("import typing\n"); + for (final String r : refs) sb.append("import ").append(r).append('\n'); + return sb.append(body).toString(); + } + + private void classStub(final Class c, final String pkg, + final Set refs, final String indent, final StringBuilder sb) + { + final List bases = new ArrayList<>(); + final Class sup = c.getSuperclass(); + if (sup != null && sup != Object.class && Modifier.isPublic(sup + .getModifiers())) bases.add(type(sup, pkg, refs)); + for (final Class i : c.getInterfaces()) { + if (Modifier.isPublic(i.getModifiers())) bases.add(type(i, pkg, refs)); + } + if (bases.isEmpty() && c != Object.class) { + bases.add(type(Object.class, pkg, refs)); + } + sb.append('\n').append(indent).append("class ").append(c.getSimpleName()); + if (!bases.isEmpty()) sb.append('(').append(String.join(", ", bases)) + .append(')'); + sb.append(":\n"); + final String in = indent + " "; + final int length = sb.length(); + final Set names = new HashSet<>(); + + // Fields. + for (final Field f : sorted(c.getDeclaredFields(), Field::getName)) { + if (!Modifier.isPublic(f.getModifiers()) || f.isSynthetic() || !valid(f + .getName())) continue; + names.add(f.getName()); + final String t = type(f.getType(), pkg, refs); + sb.append(in).append(f.getName()).append(": ").append(Modifier.isStatic( + f.getModifiers()) ? "typing.ClassVar[" + t + "]" : t).append('\n'); + } + + // Constructors. + final List> ctors = new ArrayList<>(); + for (final Constructor ctor : c.getDeclaredConstructors()) { + if (Modifier.isPublic(ctor.getModifiers()) && !ctor.isSynthetic()) { + ctors.add(ctor); + } + } + ctors.sort(Comparator.comparing(JavaStubs::signatureKey)); + for (final Constructor ctor : ctors) { + if (ctors.size() > 1) sb.append(in).append("@typing.overload\n"); + sb.append(in).append("def __init__(self").append(parameters(ctor, pkg, + refs)).append(") -> None: ...\n"); + } + + // Methods, by name. + final Map> methods = new LinkedHashMap<>(); + for (final Method m : sorted(c.getDeclaredMethods(), JavaStubs::signatureKey)) { + if (!Modifier.isPublic(m.getModifiers()) || m.isSynthetic() || m + .isBridge() || !valid(m.getName())) continue; + methods.computeIfAbsent(m.getName(), k -> new ArrayList<>()).add(m); + } + for (final Map.Entry> e : methods.entrySet()) { + names.add(e.getKey()); + for (final Method m : e.getValue()) { + final boolean isStatic = Modifier.isStatic(m.getModifiers()); + if (e.getValue().size() > 1) sb.append(in).append( + "@typing.overload\n"); + if (isStatic) sb.append(in).append("@staticmethod\n"); + sb.append(in).append("def ").append(m.getName()).append('(').append( + isStatic ? parameters(m, pkg, refs).replaceFirst("^, ", "") + : "self" + parameters(m, pkg, refs)).append(") -> ").append( + type(m.getReturnType(), pkg, refs)).append(": ...\n"); + } + } + + // Bean properties, as Jython offers them: getTitle() as title. + for (final List overloads : methods.values()) { + for (final Method m : overloads) { + final String property = property(m); + if (property == null || !names.add(property)) continue; + sb.append(in).append(property).append(": ").append(type(m + .getReturnType(), pkg, refs)).append('\n'); + } + } + + // Nested classes. + for (final Class nested : sorted(c.getDeclaredClasses(), + Class::getSimpleName)) + { + if (Modifier.isPublic(nested.getModifiers()) && !nested.isSynthetic() && + valid(nested.getSimpleName()) && names.add(nested.getSimpleName())) + { + classStub(nested, pkg, refs, in, sb); + } + } + if (sb.length() == length) sb.append(in).append("...\n"); + } + + /** Renders the parameters, each preceded by ", ". */ + private String parameters(final Executable e, final String pkg, + final Set refs) + { + final StringBuilder sb = new StringBuilder(); + final Set used = new HashSet<>(); + used.add("self"); + final Parameter[] params = e.getParameters(); + for (int i = 0; i < params.length; i++) { + final Parameter p = params[i]; + String name = p.isNamePresent() ? p.getName() : derivedName(p.getType()); + if (KEYWORDS.contains(name)) name += "_"; + while (!used.add(name)) name += i; + final Class type = e.isVarArgs() && i == params.length - 1 ? p + .getType().getComponentType() : p.getType(); + sb.append(", "); + if (e.isVarArgs() && i == params.length - 1) sb.append('*'); + sb.append(name).append(": ").append(type(type, pkg, refs)); + } + return sb.toString(); + } + + /** The Python type of a Java one, as written in a stub of {@code pkg}. */ + String type(final Class c, final String pkg, final Set refs) { + if (c == void.class) return "None"; + if (c == boolean.class || c == Boolean.class) return "bool"; + if (c == int.class || c == long.class || c == short.class || + c == byte.class || c == Integer.class || c == Long.class || + c == Short.class || c == Byte.class || c == BigInteger.class) + { + return "int"; + } + if (c == float.class || c == double.class || c == Float.class || + c == Double.class || c == BigDecimal.class) return "float"; + if (c == char.class || c == Character.class || c == String.class) { + return "str"; + } + if (c.isArray()) return "list[" + type(c.getComponentType(), pkg, refs) + + "]"; + if (!Modifier.isPublic(c.getModifiers()) || c.isAnonymousClass() || c + .isLocalClass()) return "typing.Any"; + final Class top = topLevel(c); + final int dot = top.getName().lastIndexOf('.'); + final String topPkg = dot < 0 ? "" : top.getName().substring(0, dot); + if (topPkg.isEmpty()) return "typing.Any"; + // NB: Nested classes by their path from the top-level class. + final String path = c.getName().substring(topPkg.length() + 1).replace('$', + '.'); + if (topPkg.equals(pkg)) return path; + refs.add(topPkg); + return topPkg + "." + path; + } + + // -- Helper methods -- + + private static Class topLevel(Class c) { + while (c.getEnclosingClass() != null) c = c.getEnclosingClass(); + return c; + } + + /** The property a getter offers (e.g. title for getTitle()), or null. */ + private static String property(final Method m) { + if (Modifier.isStatic(m.getModifiers()) || m.getParameterCount() != 0 || m + .getReturnType() == void.class) return null; + final String name = m.getName(); + String rest = null; + if (name.startsWith("get") && name.length() > 3) rest = name.substring(3); + else if (name.startsWith("is") && name.length() > 2 && (m + .getReturnType() == boolean.class)) rest = name.substring(2); + if (rest == null || !Character.isUpperCase(rest.charAt(0))) return null; + final String property = Character.toLowerCase(rest.charAt(0)) + rest + .substring(1); + return valid(property) ? property : null; + } + + /** A parameter name from its type, e.g. imageProcessor, or values. */ + private static String derivedName(final Class type) { + if (type.isArray()) return "array"; + final String simple = type.getSimpleName(); + if (simple.isEmpty()) return "arg"; + return Character.toLowerCase(simple.charAt(0)) + simple.substring(1); + } + + /** Whether a name can be a Python identifier in a stub. */ + private static boolean valid(final String name) { + return !name.isEmpty() && !KEYWORDS.contains(name) && name.indexOf( + '$') < 0 && Character.isJavaIdentifierStart(name.charAt(0)); + } + + /** Orders overloads (and members) the same each time. */ + private static String signatureKey(final Executable e) { + return e.getName() + Arrays.stream(e.getParameterTypes()).map( + Class::getName).collect(Collectors.joining(",", "(", ")")); + } + + private static List sorted(final T[] items, + final Function key) + { + final List list = new ArrayList<>(Arrays.asList(items)); + list.sort(Comparator.comparing(key)); + return list; + } + + /** Lists a package's classes from the {@link ClassIndex}. */ + private static List indexedClassNames(final String pkg) { + final String prefix = pkg + "."; + return ClassIndex.findClassNamesForPackage(pkg).filter(n -> n.startsWith( + prefix) && n.indexOf('.', prefix.length()) < 0).collect(Collectors + .toList()); + } +} diff --git a/scijava-code-lsp-jython/src/main/java/org/scijava/code/lsp/jython/JythonImports.java b/scijava-code-lsp-jython/src/main/java/org/scijava/code/lsp/jython/JythonImports.java new file mode 100644 index 000000000..e222c9e87 --- /dev/null +++ b/scijava-code-lsp-jython/src/main/java/org/scijava/code/lsp/jython/JythonImports.java @@ -0,0 +1,181 @@ +/*- + * #%L + * Jython code intelligence for SciJava, via Jython's own parser. + * %% + * Copyright (C) 2020 - 2025 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ +package org.scijava.code.lsp.jython; + +import java.util.HashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.TextEdit; +import org.scijava.code.lsp.Positions; + +/** + * Tools for importing Java classes as their completions are accepted. + *

+ * For example, if you type {@code ArrayLi} and accept {@code ArrayList}, the + * completion also carries an edit that adds + * {@code from java.util import ArrayList} to the script (after its last + * import), unless the class is imported already (see {@link #autoImportEdit}). + *

+ * + * @author Albert Cardona + */ +final class JythonImports { + + private static final Pattern importPattern = Pattern.compile( + "^(from[ \\t]+([a-zA-Z_][a-zA-Z0-9._]*)[ \\t]+|)import[ \\t]+([a-zA-Z_][a-zA-Z0-9_]*[ \\ta-zA-Z0-9_,]*)[ \\t]*([\\\\]*|)[ \\t]*(#.*|)$"); + private static final Pattern tripleQuotePattern = Pattern.compile("\"\"\""); + + private JythonImports() { + // prevent instantiation + } + + /** A single imported class, with its declaration line number. */ + public static final class Import { + + public final String className, alias; + public final int lineNumber; + + public Import(final String className, final String alias, + final int lineNumber) + { + this.className = className; + this.alias = null != alias ? alias : className.substring(className + .lastIndexOf('.') + 1); + this.lineNumber = lineNumber; + } + + public Import(final String packageName, final String[] parts, + final int lineNumber) + { + this(packageName + "." + parts[0], 3 == parts.length ? parts[2] : null, + lineNumber); + } + } + + /** Scans the whole script for imported classes, keyed by alias/simple name. */ + public static HashMap findImportedClasses(final String text) { + final HashMap importedClasses = new HashMap<>(); + String packageName = ""; + boolean endingBackslash = false; + boolean insideTripleQuotes = false; + + final String[] lines = text.split("\n"); + for (int i = 0; i < lines.length; ++i) { + final String line = lines[i]; + final String trimmed = line.trim(); + if (0 == trimmed.length() || '#' == trimmed.charAt(0)) continue; + final Matcher mq = tripleQuotePattern.matcher(line); + int n_triple_quotes = 0; + while (mq.find()) ++n_triple_quotes; + if (insideTripleQuotes) { + if (0 != n_triple_quotes % 2) insideTripleQuotes = false; + continue; + } + else if (0 != n_triple_quotes % 2) { + insideTripleQuotes = true; + continue; + } + if (endingBackslash) { + String importLine = line; + final int backslash = line.lastIndexOf('\\'); + if (backslash > -1) importLine = importLine.substring(0, backslash); + else { + final int sharp = importLine.lastIndexOf('#'); + if (sharp > -1) importLine = importLine.substring(0, sharp); + } + for (final String simpleClassName : importLine.split(",")) { + final Import im = new Import(packageName, simpleClassName.trim().split( + "\\s"), i); + importedClasses.put(im.alias, im); + } + endingBackslash = -1 != backslash; + continue; + } + final Matcher m = importPattern.matcher(line); + if (m.find()) { + packageName = null == m.group(2) ? "" : m.group(2); + for (final String simpleClassName : m.group(3).split(",")) { + final Import im = new Import(packageName, simpleClassName.trim().split( + "\\s"), i); + importedClasses.put(im.alias, im); + } + endingBackslash = null != m.group(4) && m.group(4).length() > 0 && + '\\' == m.group(4).charAt(0); + } + } + return importedClasses; + } + + /** + * The statement importing a class, e.g. {@code from java.io import File}. + */ + static String importStatement(final String className) { + final int idot = className.lastIndexOf('.'); + if (-1 == idot) return "import " + className; + return "from " + className.substring(0, idot) + " import " + className + .substring(idot + 1); + } + + /** + * Computes the edit that inserts {@code importStatement} after the last + * existing import (or at the top), or {@code null} if {@code className} is + * already imported. + */ + public static TextEdit autoImportEdit(final String text, + final String className, final String importStatement) + { + final HashMap imported = findImportedClasses(text); + for (final Import im : imported.values()) { + if (im.className.equals(className)) return null; // already imported + } + int lastImportLine = 0; + for (final Import im : imported.values()) { + if (im.lineNumber > lastImportLine) lastImportLine = im.lineNumber; + } + final int targetLine = imported.isEmpty() ? 0 : lastImportLine + 1; + final int offset = offsetOfLine(text, targetLine); + final Position at = Positions.position(text, Math.min(offset, text + .length())); + return new TextEdit(new Range(at, at), importStatement + "\n"); + } + + /** Character offset of the start of the given (0-based) line in {@code text}. */ + private static int offsetOfLine(final String text, final int line) { + int offset = 0; + for (int l = 0; l < line; l++) { + final int nl = text.indexOf('\n', offset); + if (nl < 0) return text.length(); + offset = nl + 1; + } + return offset; + } +} diff --git a/scijava-code-lsp-jython/src/main/java/org/scijava/code/lsp/jython/JythonLanguageServer.java b/scijava-code-lsp-jython/src/main/java/org/scijava/code/lsp/jython/JythonLanguageServer.java new file mode 100644 index 000000000..a0e9958e2 --- /dev/null +++ b/scijava-code-lsp-jython/src/main/java/org/scijava/code/lsp/jython/JythonLanguageServer.java @@ -0,0 +1,436 @@ +/*- + * #%L + * Jython code intelligence for SciJava, via Jython's own parser. + * %% + * Copyright (C) 2020 - 2025 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ +package org.scijava.code.lsp.jython; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import javax.script.ScriptContext; + +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionItemKind; +import org.eclipse.lsp4j.CompletionList; +import org.eclipse.lsp4j.CompletionParams; +import org.eclipse.lsp4j.DidChangeTextDocumentParams; +import org.eclipse.lsp4j.DidCloseTextDocumentParams; +import org.eclipse.lsp4j.DidOpenTextDocumentParams; +import org.eclipse.lsp4j.Hover; +import org.eclipse.lsp4j.HoverParams; +import org.eclipse.lsp4j.InitializeParams; +import org.eclipse.lsp4j.InitializedParams; +import org.eclipse.lsp4j.SignatureHelp; +import org.eclipse.lsp4j.SignatureHelpParams; +import org.eclipse.lsp4j.TextDocumentContentChangeEvent; +import org.eclipse.lsp4j.TextDocumentItem; +import org.eclipse.lsp4j.TextEdit; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import org.eclipse.lsp4j.services.LanguageServer; +import org.eclipse.lsp4j.services.TextDocumentService; +import org.scijava.code.lsp.Environment; +import org.scijava.code.lsp.Positions; +import org.scijava.code.lsp.jvm.AbstractLanguageServer; +import org.scijava.code.lsp.jvm.ClassIndex; + +/** + * A language server for Jython. Leans upon a Python server, with + * added Python stubs for Java types. + *

+ * FIXME Diagnostics: the Python server reads scripts as Python 3, and so would + * report Jython's Python 2 (e.g. {@code print "x"}) as problems; it is never + * asked for them. + * FIXME Python 2: completions are of Python 3's builtins and standard library + * (mostly the same as Jython's); only names like {@code __name__} are kept to + * Jython's. + * FIXME Overloads: signature help shows only the last of a Java method's + * overloads, not all of them rated against the arguments typed. + * FIXME Arguments: in a call, the variables of the parameter's type are not + * offered first. + * FIXME Parameter names: Java methods' are named after their types (e.g. + * {@code long1}), as reflection does not know them. + * + * @author Albert Cardona + * @author Curtis Rueden + * @author Gabriel Selzer + */ +class JythonLanguageServer extends AbstractLanguageServer { + + static { + // Warm the class index in the background, as completion needs it. + new Thread(ClassIndex::ensureCache, "Jython-ClassIndex-warmup").start(); + } + + /** Marks the completions made here, rather than by the Python server. */ + private static final String OWN = "jython-class-name"; + + private static final Pattern // + fromImport = Pattern.compile( + "^((from|import)[ \\t]+)([a-zA-Z_][a-zA-Z0-9._]*)$"), // + fastImport = Pattern.compile( + "^(from[ \\t]+)([a-zA-Z_][a-zA-Z0-9._]*)[ \\t]+$"), // + importStatement = Pattern.compile( + "^((from[ \\t]+([a-zA-Z0-9._]+)[ \\t]+|[ \\t]*)import[ \\t]+)([a-zA-Z0-9_., \\t]*)$"), // + simpleClassName = Pattern.compile("^(.*[ \\t]+|)([A-Z_][a-zA-Z0-9_]+)$"); + + /** + * The names like {@code __name__} that Jython 2.7 has, unqualified: its + * module globals, builtins and modules. (The Python server knows Python 3's, + * e.g. {@code __build_class__}.) + */ + private static final Set DUNDERS = new HashSet<>(Arrays.asList( + "__builtins__", "__doc__", "__file__", "__name__", "__package__", + "__debug__", "__import__", "__future__", "__main__", "__builtin__")); + + /** A name like {@code __name__}. */ + private static final Pattern DUNDER = Pattern.compile("__\\w+__"); + + /** The Python server. */ + private final LanguageServer python; + private final JavaStubs stubs; + + /** The scripts' texts, by URI: to know the names they mention. */ + private final Map texts = new ConcurrentHashMap<>(); + + /** + * @param environment Where the scripts run (e.g. a live interpreter). + * @param python The Python server, e.g. of {@link #environment(JavaStubs)}. + * @param stubs The stubs of Java packages, which the server searches. + */ + JythonLanguageServer(final Environment environment, + final LanguageServer python, final JavaStubs stubs) + { + super(environment); + this.python = python; + this.stubs = stubs; + python.initialize(new InitializeParams()).thenRun(() -> python + .initialized(new InitializedParams())); + } + + /** Where the Python server runs: its own Python, searching the stubs. */ + static Environment environment(final JavaStubs stubs) { + return new Environment().withPaths(Collections.singletonList(stubs.dir() + .getAbsolutePath())); + } + + // -- LanguageServer, with the Python server -- + + @Override + public void didOpen(final DidOpenTextDocumentParams params) { + super.didOpen(params); + final TextDocumentItem item = params.getTextDocument(); + texts.put(item.getUri(), item.getText()); + final String text = prepare(item.getText()); + documents().didOpen(new DidOpenTextDocumentParams(new TextDocumentItem( + item.getUri(), "python", item.getVersion(), text))); + } + + @Override + public void didChange(final DidChangeTextDocumentParams params) { + super.didChange(params); + if (params.getContentChanges().isEmpty()) return; + final String original = params.getContentChanges().get(params + .getContentChanges().size() - 1).getText(); + texts.put(params.getTextDocument().getUri(), original); + final String text = prepare(original); + documents().didChange(new DidChangeTextDocumentParams(params + .getTextDocument(), Collections.singletonList( + new TextDocumentContentChangeEvent(text)))); + } + + @Override + public void didClose(final DidCloseTextDocumentParams params) { + super.didClose(params); + texts.remove(params.getTextDocument().getUri()); + documents().didClose(params); + } + + /** + * The server's completions, then those of Java classes not imported yet, + * whose names the server has not completed; without names like + * {@code __name__} that Jython does not have. + */ + @Override + public CompletableFuture, CompletionList>> + completion(final CompletionParams params) + { + final CompletionList own = super.completion(params).join().getRight(); + final Set dunders = dunders(params); + // TODO: In a call's arguments, with nothing typed, offer the variables of + // the parameter's type first, as Jython's own analysis did. + return orNull(documents().completion(params)).thenApply(answer -> { + final CompletionList merged = merge(list(answer), own); + if (dunders != null) merged.getItems().removeIf(item -> DUNDER.matcher( + item.getLabel()).matches() && !dunders.contains(item.getLabel())); + return Either.forRight(merged); + }); + } + + @Override + public CompletableFuture resolveCompletionItem( + final CompletionItem item) + { + if (OWN.equals(item.getData())) { + return CompletableFuture.completedFuture(item); + } + return orNull(documents().resolveCompletionItem(item)).thenApply( + resolved -> resolved == null ? item : resolved); + } + + @Override + public CompletableFuture signatureHelp( + final SignatureHelpParams params) + { + // TODO: All of a Java method's overloads, rated against the arguments + // typed, as Jython's own analysis did: jedi-language-server offers only + // the last of a stub's typing.overloads. + return orNull(documents().signatureHelp(params)); + } + + @Override + public CompletableFuture hover(final HoverParams params) { + return orNull(documents().hover(params)); + } + + @Override + public CompletableFuture shutdown() { + return python.shutdown().thenCompose(done -> super.shutdown()); + } + + // -- AbstractLanguageServer -- + + /** Java classes, and their packages, as the caret's line is typed. */ + @Override + protected CompletionList complete(final Document doc, final int caret) { + final String line = doc.lineTo(caret); + if (line.trim().startsWith("#")) { + return list(doc, caret, caret, new ArrayList<>()); + } + final String typed = alreadyEnteredText(line); + final List items = classNames(typed, doc.text()); + for (final CompletionItem item : items) item.setData(OWN); + return list(doc, caret - typed.length(), caret, items); + } + + // -- Helper methods -- + + /** + * The names like {@code __name__} to complete unqualified, at the position + * asked about: Jython's, and those the script or the live interpreter has; + * or null if completing a member (e.g. after {@code x.}), whose names the + * server knows. + */ + private Set dunders(final CompletionParams params) { + final String text = texts.getOrDefault(params.getTextDocument().getUri(), + ""); + int start = Math.min(Positions.offset(text, params.getPosition()), text + .length()); + while (start > 0 && (Character.isLetterOrDigit(text.charAt(start - 1)) || + text.charAt(start - 1) == '_')) start--; + if (start > 0 && text.charAt(start - 1) == '.') return null; + final Set names = new HashSet<>(DUNDERS); + final Matcher m = DUNDER.matcher(text); + while (m.find()) names.add(m.group()); + environment().live().map(l -> l.getBindings(ScriptContext.ENGINE_SCOPE)) + .ifPresent(b -> names.addAll(b.keySet())); + return names; + } + + private TextDocumentService documents() { + return python.getTextDocumentService(); + } + + /** + * Gets a script ready for the server: writes the stubs of the packages it + * imports (in the background). (A live interpreter's variables are + * declared, and their packages imported, in the script's header; see + * {@code ScriptDialect#header}.) + */ + private String prepare(final String text) { + stubs.ensure(new TreeSet<>(JavaStubs.imports(text))); + return text; + } + + /** + * The server's completions first; then those made here, whose names it has + * not completed. Each keeps its own order. + */ + static CompletionList merge(final CompletionList theirs, + final CompletionList own) + { + final List out = new ArrayList<>(); + final Set names = new HashSet<>(); + if (theirs != null && theirs.getItems() != null) { + for (final CompletionItem item : theirs.getItems()) { + names.add(name(item)); + out.add(item); + } + } + int i = 0; + for (final CompletionItem item : own.getItems()) { + if (names.contains(name(item))) continue; + // NB: After the server's. + item.setSortText(String.format("~%06d", i++)); + out.add(item); + } + return new CompletionList(theirs != null && theirs.isIncomplete(), out); + } + + /** What a completion completes: its label, without any parameters. */ + private static String name(final CompletionItem item) { + final String label = item.getLabel(); + final int paren = label.indexOf('('); + return (paren < 0 ? label : label.substring(0, paren)).trim(); + } + + private static CompletionList list( + final Either, CompletionList> answer) + { + if (answer == null) return null; + return answer.isRight() ? answer.getRight() : new CompletionList(answer + .getLeft()); + } + + /** The answer; or null, if the server failed (e.g. it is not ready). */ + private static CompletableFuture orNull( + final CompletableFuture answer) + { + return answer == null ? CompletableFuture.completedFuture(null) : answer + .exceptionally(error -> null); + } + + /** + * Java classes and packages, as an import statement or a class's simple + * name is typed: classes not imported yet import themselves as they are + * accepted. + */ + private static List classNames(final String typed, + final String code) + { + final List items = new ArrayList<>(); + if (!ClassIndex.isCacheReady()) return items; // NB: Don't block. + + final Matcher m1 = fromImport.matcher(typed); + if (m1.find()) { + ClassIndex.findClassNamesContaining(m1.group(3)).map( + JythonImports::importStatement).forEach(s -> items.add(item(s, + CompletionItemKind.Module, null))); + return items; + } + final Matcher m1f = fastImport.matcher(typed); + if (m1f.find()) { + ClassIndex.findClassNamesForPackage(m1f.group(2)).map( + JythonImports::importStatement).forEach(s -> items.add(item(s, + CompletionItemKind.Module, null))); + return items; + } + final Matcher m2 = importStatement.matcher(typed); + if (m2.find()) { + final String packageName = m2.group(3); + String className = m2.group(4); + final String[] bycomma = className.split(","); + String precomma = ""; + if (bycomma.length > 1) { + className = bycomma[bycomma.length - 1].trim(); + for (int i = 0; i < bycomma.length - 1; ++i) + precomma += bycomma[0] + ", "; + } + final Stream stream; + if (className.length() > 0) stream = ClassIndex.findClassNamesStartingWith( + null == packageName ? className : packageName + "." + className); + else stream = ClassIndex.findClassNamesForPackage(packageName); + final String pre = m2.group(1) + precomma; + stream.map(s -> s.substring(Math.max(0, s.lastIndexOf('.') + 1))).forEach( + s -> items.add(item(pre + s, CompletionItemKind.Module, null))); + return items; + } + final Matcher m3 = simpleClassName.matcher(typed); + if (m3.find()) { + final String pre = m3.group(1); + final List classNames = ClassIndex + .findSimpleClassNamesStartingWith(m3.group(2)); + // Closest matches first: shortest simple name, then alphabetical. + classNames.sort(Comparator.comparingInt((String cn) -> cn.length() - cn + .lastIndexOf('.')).thenComparing(Comparator.naturalOrder())); + for (final String className : classNames) { + final String simpleName = className.substring(className.lastIndexOf( + '.') + 1); + final String importStmt = JythonImports.importStatement(className); + // Side effect on accept: insert the import near the top of the file. + final TextEdit edit = JythonImports.autoImportEdit(code, className, + importStmt); + final CompletionItem c = item(pre + simpleName, CompletionItemKind.Class, + importStmt); + if (edit != null) c.setAdditionalTextEdits(Collections.singletonList( + edit)); + items.add(c); + } + } + return items; + } + + /** A completion of the given text. */ + 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; + } + + /** + * The portion of the current line to replace: a maximal suffix of letters, + * digits, {@code '.'}, {@code '_'} and spaces (spaces let "from x import Y" + * complete as a unit), excluding leading spaces. + */ + static String alreadyEnteredText(final String lastLine) { + int start = lastLine.length(); + while (start > 0) { + final char c = lastLine.charAt(start - 1); + if (Character.isLetterOrDigit(c) || c == '.' || c == '_' || c == ' ') // + start--; + else break; + } + // Leading whitespace is not part of what the user is completing. + while (start < lastLine.length() && lastLine.charAt(start) == ' ') start++; + return lastLine.substring(start); + } +} diff --git a/scijava-code-lsp-jython/src/main/java/org/scijava/code/lsp/jython/JythonLanguageServerPlugin.java b/scijava-code-lsp-jython/src/main/java/org/scijava/code/lsp/jython/JythonLanguageServerPlugin.java new file mode 100644 index 000000000..54919eb74 --- /dev/null +++ b/scijava-code-lsp-jython/src/main/java/org/scijava/code/lsp/jython/JythonLanguageServerPlugin.java @@ -0,0 +1,82 @@ +/*- + * #%L + * Jython code intelligence for SciJava, via Jython's own parser. + * %% + * Copyright (C) 2020 - 2025 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ +package org.scijava.code.lsp.jython; + +import java.io.File; + +import org.eclipse.lsp4j.services.LanguageServer; +import org.scijava.Priority; +import org.scijava.code.lsp.LanguageServerService; +import org.scijava.code.lsp.Environment; +import org.scijava.code.lsp.LanguageServerPlugin; +import org.scijava.plugin.Parameter; +import org.scijava.plugin.Plugin; + +/** + * Offers an in-process Jython {@link LanguageServer} + * + * @author Gabriel Selzer + * @see JythonLanguageServer + */ +@Plugin(type = LanguageServerPlugin.class, name = "Jython", + priority = Priority.HIGH) +public class JythonLanguageServerPlugin implements LanguageServerPlugin { + + @Parameter + private LanguageServerService servers; + + private JavaStubs stubs; + + @Override + public boolean supports(final String languageId) { + // NB: Not "python", which this builds on (see JythonLanguageServer). + return "jython".equals(languageId); + } + + @Override + public LanguageServer create(final String languageId, + final Environment environment) + { + final JavaStubs s = stubs(); + return new JythonLanguageServer(environment, servers.launch("python", + JythonLanguageServer.environment(s)), s); + } + + /** Gets the stubs of Java packages, creating them on first use. */ + synchronized JavaStubs stubs() { + if (stubs != null) return stubs; + final String dir = System.getProperty("scijava.jython.stubs"); + stubs = new JavaStubs(dir != null ? new File(dir) : new File(System + .getProperty("user.home"), ".cache" + File.separator + "scijava" + + File.separator + "jython-stubs"), Thread.currentThread() + .getContextClassLoader()); + return stubs; + } + +} diff --git a/scijava-code-lsp-jython/src/test/java/org/scijava/code/lsp/jython/JavaStubsTest.java b/scijava-code-lsp-jython/src/test/java/org/scijava/code/lsp/jython/JavaStubsTest.java new file mode 100644 index 000000000..af885752b --- /dev/null +++ b/scijava-code-lsp-jython/src/test/java/org/scijava/code/lsp/jython/JavaStubsTest.java @@ -0,0 +1,198 @@ +/*- + * #%L + * Jython code intelligence for SciJava, via Jython's own parser. + * %% + * Copyright (C) 2020 - 2025 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ +package org.scijava.code.lsp.jython; + +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.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link JavaStubs}. + * + * @author Gabriel Selzer + */ +public class JavaStubsTest { + + private static final String PKG = + "org.scijava.code.lsp.jython"; + + /** Lists just the example classes. */ + private static final Function> CLASSES = pkg -> PKG + .equals(pkg) ? Collections.singletonList(PKG + ".JavaStubsTest") : pkg + .equals("java.util") ? Collections.singletonList("java.util.Locale") + : Collections.emptyList(); + + @Test + public void testStub() { + final JavaStubs stubs = new JavaStubs(new File("unused"), getClass() + .getClassLoader(), pkg -> Collections.singletonList(PKG + + ".JavaStubsTest")); + final Set refs = new TreeSet<>(); + final String stub = stubs.stub(PKG, refs); + // The test class: its nested classes, inside. + assertTrue(stub.contains("\nclass JavaStubsTest(java.lang.Object):\n"), stub); + assertTrue(stub.contains( + "\n class Picture(JavaStubsTest.Base, java.lang.Cloneable):\n"), stub); + // Fields; constant ones as class variables. + assertTrue(stub.contains(" MAX: typing.ClassVar[int]\n"), stub); + assertTrue(stub.contains(" title: str\n"), stub); + // Overloads, constructors too; static methods; Java types as Python's. + // NB: Parameter names are the Java ones only if compiled with them. + has(stub, " @typing.overload\n" + + " def __init__\\(self, \\w+: str\\) -> None: \\.\\.\\.\n"); + has(stub, " @typing.overload\n" + + " def resize\\(self, \\w+: int, \\w+: int\\) -> " + + "JavaStubsTest\\.Picture: \\.\\.\\.\n"); + has(stub, " @typing.overload\n" + + " def resize\\(self, \\w+: float\\) -> JavaStubsTest\\.Picture"); + has(stub, " @staticmethod\n" + + " def open\\(\\w+: str, \\*\\w+: str\\) -> " + + "JavaStubsTest\\.Picture: \\.\\.\\.\n"); + assertTrue(stub.contains(" def pixels(self) -> list[float]: ...\n"), stub); + assertTrue(stub.contains( + " def locale(self) -> java.util.Locale: ...\n"), stub); + // Getters as properties, as Jython offers them. + assertTrue(stub.contains(" width: int\n"), stub); + assertTrue(stub.contains(" opaque: bool\n"), stub); + // Not Python: left out. + assertFalse(stub.contains("def lambda"), stub); + // Other packages: imported, and noted. + assertTrue(stub.contains("\nimport java.util\n"), stub); + assertEquals(new TreeSet<>(Arrays.asList("java.lang", "java.util")), refs); + // No classes: no stub. + assertNull(new JavaStubs(new File("unused"), getClass().getClassLoader(), + pkg -> Collections.emptyList()).stub("os", new TreeSet<>())); + } + + @Test + public void testEnsure() throws Exception { + final File dir = Files.createTempDirectory("jython-stubs").toFile(); + try { + final JavaStubs stubs = new JavaStubs(dir, getClass().getClassLoader(), + CLASSES); + stubs.ensure(Collections.singleton(PKG)).get(30, TimeUnit.SECONDS); + final File stub = new File(dir, PKG.replace('.', File.separatorChar) + + File.separator + "__init__.pyi"); + assertTrue(stub.isFile()); + // Parents are packages too. + assertTrue(new File(dir, "org" + File.separator + "__init__.pyi") + .isFile()); + // The packages referred to follow (here, java.util; java.lang has no + // classes listed). + for (int i = 0; i < 100 && !new File(dir, "java/util/__init__.pyi") + .isFile(); i++) Thread.sleep(50); + assertTrue(new String(Files.readAllBytes(new File(dir, + "java/util/__init__.pyi").toPath()), StandardCharsets.UTF_8).contains( + "class Locale(")); + assertFalse(new File(dir, "java/lang/__init__.pyi").exists()); + } + finally { + try (java.util.stream.Stream paths = Files.walk(dir + .toPath())) + { + paths.sorted(java.util.Comparator.reverseOrder()).forEach(p -> p + .toFile().delete()); + } + } + } + + @Test + public void testImports() { + assertEquals(new TreeSet<>(Arrays.asList("ij", "java.util", "os", + "ij.process")), JavaStubs.imports("from ij import IJ, ImagePlus\n" + + "import java.util\nimport os, sys\n from ij.process import *\n" + + "x = 1 # import nothing\nfrom . import y\n")); + } + + private static void has(final String stub, final String regex) { + assertTrue(java.util.regex.Pattern.compile(regex) + .matcher(stub).find(), regex + " in:\n" + stub); + } + + // -- Example classes -- + + public static class Base { + + public boolean isOpaque() { + return true; + } + } + + public static class Picture extends Base implements Cloneable { + + public static final int MAX = 1 << 16; + public String title; + + public Picture(final String title) { + this.title = title; + } + + public Picture(final String title, final int width) { + this(title); + } + + public Picture resize(final int width, final int height) { + return this; + } + + public Picture resize(final double scale) { + return this; + } + + public static Picture open(final String path, final String... options) { + return null; + } + + public double[] pixels() { + return null; + } + + public java.util.Locale locale() { + return null; + } + + public int getWidth() { + return 0; + } + } +} diff --git a/scijava-code-lsp-jython/src/test/java/org/scijava/code/lsp/jython/JythonLanguageServerTest.java b/scijava-code-lsp-jython/src/test/java/org/scijava/code/lsp/jython/JythonLanguageServerTest.java new file mode 100644 index 000000000..0e5654619 --- /dev/null +++ b/scijava-code-lsp-jython/src/test/java/org/scijava/code/lsp/jython/JythonLanguageServerTest.java @@ -0,0 +1,303 @@ +/*- + * #%L + * Jython code intelligence for SciJava, via Jython's own parser. + * %% + * Copyright (C) 2020 - 2025 SciJava developers. + * %% + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * #L% + */ +package org.scijava.code.lsp.jython; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import javax.script.ScriptEngine; + +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionList; +import org.eclipse.lsp4j.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.Parameter; +import org.scijava.plugin.Plugin; +import org.scijava.script.AbstractScriptLanguage; +import org.scijava.script.ScriptLanguage; + +/** + * Tests {@link JythonLanguageServer}: what it makes of plain Jython, as its + * clients send it. + * + * @author Curtis Rueden + */ +public class JythonLanguageServerTest { + + /** A test fixture: a script language whose scripts are plain Jython. */ + private static final ScriptLanguage TEST_LANGUAGE = + new AbstractScriptLanguage() + { + + @Override + public ScriptEngine getScriptEngine() { + return null; + } + }; + + private static Context context; + + /** The stubs of Java packages, written where the tests run. */ + private static final JavaStubs STUBS = new JavaStubs(new File("target", + "jython-stubs").getAbsoluteFile(), JythonLanguageServerTest.class + .getClassLoader()); + + @BeforeAll + public static void startContext() { + context = new Context(LanguageServerService.class); + } + + @AfterAll + public static void disposeContext() { + context.dispose(); + } + + @Test + public void testMemberCompletion() { + // 's' is a Python str, as in Jython: its members are Python's, not + // java.lang.String's. + final List texts = complete("s = \"hello\"\ns."); + assertTrue(texts.contains("upper"), "expected upper among " + texts); + assertTrue(texts.contains("find"), "expected find among " + texts); + assertFalse(texts.contains("length"), "unexpected length in " + texts); + assertFalse(texts.contains("charAt"), "unexpected charAt in " + texts); + } + + @Test + public void testNameAtStartOfLine() { + final List texts = complete("greeting = \"hi\"\ngree"); + assertTrue(texts.contains("greeting"), "expected greeting among " + texts); + } + + @Test + public void testVariableNameNotDuplicated() { + // A String variable must not also offer String's constructors. + final List texts = complete("greeting = \"hi\"\nx = gree"); + assertEquals(1, texts.stream().filter("greeting"::equals).count(), texts + .toString()); + assertFalse(texts.contains( + " greeting"), "unexpected leading space in " + texts); + } + + @Test + public void testKeywordsAndBuiltins() { + assertTrue(complete("imp").contains("import")); + final List texts = complete("x = le"); + assertTrue(texts.contains("len"), "expected len among " + texts); + assertFalse(texts.stream() + .anyMatch(t -> t.contains(".")), "unexpected builtin member in " + texts); + } + + @Test + public void testImportedClassName() { + ClassIndex.ensureCache(); + final List items = completions( + "from java.util import ArrayList\nArrayList"); + final List texts = labels(items); + assertTrue(texts.contains( + "ArrayList"), "expected ArrayList among " + texts); + // Once as a name; otherwise as its constructors. + assertEquals(1, items.stream().filter(c -> c.getLabel().equals( + "ArrayList") && c.getLabelDetails() == null).count(), + "expected one plain ArrayList among " + texts); + } + + @Test + public void testJdkClassImport() { + ClassIndex.ensureCache(); + final List texts = complete("from java.util import ArrayLi"); + assertTrue(texts.contains( + "from java.util import ArrayList"), "expected ArrayList import among " + texts); + } + + @Test + public void testInheritedMembers() { + final List texts = complete( + "from java.util import ArrayList\nlist = ArrayList()\nlist."); + // members come from superinterfaces + assertTrue(texts.contains("add"), "expected add among " + texts); + // members come from superclasses + assertTrue(texts.contains("toString"), "expected toString among " + texts); + } + + @Test + public void testMerge() { + // The Python server's first, in its order; then Java classes, only for + // names it has not completed. + final CompletionList merged = JythonLanguageServer.merge( + new CompletionList(Arrays.asList(new CompletionItem("max"), + new CompletionItem("min"))), new CompletionList(Arrays.asList( + new CompletionItem("Math"), new CompletionItem("max")))); + assertEquals(Arrays.asList("max", "min", "Math"), labels(merged + .getItems())); + } + + @Test + public void testCallablesAreSnippets() { + final CompletionItem ensure = completions("from java.util import ArrayList\n" + + "a = ArrayList()\na.ensureCap").stream().filter(c -> c.getLabel().equals( + "ensureCapacity")).findFirst().orElse(null); + assertNotNull(ensure); + assertEquals("(int arg0)", ensure.getLabelDetails().getDetail().replaceAll( + " \\w+\\)$", " arg0)")); + assertTrue(ensure.getInsertText().startsWith("ensureCapacity(${1:"), + ensure.getInsertText()); + } + + @Test + public void testDunders() { + // Names like __name__: Jython's, not Python 3's. + final List dunders = labels(items(session(new Environment()), + "x = __")); + assertTrue(dunders.contains("__name__"), dunders.toString()); + assertFalse(dunders.contains("__build_class__"), dunders.toString()); + } + + @Test + public void testSignatureHelp() { + // A Java method's signature, and the argument typed. + final SignatureHelp max = help("from java.lang import Math\n" + + "Math.max(1.5, "); + assertFalse(max.getSignatures().isEmpty()); + assertEquals(Integer.valueOf(1), max.getActiveParameter()); + // Constructors. + assertTrue(help("from java.util import ArrayList\nArrayList(") + .getSignatures().get(0).getLabel().contains("Collection")); + // None outside calls. + final SignatureHelp none = help("x = 1"); + assertTrue(none == null || none.getSignatures().isEmpty()); + } + + // -- Helper methods -- + + /** A session about plain Jython, 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)); + } + + @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) { + return new Header("", Collections.emptySet()); + } + } + + @Plugin(type = LanguageServerPlugin.class) + public static class TestServers implements LanguageServerPlugin { + + @Parameter + private LanguageServerService servers; + + @Override + public boolean supports(final String languageId) { + return "test".equals(languageId); + } + + @Override + public LanguageServer create(final String languageId, + final Environment environment) + { + return new JythonLanguageServer(environment, servers.launch("python", + JythonLanguageServer.environment(STUBS)), STUBS); + } + } + + 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); + } + } + + private static List complete(final String code) { + return labels(completions(code)); + } + + private static List labels(final List items) { + return items.stream().map(CompletionItem::getLabel).collect(Collectors + .toList()); + } + + private static List completions(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 java.util.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); + } + } +} diff --git a/scijava-code-lsp/README.md b/scijava-code-lsp/README.md new file mode 100644 index 000000000..7eb7a1b39 --- /dev/null +++ b/scijava-code-lsp/README.md @@ -0,0 +1,11 @@ +# SciJava Code LSP + +Manages code intelligence through the [Language Server Protocol](https://microsoft.github.io/language-server-protocol/), based upon the [lsp4j](https://github.com/eclipse-lsp4j/lsp4j) library. + +# Running Language Servers + +`scijava-code-lsp` provides a `LanguageServerService`, allowing callers to find and launch `lsp4j` `LanguageServer` objects for a given programming language. Once created, these `LanguageServer` objects can be passed to any downstream application that knows how to use such an object. + +# Interrogating SciJava Scripts + +SciJava Scripts *cannot* simply be handed off to a `LanguageServer` for tasks like auto-completion: SciJava Script parameters, as well as Appose-Python environment information (and maybe other things in the future) must be converted to a representation that a `LanguageServer` can understand. To that end, `scijava-code-lsp` offers the `ScriptSession` class, for synchronizing a SciJava script with a `LanguageServer` as the script is edited: clients only ever deal with the script's own text and offsets, and servers only ever see plain code of their language. diff --git a/scijava-code-lsp/pom.xml b/scijava-code-lsp/pom.xml new file mode 100644 index 000000000..4ae4d9f82 --- /dev/null +++ b/scijava-code-lsp/pom.xml @@ -0,0 +1,152 @@ + + + 4.0.0 + + + org.scijava + pom-scijava + 45.1.0 + + + + scijava-code-lsp + 0.1.0-SNAPSHOT + + SciJava Code LSP + Language Server Protocol bridge for SciJava code intelligence. + 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 + bsd_2 + SciJava developers. + + + 2.101.0 + 1.0.0 + + + + + + org.scijava + scijava-common + + + + + org.eclipse.lsp4j + org.eclipse.lsp4j + ${lsp4j.version} + + + org.eclipse.lsp4j + org.eclipse.lsp4j.jsonrpc + ${lsp4j.version} + + + + com.google.code.gson + gson + + + + + 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/src/main/java/module-info.java b/scijava-code-lsp/src/main/java/module-info.java new file mode 100644 index 000000000..a33c13309 --- /dev/null +++ b/scijava-code-lsp/src/main/java/module-info.java @@ -0,0 +1,43 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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 plugins and services, and +// inject their parameters, reflectively. +open module org.scijava.code.lsp { + exports org.scijava.code.lsp; + exports org.scijava.code.lsp.jvm; + exports org.scijava.code.lsp.server; + + // NB: SciJava Common, whose plugins and services these are. + requires transitive org.scijava; + requires transitive org.eclipse.lsp4j; + requires transitive org.eclipse.lsp4j.jsonrpc; + requires java.scripting; +} diff --git a/scijava-code-lsp/src/main/java/org/scijava/code/lsp/Environment.java b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/Environment.java new file mode 100644 index 000000000..b3e8b3eb3 --- /dev/null +++ b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/Environment.java @@ -0,0 +1,148 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import javax.script.ScriptContext; + + +/** + * The context needed to analyze a block of code. + *

+ * Server processes are kept one per environment (see + * {@link LanguageServerService#launch}): e.g. jedi-language-server learns the + * Python to analyze against only when it starts. + *

+ * + * @author Gabriel Selzer + */ +public final class Environment { + + private final Optional interpreter; + private final List paths; + private final Optional live; + + /** + * No particular environment: no interpreter, no extra paths, and no live + * interpreter. Others are made from it, e.g. + * {@code new Environment().withInterpreter(python)}. + */ + public Environment() { + this(Optional.empty(), Collections.emptyList(), Optional.empty()); + } + + /** + * Since different environments may or may not have any of these, + * a builder pattern is nice here. + */ + private Environment(final Optional interpreter, + final List paths, final Optional live) + { + this.interpreter = interpreter; + this.paths = paths; + this.live = live; + } + + /** Path of the interpreter or executable running the script, if known. */ + public Optional interpreter() { + return interpreter; + } + + /** + * Extra places imports are resolved from, in order: e.g. directories added + * to Python's {@code sys.path}, or a JVM classpath. Each server reads them as + * its language does. Never null. + */ + public List paths() { + return paths; + } + + /** + * The state of the interpreter the code is typed into, if any. NB: This is + * the interpreter's own context, not a copy: its bindings change as code + * runs in it (possibly on another thread), so read them as they are when + * needed rather than keeping them. (Environments are equal if they share + * the same context, whatever its bindings.) + */ + public Optional live() { + return live; + } + + /** + * A copy of this environment, whose code is parsed by the given interpreter + * or executable. + * + * @param path Path of the interpreter or executable. + */ + public Environment withInterpreter(final String path) { + return new Environment(Optional.of(path), paths, live); + } + + /** + * A copy of this environment, whose imports are also resolved from the + * given places, in order. Language-dependent. + */ + public Environment withPaths(final List places) { + return new Environment(interpreter, List.copyOf(places), live); + } + + /** + * A copy of this environment, for code typed into the given interpreter + * (e.g. a REPL's). + */ + public Environment withLive(final ScriptContext context) { + return new Environment(interpreter, paths, Optional.of(context)); + } + + @Override + public boolean equals(final Object o) { + if (!(o instanceof Environment)) return false; + final Environment that = (Environment) o; + // NB: Live interpreters are the same only if they are the same one. + return interpreter.equals(that.interpreter) && paths.equals(that.paths) && + live.orElse(null) == that.live.orElse(null); + } + + @Override + public int hashCode() { + return Objects.hash(interpreter, paths, System.identityHashCode(live + .orElse(null))); + } + + @Override + public String toString() { + return "Environment[interpreter=" + interpreter.orElse("none") + + ", paths=" + paths + (live.isPresent() ? ", live" : "") + "]"; + } +} diff --git a/scijava-code-lsp/src/main/java/org/scijava/code/lsp/LanguageServerPlugin.java b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/LanguageServerPlugin.java new file mode 100644 index 000000000..25dd2300a --- /dev/null +++ b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/LanguageServerPlugin.java @@ -0,0 +1,72 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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; + +import org.eclipse.lsp4j.services.LanguageServer; +import org.scijava.plugin.SciJavaPlugin; + +/** + * A {@link SciJavaPlugin} that can create {@link LanguageServer}s. + *

+ * Servers need not know SciJava scripts: sessions show them each script as + * plain code of their language, as a {@link ScriptDialect} turns it, its + * parameters declared (see {@link ScriptDocument}). + *

+ * + * @author Gabriel Selzer + */ +public interface LanguageServerPlugin extends SciJavaPlugin { + + /** + * Declares whether {@link LanguageServer}s can be created to satisfy + * scripts of the given LSP language (e.g. {@code groovy}), as + * {@link LanguageServerService#launch} asks; and so sessions, for scripts + * whose {@link ScriptDialect#languageId() dialect} makes them code of it. + */ + boolean supports(String languageId); + + /** + * Creates a server for documents of an LSP language this serves: for a + * session (see {@link ScriptSession}), which initializes + * it, and shuts it down when closed; or for whoever + * {@link LanguageServerService#launch launches} it. + *

+ * NB: The server analyzes documents in exactly the given environment: a + * session moves to a new server as where its script runs changes. + *

+ * + * @param languageId The LSP language asked for, one this + * {@link #supports(String) serves} (e.g. {@code groovy}). + * @param environment Where the scripts run, as far as known: e.g., for + * code typed into a running interpreter, its state (see + * {@link Environment#live()}). + */ + LanguageServer create(String languageId, Environment environment); +} diff --git a/scijava-code-lsp/src/main/java/org/scijava/code/lsp/LanguageServerService.java b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/LanguageServerService.java new file mode 100644 index 000000000..f7a40b552 --- /dev/null +++ b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/LanguageServerService.java @@ -0,0 +1,91 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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; + + +import java.util.Map; + +import org.eclipse.lsp4j.services.LanguageServer; +import org.scijava.code.lsp.ScriptDialect.Variable; +import org.scijava.script.ScriptLanguage; +import org.scijava.service.SciJavaService; + +/** + * Serves SciJava scripts with language servers: what a {@link ScriptSession} + * about a script uses, to show servers of its own (from the highest-priority + * {@link LanguageServerPlugin} serving its {@link ScriptDialect}'s language) + * the script as plain code. Servers of an LSP language can also be + * {@link #launch launched} directly. + * + * @author Gabriel Selzer + */ +public interface LanguageServerService extends SciJavaService { + + /** + * Gets the highest-priority dialect of a script language: how its scripts + * become plain code of an LSP language. + * + * @throws IllegalArgumentException If the language has none. + */ + ScriptDialect dialect(ScriptLanguage language); + + /** + * Gets a script as plain code of the LSP language of the given dialect: its + * parameters declared, and the lines SciJava removes before it runs + * blanked, as SciJava's own script processors find them. + * + * @param variables Others to declare too (e.g. a live interpreter's), + * which its parameters of the same names win over. + */ + ScriptDocument document(String script, ScriptDialect dialect, + Map variables); + + /** Whether any plugin serves scripts of the given language. */ + boolean supports(ScriptLanguage language); + + /** + * Whether any plugin serves documents of the given LSP language (e.g. + * {@code python}): i.e. whether {@link #launch} can give a server for it. + */ + boolean supports(String languageId); + + /** + * Gets a new server for an LSP language (e.g. {@code groovy}, or + * {@code python}), from the highest-priority {@link LanguageServerPlugin} + * serving it (e.g. in-process, or a proxy to a server process). It sees + * documents as plain code of the language, and + * analyzes them as running in the given environment. The caller + * initializes it, and shuts it down. + * + * @param environment Where the scripts run. + * @throws IllegalArgumentException If no plugin serves the language. + */ + LanguageServer launch(String languageId, Environment environment); +} diff --git a/scijava-code-lsp/src/main/java/org/scijava/code/lsp/Positions.java b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/Positions.java new file mode 100644 index 000000000..ba51712fd --- /dev/null +++ b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/Positions.java @@ -0,0 +1,71 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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; + +import org.eclipse.lsp4j.Position; + +/** + * Converts between offsets in a text and LSP positions: a line, and a + * character in that line, counted in UTF-16 code units (as Java does). + * + * @author Gabriel Selzer + */ +public final class Positions { + + private Positions() { + // NB: Prevent instantiation of utility class. + } + + /** Converts an offset to an LSP position (clamped to the text). */ + public static Position position(final String text, final int offset) { + final int end = Math.max(0, Math.min(offset, text.length())); + int line = 0, start = 0; + for (int i = 0; i < end; i++) { + if (text.charAt(i) == '\n') { + line++; + start = i + 1; + } + } + return new Position(line, end - start); + } + + /** Converts an LSP position to an offset (clamped to the text). */ + public static int offset(final String text, final Position position) { + int start = 0; + for (int line = 0; line < position.getLine(); line++) { + final int nl = text.indexOf('\n', start); + if (nl < 0) return text.length(); + start = nl + 1; + } + int end = text.indexOf('\n', start); + if (end < 0) end = text.length(); + return Math.min(start + position.getCharacter(), end); + } +} diff --git a/scijava-code-lsp/src/main/java/org/scijava/code/lsp/ScriptDialect.java b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/ScriptDialect.java new file mode 100644 index 000000000..8c3a69c32 --- /dev/null +++ b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/ScriptDialect.java @@ -0,0 +1,194 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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; + +import java.io.File; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.scijava.plugin.SciJavaPlugin; +import org.scijava.script.ScriptLanguage; + +/** + * A {@link SciJavaPlugin} that knows how to turn the scripts of a SciJava + * language into plain code of an LSP language (a {@link ScriptDocument}), for + * language servers of that language to analyze (see + * {@link ScriptSession}). E.g. Appose-Python scripts become + * plain Python, their parameters declared as NumPy arrays and the like. + *

+ * So script languages need not offer language servers themselves: servers + * know their language, not SciJava scripts (see {@link LanguageServerPlugin}). + *

+ *

+ * A dialect supplies the declarations, rather than translating scripts: a + * {@link ScriptDocument} translates every script the same way, prepending the + * dialect's {@link #header}, and blanking in place the lines SciJava removes + * before a script runs (e.g. its {@code #@} lines). So, for every language, + * positions in a script and in its document differ by just the header's + * length, which is how a session maps servers' answers back to the script + * (and leaves out those about the header); and that mapping is written, and + * tested, once. A language whose scripts need more (e.g. wrapping in a class) + * would extend that rule in {@link ScriptDocument}, for all languages. + *

+ * + * @author Gabriel Selzer + * @see ScriptDocument + */ +public interface ScriptDialect extends SciJavaPlugin { + + /** Whether this turns scripts of the given SciJava language into code. */ + boolean supports(ScriptLanguage language); + + /** + * Gets the LSP language (e.g. {@code python}) of the code scripts become: + * whose servers analyze them (see {@link LanguageServerService#launch}). + */ + // TODO: Consider whether this is necessary: could the script language name + // it, or servers be found some other way (e.g. by the dialect's class)? + String languageId(); + + /** + * Gets where a script runs, as far as its text says (e.g. an Appose + * environment it declares), to analyze it there. Asked before each request + * about the script, as that may change; its session then moves to a server + * of where it runs now (see {@link ScriptSession#update}). The default + * knows nothing: a blank {@link Environment}. + * + * @param text The script, as written (e.g. with its {@code #@script} line, + * which its servers do not see). + * @param file The script's file, if it has one (not if e.g. unsaved). + */ + // TODO: Consider whether this is necessary: so far only Appose-Python + // scripts say where they run, and dialects otherwise only translate. + default Environment environment(final String text, + final Optional file) + { + return new Environment(); + } + + /** + * Gets the code declaring the variables a script has before it runs, with + * the types they have then, for a {@link ScriptDocument} to prepend to the + * script: e.g. {@code import numpy; image: numpy.ndarray} for an image in + * CPython. It may also import what the declarations need, and declare other + * variables scripts can use (e.g. Appose's {@code task}); variables without + * a useful type may be left out. + * + * @param variables The script's variables, by name, in order: its + * parameters (see {@link ScriptDocument}), and, if typed into a + * live interpreter, the interpreter's (see {@link ScriptSession}). + */ + Header header(Map variables); + + /** + * A variable a script has before it runs: its Java type, and its value, if + * known (e.g. a live interpreter's, rather than a script parameter's). + */ + final class Variable { + + private final Class type; + private final Object value; + + private Variable(final Class type, final Object value) { + this.type = type; + this.value = value; + } + + /** A variable of the given type, whose value is not known yet. */ + public static Variable ofType(final Class type) { + return new Variable(type, null); + } + + /** + * A variable with the given value (and type, by which code can refer to + * it, e.g. a public one of the value's class). + */ + public static Variable ofValue(final Object value, final Class type) { + return new Variable(type, value); + } + + /** The variable's Java type. */ + public Class type() { + return type; + } + + /** The variable's value, if known (and not null). */ + public Optional value() { + return Optional.ofNullable(value); + } + } + + /** + * One line of code to prepend to a script (see {@link #header}), e.g. + * statements joined by {@code ;}. + */ + final class Header { + + private final String code; + private final Set hiddenNames; + + /** + * @param code The code: one line, without a line break (the + * {@link ScriptDocument} adds one after it), or empty if none. + * @param hiddenNames The names to leave out of completion: those the + * code binds only to declare the parameters, e.g. + * {@code numpy} for {@code import numpy}. + * @throws IllegalArgumentException If the code has a line break. + */ + public Header(final String code, final Collection hiddenNames) { + if (code.indexOf('\n') >= 0 || code.indexOf('\r') >= 0) { + throw new IllegalArgumentException( + "A header is one line, without line breaks: " + code); + } + this.code = code; + this.hiddenNames = Collections.unmodifiableSet(new LinkedHashSet<>( + hiddenNames)); + } + + /** The code: one line, without a line break; or empty if none. */ + public String code() { + return code; + } + + /** + * The names to leave out of completion: those the code binds only to + * declare the parameters, e.g. {@code numpy} for {@code import numpy} + * (unless the script itself mentions them, see + * {@link ScriptDocument#hiddenNames()}). + */ + public Set hiddenNames() { + return hiddenNames; + } + } +} diff --git a/scijava-code-lsp/src/main/java/org/scijava/code/lsp/ScriptDocument.java b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/ScriptDocument.java new file mode 100644 index 000000000..7922625ca --- /dev/null +++ b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/ScriptDocument.java @@ -0,0 +1,217 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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; + +import java.io.File; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.BiFunction; +import java.util.regex.Pattern; + +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; + +/** + * A SciJava script as plain code of its language (a "virtual document"), for + * tools that know the language but not SciJava, e.g. language servers: its + * script parameters ({@code #@}) declared with the types they have at run + * time, and the lines SciJava removes before it runs (e.g. those {@code #@} + * lines) blanked. (A {@link org.scijava.code.lsp.ScriptSession} shows its + * server this text.) + *

+ * The transformation only prepends a header (see {@link ScriptDialect#header}) + * and blanks lines in place (with spaces), so positions in the script just + * shift by the header (see {@link #toTransformed(int)} and + * {@link #toOriginal(int)}). Offsets count + * {@code char}s, i.e. UTF-16 code units, as do Java strings and the Language + * Server Protocol. + *

+ * + * @author Gabriel Selzer + * @see ScriptDialect + */ +public final class ScriptDocument { + + private final String original; + private final String text; + private final String languageId; + /** Where the script runs, as its dialect says, given its file. */ + private final BiFunction, Environment> where; + /** Number of characters prepended. */ + private final int headerLength; + private final Set hiddenNames; + + private ScriptDocument(final String original, final String text, + final String languageId, + final BiFunction, Environment> where, + final int headerLength, final Set hiddenNames) + { + this.original = original; + this.text = text; + this.languageId = languageId; + this.where = where; + this.headerLength = headerLength; + this.hiddenNames = Collections.unmodifiableSet(hiddenNames); + } + + /** + * A script as it is, as plain text ({@code plaintext}): nothing declared, + * nothing blanked. + */ + public static ScriptDocument of(final String script) { + return new ScriptDocument(script, script, "plaintext", (t, + f) -> new Environment(), 0, Collections.emptySet()); + } + + /** + * Transforms a script. + * + * @param script The script's source code. + * @param variables The variables defined before the script runs (e.g. its + * script parameters), by name. + * @param removedLines The lines (from 0) to blank: those SciJava removes + * before running the script (e.g. its {@code #@} lines). + * @param dialect How the script's language declares them. + */ + public static ScriptDocument of(final String script, + final Map variables, + final Set removedLines, final ScriptDialect dialect) + { + final ScriptDialect.Header h = dialect.header(variables); + final String header = h.code().isEmpty() ? "" : h.code() + "\n"; + final Set hidden = new LinkedHashSet<>(); + for (final String name : h.hiddenNames()) { + if (!mentions(script, name)) hidden.add(name); + } + return new ScriptDocument(script, header + blank(script, removedLines), + dialect.languageId(), dialect::environment, header.length(), + hidden); + } + + /** + * The names to leave out of completion: those only the header binds (see + * {@link ScriptDialect.Header#hiddenNames()}), which the script does not + * mention. + */ + Set hiddenNames() { + return hiddenNames; + } + + /** The script as plain code of its language. */ + public String text() { + return text; + } + + /** + * The LSP language of the {@link #text()} (e.g. {@code python}), as its + * dialect says; or {@code plaintext}, for a script as it is. + */ + String languageId() { + return languageId; + } + + /** + * Gets where the script runs, as its dialect says (see + * {@link ScriptDialect#environment}); or a blank {@link Environment}, for a + * script as it is. + * + * @param file The script's file, if it has one. + */ + Environment environment(final Optional file) { + return where.apply(original, file); + } + + /** Maps an offset in the script to one in {@link #text()}. */ + int toTransformed(final int offset) { + return offset + headerLength; + } + + /** + * Maps an offset in {@link #text()} to one in the script; or + * returns -1 if it lies in the header prepended. + */ + public int toOriginal(final int offset) { + return offset < headerLength ? -1 : offset - headerLength; + } + + /** Maps a position in the script to one in {@link #text()}. */ + Position toTransformed(final Position p) { + return Positions.position(text, toTransformed(Positions.offset(original, + p))); + } + + /** + * Maps a position in {@link #text()} to one in the script; or returns null + * if it lies in the header prepended. + */ + Position toOriginal(final Position p) { + final int offset = toOriginal(Positions.offset(text, p)); + return offset < 0 ? null : Positions.position(original, offset); + } + + /** + * Maps a range in {@link #text()} to one in the script; or returns null if + * either end lies in the header prepended. + */ + Range toOriginal(final Range r) { + final Position start = toOriginal(r.getStart()); + final Position end = toOriginal(r.getEnd()); + return start == null || end == null ? null : new Range(start, end); + } + + // -- Helper methods -- + + /** Whether the script mentions a name, e.g. to import it itself. */ + private static boolean mentions(final String script, final String name) { + return Pattern.compile("(? lines) { + final StringBuilder sb = new StringBuilder(script); + int start = 0; + for (int line = 0; start <= script.length(); line++) { + int end = script.indexOf('\n', start); + if (end < 0) end = script.length(); + // NB: Keep a carriage return, so the line structure is unchanged. + final int contentEnd = end > start && script.charAt(end - 1) == '\r' + ? end - 1 : end; + if (lines.contains(line)) { + for (int i = start; i < contentEnd; i++) sb.setCharAt(i, ' '); + } + start = end + 1; + } + return sb.toString(); + } +} diff --git a/scijava-code-lsp/src/main/java/org/scijava/code/lsp/ScriptSession.java b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/ScriptSession.java new file mode 100644 index 000000000..8ff7c6a17 --- /dev/null +++ b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/ScriptSession.java @@ -0,0 +1,730 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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; + +import java.io.File; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +import javax.script.Bindings; +import javax.script.ScriptContext; + +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionList; +import org.eclipse.lsp4j.CompletionParams; +import org.eclipse.lsp4j.Diagnostic; +import org.eclipse.lsp4j.DidChangeTextDocumentParams; +import org.eclipse.lsp4j.DidCloseTextDocumentParams; +import org.eclipse.lsp4j.DidOpenTextDocumentParams; +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.InsertReplaceEdit; +import org.eclipse.lsp4j.MessageActionItem; +import org.eclipse.lsp4j.MessageParams; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.PublishDiagnosticsParams; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.SignatureHelp; +import org.eclipse.lsp4j.ShowMessageRequestParams; +import org.eclipse.lsp4j.SignatureHelpParams; +import org.eclipse.lsp4j.TextDocumentContentChangeEvent; +import org.eclipse.lsp4j.TextDocumentIdentifier; +import org.eclipse.lsp4j.TextDocumentItem; +import org.eclipse.lsp4j.TextEdit; +import org.eclipse.lsp4j.VersionedTextDocumentIdentifier; +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.scijava.code.lsp.ScriptDialect.Variable; +import org.scijava.log.LogService; +import org.scijava.log.Logger; +import org.scijava.script.ScriptLanguage; + +/** + * A {@link LanguageServerService} session for a given SciJava script. + *

+ * A session controls its own {@link LanguageServer}: it a server of its own, which it shuts down when closed; it + * keeps the server's copy of the script up to date when mutated by the + * client. The server sees the script as plain code of its language (see + * {@link ScriptDocument}); the session maps positions in its answers back, + * leaving out what concerns only the lines it added. + *

+ *

+ * The server analyzes the script where it runs, as its document says (see + * {@link ScriptDocument#environment}). As that changes (e.g. as the script's + * declared environment is edited), the session moves to a new server, of the + * new environment. It talks to its server off the caller's thread, telling + * and asking it everything in order. + *

+ * + * @author Gabriel Selzer + */ +public final class ScriptSession implements AutoCloseable { + + private static final AtomicInteger UNTITLED = new AtomicInteger(); + + /** + * The LSP language of scripts as they are (see + * {@link ScriptDocument#of(String)}). + */ + private static final String PLAINTEXT = "plaintext"; + + /** How long to wait for a server to initialize, in milliseconds. */ + private static final long INITIALIZE_TIMEOUT = 10_000; + + /** Where sessions talk to their servers (each in order). */ + private static final ExecutorService TALK = Executors.newCachedThreadPool( + r -> { + final Thread t = new Thread(r, "code-lsp-session"); + t.setDaemon(true); + return t; + }); + + private final Function launch; + private final Function transform; + /** Whether typed into a live interpreter, whose variables may change. */ + private final boolean live; + private final String untitled; + private volatile Supplier file = () -> null; + private final Logger log; + private volatile Consumer> diagnosticsListener; + + /** The server, once all it was told or asked so far is sent. */ + private CompletableFuture> server = + CompletableFuture.completedFuture(Optional.empty()); + /** Where the script runs, as the server is launched for. */ + private Optional environment = Optional.empty(); + /** How many servers were moved to: which one is meant, as of now. */ + private int generation; + /** The server whose messages are heard (set as it is launched). */ + private volatile LanguageServer current; + /** Whether closed: then, servers are told nothing more. */ + private boolean closed; + /** The server that made the last completions (see {@link #resolve}). */ + private int completedBy; + + private String uri; + private String text; + /** The text, as the server sees it. */ + private ScriptDocument doc; + private int version; + + /** + * Starts a session about a script of the given language: its servers are + * of the LSP language its dialect says, from the highest-priority plugin + * serving it (see {@link LanguageServerService#launch}), and see the script + * as plain code of that language. If the language has no dialect, or no + * plugin serves it, the session completes nothing. + * + * @param servers Where the dialect and servers come from. + * @param language The script's language. + */ + public ScriptSession(final LanguageServerService servers, + final ScriptLanguage language) + { + this(servers, language, Optional.empty()); + } + + /** + * As {@link #ScriptSession(LanguageServerService, ScriptLanguage)}, for + * code typed into a running interpreter: its state reaches the servers via + * their {@link Environment#live()}. If the language has no dialect, or no + * plugin serves it, the interpreter's variables are completed. + * + * @param servers Where the dialect and servers come from. + * @param language The interpreter's language. + * @param live The interpreter's state: its own context, whose bindings + * change as code runs in it. They are read anew each time the + * code is sent, so later variables are declared too. + */ + public ScriptSession(final LanguageServerService servers, + final ScriptLanguage language, final ScriptContext live) + { + this(servers, language, Optional.of(live)); + } + + private ScriptSession(final LanguageServerService servers, + final ScriptLanguage language, final Optional live) + { + this.launch = launcher(servers, language, live); + this.transform = transformer(servers, language, live); + this.live = live.isPresent(); + untitled = "untitled:/script-" + UNTITLED.incrementAndGet(); + log = servers.context().getService(LogService.class); + } + + /** + * Sets a supplier of the script's file, asked each time the script is + * updated, e.g. so that Save As is followed. + */ + public void setFile(final Supplier file) { + this.file = file == null ? () -> null : file; + } + + /** + * Sets who to tell about the problems in the script, each time the server + * reports them (on the server's thread): those of the text last sent, with + * positions in the script. + */ + public void setDiagnosticsListener( + final Consumer> listener) + { + diagnosticsListener = listener; + } + + /** + * Tells the server the script's text: opening it, the first time (or under + * a new name, or on a new server); otherwise, if it changed, sending it as + * the new version. First, if where the script runs changed (which it may, + * even if its text did not), moves to a server of where it runs now. + */ + public synchronized void update(final String newText) { + final String current = currentUri(); + if (uri != null && !uri.equals(current)) closeDocument(); + if (uri == null) { + uri = current; + version = 1; + text = newText; + doc = transform.apply(text); + follow(); + open(); + } + else { + final String sent = doc.text(); + final boolean edited = !newText.equals(text); + // NB: The code may change without the text changing, as a live + // interpreter's variables do. + if (edited || live) { + text = newText; + doc = transform.apply(text); + } + if (edited || !doc.text().equals(sent)) { + version++; + if (follow()) open(); + else { + final VersionedTextDocumentIdentifier id = + new VersionedTextDocumentIdentifier(uri, version); + final String code = doc.text(); + tell(s -> s.getTextDocumentService().didChange( + new DidChangeTextDocumentParams(id, Collections.singletonList( + new TextDocumentContentChangeEvent(code))))); + } + } + // NB: Where it runs may change without its text changing, e.g. once + // its environment is built. + else if (follow()) open(); + } + } + + // -- Requests: about the given text (sent first, if new), at an offset -- + + /** Completions at an offset: all servers', once all have answered. */ + public synchronized CompletableFuture completion( + final String script, final int offset) + { + update(script); + final ScriptDocument d = doc; + final CompletionParams params = new CompletionParams(identifier(), + position(offset)); + completedBy = generation; + return ask(s -> s.getTextDocumentService().completion(params)) + .thenApply( + answer -> answer == null ? new CompletionList() : back(d, answer + .isRight() ? answer.getRight() : new CompletionList(false, answer + .getLeft()))); + } + + /** + * A completion, with what it takes long to know (e.g. documentation); or + * null, if the server that made it is gone (e.g. as where the script runs + * changed since). + */ + public synchronized CompletableFuture resolve( + final CompletionItem item) + { + return ask(completedBy, s -> s.getTextDocumentService() + .resolveCompletionItem(item)); + } + + /** The signatures of the call around an offset (null if none). */ + public synchronized CompletableFuture signatureHelp( + final String script, final int offset) + { + update(script); + final SignatureHelpParams params = new SignatureHelpParams(identifier(), + position(offset)); + return ask(s -> s.getTextDocumentService().signatureHelp( + params)); + } + + /** What is known about the code at an offset (null if nothing). */ + public synchronized CompletableFuture hover(final String script, + final int offset) + { + update(script); + final ScriptDocument d = doc; + final HoverParams params = new HoverParams(identifier(), position( + offset)); + return ask(s -> s.getTextDocumentService().hover(params)) + .thenApply(h -> { + if (h == null || h.getRange() == null) return h; + final Hover out = new Hover(); + out.setContents(h.getContents()); + out.setRange(d.toOriginal(h.getRange())); + return out; + }); + } + + /** + * Closes the server's copy of the script, and shuts the server down + * (waiting a while for all it was told to be sent). A closed session tells + * servers nothing more, and answers nothing. + */ + @Override + public void close() { + final CompletableFuture> done; + synchronized (this) { + if (closed) return; + closeDocument(); + closed = true; + server = server.thenApplyAsync(s -> { + s.ifPresent(this::stop); + return Optional.empty(); + }, TALK); + done = server; + } + try { + done.get(INITIALIZE_TIMEOUT, TimeUnit.MILLISECONDS); + } + catch (final Exception exc) { + final Logger l = log; + if (l != null) l.debug("Language server shutdown failed", exc); + } + } + + // -- Helper methods -- + + /** + * Gets servers for scripts of a language: of the LSP language its dialect + * says, if served; otherwise (or if one cannot be created), of + * {@code plaintext}, as the script is then shown (see + * {@link ScriptDocument#of(String)}). + */ + private static Function launcher( + final LanguageServerService servers, final ScriptLanguage language, + final Optional live) + { + final LogService log = servers.context().getService(LogService.class); + final String languageId = servedLanguage(servers, language); + return env -> { + final Environment typedInto = live.map(env::withLive).orElse(env); + try { + return servers.launch(languageId, typedInto); + } + catch (final RuntimeException | LinkageError exc) { + if (PLAINTEXT.equals(languageId)) throw exc; + if (log != null) log.warn("Could not create a language server for " + + languageId, exc); + return servers.launch(PLAINTEXT, typedInto); + } + }; + } + + /** + * The LSP language of a script language's dialect, if a plugin serves it; + * or {@code plaintext}. + */ + private static String servedLanguage(final LanguageServerService servers, + final ScriptLanguage language) + { + try { + final String languageId = servers.dialect(language).languageId(); + if (servers.supports(languageId)) return languageId; + } + catch (final IllegalArgumentException exc) { + // NB: The language has no dialect. + } + return PLAINTEXT; + } + + /** + * Makes scripts of a language plain code of the LSP language its dialect + * says, if served (declaring a live interpreter's variables too, as of each + * transformation); otherwise, shows them as they are. + */ + private static Function transformer( + final LanguageServerService servers, final ScriptLanguage language, + final Optional live) + { + try { + final ScriptDialect dialect = servers.dialect(language); + if (servers.supports(dialect.languageId())) { + return text -> servers.document(text, dialect, live.map( + ScriptSession::variables).orElse(Collections.emptyMap())); + } + } + catch (final IllegalArgumentException exc) { + // NB: The language has no dialect. + } + return ScriptDocument::of; + } + + /** + * A live interpreter's variables (its bindings), by name: their values, + * with the most specific public types of them, by which code can refer to + * them. + */ + private static Map variables(final ScriptContext live) { + final Map vars = new LinkedHashMap<>(); + final Bindings bindings = live.getBindings(ScriptContext.ENGINE_SCOPE); + if (bindings == null) return vars; + for (final Map.Entry entry : bindings.entrySet()) { + final String name = entry.getKey(); + if (!isIdentifier(name) || name.startsWith("__")) continue; + final Object value = entry.getValue(); + vars.put(name, Variable.ofValue(value, value == null ? Object.class + : publicType(value.getClass()))); + } + return vars; + } + + private static boolean isIdentifier(final String name) { + if (name == null || name.isEmpty() || !Character.isJavaIdentifierStart( + name.charAt(0))) return false; + for (int i = 1; i < name.length(); i++) { + if (!Character.isJavaIdentifierPart(name.charAt(i))) return false; + } + return true; + } + + /** The most specific public class or interface of {@code c}. */ + private static Class publicType(final Class c) { + for (Class t = c; t != null; t = t.getSuperclass()) { + if (isAccessible(t)) return t; + } + for (final Class i : c.getInterfaces()) { + if (isAccessible(i)) return i; + } + return Object.class; + } + + private static boolean isAccessible(final Class c) { + for (Class t = c; t != null; t = t.getEnclosingClass()) { + if (!Modifier.isPublic(t.getModifiers())) return false; + } + return !c.isAnonymousClass() && !c.isLocalClass(); + } + + /** + * Moves to a server of where the script runs now, if that changed (or if + * there is none yet), unless closed: shutting down the server moved away + * from, after all it was told and asked so far. + * + * @return Whether it moved: if so, the new server knows nothing yet (e.g. + * of the open document). + */ + private boolean follow() { + final Environment env = doc.environment(Optional.ofNullable(file.get())); + if (closed || environment.equals(Optional.of(env))) return false; + environment = Optional.of(env); + generation++; + server = server.thenApplyAsync(old -> { + old.ifPresent(this::stop); + try { + return Optional.of(start(env)); + } + catch (final RuntimeException | LinkageError exc) { + final Logger l = log; + if (l != null) l.warn("Could not start a language server", exc); + return Optional.empty(); + } + }, TALK); + return true; + } + + /** Tells the server something, after all it was told so far. */ + private void tell(final Consumer message) { + if (closed) return; + server = server.thenApplyAsync(s -> { + try { + s.ifPresent(message); + } + catch (final RuntimeException | LinkageError exc) { + final Logger l = log; + if (l != null) l.debug("Language server failed", exc); + } + return s; + }, TALK); + } + + /** + * Asks the server something, after all it was told so far. The answer is + * null if there is no server (e.g. once closed). + */ + private CompletableFuture ask( + final Function> question) + { + return ask(generation, question); + } + + /** + * Asks the server moved to {@code of}-th something, if it is still the one + * meant: e.g. about something it made. Otherwise, the answer is null. + */ + private CompletableFuture ask(final int of, + final Function> question) + { + if (closed || of != generation) { + return CompletableFuture.completedFuture(null); + } + final CompletableFuture answer = new CompletableFuture<>(); + server = server.thenApplyAsync(s -> { + try { + final CompletableFuture a = s.isPresent() ? question.apply(s.get()) + : null; + if (a == null) answer.complete(null); + else a.whenComplete((r, exc) -> { + if (exc != null) answer.completeExceptionally(exc); + else answer.complete(r); + }); + } + catch (final RuntimeException | LinkageError exc) { + answer.completeExceptionally(exc); + } + return s; + }, TALK); + return answer; + } + + /** + * Launches a server of an environment, becomes its client, and initializes + * it: as LSP says, it awaits the server's answer before telling it anything + * else. (Servers in process answer at once.) + */ + private LanguageServer start(final Environment env) { + final LanguageServer s = launch.apply(env); + current = s; + if (s instanceof LanguageClientAware) { + ((LanguageClientAware) s).connect(new Client(s)); + } + try { + final CompletableFuture init = s.initialize( + new InitializeParams()); + if (init != null) init.get(INITIALIZE_TIMEOUT, TimeUnit.MILLISECONDS); + } + catch (final Exception exc) { + // NB: Not initialized (in time): asked anyway, answering what it can. + } + s.initialized(new InitializedParams()); + return s; + } + + /** Shuts a server down. */ + private void stop(final LanguageServer s) { + try { + final CompletableFuture done = s.shutdown(); + if (done != null) done.thenRun(s::exit); + } + catch (final RuntimeException | LinkageError exc) { + final Logger l = log; + if (l != null) l.debug("Language server shutdown failed", exc); + } + } + + /** Opens the server's copy of the script. */ + private void open() { + final TextDocumentItem item = new TextDocumentItem(uri, doc.languageId(), + version, doc.text()); + tell(s -> s.getTextDocumentService().didOpen( + new DidOpenTextDocumentParams(item))); + } + + /** Closes the server's copy of the script, if open. */ + private void closeDocument() { + if (uri == null) return; + final TextDocumentIdentifier id = new TextDocumentIdentifier(uri); + tell(s -> s.getTextDocumentService().didClose( + new DidCloseTextDocumentParams(id))); + uri = null; + text = null; + doc = null; + } + + /** Where an offset into the script is, in what the server sees. */ + private Position position(final int offset) { + return Positions.position(doc.text(), doc.toTransformed(offset)); + } + + private TextDocumentIdentifier identifier() { + return new TextDocumentIdentifier(uri); + } + + private String currentUri() { + final File f = file.get(); + return f == null ? untitled : f.getAbsoluteFile().toURI().toString(); + } + + /** + * Maps completions' edits back, leaving out those in the added lines, and + * those of names only the added lines bind. + */ + private static CompletionList back(final ScriptDocument doc, + final CompletionList list) + { + final List items = new ArrayList<>(); + for (final CompletionItem item : list.getItems()) { + if (doc.hiddenNames().contains(leadingName(item.getLabel()))) { + continue; + } + if (item.getTextEdit() != null) { + if (item.getTextEdit().isLeft()) { + final TextEdit e = item.getTextEdit().getLeft(); + final Range r = doc.toOriginal(e.getRange()); + if (r == null) continue; + item.setTextEdit(Either.forLeft(new TextEdit(r, e.getNewText()))); + } + else { + final InsertReplaceEdit e = item.getTextEdit().getRight(); + final Range insert = doc.toOriginal(e.getInsert()); + final Range replace = doc.toOriginal(e.getReplace()); + if (insert == null || replace == null) continue; + item.setTextEdit(Either.forRight(new InsertReplaceEdit(e + .getNewText(), insert, replace))); + } + } + if (item.getAdditionalTextEdits() != null) { + final List edits = new ArrayList<>(); + for (final TextEdit e : item.getAdditionalTextEdits()) { + final Range r = doc.toOriginal(e.getRange()); + if (r != null) edits.add(new TextEdit(r, e.getNewText())); + } + item.setAdditionalTextEdits(edits); + } + items.add(item); + } + return new CompletionList(list.isIncomplete(), items); + } + + /** + * The name a completion's label starts with, e.g. {@code java} for + * {@code java.lang} and {@code max} for {@code max(a, b)}. + */ + private static String leadingName(final String label) { + int end = 0; + while (end < label.length() && (Character.isLetterOrDigit(label.charAt( + end)) || label.charAt(end) == '_')) end++; + return label.substring(0, end); + } + + /** Maps problems back, leaving out those in the added lines. */ + private static List back(final ScriptDocument doc, + final List diagnostics) + { + final List out = new ArrayList<>(); + for (final Diagnostic d : diagnostics) { + final Range r = doc.toOriginal(d.getRange()); + if (r == null) continue; + d.setRange(r); + out.add(d); + } + return out; + } + + /** Hears the problems the current server finds, on its thread. */ + private void heard(final PublishDiagnosticsParams p) { + final List found; + synchronized (this) { + if (!p.getUri().equals(uri)) return; + // NB: Those of an earlier version are out of date. + if (p.getVersion() != null && p.getVersion() < version) return; + found = p.getDiagnostics() == null ? Collections.emptyList() : back( + doc, p.getDiagnostics()); + } + final Consumer> listener = diagnosticsListener; + if (listener != null) listener.accept(found); + } + + // -- Helper classes -- + + /** Receives a server's messages. */ + private final class Client implements LanguageClient { + + /** The server whose messages these are. */ + private final LanguageServer source; + + private Client(final LanguageServer source) { + this.source = source; + } + + @Override + public void publishDiagnostics(final PublishDiagnosticsParams p) { + // NB: Those of a server moved away from are out of date. + if (source == current) heard(p); + } + + @Override + public void telemetryEvent(final Object object) {} + + @Override + public void showMessage(final MessageParams m) { + logMessage(m); + } + + @Override + public CompletableFuture showMessageRequest( + final ShowMessageRequestParams r) + { + return CompletableFuture.completedFuture(null); + } + + @Override + public void logMessage(final MessageParams m) { + final Logger l = log; + if (l != null) l.debug("Language server: " + m.getMessage()); + } + } +} diff --git a/scijava-code-lsp/src/main/java/org/scijava/code/lsp/impl/BindingsLanguageServer.java b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/impl/BindingsLanguageServer.java new file mode 100644 index 000000000..f487890ac --- /dev/null +++ b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/impl/BindingsLanguageServer.java @@ -0,0 +1,117 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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.impl; + +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.regex.Matcher; +import java.util.regex.Pattern; + +import javax.script.Bindings; +import javax.script.ScriptContext; +import javax.script.SimpleBindings; + +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionItemKind; +import org.eclipse.lsp4j.CompletionList; +import org.scijava.code.lsp.Environment; +import org.scijava.code.lsp.jvm.AbstractLanguageServer; +import org.scijava.code.lsp.jvm.Members; + +/** + * A language server knowing only the variables of a running interpreter (see + * {@link Environment#live()}): their names, and the members of their values' + * classes. It serves {@code plaintext} (see {@link BindingsServerPlugin}), as + * scripts become whose languages have no servers of their own. + * + * @author Gabriel Selzer + */ +class BindingsLanguageServer extends AbstractLanguageServer { + + /** A trailing {@code variable.prefix} before the caret. */ + private static final Pattern MEMBER = Pattern.compile( + "([A-Za-z_][A-Za-z0-9_]*)\\.([A-Za-z0-9_]*)$"); + + /** A trailing word before the caret. */ + private static final Pattern WORD = Pattern.compile( + "([A-Za-z_][A-Za-z0-9_]*)$"); + + /** @param environment Where the code runs: its live interpreter, if any. */ + BindingsLanguageServer(final Environment environment) { + super(environment); + } + + @Override + protected CompletionList complete(final Document doc, final int offset) { + final Bindings bindings = environment().live().map(l -> l.getBindings( + ScriptContext.ENGINE_SCOPE)).orElseGet(SimpleBindings::new); + final String line = doc.lineTo(offset); + + final Matcher member = MEMBER.matcher(line); + if (member.find()) { + final Object value = bindings.get(member.group(1)); + final String prefix = member.group(2); + return sorted(doc, offset - prefix.length(), offset, value == null + ? new ArrayList<>() : Members.of(value.getClass(), prefix, false)); + } + final Matcher word = WORD.matcher(line); + final String prefix = word.find() ? word.group(1) : ""; + final String lp = prefix.toLowerCase(Locale.ROOT); + final List items = new ArrayList<>(); + for (final String name : bindings.keySet()) { + if (!name.toLowerCase(Locale.ROOT).startsWith(lp)) continue; + final CompletionItem item = new CompletionItem(name); + item.setKind(CompletionItemKind.Variable); + final Object value = bindings.get(name); + if (value != null) item.setDetail(Members.typeName(value.getClass())); + items.add(item); + } + return sorted(doc, offset - prefix.length(), offset, items); + } + + /** By name, without duplicates (e.g. overloads of the same arity). */ + private static CompletionList sorted(final Document doc, final int start, + final int caret, final List items) + { + final Map unique = new LinkedHashMap<>(); + for (final CompletionItem item : items) { + final String key = item.getLabel() + (item.getLabelDetails() == null ? "" + : item.getLabelDetails().getDetail()); + unique.putIfAbsent(key, item); + } + final List out = new ArrayList<>(unique.values()); + out.sort(Comparator.comparing(c -> c.getLabel().toLowerCase(Locale.ROOT))); + return list(doc, start, caret, out); + } +} diff --git a/scijava-code-lsp/src/main/java/org/scijava/code/lsp/impl/BindingsServerPlugin.java b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/impl/BindingsServerPlugin.java new file mode 100644 index 000000000..063064c9b --- /dev/null +++ b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/impl/BindingsServerPlugin.java @@ -0,0 +1,58 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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.impl; + +import org.eclipse.lsp4j.services.LanguageServer; +import org.scijava.code.lsp.Environment; +import org.scijava.code.lsp.LanguageServerPlugin; +import org.scijava.plugin.Plugin; + +/** + * Offers {@link BindingsLanguageServer}s for {@code plaintext}: scripts whose + * languages have no servers of their own, of which they then complete the + * interpreter's variables (if any). + * + * @author Gabriel Selzer + */ +@Plugin(type = LanguageServerPlugin.class, name = "Bindings") +public class BindingsServerPlugin implements LanguageServerPlugin { + + @Override + public boolean supports(final String languageId) { + return "plaintext".equals(languageId); + } + + @Override + public LanguageServer create(final String languageId, + final Environment environment) + { + return new BindingsLanguageServer(environment); + } +} diff --git a/scijava-code-lsp/src/main/java/org/scijava/code/lsp/impl/DefaultLanguageServerService.java b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/impl/DefaultLanguageServerService.java new file mode 100644 index 000000000..bd27185f3 --- /dev/null +++ b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/impl/DefaultLanguageServerService.java @@ -0,0 +1,164 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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.impl; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.lsp4j.services.LanguageServer; +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.ScriptDocument; +import org.scijava.Disposable; +import org.scijava.log.LogService; +import org.scijava.plugin.Parameter; +import org.scijava.plugin.Plugin; +import org.scijava.plugin.PluginService; +import org.scijava.script.ScriptLanguage; +import org.scijava.service.AbstractService; +import org.scijava.service.Service; + +/** + * Default implementation of {@link LanguageServerService}. + *

+ * The highest-priority dialect of a script language makes its scripts plain + * code; the highest-priority plugin serving that code's language serves it. + * Plugins are disposed with the context, e.g. to stop the server processes + * they keep. + *

+ * + * @author Gabriel Selzer + */ +@Plugin(type = Service.class) +public class DefaultLanguageServerService extends AbstractService implements + LanguageServerService { + + @Parameter + private PluginService pluginService; + + @Parameter(required = false) + private LogService log; + + private final ScriptProcessing processing = new ScriptProcessing( + this::context); + + private List plugins; + + @Override + public ScriptDocument document(final String script, + final ScriptDialect dialect, + final Map variables) + { + final ScriptProcessing.Processed p = processing.of(script); + final Map all = new LinkedHashMap<>( + variables); + // NB: Parameters win, declared last. + all.keySet().removeAll(p.parameters.keySet()); + p.parameters.forEach((name, type) -> all.put(name, ScriptDialect.Variable + .ofType(type))); + return ScriptDocument.of(script, all, p.removedLines, dialect); + } + + @Override + public boolean supports(final ScriptLanguage language) { + try { + return supports(dialect(language).languageId()); + } + catch (final IllegalArgumentException exc) { + return false; + } + } + + @Override + public boolean supports(final String languageId) { + try { + plugin(languageId); + return true; + } + catch (final IllegalArgumentException exc) { + return false; + } + } + + @Override + public LanguageServer launch(final String languageId, + final Environment environment) + { + return plugin(languageId).create(languageId, environment); + } + + // -- Disposable methods -- + + @Override + public synchronized void dispose() { + // NB: The plugins this created, e.g. those keeping server processes. + if (plugins == null) return; + for (final LanguageServerPlugin p : plugins) { + if (p instanceof Disposable) ((Disposable) p).dispose(); + } + plugins = null; + } + + // -- Helper methods -- + + /** + * The highest-priority dialect of a script language. + * + * @throws IllegalArgumentException If the language has none. + */ + @Override + public synchronized ScriptDialect dialect(final ScriptLanguage language) { + for (var p : pluginService.createInstancesOfType(ScriptDialect.class)) { + if (p.supports(language)) return p; + } + throw new IllegalArgumentException( + "No ScriptDialect for script language: " + language); + } + + /** + * The highest-priority plugin serving an LSP language. + * + * @throws IllegalArgumentException If no plugin serves the language. + */ + private synchronized LanguageServerPlugin plugin(final String languageId) { + if (plugins == null) { + plugins = pluginService.createInstancesOfType( + LanguageServerPlugin.class); + } + for (var p : plugins) { + if (p.supports(languageId)) return p; + } + throw new IllegalArgumentException( + "No LanguageServerPlugin serves language: " + languageId); + } +} \ No newline at end of file diff --git a/scijava-code-lsp/src/main/java/org/scijava/code/lsp/impl/ScriptProcessing.java b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/impl/ScriptProcessing.java new file mode 100644 index 000000000..2418d2ec8 --- /dev/null +++ b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/impl/ScriptProcessing.java @@ -0,0 +1,248 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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.impl; + +import java.io.StringReader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; +import java.util.regex.Pattern; + +import org.scijava.Context; +import org.scijava.module.ModuleItem; +import org.scijava.script.ScriptInfo; +import org.scijava.script.ScriptModule; +import org.scijava.script.process.ParameterScriptProcessor; +import org.scijava.script.process.ScriptProcessor; +import org.scijava.script.process.ShebangScriptProcessor; + +/** + * Processes scripts as SciJava does before running them (see + * {@link ScriptProcessor}), for sessions: the variables their parameters + * define, and the lines SciJava removes (e.g. {@code #@} lines). Remembers + * recent results, since sessions ask on every keystroke. + *

+ * Only the processors that just read lines run: + * {@link ShebangScriptProcessor} and {@link ParameterScriptProcessor}. + * {@code #@script} directives are removed without running theirs, which + * registers the script as a module. + *

+ * + * @author Gabriel Selzer + */ +final class ScriptProcessing { + + /** What SciJava makes of a script before running it. */ + static final class Processed { + + static final Processed NONE = new Processed(Collections.emptyMap(), + Collections.emptySet()); + + /** The variables its parameters define, by name, in order. */ + final Map> parameters; + + /** The lines (from 0) SciJava removes. */ + final Set removedLines; + + private Processed(final Map> parameters, + final Set removedLines) + { + this.parameters = parameters; + this.removedLines = removedLines; + } + } + + /** Number of distinct processed lines to remember. */ + private static final int CACHE_SIZE = 16; + + /** An old-style parameter, in a script's header: e.g. "// @String name". */ + private static final Pattern OLD_STYLE = Pattern.compile( + "[^\\w]*[^\\w#]@.*"); + + private static final Pattern WORD = Pattern.compile("\\w"); + + /** A {@code #@script(...)} directive. */ + private static final Pattern DIRECTIVE = Pattern.compile( + "#@script(?!\\w).*"); + + /** The lines processed → what SciJava made of them, in LRU order. */ + private final Map, Lines> cache = + new LinkedHashMap, Lines>(CACHE_SIZE, 0.75f, true) + { + + @Override + protected boolean removeEldestEntry( + final Map.Entry, Lines> eldest) + { + return size() > CACHE_SIZE; + } + }; + + private final Supplier context; + + ScriptProcessing(final Supplier context) { + this.context = context; + } + + /** Processes a script, as SciJava does before running it. */ + Processed of(final String text) { + if (text == null) return Processed.NONE; + // NB: Only the lines SciJava's processors act on are processed: they + // rarely change as a script is edited, so they make a good cache key; + // and processing logs warnings about invalid parameters, e.g. + // half-typed ones, which would otherwise be logged on every keystroke. + final List lines = new ArrayList<>(); + final List lineNumbers = new ArrayList<>(); + final String[] all = text.split("\r?\n", -1); + boolean header = true; + for (int n = 0; n < all.length; n++) { + final String line = all[n]; + final boolean shebang = n == 0 && line.startsWith("#!"); + final boolean processed = shebang || line.startsWith("#@") || + header && OLD_STYLE.matcher(line).matches(); + if (!processed && WORD.matcher(line).find()) header = false; + if (processed) { + lines.add(line); + lineNumbers.add(n); + } + } + if (lines.isEmpty()) return Processed.NONE; + + final Lines result; + synchronized (cache) { + result = cache.computeIfAbsent(lines, this::process); + } + final Set removed = new LinkedHashSet<>(); + for (int i = 0; i < lines.size(); i++) { + if (result.removed[i]) removed.add(lineNumbers.get(i)); + } + return new Processed(result.parameters, Collections.unmodifiableSet( + removed)); + } + + // -- Helper methods -- + + /** What SciJava made of some lines. */ + private static final class Lines { + + private final Map> parameters; + private final boolean[] removed; + + private Lines(final Map> parameters, + final boolean[] removed) + { + this.parameters = parameters; + this.removed = removed; + } + } + + /** Runs SciJava's script processors on the given lines. */ + private Lines process(final List lines) { + final Map> params = new LinkedHashMap<>(); + final boolean[] removed = new boolean[lines.size()]; + try { + final Context context = this.context.get(); + final List processors = Arrays.asList( + new ShebangScriptProcessor(), new ParameterScriptProcessor()); + for (final ScriptProcessor p : processors) context.inject(p); + final ScriptInfo info = new ScriptInfo(context, "completion", + new StringReader(String.join("\n", lines) + "\n")) + { + + @Override + public void parseParameters() { + clearParameters(); + for (final ScriptProcessor p : processors) p.begin(this); + for (int i = 0; i < lines.size(); i++) { + // NB: Directives are removed, without running their + // processor. + if (DIRECTIVE.matcher(lines.get(i)).matches()) continue; + String line = lines.get(i); + for (final ScriptProcessor p : processors) { + line = p.process(line); + } + removed[i] = line.isEmpty(); + } + for (final ScriptProcessor p : processors) p.end(); + } + }; + for (final ModuleItem item : info.inputs()) { + params.putIfAbsent(item.getName(), item.getType()); + } + final boolean implicitResult = info.isReturnValueAppended(); + for (final ModuleItem item : info.outputs()) { + final boolean declared = !implicitResult || // + !ScriptModule.RETURN_VALUE.equals(item.getName()); + if (declared) params.putIfAbsent(item.getName(), item.getType()); + } + } + catch (final RuntimeException exc) { + // NB: Services needed for script processing are unavailable, or the + // lines are too malformed to process. Fall through. + } + // NB: Parameters SciJava rejects, e.g. half-typed or of types it cannot + // resolve, are likely being typed: the variables they declare are still + // known, and their #@ lines removed all the same. + for (int i = 0; i < lines.size(); i++) { + final String line = lines.get(i); + if (line.startsWith("#!")) continue; // shebang + final String name = declaredName(line); + if (name != null) params.putIfAbsent(name, Object.class); + if (line.startsWith("#@")) removed[i] = true; + } + return new Lines(Collections.unmodifiableMap(params), removed); + } + + /** + * Gets the variable name declared by a parameter line, without resolving its + * type, or null if the line does not declare one. + */ + private static String declaredName(final String line) { + final String param = line.substring(line.indexOf('@') + 1); + final int lParen = param.indexOf('('); + final int rParen = param.lastIndexOf(')'); + final String cut = lParen >= 0 && rParen > lParen ? // + param.substring(0, lParen) + param.substring(rParen + 1) : param; + final String[] tokens = cut.trim().split("[ \t]+"); + if (tokens.length < 2) return null; + final String name = tokens[tokens.length - 1]; + if (!Character.isJavaIdentifierStart(name.charAt(0))) return null; + for (int i = 1; i < name.length(); i++) { + if (!Character.isJavaIdentifierPart(name.charAt(i))) return null; + } + return name; + } +} diff --git a/scijava-code-lsp/src/main/java/org/scijava/code/lsp/impl/package-info.java b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/impl/package-info.java new file mode 100644 index 000000000..5b0bd6918 --- /dev/null +++ b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/impl/package-info.java @@ -0,0 +1,36 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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% + */ + +/** + * The implementation of the module, internal to it (not exported): the + * services, the fallback for + * interpreters of languages no plugin serves, and the parsing of script + * parameters. + */ +package org.scijava.code.lsp.impl; diff --git a/scijava-code-lsp/src/main/java/org/scijava/code/lsp/jvm/AbstractLanguageServer.java b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/jvm/AbstractLanguageServer.java new file mode 100644 index 000000000..347828ae0 --- /dev/null +++ b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/jvm/AbstractLanguageServer.java @@ -0,0 +1,429 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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.jvm; + +import java.io.File; +import java.net.URI; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionList; +import org.eclipse.lsp4j.CompletionOptions; +import org.eclipse.lsp4j.CompletionParams; +import org.eclipse.lsp4j.Diagnostic; +import org.eclipse.lsp4j.DiagnosticSeverity; +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.MarkupContent; +import org.eclipse.lsp4j.MarkupKind; +import org.eclipse.lsp4j.PublishDiagnosticsParams; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.ServerCapabilities; +import org.eclipse.lsp4j.SignatureHelp; +import org.eclipse.lsp4j.SignatureHelpOptions; +import org.eclipse.lsp4j.SignatureHelpParams; +import org.eclipse.lsp4j.TextDocumentItem; +import org.eclipse.lsp4j.TextDocumentSyncKind; +import org.eclipse.lsp4j.TextEdit; +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.Environment; +import org.scijava.code.lsp.Positions; + +/** + * Base class for in-process language servers of SciJava script languages: + * keeps the open documents (full sync), answers requests by asking the + * subclass about a {@link Document} and offset, resolves documentation on + * demand (see {@link #describe}), and looks for problems a moment after each + * change (see {@link #diagnose}). + * + * @author Gabriel Selzer + */ +public abstract class AbstractLanguageServer implements LanguageServer, + TextDocumentService, WorkspaceService, LanguageClientAware +{ + + /** + * How long a document must stay unchanged before its problems are looked + * for, in milliseconds. (Editors send every keystroke.) + */ + private static final long PAUSE = Long.getLong("scijava.lsp.pause", 300); + + /** How many completions' documentation to remember. */ + private static final int REMEMBERED = 5000; + + private static final ScheduledExecutorService TIMER = Executors + .newSingleThreadScheduledExecutor(r -> { + final Thread t = new Thread(r, "scijava-script-server"); + t.setDaemon(true); + return t; + }); + + private final Environment environment; + private volatile LanguageClient client; + private final Map docs = new ConcurrentHashMap<>(); + private final Map> descriptions = + new ConcurrentHashMap<>(); + private final AtomicInteger ids = new AtomicInteger(); + + /** @param environment Where the scripts run. */ + protected AbstractLanguageServer(final Environment environment) { + this.environment = Objects.requireNonNull(environment, "environment"); + } + + /** Where the scripts run (e.g. a live interpreter's state). */ + protected Environment environment() { + return environment; + } + + /** An open script. */ + public static final class Document { + + private final String uri; + private final String path; + private volatile String text; + private volatile int version; + private ScheduledFuture pending; + + Document(final String uri, final String text, final int version) { + this.uri = uri; + this.path = pathOf(uri); + this.text = text; + this.version = version; + } + + public String uri() { + return uri; + } + + /** The script's file path, or null if unsaved. */ + public String path() { + return path; + } + + public String text() { + return text; + } + + /** The text of the line of an offset, up to it. */ + public String lineTo(final int offset) { + final String t = text; + final int end = Math.min(offset, t.length()); + return t.substring(t.lastIndexOf('\n', end - 1) + 1, end); + } + + /** The range between two offsets. */ + public Range range(final int start, final int end) { + return new Range(Positions.position(text, Math.min(start, text + .length())), Positions.position(text, Math.min(end, text.length()))); + } + } + + // -- To implement -- + + /** Gets the completions at an offset of a script. */ + protected abstract CompletionList complete(Document doc, int offset); + + /** Gets the signatures of the call around an offset, or null. */ + protected SignatureHelp signatureHelp(final Document doc, final int offset) { + return null; + } + + /** Gets what is known about the code at an offset, or null. */ + protected Hover hover(final Document doc, final int offset) { + return null; + } + + /** Finds the problems in a script. The default finds none. */ + protected List diagnose(final Document doc) { + return Collections.emptyList(); + } + + /** Notes that a script was opened (e.g. to get ready for it). */ + protected void opened(final Document doc) {} + + /** Notes that a script was closed (e.g. to release what is kept for it). */ + protected void closed(final Document doc) {} + + // -- Helpers -- + + /** + * Attaches documentation to a completion, computed only when shown (when + * the client resolves the completion). + * + * @param html Supplies the documentation, as HTML (sent as Markdown, which + * allows HTML). + */ + protected CompletionItem describe(final CompletionItem item, + final Supplier html) + { + if (html == null) return item; + if (descriptions.size() > REMEMBERED) descriptions.clear(); + final int id = ids.incrementAndGet(); + descriptions.put(id, html); + item.setData(id); + return item; + } + + /** + * Makes a list of completions replacing the text from {@code start} to the + * caret (those without an edit of their own), in the given order. + */ + protected static CompletionList list(final Document doc, final int start, + final int caret, final List items) + { + final Range range = doc.range(start, caret); + for (int i = 0; i < items.size(); i++) { + final CompletionItem item = items.get(i); + if (item.getTextEdit() == null) { + final String newText = item.getInsertText() != null ? item + .getInsertText() : item.getLabel(); + item.setTextEdit(Either.forLeft(new TextEdit(range, newText))); + } + if (item.getSortText() == null) item.setSortText(String.format("%06d", + i)); + } + return new CompletionList(false, items); + } + + /** An edit inserting text at an offset. */ + protected static TextEdit insert(final Document doc, final int offset, + final String text) + { + return new TextEdit(doc.range(offset, offset), text); + } + + /** A problem, between two offsets. */ + protected static Diagnostic diagnostic(final Document doc, final int start, + final int end, final DiagnosticSeverity severity, final String message, + final String source) + { + final Diagnostic d = new Diagnostic(doc.range(start, Math.max(start, end)), + message); + d.setSeverity(severity); + d.setSource(source); + return d; + } + + // -- LanguageServer -- + + @Override + public void connect(final LanguageClient c) { + client = c; + } + + @Override + public CompletableFuture initialize( + final InitializeParams params) + { + final ServerCapabilities capabilities = new ServerCapabilities(); + capabilities.setTextDocumentSync(TextDocumentSyncKind.Full); + capabilities.setCompletionProvider(new CompletionOptions(true, + Collections.singletonList("."))); + capabilities.setSignatureHelpProvider(new SignatureHelpOptions(Arrays + .asList("(", ","))); + capabilities.setHoverProvider(true); + return CompletableFuture.completedFuture(new InitializeResult( + capabilities)); + } + + @Override + public CompletableFuture shutdown() { + return CompletableFuture.completedFuture(null); + } + + @Override + public void exit() {} + + @Override + public TextDocumentService getTextDocumentService() { + return this; + } + + @Override + public WorkspaceService getWorkspaceService() { + return this; + } + + // -- TextDocumentService -- + + @Override + public void didOpen(final DidOpenTextDocumentParams params) { + final TextDocumentItem item = params.getTextDocument(); + final Document doc = new Document(item.getUri(), item.getText(), item + .getVersion()); + docs.put(doc.uri, doc); + try { + opened(doc); + } + catch (final RuntimeException | LinkageError exc) { + // NB: Getting ready is optional. + } + scheduleDiagnosis(doc); + } + + @Override + public void didChange(final DidChangeTextDocumentParams params) { + final Document doc = docs.get(params.getTextDocument().getUri()); + if (doc == null || params.getContentChanges().isEmpty()) return; + doc.text = params.getContentChanges().get(params.getContentChanges() + .size() - 1).getText(); + doc.version = params.getTextDocument().getVersion(); + scheduleDiagnosis(doc); + } + + @Override + public void didClose(final DidCloseTextDocumentParams params) { + final Document doc = docs.remove(params.getTextDocument().getUri()); + if (doc == null) return; + synchronized (doc) { + if (doc.pending != null) doc.pending.cancel(false); + } + closed(doc); + } + + @Override + public void didSave(final DidSaveTextDocumentParams params) {} + + @Override + public CompletableFuture, CompletionList>> + completion(final CompletionParams params) + { + final Document doc = docs.get(params.getTextDocument().getUri()); + if (doc == null) return CompletableFuture.completedFuture(Either.forRight( + new CompletionList())); + final CompletionList list = complete(doc, Positions.offset(doc.text, + params.getPosition())); + return CompletableFuture.completedFuture(Either.forRight(list == null + ? new CompletionList() : list)); + } + + @Override + public CompletableFuture resolveCompletionItem( + final CompletionItem item) + { + final Supplier html = descriptions.get(id(item.getData())); + if (html == null) return CompletableFuture.completedFuture(item); + return CompletableFuture.supplyAsync(() -> { + final String doc = html.get(); + if (doc != null && !doc.isEmpty()) { + item.setDocumentation(new MarkupContent(MarkupKind.MARKDOWN, doc)); + } + return item; + }); + } + + @Override + public CompletableFuture signatureHelp( + final SignatureHelpParams params) + { + final Document doc = docs.get(params.getTextDocument().getUri()); + return CompletableFuture.completedFuture(doc == null ? null + : signatureHelp(doc, Positions.offset(doc.text, params.getPosition()))); + } + + @Override + public CompletableFuture hover(final HoverParams params) { + final Document doc = docs.get(params.getTextDocument().getUri()); + return CompletableFuture.completedFuture(doc == null ? null : hover(doc, + Positions.offset(doc.text, params.getPosition()))); + } + + // -- WorkspaceService -- + + @Override + public void didChangeConfiguration(final DidChangeConfigurationParams p) {} + + @Override + public void didChangeWatchedFiles(final DidChangeWatchedFilesParams p) {} + + // -- Helper methods -- + + private void scheduleDiagnosis(final Document doc) { + synchronized (doc) { + if (doc.pending != null) doc.pending.cancel(false); + doc.pending = TIMER.schedule(() -> { + final int version = doc.version; + List found; + try { + found = diagnose(doc); + } + catch (final RuntimeException | LinkageError exc) { + found = Collections.emptyList(); + } + final LanguageClient c = client; + // NB: Only if still current. + if (c == null || docs.get(doc.uri) != doc || doc.version != version) { + return; + } + c.publishDiagnostics(new PublishDiagnosticsParams(doc.uri, found == + null ? Collections.emptyList() : found, version)); + }, PAUSE, TimeUnit.MILLISECONDS); + } + } + + private static int id(final Object data) { + return data instanceof Number ? ((Number) data).intValue() : -1; + } + + private static String pathOf(final String uri) { + try { + final URI u = URI.create(uri); + return "file".equals(u.getScheme()) ? new File(u).getPath() : null; + } + catch (final IllegalArgumentException exc) { + return null; + } + } +} diff --git a/scijava-code-lsp/src/main/java/org/scijava/code/lsp/jvm/Callable.java b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/jvm/Callable.java new file mode 100644 index 000000000..5129fc6d5 --- /dev/null +++ b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/jvm/Callable.java @@ -0,0 +1,204 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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.jvm; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionItemKind; +import org.eclipse.lsp4j.CompletionItemLabelDetails; +import org.eclipse.lsp4j.InsertTextFormat; +import org.eclipse.lsp4j.ParameterInformation; +import org.eclipse.lsp4j.SignatureInformation; + +/** + * Something to call (a method, function or constructor), as in-process + * language servers describe it: its name, its parameters (with their types, + * where known) and its return type. As a completion, it is a snippet with a + * tab stop per parameter ({@code max(${1:a}, ${2:b})}) and the typed + * parameters as label details ({@code (double a, double b)}); as a + * signature, {@code max(double a, double b) -> double}. + * + * @author Gabriel Selzer + */ +public final class Callable { + + /** A parameter: its name, and its type (or null if unknown). */ + public static final class Param { + + private final String name; + private final String type; + + public Param(final String name, final String type) { + this.name = name; + this.type = type; + } + + public String name() { + return name; + } + + /** The type (e.g. {@code double} or {@code java.lang.String}), or null. */ + public String type() { + return type; + } + + /** E.g. {@code double a}, or {@code a} if the type is unknown. */ + @Override + public String toString() { + return type == null ? name : type + " " + name; + } + } + + private final String name; + private final List params; + private final String returnType; + private CompletionItemKind kind = CompletionItemKind.Method; + private String detail; + + /** + * @param name The name (as inserted, e.g. {@code max}). + * @param params Its parameters. + * @param returnType Its return type, or null if unknown (or none). + */ + public Callable(final String name, final List params, + final String returnType) + { + this.name = name; + this.params = params == null ? Collections.emptyList() : Collections + .unmodifiableList(new ArrayList<>(params)); + this.returnType = returnType; + } + + /** Sets the kind of completion (default: method). */ + public Callable kind(final CompletionItemKind k) { + kind = k; + return this; + } + + /** Sets a one-line summary (e.g. a full signature). */ + public Callable detail(final String d) { + detail = d; + return this; + } + + public String name() { + return name; + } + + public List params() { + return params; + } + + public String returnType() { + return returnType; + } + + /** The parameter types, e.g. for sorting overloads. */ + public String parameterTypes() { + final StringBuilder sb = new StringBuilder(); + for (final Param p : params) { + if (sb.length() > 0) sb.append(','); + sb.append(p.type()); + } + return sb.toString(); + } + + /** As a completion: a snippet with a tab stop per parameter. */ + public CompletionItem toItem() { + final CompletionItem item = new CompletionItem(name); + item.setKind(kind); + item.setDetail(detail); + final StringBuilder snippet = new StringBuilder(escape(name)).append('('); + for (int i = 0; i < params.size(); i++) { + if (i > 0) snippet.append(", "); + final String p = params.get(i).name() != null ? params.get(i).name() + : "arg" + i; + snippet.append("${").append(i + 1).append(':').append(escape(p)).append( + '}'); + } + item.setInsertText(snippet.append(')').toString()); + item.setInsertTextFormat(InsertTextFormat.Snippet); + item.setFilterText(name); + final CompletionItemLabelDetails details = new CompletionItemLabelDetails(); + details.setDetail(parameterList()); + details.setDescription(returnType); + item.setLabelDetails(details); + return item; + } + + /** As a signature. */ + public SignatureInformation toSignature() { + final List infos = new ArrayList<>(); + for (final Param p : params) infos.add(new ParameterInformation(p + .toString())); + final String label = name + parameterList() + (returnType == null ? "" + : " -> " + returnType); + final SignatureInformation s = new SignatureInformation(label); + s.setParameters(infos); + return s; + } + + /** E.g. {@code (double a, double b)}. */ + private String parameterList() { + final StringBuilder sb = new StringBuilder("("); + for (int i = 0; i < params.size(); i++) { + if (i > 0) sb.append(", "); + sb.append(params.get(i)); + } + return sb.append(')').toString(); + } + + @Override + public boolean equals(final Object o) { + if (!(o instanceof Callable)) return false; + final Callable c = (Callable) o; + return name.equals(c.name) && parameterList().equals(c.parameterList()) && + Objects.equals(returnType, c.returnType); + } + + @Override + public int hashCode() { + return Objects.hash(name, parameterList(), returnType); + } + + @Override + public String toString() { + return name + parameterList(); + } + + /** Escapes a snippet's special characters. */ + static String escape(final String s) { + return s.replace("\\", "\\\\").replace("$", "\\$").replace("}", "\\}"); + } +} diff --git a/scijava-code-lsp/src/main/java/org/scijava/code/lsp/jvm/ClassIndex.java b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/jvm/ClassIndex.java new file mode 100644 index 000000000..2d61bdbcb --- /dev/null +++ b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/jvm/ClassIndex.java @@ -0,0 +1,590 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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.jvm; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.net.URI; +import java.net.URL; +import java.net.URLConnection; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Scanner; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * A lazily-built index of the fully qualified class names available on the + * classpath, together with documentation URLs derived from each containing jar's + * {@code pom.xml} and the SciJava javadoc + * aggregator. + *

+ * This is the toolkit-agnostic engine behind import and class-name completion: + * given a partial name it can enumerate matching classes, locate documentation, + * and build HTML summaries for fields, methods and constructors. It depends on no + * widget toolkit, so language servers (e.g. Jython's) can use it + * without pulling in the Swing script editor. + *

+ *

+ * The index scans the runtime classpath ({@code java.class.path}), the + * JRE/JDK home, and (on Java 9+) the runtime image's own modules. + *

+ * + * @author Albert Cardona + * @author Tiago Ferreira + * @author Curtis Rueden + */ +public final class ClassIndex { + + /** Base URL of the SciJava javadoc aggregator (with trailing slash). */ + public static final String SCIJAVA_JAVADOC_URL = + "https://javadoc.scijava.org/"; + + /** Class name → properties of its containing jar (URLs, name). */ + private static final Map class_urls = new HashMap<>(); + + /** Package name → properties of a containing jar. */ + private static final Map package_urls = + new HashMap<>(); + + /** Cache of sub-URLs of the SciJava javadoc aggregator. */ + private static final HashMap scijava_javadoc_URLs = + new HashMap<>(); + + private static boolean ready = false; + + private ClassIndex() { + // prevent instantiation of static utility class + } + + /** The default roots: the JRE/JDK home plus each classpath entry. */ + private static List defaultRoots() { + final List roots = new ArrayList<>(); + roots.add(System.getProperty("java.home")); + final String cp = System.getProperty("java.class.path"); + if (cp != null) { + for (final String entry : cp.split(File.pathSeparator)) { + if (entry != null && !entry.isEmpty()) roots.add(entry); + } + } + return roots; + } + + /** Builds the class/package index if it has not been built yet. */ + public static void ensureCache() { + synchronized (class_urls) { + if (!class_urls.isEmpty()) return; + class_urls.putAll(findAllClasses(defaultRoots())); + findRuntimeClasses(class_urls); + // Soft attempt at getting all packages (will get them wrong if + // multiple jars share the same packages). + for (final Map.Entry entry : class_urls + .entrySet()) + { + final int idot = entry.getKey().lastIndexOf('.'); + if (-1 == idot) continue; // no package + final String package_name = entry.getKey().substring(0, idot); + if (package_urls.containsKey(package_name)) continue; + package_urls.put(package_name, entry.getValue()); + } + ready = true; + } + } + + /** Whether {@link #ensureCache()} has finished building the index. */ + public static boolean isCacheReady() { + return ready; + } + + /** Builds the cache of SciJava javadoc aggregator sub-URLs, if not done. */ + private static void ensureSciJavaSubURLCache() { + synchronized (scijava_javadoc_URLs) { + if (!scijava_javadoc_URLs.isEmpty()) return; + Scanner scanner = null; + try { + final Pattern pattern = Pattern.compile( + "
"); + final URLConnection connection = + new URL(SCIJAVA_JAVADOC_URL).openConnection(); + scanner = new Scanner(connection.getInputStream()); + while (scanner.hasNext()) { + final Matcher matcher = pattern.matcher(scanner.nextLine()); + if (matcher.find()) { + String name = matcher.group(1).toLowerCase(); + if (name.endsWith("/")) name = name.substring(0, name.length() - 1); + scijava_javadoc_URLs.put(name, SCIJAVA_JAVADOC_URL + matcher + .group(1)); + } + } + } + catch (final Exception e) { + e.printStackTrace(); + } + finally { + if (null != scanner) scanner.close(); + } + } + } + + private static HashMap findClassDocumentationURLs( + final String s) + { + ensureCache(); + final HashMap matches = new HashMap<>(); + for (final Map.Entry entry : class_urls + .entrySet()) + { + if (entry.getKey().contains(s)) { + final JarProperties props = entry.getValue(); + matches.put(entry.getKey(), new JarProperties(props.name, + new ArrayList<>(props.urls))); + } + } + return matches; + } + + public static HashMap> findDocumentationForClass( + final String s) + { + final HashMap matches = findClassDocumentationURLs( + s); + ensureSciJavaSubURLCache(); + + final Pattern javaPackages = Pattern.compile( + "^(java|javax|org\\.omg|org\\.w3c|org\\.xml|org\\.ietf\\.jgss)\\..*$"); + final String version = System.getProperty("java.version"); + final String majorVersion = version.startsWith("1.") // + ? version.substring(2, version.indexOf('.', 2)) // + : version.substring(0, version.indexOf('.')); + final String javaDoc = "java" + majorVersion; + + final HashMap> result = new HashMap<>(); + + for (final Map.Entry entry : matches.entrySet()) { + final String classname = entry.getKey(); + final ArrayList urls = new ArrayList<>(); + result.put(classname, urls); + if (javaPackages.matcher(classname).matches()) { + urls.add(scijava_javadoc_URLs.get(javaDoc) + classname.replace('.', + '/') + ".html"); + } + else { + final JarProperties props = entry.getValue(); + // Find the first URL with git in it. + for (final String url : props.urls) { + final boolean github = url.contains("/github.com"), gitlab = url + .contains("/gitlab.com"); + if (github || gitlab) { + // Find the 5th slash, e.g. https://github.com/imglib/imglib2/ + int count = 0; + int last = 0; + while (count < 5) { + last = url.indexOf('/', last + 1); + if (-1 == last) break; // less than 5 found + ++count; + } + String urlbase = url; + if (5 == count) urlbase = url.substring(0, last); // no trailing / + // Assume maven, since these URLs came from a pom.xml. + urls.add(urlbase + (gitlab ? "/-" : "") + + "/blob/master/src/main/java/" + classname.replace('.', '/') + + ".java"); + break; + } + } + // Try to find a javadoc in the scijava website. + if (null != props.name) { + String scijava_javadoc_url = scijava_javadoc_URLs.get(props.name + .toLowerCase()); + if (null == scijava_javadoc_url) { + // Try cropping name at the first whitespace if any (e.g. + // "ImgLib2 Core Library" to "ImgLib2"). + for (final String word : props.name.split(" ")) { + scijava_javadoc_url = scijava_javadoc_URLs.get(word + .toLowerCase()); + if (null != scijava_javadoc_url) break; // found a valid one + } + } + if (null != scijava_javadoc_url) { + urls.add(scijava_javadoc_url + classname.replace('.', '/') + + ".html"); + } + else { + // Try Fiji: could be a plugin. + Scanner scanner = null; + try { + final String url = SCIJAVA_JAVADOC_URL + "Fiji/" + classname + .replace('.', '/') + ".html"; + final URLConnection c = new URL(url).openConnection(); + scanner = new Scanner(c.getInputStream()); + while (scanner.hasNext()) { + final String line = scanner.nextLine(); + if (line.contains("")) { + if (!line.contains("<title>404")) urls.add(url); + break; + } + } + } + catch (final Exception e) { + // Ignore: 404 that wasn't redirected to an error page. + } + finally { + if (null != scanner) scanner.close(); + } + } + } + } + } + + return result; + } + + /** Properties of a jar file: its display name and documentation URLs. */ + private static final class JarProperties { + + public final ArrayList<String> urls; + public String name; + + public JarProperties(final String name, final ArrayList<String> urls) { + this.name = name; + this.urls = urls; + } + } + + /** + * Indexes the classes of the Java runtime image itself. Since Java 9, JDK + * classes live in the {@code jrt:} module image rather than in jars, so a jar + * scan of the JDK home misses them. On Java 8 this does nothing: the runtime + * classes are in {@code rt.jar}, which the jar scan already finds. + */ + private static void findRuntimeClasses( + final Map<String, JarProperties> result) + { + final FileSystem jrt; + try { + jrt = FileSystems.getFileSystem(URI.create("jrt:/")); + } + catch (final RuntimeException exc) { + return; // no jrt filesystem (Java 8) + } + final JarProperties props = new JarProperties("Java Runtime", + new ArrayList<>()); + final Path modules = jrt.getPath("/modules"); + try (final Stream<Path> paths = Files.walk(modules)) { + paths.forEach(path -> { + // Paths look like: /modules/<module>/<package path>/<Class>.class + if (path.getNameCount() < 3) return; + final String entry = path.subpath(2, path.getNameCount()).toString(); + if (!entry.endsWith(".class") || entry.endsWith("module-info.class") || + entry.endsWith("package-info.class")) return; + final String classname = className(entry.replace( + path.getFileSystem().getSeparator(), "/")); + if (isInternalRuntimeClass(classname)) return; + result.putIfAbsent(classname, props); + }); + } + catch (final IOException | RuntimeException exc) { + // NB: Best effort; the index simply lacks the runtime classes. + } + } + + /** Whether the given JDK class is an implementation detail, not API. */ + private static boolean isInternalRuntimeClass(final String classname) { + return classname.startsWith("sun.") || classname.startsWith("jdk.internal.") || + classname.startsWith("com.sun.") && classname.contains(".internal."); + } + + /** + * Converts a class file entry (e.g. {@code java/util/Map$Entry.class}) into + * a class name, truncating nested classes to their outermost class. + */ + private static String className(final String entry) { + final String classname = entry.replace('/', '.'); + final int idollar = classname.indexOf('$'); + return -1 == idollar ? classname.substring(0, classname.length() - 6) // + : classname.substring(0, idollar); + } + + /** + * Scans the given roots for jar files and indexes every class they contain, + * recording each jar's {@code pom.xml} URLs and name where present. + * + * @param roots jar files, or directories scanned recursively for jars + */ + private static HashMap<String, JarProperties> findAllClasses( + final List<String> roots) + { + // Find all jar files. + final ArrayList<String> jarFilePaths = new ArrayList<>(); + final LinkedList<String> dirs = new LinkedList<>(roots); + final HashSet<String> seenDirs = new HashSet<>(); + while (!dirs.isEmpty()) { + final String filepath = dirs.removeFirst(); + if (null == filepath) continue; + final File file = new File(filepath); + if (file.isFile() && filepath.endsWith(".jar")) { + jarFilePaths.add(file.getAbsolutePath()); + continue; + } + seenDirs.add(file.getAbsolutePath()); + if (file.exists() && file.isDirectory()) { + final File[] children = file.listFiles(); + if (children == null) continue; + for (final File child : children) { + final String childfilepath = child.getAbsolutePath(); + if (seenDirs.contains(childfilepath)) continue; + if (child.isDirectory()) dirs.add(childfilepath); + else if (childfilepath.endsWith(".jar")) jarFilePaths.add( + childfilepath); + } + } + } + // Find all classes from all jar files. + final HashMap<String, JarProperties> result = new HashMap<>(); + final Pattern urlpattern = Pattern.compile(">(http.*?)<"); + final Pattern namepattern = Pattern.compile("<name>(.*?)<"); + for (final String jarpath : jarFilePaths) { + JarFile jar = null; + try { + jar = new JarFile(jarpath); + final Enumeration<JarEntry> entries = jar.entries(); + final ArrayList<String> urls = new ArrayList<>(); + final JarProperties props = new JarProperties(null, urls); + // For every filepath in the jar zip archive. + while (entries.hasMoreElements()) { + final JarEntry entry = entries.nextElement(); + if (entry.isDirectory()) continue; + if (entry.getName().endsWith(".class")) { + result.put(className(entry.getName()), props); + } + else if (entry.getName().endsWith("/pom.xml")) { + final Scanner scanner = new Scanner(jar.getInputStream(entry)); + while (scanner.hasNext()) { + final String line = scanner.nextLine(); + final Matcher matcher1 = urlpattern.matcher(line); + if (matcher1.find()) urls.add(matcher1.group(1)); + if (null == props.name) { + final Matcher matcher2 = namepattern.matcher(line); + if (matcher2.find()) props.name = matcher2.group(1); + } + } + scanner.close(); + } + } + } + catch (final IOException e) { + e.printStackTrace(); + } + finally { + if (null != jar) try { + jar.close(); + } + catch (final IOException e) { + e.printStackTrace(); + } + } + } + return result; + } + + public static Stream<String> findPackageNamesStartingWith( + final String text) + { + ensureCache(); + return package_urls.keySet().stream().filter(s -> s.startsWith(text)); + } + + public static Stream<String> findClassNamesForPackage( + final String packageName) + { + ensureCache(); + if (null == packageName || packageName.length() == 0) // + return class_urls.keySet().stream(); + return class_urls.keySet().stream().filter(s -> s.startsWith(packageName) && + -1 == s.indexOf('.', packageName.length() + 2)); + } + + /** + * @param text A left-justified substring of a fully qualified class name, + * with the package. + */ + public static Stream<String> findClassNamesStartingWith(final String text) { + ensureCache(); + if (text.length() == 0) return class_urls.keySet().stream(); + return class_urls.keySet().stream().filter(s -> s.startsWith(text)); + } + + /** + * @param text A substring of a class's fully qualified name. + */ + public static Stream<String> findClassNamesContaining(final String text) { + ensureCache(); + return class_urls.keySet().stream().filter(s -> s.contains(text)); + } + + /** + * Finds simple class names starting with {@code text}, returning the fully + * qualified class names. + */ + public static ArrayList<String> findSimpleClassNamesStartingWith( + final String text) + { + ensureCache(); + final ArrayList<String> matches = new ArrayList<>(); + if (0 == text.length()) return matches; + for (final String classname : class_urls.keySet()) { + final int idot = classname.lastIndexOf('.'); + final String simplename = -1 == idot ? classname : classname.substring( + idot + 1); + if (simplename.startsWith(text)) matches.add(classname); + } + return matches; + } + + /** Builds an HTML link to the SciJava javadoc for the given class. */ + private static String getJavaDocLink(final Class<?> c) { + final String name = c.getCanonicalName(); + final String pkg = getDocPackage(name); + if (pkg == null) return name; + final String url = String.format("%s%s/index.html?%s.html", + SCIJAVA_JAVADOC_URL, pkg, name.replace(".", "/")); + return String.format("<a href='%s';>%s</a>", url, name); + } + + private static String getDocPackage(final String classCanonicalName) { + // TODO: Do this programmatically. + if (classCanonicalName.startsWith("ij.")) return "ImageJ1"; + else if (classCanonicalName.startsWith("sc.fiji")) return "Fiji"; + else if (classCanonicalName.startsWith("net.imagej")) return "ImageJ"; + else if (classCanonicalName.startsWith("net.imglib2")) return "ImgLib2"; + else if (classCanonicalName.startsWith("org.scijava")) return "SciJava"; + else if (classCanonicalName.startsWith("loci.formats")) return "Bio-Formats"; + if (classCanonicalName.startsWith("java.")) return "Java8"; + else if (classCanonicalName.startsWith("sc.iview")) return "SciView"; + else if (classCanonicalName.startsWith("weka.")) return "Weka"; + else if (classCanonicalName.startsWith("inra.ijpb")) return "MorphoLibJ"; + return null; + } + + /** + * Assembles an HTML-formatted auto-completion summary with functional + * hyperlinks for the given field. + */ + public static String getSummaryCompletion(final Field field, + final Class<?> c) + { + final StringBuffer summary = new StringBuffer(); + summary.append("<b>").append(field.getName()).append("</b>"); + summary.append(" (").append(field.getType().getName()).append(")"); + summary.append("<DL>"); + summary.append("<DT><b>Defined in:</b>"); + summary.append("<DD>").append(getJavaDocLink(c)); + summary.append("</DL>"); + return summary.toString(); + } + + /** + * Assembles an HTML-formatted auto-completion summary with functional + * hyperlinks for the given method. + */ + public static String getSummaryCompletion(final Method method, + final Class<?> c) + { + final StringBuffer summary = new StringBuffer(); + final StringBuffer replacementHeader = new StringBuffer(method.getName()); + final int bIndex = replacementHeader.length(); // remember '(' position + replacementHeader.append("("); + final Parameter[] params = method.getParameters(); + if (params.length > 0) { + for (final Parameter parameter : params) { + replacementHeader.append(parameter.getType().getSimpleName()).append( + " ").append(parameter.getName()).append(", "); + } + replacementHeader.setLength(replacementHeader.length() - 2); // trailing + } + replacementHeader.append(")"); + replacementHeader.replace(bIndex, bIndex + 1, "</b>("); + summary.append("<b>").append(replacementHeader); + summary.append("<DL>"); + summary.append("<DT><b>Returns:</b>"); + summary.append("<DD>").append(method.getReturnType().getSimpleName()); + summary.append("<DT><b>Defined in:</b>"); + summary.append("<DD>").append(getJavaDocLink(c)); + summary.append("</DL>"); + return summary.toString(); + } + + /** + * Assembles an HTML-formatted auto-completion summary with functional + * hyperlinks for the given constructor. + */ + public static String getSummaryCompletion(final Constructor<?> constructor, + final Class<?> c) + { + final StringBuffer summary = new StringBuffer(); + final StringBuffer replacementHeader = new StringBuffer(c.getSimpleName()); + final int bIndex = replacementHeader.length(); // remember '(' position + replacementHeader.append("("); + final Parameter[] params = constructor.getParameters(); + if (params.length > 0) { + for (final Parameter parameter : params) { + replacementHeader.append(parameter.getType().getSimpleName()).append( + " ").append(parameter.getName()).append(", "); + } + replacementHeader.setLength(replacementHeader.length() - 2); // trailing + } + replacementHeader.append(")"); + replacementHeader.replace(bIndex, bIndex + 1, "</b>("); + summary.append("<b>").append(replacementHeader); + summary.append("<DL>"); + summary.append("<DT><b>Intantiates:</b>"); + summary.append("<DD>").append(c.getSimpleName()); + summary.append("<DT><b>Defined in:</b>"); + summary.append("<DD>").append(getJavaDocLink(c)); + summary.append("</DL>"); + return summary.toString(); + } +} diff --git a/scijava-code-lsp/src/main/java/org/scijava/code/lsp/jvm/Members.java b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/jvm/Members.java new file mode 100644 index 000000000..346546be4 --- /dev/null +++ b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/jvm/Members.java @@ -0,0 +1,151 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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.jvm; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionItemKind; +import org.eclipse.lsp4j.CompletionItemLabelDetails; + +/** + * Completions for the members of Java classes, by reflection: for in-process + * language servers of JVM languages (e.g. Groovy, Jython). + * + * @author Gabriel Selzer + */ +public final class Members { + + private Members() { + // NB: Prevent instantiation of utility class. + } + + /** + * Completions for the public fields and methods of a class whose names + * start with a (case-insensitive) prefix; methods as {@link Callable}s. + * + * @param c The class. + * @param prefix The prefix; empty matches everything. + * @param staticOnly Whether to include only static members. + */ + public static List<CompletionItem> of(final Class<?> c, final String prefix, + final boolean staticOnly) + { + final List<CompletionItem> out = new ArrayList<>(); + if (c == null) return out; + final String lp = prefix == null ? "" : prefix.toLowerCase(Locale.ROOT); + for (final Field f : c.getFields()) { + if (staticOnly && !Modifier.isStatic(f.getModifiers())) continue; + if (!f.getName().toLowerCase(Locale.ROOT).startsWith(lp)) continue; + out.add(field(f)); + } + for (final Method m : c.getMethods()) { + if (staticOnly && !Modifier.isStatic(m.getModifiers())) continue; + if (!m.getName().toLowerCase(Locale.ROOT).startsWith(lp)) continue; + out.add(method(m).toItem()); + } + return out; + } + + /** The public methods of a class with exactly the given name. */ + public static List<Callable> methods(final Class<?> c, final String name, + final boolean staticOnly) + { + final List<Callable> out = new ArrayList<>(); + if (c == null) return out; + for (final Method m : c.getMethods()) { + if (staticOnly && !Modifier.isStatic(m.getModifiers())) continue; + if (m.getName().equals(name)) out.add(method(m)); + } + return out; + } + + /** The public constructors of a class. */ + public static List<Callable> constructors(final Class<?> c) { + final List<Callable> out = new ArrayList<>(); + if (c == null) return out; + for (final Constructor<?> ctor : c.getConstructors()) { + final List<Callable.Param> params = new ArrayList<>(); + for (final java.lang.reflect.Parameter p : ctor.getParameters()) { + params.add(new Callable.Param(p.getName(), typeName(p.getType()))); + } + out.add(new Callable(c.getSimpleName(), params, typeName(c)).kind( + CompletionItemKind.Constructor)); + } + return out; + } + + /** A completion for a field. */ + private static CompletionItem field(final Field f) { + final CompletionItem item = new CompletionItem(f.getName()); + item.setKind(CompletionItemKind.Field); + item.setInsertText(f.getName()); + item.setDetail(typeName(f.getType()) + " " + f.getName()); + final CompletionItemLabelDetails details = new CompletionItemLabelDetails(); + details.setDescription(typeName(f.getType())); + item.setLabelDetails(details); + return item; + } + + /** A method, as a callable. */ + private static Callable method(final Method m) { + final List<Callable.Param> params = new ArrayList<>(); + for (final java.lang.reflect.Parameter p : m.getParameters()) { + params.add(new Callable.Param(p.getName(), typeName(p.getType()))); + } + return new Callable(m.getName(), params, typeName(m.getReturnType())) + .detail(signature(m)); + } + + /** A readable method signature, e.g. {@code String concat(String)}. */ + private static String signature(final Method m) { + final StringBuilder sb = new StringBuilder(); + sb.append(typeName(m.getReturnType())).append(' ').append(m.getName()) + .append('('); + final Class<?>[] types = m.getParameterTypes(); + for (int i = 0; i < types.length; i++) { + if (i > 0) sb.append(", "); + sb.append(typeName(types[i])); + } + return sb.append(')').toString(); + } + + /** The name of a type, as code writes it (e.g. {@code int[]}). */ + public static String typeName(final Class<?> c) { + final String n = c.getCanonicalName(); + return n == null ? c.getName() : n; + } +} diff --git a/scijava-code-lsp/src/main/java/org/scijava/code/lsp/jvm/Signatures.java b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/jvm/Signatures.java new file mode 100644 index 000000000..6b8cf2c31 --- /dev/null +++ b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/jvm/Signatures.java @@ -0,0 +1,232 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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.jvm; + +import java.lang.reflect.Array; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; + +import org.eclipse.lsp4j.SignatureInformation; + +/** + * Helpers for rating signatures against arguments, for language servers whose + * types are Java classes (e.g. Jython and Groovy, calling Java methods); and + * for finding the call around the caret. + * + * @author Gabriel Selzer + */ +public final class Signatures { + + /** How well arguments fit a signature. */ + public enum Fit { + /** The arguments fit as they are. */ + MATCH, + /** The arguments fit after a conversion (e.g. int to double). */ + CONVERSION, + /** Not known (e.g. the arguments' types are unknown). */ + UNKNOWN, + /** Some argument cannot fit. */ + MISMATCH + } + + private static final Map<String, Class<?>> PRIMITIVES = new HashMap<>(); + static { + for (final Class<?> c : new Class<?>[] { boolean.class, byte.class, + char.class, short.class, int.class, long.class, float.class, + double.class, void.class }) + { + PRIMITIVES.put(c.getName(), c); + } + } + + private Signatures() { + // NB: Prevent instantiation of utility class. + } + + /** + * Rates passing a value of type {@code argType} to a parameter of type + * {@code paramType}, with conversions as scripting languages calling Java + * typically make them: + * <ul> + * <li>{@link Fit#MATCH}: the same type, an assignable one (with boxing), or + * the same kind of number (integral to integral, floating-point to + * floating-point).</li> + * <li>{@link Fit#CONVERSION}: an integral number to a floating-point + * parameter.</li> + * <li>{@link Fit#MISMATCH}: anything else.</li> + * <li>{@link Fit#UNKNOWN}: either type is unknown.</li> + * </ul> + * + * @param argType E.g. {@code int}, {@code java.lang.String} or + * {@code long[]}; or null if unknown. + * @param paramType Likewise. + * @param loader Loads classes by name. + */ + public static Fit fit(final String argType, final String paramType, + final ClassLoader loader) + { + if (argType == null || paramType == null) return Fit.UNKNOWN; + if (argType.equals(paramType)) return Fit.MATCH; + final Class<?> a = loadType(argType, loader); + final Class<?> p = loadType(paramType, loader); + if (a == null || p == null) return Fit.UNKNOWN; + final Class<?> ba = box(a), bp = box(p); + if (bp.isAssignableFrom(ba)) return Fit.MATCH; + if (isIntegral(ba)) { + if (isIntegral(bp)) return Fit.MATCH; + if (isFloating(bp)) return Fit.CONVERSION; + } + if (isFloating(ba) && isFloating(bp)) return Fit.MATCH; + return Fit.MISMATCH; + } + + /** + * Rates a callable against the types of the arguments typed so far: as + * well as its worst-fitting argument; {@link Fit#MISMATCH} if there are + * more arguments than parameters; {@link Fit#UNKNOWN} if no argument's type + * is known. + * + * @param callable The signature to rate. + * @param argTypes Types of the arguments, null where unknown. + * @param fit Rates an argument type against a parameter type, e.g. + * {@link #fit(String, String, ClassLoader)}. + */ + private static Fit rate(final Callable callable, final List<String> argTypes, + final BiFunction<String, String, Fit> fit) + { + final List<Callable.Param> params = callable.params(); + Fit worst = null; + for (int i = 0; i < argTypes.size(); i++) { + if (argTypes.get(i) == null) continue; + if (i >= params.size()) return Fit.MISMATCH; + final Fit f = fit.apply(argTypes.get(i), params.get(i).type()); + if (f == Fit.UNKNOWN) continue; + if (worst == null || f.ordinal() > worst.ordinal()) worst = f; + } + return worst == null ? Fit.UNKNOWN : worst; + } + + /** + * Rates each callable against the arguments, and orders their signatures + * for display: matches first, then those needing conversions, unrated ones, + * and mismatches last; otherwise keeping their order. + */ + public static List<SignatureInformation> rateAll( + final List<Callable> callables, final List<String> argTypes, + final BiFunction<String, String, Fit> fit) + { + final List<Callable> sorted = new ArrayList<>(callables); + final Map<Callable, Integer> ranks = new HashMap<>(); + for (final Callable c : sorted) ranks.put(c, rank(rate(c, argTypes, fit))); + sorted.sort(Comparator.comparingInt(ranks::get)); + final List<SignatureInformation> out = new ArrayList<>(); + for (final Callable c : sorted) out.add(c.toSignature()); + return out; + } + + /** + * Finds the opening parenthesis of the call around the caret: the nearest + * unclosed {@code (} before it, on the same line; or -1 if none. + */ + public static int callStart(final String text, final int caret) { + int depth = 0; + for (int i = Math.min(caret, text.length()) - 1; i >= 0; i--) { + final char c = text.charAt(i); + if (c == '\n') return -1; + if (c == ')' || c == ']' || c == '}') depth++; + else if (c == '(' || c == '[' || c == '{') { + if (depth > 0) depth--; + else return c == '(' ? i : -1; // NB: Not in a call, but a list. + } + } + return -1; + } + + /** Loads a type such as {@code int}, {@code long[]} or {@code a.b.C}. */ + private static Class<?> loadType(final String name, final ClassLoader loader) { + if (name.endsWith("[]")) { + final Class<?> component = loadType(name.substring(0, name.length() - + 2), loader); + return component == null ? null : Array.newInstance(component, 0) + .getClass(); + } + final Class<?> primitive = PRIMITIVES.get(name); + if (primitive != null) return primitive; + try { + return Class.forName(name, false, loader); + } + catch (final ClassNotFoundException | LinkageError exc) { + return null; + } + } + + // -- Helper methods -- + + private static int rank(final Fit fit) { + switch (fit) { + case MATCH: + return 0; + case CONVERSION: + return 1; + case UNKNOWN: + return 2; + default: + return 3; + } + } + + private static Class<?> box(final Class<?> c) { + if (!c.isPrimitive()) return c; + if (c == int.class) return Integer.class; + if (c == long.class) return Long.class; + if (c == double.class) return Double.class; + if (c == float.class) return Float.class; + if (c == boolean.class) return Boolean.class; + if (c == char.class) return Character.class; + if (c == short.class) return Short.class; + if (c == byte.class) return Byte.class; + return Void.class; + } + + private static boolean isIntegral(final Class<?> c) { + return c == Integer.class || c == Long.class || c == Short.class || + c == Byte.class || c == BigInteger.class; + } + + private static boolean isFloating(final Class<?> c) { + return c == Double.class || c == Float.class || c == BigDecimal.class; + } +} diff --git a/scijava-code-lsp/src/main/java/org/scijava/code/lsp/jvm/package-info.java b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/jvm/package-info.java new file mode 100644 index 000000000..83c12e7eb --- /dev/null +++ b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/jvm/package-info.java @@ -0,0 +1,45 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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% + */ + +/** + * A toolkit for in-process language servers of JVM languages, written in + * Java (e.g. Groovy's and Jython's). + * <p> + * {@link org.scijava.code.lsp.jvm.AbstractLanguageServer} is a base class: it + * keeps the open documents, and turns requests into questions about a + * document and an offset. The rest help answer them for Java classes: + * {@link org.scijava.code.lsp.jvm.Members} (completions for their members, by + * reflection), {@link org.scijava.code.lsp.jvm.Callable} (something to call, + * as a completion snippet), {@link org.scijava.code.lsp.jvm.Signatures} + * (overloads rated against the arguments typed), and + * {@link org.scijava.code.lsp.jvm.ClassIndex} (the classes on the classpath, + * with their javadoc). + * </p> + */ +package org.scijava.code.lsp.jvm; diff --git a/scijava-code-lsp/src/main/java/org/scijava/code/lsp/package-info.java b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/package-info.java new file mode 100644 index 000000000..e6dd072d0 --- /dev/null +++ b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/package-info.java @@ -0,0 +1,63 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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% + */ + +/** + * Code intelligence for SciJava scripts (completions, signature help, hover + * and diagnostics), in the terms of the Language Server Protocol, via lsp4j. + * <p> + * This package brokers between scripts and the language servers that know + * about them. {@link org.scijava.code.lsp.ScriptDialect}s turn scripts of + * SciJava script languages into plain code of LSP languages (e.g. + * {@code python}); {@link org.scijava.code.lsp.LanguageServerPlugin}s offer + * lsp4j {@link org.eclipse.lsp4j.services.LanguageServer}s for LSP languages. + * Clients (e.g. an editor) ask about a script through a + * {@link org.scijava.code.lsp.ScriptSession}, made with the + * {@link org.scijava.code.lsp.LanguageServerService}, in terms of the + * script's text and offsets into it (see + * {@link org.scijava.code.lsp.Positions}); behind it is a server of its own, + * from the highest-priority plugin serving its dialect's language, for where + * the script runs (moving to another as that changes). + * </p> + * <p> + * Servers need know nothing of SciJava scripts. A SciJava script's {@code #@} + * parameters define variables before it runs, which plain code of its + * language would declare. A {@link org.scijava.code.lsp.ScriptDocument} is + * the script as such code, with its parameters declared with their run-time + * types, as its {@link org.scijava.code.lsp.ScriptDialect} says. A session + * shows its servers this text, and maps positions in their answers back to + * the script. + * </p> + * <p> + * The subpackages are for those writing language servers: + * {@link org.scijava.code.lsp.server} (servers that run as processes, and + * servers routing documents by where they run) and + * {@link org.scijava.code.lsp.jvm} (servers written in Java). + * </p> + */ +package org.scijava.code.lsp; diff --git a/scijava-code-lsp/src/main/java/org/scijava/code/lsp/server/ProcessLanguageServer.java b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/server/ProcessLanguageServer.java new file mode 100644 index 000000000..d44de2823 --- /dev/null +++ b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/server/ProcessLanguageServer.java @@ -0,0 +1,308 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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.server; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +import org.eclipse.lsp4j.CompletionItem; +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.PublishDiagnosticsParams; +import org.eclipse.lsp4j.ServerCapabilities; +import org.eclipse.lsp4j.SignatureHelp; +import org.eclipse.lsp4j.SignatureHelpParams; +import org.eclipse.lsp4j.TextDocumentItem; +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.Environment; + +/** + * The language server process of an environment (see + * {@link ServerProcesses}), seen as a {@link LanguageServer} of its own: + * started when first asked, restarted if it stops, stopped when idle. Each + * document is brought up to date on the process just before each request, + * and, for diagnostics, a moment after it last changed. + * <p> + * NB: Not an {@link org.scijava.code.lsp.jvm.AbstractLanguageServer}, which + * answers requests itself, as questions about a document's text and an + * offset: this one passes them on as they are, and is answered later. + * </p> + * + * @author Gabriel Selzer + */ +class ProcessLanguageServer implements LanguageServer, + TextDocumentService, WorkspaceService, LanguageClientAware +{ + + /** + * How long a document must stay unchanged before the process is asked for + * its problems, in milliseconds. TODO: Settle (around 300 ms?). + */ + private static final long PAUSE = Long.getLong("scijava.lsp.pause", 300); + + /** How long to wait for the process's problems, in milliseconds. */ + private static final long DIAGNOSE_TIMEOUT = 30_000; + + private static final ScheduledExecutorService TIMER = Executors + .newSingleThreadScheduledExecutor(r -> { + final Thread t = new Thread(r, "scijava-lsp-pause"); + t.setDaemon(true); + return t; + }); + + private final ServerProcesses servers; + private final Environment environment; + private volatile LanguageClient client; + + /** The open documents' texts and versions, by URI. */ + private final Map<String, Doc> docs = new ConcurrentHashMap<>(); + + /** + * @param servers The server processes. + * @param environment Where the scripts run: whose process to ask. + */ + ProcessLanguageServer(final ServerProcesses servers, + final Environment environment) + { + this.servers = servers; + this.environment = environment; + } + + @Override + public void connect(final LanguageClient c) { + client = c; + } + + // -- LanguageServer -- + + @Override + public CompletableFuture<InitializeResult> initialize( + final InitializeParams params) + { + // NB: The process is initialized when started (see ServerProcesses). + return CompletableFuture.completedFuture(new InitializeResult( + new ServerCapabilities())); + } + + @Override + public CompletableFuture<Object> shutdown() { + for (final String uri : docs.keySet()) { + servers.forEach(s -> s.close(uri)); + } + docs.clear(); + return CompletableFuture.completedFuture(null); + } + + @Override + public void exit() { + // NB: The process belongs to the ServerProcesses. + } + + @Override + public TextDocumentService getTextDocumentService() { + return this; + } + + @Override + public WorkspaceService getWorkspaceService() { + return this; + } + + // -- TextDocumentService -- + + @Override + public void didOpen(final DidOpenTextDocumentParams params) { + final TextDocumentItem item = params.getTextDocument(); + docs.computeIfAbsent(item.getUri(), k -> new Doc(item.getLanguageId())); + changed(item.getUri(), item.getText(), item.getVersion()); + } + + @Override + public void didChange(final DidChangeTextDocumentParams params) { + if (params.getContentChanges().isEmpty()) return; + changed(params.getTextDocument().getUri(), params.getContentChanges().get( + params.getContentChanges().size() - 1).getText(), params + .getTextDocument().getVersion()); + } + + @Override + public void didClose(final DidCloseTextDocumentParams params) { + final String uri = params.getTextDocument().getUri(); + final Doc doc = docs.remove(uri); + if (doc != null && doc.pending != null) doc.pending.cancel(false); + servers.forEach(s -> s.close(uri)); + } + + @Override + public void didSave(final DidSaveTextDocumentParams params) { + // NB: Changes come with didChange. + } + + @Override + public CompletableFuture<Either<List<CompletionItem>, CompletionList>> + completion(final CompletionParams params) + { + return request(params.getTextDocument().getUri(), s -> s.completion( + params)); + } + + @Override + public CompletableFuture<CompletionItem> resolveCompletionItem( + final CompletionItem item) + { + return servers.process(environment).thenCompose(s -> s.remote() + .getTextDocumentService().resolveCompletionItem(item)).handle(( + resolved, error) -> resolved != null ? resolved : item); + } + + @Override + public CompletableFuture<SignatureHelp> signatureHelp( + final SignatureHelpParams params) + { + return request(params.getTextDocument().getUri(), s -> s.signatureHelp( + params)); + } + + @Override + public CompletableFuture<Hover> hover(final HoverParams params) { + return request(params.getTextDocument().getUri(), s -> s.hover(params)); + } + + // -- WorkspaceService -- + + @Override + public void didChangeConfiguration(final DidChangeConfigurationParams p) {} + + @Override + public void didChangeWatchedFiles(final DidChangeWatchedFilesParams p) {} + + // -- Helper methods -- + + /** + * Asks the process, with the document's current text. + * + * @param request Asks the process. + */ + private <T> CompletableFuture<T> request(final String uri, + final Function<TextDocumentService, CompletableFuture<T>> request) + { + final Doc doc = docs.get(uri); + if (doc == null) return CompletableFuture.completedFuture(null); + final TextDocumentItem item = doc.item(uri); + // NB: Cancelling the answer cancels the request, which tells the process + // ($/cancelRequest). + final CompletableFuture<T> answer = new CompletableFuture<>(); + servers.process(environment).thenAccept(s -> { + if (answer.isDone()) return; + final CompletableFuture<T> raw = s.request(uri, item.getText(), request); + answer.whenComplete((r, error) -> { + if (answer.isCancelled()) raw.cancel(true); + }); + raw.whenComplete((r, error) -> { + if (error != null) answer.completeExceptionally(error); + else answer.complete(r); + }); + }).exceptionally(error -> { + answer.completeExceptionally(error); + return null; + }); + return answer; + } + + /** Notes a document's new text; asks for its problems after a pause. */ + private void changed(final String uri, final String text, + final int version) + { + final Doc doc = docs.get(uri); + if (doc == null) return; + synchronized (doc) { + doc.text = text; + doc.version = version; + if (doc.pending != null) doc.pending.cancel(false); + final TextDocumentItem item = doc.item(uri); + doc.pending = TIMER.schedule(() -> diagnose(doc, item), PAUSE, + TimeUnit.MILLISECONDS); + } + } + + private void diagnose(final Doc doc, final TextDocumentItem item) { + final String uri = item.getUri(); + final int version = item.getVersion(); + servers.process(environment).thenCompose(s -> s.diagnostics(uri, item + .getText())) + .orTimeout(DIAGNOSE_TIMEOUT, TimeUnit.MILLISECONDS).thenAccept(found -> { + final LanguageClient c = client; + // NB: Only if still current. + if (docs.get(uri) != doc || doc.version != version || c == null) { + return; + } + c.publishDiagnostics(new PublishDiagnosticsParams(uri, found, + version)); + }); + } + + private static final class Doc { + + private final String languageId; + private String text; + private int version; + private ScheduledFuture<?> pending; + + private Doc(final String languageId) { + this.languageId = languageId; + } + + private synchronized TextDocumentItem item(final String uri) { + return new TextDocumentItem(uri, languageId, version, text); + } + } +} diff --git a/scijava-code-lsp/src/main/java/org/scijava/code/lsp/server/ServerLauncher.java b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/server/ServerLauncher.java new file mode 100644 index 000000000..169d4253d --- /dev/null +++ b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/server/ServerLauncher.java @@ -0,0 +1,83 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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.server; + +import java.io.IOException; + +import org.scijava.code.lsp.Environment; + +/** + * How to start a language server that runs as a process (e.g. + * jedi-language-server), for scripts running in a given environment. Its + * {@link ServerProcesses} start it when first needed, one per environment, + * restart it if it stops, and stop it when idle. + * + * @author Gabriel Selzer + */ +public interface ServerLauncher { + + /** The LSP language identifier of the documents, e.g. {@code python}. */ + String languageId(); + + /** + * Starts a server, which speaks LSP over its standard input and output. (Its + * standard error must be redirected, lest it block once the pipe fills.) + * Called on a background thread; may be slow (e.g. build the environment + * the server runs in). + * + * @param env Where the scripts the server will analyze run. + */ + Process start(Environment env) throws IOException; + + /** + * The {@code initializationOptions} to send the server in its LSP + * <a href= + * "https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#initialize">{@code initialize}</a> + * request, e.g. the interpreter and extra paths of {@code env}; or null for + * none. Must serialize to JSON (e.g. maps, lists, strings, numbers, + * booleans). NB: LSP leaves what they are to each server, which documents + * its own (e.g. <a href= + * "https://github.com/pappasam/jedi-language-server#configuration">jedi-language-server's</a>). + */ + default Object initializationOptions(final Environment env) { + return null; + } + + /** + * The URI of the workspace root, or null for none: sent in the + * {@code initialize} request (see {@link #initializationOptions}) as its + * {@code rootUri}, and as its only workspace folder. (Some servers, e.g. + * jedi-language-server, apply their initialization options only with a + * root.) + */ + default String rootUri(final Environment env) { + return null; + } +} diff --git a/scijava-code-lsp/src/main/java/org/scijava/code/lsp/server/ServerProcess.java b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/server/ServerProcess.java new file mode 100644 index 000000000..4a9549c94 --- /dev/null +++ b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/server/ServerProcess.java @@ -0,0 +1,403 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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.server; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +import org.eclipse.lsp4j.ClientCapabilities; +import org.eclipse.lsp4j.CompletionCapabilities; +import org.eclipse.lsp4j.CompletionItemCapabilities; +import org.eclipse.lsp4j.Diagnostic; +import org.eclipse.lsp4j.DidChangeTextDocumentParams; +import org.eclipse.lsp4j.DidCloseTextDocumentParams; +import org.eclipse.lsp4j.DidOpenTextDocumentParams; +import org.eclipse.lsp4j.HoverCapabilities; +import org.eclipse.lsp4j.InitializeParams; +import org.eclipse.lsp4j.InitializedParams; +import org.eclipse.lsp4j.MarkupKind; +import org.eclipse.lsp4j.MessageActionItem; +import org.eclipse.lsp4j.MessageParams; +import org.eclipse.lsp4j.MessageType; +import org.eclipse.lsp4j.PublishDiagnosticsCapabilities; +import org.eclipse.lsp4j.PublishDiagnosticsParams; +import org.eclipse.lsp4j.ShowMessageRequestParams; +import org.eclipse.lsp4j.SignatureHelpCapabilities; +import org.eclipse.lsp4j.SignatureInformationCapabilities; +import org.eclipse.lsp4j.TextDocumentClientCapabilities; +import org.eclipse.lsp4j.TextDocumentContentChangeEvent; +import org.eclipse.lsp4j.TextDocumentIdentifier; +import org.eclipse.lsp4j.TextDocumentItem; +import org.eclipse.lsp4j.VersionedTextDocumentIdentifier; +import org.eclipse.lsp4j.WorkspaceFolder; +import org.eclipse.lsp4j.jsonrpc.Launcher; +import org.eclipse.lsp4j.launch.LSPLauncher; +import org.eclipse.lsp4j.services.LanguageClient; +import org.eclipse.lsp4j.services.LanguageServer; +import org.eclipse.lsp4j.services.TextDocumentService; +import org.scijava.code.lsp.Environment; +import org.scijava.log.Logger; + +/** + * A running, initialized language server, and the documents opened in it. + * + * @author Gabriel Selzer + * @see ServerProcesses + */ +final class ServerProcess { + + /** Threads reading servers' messages; daemons, so as not to keep the JVM. */ + private static final ExecutorService LISTENERS = Executors + .newCachedThreadPool(r -> { + final Thread t = new Thread(r, "scijava-lsp-listener"); + t.setDaemon(true); + return t; + }); + + private final String languageId; + private final Process process; + private final LanguageServer remote; + private final Future<Void> listening; + + /** Open documents' versions and texts, by URI. */ + private final Map<String, Document> documents = new HashMap<>(); + + /** Diagnostics the server published. */ + private final Published published; + + private volatile long lastUsed = System.currentTimeMillis(); + + private ServerProcess(final String languageId, final Process process, + final LanguageServer remote, final Future<Void> listening, + final Published published) + { + this.languageId = languageId; + this.published = published; + this.process = process; + this.remote = remote; + this.listening = listening; + } + + /** + * Starts and initializes a server. Blocks until it is ready. + * + * @param launcher How to start it. + * @param env Where the scripts it will analyze run. + * @param log Where to report the server's messages. + * @param timeout How long to wait for it to initialize, in milliseconds. + */ + static ServerProcess start(final ServerLauncher launcher, + final Environment env, final Logger log, final long timeout) + throws Exception + { + final Process process = launcher.start(env); + try { + final Published published = new Published(); + final Launcher<LanguageServer> connection = LSPLauncher + .createClientLauncher(new Client(log, published), process + .getInputStream(), process.getOutputStream(), LISTENERS, null); + final Future<Void> listening = connection.startListening(); + final LanguageServer remote = connection.getRemoteProxy(); + + final InitializeParams init = new InitializeParams(); + init.setProcessId((int) ProcessHandle.current().pid()); + init.setClientInfo(new org.eclipse.lsp4j.ClientInfo("SciJava")); + init.setCapabilities(capabilities()); + final String root = launcher.rootUri(env); + if (root != null) { + init.setRootUri(root); + init.setWorkspaceFolders(Collections.singletonList(new WorkspaceFolder( + root, "scripts"))); + } + init.setInitializationOptions(launcher.initializationOptions(env)); + remote.initialize(init).get(timeout, TimeUnit.MILLISECONDS); + remote.initialized(new InitializedParams()); + return new ServerProcess(launcher.languageId(), process, remote, listening, + published); + } + catch (final Exception exc) { + process.destroy(); + throw exc; + } + } + + /** + * Sends a request about a document, first bringing the server's copy of the + * document up to date (opening it, if needed). + * + * @param uri The document's URI. + * @param text The document's text. + * @param request Sends the request. + * @return The server's answer. + */ + public synchronized <T> CompletableFuture<T> request(final String uri, + final String text, + final Function<TextDocumentService, CompletableFuture<T>> request) + { + // NB: Synchronized, so that no other request comes between the document + // update and this request. + sync(uri, text); + return request.apply(remote.getTextDocumentService()); + } + + /** + * Gets the diagnostics of a document, as the server publishes them once + * it has analyzed this text of it (first bringing the server's copy of the + * document up to date). + */ + public synchronized CompletableFuture<List<Diagnostic>> diagnostics( + final String uri, final String text) + { + final boolean changed = sync(uri, text); + final int version = documents.get(uri).version; + return published.await(uri, version, !changed); + } + + /** Opens a document, or brings the server's copy up to date. */ + public synchronized void open(final String uri, final String text) { + sync(uri, text); + } + + /** Closes a document, if open. */ + public synchronized void close(final String uri) { + if (documents.remove(uri) == null || !isAlive()) return; + remote.getTextDocumentService().didClose(new DidCloseTextDocumentParams( + new TextDocumentIdentifier(uri))); + } + + /** The server's proxy, e.g. for requests not about a document. */ + public LanguageServer remote() { + lastUsed = System.currentTimeMillis(); + return remote; + } + + /** Whether the server still runs. */ + public boolean isAlive() { + return process.isAlive() && !listening.isDone(); + } + + /** When the server was last used, in milliseconds since the epoch. */ + public long lastUsed() { + return lastUsed; + } + + /** Stops the server, asking it nicely first. */ + public void shutdown() { + try { + if (isAlive()) { + remote.shutdown().get(2, TimeUnit.SECONDS); + remote.exit(); + } + } + catch (final Exception exc) { + // NB: Stopped below, regardless. + } + finally { + process.destroy(); + listening.cancel(true); + } + } + + // -- Helper methods -- + + /** Brings the server's copy of a document up to date: whether it was not. */ + private boolean sync(final String uri, final String text) { + lastUsed = System.currentTimeMillis(); + final TextDocumentService docs = remote.getTextDocumentService(); + final Document doc = documents.get(uri); + if (doc == null) { + documents.put(uri, new Document(text)); + published.sent(uri, 1); + docs.didOpen(new DidOpenTextDocumentParams(new TextDocumentItem(uri, + languageId, 1, text))); + return true; + } + if (doc.text.equals(text)) return false; + doc.text = text; + doc.version++; + published.sent(uri, doc.version); + docs.didChange(new DidChangeTextDocumentParams( + new VersionedTextDocumentIdentifier(uri, doc.version), Collections + .singletonList(new TextDocumentContentChangeEvent(text)))); + return true; + } + + /** What this client can do: plain text documentation, no snippets. */ + private static ClientCapabilities capabilities() { + final CompletionItemCapabilities item = new CompletionItemCapabilities( + false); + item.setDocumentationFormat(Collections.singletonList( + MarkupKind.PLAINTEXT)); + final CompletionCapabilities completion = new CompletionCapabilities(item); + final SignatureInformationCapabilities signature = + new SignatureInformationCapabilities(Collections.singletonList( + MarkupKind.PLAINTEXT)); + final TextDocumentClientCapabilities text = + new TextDocumentClientCapabilities(); + text.setCompletion(completion); + text.setSignatureHelp(new SignatureHelpCapabilities(signature, false)); + text.setHover(new HoverCapabilities(Collections.singletonList( + MarkupKind.PLAINTEXT), false)); + final PublishDiagnosticsCapabilities diagnostics = + new PublishDiagnosticsCapabilities(); + diagnostics.setVersionSupport(true); + text.setPublishDiagnostics(diagnostics); + final ClientCapabilities capabilities = new ClientCapabilities(); + capabilities.setTextDocument(text); + return capabilities; + } + + private static final class Document { + + private String text; + private int version = 1; + + private Document(final String text) { + this.text = text; + } + } + + /** + * Diagnostics the server published, by document URI; and those awaited. + * A server publishes diagnostics whenever it likes (e.g. after each change + * of a document), not on request. + */ + private static final class Published { + + private final Map<String, List<Diagnostic>> latest = new HashMap<>(); + /** The last version sent of each document. */ + private final Map<String, Integer> sent = new HashMap<>(); + /** Since when (which version) each document's latest diagnostics are. */ + private final Map<String, Integer> since = new HashMap<>(); + private final Map<String, List<Waiter>> waiting = new HashMap<>(); + + private synchronized void sent(final String uri, final int version) { + sent.put(uri, version); + } + + /** + * Gets the diagnostics for (at least) the given version of a document. + * + * @param known Whether this version was analyzed before (unchanged). + */ + private synchronized CompletableFuture<List<Diagnostic>> await( + final String uri, final int version, final boolean known) + { + final Integer have = since.get(uri); + if (known && have != null && have >= version) { + return CompletableFuture.completedFuture(latest.get(uri)); + } + final Waiter w = new Waiter(version); + waiting.computeIfAbsent(uri, k -> new ArrayList<>()).add(w); + return w.future; + } + + private synchronized void publish(final PublishDiagnosticsParams p) { + final String uri = p.getUri(); + // NB: Without a version, the diagnostics are for the last one sent. + final int version = p.getVersion() != null ? p.getVersion() : sent + .getOrDefault(uri, 0); + final List<Diagnostic> list = p.getDiagnostics() == null ? Collections + .emptyList() : p.getDiagnostics(); + latest.put(uri, list); + since.put(uri, version); + final List<Waiter> ws = waiting.get(uri); + if (ws == null) return; + ws.removeIf(w -> { + if (w.version > version) return false; + w.future.complete(list); + return true; + }); + } + + private static final class Waiter { + + private final int version; + private final CompletableFuture<List<Diagnostic>> future = + new CompletableFuture<>(); + + private Waiter(final int version) { + this.version = version; + } + } + } + + /** Receives the server's messages. */ + private static final class Client implements LanguageClient { + + private final Logger log; + private final Published published; + + private Client(final Logger log, final Published published) { + this.log = log; + this.published = published; + } + + @Override + public void telemetryEvent(final Object object) { + // NB: Not of interest. + } + + @Override + public void publishDiagnostics(final PublishDiagnosticsParams diagnostics) { + published.publish(diagnostics); + } + + @Override + public void showMessage(final MessageParams message) { + if (message.getType() == MessageType.Error) { + log.warn("Language server: " + message.getMessage()); + } + else log.debug("Language server: " + message.getMessage()); + } + + @Override + public CompletableFuture<MessageActionItem> showMessageRequest( + final ShowMessageRequestParams request) + { + log.debug("Language server: " + request.getMessage()); + return CompletableFuture.completedFuture(null); + } + + @Override + public void logMessage(final MessageParams message) { + log.debug("Language server: " + message.getMessage()); + } + } +} diff --git a/scijava-code-lsp/src/main/java/org/scijava/code/lsp/server/ServerProcesses.java b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/server/ServerProcesses.java new file mode 100644 index 000000000..3c7cfebbf --- /dev/null +++ b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/server/ServerProcesses.java @@ -0,0 +1,239 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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.server; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.LongSupplier; + +import org.eclipse.lsp4j.services.LanguageServer; +import org.scijava.code.lsp.Environment; +import org.scijava.log.Logger; + +/** + * The language server processes of one kind (see {@link ServerLauncher}), one + * per environment scripts run in: started when first needed (in the + * background), restarted if they stop, and stopped when unused for a while or + * when too many run. Each is seen as a {@link LanguageServer} of its own via + * {@link #server}. + * + * @author Gabriel Selzer + */ +public final class ServerProcesses { + + /** How long to wait for a server to initialize, in milliseconds. */ + private static final long INIT_TIMEOUT = 60_000; + + /** How long to wait before starting a server that failed again, in ms. */ + private static final long RETRY_DELAY = 30_000; + + /** How long a server may go unused before it stops, by default, in ms. */ + private static final long IDLE = Long.getLong("scijava.lsp.idle", 10 * 60 * + 1000); + + /** How many servers may run at once, by default. */ + private static final int MAX = Integer.getInteger("scijava.lsp.max", 4); + + private final ServerLauncher launcher; + private final Logger log; + private final long idle; + private final int max; + private final LongSupplier clock; + + /** The servers, by environment; least recently used first. */ + private final Map<Environment, Entry> servers = new LinkedHashMap<>( + 16, 0.75f, true); + + /** Starts servers, one at a time, off the callers' threads. */ + private final ExecutorService starter; + + /** Stops idle servers. */ + private final ScheduledExecutorService reaper; + + /** + * Servers stopped after ten minutes unused, four at most (or as the system + * properties {@code scijava.lsp.idle}, in ms, and {@code scijava.lsp.max} + * say). + * + * @param launcher How to start a server. + * @param log Where to report problems. + */ + public ServerProcesses(final ServerLauncher launcher, final Logger log) { + this(launcher, log, IDLE, MAX, System::currentTimeMillis); + } + + ServerProcesses(final ServerLauncher launcher, final Logger log, + final long idle, final int max, final LongSupplier clock) + { + this.launcher = launcher; + this.log = log; + this.idle = idle; + this.max = max; + this.clock = clock; + final String name = "scijava-lsp-" + launcher.languageId(); + starter = Executors.newSingleThreadExecutor(daemon(name)); + reaper = Executors.newSingleThreadScheduledExecutor(daemon(name + + "-reaper")); + final long period = Math.max(1000, idle / 4); + reaper.scheduleWithFixedDelay(this::stopIdle, period, period, + TimeUnit.MILLISECONDS); + } + + /** + * Gets a server for scripts running in the given environment: the server + * process of that environment, started when first asked (and again if it + * stops), seen as a {@link LanguageServer} of its own. Requests fail while + * the process cannot be started. Shutting it down closes its documents, + * but leaves the process (see {@link #shutdown}). + */ + public LanguageServer server(final Environment env) { + return new ProcessLanguageServer(this, env); + } + + /** + * Gets the process for scripts running in the given environment, starting + * it if needed. Never blocks. + * + * @return The process, once started; fails if it could not be. + */ + synchronized CompletableFuture<ServerProcess> process( + final Environment env) + { + final Entry known = servers.get(env); + if (known != null && known.usable(clock.getAsLong())) return known.server; + if (known != null) stop(known); + + final Entry entry = new Entry(); + entry.server = CompletableFuture.supplyAsync(() -> { + try { + log.debug("Starting " + launcher.languageId() + " language server for " + + env); + return ServerProcess.start(launcher, env, log, INIT_TIMEOUT); + } + catch (final Exception exc) { + log.warn("Could not start " + launcher.languageId() + + " language server for " + env, exc); + throw new CompletionException(exc); + } + }, starter); + entry.server.whenComplete((s, error) -> { + if (error != null) entry.failed = clock.getAsLong(); + }); + servers.put(env, entry); + + // Too many? Stop the least recently used. + final Iterator<Entry> it = servers.values().iterator(); + while (servers.size() > max && it.hasNext()) { + final Entry eldest = it.next(); + it.remove(); + stop(eldest); + } + return entry.server; + } + + /** Calls {@code action} on each running server. */ + void forEach(final Consumer<ServerProcess> action) { + final List<Entry> entries; + synchronized (this) { + entries = new ArrayList<>(servers.values()); + } + for (final Entry e : entries) { + final ServerProcess s = e.server.getNow(null); + if (s != null) action.accept(s); + } + } + + /** Stops all servers; those asked for later start anew. */ + public synchronized void shutdown() { + reaper.shutdownNow(); + for (final Entry e : servers.values()) { + // NB: Not via the starter, which stops next (dropping what it has not + // begun): those started, now; those starting, once started. + if (e.server.isDone()) e.server.thenAccept(ServerProcess::shutdown); + else e.server.thenAcceptAsync(ServerProcess::shutdown); + } + servers.clear(); + starter.shutdownNow(); + } + + /** Stops the servers unused for too long. */ + synchronized void stopIdle() { + final long now = clock.getAsLong(); + final Iterator<Entry> it = servers.values().iterator(); + while (it.hasNext()) { + final Entry e = it.next(); + final ServerProcess s = e.server.isDone() && !e.server + .isCompletedExceptionally() ? e.server.join() : null; + if (s != null && now - s.lastUsed() > idle) { + log.debug("Stopping idle " + launcher.languageId() + " language server"); + it.remove(); + stop(e); + } + } + } + + // -- Helper methods -- + + private void stop(final Entry e) { + // NB: Stop it once started, should it still be starting. + e.server.thenAcceptAsync(ServerProcess::shutdown, starter); + } + + private static java.util.concurrent.ThreadFactory daemon(final String name) { + return r -> { + final Thread t = new Thread(r, name); + t.setDaemon(true); + return t; + }; + } + + private final class Entry { + + private CompletableFuture<ServerProcess> server; + private volatile long failed = -1; + + /** Whether the server is starting or running, or failed too recently. */ + private boolean usable(final long now) { + if (!server.isDone()) return true; + if (server.isCompletedExceptionally()) return now - failed < RETRY_DELAY; + return server.join().isAlive(); + } + } +} diff --git a/scijava-code-lsp/src/main/java/org/scijava/code/lsp/server/package-info.java b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/server/package-info.java new file mode 100644 index 000000000..c17886944 --- /dev/null +++ b/scijava-code-lsp/src/main/java/org/scijava/code/lsp/server/package-info.java @@ -0,0 +1,107 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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% + */ + +/** + * A toolkit for offering language servers that run as processes, e.g. an + * existing server for some language, speaking LSP over its standard input and + * output. + * <p> + * {@link org.scijava.code.lsp.server.ServerProcesses} keeps the server + * processes of one kind: one per environment scripts run in, started when + * first needed (in the background), restarted if they stop, and stopped when + * unused for a while. Each is seen as a + * {@link org.eclipse.lsp4j.services.LanguageServer} of its own. + * </p> + * <h2>Offering a server of your own</h2> + * <ol> + * <li>Implement {@link org.scijava.code.lsp.server.ServerLauncher}: the + * language it serves, and how to start its process for an environment, e.g. + * against that environment's interpreter and extra search paths. Optionally, + * also the server's initialization options and workspace root.</li> + * <li>Offer it as a {@link org.scijava.code.lsp.LanguageServerPlugin}, which + * keeps one {@code ServerProcesses} of it, gets each environment's server from + * it, and shuts it down when disposed (stopping the processes).</li> + * <li>For scripts of a SciJava script language to be served by it, a + * {@link org.scijava.code.lsp.ScriptDialect} must make them code of that + * language.</li> + * </ol> + * <p> + * For example, for a server run as {@code my-language-server --stdio}: + * </p> + * <pre> + * class MyLauncher implements ServerLauncher { + * + * @Override + * public String languageId() { + * return "mylang"; + * } + * + * @Override + * public Process start(Environment env) throws IOException { + * return new ProcessBuilder("my-language-server", "--stdio") + * // NB: Lest a full pipe block the server. + * .redirectError(ProcessBuilder.Redirect.DISCARD).start(); + * } + * } + * + * @Plugin(type = LanguageServerPlugin.class) + * public class MyServerPlugin implements LanguageServerPlugin, Disposable { + * + * @Parameter + * private LogService log; + * + * private ServerProcesses servers; + * + * @Override + * public boolean supports(String languageId) { + * return "mylang".equals(languageId); + * } + * + * @Override + * public synchronized LanguageServer create(String languageId, + * Environment environment) + * { + * if (servers == null) servers = new ServerProcesses(new MyLauncher(), log); + * return servers.server(environment); + * } + * + * @Override + * public synchronized void dispose() { + * if (servers != null) servers.shutdown(); + * } + * } + * </pre> + * <p> + * scijava-code-lsp-jedi's {@code JediLauncher} and {@code PythonServerPlugin} + * do this for jedi-language-server, building the environment it runs in the + * first time it is needed. (Servers written in Java can run in this JVM + * instead: see {@link org.scijava.code.lsp.jvm.AbstractLanguageServer}.) + * </p> + */ +package org.scijava.code.lsp.server; diff --git a/scijava-code-lsp/src/test/java/org/scijava/code/lsp/LanguageServersTest.java b/scijava-code-lsp/src/test/java/org/scijava/code/lsp/LanguageServersTest.java new file mode 100644 index 000000000..d741d1527 --- /dev/null +++ b/scijava-code-lsp/src/test/java/org/scijava/code/lsp/LanguageServersTest.java @@ -0,0 +1,793 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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; + +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.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +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.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; +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.CompletionList; +import org.eclipse.lsp4j.CompletionParams; +import org.eclipse.lsp4j.Diagnostic; +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.MarkupContent; +import org.eclipse.lsp4j.MessageActionItem; +import org.eclipse.lsp4j.MessageParams; +import org.eclipse.lsp4j.ParameterInformation; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.PublishDiagnosticsParams; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.ServerCapabilities; +import org.eclipse.lsp4j.ShowMessageRequestParams; +import org.eclipse.lsp4j.SignatureHelp; +import org.eclipse.lsp4j.SignatureHelpParams; +import org.eclipse.lsp4j.SignatureInformation; +import org.eclipse.lsp4j.TextDocumentIdentifier; +import org.eclipse.lsp4j.TextDocumentItem; +import org.eclipse.lsp4j.TextEdit; +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.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.scijava.Context; +import org.scijava.Priority; +import org.scijava.plugin.Plugin; +import org.scijava.script.AbstractScriptLanguage; +import org.scijava.script.ScriptLanguage; +import org.scijava.script.ScriptService; + +/** + * Tests {@link LanguageServerService} and {@link ScriptSession}, with fake + * in-process servers; and the {@code plaintext} server, of an interpreter's + * variables. + * + * @author Gabriel Selzer + */ +public class LanguageServersTest { + + private static final String URI = "untitled:/a.fake"; + + /** The fake servers created, in order. */ + static final List<FakeServer> SERVERS = new CopyOnWriteArrayList<>(); + + private Context context; + private LanguageServerService service; + private ScriptLanguage fake; + private final List<PublishDiagnosticsParams> published = + new CopyOnWriteArrayList<>(); + /** The problems sessions' listeners heard, in order. */ + private final List<List<Diagnostic>> heard = new CopyOnWriteArrayList<>(); + + @BeforeEach + public void setUp() { + SERVERS.clear(); + context = new Context(ScriptService.class, + LanguageServerService.class); + service = context.service(LanguageServerService.class); + fake = context.service(ScriptService.class).getLanguageByName("Fake"); + } + + @AfterEach + public void tearDown() { + context.dispose(); + } + + @Test + public void testHighestPriorityServes() throws Exception { + try (ScriptSession session = new ScriptSession(service, fake)) { + final CompletionList list = session.completion("x.ma", 4).get(10, + TimeUnit.SECONDS); + // Only the highest-priority plugin's server, created for the session. + assertEquals(Arrays.asList("max", "mass"), labels(list)); + assertEquals(1, SERVERS.size()); + assertEquals("high", SERVERS.get(0).name); + // For the LSP language the script language's dialect names. + assertEquals("fake", SERVERS.get(0).languageId); + } + // Each session gets a server of its own. + try (ScriptSession other = new ScriptSession(service, fake)) { + other.completion("x", 1).get(10, TimeUnit.SECONDS); + assertEquals(2, SERVERS.size()); + } + } + + @Test + public void testLifeCycle() throws Exception { + final ScriptSession session = new ScriptSession(service, fake); + // Created once there is a script, and initialized at once; told about the + // script as it changes. + session.update("x = 1"); + final FakeServer server = server(0); + waitFor(() -> server.events.size() >= 2); + session.update("x = 2"); + waitFor(() -> server.events.size() >= 3); + assertEquals(Arrays.asList("initialized", "open 1 x = 1", + "change 2 x = 2"), server.events); + // Closed: the script, then the server. + session.close(); + waitFor(() -> server.events.size() >= 6); + assertEquals(Arrays.asList("close", "shutdown", "exit"), server.events + .subList(3, 6)); + // Once closed: no server more, and no answers. + assertTrue(session.completion("x = 3", 5).get(10, TimeUnit.SECONDS) + .getItems().isEmpty()); + assertEquals(1, SERVERS.size()); + assertEquals(6, server.events.size()); + } + + @Test + public void testAwaitsInitialization() { + // A server answering initialize later (e.g. a process starting). + final FakeServer late = new FakeServer("late", new Environment()) { + + @Override + public CompletableFuture<InitializeResult> initialize( + final InitializeParams params) + { + return CompletableFuture.supplyAsync(() -> { + try { + Thread.sleep(100); + } + catch (final InterruptedException exc) { + Thread.currentThread().interrupt(); + } + events.add("answered"); + return new InitializeResult(new ServerCapabilities()); + }); + } + }; + HighPlugin.next = late; + try (ScriptSession session = new ScriptSession(service, fake)) { + session.update("x"); + } + finally { + HighPlugin.next = null; + } + // Told nothing before it answered. + assertEquals(Arrays.asList("answered", "initialized", "open 1 x"), + late.events.subList(0, 3)); + } + + @Test + public void testLiveEnvironment() { + final ScriptContext live = new SimpleScriptContext(); + try (ScriptSession session = new ScriptSession(service, fake, live)) { + session.update("x"); + assertSame(live, server(0).environment().live().get()); + } + try (ScriptSession session = new ScriptSession(service, fake)) { + session.update("x"); + assertEquals(Optional.empty(), server(1).environment().live()); + } + // Where the script runs, as its dialect says. + try (ScriptSession session = new ScriptSession(service, fake, live)) { + // NB: As written: its server sees the line blanked. + final String directive = "#@script(env=/env/bin/fake)"; + session.update(directive + "\nx"); + final FakeServer server = server(2); + assertEquals(Optional.of("/env/bin/fake"), server.environment() + .interpreter()); + assertSame(live, server.environment().live().get()); + waitFor(() -> server.events.size() >= 2); + assertEquals("open 1 " + directive.replaceAll(".", " ") + "\nx", + server.events.get(1)); + } + } + + @Test + public void testSession() throws Exception { + final ScriptSession session = new ScriptSession(service, fake); + session.setDiagnosticsListener(heard::add); + // Opened with its first text; untitled. + session.update("x = 1"); + final FakeServer server = server(0); + waitFor(() -> server.events.size() >= 2); + // The same text again: nothing sent; another: the next version. + session.update("x = 1"); + session.update("x.ma"); + final List<String> events = server.events; + waitFor(() -> events.size() >= 3); + assertEquals(Arrays.asList("open 1 x = 1", "change 2 x.ma"), events + .subList(1, 3)); + final List<String> opened = server.opened; + assertTrue(opened.get(0).startsWith("untitled:/script-")); + + // Asked about a text (the current one: nothing sent), at an offset. + final CompletionList list = session.completion("x.ma", 4).get(10, + TimeUnit.SECONDS); + assertEquals(new Position(0, 4), server.asked.get(server.asked.size() - + 1)); + assertTrue(labels(list).contains("max")); + assertEquals(3, events.size()); + + // The problems in the current text. + awaitHeard("high: x.ma"); + + // Saved as a file: closed, and opened anew as the file. + final File file = new File("scripts", "a.fake"); + session.setFile(() -> file); + session.update("x.ma"); + waitFor(() -> events.size() >= 5); + assertEquals(Arrays.asList("close", "open 1 x.ma"), events.subList(3, 5)); + assertEquals(file.getAbsoluteFile().toURI().toString(), opened.get(1)); + session.close(); + waitFor(() -> events.size() >= 6); + assertEquals("close", events.get(5)); + } + + @Test + public void testDiagnostics() throws Exception { + try (ScriptSession session = new ScriptSession(service, fake)) { + session.setDiagnosticsListener(heard::add); + session.update("oops"); + awaitHeard("high: oops"); + final FakeServer server = server(0); + // Problems of an earlier version are out of date. + session.update("fine"); + awaitHeard(); + final int before = heard.size(); + server.publish(server.opened.get(0), 1, "stale"); + assertEquals(before, heard.size()); + } + } + + @Test + public void testSignatureHelpAndHover() throws Exception { + try (ScriptSession session = new ScriptSession(service, fake)) { + final SignatureHelp help = session.signatureHelp("max(1, ", 7).get(10, + TimeUnit.SECONDS); + assertEquals("max(int a, int b)", help.getSignatures().get(0) + .getLabel()); + final Hover hover = session.hover("max(1, ", 1).get(10, + TimeUnit.SECONDS); + assertEquals("high hover", hover.getContents().getRight().getValue()); + } + } + + @Test + public void testTransforming() throws Exception { + final String script = "#@ double sigma\nx.ma"; + try (ScriptSession session = new ScriptSession(service, fake)) { + session.setDiagnosticsListener(heard::add); + final CompletionList list = session.completion(script, script.length()) + .get(10, TimeUnit.SECONDS); + final FakeServer server = server(0); + // The server sees plain code, in the script language's dialect. + assertEquals("open 1 import mass; sigma: double\n" + script.replace( + "#@ double sigma", " "), server.events.get(1)); + // Asked one line down; its edits mapped back up. + assertEquals(new Position(2, 4), server.asked.get(0)); + // Names only the declarations import are not completed. + assertEquals(Collections.singletonList("max"), labels(list)); + assertEquals(new Range(new Position(1, 2), new Position(1, 4)), list + .getItems().get(0).getTextEdit().getLeft().getRange()); + // Problems mapped back; those in the added line left out. + server.publish(server.opened.get(0), 1, "oops", new Range(new Position( + 0, 0), new Position(0, 5)), new Range(new Position(2, 0), + new Position(2, 1))); + final List<Diagnostic> problems = heard.get(heard.size() - 1); + assertEquals(1, problems.size()); + assertEquals(new Range(new Position(1, 0), new Position(1, 1)), problems + .get(0).getRange()); + } + } + + @Test + public void testBindings() throws Exception { + // Code typed into an interpreter whose language has no servers: its + // variables, and their values' members. + final SimpleScriptContext live = new SimpleScriptContext(); + live.setAttribute("text", "hello", ScriptContext.ENGINE_SCOPE); + live.setAttribute("count", 3, ScriptContext.ENGINE_SCOPE); + final LanguageServer server = service.launch("plaintext", + new Environment().withLive(live)); + assertEquals(Collections.singletonList("count"), labels(complete(server, + "x = co"))); + assertEquals(Arrays.asList("count", "text"), labels(complete(server, ""))); + final CompletionList members = complete(server, "text.toUpp"); + assertEquals(Arrays.asList("toUpperCase", "toUpperCase"), labels(members)); + final CompletionItem upper = members.getItems().get(0); + assertTrue(upper.getTextEdit().getLeft().getNewText().startsWith( + "toUpperCase(")); + assertEquals(new Range(new Position(0, 5), new Position(0, 10)), upper + .getTextEdit().getLeft().getRange()); + // Unknown variables: nothing. + assertTrue(complete(server, "nothing.x").getItems().isEmpty()); + + // A session about code typed into an interpreter of a language no plugin + // serves: the same. + final ScriptLanguage unserved = new AbstractScriptLanguage() { + + @Override + public ScriptEngine getScriptEngine() { + return null; + } + }; + try (ScriptSession session = new ScriptSession(service, unserved, live)) { + assertEquals(Collections.singletonList("count"), labels(session + .completion("x = co", 6).get(10, TimeUnit.SECONDS))); + } + // Without an interpreter: nothing, and none of the fake servers. + try (ScriptSession session = new ScriptSession(service, unserved)) { + assertTrue(session.completion("x = co", 6).get(10, TimeUnit.SECONDS) + .getItems().isEmpty()); + } + assertTrue(SERVERS.isEmpty()); + } + + @Test + public void testSupports() { + assertTrue(service.supports(fake)); + assertFalse(service.supports((ScriptLanguage) null)); + // By LSP language. + assertTrue(service.supports("fake")); + assertFalse(service.supports("cobol")); + // Scripts as they are: their interpreters' variables. + assertTrue(service.supports("plaintext")); + } + + @Test + public void testMoving() throws Exception { + // Where the script runs changes as it is edited: the session moves to a + // server of where it runs now, which is told the script anew. + final String a = "#@script(env=/a)\nx.ma"; + final String b = "#@script(env=/b)\nx.ma"; + try (ScriptSession session = new ScriptSession(service, fake)) { + session.setDiagnosticsListener(heard::add); + session.completion(a, a.length()).get(10, TimeUnit.SECONDS); + final FakeServer first = server(0); + assertEquals(Optional.of("/a"), first.environment().interpreter()); + // What the server moved away from made: not asked of the next. + session.update(b); + assertNull(session.resolve(new CompletionItem("max")).get(10, + TimeUnit.SECONDS)); + session.completion(b, b.length()).get(10, TimeUnit.SECONDS); + final FakeServer second = server(1); + assertEquals(Optional.of("/b"), second.environment().interpreter()); + assertTrue(second.events.contains("open 2 " + b.replace( + "#@script(env=/b)", " ")), second.events.toString()); + // The server moved away from is let go... + waitFor(() -> first.events.contains("shutdown")); + // ...and what it still says, not heard. + first.publish(first.opened.get(0), 2, "stale"); + assertTrue(heard.stream().flatMap(List::stream).noneMatch(d -> d + .getMessage().getLeft().contains("stale"))); + // Edits stay with the server of where it runs. + session.update(b + "x"); + waitFor(() -> second.events.stream().anyMatch(e -> e.startsWith( + "change 3 "))); + assertEquals(2, SERVERS.size()); + } + } + + @Test + public void testLaunch() { + // A server, from the highest-priority plugin serving the + // language: a new one, in the environment given. + final Environment env = new Environment().withInterpreter("/env/bin/fake"); + final LanguageServer server = service.launch("fake", env); + assertSame(SERVERS.get(0), server); + assertEquals("high", SERVERS.get(0).name); + assertEquals("fake", SERVERS.get(0).languageId); + assertSame(env, SERVERS.get(0).environment()); + // No plugin, no server. + assertThrows(IllegalArgumentException.class, () -> service.launch("cobol", + new Environment())); + } + + // -- Helper methods -- + + /** The server created i-th, once created. */ + private static FakeServer server(final int i) { + waitFor(() -> SERVERS.size() > i); + return SERVERS.get(i); + } + + private static void waitFor(final BooleanSupplier condition) { + final long deadline = System.currentTimeMillis() + 10_000; + while (!condition.getAsBoolean()) { + if (System.currentTimeMillis() > deadline) { + throw new AssertionError("Timed out"); + } + try { + Thread.sleep(10); + } + catch (final InterruptedException exc) { + throw new AssertionError(exc); + } + } + } + + private static CompletionList complete(final LanguageServer server, + final String text) throws Exception + { + final String uri = "untitled:/live"; + server.getTextDocumentService().didOpen(new DidOpenTextDocumentParams( + new TextDocumentItem(uri, "live", 1, text))); + try { + return server.getTextDocumentService().completion(new CompletionParams( + new TextDocumentIdentifier(uri), Positions.position(text, text + .length()))).get(10, TimeUnit.SECONDS).getRight(); + } + finally { + server.getTextDocumentService().didClose(new DidCloseTextDocumentParams( + new TextDocumentIdentifier(uri))); + } + } + + private static List<String> labels(final CompletionList list) { + return list.getItems().stream().map(CompletionItem::getLabel).collect( + Collectors.toList()); + } + + /** Waits until the problems heard last are those given. */ + private void awaitHeard(final String... expected) { + waitFor(() -> !heard.isEmpty() && messages(heard.get(heard.size() - 1)) + .equals(Arrays.asList(expected))); + } + + private static List<String> messages(final List<Diagnostic> diagnostics) { + return diagnostics.stream().map(d -> d.getMessage().getLeft()).collect( + Collectors.toList()); + } + + private final class Client implements LanguageClient { + + @Override + public void publishDiagnostics(final PublishDiagnosticsParams p) { + published.add(p); + } + + @Override + public void telemetryEvent(final Object object) {} + + @Override + public void showMessage(final MessageParams m) {} + + @Override + public CompletableFuture<MessageActionItem> showMessageRequest( + final ShowMessageRequestParams r) + { + return CompletableFuture.completedFuture(null); + } + + @Override + public void logMessage(final MessageParams m) {} + } + + // -- A fake language, and its servers -- + + @Plugin(type = ScriptLanguage.class, name = "Fake") + public static class FakeLanguage extends AbstractScriptLanguage { + + @Override + public ScriptEngine getScriptEngine() { + return null; + } + + @Override + public List<String> getNames() { + return Collections.singletonList("Fake"); + } + + @Override + public List<String> getExtensions() { + return Collections.singletonList("fake"); + } + } + + /** + * Turns fake scripts into {@code fake} code; they run where a line + * {@code #@script(env=<interpreter>)} says, if any. + */ + @Plugin(type = ScriptDialect.class) + public static class FakeDialect implements ScriptDialect { + + @Override + public boolean supports(final ScriptLanguage language) { + return language instanceof FakeLanguage; + } + + @Override + public String languageId() { + return "fake"; + } + + @Override + public Environment environment(final String text, + final Optional<File> file) + { + for (final String line : text.split("\n")) { + if (line.startsWith("#@script(env=")) { + final String env = line.substring(13, line.length() - 1); + return new Environment().withInterpreter(env); + } + } + return new Environment(); + } + + @Override + public Header header(final Map<String, Variable> variables) { + if (variables.isEmpty()) { + return new Header("", Collections.emptySet()); + } + final List<String> code = new ArrayList<>(); + code.add("import mass"); + variables.forEach((name, v) -> code.add(name + ": " + v.type() + .getSimpleName())); + return new Header(String.join("; ", code), Collections.singleton( + "mass")); + } + } + + /** Serves the fake language (as {@code fake}): the one asked. */ + @Plugin(type = LanguageServerPlugin.class, priority = Priority.HIGH) + public static class HighPlugin implements LanguageServerPlugin { + + /** The server to create next, if not a new one (e.g. a special one). */ + static volatile FakeServer next; + + @Override + public boolean supports(final String languageId) { + return "fake".equals(languageId); + } + + + @Override + public LanguageServer create(final String languageId, + final Environment environment) + { + final FakeServer special = next; + return register(special != null ? special : new FakeServer("high", + languageId, environment)); + } + } + + /** Serves the fake language too, at a lower priority: never asked. */ + @Plugin(type = LanguageServerPlugin.class, priority = Priority.LOW) + public static class LowPlugin implements LanguageServerPlugin { + + @Override + public boolean supports(final String languageId) { + return "fake".equals(languageId); + } + + + @Override + public LanguageServer create(final String languageId, + final Environment environment) + { + return register(new FakeServer("low", languageId, environment)); + } + } + + private static FakeServer register(final FakeServer s) { + SERVERS.add(s); + return s; + } + + /** A fake in-process server, recording what it is told. */ + static class FakeServer implements LanguageServer, TextDocumentService, + WorkspaceService, LanguageClientAware + { + + final String name; + /** The LSP language it was created for, if any. */ + final String languageId; + final Environment environment; + final List<String> events = new CopyOnWriteArrayList<>(); + /** The URIs of the documents opened, in order. */ + final List<String> opened = new CopyOnWriteArrayList<>(); + final List<Position> asked = new CopyOnWriteArrayList<>(); + LanguageClient client; + + FakeServer(final String name, final Environment environment) { + this(name, null, environment); + } + + FakeServer(final String name, final String languageId, + final Environment environment) + { + this.name = name; + this.languageId = languageId; + this.environment = environment; + } + + /** Where its scripts run. */ + Environment environment() { + return environment; + } + + @Override + public void connect(final LanguageClient c) { + client = c; + } + + @Override + public CompletableFuture<InitializeResult> initialize( + final InitializeParams params) + { + return CompletableFuture.completedFuture(new InitializeResult( + new ServerCapabilities())); + } + + @Override + public void initialized(final InitializedParams params) { + events.add("initialized"); + } + + @Override + public CompletableFuture<Object> shutdown() { + events.add("shutdown"); + return CompletableFuture.completedFuture(null); + } + + @Override + public void exit() { + events.add("exit"); + } + + @Override + public TextDocumentService getTextDocumentService() { + return this; + } + + @Override + public WorkspaceService getWorkspaceService() { + return this; + } + + @Override + public void didOpen(final DidOpenTextDocumentParams p) { + final TextDocumentItem d = p.getTextDocument(); + opened.add(d.getUri()); + events.add("open " + d.getVersion() + " " + d.getText()); + diagnose(d.getUri(), d.getVersion(), d.getText()); + } + + @Override + public void didChange(final DidChangeTextDocumentParams p) { + final String text = p.getContentChanges().get(0).getText(); + events.add("change " + p.getTextDocument().getVersion() + " " + text); + diagnose(p.getTextDocument().getUri(), p.getTextDocument().getVersion(), + text); + } + + /** Complains about the text, unless it is "fine". */ + private void diagnose(final String uri, final int version, + final String text) + { + if (client == null) return; + if (text.equals("fine")) { + client.publishDiagnostics(new PublishDiagnosticsParams(uri, + Collections.emptyList(), version)); + } + else publish(uri, version, text); + } + + @Override + public void didClose(final DidCloseTextDocumentParams p) { + events.add("close"); + } + + @Override + public void didSave(final DidSaveTextDocumentParams p) {} + + void publish(final String uri, final int version, final String message, + final Range... ranges) + { + final List<Diagnostic> list = new ArrayList<>(); + final Range[] rs = ranges.length > 0 ? ranges : new Range[] { new Range( + new Position(0, 0), new Position(0, 1)) }; + for (final Range r : rs) { + list.add(new Diagnostic(r, ranges.length > 0 ? message : name + ": " + + message)); + } + client.publishDiagnostics(new PublishDiagnosticsParams(uri, list, + version)); + } + + @Override + public CompletableFuture<Either<List<CompletionItem>, CompletionList>> + completion(final CompletionParams p) + { + asked.add(p.getPosition()); + final Position at = p.getPosition(); + final Range word = new Range(new Position(at.getLine(), Math.max(0, at + .getCharacter() - 2)), at); + final List<CompletionItem> items = new ArrayList<>(); + for (final String label : new String[] { "max", "mass" }) { + final CompletionItem item = new CompletionItem(label); + item.setTextEdit(Either.forLeft(new TextEdit(word, label))); + items.add(item); + } + return CompletableFuture.completedFuture(Either.forRight( + new CompletionList(items))); + } + + @Override + public CompletableFuture<SignatureHelp> signatureHelp( + final SignatureHelpParams p) + { + return CompletableFuture.completedFuture(new SignatureHelp(Collections + .singletonList(new SignatureInformation("max(int a, int b)", + (String) null, Arrays.asList(new ParameterInformation("int a"), + new ParameterInformation("int b")))), 0, 1)); + } + + @Override + public CompletableFuture<Hover> hover(final HoverParams p) { + return CompletableFuture.completedFuture(new Hover(new MarkupContent( + "plaintext", name + " hover"))); + } + + @Override + public void didChangeConfiguration(final DidChangeConfigurationParams p) {} + + @Override + public void didChangeWatchedFiles(final DidChangeWatchedFilesParams p) {} + } +} diff --git a/scijava-code-lsp/src/test/java/org/scijava/code/lsp/ScriptDocumentTest.java b/scijava-code-lsp/src/test/java/org/scijava/code/lsp/ScriptDocumentTest.java new file mode 100644 index 000000000..4584af413 --- /dev/null +++ b/scijava-code-lsp/src/test/java/org/scijava/code/lsp/ScriptDocumentTest.java @@ -0,0 +1,223 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.junit.jupiter.api.Test; +import org.scijava.code.lsp.ScriptDialect.Variable; +import org.scijava.script.ScriptLanguage; + +/** + * Tests {@link ScriptDocument}. + * + * @author Gabriel Selzer + */ +public class ScriptDocumentTest { + + /** A Python-like dialect: arrays become NumPy arrays; strings stay. */ + private static final ScriptDialect PYTHON = new ScriptDialect() { + + @Override + public boolean supports(final ScriptLanguage language) { + return false; + } + + @Override + public String languageId() { + return "python"; + } + + @Override + public Header header(final Map<String, Variable> variables) { + final List<String> code = new ArrayList<>(); + final boolean numpy = variables.values().stream().anyMatch(v -> v.type() + .isArray()); + if (numpy) code.add("import numpy"); + variables.forEach((name, v) -> { + if (v.type().isArray()) code.add(name + ": numpy.ndarray"); + else if (v.type() == String.class) code.add(name + ": str"); + }); + code.add("task: object"); + return new Header(String.join("; ", code), numpy ? Collections + .singleton("numpy") : Collections.emptySet()); + } + }; + + /** A Groovy-like dialect: Java types. */ + private static final ScriptDialect GROOVY = new ScriptDialect() { + + @Override + public boolean supports(final ScriptLanguage language) { + return false; + } + + @Override + public String languageId() { + return "groovy"; + } + + @Override + public Header header(final Map<String, Variable> variables) { + final List<String> code = new ArrayList<>(); + variables.forEach((name, v) -> code.add(v.type().getName() + " " + + name)); + return new Header(String.join("; ", code), Collections.emptySet()); + } + }; + + private static Map<String, Variable> params(final Object... namesAndTypes) { + final Map<String, Variable> params = new LinkedHashMap<>(); + for (int i = 0; i < namesAndTypes.length; i += 2) { + params.put((String) namesAndTypes[i], Variable.ofType( + (Class<?>) namesAndTypes[i + 1])); + } + return params; + } + + private static Set<Integer> lines(final Integer... lines) { + return new HashSet<>(Arrays.asList(lines)); + } + + @Test + public void testDeclarations() { + final String script = "#@ double[] a\n#@ String s\n#@ Integer n\na."; + final ScriptDocument doc = ScriptDocument.of(script, params("a", + double[].class, "s", String.class, "n", Integer.class), lines(0, 1, + 2), PYTHON); + // The dialect's header, on a line of its own; the parameter lines + // blanked. + assertEquals("import numpy; a: numpy.ndarray; s: str; task: object\n" + + blanked(script.substring(0, script.lastIndexOf('\n'))) + "\na.", doc + .text()); + // The module is the header's alone, unless the script mentions it. + assertEquals(Collections.singleton("numpy"), doc.hiddenNames()); + assertTrue(ScriptDocument.of("import numpy as np\n" + script, params("a", + double[].class), lines(), PYTHON).hiddenNames().isEmpty()); + assertTrue(ScriptDocument.of(script + "\nnumpyish = 1", params("a", + double[].class), lines(), PYTHON).hiddenNames().contains("numpy")); + } + + @Test + public void testIdentity() { + final String script = "x = 1\nx."; + final ScriptDocument doc = ScriptDocument.of(script, params(), lines(), + GROOVY); + assertEquals(script, doc.text()); + assertEquals(5, doc.toTransformed(5)); + assertEquals(5, doc.toOriginal(5)); + assertEquals(new Position(1, 1), doc.toTransformed(new Position(1, 1))); + } + + @Test + public void testBlanking() { + final String script = "#@ String s\r\nprintln s\r\n#@output x\n# end"; + final ScriptDocument doc = ScriptDocument.of(script, params("s", + String.class), lines(0, 2), GROOVY); + assertEquals("java.lang.String s\n" + + " \r\nprintln s\r\n \n# end", doc.text()); + // Blanking keeps every offset, and line breaks (incl. CR) in place. + assertEquals(doc.text().length() - doc.toTransformed(0), script + .length()); + assertEquals(doc.text().split("\n", -1).length - 1, script.split("\n", + -1).length); + } + + @Test + public void testRoundTrips() { + // NB: Includes a CRLF and a surrogate pair (two Java chars). + final String script = "#@ String s\r\nt = \"šŸ˜€\"\r\ns.len"; + final ScriptDocument doc = ScriptDocument.of(script, params("s", + String.class), lines(0), PYTHON); + for (int i = 0; i <= script.length(); i++) { + final int t = doc.toTransformed(i); + assertEquals(i, doc.toOriginal(t)); + // The text after a position is the same in both, but for the + // blanked parameter line. + final String after = doc.text().substring(t); + assertEquals(script.length() - i, after.length()); + if (i >= script.indexOf('\r')) { + assertEquals(script.substring(i), after); + } + } + for (int line = 0; line < 3; line++) { + final Position p = new Position(line, 2); + final Position t = doc.toTransformed(p); + assertEquals(new Position(line + 1, 2), t); + assertEquals(p, doc.toOriginal(t)); + } + // The header maps to nothing. + assertEquals(-1, doc.toOriginal(0)); + assertEquals(-1, doc.toOriginal(doc.toTransformed(0) - 1)); + assertNull(doc.toOriginal(new Position(0, 0))); + assertNull(doc.toOriginal(new Range(new Position(0, 0), new Position(1, + 1)))); + assertEquals(new Range(new Position(0, 0), new Position(1, 1)), doc + .toOriginal(new Range(new Position(1, 0), new Position(2, 1)))); + } + + @Test + public void testOneLineHeader() { + // A header is one line: positions shift by just that line. + assertThrows(IllegalArgumentException.class, + () -> new ScriptDialect.Header("import numpy\nx: int", Collections + .emptySet())); + assertThrows(IllegalArgumentException.class, + () -> new ScriptDialect.Header("x: int\r", Collections.emptySet())); + } + + @Test + public void testAsIs() { + final String script = "#@ String s\ns."; + final ScriptDocument doc = ScriptDocument.of(script); + assertEquals(script, doc.text()); + assertEquals(0, doc.toTransformed(0)); + assertTrue(doc.hiddenNames().isEmpty()); + } + + /** The script, with every character but its line breaks blanked. */ + private static String blanked(final String script) { + return script.replaceAll("[^\r\n]", " "); + } +} diff --git a/scijava-code-lsp/src/test/java/org/scijava/code/lsp/impl/ScriptProcessingTest.java b/scijava-code-lsp/src/test/java/org/scijava/code/lsp/impl/ScriptProcessingTest.java new file mode 100644 index 000000000..5c5b19fcb --- /dev/null +++ b/scijava-code-lsp/src/test/java/org/scijava/code/lsp/impl/ScriptProcessingTest.java @@ -0,0 +1,176 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; + +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.log.LogLevel; +import org.scijava.log.LogService; + +/** + * Tests {@link ScriptProcessing}. + * + * @author Gabriel Selzer + * @author Curtis Rueden + */ +public class ScriptProcessingTest { + + private Context context; + private ScriptProcessing processing; + + @BeforeEach + public void setUp() { + context = new Context(); + processing = new ScriptProcessing(() -> context); + } + + @AfterEach + public void tearDown() { + context.dispose(); + } + + @Test + public void testRemovedLines() { + // The lines SciJava removes before running a script: its shebang, its + // #@ lines (parameters and directives), and old-style parameters in its + // header. + final ScriptProcessing.Processed p = processing.of("" + // + "#!/usr/bin/env groovy\n" + // 0 + "// @String old\n" + // 1 + "#@script(name=\"x\")\n" + // 2 + "#@ int n\n" + // 3 + "println n // @not\n" + // 4: not these, which are the script's + " #@ indented\n" + // 5 + "# comment\n" + // 6 + "// @late\n" + // 7 + "#!late"); // 8 + assertEquals(Arrays.asList(0, 1, 2, 3), new ArrayList<>(new TreeSet<>( + p.removedLines))); + assertEquals(Arrays.asList("old", "n"), new ArrayList<>(p.parameters + .keySet())); + } + + @Test + public void testRejectedParameters() { + // Parameters SciJava rejects are likely being typed: their lines are + // removed all the same, and the variables they declare known. + final ScriptProcessing.Processed p = processing.of("" + // + "#@ NoSuchType thing\n" + // + "#@ Str\n" + // + "x = 1"); + assertEquals(Arrays.asList(0, 1), new ArrayList<>(new TreeSet<>( + p.removedLines))); + assertSame(Object.class, p.parameters.get("thing")); + } + + @Test + public void testNothingToProcess() { + final ScriptProcessing.Processed p = processing.of("x = 1\n# comment"); + assertEquals(Collections.emptySet(), p.removedLines); + assertEquals(Collections.emptyMap(), p.parameters); + } + + @Test + public void testInputsAndOutputs() { + final Map<String, Class<?>> params = parameters("" + // + "#@ String (label=\"Please enter your name\") name\n" + // + "#@output String greeting\n" + // + "#@ int count\n" + // + "\n" + // + "greeting = \"Hello, \" + name + \"!\"\n"); + assertEquals(Arrays.asList("name", "count", "greeting"), // + new ArrayList<>(params.keySet())); + assertSame(String.class, params.get("name")); + assertSame(int.class, params.get("count")); + assertSame(String.class, params.get("greeting")); + } + + @Test + public void testNoParameters() { + assertTrue(parameters("x = 1\n").isEmpty()); + // The implicit return value output is not a declared variable. + assertTrue(parameters("#@script(name=\"x\")\n").isEmpty()); + } + + @Test + public void testHalfTypedAndUnresolvable() { + final Map<String, Class<?>> params = parameters("" + // + "#@ String name\n" + // + "#@ NoSuchType thing\n" + // + "#@ Str\n" + // + "#@ String (label=\"Unfinished\n"); + assertEquals(Arrays.asList("name", "thing"), // + new ArrayList<>(params.keySet())); + assertSame(String.class, params.get("name")); + assertSame(Object.class, params.get("thing")); + } + + @Test + public void testScriptDirective() { + final List<String> warnings = new ArrayList<>(); + context.service(LogService.class).addLogListener(message -> { + if (message.level() == LogLevel.WARN) warnings.add(message.text()); + }); + final Map<String, Class<?>> params = parameters("" + // + "#@script(env=\"env.toml\", scheme=\"pixi.toml\")\n" + // + "#@ String name\n"); + assertEquals(Arrays.asList("name"), new ArrayList<>(params.keySet())); + // The directive is not a parameter, so it is not warned about. + assertTrue(warnings.isEmpty(), warnings.toString()); + } + + @Test + public void testCached() { + final Map<String, Class<?>> first = parameters( + "#@ String name\nx = 1\n"); + // Non-declaration edits reuse the parse. + assertSame(first, parameters("#@ String name\nx = 12\n")); + } + + // -- Helper methods -- + + /** The variables a script's parameters define, as processed. */ + private Map<String, Class<?>> parameters(final String text) { + return processing.of(text).parameters; + } +} diff --git a/scijava-code-lsp/src/test/java/org/scijava/code/lsp/jvm/ClassIndexTest.java b/scijava-code-lsp/src/test/java/org/scijava/code/lsp/jvm/ClassIndexTest.java new file mode 100644 index 000000000..d27bd4fb7 --- /dev/null +++ b/scijava-code-lsp/src/test/java/org/scijava/code/lsp/jvm/ClassIndexTest.java @@ -0,0 +1,52 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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.jvm; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link ClassIndex}. + * + * @author Curtis Rueden + */ +public class ClassIndexTest { + + /** The JDK's own classes must be indexed, including on Java 9+. */ + @Test + public void testRuntimeClassesIndexed() { + assertTrue(ClassIndex.findSimpleClassNamesStartingWith("ArrayList") + .contains("java.util.ArrayList")); + assertTrue(ClassIndex.findClassNamesForPackage("java.util").anyMatch( + "java.util.HashMap"::equals)); + assertTrue(ClassIndex.isCacheReady()); + } +} diff --git a/scijava-code-lsp/src/test/java/org/scijava/code/lsp/server/ServerProcessesTest.java b/scijava-code-lsp/src/test/java/org/scijava/code/lsp/server/ServerProcessesTest.java new file mode 100644 index 000000000..a2a6f5bab --- /dev/null +++ b/scijava-code-lsp/src/test/java/org/scijava/code/lsp/server/ServerProcessesTest.java @@ -0,0 +1,490 @@ +/*- + * #%L + * Language Server Protocol bridge for SciJava code intelligence. + * %% + * 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.server; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionItemKind; +import org.eclipse.lsp4j.CompletionList; +import org.eclipse.lsp4j.CompletionOptions; +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.InitializeParams; +import org.eclipse.lsp4j.InitializeResult; +import org.eclipse.lsp4j.ParameterInformation; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.ServerCapabilities; +import org.eclipse.lsp4j.SignatureHelp; +import org.eclipse.lsp4j.SignatureHelpParams; +import org.eclipse.lsp4j.SignatureInformation; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import org.eclipse.lsp4j.launch.LSPLauncher; +import org.eclipse.lsp4j.services.LanguageServer; +import org.eclipse.lsp4j.services.TextDocumentService; +import org.eclipse.lsp4j.services.WorkspaceService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.scijava.code.lsp.Environment; +import org.scijava.code.lsp.Positions; +import org.scijava.log.StderrLogService; + +/** + * Tests {@link ServerProcesses} and {@link ProcessLanguageServer}, against a fake + * language server. + * + * @author Gabriel Selzer + */ +public class ServerProcessesTest { + + private static final Environment ENV = new Environment().withInterpreter( + "/env/python").withPaths(Collections.singletonList("/overlay")); + + private final List<FakeServer> started = new CopyOnWriteArrayList<>(); + // NB: Servers note their use by the system clock. + private final AtomicLong now = new AtomicLong(System.currentTimeMillis()); + private ServerProcesses servers; + + private void setUp(final long idle) { + servers = new ServerProcesses(new FakeLauncher(), new StderrLogService(), idle, 2, + now::get); + } + + @AfterEach + public void tearDown() { + if (servers != null) servers.shutdown(); + } + + @Test + public void testOffset() { + assertEquals(4, Positions.offset("ab\ncd", new Position(1, 1))); + // Clamped to the line, and the text. + assertEquals(2, Positions.offset("ab\ncd", new Position(0, 9))); + assertEquals(5, Positions.offset("ab\ncd", new Position(7, 0))); + } + + @Test + public void testLaunched() throws Exception { + // A server process, seen as a LanguageServer (for LanguageServerPlugins). + setUp(60_000); + final ProcessLanguageServer launched = (ProcessLanguageServer) servers + .server(ENV); + final List<org.eclipse.lsp4j.PublishDiagnosticsParams> published = + new CopyOnWriteArrayList<>(); + launched.connect(new org.eclipse.lsp4j.services.LanguageClient() { + + @Override + public void telemetryEvent(final Object o) {} + + @Override + public void publishDiagnostics( + final org.eclipse.lsp4j.PublishDiagnosticsParams p) + { + published.add(p); + } + + @Override + public void showMessage(final org.eclipse.lsp4j.MessageParams m) {} + + @Override + public CompletableFuture<org.eclipse.lsp4j.MessageActionItem> + showMessageRequest(final org.eclipse.lsp4j.ShowMessageRequestParams r) + { + return CompletableFuture.completedFuture(null); + } + + @Override + public void logMessage(final org.eclipse.lsp4j.MessageParams m) {} + }); + final String uri = "untitled:/l.py"; + launched.didOpen(new DidOpenTextDocumentParams( + new org.eclipse.lsp4j.TextDocumentItem(uri, "python", 1, "y = !!"))); + // Requests bring the process's copy up to date first. + final List<CompletionItem> items = launched.completion(new CompletionParams( + new org.eclipse.lsp4j.TextDocumentIdentifier(uri), new Position(0, 2))) + .get(10, TimeUnit.SECONDS).getRight().getItems(); + assertEquals(3, items.size()); + final FakeServer server = started.get(0); + assertEquals("y = !!", server.texts.get(uri)); + // Problems: published a moment after the last change, for its version. + waitFor(() -> !published.isEmpty()); + assertEquals(Integer.valueOf(1), published.get(0).getVersion()); + assertEquals(2, published.get(0).getDiagnostics().size()); + launched.didClose(new DidCloseTextDocumentParams( + new org.eclipse.lsp4j.TextDocumentIdentifier(uri))); + waitFor(() -> server.events.contains("close " + uri)); + } + + @Test + public void testRestartAndIdle() throws Exception { + setUp(60_000); + final ServerProcess first = servers.process(ENV).get(10, TimeUnit.SECONDS); + assertSame(first, servers.process(ENV).get(10, TimeUnit.SECONDS)); + // The server dies: the next request starts another. + started.get(0).alive = false; + final ServerProcess second = servers.process(ENV).get(10, TimeUnit.SECONDS); + assertNotSame(first, second); + assertEquals(2, started.size()); + + // Unused for long: stopped. + now.addAndGet(120_000); + servers.stopIdle(); + waitFor(() -> started.get(1).shutdown); + assertNotSame(second, servers.process(ENV).get(10, TimeUnit.SECONDS)); + } + + @Test + public void testPosition() { + assertEquals(new Position(0, 0), Positions.position("ab\ncd", 0)); + assertEquals(new Position(1, 1), Positions.position("ab\ncd", 4)); + // UTF-16 code units, as Java counts. + assertEquals(new Position(1, 3), Positions.position("a\nšŸ˜€b", + 5)); + } + + // -- Helper methods -- + + private static List<CompletionItem> complete(final ProcessLanguageServer s, + final String uri) throws Exception + { + return s.completion(new CompletionParams( + new org.eclipse.lsp4j.TextDocumentIdentifier(uri), new Position(0, 1))) + .get(10, TimeUnit.SECONDS).getRight().getItems(); + } + + private static void waitFor(final java.util.function.BooleanSupplier c) + throws InterruptedException + { + final long end = System.currentTimeMillis() + 10_000; + while (!c.getAsBoolean()) { + if (System.currentTimeMillis() > end) throw new AssertionError( + "Timed out"); + Thread.sleep(10); + } + } + + /** Starts fake servers, connected by pipes. */ + private final class FakeLauncher implements ServerLauncher { + + @Override + public String languageId() { + return "python"; + } + + @Override + public Process start(final Environment env) + throws IOException + { + final PipedInputStream clientIn = new PipedInputStream(1 << 16); + final PipedOutputStream serverOut = new PipedOutputStream(clientIn); + final PipedInputStream serverIn = new PipedInputStream(1 << 16); + final PipedOutputStream clientOut = new PipedOutputStream(serverIn); + final FakeServer server = new FakeServer(); + final org.eclipse.lsp4j.jsonrpc.Launcher<org.eclipse.lsp4j.services.LanguageClient> launcher = + LSPLauncher.createServerLauncher(server, serverIn, serverOut); + server.client = launcher.getRemoteProxy(); + launcher.startListening(); + started.add(server); + return new Process() { + + @Override + public InputStream getInputStream() { + return clientIn; + } + + @Override + public OutputStream getOutputStream() { + return clientOut; + } + + @Override + public InputStream getErrorStream() { + return InputStream.nullInputStream(); + } + + @Override + public boolean isAlive() { + return server.alive; + } + + @Override + public int exitValue() { + if (server.alive) throw new IllegalThreadStateException(); + return 0; + } + + @Override + public int waitFor() throws InterruptedException { + while (server.alive) Thread.sleep(10); + return 0; + } + + @Override + public void destroy() { + server.alive = false; + try { + clientOut.close(); + serverOut.close(); + } + catch (final IOException exc) { + // NB: Closed anyway. + } + } + }; + } + + @Override + public Object initializationOptions(final Environment env) { + final Map<String, Object> workspace = new LinkedHashMap<>(); + env.interpreter().ifPresent(p -> workspace.put("environmentPath", p)); + workspace.put("extraPaths", env.paths()); + return Collections.singletonMap("workspace", workspace); + } + + @Override + public String rootUri(final Environment env) { + return "file:///scripts"; + } + } + + /** A language server knowing a few things about json, and a function f. */ + private static final class FakeServer implements LanguageServer, + TextDocumentService, WorkspaceService + { + + private volatile boolean alive = true, shutdown, noActive, mute; + private volatile org.eclipse.lsp4j.services.LanguageClient client; + private volatile Object options; + private final Map<String, String> texts = new LinkedHashMap<>(); + private final List<String> events = new CopyOnWriteArrayList<>(); + private final List<Position> positions = new CopyOnWriteArrayList<>(); + /** If set, completion requests wait for this (and are kept as held). */ + private volatile CompletableFuture<Void> hold; + private final List<CompletableFuture<Either<List<CompletionItem>, CompletionList>>> held = + new CopyOnWriteArrayList<>(); + + @Override + public CompletableFuture<InitializeResult> initialize( + final InitializeParams params) + { + options = params.getInitializationOptions(); + final ServerCapabilities capabilities = new ServerCapabilities(); + capabilities.setCompletionProvider(new CompletionOptions(true, null)); + return CompletableFuture.completedFuture(new InitializeResult( + capabilities)); + } + + @Override + public CompletableFuture<Object> shutdown() { + shutdown = true; + return CompletableFuture.completedFuture(null); + } + + @Override + public void exit() { + alive = false; + } + + @Override + public TextDocumentService getTextDocumentService() { + return this; + } + + @Override + public WorkspaceService getWorkspaceService() { + return this; + } + + @Override + public CompletableFuture<Either<List<CompletionItem>, CompletionList>> + completion(final CompletionParams params) + { + positions.add(params.getPosition()); + final List<CompletionItem> items = new ArrayList<>(); + items.add(item("decoder", CompletionItemKind.Module, "c")); + items.add(item("dump", CompletionItemKind.Function, "b")); + items.add(item("dumps", CompletionItemKind.Function, "a")); + final Either<List<CompletionItem>, CompletionList> answer = Either + .forRight(new CompletionList(items)); + if (hold == null) return CompletableFuture.completedFuture(answer); + final CompletableFuture<Either<List<CompletionItem>, CompletionList>> f = + new CompletableFuture<>(); + held.add(f); + hold.thenRun(() -> f.complete(answer)); + return f; + } + + @Override + public CompletableFuture<CompletionItem> resolveCompletionItem( + final CompletionItem item) + { + events.add("resolve " + item.getLabel()); + item.setDetail("def " + item.getLabel() + "(obj, indent) -> str"); + item.setDocumentation("Docs of " + item.getLabel() + " & co."); + return CompletableFuture.completedFuture(item); + } + + @Override + public CompletableFuture<SignatureHelp> signatureHelp( + final SignatureHelpParams params) + { + if (params.getPosition().getCharacter() < 2) { + return CompletableFuture.completedFuture(null); + } + final SignatureInformation f = new SignatureInformation("def f(x, y=1)"); + f.setDocumentation("Docs of f"); + f.setParameters(Arrays.asList(new ParameterInformation("x"), + new ParameterInformation("y=1"))); + final SignatureInformation g = new SignatureInformation("def f(z)"); + return CompletableFuture.completedFuture(new SignatureHelp(Arrays.asList( + f, g), 0, noActive ? null : 1)); + } + + @Override + public void didOpen(final DidOpenTextDocumentParams params) { + texts.put(params.getTextDocument().getUri(), params.getTextDocument() + .getText()); + events.add("open " + params.getTextDocument().getUri() + " " + params + .getTextDocument().getVersion()); + publish(params.getTextDocument().getUri(), params.getTextDocument() + .getVersion()); + } + + @Override + public void didChange(final DidChangeTextDocumentParams params) { + texts.put(params.getTextDocument().getUri(), params.getContentChanges() + .get(0).getText()); + events.add("change " + params.getTextDocument().getUri() + " " + params + .getTextDocument().getVersion()); + publish(params.getTextDocument().getUri(), params.getTextDocument() + .getVersion()); + } + + /** Complains about each "!!", and about the first line, always. */ + private void publish(final String uri, final int version) { + if (mute) return; + final List<org.eclipse.lsp4j.Diagnostic> list = new ArrayList<>(); + list.add(diagnostic(0, 0, 1, "First line")); + final String[] lines = texts.get(uri).split("\n", -1); + for (int i = 0; i < lines.length; i++) { + final int bang = lines[i].indexOf("!!"); + if (bang >= 0) list.add(diagnostic(i, bang, bang + 2, "Bangs")); + } + client.publishDiagnostics(new org.eclipse.lsp4j.PublishDiagnosticsParams( + uri, list, version)); + } + + private static org.eclipse.lsp4j.Diagnostic diagnostic(final int line, + final int start, final int end, final String message) + { + final org.eclipse.lsp4j.Diagnostic d = new org.eclipse.lsp4j.Diagnostic( + new org.eclipse.lsp4j.Range(new Position(line, start), new Position( + line, end)), message); + d.setSeverity(org.eclipse.lsp4j.DiagnosticSeverity.Warning); + d.setSource("fake"); + return d; + } + + @Override + public CompletableFuture<org.eclipse.lsp4j.Hover> hover( + final org.eclipse.lsp4j.HoverParams params) + { + final String text = texts.get(params.getTextDocument().getUri()); + final String line = text.split("\n", -1)[params.getPosition() + .getLine()]; + int start = params.getPosition().getCharacter(), end = start; + while (start > 0 && Character.isLetter(line.charAt(start - 1))) start--; + while (end < line.length() && Character.isLetter(line.charAt(end))) end++; + if (start == end) return CompletableFuture.completedFuture(null); + final org.eclipse.lsp4j.Hover h = new org.eclipse.lsp4j.Hover( + new org.eclipse.lsp4j.MarkupContent("plaintext", "Docs of " + line + .substring(start, end)), new org.eclipse.lsp4j.Range(new Position( + params.getPosition().getLine(), start), new Position(params + .getPosition().getLine(), end))); + return CompletableFuture.completedFuture(h); + } + + @Override + public void didClose(final DidCloseTextDocumentParams params) { + events.add("close " + params.getTextDocument().getUri()); + } + + @Override + public void didSave(final DidSaveTextDocumentParams params) {} + + @Override + public void didChangeConfiguration( + final DidChangeConfigurationParams params) + {} + + @Override + public void didChangeWatchedFiles( + final DidChangeWatchedFilesParams params) + {} + + private static CompletionItem item(final String label, + final CompletionItemKind kind, final String sort) + { + final CompletionItem item = new CompletionItem(label); + item.setKind(kind); + item.setInsertText(label); + item.setSortText(sort); + return item; + } + } +}