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..9644add8085 --- /dev/null +++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Definition.java @@ -0,0 +1,274 @@ +/* + * 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 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; +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"
  • + *
  • {@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 + * 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), 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( + 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 + final Source mainSource = pContext.getSource(); + if (found instanceof final FunctionCall call) { + return resolveFunctionDefinition(call, mainSource); + } 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, + @Nullable final Source mainSource) 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(); + + // 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, + 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", null); + } + } + } + + return Sequence.EMPTY_SEQUENCE; + } + + private Sequence buildResult(final int line, final int column, + 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; + } + + 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..7016e655999 --- /dev/null +++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Diagnostics.java @@ -0,0 +1,207 @@ +/* + * 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()); + 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; + } + + 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); + // 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())); + 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..42e6827e04a --- /dev/null +++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Hover.java @@ -0,0 +1,282 @@ +/* + * 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); + } + } catch (final Exception e) { + // Gracefully return empty for any position that can't be resolved + logger.debug("Error during hover lookup: {}", e.getMessage()); + } finally { + pContext.runCleanupTasks(); + } + + return Sequence.EMPTY_SEQUENCE; + } + + private PathExpr compile(final XQueryContext pContext, final String expr) { + try { + final XQueryLexer lexer = new XQueryLexer(pContext, new StringReader(expr)); + final XQueryParser parser = new XQueryParser(lexer); + final XQueryTreeParser astParser = new XQueryTreeParser(pContext); + + parser.xpath(); + if (parser.foundErrors()) { + return null; + } + + final AST ast = parser.getAST(); + final PathExpr path = new PathExpr(pContext); + astParser.xpath(ast, path); + if (astParser.foundErrors()) { + return null; + } + + path.analyze(new AnalyzeContextInfo()); + return path; + } catch (final Exception e) { + logger.debug("Error compiling expression for hover: {}", e.getMessage()); + return null; + } + } + + private Sequence buildHoverResult(final Expression expr) throws XPathException { + if (expr instanceof final FunctionCall call) { + return buildFunctionHover(call.getSignature(), call.getFunction()); + } else if (expr instanceof final Function func) { + // Built-in function (inner function from InternalFunctionCall) + return buildFunctionHover(func.getSignature(), null); + } else if (expr instanceof final VariableReference varRef) { + return buildVariableHover(varRef); + } + return Sequence.EMPTY_SEQUENCE; + } + + private Sequence buildFunctionHover(final FunctionSignature sig, + final UserDefinedFunction udf) throws XPathException { + final StringBuilder contents = new StringBuilder(); + contents.append(sig.toString()); + + final String description = sig.getDescription(); + if (description != null && !description.isEmpty()) { + contents.append("\n\n").append(description); + } + + final MapType result = new MapType(this, context); + result.add(new StringValue(this, "contents"), new StringValue(this, contents.toString())); + result.add(new StringValue(this, "kind"), new StringValue(this, "function")); + return result; + } + + private Sequence buildVariableHover(final VariableReference varRef) throws XPathException { + final org.exist.dom.QName name = varRef.getName(); + final String prefix = name.getPrefix(); + final String varName = (prefix != null && !prefix.isEmpty()) + ? "$" + prefix + ":" + name.getLocalPart() + : "$" + name.getLocalPart(); + + final MapType result = new MapType(this, context); + result.add(new StringValue(this, "contents"), new StringValue(this, varName)); + result.add(new StringValue(this, "kind"), new StringValue(this, "variable")); + return result; + } + + /** + * Visitor that traverses the full expression tree (including FLWOR clauses) + * to find the best-matching FunctionCall, InternalFunctionCall, or + * VariableReference at the given position. + * + *

Uses {@link DefaultExpressionVisitor} for traversal since it knows + * how to enter FLWOR expressions, which don't expose children via + * {@code getSubExpressionCount()}.

+ */ + static class NodeAtPositionFinder extends DefaultExpressionVisitor { + private final int targetLine; + private final int targetColumn; + Expression foundExpression; + private int bestColumn = -1; + + NodeAtPositionFinder(final int targetLine, final int targetColumn) { + this.targetLine = targetLine; + this.targetColumn = targetColumn; + } + + @Override + public void visitFunctionCall(final FunctionCall call) { + checkExpression(call); + super.visitFunctionCall(call); + } + + @Override + public void visitBuiltinFunction(final Function function) { + // The InternalFunctionCall wrapper delegates accept() to the inner + // function, so we receive the inner BasicFunction here. Check position + // on the inner function (which inherits position from the wrapper + // via the compilation process). + checkExpression(function); + super.visitBuiltinFunction(function); + } + + @Override + public void visitVariableReference(final VariableReference ref) { + checkExpression(ref); + } + + @Override + public void visit(final Expression expression) { + // Traverse children for generic expressions + for (int i = 0; i < expression.getSubExpressionCount(); i++) { + expression.getSubExpression(i).accept(this); + } + } + + private void checkExpression(final Expression expr) { + final int line = expr.getLine(); + final int column = expr.getColumn(); + + if (line == targetLine && column <= targetColumn && column > bestColumn) { + foundExpression = expr; + bestColumn = column; + } + } + } +} diff --git a/exist-core/src/main/java/org/exist/xquery/functions/lsp/LspModule.java b/exist-core/src/main/java/org/exist/xquery/functions/lsp/LspModule.java new file mode 100644 index 00000000000..7cab16bf870 --- /dev/null +++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/LspModule.java @@ -0,0 +1,92 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.functions.lsp; + +import org.exist.dom.QName; +import org.exist.xquery.AbstractInternalModule; +import org.exist.xquery.FunctionDef; + +import java.util.List; +import java.util.Map; + +import static org.exist.xquery.FunctionDSL.functionDefs; + +/** + * XQuery function module providing Language Server Protocol support. + * + *

This module exposes eXist-db's XQuery compiler internals as XQuery functions, + * enabling LSP servers to provide diagnostics, symbol information, and other + * language intelligence features.

+ * + * @author eXist-db + */ +public class LspModule extends AbstractInternalModule { + + public static final String NAMESPACE_URI = "http://exist-db.org/xquery/lsp"; + + public static final String PREFIX = "lsp"; + + public static final String RELEASE = "7.0.0"; + + public static final FunctionDef[] functions = functionDefs( + functionDefs(Completions.class, + Completions.FS_COMPLETIONS), + functionDefs(Definition.class, + Definition.FS_DEFINITION), + functionDefs(Diagnostics.class, + Diagnostics.FS_DIAGNOSTICS), + functionDefs(Hover.class, + Hover.FS_HOVER), + functionDefs(References.class, + References.FS_REFERENCES), + functionDefs(Symbols.class, + Symbols.FS_SYMBOLS) + ); + + public LspModule(final Map> parameters) { + super(functions, parameters, true); + } + + @Override + public String getNamespaceURI() { + return NAMESPACE_URI; + } + + @Override + public String getDefaultPrefix() { + return PREFIX; + } + + @Override + public String getDescription() { + return "Functions for Language Server Protocol support, exposing compiler diagnostics and symbol information"; + } + + @Override + public String getReleaseVersion() { + return RELEASE; + } + + static QName qname(final String localPart) { + return new QName(localPart, NAMESPACE_URI, PREFIX); + } +} diff --git a/exist-core/src/main/java/org/exist/xquery/functions/lsp/References.java b/exist-core/src/main/java/org/exist/xquery/functions/lsp/References.java new file mode 100644 index 00000000000..d995e0211b8 --- /dev/null +++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/References.java @@ -0,0 +1,340 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.functions.lsp; + +import java.io.StringReader; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.exist.dom.QName; +import org.exist.xquery.AnalyzeContextInfo; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.DefaultExpressionVisitor; +import org.exist.xquery.Expression; +import org.exist.xquery.FunctionCall; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.PathExpr; +import org.exist.xquery.UserDefinedFunction; +import org.exist.xquery.VariableDeclaration; +import org.exist.xquery.VariableReference; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.functions.map.MapType; +import org.exist.xquery.parser.XQueryLexer; +import org.exist.xquery.parser.XQueryParser; +import org.exist.xquery.parser.XQueryTreeParser; +import org.exist.xquery.value.IntegerValue; +import org.exist.xquery.value.Sequence; +import org.exist.xquery.value.StringValue; +import org.exist.xquery.value.Type; +import org.exist.xquery.value.ValueSequence; +import org.exist.xquery.functions.array.ArrayType; + +import antlr.collections.AST; + +import static org.exist.xquery.FunctionDSL.*; + +/** + * Finds all references to the symbol at a given position in an XQuery + * expression, suitable for Language Server Protocol + * {@code textDocument/references} responses. + * + *

Returns an array of maps, each with:

+ *
    + *
  • {@code line} — 0-based line of the reference
  • + *
  • {@code column} — 0-based column of the reference
  • + *
  • {@code name} — name of the referenced symbol
  • + *
  • {@code kind} — "function" or "variable"
  • + *
+ * + *

Includes the definition itself if {@code includeDeclaration} is true.

+ * + * @author eXist-db + */ +public class References extends BasicFunction { + + private static final Logger logger = LogManager.getLogger(References.class); + + private static final String FS_REFERENCES_NAME = "references"; + private static final String FS_REFERENCES_DESCRIPTION = """ + Finds all references to the symbol at the given position. \ + Returns an array of maps with keys: line (xs:integer, 0-based), \ + column (xs:integer, 0-based), name (xs:string), and kind \ + (xs:string, "function" or "variable"). Returns an empty \ + array if no symbol is found at the position."""; + + public static final FunctionSignature[] FS_REFERENCES = functionSignatures( + LspModule.qname(FS_REFERENCES_NAME), + FS_REFERENCES_DESCRIPTION, + returns(Type.ARRAY_ITEM, "an array of reference location maps"), + arities( + arity( + param("expression", Type.STRING, "The XQuery expression."), + param("line", Type.INTEGER, "0-based line number."), + param("column", Type.INTEGER, "0-based column number.") + ), + arity( + param("expression", Type.STRING, "The XQuery expression."), + param("line", Type.INTEGER, "0-based line number."), + param("column", Type.INTEGER, "0-based column number."), + optParam("module-load-path", Type.STRING, "The module load path.") + ) + ) + ); + + public References(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + final String expr = args[0].getStringValue(); + final int targetLine = ((IntegerValue) args[1].itemAt(0)).getInt() + 1; + final int targetColumn = ((IntegerValue) args[2].itemAt(0)).getInt() + 1; + + if (expr.trim().isEmpty()) { + return new ArrayType(this, context, new ArrayList<>()); + } + + final XQueryContext pContext = new XQueryContext(context.getBroker().getBrokerPool()); + try { + if (getArgumentCount() == 4 && args[3].hasOne()) { + pContext.setModuleLoadPath(args[3].getStringValue()); + } + + context.pushNamespaceContext(); + try { + final PathExpr path = compile(pContext, expr); + if (path == null) { + return new ArrayType(this, context, new ArrayList<>()); + } + + // Step 1: Find the symbol at the cursor position + final Hover.NodeAtPositionFinder finder = + new Hover.NodeAtPositionFinder(targetLine, targetColumn); + path.accept(finder); + final Iterator localFuncs = pContext.localFunctions(); + while (localFuncs.hasNext()) { + localFuncs.next().getFunctionBody().accept(finder); + } + + final Expression found = finder.foundExpression; + if (found == null) { + return new ArrayType(this, context, new ArrayList<>()); + } + + // Step 2: Determine what we're looking for + if (found instanceof final FunctionCall call) { + return findFunctionReferences(call, path, pContext); + } else if (found instanceof final VariableReference varRef) { + return findVariableReferences(varRef, path, pContext); + } + } finally { + context.popNamespaceContext(); + pContext.reset(false); + } + } finally { + pContext.runCleanupTasks(); + } + + return new ValueSequence(); + } + + private PathExpr compile(final XQueryContext pContext, final String expr) { + try { + final XQueryLexer lexer = new XQueryLexer(pContext, new StringReader(expr)); + final XQueryParser parser = new XQueryParser(lexer); + final XQueryTreeParser astParser = new XQueryTreeParser(pContext); + + parser.xpath(); + if (parser.foundErrors()) { + return null; + } + + final AST ast = parser.getAST(); + final PathExpr path = new PathExpr(pContext); + astParser.xpath(ast, path); + if (astParser.foundErrors()) { + return null; + } + + path.analyze(new AnalyzeContextInfo()); + return path; + } catch (final Exception e) { + logger.debug("Error compiling expression for references: {}", e.getMessage()); + return null; + } + } + + private Sequence findFunctionReferences(final FunctionCall call, final PathExpr path, + final XQueryContext pContext) throws XPathException { + final UserDefinedFunction targetFunc = call.getFunction(); + if (targetFunc == null) { + return new ArrayType(this, context, new ArrayList<>()); + } + + final QName targetName = targetFunc.getSignature().getName(); + final int targetArity = targetFunc.getSignature().getArgumentCount(); + + // Collect all calls to the same function + final FunctionReferenceCollector collector = + new FunctionReferenceCollector(targetName, targetArity); + path.accept(collector); + final Iterator localFuncs = pContext.localFunctions(); + while (localFuncs.hasNext()) { + final UserDefinedFunction udf = localFuncs.next(); + udf.getFunctionBody().accept(collector); + // Include the declaration itself + if (targetName.equals(udf.getSignature().getName()) + && targetArity == udf.getSignature().getArgumentCount()) { + final int line = udf.getLine(); + if (line > 0) { + collector.locations.add(new RefLocation( + line - 1, Math.max(udf.getColumn() - 1, 0), + formatQName(targetName) + "#" + targetArity, "function")); + } + } + } + + return buildResult(collector.locations); + } + + private Sequence findVariableReferences(final VariableReference varRef, final PathExpr path, + final XQueryContext pContext) throws XPathException { + final QName targetName = varRef.getName(); + + // Collect all references to the same variable + final VariableReferenceCollector collector = new VariableReferenceCollector(targetName); + path.accept(collector); + final Iterator localFuncs = pContext.localFunctions(); + while (localFuncs.hasNext()) { + localFuncs.next().getFunctionBody().accept(collector); + } + + // Include the declaration + for (int i = 0; i < path.getSubExpressionCount(); i++) { + final Expression step = path.getSubExpression(i); + if (step instanceof final VariableDeclaration varDecl) { + if (targetName.equals(varDecl.getName()) && varDecl.getLine() > 0) { + collector.locations.add(new RefLocation( + varDecl.getLine() - 1, Math.max(varDecl.getColumn() - 1, 0), + "$" + formatQName(targetName), "variable")); + } + } + } + + return buildResult(collector.locations); + } + + private Sequence buildResult(final List locations) throws XPathException { + final List items = new ArrayList<>(); + for (final RefLocation loc : locations) { + final MapType map = new MapType(this, context); + map.add(new StringValue(this, "line"), new IntegerValue(this, loc.line)); + map.add(new StringValue(this, "column"), new IntegerValue(this, loc.column)); + map.add(new StringValue(this, "name"), new StringValue(this, loc.name)); + map.add(new StringValue(this, "kind"), new StringValue(this, loc.kind)); + items.add(map); + } + return new ArrayType(this, context, items); + } + + private static String formatQName(final QName name) { + final String prefix = name.getPrefix(); + if (prefix != null && !prefix.isEmpty()) { + return prefix + ":" + name.getLocalPart(); + } + return name.getLocalPart(); + } + + // --- Helper classes --- + + private record RefLocation(int line, int column, String name, String kind) {} + + /** + * Collects all FunctionCall nodes that call the target function. + */ + private static class FunctionReferenceCollector extends DefaultExpressionVisitor { + private final QName targetName; + private final int targetArity; + final List locations = new ArrayList<>(); + + FunctionReferenceCollector(final QName targetName, final int targetArity) { + this.targetName = targetName; + this.targetArity = targetArity; + } + + @Override + public void visitFunctionCall(final FunctionCall call) { + final UserDefinedFunction func = call.getFunction(); + if (func != null) { + final QName name = func.getSignature().getName(); + if (targetName.equals(name) + && targetArity == func.getSignature().getArgumentCount() + && call.getLine() > 0) { + locations.add(new RefLocation( + call.getLine() - 1, Math.max(call.getColumn() - 1, 0), + formatQName(targetName) + "#" + targetArity, "function")); + } + } + super.visitFunctionCall(call); + } + + @Override + public void visit(final Expression expression) { + for (int i = 0; i < expression.getSubExpressionCount(); i++) { + expression.getSubExpression(i).accept(this); + } + } + } + + /** + * Collects all VariableReference nodes that refer to the target variable. + */ + private static class VariableReferenceCollector extends DefaultExpressionVisitor { + private final QName targetName; + final List locations = new ArrayList<>(); + + VariableReferenceCollector(final QName targetName) { + this.targetName = targetName; + } + + @Override + public void visitVariableReference(final VariableReference ref) { + if (targetName.equals(ref.getName()) && ref.getLine() > 0) { + locations.add(new RefLocation( + ref.getLine() - 1, Math.max(ref.getColumn() - 1, 0), + "$" + formatQName(targetName), "variable")); + } + } + + @Override + public void visit(final Expression expression) { + for (int i = 0; i < expression.getSubExpressionCount(); i++) { + expression.getSubExpression(i).accept(this); + } + } + } +} diff --git a/exist-core/src/main/java/org/exist/xquery/functions/lsp/Symbols.java b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Symbols.java new file mode 100644 index 00000000000..47007fbf876 --- /dev/null +++ b/exist-core/src/main/java/org/exist/xquery/functions/lsp/Symbols.java @@ -0,0 +1,272 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.functions.lsp; + +import java.io.StringReader; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.exist.xquery.AnalyzeContextInfo; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.Expression; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.PathExpr; +import org.exist.xquery.UserDefinedFunction; +import org.exist.xquery.VariableDeclaration; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.functions.array.ArrayType; +import org.exist.xquery.functions.map.MapType; +import org.exist.xquery.parser.XQueryLexer; +import org.exist.xquery.parser.XQueryParser; +import org.exist.xquery.parser.XQueryTreeParser; +import org.exist.xquery.value.IntegerValue; +import org.exist.xquery.value.Sequence; +import org.exist.xquery.value.SequenceType; +import org.exist.xquery.value.StringValue; +import org.exist.xquery.value.Type; + +import antlr.collections.AST; + +import static org.exist.xquery.FunctionDSL.*; + +/** + * Compiles an XQuery expression and returns document symbols suitable for + * Language Server Protocol {@code textDocument/documentSymbol} responses. + * + *

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

+ *
    + *
  • {@code name} — symbol name (e.g., "my:function#2", "$my:variable")
  • + *
  • {@code kind} — LSP SymbolKind integer (12=Function, 13=Variable)
  • + *
  • {@code line} — 0-based start line
  • + *
  • {@code column} — 0-based start column
  • + *
  • {@code detail} — additional info (return type, variable type, arity)
  • + *
+ * + *

Returns an empty array if the expression cannot be compiled.

+ * + * @author eXist-db + */ +public class Symbols extends BasicFunction { + + private static final Logger logger = LogManager.getLogger(Symbols.class); + + /** LSP SymbolKind constants */ + private static final long SYMBOL_KIND_FUNCTION = 12; + private static final long SYMBOL_KIND_VARIABLE = 13; + + private static final String FS_SYMBOLS_NAME = "symbols"; + private static final String FS_SYMBOLS_DESCRIPTION = """ + Compiles the XQuery expression and returns an array of document symbol maps. \ + Each map contains keys: name (xs:string), kind (xs:integer, LSP SymbolKind), \ + line (xs:integer, 0-based), column (xs:integer, 0-based), and \ + detail (xs:string, type or signature info). \ + Returns an empty array if the expression cannot be compiled."""; + + public static final FunctionSignature[] FS_SYMBOLS = functionSignatures( + LspModule.qname(FS_SYMBOLS_NAME), + FS_SYMBOLS_DESCRIPTION, + returns(Type.ARRAY_ITEM, "an array of document symbol maps"), + arities( + arity( + param("expression", Type.STRING, "The XQuery expression to analyze.") + ), + arity( + param("expression", Type.STRING, "The XQuery expression to analyze."), + optParam("module-load-path", Type.STRING, "The module load path. " + + "Imports will be resolved relative to this. " + + "Use xmldb:exist:///db for database-stored modules.") + ) + ) + ); + + public Symbols(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + final String expr = args[0].getStringValue(); + if (expr.trim().isEmpty()) { + return new ArrayType(this, context, new ArrayList<>()); + } + + final List symbols = new ArrayList<>(); + + final XQueryContext pContext = new XQueryContext(context.getBroker().getBrokerPool()); + try { + if (getArgumentCount() == 2 && args[1].hasOne()) { + pContext.setModuleLoadPath(args[1].getStringValue()); + } + + context.pushNamespaceContext(); + try { + final PathExpr path = compile(pContext, expr); + if (path != null) { + extractFunctions(pContext, symbols); + extractVariables(path, symbols); + } + } finally { + context.popNamespaceContext(); + pContext.reset(false); + } + } finally { + pContext.runCleanupTasks(); + } + + return new ArrayType(this, context, symbols); + } + + /** + * Compiles the expression and returns the PathExpr, or null on error. + */ + private PathExpr compile(final XQueryContext pContext, final String expr) { + try { + final XQueryLexer lexer = new XQueryLexer(pContext, new StringReader(expr)); + final XQueryParser parser = new XQueryParser(lexer); + final XQueryTreeParser astParser = new XQueryTreeParser(pContext); + + parser.xpath(); + if (parser.foundErrors()) { + return null; + } + + final AST ast = parser.getAST(); + final PathExpr path = new PathExpr(pContext); + astParser.xpath(ast, path); + if (astParser.foundErrors()) { + return null; + } + + path.analyze(new AnalyzeContextInfo()); + return path; + + } catch (final Exception e) { + logger.debug("Error compiling expression for symbol extraction: {}", e.getMessage()); + return null; + } + } + + /** + * Extracts user-defined function declarations from the compilation context. + */ + private void extractFunctions(final XQueryContext pContext, final List symbols) + throws XPathException { + final Iterator funcs = pContext.localFunctions(); + while (funcs.hasNext()) { + final UserDefinedFunction func = funcs.next(); + final FunctionSignature sig = func.getSignature(); + final org.exist.dom.QName name = sig.getName(); + + // Build display name: prefix:local#arity + final String displayName = formatFunctionName(name, sig.getArgumentCount()); + + final String detail = sig.toString(); + + int line = func.getLine(); + if (line <= 0 && func.getFunctionBody() != null) { + line = firstPositiveLineIn(func.getFunctionBody()); + } + final int column = func.getColumn(); + + addSymbol(symbols, displayName, SYMBOL_KIND_FUNCTION, + Math.max(line - 1, 0), Math.max(column - 1, 0), detail); + } + } + + /** + * Extracts global variable declarations from the compiled PathExpr. + */ + private void extractVariables(final PathExpr path, final List symbols) + throws XPathException { + for (int i = 0; i < path.getSubExpressionCount(); i++) { + final Expression step = path.getSubExpression(i); + if (step instanceof final VariableDeclaration varDecl) { + final org.exist.dom.QName name = varDecl.getName(); + final String displayName = "$" + formatQName(name); + + final SequenceType seqType = varDecl.getSequenceType(); + final String detail = seqType != null + ? Type.getTypeName(seqType.getPrimaryType()) + seqType.getCardinality().toXQueryCardinalityString() + : ""; + + final int line = varDecl.getLine(); + final int column = varDecl.getColumn(); + + addSymbol(symbols, displayName, SYMBOL_KIND_VARIABLE, + Math.max(line - 1, 0), Math.max(column - 1, 0), detail); + } + } + } + + /** + * Creates a symbol map and adds it to the list. + */ + private void addSymbol(final List symbols, final String name, final long kind, + final int line, final int column, final String detail) throws XPathException { + final MapType symbol = new MapType(this, context); + symbol.add(new StringValue(this, "name"), new StringValue(this, name)); + symbol.add(new StringValue(this, "kind"), new IntegerValue(this, kind)); + symbol.add(new StringValue(this, "line"), new IntegerValue(this, line)); + symbol.add(new StringValue(this, "column"), new IntegerValue(this, column)); + symbol.add(new StringValue(this, "detail"), new StringValue(this, detail)); + symbols.add(symbol); + } + + private static String formatFunctionName(final org.exist.dom.QName name, final int arity) { + return formatQName(name) + "#" + arity; + } + + private static String formatQName(final org.exist.dom.QName name) { + final String prefix = name.getPrefix(); + if (prefix != null && !prefix.isEmpty()) { + return prefix + ":" + name.getLocalPart(); + } + return name.getLocalPart(); + } + + /** + * Depth-first search for the first positive line number in an expression tree. + * Used as fallback when the function's own line is not set. + */ + private static int firstPositiveLineIn(final Expression expr) { + if (expr == null) { + return -1; + } + final int line = expr.getLine(); + if (line > 0) { + return line; + } + final int count = expr.getSubExpressionCount(); + for (int i = 0; i < count; i++) { + final int sub = firstPositiveLineIn(expr.getSubExpression(i)); + if (sub > 0) { + return sub; + } + } + return -1; + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/functions/lsp/CompletionsTest.java b/exist-core/src/test/java/org/exist/xquery/functions/lsp/CompletionsTest.java new file mode 100644 index 00000000000..834e37ed6e0 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/functions/lsp/CompletionsTest.java @@ -0,0 +1,244 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.functions.lsp; + +import org.exist.test.ExistXmldbEmbeddedServer; +import org.junit.ClassRule; +import org.junit.Test; +import org.xmldb.api.base.ResourceSet; +import org.xmldb.api.base.XMLDBException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Tests for the lsp:completions() function. + */ +public class CompletionsTest { + + @ClassRule + public static final ExistXmldbEmbeddedServer existEmbeddedServer = + new ExistXmldbEmbeddedServer(false, true, true); + + private static final String LSP_IMPORT = + "import module namespace lsp = 'http://exist-db.org/xquery/lsp';\n"; + + @Test + public void returnsNonEmptyArrayForEmptyExpression() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + "array:size(lsp:completions('')) > 0"); + assertEquals("Should return built-in functions even for empty expression", + "true", result.getResource(0).getContent().toString()); + } + + @Test + public void includesBuiltinFnCount() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $completions := lsp:completions('')\n" + + "return array:size(array:filter($completions, function($c) { $c?label = 'fn:count#1' }))"); + assertEquals("Should include fn:count#1", + "1", result.getResource(0).getContent().toString()); + } + + @Test + public void includesBuiltinFnStringJoin() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $completions := lsp:completions('')\n" + + "return array:size(array:filter($completions, function($c) { $c?label = 'fn:string-join#2' }))"); + assertEquals("Should include fn:string-join#2", + "1", result.getResource(0).getContent().toString()); + } + + @Test + public void builtinFunctionHasCorrectKind() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $completions := lsp:completions('')\n" + + "let $fnCount := array:filter($completions, function($c) { $c?label = 'fn:count#1' })\n" + + "return $fnCount(1)?kind"); + assertEquals("Function CompletionItemKind = 3", + "3", result.getResource(0).getContent().toString()); + } + + @Test + public void builtinFunctionHasDetail() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $completions := lsp:completions('')\n" + + "let $fnCount := array:filter($completions, function($c) { $c?label = 'fn:count#1' })\n" + + "return string-length($fnCount(1)?detail) > 0"); + assertEquals("Function should have non-empty detail", + "true", result.getResource(0).getContent().toString()); + } + + @Test + public void builtinFunctionHasInsertText() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $completions := lsp:completions('')\n" + + "let $fnCount := array:filter($completions, function($c) { $c?label = 'fn:count#1' })\n" + + "return $fnCount(1)?insertText"); + assertEquals("Insert text should be fn:count()", + "fn:count()", result.getResource(0).getContent().toString()); + } + + @Test + public void builtinFunctionHasDocumentation() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $completions := lsp:completions('')\n" + + "let $fnCount := array:filter($completions, function($c) { $c?label = 'fn:count#1' })\n" + + "return string-length($fnCount(1)?documentation) > 0"); + assertEquals("Built-in function should have documentation", + "true", result.getResource(0).getContent().toString()); + } + + @Test + public void includesKeywords() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $completions := lsp:completions('')\n" + + "return array:size(array:filter($completions, function($c) { $c?label = 'return' }))"); + assertEquals("Should include 'return' keyword", + "1", result.getResource(0).getContent().toString()); + } + + @Test + public void keywordHasCorrectKind() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $completions := lsp:completions('')\n" + + "let $kw := array:filter($completions, function($c) { $c?label = 'let' })\n" + + "return $kw(1)?kind"); + assertEquals("Keyword CompletionItemKind = 14", + "14", result.getResource(0).getContent().toString()); + } + + @Test + public void includesUserDeclaredFunction() throws XMLDBException { + final String xquery = + "declare function local:greet($name as xs:string) as xs:string { " + + " ''Hello, '' || $name " + + "}; local:greet(''world'')"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $completions := lsp:completions('" + xquery + "')\n" + + "return array:size(array:filter($completions, function($c) { $c?label = 'local:greet#1' }))"); + assertEquals("Should include user-declared local:greet#1", + "1", result.getResource(0).getContent().toString()); + } + + @Test + public void includesUserDeclaredVariable() throws XMLDBException { + final String xquery = + "declare variable $local:greeting := ''hello''; $local:greeting"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $completions := lsp:completions('" + xquery + "')\n" + + "return array:size(array:filter($completions, function($c) { $c?label = '$local:greeting' }))"); + assertEquals("Should include user-declared $local:greeting", + "1", result.getResource(0).getContent().toString()); + } + + @Test + public void userVariableHasCorrectKind() throws XMLDBException { + final String xquery = + "declare variable $local:x := 42; $local:x"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $completions := lsp:completions('" + xquery + "')\n" + + "let $var := array:filter($completions, function($c) { $c?label = '$local:x' })\n" + + "return $var(1)?kind"); + assertEquals("Variable CompletionItemKind = 6", + "6", result.getResource(0).getContent().toString()); + } + + @Test + public void invalidExpressionStillReturnsBuiltins() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $completions := lsp:completions('let $x :=')\n" + + "return array:size(array:filter($completions, function($c) { $c?label = 'fn:count#1' }))"); + assertEquals("Should include fn:count#1 even with invalid expression", + "1", result.getResource(0).getContent().toString()); + } + + @Test + public void includesMapModuleFunctions() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $completions := lsp:completions('')\n" + + "return array:size(array:filter($completions, function($c) { starts-with($c?label, 'map:') })) > 0"); + assertEquals("Should include map: module functions", + "true", result.getResource(0).getContent().toString()); + } + + @Test + public void includesArrayModuleFunctions() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $completions := lsp:completions('')\n" + + "return array:size(array:filter($completions, function($c) { starts-with($c?label, 'array:') })) > 0"); + assertEquals("Should include array: module functions", + "true", result.getResource(0).getContent().toString()); + } + + @Test + public void withModuleLoadPath() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + "array:size(lsp:completions('', '/db')) > 0"); + assertEquals("Should return completions with /db load path", + "true", result.getResource(0).getContent().toString()); + } + + @Test + public void excludesPrivateFunctions() throws XMLDBException { + // Private functions from built-in modules should not appear + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $completions := lsp:completions('')\n" + + "let $private := array:filter($completions, function($c) { " + + " $c?kind = 3 and contains($c?detail, 'private') })\n" + + "return array:size($private)"); + assertEquals("Should not include private functions", + "0", result.getResource(0).getContent().toString()); + } + + @Test + public void completionItemHasAllKeys() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $completions := lsp:completions('')\n" + + "let $first := $completions(1)\n" + + "return string-join((\n" + + " 'label=' || map:contains($first, 'label'),\n" + + " 'kind=' || map:contains($first, 'kind'),\n" + + " 'detail=' || map:contains($first, 'detail'),\n" + + " 'documentation=' || map:contains($first, 'documentation'),\n" + + " 'insertText=' || map:contains($first, 'insertText')\n" + + "), ',')"); + assertEquals("label=true,kind=true,detail=true,documentation=true,insertText=true", + result.getResource(0).getContent().toString()); + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/functions/lsp/DefinitionTest.java b/exist-core/src/test/java/org/exist/xquery/functions/lsp/DefinitionTest.java new file mode 100644 index 00000000000..18560cac687 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/functions/lsp/DefinitionTest.java @@ -0,0 +1,227 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.functions.lsp; + +import org.exist.test.ExistXmldbEmbeddedServer; +import org.exist.xmldb.EXistResource; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.xmldb.api.base.Collection; +import org.xmldb.api.base.ResourceSet; +import org.xmldb.api.base.XMLDBException; +import org.xmldb.api.modules.BinaryResource; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Tests for the lsp:definition() function. + */ +public class DefinitionTest { + + @ClassRule + public static final ExistXmldbEmbeddedServer existEmbeddedServer = + new ExistXmldbEmbeddedServer(false, true, true); + + private static final String LSP_IMPORT = + "import module namespace lsp = 'http://exist-db.org/xquery/lsp';\n"; + + private static final String LIB_MODULE = + "xquery version '3.1';\n" + + "module namespace utils = 'http://example.com/utils';\n" + + "\n" + + "declare function utils:hello($name as xs:string) as xs:string {\n" + + " 'Hello ' || $name\n" + + "};\n"; + + @BeforeClass + public static void storeLibraryModule() throws XMLDBException { + final Collection root = existEmbeddedServer.getRoot(); + final BinaryResource resource = root.createResource("utils.xqm", BinaryResource.class); + resource.setContent(LIB_MODULE); + ((EXistResource) resource).setMimeType("application/xquery"); + root.storeResource(resource); + } + + @Test + public void emptyExpressionReturnsEmpty() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + "empty(lsp:definition('', 0, 0))"); + assertEquals("true", result.getResource(0).getContent().toString()); + } + + @Test + public void invalidExpressionReturnsEmpty() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + "empty(lsp:definition('let $x :=', 0, 5))"); + assertEquals("true", result.getResource(0).getContent().toString()); + } + + @Test + public void functionCallGoesToDefinition() throws XMLDBException { + // Line 0: declare function local:greet() { 42 }; + // Line 1: local:greet() + final String xquery = + "declare function local:greet() { 42 }; local:greet()"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $def := lsp:definition('" + xquery + "', 1, 5)\n" + + "return $def?kind"); + assertEquals("function", result.getResource(0).getContent().toString()); + } + + @Test + public void functionDefinitionHasName() throws XMLDBException { + final String xquery = + "declare function local:greet() { 42 }; local:greet()"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $def := lsp:definition('" + xquery + "', 1, 5)\n" + + "return $def?name"); + assertEquals("local:greet#0", result.getResource(0).getContent().toString()); + } + + @Test + public void functionDefinitionPointsToDeclaration() throws XMLDBException { + // The declaration is on line 0 + final String xquery = + "declare function local:greet() { 42 }; local:greet()"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $def := lsp:definition('" + xquery + "', 1, 5)\n" + + "return $def?line"); + assertEquals("0", result.getResource(0).getContent().toString()); + } + + @Test + public void variableReferenceGoesToDeclaration() throws XMLDBException { + // Line 0: declare variable $local:x := 42; + // Line 1: $local:x + final String xquery = + "declare variable $local:x := 42; $local:x"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $def := lsp:definition('" + xquery + "', 1, 3)\n" + + "return $def?kind"); + assertEquals("variable", result.getResource(0).getContent().toString()); + } + + @Test + public void variableDefinitionHasName() throws XMLDBException { + final String xquery = + "declare variable $local:x := 42; $local:x"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $def := lsp:definition('" + xquery + "', 1, 3)\n" + + "return $def?name"); + assertEquals("$local:x", result.getResource(0).getContent().toString()); + } + + @Test + public void variableDefinitionPointsToDeclaration() throws XMLDBException { + final String xquery = + "declare variable $local:x := 42; $local:x"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $def := lsp:definition('" + xquery + "', 1, 3)\n" + + "return $def?line"); + assertEquals("0", result.getResource(0).getContent().toString()); + } + + @Test + public void builtinFunctionReturnsEmpty() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + "empty(lsp:definition('fn:count((1,2,3))', 0, 3))"); + assertEquals("true", result.getResource(0).getContent().toString()); + } + + @Test + public void noSymbolAtPositionReturnsEmpty() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + "empty(lsp:definition('1 + 2', 0, 2))"); + assertEquals("true", result.getResource(0).getContent().toString()); + } + + @Test + public void withModuleLoadPath() throws XMLDBException { + final String xquery = + "declare function local:foo() { 42 }; local:foo()"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $def := lsp:definition('" + xquery + "', 1, 5, '/db')\n" + + "return $def?kind"); + assertEquals("function", result.getResource(0).getContent().toString()); + } + + @Test + public void localDefinitionHasNoUri() throws XMLDBException { + final String xquery = + "declare function local:greet() { 42 }; local:greet()"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $def := lsp:definition('" + xquery + "', 1, 5)\n" + + "return empty($def?uri)"); + assertEquals("true", result.getResource(0).getContent().toString()); + } + + /** + * Inner query that imports utils.xqm and calls utils:hello. + * Uses double quotes inside so it can be wrapped in single quotes + * in the outer XQuery: lsp:definition('...this...', line, col). + */ + private static final String CROSS_MODULE_QUERY = + "import module namespace utils = \"http://example.com/utils\" " + + "at \"xmldb:exist:///db/utils.xqm\"; " + + "utils:hello(\"world\")"; + + @Test + public void crossModuleDefinitionHasUri() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $def := lsp:definition('" + CROSS_MODULE_QUERY + "', 1, 5, '/db')\n" + + "return $def?uri"); + final String uri = result.getResource(0).getContent().toString(); + assertNotNull("cross-module definition should include uri", uri); + assertTrue("uri should point to utils.xqm, got: " + uri, + uri.contains("utils.xqm")); + } + + @Test + public void crossModuleDefinitionHasKind() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $def := lsp:definition('" + CROSS_MODULE_QUERY + "', 1, 5, '/db')\n" + + "return $def?kind"); + assertEquals("function", result.getResource(0).getContent().toString()); + } + + @Test + public void crossModuleDefinitionHasLineAndColumn() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $def := lsp:definition('" + CROSS_MODULE_QUERY + "', 1, 5, '/db')\n" + + "return $def?line >= 0"); + assertEquals("true", result.getResource(0).getContent().toString()); + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/functions/lsp/DiagnosticsTest.java b/exist-core/src/test/java/org/exist/xquery/functions/lsp/DiagnosticsTest.java new file mode 100644 index 00000000000..b32cb40def9 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/functions/lsp/DiagnosticsTest.java @@ -0,0 +1,178 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.functions.lsp; + +import org.exist.test.ExistXmldbEmbeddedServer; +import org.junit.ClassRule; +import org.junit.Test; +import org.xmldb.api.base.ResourceSet; +import org.xmldb.api.base.XMLDBException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Tests for the lsp:diagnostics() function. + */ +public class DiagnosticsTest { + + @ClassRule + public static final ExistXmldbEmbeddedServer existEmbeddedServer = + new ExistXmldbEmbeddedServer(false, true, true); + + private static final String LSP_IMPORT = + "import module namespace lsp = 'http://exist-db.org/xquery/lsp';\n"; + + @Test + public void validExpressionReturnsEmptyArray() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + "array:size(lsp:diagnostics('let $x := 1 return $x'))"); + assertEquals("Valid expression should return empty array", + "0", result.getResource(0).getContent().toString()); + } + + @Test + public void syntaxErrorReturnsDiagnostic() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + "array:size(lsp:diagnostics('let $x := 1 retrun $x'))"); + assertEquals("Syntax error should return one diagnostic", + "1", result.getResource(0).getContent().toString()); + } + + @Test + public void diagnosticSeverityIsError() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $result := lsp:diagnostics('let $x := retrun $x')\n" + + "return $result(1)?severity"); + assertEquals("Severity should be 1 (LSP DiagnosticSeverity.Error)", + "1", result.getResource(0).getContent().toString()); + } + + @Test + public void syntaxErrorHasCorrectLine() throws XMLDBException { + // Single-line expression: error should be on line 0 + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $result := lsp:diagnostics('let $x := 1 retrun $x')\n" + + "return $result(1)?line"); + final int line = Integer.parseInt(result.getResource(0).getContent().toString()); + assertTrue("Error in single-line expression should be on line 0 or 1, got: " + line, + line >= 0 && line <= 1); + } + + @Test + public void multiLineErrorHasCorrectLine() throws XMLDBException { + // Error on line 3 (0-based: line 2): the "retrun" typo + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $result := lsp:diagnostics('xquery version \"3.1\";\n\nlet $x := 1 retrun $x')\n" + + "return $result(1)?line"); + final int line = Integer.parseInt(result.getResource(0).getContent().toString()); + assertEquals("Error on line 3 should report 0-based line 2", 2, line); + } + + @Test + public void syntaxErrorHasPositiveColumn() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $result := lsp:diagnostics('let $x := 1 retrun $x')\n" + + "return $result(1)?column"); + final int column = Integer.parseInt(result.getResource(0).getContent().toString()); + assertTrue("Column should be non-negative, got: " + column, column >= 0); + } + + @Test + public void diagnosticMessageDescribesError() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $result := lsp:diagnostics('let $x := 1 retrun $x')\n" + + "return $result(1)?message"); + final String message = result.getResource(0).getContent().toString(); + assertTrue("Error message should not be empty", message.length() > 0); + } + + @Test + public void undeclaredVariableHasErrorCode() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $result := lsp:diagnostics('$undeclared')\n" + + "return $result(1)?code"); + final String code = result.getResource(0).getContent().toString(); + assertTrue("Undeclared variable error code should contain XPST0008, got: " + code, + code.contains("XPST0008")); + } + + @Test + public void undeclaredVariableMessageMentionsVarName() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $result := lsp:diagnostics('$undeclared')\n" + + "return $result(1)?message"); + final String message = result.getResource(0).getContent().toString(); + assertTrue("Message should mention the variable name: " + message, + message.contains("undeclared")); + } + + @Test + public void emptyExpressionReturnsEmptyArray() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + "array:size(lsp:diagnostics(''))"); + assertEquals("Empty expression should return empty array", + "0", result.getResource(0).getContent().toString()); + } + + @Test + public void whitespaceOnlyExpressionReturnsEmptyArray() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + "array:size(lsp:diagnostics(' '))"); + assertEquals("Whitespace-only expression should return empty array", + "0", result.getResource(0).getContent().toString()); + } + + @Test + public void withXmldbModuleLoadPath() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "array:size(lsp:diagnostics('let $x := 1 return $x', 'xmldb:exist:///db'))"); + assertEquals("Valid expression with xmldb load path should return empty array", + "0", result.getResource(0).getContent().toString()); + } + + @Test + public void withDbModuleLoadPath() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "array:size(lsp:diagnostics('let $x := 1 return $x', '/db'))"); + assertEquals("Valid expression with /db load path should return empty array", + "0", result.getResource(0).getContent().toString()); + } + + @Test + public void withEmptyModuleLoadPath() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "array:size(lsp:diagnostics('let $x := 1 return $x', ()))"); + assertEquals("Valid expression with empty load path should return empty array", + "0", result.getResource(0).getContent().toString()); + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/functions/lsp/HoverTest.java b/exist-core/src/test/java/org/exist/xquery/functions/lsp/HoverTest.java new file mode 100644 index 00000000000..a716738a214 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/functions/lsp/HoverTest.java @@ -0,0 +1,151 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.functions.lsp; + +import org.exist.test.ExistXmldbEmbeddedServer; +import org.junit.ClassRule; +import org.junit.Test; +import org.xmldb.api.base.ResourceSet; +import org.xmldb.api.base.XMLDBException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Tests for the lsp:hover() function. + */ +public class HoverTest { + + @ClassRule + public static final ExistXmldbEmbeddedServer existEmbeddedServer = + new ExistXmldbEmbeddedServer(false, true, true); + + private static final String LSP_IMPORT = + "import module namespace lsp = 'http://exist-db.org/xquery/lsp';\n"; + + @Test + public void emptyExpressionReturnsEmpty() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + "empty(lsp:hover('', 0, 0))"); + assertEquals("true", result.getResource(0).getContent().toString()); + } + + @Test + public void invalidExpressionReturnsEmpty() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + "empty(lsp:hover('let $x :=', 0, 5))"); + assertEquals("true", result.getResource(0).getContent().toString()); + } + + @Test + public void hoverOnBuiltinFunctionCall() throws XMLDBException { + // fn:count starts at column 0 + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $hover := lsp:hover('fn:count((1,2,3))', 0, 3)\n" + + "return $hover?kind"); + assertEquals("function", result.getResource(0).getContent().toString()); + } + + @Test + public void hoverOnBuiltinFunctionHasContents() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $hover := lsp:hover('fn:count((1,2,3))', 0, 3)\n" + + "return string-length($hover?contents) > 0"); + assertEquals("true", result.getResource(0).getContent().toString()); + } + + @Test + public void hoverOnBuiltinFunctionContainsName() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $hover := lsp:hover('fn:count((1,2,3))', 0, 3)\n" + + "return contains($hover?contents, 'count')"); + assertEquals("true", result.getResource(0).getContent().toString()); + } + + @Test + public void hoverOnUserFunctionCall() throws XMLDBException { + // Use a multiline expression so the call is on a known line + // Line 0: declare function local:greet() as xs:string { 'hi' }; + // Line 1: local:greet() + final String xquery = + "declare function local:greet() as xs:string { ''hi'' }; local:greet()"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $hover := lsp:hover('" + xquery + "', 1, 5)\n" + + "return $hover?kind"); + assertEquals("function", result.getResource(0).getContent().toString()); + } + + @Test + public void hoverOnUserFunctionContainsSig() throws XMLDBException { + final String xquery = + "declare function local:greet() as xs:string { ''hi'' }; local:greet()"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $hover := lsp:hover('" + xquery + "', 1, 5)\n" + + "return contains($hover?contents, 'greet')"); + assertEquals("true", result.getResource(0).getContent().toString()); + } + + @Test + public void hoverOnVariableReference() throws XMLDBException { + // Line 0: declare variable $local:x := 42; + // Line 1: $local:x + final String xquery = + "declare variable $local:x := 42; $local:x"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $hover := lsp:hover('" + xquery + "', 1, 3)\n" + + "return $hover?kind"); + assertEquals("variable", result.getResource(0).getContent().toString()); + } + + @Test + public void hoverOnVariableHasName() throws XMLDBException { + final String xquery = + "declare variable $local:x := 42; $local:x"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $hover := lsp:hover('" + xquery + "', 1, 3)\n" + + "return contains($hover?contents, '$local:x')"); + assertEquals("true", result.getResource(0).getContent().toString()); + } + + @Test + public void noSymbolAtPositionReturnsEmpty() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + "empty(lsp:hover('1 + 2', 0, 2))"); + assertEquals("true", result.getResource(0).getContent().toString()); + } + + @Test + public void withModuleLoadPath() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $hover := lsp:hover('fn:count((1,2,3))', 0, 3, '/db')\n" + + "return $hover?kind"); + assertEquals("function", result.getResource(0).getContent().toString()); + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/functions/lsp/ReferencesTest.java b/exist-core/src/test/java/org/exist/xquery/functions/lsp/ReferencesTest.java new file mode 100644 index 00000000000..aa8f3b1e266 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/functions/lsp/ReferencesTest.java @@ -0,0 +1,125 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.functions.lsp; + +import org.exist.test.ExistXmldbEmbeddedServer; +import org.junit.ClassRule; +import org.junit.Test; +import org.xmldb.api.base.ResourceSet; +import org.xmldb.api.base.XMLDBException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Tests for the lsp:references() function. + */ +public class ReferencesTest { + + @ClassRule + public static final ExistXmldbEmbeddedServer existEmbeddedServer = + new ExistXmldbEmbeddedServer(false, true, true); + + private static final String LSP_IMPORT = + "import module namespace lsp = 'http://exist-db.org/xquery/lsp';\n"; + + @Test + public void emptyExpressionReturnsEmpty() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + "array:size(lsp:references('', 0, 0))"); + assertEquals("0", result.getResource(0).getContent().toString()); + } + + @Test + public void functionCallFindsAllReferences() throws XMLDBException { + // local:greet is declared on line 0 and called on lines 1 and 2 + final String xquery = + "declare function local:greet() { 42 }; " + + "local:greet(), " + + "local:greet()"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $refs := lsp:references('" + xquery + "', 1, 5)\n" + + "return array:size($refs)"); + // Should find: declaration + 2 calls = 3 + final int count = Integer.parseInt(result.getResource(0).getContent().toString()); + assertTrue("expected at least 2 references, got " + count, count >= 2); + } + + @Test + public void functionReferenceHasKind() throws XMLDBException { + final String xquery = + "declare function local:greet() { 42 }; local:greet()"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $refs := lsp:references('" + xquery + "', 1, 5)\n" + + "return $refs(1)?kind"); + assertEquals("function", result.getResource(0).getContent().toString()); + } + + @Test + public void variableReferenceFindsAllReferences() throws XMLDBException { + // $local:x is declared on line 0 and referenced on lines 1 and 2 + final String xquery = + "declare variable $local:x := 42; " + + "$local:x + 1, " + + "$local:x + 2"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $refs := lsp:references('" + xquery + "', 1, 3)\n" + + "return array:size($refs)"); + final int count = Integer.parseInt(result.getResource(0).getContent().toString()); + assertTrue("expected at least 2 references, got " + count, count >= 2); + } + + @Test + public void variableReferenceHasKind() throws XMLDBException { + final String xquery = + "declare variable $local:x := 42; $local:x"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $refs := lsp:references('" + xquery + "', 1, 3)\n" + + "return $refs(1)?kind"); + assertEquals("variable", result.getResource(0).getContent().toString()); + } + + @Test + public void noSymbolReturnsEmpty() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + "array:size(lsp:references('1 + 2', 0, 2))"); + assertEquals("0", result.getResource(0).getContent().toString()); + } + + @Test + public void withModuleLoadPath() throws XMLDBException { + final String xquery = + "declare function local:foo() { 42 }; " + + "local:foo(), " + + "local:foo()"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $refs := lsp:references('" + xquery + "', 1, 5, '/db')\n" + + "return array:size($refs)"); + final int count = Integer.parseInt(result.getResource(0).getContent().toString()); + assertTrue("expected at least 2 references, got " + count, count >= 2); + } +} diff --git a/exist-core/src/test/java/org/exist/xquery/functions/lsp/SymbolsTest.java b/exist-core/src/test/java/org/exist/xquery/functions/lsp/SymbolsTest.java new file mode 100644 index 00000000000..f6ac24bf805 --- /dev/null +++ b/exist-core/src/test/java/org/exist/xquery/functions/lsp/SymbolsTest.java @@ -0,0 +1,223 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.functions.lsp; + +import org.exist.test.ExistXmldbEmbeddedServer; +import org.junit.ClassRule; +import org.junit.Test; +import org.xmldb.api.base.ResourceSet; +import org.xmldb.api.base.XMLDBException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Tests for the lsp:symbols() function. + */ +public class SymbolsTest { + + @ClassRule + public static final ExistXmldbEmbeddedServer existEmbeddedServer = + new ExistXmldbEmbeddedServer(false, true, true); + + private static final String LSP_IMPORT = + "import module namespace lsp = 'http://exist-db.org/xquery/lsp';\n"; + + @Test + public void emptyExpressionReturnsEmptyArray() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + "array:size(lsp:symbols(''))"); + assertEquals("0", result.getResource(0).getContent().toString()); + } + + @Test + public void invalidExpressionReturnsEmptyArray() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + "array:size(lsp:symbols('let $x :='))"); + assertEquals("0", result.getResource(0).getContent().toString()); + } + + @Test + public void functionDeclarationReturnsSymbol() throws XMLDBException { + final String xquery = + "declare function local:hello($name as xs:string) as xs:string { " + + " ''Hello, '' || $name " + + "}; local:hello(''world'')"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $symbols := lsp:symbols('" + xquery + "')\n" + + "return array:size($symbols)"); + assertEquals("1", result.getResource(0).getContent().toString()); + } + + @Test + public void functionSymbolHasCorrectName() throws XMLDBException { + final String xquery = + "declare function local:greet($name as xs:string) as xs:string { " + + " ''Hello, '' || $name " + + "}; local:greet(''world'')"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $symbols := lsp:symbols('" + xquery + "')\n" + + "return $symbols(1)?name"); + assertEquals("local:greet#1", result.getResource(0).getContent().toString()); + } + + @Test + public void functionSymbolHasKind12() throws XMLDBException { + final String xquery = + "declare function local:foo() { () }; local:foo()"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $symbols := lsp:symbols('" + xquery + "')\n" + + "return $symbols(1)?kind"); + assertEquals("LSP SymbolKind.Function = 12", + "12", result.getResource(0).getContent().toString()); + } + + @Test + public void functionSymbolHasPositiveLine() throws XMLDBException { + final String xquery = + "declare function local:foo() { () }; local:foo()"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $symbols := lsp:symbols('" + xquery + "')\n" + + "return $symbols(1)?line"); + // Line should be 0 (first line, 0-based) + assertEquals("0", result.getResource(0).getContent().toString()); + } + + @Test + public void functionSymbolDetailIncludesReturnType() throws XMLDBException { + final String xquery = + "declare function local:add($a as xs:integer, $b as xs:integer) as xs:integer { " + + " $a + $b " + + "}; local:add(1, 2)"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $symbols := lsp:symbols('" + xquery + "')\n" + + "return $symbols(1)?detail"); + final String detail = result.getResource(0).getContent().toString(); + assertTrue("Detail should contain parameter names: " + detail, + detail.contains("$a") && detail.contains("$b")); + assertTrue("Detail should contain return type: " + detail, + detail.contains("xs:integer")); + } + + @Test + public void variableDeclarationReturnsSymbol() throws XMLDBException { + final String xquery = + "declare variable $local:greeting := ''hello''; $local:greeting"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $symbols := lsp:symbols('" + xquery + "')\n" + + "return array:size($symbols)"); + assertEquals("1", result.getResource(0).getContent().toString()); + } + + @Test + public void variableSymbolHasCorrectName() throws XMLDBException { + final String xquery = + "declare variable $local:greeting := ''hello''; $local:greeting"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $symbols := lsp:symbols('" + xquery + "')\n" + + "return $symbols(1)?name"); + assertEquals("$local:greeting", result.getResource(0).getContent().toString()); + } + + @Test + public void variableSymbolHasKind13() throws XMLDBException { + final String xquery = + "declare variable $local:greeting := ''hello''; $local:greeting"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $symbols := lsp:symbols('" + xquery + "')\n" + + "return $symbols(1)?kind"); + assertEquals("LSP SymbolKind.Variable = 13", + "13", result.getResource(0).getContent().toString()); + } + + @Test + public void typedVariableHasDetailInfo() throws XMLDBException { + final String xquery = + "declare variable $local:count as xs:integer := 42; $local:count"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $symbols := lsp:symbols('" + xquery + "')\n" + + "return $symbols(1)?detail"); + final String detail = result.getResource(0).getContent().toString(); + assertTrue("Detail should contain xs:integer: " + detail, + detail.contains("integer")); + } + + @Test + public void multipleFunctionsAndVariables() throws XMLDBException { + final String xquery = + "declare variable $local:x := 1; " + + "declare function local:a() { $local:x }; " + + "declare function local:b($n as xs:integer) as xs:integer { $n + $local:x }; " + + "local:b(local:a())"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $symbols := lsp:symbols('" + xquery + "')\n" + + "return array:size($symbols)"); + assertEquals("Should find 1 variable + 2 functions = 3 symbols", + "3", result.getResource(0).getContent().toString()); + } + + @Test + public void multipleFunctionsAndVariablesNames() throws XMLDBException { + final String xquery = + "declare variable $local:x := 1; " + + "declare function local:a() { $local:x }; " + + "declare function local:b($n as xs:integer) as xs:integer { $n + $local:x }; " + + "local:b(local:a())"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $symbols := lsp:symbols('" + xquery + "')\n" + + "return string-join(for $s in $symbols?* return $s?name, ', ')"); + final String names = result.getResource(0).getContent().toString(); + assertTrue("Should contain local:a#0: " + names, names.contains("local:a#0")); + assertTrue("Should contain local:b#1: " + names, names.contains("local:b#1")); + assertTrue("Should contain $local:x: " + names, names.contains("$local:x")); + } + + @Test + public void noSymbolsInSimpleExpression() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + "array:size(lsp:symbols('1 + 2'))"); + assertEquals("Simple expression has no declarations", + "0", result.getResource(0).getContent().toString()); + } + + @Test + public void withModuleLoadPathEmpty() throws XMLDBException { + final String xquery = + "declare function local:foo() { () }; local:foo()"; + final ResourceSet result = existEmbeddedServer.executeQuery( + LSP_IMPORT + + "let $symbols := lsp:symbols('" + xquery + "', ())\n" + + "return array:size($symbols)"); + assertEquals("1", result.getResource(0).getContent().toString()); + } +} diff --git a/exist-core/src/test/resources-filtered/conf.xml b/exist-core/src/test/resources-filtered/conf.xml index 9a76f8c79a5..d9905ea2283 100644 --- a/exist-core/src/test/resources-filtered/conf.xml +++ b/exist-core/src/test/resources-filtered/conf.xml @@ -897,6 +897,7 @@ + diff --git a/exist-core/src/test/resources-filtered/org/exist/storage/statistics/conf.xml b/exist-core/src/test/resources-filtered/org/exist/storage/statistics/conf.xml index 15d68dea5fb..0696996b181 100644 --- a/exist-core/src/test/resources-filtered/org/exist/storage/statistics/conf.xml +++ b/exist-core/src/test/resources-filtered/org/exist/storage/statistics/conf.xml @@ -888,6 +888,7 @@ + diff --git a/exist-core/src/test/resources-filtered/org/exist/xquery/conf.xml b/exist-core/src/test/resources-filtered/org/exist/xquery/conf.xml index b9bc14f5b53..9efe1e017d0 100644 --- a/exist-core/src/test/resources-filtered/org/exist/xquery/conf.xml +++ b/exist-core/src/test/resources-filtered/org/exist/xquery/conf.xml @@ -907,6 +907,7 @@ + diff --git a/exist-core/src/test/resources-filtered/org/exist/xquery/functions/transform/conf.xml b/exist-core/src/test/resources-filtered/org/exist/xquery/functions/transform/conf.xml index 7f2354f9f40..9a3b690b31c 100644 --- a/exist-core/src/test/resources-filtered/org/exist/xquery/functions/transform/conf.xml +++ b/exist-core/src/test/resources-filtered/org/exist/xquery/functions/transform/conf.xml @@ -899,6 +899,7 @@ + diff --git a/exist-core/src/test/resources/org/exist/xmldb/allowAnyUri.xml b/exist-core/src/test/resources/org/exist/xmldb/allowAnyUri.xml index 46f2ccd8e99..dab4aff3de8 100644 --- a/exist-core/src/test/resources/org/exist/xmldb/allowAnyUri.xml +++ b/exist-core/src/test/resources/org/exist/xmldb/allowAnyUri.xml @@ -940,6 +940,7 @@ + diff --git a/exist-distribution/src/main/config/conf.xml b/exist-distribution/src/main/config/conf.xml index 026734ebf05..834097f02c9 100644 --- a/exist-distribution/src/main/config/conf.xml +++ b/exist-distribution/src/main/config/conf.xml @@ -968,6 +968,7 @@ + diff --git a/extensions/contentextraction/src/test/resources-filtered/conf.xml b/extensions/contentextraction/src/test/resources-filtered/conf.xml index 1311e06f555..607e568ff2a 100644 --- a/extensions/contentextraction/src/test/resources-filtered/conf.xml +++ b/extensions/contentextraction/src/test/resources-filtered/conf.xml @@ -750,6 +750,7 @@ + diff --git a/extensions/expath/src/test/resources-filtered/conf.xml b/extensions/expath/src/test/resources-filtered/conf.xml index a0e02a2c06d..0020e5f421b 100644 --- a/extensions/expath/src/test/resources-filtered/conf.xml +++ b/extensions/expath/src/test/resources-filtered/conf.xml @@ -750,6 +750,7 @@ + diff --git a/extensions/exquery/restxq/src/test/resources-filtered/conf.xml b/extensions/exquery/restxq/src/test/resources-filtered/conf.xml index 697afdbf11b..44d57115066 100644 --- a/extensions/exquery/restxq/src/test/resources-filtered/conf.xml +++ b/extensions/exquery/restxq/src/test/resources-filtered/conf.xml @@ -731,6 +731,7 @@ + diff --git a/extensions/indexes/indexes-integration-tests/src/test/resources-filtered/conf.xml b/extensions/indexes/indexes-integration-tests/src/test/resources-filtered/conf.xml index 2aae0f7d207..aa403553838 100644 --- a/extensions/indexes/indexes-integration-tests/src/test/resources-filtered/conf.xml +++ b/extensions/indexes/indexes-integration-tests/src/test/resources-filtered/conf.xml @@ -899,6 +899,7 @@ + diff --git a/extensions/indexes/lucene/src/test/resources-filtered/conf.xml b/extensions/indexes/lucene/src/test/resources-filtered/conf.xml index beaf90e4e62..54e75f31706 100644 --- a/extensions/indexes/lucene/src/test/resources-filtered/conf.xml +++ b/extensions/indexes/lucene/src/test/resources-filtered/conf.xml @@ -897,6 +897,7 @@ + diff --git a/extensions/indexes/ngram/src/test/resources-filtered/conf.xml b/extensions/indexes/ngram/src/test/resources-filtered/conf.xml index 7b290c22429..f721d992297 100644 --- a/extensions/indexes/ngram/src/test/resources-filtered/conf.xml +++ b/extensions/indexes/ngram/src/test/resources-filtered/conf.xml @@ -896,6 +896,7 @@ + diff --git a/extensions/indexes/range/src/test/resources-filtered/conf.xml b/extensions/indexes/range/src/test/resources-filtered/conf.xml index a22d440f625..7be8cb12990 100644 --- a/extensions/indexes/range/src/test/resources-filtered/conf.xml +++ b/extensions/indexes/range/src/test/resources-filtered/conf.xml @@ -901,6 +901,7 @@ + diff --git a/extensions/indexes/sort/src/test/resources-filtered/conf.xml b/extensions/indexes/sort/src/test/resources-filtered/conf.xml index e6d70cea684..6b38321794f 100644 --- a/extensions/indexes/sort/src/test/resources-filtered/conf.xml +++ b/extensions/indexes/sort/src/test/resources-filtered/conf.xml @@ -896,6 +896,7 @@ + diff --git a/extensions/modules/cache/src/test/resources-filtered/conf.xml b/extensions/modules/cache/src/test/resources-filtered/conf.xml index af9663be608..94f97c048c8 100644 --- a/extensions/modules/cache/src/test/resources-filtered/conf.xml +++ b/extensions/modules/cache/src/test/resources-filtered/conf.xml @@ -753,6 +753,7 @@ + diff --git a/extensions/modules/cache/src/test/resources-filtered/lazy-cache-conf.xml b/extensions/modules/cache/src/test/resources-filtered/lazy-cache-conf.xml index af9663be608..94f97c048c8 100644 --- a/extensions/modules/cache/src/test/resources-filtered/lazy-cache-conf.xml +++ b/extensions/modules/cache/src/test/resources-filtered/lazy-cache-conf.xml @@ -753,6 +753,7 @@ + diff --git a/extensions/modules/cache/src/test/resources-filtered/non-lazy-cache-conf.xml b/extensions/modules/cache/src/test/resources-filtered/non-lazy-cache-conf.xml index d7ec021a27f..fb29dd589ba 100644 --- a/extensions/modules/cache/src/test/resources-filtered/non-lazy-cache-conf.xml +++ b/extensions/modules/cache/src/test/resources-filtered/non-lazy-cache-conf.xml @@ -752,6 +752,7 @@ + diff --git a/extensions/modules/compression/src/test/resources-filtered/conf.xml b/extensions/modules/compression/src/test/resources-filtered/conf.xml index 0bdebfee2d6..62cec760a15 100644 --- a/extensions/modules/compression/src/test/resources-filtered/conf.xml +++ b/extensions/modules/compression/src/test/resources-filtered/conf.xml @@ -750,6 +750,7 @@ + diff --git a/extensions/modules/expathrepo/src/test/resources-filtered/conf.xml b/extensions/modules/expathrepo/src/test/resources-filtered/conf.xml index 0203297b9dd..1b483d207f7 100644 --- a/extensions/modules/expathrepo/src/test/resources-filtered/conf.xml +++ b/extensions/modules/expathrepo/src/test/resources-filtered/conf.xml @@ -753,6 +753,7 @@ + diff --git a/extensions/modules/image/src/test/resources-filtered/conf.xml b/extensions/modules/image/src/test/resources-filtered/conf.xml index 9df613700e8..951a502236e 100644 --- a/extensions/modules/image/src/test/resources-filtered/conf.xml +++ b/extensions/modules/image/src/test/resources-filtered/conf.xml @@ -753,6 +753,7 @@ + diff --git a/extensions/modules/xmldiff/src/test/resources-filtered/conf.xml b/extensions/modules/xmldiff/src/test/resources-filtered/conf.xml index a1a95c324d6..40e724e8e4b 100644 --- a/extensions/modules/xmldiff/src/test/resources-filtered/conf.xml +++ b/extensions/modules/xmldiff/src/test/resources-filtered/conf.xml @@ -750,6 +750,7 @@ + diff --git a/extensions/modules/xslfo/src/test/resources-filtered/conf.xml b/extensions/modules/xslfo/src/test/resources-filtered/conf.xml index 3e14e631740..5b4a02b7474 100644 --- a/extensions/modules/xslfo/src/test/resources-filtered/conf.xml +++ b/extensions/modules/xslfo/src/test/resources-filtered/conf.xml @@ -752,6 +752,7 @@ + diff --git a/extensions/xqdoc/src/test/resources-filtered/conf.xml b/extensions/xqdoc/src/test/resources-filtered/conf.xml index 7c96ef98809..4b9955dcab7 100644 --- a/extensions/xqdoc/src/test/resources-filtered/conf.xml +++ b/extensions/xqdoc/src/test/resources-filtered/conf.xml @@ -752,6 +752,7 @@ +