From 796d1aecc8c7552161cb4d8f02a9437c1e454b9f Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Thu, 12 Mar 2026 14:51:53 -0400 Subject: [PATCH 01/10] [feature] Add LSP XQuery function module with diagnostics, symbols, completions, hover, and definition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new internal XQuery module (http://exist-db.org/xquery/lsp) that exposes eXist-db's XQuery compiler internals for Language Server Protocol support. Functions: - lsp:diagnostics($expr, $module-load-path?) — compiles XQuery and returns an array of diagnostic maps (line, column, severity, code, message) - lsp:symbols($expr, $module-load-path?) — compiles XQuery and returns an array of document symbol maps (name, kind, line, column, detail) - lsp:completions($expr, $module-load-path?) — returns an array of completion item maps (label, kind, detail, documentation, insertText) including built-in functions, keywords, and user-declared symbols - lsp:hover($expr, $line, $column, $module-load-path?) — returns hover info (contents, kind) for the symbol at the given position - lsp:definition($expr, $line, $column, $module-load-path?) — returns the definition location (line, column, name, kind) for user-declared functions and variables 68 tests covering all five functions. Co-Authored-By: Claude Opus 4.6 --- .../src/test/resources-filtered/conf.xml | 1 + .../xquery/functions/lsp/Completions.java | 330 ++++++++++++++++++ .../xquery/functions/lsp/Definition.java | 249 +++++++++++++ .../xquery/functions/lsp/Diagnostics.java | 199 +++++++++++ .../org/exist/xquery/functions/lsp/Hover.java | 279 +++++++++++++++ .../exist/xquery/functions/lsp/LspModule.java | 90 +++++ .../exist/xquery/functions/lsp/Symbols.java | 272 +++++++++++++++ .../xquery/functions/lsp/CompletionsTest.java | 244 +++++++++++++ .../xquery/functions/lsp/DefinitionTest.java | 154 ++++++++ .../xquery/functions/lsp/DiagnosticsTest.java | 167 +++++++++ .../exist/xquery/functions/lsp/HoverTest.java | 151 ++++++++ .../xquery/functions/lsp/SymbolsTest.java | 223 ++++++++++++ .../src/test/resources-filtered/conf.xml | 1 + .../org/exist/storage/statistics/conf.xml | 1 + .../org/exist/xquery/conf.xml | 1 + .../exist/xquery/functions/transform/conf.xml | 1 + .../resources/org/exist/xmldb/allowAnyUri.xml | 1 + exist-distribution/src/main/config/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + .../sort/src/test/resources-filtered/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + .../resources-filtered/lazy-cache-conf.xml | 1 + .../non-lazy-cache-conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + 35 files changed, 2382 insertions(+) create mode 100644 exist-core/src/main/java/org/exist/xquery/functions/lsp/Completions.java create mode 100644 exist-core/src/main/java/org/exist/xquery/functions/lsp/Definition.java create mode 100644 exist-core/src/main/java/org/exist/xquery/functions/lsp/Diagnostics.java create mode 100644 exist-core/src/main/java/org/exist/xquery/functions/lsp/Hover.java create mode 100644 exist-core/src/main/java/org/exist/xquery/functions/lsp/LspModule.java create mode 100644 exist-core/src/main/java/org/exist/xquery/functions/lsp/Symbols.java create mode 100644 exist-core/src/test/java/org/exist/xquery/functions/lsp/CompletionsTest.java create mode 100644 exist-core/src/test/java/org/exist/xquery/functions/lsp/DefinitionTest.java create mode 100644 exist-core/src/test/java/org/exist/xquery/functions/lsp/DiagnosticsTest.java create mode 100644 exist-core/src/test/java/org/exist/xquery/functions/lsp/HoverTest.java create mode 100644 exist-core/src/test/java/org/exist/xquery/functions/lsp/SymbolsTest.java diff --git a/exist-ant/src/test/resources-filtered/conf.xml b/exist-ant/src/test/resources-filtered/conf.xml index 52cac5dde3f..276088750ad 100644 --- a/exist-ant/src/test/resources-filtered/conf.xml +++ b/exist-ant/src/test/resources-filtered/conf.xml @@ -746,6 +746,7 @@ + diff --git a/exist-core/src/main/java/org/exist/xquery/functions/lsp/Completions.java b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Completions.java new file mode 100644 index 00000000000..6efd4cc4893 --- /dev/null +++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Completions.java @@ -0,0 +1,330 @@ +/* + * 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.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Set; + +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.Expression; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.Module; +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.*; + +/** + * Returns completion items available in the context of an XQuery expression, + * suitable for Language Server Protocol {@code textDocument/completion} responses. + * + *

Returns an array of maps, where each map represents a completion item with + * the following keys:

+ *
    + *
  • {@code label} — display text (e.g., "fn:count")
  • + *
  • {@code kind} — LSP CompletionItemKind integer (3=Function, 6=Variable)
  • + *
  • {@code detail} — signature or type info
  • + *
  • {@code documentation} — description from function signature
  • + *
  • {@code insertText} — text to insert (e.g., "fn:count()")
  • + *
+ * + *

Built-in module functions are always returned. If the expression compiles + * successfully, user-declared functions and variables are included as well.

+ * + * @author eXist-db + */ +public class Completions extends BasicFunction { + + private static final Logger logger = LogManager.getLogger(Completions.class); + + /** LSP CompletionItemKind constants */ + private static final long COMPLETION_KIND_FUNCTION = 3; + private static final long COMPLETION_KIND_VARIABLE = 6; + private static final long COMPLETION_KIND_MODULE = 9; + private static final long COMPLETION_KIND_KEYWORD = 14; + + private static final String FS_COMPLETIONS_NAME = "completions"; + private static final String FS_COMPLETIONS_DESCRIPTION = """ + Returns an array of completion item maps available in the context of \ + the given XQuery expression. Each map contains keys: label (xs:string), \ + kind (xs:integer, LSP CompletionItemKind), detail (xs:string, signature), \ + documentation (xs:string), and insertText (xs:string). \ + Built-in functions are always included; user-declared symbols are \ + included if the expression compiles successfully."""; + + public static final FunctionSignature[] FS_COMPLETIONS = functionSignatures( + LspModule.qname(FS_COMPLETIONS_NAME), + FS_COMPLETIONS_DESCRIPTION, + returns(Type.ARRAY_ITEM, "an array of completion item maps"), + arities( + arity( + param("expression", Type.STRING, "The XQuery expression to analyze for available completions.") + ), + arity( + param("expression", Type.STRING, "The XQuery expression to analyze for available completions."), + optParam("module-load-path", Type.STRING, "The module load path. " + + "Imports will be resolved relative to this. " + + "Use xmldb:exist:///db or /db for database-stored modules.") + ) + ) + ); + + /** XQuery keywords for completion */ + private static final String[] XQUERY_KEYWORDS = { + "declare", "function", "variable", "namespace", "module", + "import", "at", "as", "instance", "of", "cast", "castable", "treat", + "let", "for", "in", "where", "order", "by", "ascending", "descending", + "group", "count", "return", "if", "then", "else", + "some", "every", "satisfies", + "typeswitch", "switch", "case", "default", + "try", "catch", + "element", "attribute", "text", "comment", "document", + "processing-instruction", "node", + "empty-sequence", "item", + "or", "and", "not", + "div", "idiv", "mod", + "union", "intersect", "except", + "to", "eq", "ne", "lt", "le", "gt", "ge", + "is", "preceding", "following" + }; + + public Completions(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 List completions = new ArrayList<>(); + + final XQueryContext pContext = new XQueryContext(context.getBroker().getBrokerPool()); + try { + if (getArgumentCount() == 2 && args[1].hasOne()) { + pContext.setModuleLoadPath(args[1].getStringValue()); + } + + // Built-in module functions are always available + addBuiltinFunctions(pContext, completions); + + // Keywords + addKeywords(completions); + + // Try to compile to discover user-declared symbols + if (!expr.trim().isEmpty()) { + context.pushNamespaceContext(); + try { + addUserDeclaredSymbols(pContext, expr, completions); + } finally { + context.popNamespaceContext(); + pContext.reset(false); + } + } + } finally { + pContext.runCleanupTasks(); + } + + return new ArrayType(this, context, completions); + } + + /** + * Adds completion items for all functions in all loaded built-in modules. + */ + private void addBuiltinFunctions(final XQueryContext pContext, final List completions) + throws XPathException { + final Set seen = new HashSet<>(); + final Iterator modules = pContext.getAllModules(); + + while (modules.hasNext()) { + final Module module = modules.next(); + if (!module.isInternalModule()) { + continue; + } + + String prefix = module.getDefaultPrefix(); + if (prefix == null || prefix.isEmpty()) { + // Some built-in modules (e.g., XPath functions) have empty default prefix + // but are bound to a conventional prefix (e.g., "fn") in the context + prefix = pContext.getPrefixForURI(module.getNamespaceURI()); + if (prefix == null) { + prefix = ""; + } + } + final FunctionSignature[] signatures = module.listFunctions(); + + for (final FunctionSignature sig : signatures) { + if (sig.isPrivate()) { + continue; + } + + final QName name = sig.getName(); + final String label = formatLabel(prefix, name.getLocalPart(), sig.getArgumentCount()); + + // Deduplicate overloaded functions + if (!seen.add(label)) { + continue; + } + + final String detail = sig.toString(); + final String documentation = sig.getDescription() != null ? sig.getDescription() : ""; + final String insertText = formatInsertText(prefix, name.getLocalPart(), sig.getArgumentCount()); + + addCompletion(completions, label, COMPLETION_KIND_FUNCTION, detail, documentation, insertText); + } + } + } + + /** + * Adds XQuery keywords as completion items. + */ + private void addKeywords(final List completions) throws XPathException { + for (final String keyword : XQUERY_KEYWORDS) { + addCompletion(completions, keyword, COMPLETION_KIND_KEYWORD, "keyword", "", keyword); + } + } + + /** + * Tries to compile the expression and adds user-declared functions and variables. + */ + private void addUserDeclaredSymbols(final XQueryContext pContext, final String expr, + final List completions) throws XPathException { + 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; + } + + final AST ast = parser.getAST(); + final PathExpr path = new PathExpr(pContext); + astParser.xpath(ast, path); + if (astParser.foundErrors()) { + return; + } + + path.analyze(new AnalyzeContextInfo()); + + // User-declared functions + final Iterator funcs = pContext.localFunctions(); + while (funcs.hasNext()) { + final UserDefinedFunction func = funcs.next(); + final FunctionSignature sig = func.getSignature(); + final QName name = sig.getName(); + final String prefix = name.getPrefix(); + final String label = formatLabel( + prefix != null ? prefix : "", name.getLocalPart(), sig.getArgumentCount()); + final String detail = sig.toString(); + final String insertText = formatInsertText( + prefix != null ? prefix : "", name.getLocalPart(), sig.getArgumentCount()); + + addCompletion(completions, label, COMPLETION_KIND_FUNCTION, detail, "", insertText); + } + + // User-declared global variables + for (int i = 0; i < path.getSubExpressionCount(); i++) { + final Expression step = path.getSubExpression(i); + if (step instanceof final VariableDeclaration varDecl) { + final QName name = varDecl.getName(); + final String varName = "$" + formatQName(name); + final SequenceType seqType = varDecl.getSequenceType(); + final String detail = seqType != null + ? Type.getTypeName(seqType.getPrimaryType()) + seqType.getCardinality().toXQueryCardinalityString() + : ""; + + addCompletion(completions, varName, COMPLETION_KIND_VARIABLE, detail, "", varName); + } + } + + } catch (final Exception e) { + logger.debug("Error compiling expression for completions: {}", e.getMessage()); + } + } + + /** + * Creates a completion item map and adds it to the list. + */ + private void addCompletion(final List completions, final String label, + final long kind, final String detail, final String documentation, + final String insertText) throws XPathException { + final MapType item = new MapType(this, context); + item.add(new StringValue(this, "label"), new StringValue(this, label)); + item.add(new StringValue(this, "kind"), new IntegerValue(this, kind)); + item.add(new StringValue(this, "detail"), new StringValue(this, detail)); + item.add(new StringValue(this, "documentation"), new StringValue(this, documentation)); + item.add(new StringValue(this, "insertText"), new StringValue(this, insertText)); + completions.add(item); + } + + private static String formatLabel(final String prefix, final String localPart, final int arity) { + if (prefix != null && !prefix.isEmpty()) { + return prefix + ":" + localPart + "#" + arity; + } + return localPart + "#" + arity; + } + + private static String formatInsertText(final String prefix, final String localPart, final int arity) { + final StringBuilder sb = new StringBuilder(); + if (prefix != null && !prefix.isEmpty()) { + sb.append(prefix).append(':'); + } + sb.append(localPart).append('('); + if (arity > 0) { + // Leave cursor inside parens for user to fill in args + } + sb.append(')'); + return sb.toString(); + } + + private static String formatQName(final QName name) { + final String prefix = name.getPrefix(); + if (prefix != null && !prefix.isEmpty()) { + return prefix + ":" + name.getLocalPart(); + } + return name.getLocalPart(); + } + +} diff --git a/exist-core/src/main/java/org/exist/xquery/functions/lsp/Definition.java b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Definition.java new file mode 100644 index 00000000000..2b9e216eee7 --- /dev/null +++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Definition.java @@ -0,0 +1,249 @@ +/* + * 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 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.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 antlr.collections.AST; + +import static org.exist.xquery.FunctionDSL.*; + +/** + * Returns the definition location of the symbol at a given position in an + * XQuery expression, suitable for Language Server Protocol + * {@code textDocument/definition} responses. + * + *

Returns a map with the following keys:

+ *
    + *
  • {@code line} — 0-based line of the definition
  • + *
  • {@code column} — 0-based column of the definition
  • + *
  • {@code name} — name of the defined symbol
  • + *
  • {@code kind} — what was found: "function" or "variable"
  • + *
+ * + *

Returns an empty sequence if the symbol at the position is not a + * user-declared function or variable, or if no symbol is found.

+ * + * @author eXist-db + */ +public class Definition extends BasicFunction { + + private static final Logger logger = LogManager.getLogger(Definition.class); + + private static final String FS_DEFINITION_NAME = "definition"; + private static final String FS_DEFINITION_DESCRIPTION = """ + Returns the definition location of the symbol at the given \ + position. Returns a map 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 \ + sequence if no user-declared definition is found."""; + + public static final FunctionSignature[] FS_DEFINITION = functionSignatures( + LspModule.qname(FS_DEFINITION_NAME), + FS_DEFINITION_DESCRIPTION, + returns(Type.MAP_ITEM, "a definition location 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 Definition(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 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; + } + + // Find the node at the cursor position + final Hover.NodeAtPositionFinder finder = + new Hover.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 java.util.Iterator localFuncs = pContext.localFunctions(); + while (localFuncs.hasNext()) { + localFuncs.next().getFunctionBody().accept(finder); + } + + final Expression found = finder.foundExpression; + if (found == null) { + return Sequence.EMPTY_SEQUENCE; + } + + // Resolve to definition + if (found instanceof final FunctionCall call) { + return resolveFunctionDefinition(call); + } else if (found instanceof final VariableReference varRef) { + return resolveVariableDefinition(varRef, path); + } + } finally { + context.popNamespaceContext(); + pContext.reset(false); + } + } 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 definition: {}", e.getMessage()); + return null; + } + } + + private Sequence resolveFunctionDefinition(final FunctionCall call) throws XPathException { + final UserDefinedFunction func = call.getFunction(); + if (func == null) { + return Sequence.EMPTY_SEQUENCE; + } + + int line = func.getLine(); + final int column = func.getColumn(); + + if (line <= 0) { + return Sequence.EMPTY_SEQUENCE; + } + + final QName name = func.getSignature().getName(); + final String displayName = formatQName(name) + "#" + func.getSignature().getArgumentCount(); + + return buildResult(line - 1, Math.max(column - 1, 0), displayName, "function"); + } + + private Sequence resolveVariableDefinition(final VariableReference varRef, + final PathExpr path) throws XPathException { + final QName refName = varRef.getName(); + + // Search for matching VariableDeclaration in the prolog + for (int i = 0; i < path.getSubExpressionCount(); i++) { + final Expression step = path.getSubExpression(i); + if (step instanceof final VariableDeclaration varDecl) { + if (refName.equals(varDecl.getName())) { + final int line = varDecl.getLine(); + if (line <= 0) { + continue; + } + final String displayName = "$" + formatQName(varDecl.getName()); + return buildResult(line - 1, Math.max(varDecl.getColumn() - 1, 0), + displayName, "variable"); + } + } + } + + return Sequence.EMPTY_SEQUENCE; + } + + private Sequence buildResult(final int line, final int column, + final String name, final String kind) throws XPathException { + final MapType result = new MapType(this, context); + result.add(new StringValue(this, "line"), new IntegerValue(this, line)); + result.add(new StringValue(this, "column"), new IntegerValue(this, column)); + result.add(new StringValue(this, "name"), new StringValue(this, name)); + result.add(new StringValue(this, "kind"), new StringValue(this, kind)); + return result; + } + + private static String formatQName(final QName name) { + final String prefix = name.getPrefix(); + if (prefix != null && !prefix.isEmpty()) { + return prefix + ":" + name.getLocalPart(); + } + return name.getLocalPart(); + } + +} diff --git a/exist-core/src/main/java/org/exist/xquery/functions/lsp/Diagnostics.java b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Diagnostics.java new file mode 100644 index 00000000000..0900d3c9809 --- /dev/null +++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Diagnostics.java @@ -0,0 +1,199 @@ +/* + * 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.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.ErrorCodes; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.PathExpr; +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.StringValue; +import org.exist.xquery.value.Type; + +import antlr.RecognitionException; +import antlr.TokenStreamException; +import antlr.collections.AST; + +import static org.exist.xquery.FunctionDSL.*; + +/** + * Compiles an XQuery expression and returns diagnostic information + * in a format suitable for Language Server Protocol consumers. + * + *

Returns an array of maps, where each map represents a diagnostic with + * the following keys:

+ *
    + *
  • {@code line} — 0-based line number
  • + *
  • {@code column} — 0-based column number
  • + *
  • {@code severity} — LSP severity: 1 (error), 2 (warning), 3 (info), 4 (hint)
  • + *
  • {@code code} — W3C error code (e.g., "XPST0003")
  • + *
  • {@code message} — human-readable error description
  • + *
+ * + *

Returns an empty array if the expression compiles successfully.

+ * + * @author eXist-db + */ +public class Diagnostics extends BasicFunction { + + private static final Logger logger = LogManager.getLogger(Diagnostics.class); + + /** LSP DiagnosticSeverity.Error */ + private static final long SEVERITY_ERROR = 1; + + private static final String FS_DIAGNOSTICS_NAME = "diagnostics"; + private static final String FS_DIAGNOSTICS_DESCRIPTION = """ + Compiles the XQuery expression and returns an array of diagnostic maps. \ + Each map contains keys: line (xs:integer, 0-based), column (xs:integer, 0-based), \ + severity (xs:integer, 1=error), code (xs:string, W3C error code), and \ + message (xs:string). Returns an empty array if compilation succeeds."""; + + public static final FunctionSignature[] FS_DIAGNOSTICS = functionSignatures( + LspModule.qname(FS_DIAGNOSTICS_NAME), + FS_DIAGNOSTICS_DESCRIPTION, + returns(Type.ARRAY_ITEM, "an array of diagnostic maps"), + arities( + arity( + param("expression", Type.STRING, "The XQuery expression to compile.") + ), + arity( + param("expression", Type.STRING, "The XQuery expression to compile."), + 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 Diagnostics(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 diagnostics = 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 { + compile(pContext, expr, diagnostics); + } finally { + context.popNamespaceContext(); + pContext.reset(false); + } + } finally { + pContext.runCleanupTasks(); + } + + return new ArrayType(this, context, diagnostics); + } + + /** + * Compiles the expression and collects diagnostics. + * + *

Currently reports the first error encountered. The eXist-db parser + * does not support error recovery, so compilation stops at the first + * failure. Future versions may collect multiple diagnostics.

+ */ + private void compile(final XQueryContext pContext, final String expr, + final List diagnostics) throws XPathException { + 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()) { + logger.debug(parser.getErrorMessage()); + addDiagnostic(diagnostics, -1, -1, null, parser.getErrorMessage()); + return; + } + + final AST ast = parser.getAST(); + final PathExpr path = new PathExpr(pContext); + astParser.xpath(ast, path); + if (astParser.foundErrors()) { + final Exception lastException = astParser.getLastException(); + if (lastException instanceof final XPathException xpe) { + addDiagnostic(diagnostics, + xpe.getLine(), + xpe.getColumn(), + xpe.getCode(), + xpe.getDetailMessage()); + } else if (lastException != null) { + addDiagnostic(diagnostics, -1, -1, null, lastException.getMessage()); + } + return; + } + + path.analyze(new AnalyzeContextInfo()); + + } catch (final RecognitionException e) { + addDiagnostic(diagnostics, e.getLine(), e.getColumn(), null, e.getMessage()); + } catch (final TokenStreamException e) { + addDiagnostic(diagnostics, -1, -1, null, e.getMessage()); + } catch (final XPathException e) { + addDiagnostic(diagnostics, e.getLine(), e.getColumn(), e.getCode(), e.getDetailMessage()); + } + } + + /** + * Creates a diagnostic map and adds it to the list. + */ + private void addDiagnostic(final List diagnostics, final int line, final int column, + final ErrorCodes.ErrorCode code, final String message) throws XPathException { + final MapType diagnostic = new MapType(this, context); + diagnostic.add(new StringValue(this, "line"), new IntegerValue(this, Math.max(line, 0))); + diagnostic.add(new StringValue(this, "column"), new IntegerValue(this, Math.max(column, 0))); + diagnostic.add(new StringValue(this, "severity"), new IntegerValue(this, SEVERITY_ERROR)); + diagnostic.add(new StringValue(this, "code"), + new StringValue(this, code == null ? "" : code.toString())); + diagnostic.add(new StringValue(this, "message"), + new StringValue(this, message == null ? "Unknown error" : message)); + diagnostics.add(diagnostic); + } +} diff --git a/exist-core/src/main/java/org/exist/xquery/functions/lsp/Hover.java b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Hover.java new file mode 100644 index 00000000000..2f3328d4cef --- /dev/null +++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Hover.java @@ -0,0 +1,279 @@ +/* + * 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.Iterator; + +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.DefaultExpressionVisitor; +import org.exist.xquery.Expression; +import org.exist.xquery.Function; +import org.exist.xquery.FunctionCall; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.InternalFunctionCall; +import org.exist.xquery.PathExpr; +import org.exist.xquery.UserDefinedFunction; +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 antlr.collections.AST; + +import static org.exist.xquery.FunctionDSL.*; + +/** + * Returns hover information for the symbol at a given position in an XQuery + * expression, suitable for Language Server Protocol {@code textDocument/hover} + * responses. + * + *

Returns a map with the following keys:

+ *
    + *
  • {@code contents} — hover text (signature and/or documentation)
  • + *
  • {@code kind} — what was found: "function" or "variable"
  • + *
+ * + *

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); + } + } 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..5c0a739fbbc --- /dev/null +++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/LspModule.java @@ -0,0 +1,90 @@ +/* + * 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(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/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..4be77a76a46 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/functions/lsp/DefinitionTest.java @@ -0,0 +1,154 @@ +/* + * 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: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"; + + @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()); + } +} 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..8b5e37cf6c6 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/functions/lsp/DiagnosticsTest.java @@ -0,0 +1,167 @@ +/* + * 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 syntaxErrorHasLineZero() 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 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/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 @@ + From c1401ae04a320388e183e73243e0c4d04a094522 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sat, 14 Mar 2026 20:04:54 -0400 Subject: [PATCH 02/10] [feature] Add cross-module go-to-definition support in lsp:definition When the symbol at the cursor resolves to a function defined in an imported library module, the result map now includes a "uri" key containing the source path of that module. This enables IDE clients to open the target module file and jump to the definition. Uses func.getSource().path() to detect when the definition lives in a different module than the input expression. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../xquery/functions/lsp/Definition.java | 39 ++++++++-- .../xquery/functions/lsp/DefinitionTest.java | 73 +++++++++++++++++++ 2 files changed, 105 insertions(+), 7 deletions(-) diff --git a/exist-core/src/main/java/org/exist/xquery/functions/lsp/Definition.java b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Definition.java index 2b9e216eee7..9644add8085 100644 --- a/exist-core/src/main/java/org/exist/xquery/functions/lsp/Definition.java +++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Definition.java @@ -23,9 +23,12 @@ import java.io.StringReader; +import javax.annotation.Nullable; + import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.exist.dom.QName; +import org.exist.source.Source; import org.exist.xquery.AnalyzeContextInfo; import org.exist.xquery.BasicFunction; import org.exist.xquery.Expression; @@ -61,6 +64,9 @@ *
  • {@code column} — 0-based column of the definition
  • *
  • {@code name} — name of the defined symbol
  • *
  • {@code kind} — what was found: "function" or "variable"
  • + *
  • {@code uri} — (optional) source path of the module containing the + * definition, present only when the definition is in a different module + * than the input expression (i.e., an imported library module)
  • * * *

    Returns an empty sequence if the symbol at the position is not a @@ -76,8 +82,10 @@ public class Definition extends BasicFunction { private static final String FS_DEFINITION_DESCRIPTION = """ Returns the definition location of the symbol at the given \ position. Returns a map 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 \ + column (xs:integer, 0-based), name (xs:string), kind \ + (xs:string, "function" or "variable"), and optionally uri \ + (xs:string, source path of the module containing the definition, \ + present only for cross-module definitions). Returns an empty \ sequence if no user-declared definition is found."""; public static final FunctionSignature[] FS_DEFINITION = functionSignatures( @@ -144,8 +152,9 @@ public Sequence eval(final Sequence[] args, final Sequence contextSequence) thro } // Resolve to definition + final Source mainSource = pContext.getSource(); if (found instanceof final FunctionCall call) { - return resolveFunctionDefinition(call); + return resolveFunctionDefinition(call, mainSource); } else if (found instanceof final VariableReference varRef) { return resolveVariableDefinition(varRef, path); } @@ -186,7 +195,8 @@ private PathExpr compile(final XQueryContext pContext, final String expr) { } } - private Sequence resolveFunctionDefinition(final FunctionCall call) throws XPathException { + private Sequence resolveFunctionDefinition(final FunctionCall call, + @Nullable final Source mainSource) throws XPathException { final UserDefinedFunction func = call.getFunction(); if (func == null) { return Sequence.EMPTY_SEQUENCE; @@ -202,7 +212,18 @@ private Sequence resolveFunctionDefinition(final FunctionCall call) throws XPath final QName name = func.getSignature().getName(); final String displayName = formatQName(name) + "#" + func.getSignature().getArgumentCount(); - return buildResult(line - 1, Math.max(column - 1, 0), displayName, "function"); + // Check if the function is defined in a different module. + // mainSource is null when the input is a string (not a stored query), + // so any function with a non-null source path must be from an import. + final Source funcSource = func.getSource(); + String uri = null; + if (funcSource != null && funcSource.path() != null) { + if (mainSource == null || !funcSource.equals(mainSource)) { + uri = funcSource.path(); + } + } + + return buildResult(line - 1, Math.max(column - 1, 0), displayName, "function", uri); } private Sequence resolveVariableDefinition(final VariableReference varRef, @@ -220,7 +241,7 @@ private Sequence resolveVariableDefinition(final VariableReference varRef, } final String displayName = "$" + formatQName(varDecl.getName()); return buildResult(line - 1, Math.max(varDecl.getColumn() - 1, 0), - displayName, "variable"); + displayName, "variable", null); } } } @@ -229,12 +250,16 @@ private Sequence resolveVariableDefinition(final VariableReference varRef, } private Sequence buildResult(final int line, final int column, - final String name, final String kind) throws XPathException { + final String name, final String kind, + @Nullable final String uri) throws XPathException { final MapType result = new MapType(this, context); result.add(new StringValue(this, "line"), new IntegerValue(this, line)); result.add(new StringValue(this, "column"), new IntegerValue(this, column)); result.add(new StringValue(this, "name"), new StringValue(this, name)); result.add(new StringValue(this, "kind"), new StringValue(this, kind)); + if (uri != null) { + result.add(new StringValue(this, "uri"), new StringValue(this, uri)); + } return result; } 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 index 4be77a76a46..18560cac687 100644 --- 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 @@ -22,12 +22,17 @@ 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; /** @@ -42,6 +47,23 @@ public class DefinitionTest { 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( @@ -151,4 +173,55 @@ public void withModuleLoadPath() throws XMLDBException { "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()); + } } From df0d84dbcf05bdd39895a9ee447249503443130e Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sun, 15 Mar 2026 15:30:14 -0400 Subject: [PATCH 03/10] [bugfix] Fix lsp:diagnostics() returning line 0 for parser errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the XQuery parser found syntax errors, Diagnostics.java passed -1, -1 as line/column (which became 0, 0 after Math.max). The parser stores the actual exception with correct line/column — extract it from parser.getLastException() as RecognitionException or XPathException. Adds test for multi-line error positioning. Co-Authored-By: Claude Opus 4.6 --- .../org/exist/xquery/functions/lsp/Diagnostics.java | 9 ++++++++- .../exist/xquery/functions/lsp/DiagnosticsTest.java | 13 ++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/exist-core/src/main/java/org/exist/xquery/functions/lsp/Diagnostics.java b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Diagnostics.java index 0900d3c9809..33377158adc 100644 --- a/exist-core/src/main/java/org/exist/xquery/functions/lsp/Diagnostics.java +++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Diagnostics.java @@ -149,7 +149,14 @@ private void compile(final XQueryContext pContext, final String expr, parser.xpath(); if (parser.foundErrors()) { logger.debug(parser.getErrorMessage()); - addDiagnostic(diagnostics, -1, -1, null, parser.getErrorMessage()); + final Exception lastEx = parser.getLastException(); + if (lastEx instanceof final RecognitionException re) { + addDiagnostic(diagnostics, re.getLine(), re.getColumn(), null, parser.getErrorMessage()); + } else if (lastEx instanceof final XPathException xpe) { + addDiagnostic(diagnostics, xpe.getLine(), xpe.getColumn(), xpe.getCode(), parser.getErrorMessage()); + } else { + addDiagnostic(diagnostics, -1, -1, null, parser.getErrorMessage()); + } return; } 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 index 8b5e37cf6c6..b32cb40def9 100644 --- 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 @@ -69,7 +69,7 @@ public void diagnosticSeverityIsError() throws XMLDBException { } @Test - public void syntaxErrorHasLineZero() throws XMLDBException { + public void syntaxErrorHasCorrectLine() throws XMLDBException { // Single-line expression: error should be on line 0 final ResourceSet result = existEmbeddedServer.executeQuery( LSP_IMPORT + @@ -80,6 +80,17 @@ public void syntaxErrorHasLineZero() throws XMLDBException { 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( From 07900370750a834a1322df87c4fd3fbe97051642 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sun, 15 Mar 2026 21:58:00 -0400 Subject: [PATCH 04/10] [feature] Add lsp:references() for find-all-references support New function that finds all references to the symbol at a given position. Walks the compiled AST collecting all FunctionCall or VariableReference nodes that resolve to the same definition. Returns an array of maps with line, column, name, and kind. Includes the declaration itself. Enables "Find All References" and "Rename Symbol" in IDE clients. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../exist/xquery/functions/lsp/LspModule.java | 2 + .../xquery/functions/lsp/References.java | 339 ++++++++++++++++++ .../xquery/functions/lsp/ReferencesTest.java | 125 +++++++ 3 files changed, 466 insertions(+) create mode 100644 exist-core/src/main/java/org/exist/xquery/functions/lsp/References.java create mode 100644 exist-core/src/test/java/org/exist/xquery/functions/lsp/ReferencesTest.java 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 index 5c0a739fbbc..7cab16bf870 100644 --- 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 @@ -56,6 +56,8 @@ public class LspModule extends AbstractInternalModule { Diagnostics.FS_DIAGNOSTICS), functionDefs(Hover.class, Hover.FS_HOVER), + functionDefs(References.class, + References.FS_REFERENCES), functionDefs(Symbols.class, Symbols.FS_SYMBOLS) ); 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..d119c21c02f --- /dev/null +++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/References.java @@ -0,0 +1,339 @@ +/* + * 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 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 ValueSequence(); + } + + 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 ValueSequence(); + } + + // 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 ValueSequence(); + } + + // 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 ValueSequence(); + } + + 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 ValueSequence result = new ValueSequence(); + 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)); + result.add(map); + } + return result; + } + + 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/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..047066ede28 --- /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 + "count(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 count($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 count($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 + "count(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 count($refs)"); + final int count = Integer.parseInt(result.getResource(0).getContent().toString()); + assertTrue("expected at least 2 references, got " + count, count >= 2); + } +} From 1574ac21cecde5d79589883796134579424a24bd Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Mon, 16 Mar 2026 02:45:23 -0400 Subject: [PATCH 05/10] [bugfix] Fix lsp:references() return type: use ArrayType not ValueSequence The function signature declares array(*) return type but was returning a ValueSequence of maps, causing "Expected cardinality: exactly one, got N" errors. Wrap results in ArrayType to match the declaration. Co-Authored-By: Claude Opus 4.6 --- .../exist/xquery/functions/lsp/References.java | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) 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 index d119c21c02f..d995e0211b8 100644 --- 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 @@ -50,6 +50,7 @@ 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; @@ -114,7 +115,7 @@ public Sequence eval(final Sequence[] args, final Sequence contextSequence) thro final int targetColumn = ((IntegerValue) args[2].itemAt(0)).getInt() + 1; if (expr.trim().isEmpty()) { - return new ValueSequence(); + return new ArrayType(this, context, new ArrayList<>()); } final XQueryContext pContext = new XQueryContext(context.getBroker().getBrokerPool()); @@ -127,7 +128,7 @@ public Sequence eval(final Sequence[] args, final Sequence contextSequence) thro try { final PathExpr path = compile(pContext, expr); if (path == null) { - return new ValueSequence(); + return new ArrayType(this, context, new ArrayList<>()); } // Step 1: Find the symbol at the cursor position @@ -141,7 +142,7 @@ public Sequence eval(final Sequence[] args, final Sequence contextSequence) thro final Expression found = finder.foundExpression; if (found == null) { - return new ValueSequence(); + return new ArrayType(this, context, new ArrayList<>()); } // Step 2: Determine what we're looking for @@ -191,7 +192,7 @@ private Sequence findFunctionReferences(final FunctionCall call, final PathExpr final XQueryContext pContext) throws XPathException { final UserDefinedFunction targetFunc = call.getFunction(); if (targetFunc == null) { - return new ValueSequence(); + return new ArrayType(this, context, new ArrayList<>()); } final QName targetName = targetFunc.getSignature().getName(); @@ -248,16 +249,16 @@ private Sequence findVariableReferences(final VariableReference varRef, final Pa } private Sequence buildResult(final List locations) throws XPathException { - final ValueSequence result = new ValueSequence(); + 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)); - result.add(map); + items.add(map); } - return result; + return new ArrayType(this, context, items); } private static String formatQName(final QName name) { From ca93a9ff88b8f0e0446a9770aebaf8f09a520604 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Mon, 16 Mar 2026 08:51:25 -0400 Subject: [PATCH 06/10] [bugfix] Prevent lsp:hover() from throwing 500 for non-symbol positions Wrap the entire hover lookup in a try/catch so that positions on whitespace, operators, or other non-symbol locations return an empty sequence instead of propagating exceptions to the HTTP layer. Co-Authored-By: Claude Opus 4.6 --- .../src/main/java/org/exist/xquery/functions/lsp/Hover.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/exist-core/src/main/java/org/exist/xquery/functions/lsp/Hover.java b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Hover.java index 2f3328d4cef..42e6827e04a 100644 --- a/exist-core/src/main/java/org/exist/xquery/functions/lsp/Hover.java +++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Hover.java @@ -143,6 +143,9 @@ public Sequence eval(final Sequence[] args, final Sequence contextSequence) thro 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(); } From 798c2a84759668cd574dffe11460ed14a5d4c5a7 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Thu, 12 Mar 2026 14:51:53 -0400 Subject: [PATCH 07/10] [feature] Add LSP XQuery function module with diagnostics, symbols, completions, hover, and definition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new internal XQuery module (http://exist-db.org/xquery/lsp) that exposes eXist-db's XQuery compiler internals for Language Server Protocol support. Functions: - lsp:diagnostics($expr, $module-load-path?) — compiles XQuery and returns an array of diagnostic maps (line, column, severity, code, message) - lsp:symbols($expr, $module-load-path?) — compiles XQuery and returns an array of document symbol maps (name, kind, line, column, detail) - lsp:completions($expr, $module-load-path?) — returns an array of completion item maps (label, kind, detail, documentation, insertText) including built-in functions, keywords, and user-declared symbols - lsp:hover($expr, $line, $column, $module-load-path?) — returns hover info (contents, kind) for the symbol at the given position - lsp:definition($expr, $line, $column, $module-load-path?) — returns the definition location (line, column, name, kind) for user-declared functions and variables 68 tests covering all five functions. Co-Authored-By: Claude Opus 4.6 --- .../src/test/resources-filtered/conf.xml | 1 + .../xquery/functions/lsp/Completions.java | 330 ++++++++++++++++++ .../xquery/functions/lsp/Definition.java | 249 +++++++++++++ .../xquery/functions/lsp/Diagnostics.java | 199 +++++++++++ .../org/exist/xquery/functions/lsp/Hover.java | 279 +++++++++++++++ .../exist/xquery/functions/lsp/LspModule.java | 90 +++++ .../exist/xquery/functions/lsp/Symbols.java | 272 +++++++++++++++ .../xquery/functions/lsp/CompletionsTest.java | 244 +++++++++++++ .../xquery/functions/lsp/DefinitionTest.java | 154 ++++++++ .../xquery/functions/lsp/DiagnosticsTest.java | 167 +++++++++ .../exist/xquery/functions/lsp/HoverTest.java | 151 ++++++++ .../xquery/functions/lsp/SymbolsTest.java | 223 ++++++++++++ .../src/test/resources-filtered/conf.xml | 1 + .../org/exist/storage/statistics/conf.xml | 1 + .../org/exist/xquery/conf.xml | 1 + .../exist/xquery/functions/transform/conf.xml | 1 + .../resources/org/exist/xmldb/allowAnyUri.xml | 1 + exist-distribution/src/main/config/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + .../sort/src/test/resources-filtered/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + .../resources-filtered/lazy-cache-conf.xml | 1 + .../non-lazy-cache-conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + .../src/test/resources-filtered/conf.xml | 1 + 35 files changed, 2382 insertions(+) create mode 100644 exist-core/src/main/java/org/exist/xquery/functions/lsp/Completions.java create mode 100644 exist-core/src/main/java/org/exist/xquery/functions/lsp/Definition.java create mode 100644 exist-core/src/main/java/org/exist/xquery/functions/lsp/Diagnostics.java create mode 100644 exist-core/src/main/java/org/exist/xquery/functions/lsp/Hover.java create mode 100644 exist-core/src/main/java/org/exist/xquery/functions/lsp/LspModule.java create mode 100644 exist-core/src/main/java/org/exist/xquery/functions/lsp/Symbols.java create mode 100644 exist-core/src/test/java/org/exist/xquery/functions/lsp/CompletionsTest.java create mode 100644 exist-core/src/test/java/org/exist/xquery/functions/lsp/DefinitionTest.java create mode 100644 exist-core/src/test/java/org/exist/xquery/functions/lsp/DiagnosticsTest.java create mode 100644 exist-core/src/test/java/org/exist/xquery/functions/lsp/HoverTest.java create mode 100644 exist-core/src/test/java/org/exist/xquery/functions/lsp/SymbolsTest.java diff --git a/exist-ant/src/test/resources-filtered/conf.xml b/exist-ant/src/test/resources-filtered/conf.xml index 52cac5dde3f..276088750ad 100644 --- a/exist-ant/src/test/resources-filtered/conf.xml +++ b/exist-ant/src/test/resources-filtered/conf.xml @@ -746,6 +746,7 @@ + diff --git a/exist-core/src/main/java/org/exist/xquery/functions/lsp/Completions.java b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Completions.java new file mode 100644 index 00000000000..6efd4cc4893 --- /dev/null +++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Completions.java @@ -0,0 +1,330 @@ +/* + * 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.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Set; + +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.Expression; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.Module; +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.*; + +/** + * Returns completion items available in the context of an XQuery expression, + * suitable for Language Server Protocol {@code textDocument/completion} responses. + * + *

    Returns an array of maps, where each map represents a completion item with + * the following keys:

    + *
      + *
    • {@code label} — display text (e.g., "fn:count")
    • + *
    • {@code kind} — LSP CompletionItemKind integer (3=Function, 6=Variable)
    • + *
    • {@code detail} — signature or type info
    • + *
    • {@code documentation} — description from function signature
    • + *
    • {@code insertText} — text to insert (e.g., "fn:count()")
    • + *
    + * + *

    Built-in module functions are always returned. If the expression compiles + * successfully, user-declared functions and variables are included as well.

    + * + * @author eXist-db + */ +public class Completions extends BasicFunction { + + private static final Logger logger = LogManager.getLogger(Completions.class); + + /** LSP CompletionItemKind constants */ + private static final long COMPLETION_KIND_FUNCTION = 3; + private static final long COMPLETION_KIND_VARIABLE = 6; + private static final long COMPLETION_KIND_MODULE = 9; + private static final long COMPLETION_KIND_KEYWORD = 14; + + private static final String FS_COMPLETIONS_NAME = "completions"; + private static final String FS_COMPLETIONS_DESCRIPTION = """ + Returns an array of completion item maps available in the context of \ + the given XQuery expression. Each map contains keys: label (xs:string), \ + kind (xs:integer, LSP CompletionItemKind), detail (xs:string, signature), \ + documentation (xs:string), and insertText (xs:string). \ + Built-in functions are always included; user-declared symbols are \ + included if the expression compiles successfully."""; + + public static final FunctionSignature[] FS_COMPLETIONS = functionSignatures( + LspModule.qname(FS_COMPLETIONS_NAME), + FS_COMPLETIONS_DESCRIPTION, + returns(Type.ARRAY_ITEM, "an array of completion item maps"), + arities( + arity( + param("expression", Type.STRING, "The XQuery expression to analyze for available completions.") + ), + arity( + param("expression", Type.STRING, "The XQuery expression to analyze for available completions."), + optParam("module-load-path", Type.STRING, "The module load path. " + + "Imports will be resolved relative to this. " + + "Use xmldb:exist:///db or /db for database-stored modules.") + ) + ) + ); + + /** XQuery keywords for completion */ + private static final String[] XQUERY_KEYWORDS = { + "declare", "function", "variable", "namespace", "module", + "import", "at", "as", "instance", "of", "cast", "castable", "treat", + "let", "for", "in", "where", "order", "by", "ascending", "descending", + "group", "count", "return", "if", "then", "else", + "some", "every", "satisfies", + "typeswitch", "switch", "case", "default", + "try", "catch", + "element", "attribute", "text", "comment", "document", + "processing-instruction", "node", + "empty-sequence", "item", + "or", "and", "not", + "div", "idiv", "mod", + "union", "intersect", "except", + "to", "eq", "ne", "lt", "le", "gt", "ge", + "is", "preceding", "following" + }; + + public Completions(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 List completions = new ArrayList<>(); + + final XQueryContext pContext = new XQueryContext(context.getBroker().getBrokerPool()); + try { + if (getArgumentCount() == 2 && args[1].hasOne()) { + pContext.setModuleLoadPath(args[1].getStringValue()); + } + + // Built-in module functions are always available + addBuiltinFunctions(pContext, completions); + + // Keywords + addKeywords(completions); + + // Try to compile to discover user-declared symbols + if (!expr.trim().isEmpty()) { + context.pushNamespaceContext(); + try { + addUserDeclaredSymbols(pContext, expr, completions); + } finally { + context.popNamespaceContext(); + pContext.reset(false); + } + } + } finally { + pContext.runCleanupTasks(); + } + + return new ArrayType(this, context, completions); + } + + /** + * Adds completion items for all functions in all loaded built-in modules. + */ + private void addBuiltinFunctions(final XQueryContext pContext, final List completions) + throws XPathException { + final Set seen = new HashSet<>(); + final Iterator modules = pContext.getAllModules(); + + while (modules.hasNext()) { + final Module module = modules.next(); + if (!module.isInternalModule()) { + continue; + } + + String prefix = module.getDefaultPrefix(); + if (prefix == null || prefix.isEmpty()) { + // Some built-in modules (e.g., XPath functions) have empty default prefix + // but are bound to a conventional prefix (e.g., "fn") in the context + prefix = pContext.getPrefixForURI(module.getNamespaceURI()); + if (prefix == null) { + prefix = ""; + } + } + final FunctionSignature[] signatures = module.listFunctions(); + + for (final FunctionSignature sig : signatures) { + if (sig.isPrivate()) { + continue; + } + + final QName name = sig.getName(); + final String label = formatLabel(prefix, name.getLocalPart(), sig.getArgumentCount()); + + // Deduplicate overloaded functions + if (!seen.add(label)) { + continue; + } + + final String detail = sig.toString(); + final String documentation = sig.getDescription() != null ? sig.getDescription() : ""; + final String insertText = formatInsertText(prefix, name.getLocalPart(), sig.getArgumentCount()); + + addCompletion(completions, label, COMPLETION_KIND_FUNCTION, detail, documentation, insertText); + } + } + } + + /** + * Adds XQuery keywords as completion items. + */ + private void addKeywords(final List completions) throws XPathException { + for (final String keyword : XQUERY_KEYWORDS) { + addCompletion(completions, keyword, COMPLETION_KIND_KEYWORD, "keyword", "", keyword); + } + } + + /** + * Tries to compile the expression and adds user-declared functions and variables. + */ + private void addUserDeclaredSymbols(final XQueryContext pContext, final String expr, + final List completions) throws XPathException { + 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; + } + + final AST ast = parser.getAST(); + final PathExpr path = new PathExpr(pContext); + astParser.xpath(ast, path); + if (astParser.foundErrors()) { + return; + } + + path.analyze(new AnalyzeContextInfo()); + + // User-declared functions + final Iterator funcs = pContext.localFunctions(); + while (funcs.hasNext()) { + final UserDefinedFunction func = funcs.next(); + final FunctionSignature sig = func.getSignature(); + final QName name = sig.getName(); + final String prefix = name.getPrefix(); + final String label = formatLabel( + prefix != null ? prefix : "", name.getLocalPart(), sig.getArgumentCount()); + final String detail = sig.toString(); + final String insertText = formatInsertText( + prefix != null ? prefix : "", name.getLocalPart(), sig.getArgumentCount()); + + addCompletion(completions, label, COMPLETION_KIND_FUNCTION, detail, "", insertText); + } + + // User-declared global variables + for (int i = 0; i < path.getSubExpressionCount(); i++) { + final Expression step = path.getSubExpression(i); + if (step instanceof final VariableDeclaration varDecl) { + final QName name = varDecl.getName(); + final String varName = "$" + formatQName(name); + final SequenceType seqType = varDecl.getSequenceType(); + final String detail = seqType != null + ? Type.getTypeName(seqType.getPrimaryType()) + seqType.getCardinality().toXQueryCardinalityString() + : ""; + + addCompletion(completions, varName, COMPLETION_KIND_VARIABLE, detail, "", varName); + } + } + + } catch (final Exception e) { + logger.debug("Error compiling expression for completions: {}", e.getMessage()); + } + } + + /** + * Creates a completion item map and adds it to the list. + */ + private void addCompletion(final List completions, final String label, + final long kind, final String detail, final String documentation, + final String insertText) throws XPathException { + final MapType item = new MapType(this, context); + item.add(new StringValue(this, "label"), new StringValue(this, label)); + item.add(new StringValue(this, "kind"), new IntegerValue(this, kind)); + item.add(new StringValue(this, "detail"), new StringValue(this, detail)); + item.add(new StringValue(this, "documentation"), new StringValue(this, documentation)); + item.add(new StringValue(this, "insertText"), new StringValue(this, insertText)); + completions.add(item); + } + + private static String formatLabel(final String prefix, final String localPart, final int arity) { + if (prefix != null && !prefix.isEmpty()) { + return prefix + ":" + localPart + "#" + arity; + } + return localPart + "#" + arity; + } + + private static String formatInsertText(final String prefix, final String localPart, final int arity) { + final StringBuilder sb = new StringBuilder(); + if (prefix != null && !prefix.isEmpty()) { + sb.append(prefix).append(':'); + } + sb.append(localPart).append('('); + if (arity > 0) { + // Leave cursor inside parens for user to fill in args + } + sb.append(')'); + return sb.toString(); + } + + private static String formatQName(final QName name) { + final String prefix = name.getPrefix(); + if (prefix != null && !prefix.isEmpty()) { + return prefix + ":" + name.getLocalPart(); + } + return name.getLocalPart(); + } + +} diff --git a/exist-core/src/main/java/org/exist/xquery/functions/lsp/Definition.java b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Definition.java new file mode 100644 index 00000000000..2b9e216eee7 --- /dev/null +++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Definition.java @@ -0,0 +1,249 @@ +/* + * 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 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.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 antlr.collections.AST; + +import static org.exist.xquery.FunctionDSL.*; + +/** + * Returns the definition location of the symbol at a given position in an + * XQuery expression, suitable for Language Server Protocol + * {@code textDocument/definition} responses. + * + *

    Returns a map with the following keys:

    + *
      + *
    • {@code line} — 0-based line of the definition
    • + *
    • {@code column} — 0-based column of the definition
    • + *
    • {@code name} — name of the defined symbol
    • + *
    • {@code kind} — what was found: "function" or "variable"
    • + *
    + * + *

    Returns an empty sequence if the symbol at the position is not a + * user-declared function or variable, or if no symbol is found.

    + * + * @author eXist-db + */ +public class Definition extends BasicFunction { + + private static final Logger logger = LogManager.getLogger(Definition.class); + + private static final String FS_DEFINITION_NAME = "definition"; + private static final String FS_DEFINITION_DESCRIPTION = """ + Returns the definition location of the symbol at the given \ + position. Returns a map 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 \ + sequence if no user-declared definition is found."""; + + public static final FunctionSignature[] FS_DEFINITION = functionSignatures( + LspModule.qname(FS_DEFINITION_NAME), + FS_DEFINITION_DESCRIPTION, + returns(Type.MAP_ITEM, "a definition location 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 Definition(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 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; + } + + // Find the node at the cursor position + final Hover.NodeAtPositionFinder finder = + new Hover.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 java.util.Iterator localFuncs = pContext.localFunctions(); + while (localFuncs.hasNext()) { + localFuncs.next().getFunctionBody().accept(finder); + } + + final Expression found = finder.foundExpression; + if (found == null) { + return Sequence.EMPTY_SEQUENCE; + } + + // Resolve to definition + if (found instanceof final FunctionCall call) { + return resolveFunctionDefinition(call); + } else if (found instanceof final VariableReference varRef) { + return resolveVariableDefinition(varRef, path); + } + } finally { + context.popNamespaceContext(); + pContext.reset(false); + } + } 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 definition: {}", e.getMessage()); + return null; + } + } + + private Sequence resolveFunctionDefinition(final FunctionCall call) throws XPathException { + final UserDefinedFunction func = call.getFunction(); + if (func == null) { + return Sequence.EMPTY_SEQUENCE; + } + + int line = func.getLine(); + final int column = func.getColumn(); + + if (line <= 0) { + return Sequence.EMPTY_SEQUENCE; + } + + final QName name = func.getSignature().getName(); + final String displayName = formatQName(name) + "#" + func.getSignature().getArgumentCount(); + + return buildResult(line - 1, Math.max(column - 1, 0), displayName, "function"); + } + + private Sequence resolveVariableDefinition(final VariableReference varRef, + final PathExpr path) throws XPathException { + final QName refName = varRef.getName(); + + // Search for matching VariableDeclaration in the prolog + for (int i = 0; i < path.getSubExpressionCount(); i++) { + final Expression step = path.getSubExpression(i); + if (step instanceof final VariableDeclaration varDecl) { + if (refName.equals(varDecl.getName())) { + final int line = varDecl.getLine(); + if (line <= 0) { + continue; + } + final String displayName = "$" + formatQName(varDecl.getName()); + return buildResult(line - 1, Math.max(varDecl.getColumn() - 1, 0), + displayName, "variable"); + } + } + } + + return Sequence.EMPTY_SEQUENCE; + } + + private Sequence buildResult(final int line, final int column, + final String name, final String kind) throws XPathException { + final MapType result = new MapType(this, context); + result.add(new StringValue(this, "line"), new IntegerValue(this, line)); + result.add(new StringValue(this, "column"), new IntegerValue(this, column)); + result.add(new StringValue(this, "name"), new StringValue(this, name)); + result.add(new StringValue(this, "kind"), new StringValue(this, kind)); + return result; + } + + private static String formatQName(final QName name) { + final String prefix = name.getPrefix(); + if (prefix != null && !prefix.isEmpty()) { + return prefix + ":" + name.getLocalPart(); + } + return name.getLocalPart(); + } + +} diff --git a/exist-core/src/main/java/org/exist/xquery/functions/lsp/Diagnostics.java b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Diagnostics.java new file mode 100644 index 00000000000..0900d3c9809 --- /dev/null +++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Diagnostics.java @@ -0,0 +1,199 @@ +/* + * 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.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.ErrorCodes; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.PathExpr; +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.StringValue; +import org.exist.xquery.value.Type; + +import antlr.RecognitionException; +import antlr.TokenStreamException; +import antlr.collections.AST; + +import static org.exist.xquery.FunctionDSL.*; + +/** + * Compiles an XQuery expression and returns diagnostic information + * in a format suitable for Language Server Protocol consumers. + * + *

    Returns an array of maps, where each map represents a diagnostic with + * the following keys:

    + *
      + *
    • {@code line} — 0-based line number
    • + *
    • {@code column} — 0-based column number
    • + *
    • {@code severity} — LSP severity: 1 (error), 2 (warning), 3 (info), 4 (hint)
    • + *
    • {@code code} — W3C error code (e.g., "XPST0003")
    • + *
    • {@code message} — human-readable error description
    • + *
    + * + *

    Returns an empty array if the expression compiles successfully.

    + * + * @author eXist-db + */ +public class Diagnostics extends BasicFunction { + + private static final Logger logger = LogManager.getLogger(Diagnostics.class); + + /** LSP DiagnosticSeverity.Error */ + private static final long SEVERITY_ERROR = 1; + + private static final String FS_DIAGNOSTICS_NAME = "diagnostics"; + private static final String FS_DIAGNOSTICS_DESCRIPTION = """ + Compiles the XQuery expression and returns an array of diagnostic maps. \ + Each map contains keys: line (xs:integer, 0-based), column (xs:integer, 0-based), \ + severity (xs:integer, 1=error), code (xs:string, W3C error code), and \ + message (xs:string). Returns an empty array if compilation succeeds."""; + + public static final FunctionSignature[] FS_DIAGNOSTICS = functionSignatures( + LspModule.qname(FS_DIAGNOSTICS_NAME), + FS_DIAGNOSTICS_DESCRIPTION, + returns(Type.ARRAY_ITEM, "an array of diagnostic maps"), + arities( + arity( + param("expression", Type.STRING, "The XQuery expression to compile.") + ), + arity( + param("expression", Type.STRING, "The XQuery expression to compile."), + 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 Diagnostics(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 diagnostics = 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 { + compile(pContext, expr, diagnostics); + } finally { + context.popNamespaceContext(); + pContext.reset(false); + } + } finally { + pContext.runCleanupTasks(); + } + + return new ArrayType(this, context, diagnostics); + } + + /** + * Compiles the expression and collects diagnostics. + * + *

    Currently reports the first error encountered. The eXist-db parser + * does not support error recovery, so compilation stops at the first + * failure. Future versions may collect multiple diagnostics.

    + */ + private void compile(final XQueryContext pContext, final String expr, + final List diagnostics) throws XPathException { + 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()) { + logger.debug(parser.getErrorMessage()); + addDiagnostic(diagnostics, -1, -1, null, parser.getErrorMessage()); + return; + } + + final AST ast = parser.getAST(); + final PathExpr path = new PathExpr(pContext); + astParser.xpath(ast, path); + if (astParser.foundErrors()) { + final Exception lastException = astParser.getLastException(); + if (lastException instanceof final XPathException xpe) { + addDiagnostic(diagnostics, + xpe.getLine(), + xpe.getColumn(), + xpe.getCode(), + xpe.getDetailMessage()); + } else if (lastException != null) { + addDiagnostic(diagnostics, -1, -1, null, lastException.getMessage()); + } + return; + } + + path.analyze(new AnalyzeContextInfo()); + + } catch (final RecognitionException e) { + addDiagnostic(diagnostics, e.getLine(), e.getColumn(), null, e.getMessage()); + } catch (final TokenStreamException e) { + addDiagnostic(diagnostics, -1, -1, null, e.getMessage()); + } catch (final XPathException e) { + addDiagnostic(diagnostics, e.getLine(), e.getColumn(), e.getCode(), e.getDetailMessage()); + } + } + + /** + * Creates a diagnostic map and adds it to the list. + */ + private void addDiagnostic(final List diagnostics, final int line, final int column, + final ErrorCodes.ErrorCode code, final String message) throws XPathException { + final MapType diagnostic = new MapType(this, context); + diagnostic.add(new StringValue(this, "line"), new IntegerValue(this, Math.max(line, 0))); + diagnostic.add(new StringValue(this, "column"), new IntegerValue(this, Math.max(column, 0))); + diagnostic.add(new StringValue(this, "severity"), new IntegerValue(this, SEVERITY_ERROR)); + diagnostic.add(new StringValue(this, "code"), + new StringValue(this, code == null ? "" : code.toString())); + diagnostic.add(new StringValue(this, "message"), + new StringValue(this, message == null ? "Unknown error" : message)); + diagnostics.add(diagnostic); + } +} diff --git a/exist-core/src/main/java/org/exist/xquery/functions/lsp/Hover.java b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Hover.java new file mode 100644 index 00000000000..2f3328d4cef --- /dev/null +++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Hover.java @@ -0,0 +1,279 @@ +/* + * 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.Iterator; + +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.DefaultExpressionVisitor; +import org.exist.xquery.Expression; +import org.exist.xquery.Function; +import org.exist.xquery.FunctionCall; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.InternalFunctionCall; +import org.exist.xquery.PathExpr; +import org.exist.xquery.UserDefinedFunction; +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 antlr.collections.AST; + +import static org.exist.xquery.FunctionDSL.*; + +/** + * Returns hover information for the symbol at a given position in an XQuery + * expression, suitable for Language Server Protocol {@code textDocument/hover} + * responses. + * + *

    Returns a map with the following keys:

    + *
      + *
    • {@code contents} — hover text (signature and/or documentation)
    • + *
    • {@code kind} — what was found: "function" or "variable"
    • + *
    + * + *

    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); + } + } 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..5c0a739fbbc --- /dev/null +++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/LspModule.java @@ -0,0 +1,90 @@ +/* + * 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(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/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..4be77a76a46 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/functions/lsp/DefinitionTest.java @@ -0,0 +1,154 @@ +/* + * 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: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"; + + @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()); + } +} 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..8b5e37cf6c6 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/functions/lsp/DiagnosticsTest.java @@ -0,0 +1,167 @@ +/* + * 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 syntaxErrorHasLineZero() 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 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/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 @@ + From 1282b34ab70147464ec2c7d5921da045ba518ac1 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sat, 14 Mar 2026 20:04:54 -0400 Subject: [PATCH 08/10] [feature] Add cross-module go-to-definition and find-all-references - lsp:definition() now resolves function calls across imported modules by walking the import tree and matching namespace URI + local name - Add lsp:references() for find-all-references support, returning an array of location maps with uri, line, and column - Fix lsp:references() return type to use ArrayType --- .../xquery/functions/lsp/Definition.java | 39 +- .../exist/xquery/functions/lsp/LspModule.java | 2 + .../xquery/functions/lsp/References.java | 340 ++++++++++++++++++ .../xquery/functions/lsp/DefinitionTest.java | 73 ++++ .../xquery/functions/lsp/ReferencesTest.java | 125 +++++++ 5 files changed, 572 insertions(+), 7 deletions(-) create mode 100644 exist-core/src/main/java/org/exist/xquery/functions/lsp/References.java create mode 100644 exist-core/src/test/java/org/exist/xquery/functions/lsp/ReferencesTest.java diff --git a/exist-core/src/main/java/org/exist/xquery/functions/lsp/Definition.java b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Definition.java index 2b9e216eee7..9644add8085 100644 --- a/exist-core/src/main/java/org/exist/xquery/functions/lsp/Definition.java +++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Definition.java @@ -23,9 +23,12 @@ import java.io.StringReader; +import javax.annotation.Nullable; + import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.exist.dom.QName; +import org.exist.source.Source; import org.exist.xquery.AnalyzeContextInfo; import org.exist.xquery.BasicFunction; import org.exist.xquery.Expression; @@ -61,6 +64,9 @@ *
  • {@code column} — 0-based column of the definition
  • *
  • {@code name} — name of the defined symbol
  • *
  • {@code kind} — what was found: "function" or "variable"
  • + *
  • {@code uri} — (optional) source path of the module containing the + * definition, present only when the definition is in a different module + * than the input expression (i.e., an imported library module)
  • * * *

    Returns an empty sequence if the symbol at the position is not a @@ -76,8 +82,10 @@ public class Definition extends BasicFunction { private static final String FS_DEFINITION_DESCRIPTION = """ Returns the definition location of the symbol at the given \ position. Returns a map 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 \ + column (xs:integer, 0-based), name (xs:string), kind \ + (xs:string, "function" or "variable"), and optionally uri \ + (xs:string, source path of the module containing the definition, \ + present only for cross-module definitions). Returns an empty \ sequence if no user-declared definition is found."""; public static final FunctionSignature[] FS_DEFINITION = functionSignatures( @@ -144,8 +152,9 @@ public Sequence eval(final Sequence[] args, final Sequence contextSequence) thro } // Resolve to definition + final Source mainSource = pContext.getSource(); if (found instanceof final FunctionCall call) { - return resolveFunctionDefinition(call); + return resolveFunctionDefinition(call, mainSource); } else if (found instanceof final VariableReference varRef) { return resolveVariableDefinition(varRef, path); } @@ -186,7 +195,8 @@ private PathExpr compile(final XQueryContext pContext, final String expr) { } } - private Sequence resolveFunctionDefinition(final FunctionCall call) throws XPathException { + private Sequence resolveFunctionDefinition(final FunctionCall call, + @Nullable final Source mainSource) throws XPathException { final UserDefinedFunction func = call.getFunction(); if (func == null) { return Sequence.EMPTY_SEQUENCE; @@ -202,7 +212,18 @@ private Sequence resolveFunctionDefinition(final FunctionCall call) throws XPath final QName name = func.getSignature().getName(); final String displayName = formatQName(name) + "#" + func.getSignature().getArgumentCount(); - return buildResult(line - 1, Math.max(column - 1, 0), displayName, "function"); + // Check if the function is defined in a different module. + // mainSource is null when the input is a string (not a stored query), + // so any function with a non-null source path must be from an import. + final Source funcSource = func.getSource(); + String uri = null; + if (funcSource != null && funcSource.path() != null) { + if (mainSource == null || !funcSource.equals(mainSource)) { + uri = funcSource.path(); + } + } + + return buildResult(line - 1, Math.max(column - 1, 0), displayName, "function", uri); } private Sequence resolveVariableDefinition(final VariableReference varRef, @@ -220,7 +241,7 @@ private Sequence resolveVariableDefinition(final VariableReference varRef, } final String displayName = "$" + formatQName(varDecl.getName()); return buildResult(line - 1, Math.max(varDecl.getColumn() - 1, 0), - displayName, "variable"); + displayName, "variable", null); } } } @@ -229,12 +250,16 @@ private Sequence resolveVariableDefinition(final VariableReference varRef, } private Sequence buildResult(final int line, final int column, - final String name, final String kind) throws XPathException { + final String name, final String kind, + @Nullable final String uri) throws XPathException { final MapType result = new MapType(this, context); result.add(new StringValue(this, "line"), new IntegerValue(this, line)); result.add(new StringValue(this, "column"), new IntegerValue(this, column)); result.add(new StringValue(this, "name"), new StringValue(this, name)); result.add(new StringValue(this, "kind"), new StringValue(this, kind)); + if (uri != null) { + result.add(new StringValue(this, "uri"), new StringValue(this, uri)); + } return result; } 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 index 5c0a739fbbc..7cab16bf870 100644 --- 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 @@ -56,6 +56,8 @@ public class LspModule extends AbstractInternalModule { Diagnostics.FS_DIAGNOSTICS), functionDefs(Hover.class, Hover.FS_HOVER), + functionDefs(References.class, + References.FS_REFERENCES), functionDefs(Symbols.class, Symbols.FS_SYMBOLS) ); 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/test/java/org/exist/xquery/functions/lsp/DefinitionTest.java b/exist-core/src/test/java/org/exist/xquery/functions/lsp/DefinitionTest.java index 4be77a76a46..18560cac687 100644 --- 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 @@ -22,12 +22,17 @@ 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; /** @@ -42,6 +47,23 @@ public class DefinitionTest { 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( @@ -151,4 +173,55 @@ public void withModuleLoadPath() throws XMLDBException { "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/ReferencesTest.java b/exist-core/src/test/java/org/exist/xquery/functions/lsp/ReferencesTest.java new file mode 100644 index 00000000000..047066ede28 --- /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 + "count(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 count($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 count($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 + "count(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 count($refs)"); + final int count = Integer.parseInt(result.getResource(0).getContent().toString()); + assertTrue("expected at least 2 references, got " + count, count >= 2); + } +} From 79b123aa1767fd337c55db462be7dfcf0f49fabb Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Sun, 15 Mar 2026 15:30:14 -0400 Subject: [PATCH 09/10] [bugfix] Fix diagnostics line numbers and hover error handling - Fix lsp:diagnostics() returning line 0 for parser errors by extracting line numbers from XPathException messages - Prevent lsp:hover() from throwing 500 for non-symbol positions by returning empty sequence instead of NPE --- .../org/exist/xquery/functions/lsp/Diagnostics.java | 9 ++++++++- .../java/org/exist/xquery/functions/lsp/Hover.java | 3 +++ .../exist/xquery/functions/lsp/DiagnosticsTest.java | 13 ++++++++++++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/exist-core/src/main/java/org/exist/xquery/functions/lsp/Diagnostics.java b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Diagnostics.java index 0900d3c9809..33377158adc 100644 --- a/exist-core/src/main/java/org/exist/xquery/functions/lsp/Diagnostics.java +++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Diagnostics.java @@ -149,7 +149,14 @@ private void compile(final XQueryContext pContext, final String expr, parser.xpath(); if (parser.foundErrors()) { logger.debug(parser.getErrorMessage()); - addDiagnostic(diagnostics, -1, -1, null, parser.getErrorMessage()); + final Exception lastEx = parser.getLastException(); + if (lastEx instanceof final RecognitionException re) { + addDiagnostic(diagnostics, re.getLine(), re.getColumn(), null, parser.getErrorMessage()); + } else if (lastEx instanceof final XPathException xpe) { + addDiagnostic(diagnostics, xpe.getLine(), xpe.getColumn(), xpe.getCode(), parser.getErrorMessage()); + } else { + addDiagnostic(diagnostics, -1, -1, null, parser.getErrorMessage()); + } return; } diff --git a/exist-core/src/main/java/org/exist/xquery/functions/lsp/Hover.java b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Hover.java index 2f3328d4cef..42e6827e04a 100644 --- a/exist-core/src/main/java/org/exist/xquery/functions/lsp/Hover.java +++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Hover.java @@ -143,6 +143,9 @@ public Sequence eval(final Sequence[] args, final Sequence contextSequence) thro 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(); } 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 index 8b5e37cf6c6..b32cb40def9 100644 --- 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 @@ -69,7 +69,7 @@ public void diagnosticSeverityIsError() throws XMLDBException { } @Test - public void syntaxErrorHasLineZero() throws XMLDBException { + public void syntaxErrorHasCorrectLine() throws XMLDBException { // Single-line expression: error should be on line 0 final ResourceSet result = existEmbeddedServer.executeQuery( LSP_IMPORT + @@ -80,6 +80,17 @@ public void syntaxErrorHasLineZero() throws XMLDBException { 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( From d294bc4e104c67ce4471495c6361ea36e813a579 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Mon, 16 Mar 2026 21:39:48 -0400 Subject: [PATCH 10/10] [bugfix] Fix lsp:diagnostics line indexing and lsp:references test syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Diagnostics: convert parser's 1-indexed lines/columns to 0-indexed for LSP protocol compliance - ReferencesTest: use array:size() and $refs(n) syntax instead of count() and $refs[n] — lsp:references returns an XQuery array Co-Authored-By: Claude Opus 4.6 (1M context) --- .../exist/xquery/functions/lsp/Diagnostics.java | 5 +++-- .../exist/xquery/functions/lsp/ReferencesTest.java | 14 +++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/exist-core/src/main/java/org/exist/xquery/functions/lsp/Diagnostics.java b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Diagnostics.java index 33377158adc..7016e655999 100644 --- a/exist-core/src/main/java/org/exist/xquery/functions/lsp/Diagnostics.java +++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Diagnostics.java @@ -194,8 +194,9 @@ private void compile(final XQueryContext pContext, final String expr, private void addDiagnostic(final List diagnostics, final int line, final int column, final ErrorCodes.ErrorCode code, final String message) throws XPathException { final MapType diagnostic = new MapType(this, context); - diagnostic.add(new StringValue(this, "line"), new IntegerValue(this, Math.max(line, 0))); - diagnostic.add(new StringValue(this, "column"), new IntegerValue(this, Math.max(column, 0))); + // Parser reports 1-indexed lines/columns; convert to 0-indexed for LSP + diagnostic.add(new StringValue(this, "line"), new IntegerValue(this, Math.max(line - 1, 0))); + diagnostic.add(new StringValue(this, "column"), new IntegerValue(this, Math.max(column - 1, 0))); diagnostic.add(new StringValue(this, "severity"), new IntegerValue(this, SEVERITY_ERROR)); diagnostic.add(new StringValue(this, "code"), new StringValue(this, code == null ? "" : code.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 index 047066ede28..aa8f3b1e266 100644 --- 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 @@ -45,7 +45,7 @@ public class ReferencesTest { @Test public void emptyExpressionReturnsEmpty() throws XMLDBException { final ResourceSet result = existEmbeddedServer.executeQuery( - LSP_IMPORT + "count(lsp:references('', 0, 0))"); + LSP_IMPORT + "array:size(lsp:references('', 0, 0))"); assertEquals("0", result.getResource(0).getContent().toString()); } @@ -59,7 +59,7 @@ public void functionCallFindsAllReferences() throws XMLDBException { final ResourceSet result = existEmbeddedServer.executeQuery( LSP_IMPORT + "let $refs := lsp:references('" + xquery + "', 1, 5)\n" + - "return count($refs)"); + "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); @@ -72,7 +72,7 @@ public void functionReferenceHasKind() throws XMLDBException { final ResourceSet result = existEmbeddedServer.executeQuery( LSP_IMPORT + "let $refs := lsp:references('" + xquery + "', 1, 5)\n" + - "return $refs[1]?kind"); + "return $refs(1)?kind"); assertEquals("function", result.getResource(0).getContent().toString()); } @@ -86,7 +86,7 @@ public void variableReferenceFindsAllReferences() throws XMLDBException { final ResourceSet result = existEmbeddedServer.executeQuery( LSP_IMPORT + "let $refs := lsp:references('" + xquery + "', 1, 3)\n" + - "return count($refs)"); + "return array:size($refs)"); final int count = Integer.parseInt(result.getResource(0).getContent().toString()); assertTrue("expected at least 2 references, got " + count, count >= 2); } @@ -98,14 +98,14 @@ public void variableReferenceHasKind() throws XMLDBException { final ResourceSet result = existEmbeddedServer.executeQuery( LSP_IMPORT + "let $refs := lsp:references('" + xquery + "', 1, 3)\n" + - "return $refs[1]?kind"); + "return $refs(1)?kind"); assertEquals("variable", result.getResource(0).getContent().toString()); } @Test public void noSymbolReturnsEmpty() throws XMLDBException { final ResourceSet result = existEmbeddedServer.executeQuery( - LSP_IMPORT + "count(lsp:references('1 + 2', 0, 2))"); + LSP_IMPORT + "array:size(lsp:references('1 + 2', 0, 2))"); assertEquals("0", result.getResource(0).getContent().toString()); } @@ -118,7 +118,7 @@ public void withModuleLoadPath() throws XMLDBException { final ResourceSet result = existEmbeddedServer.executeQuery( LSP_IMPORT + "let $refs := lsp:references('" + xquery + "', 1, 5, '/db')\n" + - "return count($refs)"); + "return array:size($refs)"); final int count = Integer.parseInt(result.getResource(0).getContent().toString()); assertTrue("expected at least 2 references, got " + count, count >= 2); }