Returns an empty sequence if nothing is found at the given position.
+ *
+ * @author eXist-db
+ */
+public class Hover extends BasicFunction {
+
+ private static final Logger logger = LogManager.getLogger(Hover.class);
+
+ private static final String FS_HOVER_NAME = "hover";
+ private static final String FS_HOVER_DESCRIPTION = """
+ Returns hover information for the symbol at the given position \
+ in the XQuery expression. Returns a map with keys: contents \
+ (xs:string, signature and documentation) and kind (xs:string, \
+ "function" or "variable"). Returns an empty sequence if no \
+ symbol is found at the position.""";
+
+ public static final FunctionSignature[] FS_HOVER = functionSignatures(
+ LspModule.qname(FS_HOVER_NAME),
+ FS_HOVER_DESCRIPTION,
+ returns(Type.MAP_ITEM, "a hover info map, or empty sequence"),
+ arities(
+ arity(
+ param("expression", Type.STRING, "The XQuery expression."),
+ param("line", Type.INTEGER, "0-based line number."),
+ param("column", Type.INTEGER, "0-based column number.")
+ ),
+ arity(
+ param("expression", Type.STRING, "The XQuery expression."),
+ param("line", Type.INTEGER, "0-based line number."),
+ param("column", Type.INTEGER, "0-based column number."),
+ optParam("module-load-path", Type.STRING, "The module load path.")
+ )
+ )
+ );
+
+ public Hover(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final String expr = args[0].getStringValue();
+ // Convert 0-based LSP position to 1-based parser position
+ final int targetLine = ((IntegerValue) args[1].itemAt(0)).getInt() + 1;
+ final int targetColumn = ((IntegerValue) args[2].itemAt(0)).getInt() + 1;
+
+ if (expr.trim().isEmpty()) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ final XQueryContext pContext = new XQueryContext(context.getBroker().getBrokerPool());
+ try {
+ if (getArgumentCount() == 4 && args[3].hasOne()) {
+ pContext.setModuleLoadPath(args[3].getStringValue());
+ }
+
+ context.pushNamespaceContext();
+ try {
+ final PathExpr path = compile(pContext, expr);
+ if (path == null) {
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ final NodeAtPositionFinder finder = new NodeAtPositionFinder(targetLine, targetColumn);
+ path.accept(finder);
+
+ // Also traverse user-defined function bodies (needed for library
+ // modules where function bodies aren't part of the main PathExpr)
+ final Iterator localFuncs = pContext.localFunctions();
+ while (localFuncs.hasNext()) {
+ localFuncs.next().getFunctionBody().accept(finder);
+ }
+
+ if (finder.foundExpression != null) {
+ return buildHoverResult(finder.foundExpression);
+ }
+ } finally {
+ context.popNamespaceContext();
+ pContext.reset(false);
+ }
+ } catch (final Exception e) {
+ // Gracefully return empty for any position that can't be resolved
+ logger.debug("Error during hover lookup: {}", e.getMessage());
+ } finally {
+ pContext.runCleanupTasks();
+ }
+
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ private PathExpr compile(final XQueryContext pContext, final String expr) {
+ try {
+ final XQueryLexer lexer = new XQueryLexer(pContext, new StringReader(expr));
+ final XQueryParser parser = new XQueryParser(lexer);
+ final XQueryTreeParser astParser = new XQueryTreeParser(pContext);
+
+ parser.xpath();
+ if (parser.foundErrors()) {
+ return null;
+ }
+
+ final AST ast = parser.getAST();
+ final PathExpr path = new PathExpr(pContext);
+ astParser.xpath(ast, path);
+ if (astParser.foundErrors()) {
+ return null;
+ }
+
+ path.analyze(new AnalyzeContextInfo());
+ return path;
+ } catch (final Exception e) {
+ logger.debug("Error compiling expression for hover: {}", e.getMessage());
+ return null;
+ }
+ }
+
+ private Sequence buildHoverResult(final Expression expr) throws XPathException {
+ if (expr instanceof final FunctionCall call) {
+ return buildFunctionHover(call.getSignature(), call.getFunction());
+ } else if (expr instanceof final Function func) {
+ // Built-in function (inner function from InternalFunctionCall)
+ return buildFunctionHover(func.getSignature(), null);
+ } else if (expr instanceof final VariableReference varRef) {
+ return buildVariableHover(varRef);
+ }
+ return Sequence.EMPTY_SEQUENCE;
+ }
+
+ private Sequence buildFunctionHover(final FunctionSignature sig,
+ final UserDefinedFunction udf) throws XPathException {
+ final StringBuilder contents = new StringBuilder();
+ contents.append(sig.toString());
+
+ final String description = sig.getDescription();
+ if (description != null && !description.isEmpty()) {
+ contents.append("\n\n").append(description);
+ }
+
+ final MapType result = new MapType(this, context);
+ result.add(new StringValue(this, "contents"), new StringValue(this, contents.toString()));
+ result.add(new StringValue(this, "kind"), new StringValue(this, "function"));
+ return result;
+ }
+
+ private Sequence buildVariableHover(final VariableReference varRef) throws XPathException {
+ final org.exist.dom.QName name = varRef.getName();
+ final String prefix = name.getPrefix();
+ final String varName = (prefix != null && !prefix.isEmpty())
+ ? "$" + prefix + ":" + name.getLocalPart()
+ : "$" + name.getLocalPart();
+
+ final MapType result = new MapType(this, context);
+ result.add(new StringValue(this, "contents"), new StringValue(this, varName));
+ result.add(new StringValue(this, "kind"), new StringValue(this, "variable"));
+ return result;
+ }
+
+ /**
+ * Visitor that traverses the full expression tree (including FLWOR clauses)
+ * to find the best-matching FunctionCall, InternalFunctionCall, or
+ * VariableReference at the given position.
+ *
+ * Uses {@link DefaultExpressionVisitor} for traversal since it knows
+ * how to enter FLWOR expressions, which don't expose children via
+ * {@code getSubExpressionCount()}.
+ */
+ static class NodeAtPositionFinder extends DefaultExpressionVisitor {
+ private final int targetLine;
+ private final int targetColumn;
+ Expression foundExpression;
+ private int bestColumn = -1;
+
+ NodeAtPositionFinder(final int targetLine, final int targetColumn) {
+ this.targetLine = targetLine;
+ this.targetColumn = targetColumn;
+ }
+
+ @Override
+ public void visitFunctionCall(final FunctionCall call) {
+ checkExpression(call);
+ super.visitFunctionCall(call);
+ }
+
+ @Override
+ public void visitBuiltinFunction(final Function function) {
+ // The InternalFunctionCall wrapper delegates accept() to the inner
+ // function, so we receive the inner BasicFunction here. Check position
+ // on the inner function (which inherits position from the wrapper
+ // via the compilation process).
+ checkExpression(function);
+ super.visitBuiltinFunction(function);
+ }
+
+ @Override
+ public void visitVariableReference(final VariableReference ref) {
+ checkExpression(ref);
+ }
+
+ @Override
+ public void visit(final Expression expression) {
+ // Traverse children for generic expressions
+ for (int i = 0; i < expression.getSubExpressionCount(); i++) {
+ expression.getSubExpression(i).accept(this);
+ }
+ }
+
+ private void checkExpression(final Expression expr) {
+ final int line = expr.getLine();
+ final int column = expr.getColumn();
+
+ if (line == targetLine && column <= targetColumn && column > bestColumn) {
+ foundExpression = expr;
+ bestColumn = column;
+ }
+ }
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/lsp/LspModule.java b/exist-core/src/main/java/org/exist/xquery/functions/lsp/LspModule.java
new file mode 100644
index 00000000000..7cab16bf870
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/LspModule.java
@@ -0,0 +1,92 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.lsp;
+
+import org.exist.dom.QName;
+import org.exist.xquery.AbstractInternalModule;
+import org.exist.xquery.FunctionDef;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.exist.xquery.FunctionDSL.functionDefs;
+
+/**
+ * XQuery function module providing Language Server Protocol support.
+ *
+ * This module exposes eXist-db's XQuery compiler internals as XQuery functions,
+ * enabling LSP servers to provide diagnostics, symbol information, and other
+ * language intelligence features.
+ *
+ * @author eXist-db
+ */
+public class LspModule extends AbstractInternalModule {
+
+ public static final String NAMESPACE_URI = "http://exist-db.org/xquery/lsp";
+
+ public static final String PREFIX = "lsp";
+
+ public static final String RELEASE = "7.0.0";
+
+ public static final FunctionDef[] functions = functionDefs(
+ functionDefs(Completions.class,
+ Completions.FS_COMPLETIONS),
+ functionDefs(Definition.class,
+ Definition.FS_DEFINITION),
+ functionDefs(Diagnostics.class,
+ Diagnostics.FS_DIAGNOSTICS),
+ functionDefs(Hover.class,
+ Hover.FS_HOVER),
+ functionDefs(References.class,
+ References.FS_REFERENCES),
+ functionDefs(Symbols.class,
+ Symbols.FS_SYMBOLS)
+ );
+
+ public LspModule(final Map> parameters) {
+ super(functions, parameters, true);
+ }
+
+ @Override
+ public String getNamespaceURI() {
+ return NAMESPACE_URI;
+ }
+
+ @Override
+ public String getDefaultPrefix() {
+ return PREFIX;
+ }
+
+ @Override
+ public String getDescription() {
+ return "Functions for Language Server Protocol support, exposing compiler diagnostics and symbol information";
+ }
+
+ @Override
+ public String getReleaseVersion() {
+ return RELEASE;
+ }
+
+ static QName qname(final String localPart) {
+ return new QName(localPart, NAMESPACE_URI, PREFIX);
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/lsp/References.java b/exist-core/src/main/java/org/exist/xquery/functions/lsp/References.java
new file mode 100644
index 00000000000..d995e0211b8
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/References.java
@@ -0,0 +1,340 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.lsp;
+
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.exist.dom.QName;
+import org.exist.xquery.AnalyzeContextInfo;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.DefaultExpressionVisitor;
+import org.exist.xquery.Expression;
+import org.exist.xquery.FunctionCall;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.PathExpr;
+import org.exist.xquery.UserDefinedFunction;
+import org.exist.xquery.VariableDeclaration;
+import org.exist.xquery.VariableReference;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.functions.map.MapType;
+import org.exist.xquery.parser.XQueryLexer;
+import org.exist.xquery.parser.XQueryParser;
+import org.exist.xquery.parser.XQueryTreeParser;
+import org.exist.xquery.value.IntegerValue;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.StringValue;
+import org.exist.xquery.value.Type;
+import org.exist.xquery.value.ValueSequence;
+import org.exist.xquery.functions.array.ArrayType;
+
+import antlr.collections.AST;
+
+import static org.exist.xquery.FunctionDSL.*;
+
+/**
+ * Finds all references to the symbol at a given position in an XQuery
+ * expression, suitable for Language Server Protocol
+ * {@code textDocument/references} responses.
+ *
+ * Returns an array of maps, each with:
+ *
+ * - {@code line} — 0-based line of the reference
+ * - {@code column} — 0-based column of the reference
+ * - {@code name} — name of the referenced symbol
+ * - {@code kind} — "function" or "variable"
+ *
+ *
+ * Includes the definition itself if {@code includeDeclaration} is true.
+ *
+ * @author eXist-db
+ */
+public class References extends BasicFunction {
+
+ private static final Logger logger = LogManager.getLogger(References.class);
+
+ private static final String FS_REFERENCES_NAME = "references";
+ private static final String FS_REFERENCES_DESCRIPTION = """
+ Finds all references to the symbol at the given position. \
+ Returns an array of maps with keys: line (xs:integer, 0-based), \
+ column (xs:integer, 0-based), name (xs:string), and kind \
+ (xs:string, "function" or "variable"). Returns an empty \
+ array if no symbol is found at the position.""";
+
+ public static final FunctionSignature[] FS_REFERENCES = functionSignatures(
+ LspModule.qname(FS_REFERENCES_NAME),
+ FS_REFERENCES_DESCRIPTION,
+ returns(Type.ARRAY_ITEM, "an array of reference location maps"),
+ arities(
+ arity(
+ param("expression", Type.STRING, "The XQuery expression."),
+ param("line", Type.INTEGER, "0-based line number."),
+ param("column", Type.INTEGER, "0-based column number.")
+ ),
+ arity(
+ param("expression", Type.STRING, "The XQuery expression."),
+ param("line", Type.INTEGER, "0-based line number."),
+ param("column", Type.INTEGER, "0-based column number."),
+ optParam("module-load-path", Type.STRING, "The module load path.")
+ )
+ )
+ );
+
+ public References(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final String expr = args[0].getStringValue();
+ final int targetLine = ((IntegerValue) args[1].itemAt(0)).getInt() + 1;
+ final int targetColumn = ((IntegerValue) args[2].itemAt(0)).getInt() + 1;
+
+ if (expr.trim().isEmpty()) {
+ return new ArrayType(this, context, new ArrayList<>());
+ }
+
+ final XQueryContext pContext = new XQueryContext(context.getBroker().getBrokerPool());
+ try {
+ if (getArgumentCount() == 4 && args[3].hasOne()) {
+ pContext.setModuleLoadPath(args[3].getStringValue());
+ }
+
+ context.pushNamespaceContext();
+ try {
+ final PathExpr path = compile(pContext, expr);
+ if (path == null) {
+ return new ArrayType(this, context, new ArrayList<>());
+ }
+
+ // Step 1: Find the symbol at the cursor position
+ final Hover.NodeAtPositionFinder finder =
+ new Hover.NodeAtPositionFinder(targetLine, targetColumn);
+ path.accept(finder);
+ final Iterator localFuncs = pContext.localFunctions();
+ while (localFuncs.hasNext()) {
+ localFuncs.next().getFunctionBody().accept(finder);
+ }
+
+ final Expression found = finder.foundExpression;
+ if (found == null) {
+ return new ArrayType(this, context, new ArrayList<>());
+ }
+
+ // Step 2: Determine what we're looking for
+ if (found instanceof final FunctionCall call) {
+ return findFunctionReferences(call, path, pContext);
+ } else if (found instanceof final VariableReference varRef) {
+ return findVariableReferences(varRef, path, pContext);
+ }
+ } finally {
+ context.popNamespaceContext();
+ pContext.reset(false);
+ }
+ } finally {
+ pContext.runCleanupTasks();
+ }
+
+ return new ValueSequence();
+ }
+
+ private PathExpr compile(final XQueryContext pContext, final String expr) {
+ try {
+ final XQueryLexer lexer = new XQueryLexer(pContext, new StringReader(expr));
+ final XQueryParser parser = new XQueryParser(lexer);
+ final XQueryTreeParser astParser = new XQueryTreeParser(pContext);
+
+ parser.xpath();
+ if (parser.foundErrors()) {
+ return null;
+ }
+
+ final AST ast = parser.getAST();
+ final PathExpr path = new PathExpr(pContext);
+ astParser.xpath(ast, path);
+ if (astParser.foundErrors()) {
+ return null;
+ }
+
+ path.analyze(new AnalyzeContextInfo());
+ return path;
+ } catch (final Exception e) {
+ logger.debug("Error compiling expression for references: {}", e.getMessage());
+ return null;
+ }
+ }
+
+ private Sequence findFunctionReferences(final FunctionCall call, final PathExpr path,
+ final XQueryContext pContext) throws XPathException {
+ final UserDefinedFunction targetFunc = call.getFunction();
+ if (targetFunc == null) {
+ return new ArrayType(this, context, new ArrayList<>());
+ }
+
+ final QName targetName = targetFunc.getSignature().getName();
+ final int targetArity = targetFunc.getSignature().getArgumentCount();
+
+ // Collect all calls to the same function
+ final FunctionReferenceCollector collector =
+ new FunctionReferenceCollector(targetName, targetArity);
+ path.accept(collector);
+ final Iterator localFuncs = pContext.localFunctions();
+ while (localFuncs.hasNext()) {
+ final UserDefinedFunction udf = localFuncs.next();
+ udf.getFunctionBody().accept(collector);
+ // Include the declaration itself
+ if (targetName.equals(udf.getSignature().getName())
+ && targetArity == udf.getSignature().getArgumentCount()) {
+ final int line = udf.getLine();
+ if (line > 0) {
+ collector.locations.add(new RefLocation(
+ line - 1, Math.max(udf.getColumn() - 1, 0),
+ formatQName(targetName) + "#" + targetArity, "function"));
+ }
+ }
+ }
+
+ return buildResult(collector.locations);
+ }
+
+ private Sequence findVariableReferences(final VariableReference varRef, final PathExpr path,
+ final XQueryContext pContext) throws XPathException {
+ final QName targetName = varRef.getName();
+
+ // Collect all references to the same variable
+ final VariableReferenceCollector collector = new VariableReferenceCollector(targetName);
+ path.accept(collector);
+ final Iterator localFuncs = pContext.localFunctions();
+ while (localFuncs.hasNext()) {
+ localFuncs.next().getFunctionBody().accept(collector);
+ }
+
+ // Include the declaration
+ for (int i = 0; i < path.getSubExpressionCount(); i++) {
+ final Expression step = path.getSubExpression(i);
+ if (step instanceof final VariableDeclaration varDecl) {
+ if (targetName.equals(varDecl.getName()) && varDecl.getLine() > 0) {
+ collector.locations.add(new RefLocation(
+ varDecl.getLine() - 1, Math.max(varDecl.getColumn() - 1, 0),
+ "$" + formatQName(targetName), "variable"));
+ }
+ }
+ }
+
+ return buildResult(collector.locations);
+ }
+
+ private Sequence buildResult(final List locations) throws XPathException {
+ final List items = new ArrayList<>();
+ for (final RefLocation loc : locations) {
+ final MapType map = new MapType(this, context);
+ map.add(new StringValue(this, "line"), new IntegerValue(this, loc.line));
+ map.add(new StringValue(this, "column"), new IntegerValue(this, loc.column));
+ map.add(new StringValue(this, "name"), new StringValue(this, loc.name));
+ map.add(new StringValue(this, "kind"), new StringValue(this, loc.kind));
+ items.add(map);
+ }
+ return new ArrayType(this, context, items);
+ }
+
+ private static String formatQName(final QName name) {
+ final String prefix = name.getPrefix();
+ if (prefix != null && !prefix.isEmpty()) {
+ return prefix + ":" + name.getLocalPart();
+ }
+ return name.getLocalPart();
+ }
+
+ // --- Helper classes ---
+
+ private record RefLocation(int line, int column, String name, String kind) {}
+
+ /**
+ * Collects all FunctionCall nodes that call the target function.
+ */
+ private static class FunctionReferenceCollector extends DefaultExpressionVisitor {
+ private final QName targetName;
+ private final int targetArity;
+ final List locations = new ArrayList<>();
+
+ FunctionReferenceCollector(final QName targetName, final int targetArity) {
+ this.targetName = targetName;
+ this.targetArity = targetArity;
+ }
+
+ @Override
+ public void visitFunctionCall(final FunctionCall call) {
+ final UserDefinedFunction func = call.getFunction();
+ if (func != null) {
+ final QName name = func.getSignature().getName();
+ if (targetName.equals(name)
+ && targetArity == func.getSignature().getArgumentCount()
+ && call.getLine() > 0) {
+ locations.add(new RefLocation(
+ call.getLine() - 1, Math.max(call.getColumn() - 1, 0),
+ formatQName(targetName) + "#" + targetArity, "function"));
+ }
+ }
+ super.visitFunctionCall(call);
+ }
+
+ @Override
+ public void visit(final Expression expression) {
+ for (int i = 0; i < expression.getSubExpressionCount(); i++) {
+ expression.getSubExpression(i).accept(this);
+ }
+ }
+ }
+
+ /**
+ * Collects all VariableReference nodes that refer to the target variable.
+ */
+ private static class VariableReferenceCollector extends DefaultExpressionVisitor {
+ private final QName targetName;
+ final List locations = new ArrayList<>();
+
+ VariableReferenceCollector(final QName targetName) {
+ this.targetName = targetName;
+ }
+
+ @Override
+ public void visitVariableReference(final VariableReference ref) {
+ if (targetName.equals(ref.getName()) && ref.getLine() > 0) {
+ locations.add(new RefLocation(
+ ref.getLine() - 1, Math.max(ref.getColumn() - 1, 0),
+ "$" + formatQName(targetName), "variable"));
+ }
+ }
+
+ @Override
+ public void visit(final Expression expression) {
+ for (int i = 0; i < expression.getSubExpressionCount(); i++) {
+ expression.getSubExpression(i).accept(this);
+ }
+ }
+ }
+}
diff --git a/exist-core/src/main/java/org/exist/xquery/functions/lsp/Symbols.java b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Symbols.java
new file mode 100644
index 00000000000..47007fbf876
--- /dev/null
+++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Symbols.java
@@ -0,0 +1,272 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.lsp;
+
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.exist.xquery.AnalyzeContextInfo;
+import org.exist.xquery.BasicFunction;
+import org.exist.xquery.Expression;
+import org.exist.xquery.FunctionSignature;
+import org.exist.xquery.PathExpr;
+import org.exist.xquery.UserDefinedFunction;
+import org.exist.xquery.VariableDeclaration;
+import org.exist.xquery.XPathException;
+import org.exist.xquery.XQueryContext;
+import org.exist.xquery.functions.array.ArrayType;
+import org.exist.xquery.functions.map.MapType;
+import org.exist.xquery.parser.XQueryLexer;
+import org.exist.xquery.parser.XQueryParser;
+import org.exist.xquery.parser.XQueryTreeParser;
+import org.exist.xquery.value.IntegerValue;
+import org.exist.xquery.value.Sequence;
+import org.exist.xquery.value.SequenceType;
+import org.exist.xquery.value.StringValue;
+import org.exist.xquery.value.Type;
+
+import antlr.collections.AST;
+
+import static org.exist.xquery.FunctionDSL.*;
+
+/**
+ * Compiles an XQuery expression and returns document symbols suitable for
+ * Language Server Protocol {@code textDocument/documentSymbol} responses.
+ *
+ * Returns an array of maps, where each map represents a symbol with the
+ * following keys:
+ *
+ * - {@code name} — symbol name (e.g., "my:function#2", "$my:variable")
+ * - {@code kind} — LSP SymbolKind integer (12=Function, 13=Variable)
+ * - {@code line} — 0-based start line
+ * - {@code column} — 0-based start column
+ * - {@code detail} — additional info (return type, variable type, arity)
+ *
+ *
+ * Returns an empty array if the expression cannot be compiled.
+ *
+ * @author eXist-db
+ */
+public class Symbols extends BasicFunction {
+
+ private static final Logger logger = LogManager.getLogger(Symbols.class);
+
+ /** LSP SymbolKind constants */
+ private static final long SYMBOL_KIND_FUNCTION = 12;
+ private static final long SYMBOL_KIND_VARIABLE = 13;
+
+ private static final String FS_SYMBOLS_NAME = "symbols";
+ private static final String FS_SYMBOLS_DESCRIPTION = """
+ Compiles the XQuery expression and returns an array of document symbol maps. \
+ Each map contains keys: name (xs:string), kind (xs:integer, LSP SymbolKind), \
+ line (xs:integer, 0-based), column (xs:integer, 0-based), and \
+ detail (xs:string, type or signature info). \
+ Returns an empty array if the expression cannot be compiled.""";
+
+ public static final FunctionSignature[] FS_SYMBOLS = functionSignatures(
+ LspModule.qname(FS_SYMBOLS_NAME),
+ FS_SYMBOLS_DESCRIPTION,
+ returns(Type.ARRAY_ITEM, "an array of document symbol maps"),
+ arities(
+ arity(
+ param("expression", Type.STRING, "The XQuery expression to analyze.")
+ ),
+ arity(
+ param("expression", Type.STRING, "The XQuery expression to analyze."),
+ optParam("module-load-path", Type.STRING, "The module load path. " +
+ "Imports will be resolved relative to this. " +
+ "Use xmldb:exist:///db for database-stored modules.")
+ )
+ )
+ );
+
+ public Symbols(final XQueryContext context, final FunctionSignature signature) {
+ super(context, signature);
+ }
+
+ @Override
+ public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException {
+ final String expr = args[0].getStringValue();
+ if (expr.trim().isEmpty()) {
+ return new ArrayType(this, context, new ArrayList<>());
+ }
+
+ final List symbols = new ArrayList<>();
+
+ final XQueryContext pContext = new XQueryContext(context.getBroker().getBrokerPool());
+ try {
+ if (getArgumentCount() == 2 && args[1].hasOne()) {
+ pContext.setModuleLoadPath(args[1].getStringValue());
+ }
+
+ context.pushNamespaceContext();
+ try {
+ final PathExpr path = compile(pContext, expr);
+ if (path != null) {
+ extractFunctions(pContext, symbols);
+ extractVariables(path, symbols);
+ }
+ } finally {
+ context.popNamespaceContext();
+ pContext.reset(false);
+ }
+ } finally {
+ pContext.runCleanupTasks();
+ }
+
+ return new ArrayType(this, context, symbols);
+ }
+
+ /**
+ * Compiles the expression and returns the PathExpr, or null on error.
+ */
+ private PathExpr compile(final XQueryContext pContext, final String expr) {
+ try {
+ final XQueryLexer lexer = new XQueryLexer(pContext, new StringReader(expr));
+ final XQueryParser parser = new XQueryParser(lexer);
+ final XQueryTreeParser astParser = new XQueryTreeParser(pContext);
+
+ parser.xpath();
+ if (parser.foundErrors()) {
+ return null;
+ }
+
+ final AST ast = parser.getAST();
+ final PathExpr path = new PathExpr(pContext);
+ astParser.xpath(ast, path);
+ if (astParser.foundErrors()) {
+ return null;
+ }
+
+ path.analyze(new AnalyzeContextInfo());
+ return path;
+
+ } catch (final Exception e) {
+ logger.debug("Error compiling expression for symbol extraction: {}", e.getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * Extracts user-defined function declarations from the compilation context.
+ */
+ private void extractFunctions(final XQueryContext pContext, final List symbols)
+ throws XPathException {
+ final Iterator funcs = pContext.localFunctions();
+ while (funcs.hasNext()) {
+ final UserDefinedFunction func = funcs.next();
+ final FunctionSignature sig = func.getSignature();
+ final org.exist.dom.QName name = sig.getName();
+
+ // Build display name: prefix:local#arity
+ final String displayName = formatFunctionName(name, sig.getArgumentCount());
+
+ final String detail = sig.toString();
+
+ int line = func.getLine();
+ if (line <= 0 && func.getFunctionBody() != null) {
+ line = firstPositiveLineIn(func.getFunctionBody());
+ }
+ final int column = func.getColumn();
+
+ addSymbol(symbols, displayName, SYMBOL_KIND_FUNCTION,
+ Math.max(line - 1, 0), Math.max(column - 1, 0), detail);
+ }
+ }
+
+ /**
+ * Extracts global variable declarations from the compiled PathExpr.
+ */
+ private void extractVariables(final PathExpr path, final List symbols)
+ throws XPathException {
+ for (int i = 0; i < path.getSubExpressionCount(); i++) {
+ final Expression step = path.getSubExpression(i);
+ if (step instanceof final VariableDeclaration varDecl) {
+ final org.exist.dom.QName name = varDecl.getName();
+ final String displayName = "$" + formatQName(name);
+
+ final SequenceType seqType = varDecl.getSequenceType();
+ final String detail = seqType != null
+ ? Type.getTypeName(seqType.getPrimaryType()) + seqType.getCardinality().toXQueryCardinalityString()
+ : "";
+
+ final int line = varDecl.getLine();
+ final int column = varDecl.getColumn();
+
+ addSymbol(symbols, displayName, SYMBOL_KIND_VARIABLE,
+ Math.max(line - 1, 0), Math.max(column - 1, 0), detail);
+ }
+ }
+ }
+
+ /**
+ * Creates a symbol map and adds it to the list.
+ */
+ private void addSymbol(final List symbols, final String name, final long kind,
+ final int line, final int column, final String detail) throws XPathException {
+ final MapType symbol = new MapType(this, context);
+ symbol.add(new StringValue(this, "name"), new StringValue(this, name));
+ symbol.add(new StringValue(this, "kind"), new IntegerValue(this, kind));
+ symbol.add(new StringValue(this, "line"), new IntegerValue(this, line));
+ symbol.add(new StringValue(this, "column"), new IntegerValue(this, column));
+ symbol.add(new StringValue(this, "detail"), new StringValue(this, detail));
+ symbols.add(symbol);
+ }
+
+ private static String formatFunctionName(final org.exist.dom.QName name, final int arity) {
+ return formatQName(name) + "#" + arity;
+ }
+
+ private static String formatQName(final org.exist.dom.QName name) {
+ final String prefix = name.getPrefix();
+ if (prefix != null && !prefix.isEmpty()) {
+ return prefix + ":" + name.getLocalPart();
+ }
+ return name.getLocalPart();
+ }
+
+ /**
+ * Depth-first search for the first positive line number in an expression tree.
+ * Used as fallback when the function's own line is not set.
+ */
+ private static int firstPositiveLineIn(final Expression expr) {
+ if (expr == null) {
+ return -1;
+ }
+ final int line = expr.getLine();
+ if (line > 0) {
+ return line;
+ }
+ final int count = expr.getSubExpressionCount();
+ for (int i = 0; i < count; i++) {
+ final int sub = firstPositiveLineIn(expr.getSubExpression(i));
+ if (sub > 0) {
+ return sub;
+ }
+ }
+ return -1;
+ }
+}
diff --git a/exist-core/src/test/java/org/exist/xquery/functions/lsp/CompletionsTest.java b/exist-core/src/test/java/org/exist/xquery/functions/lsp/CompletionsTest.java
new file mode 100644
index 00000000000..834e37ed6e0
--- /dev/null
+++ b/exist-core/src/test/java/org/exist/xquery/functions/lsp/CompletionsTest.java
@@ -0,0 +1,244 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.lsp;
+
+import org.exist.test.ExistXmldbEmbeddedServer;
+import org.junit.ClassRule;
+import org.junit.Test;
+import org.xmldb.api.base.ResourceSet;
+import org.xmldb.api.base.XMLDBException;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Tests for the lsp:completions() function.
+ */
+public class CompletionsTest {
+
+ @ClassRule
+ public static final ExistXmldbEmbeddedServer existEmbeddedServer =
+ new ExistXmldbEmbeddedServer(false, true, true);
+
+ private static final String LSP_IMPORT =
+ "import module namespace lsp = 'http://exist-db.org/xquery/lsp';\n";
+
+ @Test
+ public void returnsNonEmptyArrayForEmptyExpression() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT + "array:size(lsp:completions('')) > 0");
+ assertEquals("Should return built-in functions even for empty expression",
+ "true", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void includesBuiltinFnCount() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $completions := lsp:completions('')\n" +
+ "return array:size(array:filter($completions, function($c) { $c?label = 'fn:count#1' }))");
+ assertEquals("Should include fn:count#1",
+ "1", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void includesBuiltinFnStringJoin() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $completions := lsp:completions('')\n" +
+ "return array:size(array:filter($completions, function($c) { $c?label = 'fn:string-join#2' }))");
+ assertEquals("Should include fn:string-join#2",
+ "1", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void builtinFunctionHasCorrectKind() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $completions := lsp:completions('')\n" +
+ "let $fnCount := array:filter($completions, function($c) { $c?label = 'fn:count#1' })\n" +
+ "return $fnCount(1)?kind");
+ assertEquals("Function CompletionItemKind = 3",
+ "3", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void builtinFunctionHasDetail() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $completions := lsp:completions('')\n" +
+ "let $fnCount := array:filter($completions, function($c) { $c?label = 'fn:count#1' })\n" +
+ "return string-length($fnCount(1)?detail) > 0");
+ assertEquals("Function should have non-empty detail",
+ "true", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void builtinFunctionHasInsertText() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $completions := lsp:completions('')\n" +
+ "let $fnCount := array:filter($completions, function($c) { $c?label = 'fn:count#1' })\n" +
+ "return $fnCount(1)?insertText");
+ assertEquals("Insert text should be fn:count()",
+ "fn:count()", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void builtinFunctionHasDocumentation() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $completions := lsp:completions('')\n" +
+ "let $fnCount := array:filter($completions, function($c) { $c?label = 'fn:count#1' })\n" +
+ "return string-length($fnCount(1)?documentation) > 0");
+ assertEquals("Built-in function should have documentation",
+ "true", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void includesKeywords() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $completions := lsp:completions('')\n" +
+ "return array:size(array:filter($completions, function($c) { $c?label = 'return' }))");
+ assertEquals("Should include 'return' keyword",
+ "1", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void keywordHasCorrectKind() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $completions := lsp:completions('')\n" +
+ "let $kw := array:filter($completions, function($c) { $c?label = 'let' })\n" +
+ "return $kw(1)?kind");
+ assertEquals("Keyword CompletionItemKind = 14",
+ "14", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void includesUserDeclaredFunction() throws XMLDBException {
+ final String xquery =
+ "declare function local:greet($name as xs:string) as xs:string { " +
+ " ''Hello, '' || $name " +
+ "}; local:greet(''world'')";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $completions := lsp:completions('" + xquery + "')\n" +
+ "return array:size(array:filter($completions, function($c) { $c?label = 'local:greet#1' }))");
+ assertEquals("Should include user-declared local:greet#1",
+ "1", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void includesUserDeclaredVariable() throws XMLDBException {
+ final String xquery =
+ "declare variable $local:greeting := ''hello''; $local:greeting";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $completions := lsp:completions('" + xquery + "')\n" +
+ "return array:size(array:filter($completions, function($c) { $c?label = '$local:greeting' }))");
+ assertEquals("Should include user-declared $local:greeting",
+ "1", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void userVariableHasCorrectKind() throws XMLDBException {
+ final String xquery =
+ "declare variable $local:x := 42; $local:x";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $completions := lsp:completions('" + xquery + "')\n" +
+ "let $var := array:filter($completions, function($c) { $c?label = '$local:x' })\n" +
+ "return $var(1)?kind");
+ assertEquals("Variable CompletionItemKind = 6",
+ "6", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void invalidExpressionStillReturnsBuiltins() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $completions := lsp:completions('let $x :=')\n" +
+ "return array:size(array:filter($completions, function($c) { $c?label = 'fn:count#1' }))");
+ assertEquals("Should include fn:count#1 even with invalid expression",
+ "1", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void includesMapModuleFunctions() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $completions := lsp:completions('')\n" +
+ "return array:size(array:filter($completions, function($c) { starts-with($c?label, 'map:') })) > 0");
+ assertEquals("Should include map: module functions",
+ "true", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void includesArrayModuleFunctions() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $completions := lsp:completions('')\n" +
+ "return array:size(array:filter($completions, function($c) { starts-with($c?label, 'array:') })) > 0");
+ assertEquals("Should include array: module functions",
+ "true", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void withModuleLoadPath() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT + "array:size(lsp:completions('', '/db')) > 0");
+ assertEquals("Should return completions with /db load path",
+ "true", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void excludesPrivateFunctions() throws XMLDBException {
+ // Private functions from built-in modules should not appear
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $completions := lsp:completions('')\n" +
+ "let $private := array:filter($completions, function($c) { " +
+ " $c?kind = 3 and contains($c?detail, 'private') })\n" +
+ "return array:size($private)");
+ assertEquals("Should not include private functions",
+ "0", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void completionItemHasAllKeys() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $completions := lsp:completions('')\n" +
+ "let $first := $completions(1)\n" +
+ "return string-join((\n" +
+ " 'label=' || map:contains($first, 'label'),\n" +
+ " 'kind=' || map:contains($first, 'kind'),\n" +
+ " 'detail=' || map:contains($first, 'detail'),\n" +
+ " 'documentation=' || map:contains($first, 'documentation'),\n" +
+ " 'insertText=' || map:contains($first, 'insertText')\n" +
+ "), ',')");
+ assertEquals("label=true,kind=true,detail=true,documentation=true,insertText=true",
+ result.getResource(0).getContent().toString());
+ }
+}
diff --git a/exist-core/src/test/java/org/exist/xquery/functions/lsp/DefinitionTest.java b/exist-core/src/test/java/org/exist/xquery/functions/lsp/DefinitionTest.java
new file mode 100644
index 00000000000..18560cac687
--- /dev/null
+++ b/exist-core/src/test/java/org/exist/xquery/functions/lsp/DefinitionTest.java
@@ -0,0 +1,227 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.lsp;
+
+import org.exist.test.ExistXmldbEmbeddedServer;
+import org.exist.xmldb.EXistResource;
+import org.junit.BeforeClass;
+import org.junit.ClassRule;
+import org.junit.Test;
+import org.xmldb.api.base.Collection;
+import org.xmldb.api.base.ResourceSet;
+import org.xmldb.api.base.XMLDBException;
+import org.xmldb.api.modules.BinaryResource;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Tests for the lsp:definition() function.
+ */
+public class DefinitionTest {
+
+ @ClassRule
+ public static final ExistXmldbEmbeddedServer existEmbeddedServer =
+ new ExistXmldbEmbeddedServer(false, true, true);
+
+ private static final String LSP_IMPORT =
+ "import module namespace lsp = 'http://exist-db.org/xquery/lsp';\n";
+
+ private static final String LIB_MODULE =
+ "xquery version '3.1';\n" +
+ "module namespace utils = 'http://example.com/utils';\n" +
+ "\n" +
+ "declare function utils:hello($name as xs:string) as xs:string {\n" +
+ " 'Hello ' || $name\n" +
+ "};\n";
+
+ @BeforeClass
+ public static void storeLibraryModule() throws XMLDBException {
+ final Collection root = existEmbeddedServer.getRoot();
+ final BinaryResource resource = root.createResource("utils.xqm", BinaryResource.class);
+ resource.setContent(LIB_MODULE);
+ ((EXistResource) resource).setMimeType("application/xquery");
+ root.storeResource(resource);
+ }
+
+ @Test
+ public void emptyExpressionReturnsEmpty() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT + "empty(lsp:definition('', 0, 0))");
+ assertEquals("true", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void invalidExpressionReturnsEmpty() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT + "empty(lsp:definition('let $x :=', 0, 5))");
+ assertEquals("true", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void functionCallGoesToDefinition() throws XMLDBException {
+ // Line 0: declare function local:greet() { 42 };
+ // Line 1: local:greet()
+ final String xquery =
+ "declare function local:greet() { 42 };
local:greet()";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $def := lsp:definition('" + xquery + "', 1, 5)\n" +
+ "return $def?kind");
+ assertEquals("function", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void functionDefinitionHasName() throws XMLDBException {
+ final String xquery =
+ "declare function local:greet() { 42 };
local:greet()";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $def := lsp:definition('" + xquery + "', 1, 5)\n" +
+ "return $def?name");
+ assertEquals("local:greet#0", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void functionDefinitionPointsToDeclaration() throws XMLDBException {
+ // The declaration is on line 0
+ final String xquery =
+ "declare function local:greet() { 42 };
local:greet()";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $def := lsp:definition('" + xquery + "', 1, 5)\n" +
+ "return $def?line");
+ assertEquals("0", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void variableReferenceGoesToDeclaration() throws XMLDBException {
+ // Line 0: declare variable $local:x := 42;
+ // Line 1: $local:x
+ final String xquery =
+ "declare variable $local:x := 42;
$local:x";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $def := lsp:definition('" + xquery + "', 1, 3)\n" +
+ "return $def?kind");
+ assertEquals("variable", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void variableDefinitionHasName() throws XMLDBException {
+ final String xquery =
+ "declare variable $local:x := 42;
$local:x";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $def := lsp:definition('" + xquery + "', 1, 3)\n" +
+ "return $def?name");
+ assertEquals("$local:x", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void variableDefinitionPointsToDeclaration() throws XMLDBException {
+ final String xquery =
+ "declare variable $local:x := 42;
$local:x";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $def := lsp:definition('" + xquery + "', 1, 3)\n" +
+ "return $def?line");
+ assertEquals("0", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void builtinFunctionReturnsEmpty() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT + "empty(lsp:definition('fn:count((1,2,3))', 0, 3))");
+ assertEquals("true", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void noSymbolAtPositionReturnsEmpty() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT + "empty(lsp:definition('1 + 2', 0, 2))");
+ assertEquals("true", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void withModuleLoadPath() throws XMLDBException {
+ final String xquery =
+ "declare function local:foo() { 42 };
local:foo()";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $def := lsp:definition('" + xquery + "', 1, 5, '/db')\n" +
+ "return $def?kind");
+ assertEquals("function", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void localDefinitionHasNoUri() throws XMLDBException {
+ final String xquery =
+ "declare function local:greet() { 42 };
local:greet()";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $def := lsp:definition('" + xquery + "', 1, 5)\n" +
+ "return empty($def?uri)");
+ assertEquals("true", result.getResource(0).getContent().toString());
+ }
+
+ /**
+ * Inner query that imports utils.xqm and calls utils:hello.
+ * Uses double quotes inside so it can be wrapped in single quotes
+ * in the outer XQuery: lsp:definition('...this...', line, col).
+ */
+ private static final String CROSS_MODULE_QUERY =
+ "import module namespace utils = \"http://example.com/utils\" " +
+ "at \"xmldb:exist:///db/utils.xqm\";
" +
+ "utils:hello(\"world\")";
+
+ @Test
+ public void crossModuleDefinitionHasUri() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $def := lsp:definition('" + CROSS_MODULE_QUERY + "', 1, 5, '/db')\n" +
+ "return $def?uri");
+ final String uri = result.getResource(0).getContent().toString();
+ assertNotNull("cross-module definition should include uri", uri);
+ assertTrue("uri should point to utils.xqm, got: " + uri,
+ uri.contains("utils.xqm"));
+ }
+
+ @Test
+ public void crossModuleDefinitionHasKind() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $def := lsp:definition('" + CROSS_MODULE_QUERY + "', 1, 5, '/db')\n" +
+ "return $def?kind");
+ assertEquals("function", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void crossModuleDefinitionHasLineAndColumn() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $def := lsp:definition('" + CROSS_MODULE_QUERY + "', 1, 5, '/db')\n" +
+ "return $def?line >= 0");
+ assertEquals("true", result.getResource(0).getContent().toString());
+ }
+}
diff --git a/exist-core/src/test/java/org/exist/xquery/functions/lsp/DiagnosticsTest.java b/exist-core/src/test/java/org/exist/xquery/functions/lsp/DiagnosticsTest.java
new file mode 100644
index 00000000000..b32cb40def9
--- /dev/null
+++ b/exist-core/src/test/java/org/exist/xquery/functions/lsp/DiagnosticsTest.java
@@ -0,0 +1,178 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.lsp;
+
+import org.exist.test.ExistXmldbEmbeddedServer;
+import org.junit.ClassRule;
+import org.junit.Test;
+import org.xmldb.api.base.ResourceSet;
+import org.xmldb.api.base.XMLDBException;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Tests for the lsp:diagnostics() function.
+ */
+public class DiagnosticsTest {
+
+ @ClassRule
+ public static final ExistXmldbEmbeddedServer existEmbeddedServer =
+ new ExistXmldbEmbeddedServer(false, true, true);
+
+ private static final String LSP_IMPORT =
+ "import module namespace lsp = 'http://exist-db.org/xquery/lsp';\n";
+
+ @Test
+ public void validExpressionReturnsEmptyArray() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT + "array:size(lsp:diagnostics('let $x := 1 return $x'))");
+ assertEquals("Valid expression should return empty array",
+ "0", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void syntaxErrorReturnsDiagnostic() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT + "array:size(lsp:diagnostics('let $x := 1 retrun $x'))");
+ assertEquals("Syntax error should return one diagnostic",
+ "1", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void diagnosticSeverityIsError() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $result := lsp:diagnostics('let $x := retrun $x')\n" +
+ "return $result(1)?severity");
+ assertEquals("Severity should be 1 (LSP DiagnosticSeverity.Error)",
+ "1", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void syntaxErrorHasCorrectLine() throws XMLDBException {
+ // Single-line expression: error should be on line 0
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $result := lsp:diagnostics('let $x := 1 retrun $x')\n" +
+ "return $result(1)?line");
+ final int line = Integer.parseInt(result.getResource(0).getContent().toString());
+ assertTrue("Error in single-line expression should be on line 0 or 1, got: " + line,
+ line >= 0 && line <= 1);
+ }
+
+ @Test
+ public void multiLineErrorHasCorrectLine() throws XMLDBException {
+ // Error on line 3 (0-based: line 2): the "retrun" typo
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $result := lsp:diagnostics('xquery version \"3.1\";\n\nlet $x := 1 retrun $x')\n" +
+ "return $result(1)?line");
+ final int line = Integer.parseInt(result.getResource(0).getContent().toString());
+ assertEquals("Error on line 3 should report 0-based line 2", 2, line);
+ }
+
+ @Test
+ public void syntaxErrorHasPositiveColumn() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $result := lsp:diagnostics('let $x := 1 retrun $x')\n" +
+ "return $result(1)?column");
+ final int column = Integer.parseInt(result.getResource(0).getContent().toString());
+ assertTrue("Column should be non-negative, got: " + column, column >= 0);
+ }
+
+ @Test
+ public void diagnosticMessageDescribesError() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $result := lsp:diagnostics('let $x := 1 retrun $x')\n" +
+ "return $result(1)?message");
+ final String message = result.getResource(0).getContent().toString();
+ assertTrue("Error message should not be empty", message.length() > 0);
+ }
+
+ @Test
+ public void undeclaredVariableHasErrorCode() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $result := lsp:diagnostics('$undeclared')\n" +
+ "return $result(1)?code");
+ final String code = result.getResource(0).getContent().toString();
+ assertTrue("Undeclared variable error code should contain XPST0008, got: " + code,
+ code.contains("XPST0008"));
+ }
+
+ @Test
+ public void undeclaredVariableMessageMentionsVarName() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $result := lsp:diagnostics('$undeclared')\n" +
+ "return $result(1)?message");
+ final String message = result.getResource(0).getContent().toString();
+ assertTrue("Message should mention the variable name: " + message,
+ message.contains("undeclared"));
+ }
+
+ @Test
+ public void emptyExpressionReturnsEmptyArray() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT + "array:size(lsp:diagnostics(''))");
+ assertEquals("Empty expression should return empty array",
+ "0", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void whitespaceOnlyExpressionReturnsEmptyArray() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT + "array:size(lsp:diagnostics(' '))");
+ assertEquals("Whitespace-only expression should return empty array",
+ "0", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void withXmldbModuleLoadPath() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "array:size(lsp:diagnostics('let $x := 1 return $x', 'xmldb:exist:///db'))");
+ assertEquals("Valid expression with xmldb load path should return empty array",
+ "0", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void withDbModuleLoadPath() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "array:size(lsp:diagnostics('let $x := 1 return $x', '/db'))");
+ assertEquals("Valid expression with /db load path should return empty array",
+ "0", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void withEmptyModuleLoadPath() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "array:size(lsp:diagnostics('let $x := 1 return $x', ()))");
+ assertEquals("Valid expression with empty load path should return empty array",
+ "0", result.getResource(0).getContent().toString());
+ }
+}
diff --git a/exist-core/src/test/java/org/exist/xquery/functions/lsp/HoverTest.java b/exist-core/src/test/java/org/exist/xquery/functions/lsp/HoverTest.java
new file mode 100644
index 00000000000..a716738a214
--- /dev/null
+++ b/exist-core/src/test/java/org/exist/xquery/functions/lsp/HoverTest.java
@@ -0,0 +1,151 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.lsp;
+
+import org.exist.test.ExistXmldbEmbeddedServer;
+import org.junit.ClassRule;
+import org.junit.Test;
+import org.xmldb.api.base.ResourceSet;
+import org.xmldb.api.base.XMLDBException;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Tests for the lsp:hover() function.
+ */
+public class HoverTest {
+
+ @ClassRule
+ public static final ExistXmldbEmbeddedServer existEmbeddedServer =
+ new ExistXmldbEmbeddedServer(false, true, true);
+
+ private static final String LSP_IMPORT =
+ "import module namespace lsp = 'http://exist-db.org/xquery/lsp';\n";
+
+ @Test
+ public void emptyExpressionReturnsEmpty() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT + "empty(lsp:hover('', 0, 0))");
+ assertEquals("true", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void invalidExpressionReturnsEmpty() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT + "empty(lsp:hover('let $x :=', 0, 5))");
+ assertEquals("true", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void hoverOnBuiltinFunctionCall() throws XMLDBException {
+ // fn:count starts at column 0
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $hover := lsp:hover('fn:count((1,2,3))', 0, 3)\n" +
+ "return $hover?kind");
+ assertEquals("function", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void hoverOnBuiltinFunctionHasContents() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $hover := lsp:hover('fn:count((1,2,3))', 0, 3)\n" +
+ "return string-length($hover?contents) > 0");
+ assertEquals("true", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void hoverOnBuiltinFunctionContainsName() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $hover := lsp:hover('fn:count((1,2,3))', 0, 3)\n" +
+ "return contains($hover?contents, 'count')");
+ assertEquals("true", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void hoverOnUserFunctionCall() throws XMLDBException {
+ // Use a multiline expression so the call is on a known line
+ // Line 0: declare function local:greet() as xs:string { 'hi' };
+ // Line 1: local:greet()
+ final String xquery =
+ "declare function local:greet() as xs:string { ''hi'' };
local:greet()";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $hover := lsp:hover('" + xquery + "', 1, 5)\n" +
+ "return $hover?kind");
+ assertEquals("function", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void hoverOnUserFunctionContainsSig() throws XMLDBException {
+ final String xquery =
+ "declare function local:greet() as xs:string { ''hi'' };
local:greet()";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $hover := lsp:hover('" + xquery + "', 1, 5)\n" +
+ "return contains($hover?contents, 'greet')");
+ assertEquals("true", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void hoverOnVariableReference() throws XMLDBException {
+ // Line 0: declare variable $local:x := 42;
+ // Line 1: $local:x
+ final String xquery =
+ "declare variable $local:x := 42;
$local:x";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $hover := lsp:hover('" + xquery + "', 1, 3)\n" +
+ "return $hover?kind");
+ assertEquals("variable", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void hoverOnVariableHasName() throws XMLDBException {
+ final String xquery =
+ "declare variable $local:x := 42;
$local:x";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $hover := lsp:hover('" + xquery + "', 1, 3)\n" +
+ "return contains($hover?contents, '$local:x')");
+ assertEquals("true", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void noSymbolAtPositionReturnsEmpty() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT + "empty(lsp:hover('1 + 2', 0, 2))");
+ assertEquals("true", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void withModuleLoadPath() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $hover := lsp:hover('fn:count((1,2,3))', 0, 3, '/db')\n" +
+ "return $hover?kind");
+ assertEquals("function", result.getResource(0).getContent().toString());
+ }
+}
diff --git a/exist-core/src/test/java/org/exist/xquery/functions/lsp/ReferencesTest.java b/exist-core/src/test/java/org/exist/xquery/functions/lsp/ReferencesTest.java
new file mode 100644
index 00000000000..aa8f3b1e266
--- /dev/null
+++ b/exist-core/src/test/java/org/exist/xquery/functions/lsp/ReferencesTest.java
@@ -0,0 +1,125 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.lsp;
+
+import org.exist.test.ExistXmldbEmbeddedServer;
+import org.junit.ClassRule;
+import org.junit.Test;
+import org.xmldb.api.base.ResourceSet;
+import org.xmldb.api.base.XMLDBException;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Tests for the lsp:references() function.
+ */
+public class ReferencesTest {
+
+ @ClassRule
+ public static final ExistXmldbEmbeddedServer existEmbeddedServer =
+ new ExistXmldbEmbeddedServer(false, true, true);
+
+ private static final String LSP_IMPORT =
+ "import module namespace lsp = 'http://exist-db.org/xquery/lsp';\n";
+
+ @Test
+ public void emptyExpressionReturnsEmpty() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT + "array:size(lsp:references('', 0, 0))");
+ assertEquals("0", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void functionCallFindsAllReferences() throws XMLDBException {
+ // local:greet is declared on line 0 and called on lines 1 and 2
+ final String xquery =
+ "declare function local:greet() { 42 };
" +
+ "local:greet(),
" +
+ "local:greet()";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $refs := lsp:references('" + xquery + "', 1, 5)\n" +
+ "return array:size($refs)");
+ // Should find: declaration + 2 calls = 3
+ final int count = Integer.parseInt(result.getResource(0).getContent().toString());
+ assertTrue("expected at least 2 references, got " + count, count >= 2);
+ }
+
+ @Test
+ public void functionReferenceHasKind() throws XMLDBException {
+ final String xquery =
+ "declare function local:greet() { 42 };
local:greet()";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $refs := lsp:references('" + xquery + "', 1, 5)\n" +
+ "return $refs(1)?kind");
+ assertEquals("function", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void variableReferenceFindsAllReferences() throws XMLDBException {
+ // $local:x is declared on line 0 and referenced on lines 1 and 2
+ final String xquery =
+ "declare variable $local:x := 42;
" +
+ "$local:x + 1,
" +
+ "$local:x + 2";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $refs := lsp:references('" + xquery + "', 1, 3)\n" +
+ "return array:size($refs)");
+ final int count = Integer.parseInt(result.getResource(0).getContent().toString());
+ assertTrue("expected at least 2 references, got " + count, count >= 2);
+ }
+
+ @Test
+ public void variableReferenceHasKind() throws XMLDBException {
+ final String xquery =
+ "declare variable $local:x := 42;
$local:x";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $refs := lsp:references('" + xquery + "', 1, 3)\n" +
+ "return $refs(1)?kind");
+ assertEquals("variable", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void noSymbolReturnsEmpty() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT + "array:size(lsp:references('1 + 2', 0, 2))");
+ assertEquals("0", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void withModuleLoadPath() throws XMLDBException {
+ final String xquery =
+ "declare function local:foo() { 42 };
" +
+ "local:foo(),
" +
+ "local:foo()";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $refs := lsp:references('" + xquery + "', 1, 5, '/db')\n" +
+ "return array:size($refs)");
+ final int count = Integer.parseInt(result.getResource(0).getContent().toString());
+ assertTrue("expected at least 2 references, got " + count, count >= 2);
+ }
+}
diff --git a/exist-core/src/test/java/org/exist/xquery/functions/lsp/SymbolsTest.java b/exist-core/src/test/java/org/exist/xquery/functions/lsp/SymbolsTest.java
new file mode 100644
index 00000000000..f6ac24bf805
--- /dev/null
+++ b/exist-core/src/test/java/org/exist/xquery/functions/lsp/SymbolsTest.java
@@ -0,0 +1,223 @@
+/*
+ * eXist-db Open Source Native XML Database
+ * Copyright (C) 2001 The eXist-db Authors
+ *
+ * info@exist-db.org
+ * http://www.exist-db.org
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+package org.exist.xquery.functions.lsp;
+
+import org.exist.test.ExistXmldbEmbeddedServer;
+import org.junit.ClassRule;
+import org.junit.Test;
+import org.xmldb.api.base.ResourceSet;
+import org.xmldb.api.base.XMLDBException;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Tests for the lsp:symbols() function.
+ */
+public class SymbolsTest {
+
+ @ClassRule
+ public static final ExistXmldbEmbeddedServer existEmbeddedServer =
+ new ExistXmldbEmbeddedServer(false, true, true);
+
+ private static final String LSP_IMPORT =
+ "import module namespace lsp = 'http://exist-db.org/xquery/lsp';\n";
+
+ @Test
+ public void emptyExpressionReturnsEmptyArray() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT + "array:size(lsp:symbols(''))");
+ assertEquals("0", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void invalidExpressionReturnsEmptyArray() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT + "array:size(lsp:symbols('let $x :='))");
+ assertEquals("0", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void functionDeclarationReturnsSymbol() throws XMLDBException {
+ final String xquery =
+ "declare function local:hello($name as xs:string) as xs:string { " +
+ " ''Hello, '' || $name " +
+ "}; local:hello(''world'')";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $symbols := lsp:symbols('" + xquery + "')\n" +
+ "return array:size($symbols)");
+ assertEquals("1", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void functionSymbolHasCorrectName() throws XMLDBException {
+ final String xquery =
+ "declare function local:greet($name as xs:string) as xs:string { " +
+ " ''Hello, '' || $name " +
+ "}; local:greet(''world'')";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $symbols := lsp:symbols('" + xquery + "')\n" +
+ "return $symbols(1)?name");
+ assertEquals("local:greet#1", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void functionSymbolHasKind12() throws XMLDBException {
+ final String xquery =
+ "declare function local:foo() { () }; local:foo()";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $symbols := lsp:symbols('" + xquery + "')\n" +
+ "return $symbols(1)?kind");
+ assertEquals("LSP SymbolKind.Function = 12",
+ "12", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void functionSymbolHasPositiveLine() throws XMLDBException {
+ final String xquery =
+ "declare function local:foo() { () }; local:foo()";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $symbols := lsp:symbols('" + xquery + "')\n" +
+ "return $symbols(1)?line");
+ // Line should be 0 (first line, 0-based)
+ assertEquals("0", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void functionSymbolDetailIncludesReturnType() throws XMLDBException {
+ final String xquery =
+ "declare function local:add($a as xs:integer, $b as xs:integer) as xs:integer { " +
+ " $a + $b " +
+ "}; local:add(1, 2)";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $symbols := lsp:symbols('" + xquery + "')\n" +
+ "return $symbols(1)?detail");
+ final String detail = result.getResource(0).getContent().toString();
+ assertTrue("Detail should contain parameter names: " + detail,
+ detail.contains("$a") && detail.contains("$b"));
+ assertTrue("Detail should contain return type: " + detail,
+ detail.contains("xs:integer"));
+ }
+
+ @Test
+ public void variableDeclarationReturnsSymbol() throws XMLDBException {
+ final String xquery =
+ "declare variable $local:greeting := ''hello''; $local:greeting";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $symbols := lsp:symbols('" + xquery + "')\n" +
+ "return array:size($symbols)");
+ assertEquals("1", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void variableSymbolHasCorrectName() throws XMLDBException {
+ final String xquery =
+ "declare variable $local:greeting := ''hello''; $local:greeting";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $symbols := lsp:symbols('" + xquery + "')\n" +
+ "return $symbols(1)?name");
+ assertEquals("$local:greeting", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void variableSymbolHasKind13() throws XMLDBException {
+ final String xquery =
+ "declare variable $local:greeting := ''hello''; $local:greeting";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $symbols := lsp:symbols('" + xquery + "')\n" +
+ "return $symbols(1)?kind");
+ assertEquals("LSP SymbolKind.Variable = 13",
+ "13", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void typedVariableHasDetailInfo() throws XMLDBException {
+ final String xquery =
+ "declare variable $local:count as xs:integer := 42; $local:count";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $symbols := lsp:symbols('" + xquery + "')\n" +
+ "return $symbols(1)?detail");
+ final String detail = result.getResource(0).getContent().toString();
+ assertTrue("Detail should contain xs:integer: " + detail,
+ detail.contains("integer"));
+ }
+
+ @Test
+ public void multipleFunctionsAndVariables() throws XMLDBException {
+ final String xquery =
+ "declare variable $local:x := 1; " +
+ "declare function local:a() { $local:x }; " +
+ "declare function local:b($n as xs:integer) as xs:integer { $n + $local:x }; " +
+ "local:b(local:a())";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $symbols := lsp:symbols('" + xquery + "')\n" +
+ "return array:size($symbols)");
+ assertEquals("Should find 1 variable + 2 functions = 3 symbols",
+ "3", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void multipleFunctionsAndVariablesNames() throws XMLDBException {
+ final String xquery =
+ "declare variable $local:x := 1; " +
+ "declare function local:a() { $local:x }; " +
+ "declare function local:b($n as xs:integer) as xs:integer { $n + $local:x }; " +
+ "local:b(local:a())";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $symbols := lsp:symbols('" + xquery + "')\n" +
+ "return string-join(for $s in $symbols?* return $s?name, ', ')");
+ final String names = result.getResource(0).getContent().toString();
+ assertTrue("Should contain local:a#0: " + names, names.contains("local:a#0"));
+ assertTrue("Should contain local:b#1: " + names, names.contains("local:b#1"));
+ assertTrue("Should contain $local:x: " + names, names.contains("$local:x"));
+ }
+
+ @Test
+ public void noSymbolsInSimpleExpression() throws XMLDBException {
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT + "array:size(lsp:symbols('1 + 2'))");
+ assertEquals("Simple expression has no declarations",
+ "0", result.getResource(0).getContent().toString());
+ }
+
+ @Test
+ public void withModuleLoadPathEmpty() throws XMLDBException {
+ final String xquery =
+ "declare function local:foo() { () }; local:foo()";
+ final ResourceSet result = existEmbeddedServer.executeQuery(
+ LSP_IMPORT +
+ "let $symbols := lsp:symbols('" + xquery + "', ())\n" +
+ "return array:size($symbols)");
+ assertEquals("1", result.getResource(0).getContent().toString());
+ }
+}
diff --git a/exist-core/src/test/resources-filtered/conf.xml b/exist-core/src/test/resources-filtered/conf.xml
index 9a76f8c79a5..d9905ea2283 100644
--- a/exist-core/src/test/resources-filtered/conf.xml
+++ b/exist-core/src/test/resources-filtered/conf.xml
@@ -897,6 +897,7 @@
+
diff --git a/exist-core/src/test/resources-filtered/org/exist/storage/statistics/conf.xml b/exist-core/src/test/resources-filtered/org/exist/storage/statistics/conf.xml
index 15d68dea5fb..0696996b181 100644
--- a/exist-core/src/test/resources-filtered/org/exist/storage/statistics/conf.xml
+++ b/exist-core/src/test/resources-filtered/org/exist/storage/statistics/conf.xml
@@ -888,6 +888,7 @@
+
diff --git a/exist-core/src/test/resources-filtered/org/exist/xquery/conf.xml b/exist-core/src/test/resources-filtered/org/exist/xquery/conf.xml
index b9bc14f5b53..9efe1e017d0 100644
--- a/exist-core/src/test/resources-filtered/org/exist/xquery/conf.xml
+++ b/exist-core/src/test/resources-filtered/org/exist/xquery/conf.xml
@@ -907,6 +907,7 @@
+
diff --git a/exist-core/src/test/resources-filtered/org/exist/xquery/functions/transform/conf.xml b/exist-core/src/test/resources-filtered/org/exist/xquery/functions/transform/conf.xml
index 7f2354f9f40..9a3b690b31c 100644
--- a/exist-core/src/test/resources-filtered/org/exist/xquery/functions/transform/conf.xml
+++ b/exist-core/src/test/resources-filtered/org/exist/xquery/functions/transform/conf.xml
@@ -899,6 +899,7 @@
+
diff --git a/exist-core/src/test/resources/org/exist/xmldb/allowAnyUri.xml b/exist-core/src/test/resources/org/exist/xmldb/allowAnyUri.xml
index 46f2ccd8e99..dab4aff3de8 100644
--- a/exist-core/src/test/resources/org/exist/xmldb/allowAnyUri.xml
+++ b/exist-core/src/test/resources/org/exist/xmldb/allowAnyUri.xml
@@ -940,6 +940,7 @@
+
diff --git a/exist-distribution/src/main/config/conf.xml b/exist-distribution/src/main/config/conf.xml
index 026734ebf05..834097f02c9 100644
--- a/exist-distribution/src/main/config/conf.xml
+++ b/exist-distribution/src/main/config/conf.xml
@@ -968,6 +968,7 @@
+
diff --git a/extensions/contentextraction/src/test/resources-filtered/conf.xml b/extensions/contentextraction/src/test/resources-filtered/conf.xml
index 1311e06f555..607e568ff2a 100644
--- a/extensions/contentextraction/src/test/resources-filtered/conf.xml
+++ b/extensions/contentextraction/src/test/resources-filtered/conf.xml
@@ -750,6 +750,7 @@
+
diff --git a/extensions/expath/src/test/resources-filtered/conf.xml b/extensions/expath/src/test/resources-filtered/conf.xml
index a0e02a2c06d..0020e5f421b 100644
--- a/extensions/expath/src/test/resources-filtered/conf.xml
+++ b/extensions/expath/src/test/resources-filtered/conf.xml
@@ -750,6 +750,7 @@
+
diff --git a/extensions/exquery/restxq/src/test/resources-filtered/conf.xml b/extensions/exquery/restxq/src/test/resources-filtered/conf.xml
index 697afdbf11b..44d57115066 100644
--- a/extensions/exquery/restxq/src/test/resources-filtered/conf.xml
+++ b/extensions/exquery/restxq/src/test/resources-filtered/conf.xml
@@ -731,6 +731,7 @@
+
diff --git a/extensions/indexes/indexes-integration-tests/src/test/resources-filtered/conf.xml b/extensions/indexes/indexes-integration-tests/src/test/resources-filtered/conf.xml
index 2aae0f7d207..aa403553838 100644
--- a/extensions/indexes/indexes-integration-tests/src/test/resources-filtered/conf.xml
+++ b/extensions/indexes/indexes-integration-tests/src/test/resources-filtered/conf.xml
@@ -899,6 +899,7 @@
+
diff --git a/extensions/indexes/lucene/src/test/resources-filtered/conf.xml b/extensions/indexes/lucene/src/test/resources-filtered/conf.xml
index beaf90e4e62..54e75f31706 100644
--- a/extensions/indexes/lucene/src/test/resources-filtered/conf.xml
+++ b/extensions/indexes/lucene/src/test/resources-filtered/conf.xml
@@ -897,6 +897,7 @@
+
diff --git a/extensions/indexes/ngram/src/test/resources-filtered/conf.xml b/extensions/indexes/ngram/src/test/resources-filtered/conf.xml
index 7b290c22429..f721d992297 100644
--- a/extensions/indexes/ngram/src/test/resources-filtered/conf.xml
+++ b/extensions/indexes/ngram/src/test/resources-filtered/conf.xml
@@ -896,6 +896,7 @@
+
diff --git a/extensions/indexes/range/src/test/resources-filtered/conf.xml b/extensions/indexes/range/src/test/resources-filtered/conf.xml
index a22d440f625..7be8cb12990 100644
--- a/extensions/indexes/range/src/test/resources-filtered/conf.xml
+++ b/extensions/indexes/range/src/test/resources-filtered/conf.xml
@@ -901,6 +901,7 @@
+
diff --git a/extensions/indexes/sort/src/test/resources-filtered/conf.xml b/extensions/indexes/sort/src/test/resources-filtered/conf.xml
index e6d70cea684..6b38321794f 100644
--- a/extensions/indexes/sort/src/test/resources-filtered/conf.xml
+++ b/extensions/indexes/sort/src/test/resources-filtered/conf.xml
@@ -896,6 +896,7 @@
+
diff --git a/extensions/modules/cache/src/test/resources-filtered/conf.xml b/extensions/modules/cache/src/test/resources-filtered/conf.xml
index af9663be608..94f97c048c8 100644
--- a/extensions/modules/cache/src/test/resources-filtered/conf.xml
+++ b/extensions/modules/cache/src/test/resources-filtered/conf.xml
@@ -753,6 +753,7 @@
+
diff --git a/extensions/modules/cache/src/test/resources-filtered/lazy-cache-conf.xml b/extensions/modules/cache/src/test/resources-filtered/lazy-cache-conf.xml
index af9663be608..94f97c048c8 100644
--- a/extensions/modules/cache/src/test/resources-filtered/lazy-cache-conf.xml
+++ b/extensions/modules/cache/src/test/resources-filtered/lazy-cache-conf.xml
@@ -753,6 +753,7 @@
+
diff --git a/extensions/modules/cache/src/test/resources-filtered/non-lazy-cache-conf.xml b/extensions/modules/cache/src/test/resources-filtered/non-lazy-cache-conf.xml
index d7ec021a27f..fb29dd589ba 100644
--- a/extensions/modules/cache/src/test/resources-filtered/non-lazy-cache-conf.xml
+++ b/extensions/modules/cache/src/test/resources-filtered/non-lazy-cache-conf.xml
@@ -752,6 +752,7 @@
+
diff --git a/extensions/modules/compression/src/test/resources-filtered/conf.xml b/extensions/modules/compression/src/test/resources-filtered/conf.xml
index 0bdebfee2d6..62cec760a15 100644
--- a/extensions/modules/compression/src/test/resources-filtered/conf.xml
+++ b/extensions/modules/compression/src/test/resources-filtered/conf.xml
@@ -750,6 +750,7 @@
+
diff --git a/extensions/modules/expathrepo/src/test/resources-filtered/conf.xml b/extensions/modules/expathrepo/src/test/resources-filtered/conf.xml
index 0203297b9dd..1b483d207f7 100644
--- a/extensions/modules/expathrepo/src/test/resources-filtered/conf.xml
+++ b/extensions/modules/expathrepo/src/test/resources-filtered/conf.xml
@@ -753,6 +753,7 @@
+
diff --git a/extensions/modules/image/src/test/resources-filtered/conf.xml b/extensions/modules/image/src/test/resources-filtered/conf.xml
index 9df613700e8..951a502236e 100644
--- a/extensions/modules/image/src/test/resources-filtered/conf.xml
+++ b/extensions/modules/image/src/test/resources-filtered/conf.xml
@@ -753,6 +753,7 @@
+
diff --git a/extensions/modules/xmldiff/src/test/resources-filtered/conf.xml b/extensions/modules/xmldiff/src/test/resources-filtered/conf.xml
index a1a95c324d6..40e724e8e4b 100644
--- a/extensions/modules/xmldiff/src/test/resources-filtered/conf.xml
+++ b/extensions/modules/xmldiff/src/test/resources-filtered/conf.xml
@@ -750,6 +750,7 @@
+
diff --git a/extensions/modules/xslfo/src/test/resources-filtered/conf.xml b/extensions/modules/xslfo/src/test/resources-filtered/conf.xml
index 3e14e631740..5b4a02b7474 100644
--- a/extensions/modules/xslfo/src/test/resources-filtered/conf.xml
+++ b/extensions/modules/xslfo/src/test/resources-filtered/conf.xml
@@ -752,6 +752,7 @@
+
diff --git a/extensions/xqdoc/src/test/resources-filtered/conf.xml b/extensions/xqdoc/src/test/resources-filtered/conf.xml
index 7c96ef98809..4b9955dcab7 100644
--- a/extensions/xqdoc/src/test/resources-filtered/conf.xml
+++ b/extensions/xqdoc/src/test/resources-filtered/conf.xml
@@ -752,6 +752,7 @@
+