diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9917e21e..70d9e112 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,4 +34,12 @@ To set up the vscode-java-dependency project, follow these steps: - Run the "Attach to Plugin" task. - Note: This task is required only if you want to debug Java code [jdtls.ext](./jdtls.ext). It requires the [vscode-pde](https://marketplace.visualstudio.com/items?itemName=yaozheng.vscode-pde) extension to be installed. +## Java LSP Tool Contract Tests + +After installing dependencies, run `npm run test-lsp-tools` for the isolated navigation-tool suite. It compiles TypeScript and starts a separate VS Code test host; no Java server build, Java project, or signed-in Copilot session is required. Set `VSCODE_EXECUTABLE_PATH` to reuse an existing VS Code executable instead of downloading one. + +These tests exercise the tool implementations with real VS Code URI, range, error and tool-result types, but mock providers, workspace membership, readiness and telemetry. They cover output contracts, URI handoff, error classification, retry behavior and truncation. They do not validate live JDT search coverage, indexing completeness, Native/CLI reader integration or token savings. The suite also runs as part of `npm test`. + +`lmTool.findSymbol` records `initialQueryDurationMs` and `retryQueryDurationMs` separately from total `durationMs`. These measure client-observed provider calls, not internal JDT phases; retry duration is zero when no retry occurs. No query text, source paths or symbol names are added to these events. + Thank you for your contributions and support! \ No newline at end of file diff --git a/package.json b/package.json index fdf21014..0fe4894e 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ { "name": "lsp_java_getFileStructure", "toolReferenceName": "javaFileStructure", - "modelDescription": "Outline a known Java file (classes, methods, fields with line ranges) to pick a precise read_file range instead of reading the whole file. Needs a path from lsp_java_findSymbol or the user — do not guess. Returns file plus per-symbol readFileRange ({ offset, limit }) for read_file. Use limit to cap outline items (default 20, max 60). Not for workspace search (use lsp_java_findSymbol).", + "modelDescription": "Outline a known workspace Java file with full declaration readFileRange. Use a confirmed path or documentUri; never guess. Only file: workspace documents are supported.", "displayName": "Java: Get File Structure", "userDescription": "Get a Java file outline with classes, methods, fields, and line ranges.", "tags": [ @@ -69,11 +69,13 @@ "properties": { "uri": { "type": "string", - "description": "Workspace-relative path to a Java file, from lsp_java_findSymbol or user input — do not guess." + "description": "File URI or absolute/workspace-relative path." }, "limit": { - "type": "number", - "description": "Maximum outline items to return (default: 20, max: 60). Use a smaller value when only top-level context is needed." + "type": "integer", + "minimum": 1, + "maximum": 60, + "description": "Output node cap, including children; default 20. Does not bound provider work." } }, "required": [ @@ -84,9 +86,9 @@ { "name": "lsp_java_findSymbol", "toolReferenceName": "javaFindSymbol", - "modelDescription": "Find Java class/interface/method/field definitions across the workspace by name or partial identifier. Prefer over grep_search, file_search, or semantic_search for Java symbol lookup. Each result has file and readFileInput ({ filePath, offset, limit }) for read_file; use it when source is needed, or lsp_java_getFileStructure with file for broader context. On empty results don't re-search (it retries internally); retry once only if it reports indexing in progress, else use generic search. Not for non-Java files, literals, comments, or build/XML files.", + "modelDescription": "Find Java types by name/pattern; source methods require opt-in, fields are unsupported. selectionRange is navigation-only. For full source ranges, pass documentUri to lsp_java_getFileStructure when outlineSupported=true.", "displayName": "Java: Find Symbol", - "userDescription": "Find Java class, method, field, or interface definitions by name.", + "userDescription": "Locate Java types by name; source methods depend on Java symbol-search settings.", "tags": [ "java", "lsp", @@ -101,11 +103,13 @@ "properties": { "query": { "type": "string", - "description": "Symbol name or pattern to search for" + "description": "Java type name or pattern." }, "limit": { - "type": "number", - "description": "Maximum results (default: 20, max: 50)" + "type": "integer", + "minimum": 1, + "maximum": 50, + "description": "Output result cap; default 20. Does not bound provider work." } }, "required": [ @@ -1192,6 +1196,7 @@ "compile": "tsc -p . && webpack --config webpack.config.js --mode development", "watch": "webpack --mode development --watch", "test": "tsc -p . && webpack --config webpack.config.js --mode development && node ./dist/test/index.js", + "test-lsp-tools": "tsc -p . && node ./out/test/runLspToolsTests.js", "test-e2e": "autotest run-all test/e2e-plans --no-llm", "build-server": "node scripts/buildJdtlsExt.js", "vscode:prepublish": "tsc -p ./ && webpack --mode production", diff --git a/resources/instruments/javaLspContext.instructions.md b/resources/instruments/javaLspContext.instructions.md index 6c5d10a4..48fc7314 100644 --- a/resources/instruments/javaLspContext.instructions.md +++ b/resources/instruments/javaLspContext.instructions.md @@ -1,15 +1,8 @@ --- -description: Use Java LSP tools for precise Java symbol navigation. Prefer lsp_java_findSymbol and lsp_java_getFileStructure over generic search only when locating Java classes, methods, fields, or file outlines. +description: Java type lookup and known-file outlines. applyTo: '**/*.java' --- -For Java symbol navigation, two compiler-accurate `lsp_java_*` tools are available and return structured results with smaller, easier-to-interpret payloads than generic search: +Prefer Java LSP tools for type-name lookup and known-file outlines. Use text search for members with unknown containing types or non-symbol content. Do not change Java settings to make a lookup work. -- `lsp_java_findSymbol(query)` — find class/method/field definitions by name across the workspace -- `lsp_java_getFileStructure(uri)` — get file outline (classes, methods, fields) with line ranges - -If these tools are not already available in the current tool list, load them with `tool_search` using a query such as `Java LSP symbol navigation lsp_java`. - -Use `lsp_java_findSymbol` before `grep_search`, `search_subagent`, `semantic_search`, or `file_search` only when the task is to locate Java symbols by name or partial identifier. If it returns relevant symbols and source is needed, call `read_file` with the returned `readFileInput`, or call `lsp_java_getFileStructure` with the returned `file` when broader file context is needed. - -Use `lsp_java_getFileStructure` only with a path confirmed by the user or a previous tool result. Prefer `file` from `lsp_java_findSymbol`; do not guess paths. Its output includes a top-level `file` and per-symbol `readFileRange`; to read a selected symbol, call `read_file` with `filePath=file` and that `readFileRange`. Use `limit` to keep large outlines small. Use generic search for string literals, comments, XML, Gradle/Maven files, non-Java files, or broad conceptual exploration. `lsp_java_findSymbol` already retries internally with a normalized identifier, so do not re-issue the same search on an empty result: if it reports indexing in progress, retry once after a short pause; otherwise fall back to generic search. +Load the `java-lsp-tools` skill as needed for tool discovery, settings, URI/range handoff, reader adaptation and fallback rules. diff --git a/resources/skills/java-lsp-tools/SKILL.md b/resources/skills/java-lsp-tools/SKILL.md index 4535b75d..dcb56e01 100644 --- a/resources/skills/java-lsp-tools/SKILL.md +++ b/resources/skills/java-lsp-tools/SKILL.md @@ -1,32 +1,41 @@ --- name: java-lsp-tools -description: Compiler-accurate Java symbol navigation via the Java Language Server. Use lsp_java_findSymbol for Java identifiers and lsp_java_getFileStructure for known Java files; prefer them over generic search only for symbol/file-outline navigation. +description: Java LSP navigation, source-range handoff and troubleshooting for type lookup and file outlines. --- # Java LSP Tools -Two compiler-accurate tools backed by the Java Language Server (jdtls). They return structured JSON that is easier to interpret than generic search results for Java symbol navigation. +Two navigation tools backed by language-service providers, including the Java Language Server (jdtls). Availability and results depend on imported projects, provider scope and Java settings; they do not certify complete coverage. + +If these tools are not already available in the current tool list, load them with `tool_search` using a query such as `Java LSP symbol navigation lsp_java`. ## Tools ### `lsp_java_findSymbol` -Search for Java symbol definitions (classes, methods, fields) by name across the workspace. Supports partial matching. -- Input: `{ query, limit? }` — limit defaults to 20, max 50 -- Output: `{ results: [{ name, kind, container?, file, startLine, endLine, readFileInput, range }], total }`; `readFileInput` is `{ filePath, offset, limit }` for `read_file`, and `file` can be passed to `lsp_java_getFileStructure` -- **Use instead of** `grep_search`, `file_search`, `semantic_search`, or `search_subagent` when looking for where a Java class/method/field is defined by identifier -- When source is needed for a returned symbol, use its `readFileInput` directly +Locate Java types (classes, interfaces, enums, records) by name or pattern. +- Input: `{ query, limit? }` — integer limit defaults to 20, max 50; caps output only, not provider search work. +- Source methods are searched only when `java.symbols.includeSourceMethodDeclarations` is enabled (off by default). Fields are not searched. Do not change user settings to make a query work. +- Output: `{ results: [{ name, kind, container?, documentUri, file?, outlineSupported, unsupportedReason?, selectionRange: { startLine, endLine } }], total, truncated? }`. +- `selectionRange` contains 1-based inclusive navigation lines, usually only the name. It is **not** a full declaration or implementation read range. +- `documentUri` preserves the provider's exact URI. `file` is an absolute path, present only for supported `file:` workspace documents. `outlineSupported` means the location is eligible for the outline tool, not that the file exists or is readable. +- Prefer this tool for type-name lookup. For a member, inspect the known containing type's file outline; if the type is unknown, use text search. ### `lsp_java_getFileStructure` Get hierarchical outline of a Java file (classes, methods, fields) with line ranges. -- Input: `{ uri, limit? }` — workspace-relative path plus max outline items. Prefer `file` from `lsp_java_findSymbol`; limit defaults to 20, max 60. Must be a known path from prior tool results or user input — do not guess -- Output: `{ file, symbols: [{ name, kind, startLine, endLine, readFileRange, range, detail?, children? }], truncated? }`; call `read_file` with `filePath=file` and the selected symbol's `readFileRange` +- Input: `{ uri, limit? }` — prefer the exact `documentUri` from a result with `outlineSupported=true`. Confirmed absolute or workspace-relative file paths also work. Do not guess paths. Integer limit defaults to 20, max 60, including child nodes. +- Only `file:` documents within the workspace are supported. The item limit caps output, not provider work. +- Output: `{ documentUri, file, symbols: [{ name, kind, startLine, endLine, readFileRange, range, detail?, children? }], truncated? }`. `file` is absolute. `readFileRange` contains a 1-based `offset` and line-count `limit` covering the provider's full declaration range. +- For a reader accepting `{ filePath, offset, limit }`, use `filePath=file` with the selected symbol's `readFileRange`. Adapt to other reader schemas; Native and CLI parameters are not necessarily identical. +- Select a member before reading a large class. `truncated=true` means count or depth limits omitted symbols; it does not mean the requested member is absent. Use targeted text search when the capped outline omits it. - **Use before** `read_file` when you need to choose a precise line range in a known Java file ## When to Use | Task | Use | Not | |---|---|---| -| Find class/method/field definition | `lsp_java_findSymbol` | `grep_search` | +| Find a type by name | `lsp_java_findSymbol` | Full-file reads | +| Find a member of a known type | Locate type, then `lsp_java_getFileStructure` | Blind member-name workspace search | +| Find a member with unknown containing type | Text search | Assuming method/field search is supported | | See known Java file outline before reading | `lsp_java_getFileStructure` | `read_file` full file | | Search non-Java files (xml, gradle) | `grep_search` | lsp tools | | Search string literals or comments | `grep_search` | lsp tools | @@ -36,10 +45,13 @@ Get hierarchical outline of a Java file (classes, methods, fields) with line ran **lsp_java_findSymbol → lsp_java_getFileStructure → read_file (specific lines only)** -If `lsp_java_findSymbol` returns relevant symbols and source is needed, call `read_file` with the returned `readFileInput`, or call `lsp_java_getFileStructure` with the returned `file` when broader file context is needed. +If `lsp_java_findSymbol` returns a relevant result with `outlineSupported=true` and implementation is needed, pass `documentUri` to `lsp_java_getFileStructure`. Select the appropriate member's full range, then read it. Do not use the workspace symbol's `selectionRange` as a substitute for a full implementation. ## Fallback -- `findSymbol` returns empty → it already retried internally with a normalized identifier, so do not re-issue the same search. If the result says indexing is in progress, retry once after a short pause; otherwise fall back to `grep_search` -- Path error (`fileNotFound`) → use `findSymbol` to discover the correct path first; do not guess paths -- Tool error / jdtls not ready → fall back to `grep_search` + `read_file`, don't retry more than once +- Empty result: normalization is retried internally only when it changes the query. Retry once after initialization if `reason=serverNotFullyReady`; otherwise use text search. Initialization readiness is not index-completeness evidence. +- `outlineSupported=false`: use an authorized document reader supporting `documentUri`. Dependency, virtual, remote and outside-workspace documents are not supported by this outline tool; do not rewrite their URIs as workspace paths or bypass access boundaries. +- `fileNotFound`: confirm the file via type lookup or file search; do not guess. +- `permissionDenied` / `fileSystemUnavailable`: check permissions or the file system connection; symbol search does not repair these failures. +- `ambiguousWorkspacePath`: pass `documentUri` instead of a duplicated workspace-folder display name. +- Other tool errors: fall back to text search and an appropriate reader; do not repeatedly retry. diff --git a/src/copilot/tools/javaContextTools.ts b/src/copilot/tools/javaContextTools.ts index 22179a3b..c9205e02 100644 --- a/src/copilot/tools/javaContextTools.ts +++ b/src/copilot/tools/javaContextTools.ts @@ -4,9 +4,9 @@ *--------------------------------------------------------------------------------------------*/ /** - * Java Context Tools — First Batch (Zero-Blocking) + * Java Context Tools * - * These 6 tools are all non-blocking after jdtls is ready: + * Two tools are registered; the remaining implementations are not exposed: * 1. lsp_java_getFileStructure — LSP documentSymbol * 2. lsp_java_findSymbol — LSP workspaceSymbol * 3. lsp_java_getFileImports — jdtls AST-only command (no type resolution) @@ -15,9 +15,9 @@ * 6. lsp_java_getTypeHierarchy — LSP type hierarchy * * Design principles: - * - Each tool returns < 200 tokens + * - Bound output size by result count (not a token guarantee) * - Structured JSON output - * - No classpath resolution, no dependency download + * - Delegate semantic work to installed language-service providers */ import * as path from "path"; @@ -26,7 +26,7 @@ import { Commands } from "../../commands"; import { languageServerApiManager } from "../../languageServerApi/languageServerApiManager"; import { sendInfo } from "vscode-extension-telemetry-wrapper"; -// Hard caps to keep tool responses within the < 200 token budget. +// Output caps do not bound provider work or response tokens. const MAX_SYMBOL_DEPTH = 3; const MAX_FILE_STRUCTURE_SYMBOL_NODES = 60; const DEFAULT_FILE_STRUCTURE_SYMBOL_NODES = 20; @@ -45,12 +45,6 @@ function getResponseCharCount(data: unknown): number { return typeof data === "string" ? data.length : JSON.stringify(data, null, 2).length; } -interface ReadFileInput { - filePath: string; - offset: number; - limit: number; -} - interface ReadFileRange { offset: number; limit: number; @@ -71,13 +65,6 @@ function toReadFileRange(startLine: number, endLine: number): ReadFileRange { }; } -function toReadFileInput(filePath: string, startLine: number, endLine: number): ReadFileInput { - return { - filePath, - ...toReadFileRange(startLine, endLine), - }; -} - /** * Normalize a workspace-symbol query for a single fallback retry. * Strips a fully-qualified package prefix (com.foo.Bar -> Bar), generic parameters @@ -99,7 +86,40 @@ function normalizeSymbolQuery(query: string): string { return q.trim(); } +class FileAccessError extends Error { + constructor(public readonly code: string, message: string, public readonly hint: string) { + super(message); + } +} + +function getFileAccessError(error: unknown): FileAccessError | undefined { + if (error instanceof FileAccessError) { + return error; + } + if (error instanceof vscode.FileSystemError) { + switch (error.code) { + case "FileNotFound": + case "FileNotADirectory": + return new FileAccessError("fileNotFound", "File not found.", + "Use a confirmed file path or documentUri. Locate the containing type or use file search; do not guess paths."); + case "NoPermissions": + return new FileAccessError("permissionDenied", "Permission denied.", + "Check file permissions. Repeating symbol search will not fix access permissions."); + case "Unavailable": + return new FileAccessError("fileSystemUnavailable", "File system is unavailable.", + "Restore the file system connection before retrying."); + default: + return undefined; + } + } + return undefined; +} + function getToolErrorCode(error: unknown): string { + const fileError = getFileAccessError(error); + if (fileError) { + return fileError.code; + } const message = error instanceof Error ? error.message : String(error); if (message.includes("No workspace folder")) { return "noWorkspaceFolder"; @@ -113,6 +133,33 @@ function getToolErrorCode(error: unknown): string { return "unexpectedError"; } +function checkFileUri(uri: vscode.Uri): void { + if (uri.scheme !== "file") { + throw new FileAccessError("unsupportedUriScheme", "This tool only supports file: documents.", + "Use a document reader that supports the returned documentUri for virtual or remote documents."); + } + if (!vscode.workspace.workspaceFolders?.length) { + throw new FileAccessError("noWorkspaceFolder", "No workspace folder is open.", "Open the containing workspace first."); + } + if (!vscode.workspace.getWorkspaceFolder(uri)) { + throw new FileAccessError("outsideWorkspace", "The document is outside the current workspace.", + "Use an authorized reader for dependency or external source; do not rewrite the URI as a workspace path."); + } +} + +function getDocumentLocation(uri: vscode.Uri) { + const documentUri = uri.toString(); + try { + checkFileUri(uri); + return { documentUri, file: uri.fsPath, outlineSupported: true }; + } catch (error) { + if (!(error instanceof FileAccessError)) { + throw error; + } + return { documentUri, outlineSupported: false, unsupportedReason: error.code }; + } +} + /** * Resolve a file path to a vscode.Uri. * Accepts: @@ -127,27 +174,28 @@ function getToolErrorCode(error: unknown): string { function resolveFileUri(input: string): vscode.Uri { const folders = vscode.workspace.workspaceFolders; if (!folders || folders.length === 0) { - throw new Error("No workspace folder is open."); + throw new FileAccessError("noWorkspaceFolder", "No workspace folder is open.", "Open the containing workspace first."); } let uri: vscode.Uri; const normalizedInput = input.trim(); - if (normalizedInput.includes("://")) { - // URI string (e.g. "file:///home/user/project/src/Main.java") - uri = vscode.Uri.parse(normalizedInput); - if (uri.scheme !== "file") { - throw new Error(`Unsupported URI scheme "${uri.scheme}". Only file: URIs are allowed.`); - } - } else if (path.isAbsolute(normalizedInput)) { - // Absolute filesystem path (Unix or Windows) + if (path.isAbsolute(normalizedInput)) { + // Check filesystem paths before URI schemes to preserve Windows drive paths. uri = vscode.Uri.file(normalizedInput); + } else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(normalizedInput)) { + uri = vscode.Uri.parse(normalizedInput); } else { // Relative path — resolve against a matching workspace folder when // asRelativePath included the folder name, otherwise use the first root. const normalizedRelativePath = normalizedInput.replace(/\\/g, "/"); - const matchingFolder = folders.find(folder => + const matchingFolders = folders.filter(folder => normalizedRelativePath === folder.name || normalizedRelativePath.startsWith(`${folder.name}/`)); + if (matchingFolders.length > 1) { + throw new FileAccessError("ambiguousWorkspacePath", "The path matches multiple workspace folders.", + "Pass the exact documentUri from a previous result instead of a workspace-folder display name."); + } + const matchingFolder = matchingFolders[0]; if (matchingFolder) { const pathInFolder = normalizedRelativePath === matchingFolder.name ? "" @@ -158,15 +206,7 @@ function resolveFileUri(input: string): vscode.Uri { } } - // Ensure the resolved path is under a workspace folder - const resolvedPath = uri.fsPath.toLowerCase(); - const isUnderWorkspace = folders.some(folder => { - const folderPath = folder.uri.fsPath.toLowerCase(); - return resolvedPath === folderPath || resolvedPath.startsWith(folderPath + (process.platform === "win32" ? "\\" : "/")); - }); - if (!isUnderWorkspace) { - throw new Error("The resolved path is outside the current workspace."); - } + checkFileUri(uri); return uri; } @@ -192,32 +232,21 @@ const fileStructureTool: vscode.LanguageModelTool = { let truncated = false; try { const uri = resolveFileUri(options.input.uri); - try { - await vscode.workspace.fs.stat(uri); - } catch { - status = "error"; - errorCode = "fileNotFound"; - // Most fileNotFound errors come from the model guessing a path. Return an - // actionable hint instead of a dead end so it can self-correct via findSymbol. - const fileNotFoundPayload = { - error: "File not found.", - hint: "Call lsp_java_findSymbol to obtain the exact workspace path before retrying. Do not guess file paths.", - }; - responseCharCount = getResponseCharCount(fileNotFoundPayload); - return toResult(fileNotFoundPayload); - } + await vscode.workspace.fs.stat(uri); const symbols = await vscode.commands.executeCommand( "vscode.executeDocumentSymbolProvider", uri, ); if (!symbols || symbols.length === 0) { status = "empty"; - // Separate "index not ready yet" from a genuine no-symbol result so the model - // (and telemetry) can tell a transient state apart from an unrecognized file. - const indexing = !languageServerApiManager.isFullyReady(); - emptyReason = indexing ? "indexingInProgress" : "documentSymbolProviderEmpty"; - const noSymbolsPayload = indexing - ? { error: "Java language server is still indexing. Retry shortly." } - : { error: "No symbols found. The file may not be recognized by the Java language server." }; + const serverNotFullyReady = !languageServerApiManager.isFullyReady(); + emptyReason = serverNotFullyReady ? "serverNotFullyReady" : "documentSymbolProviderEmpty"; + const noSymbolsPayload = { + symbols: [], + reason: emptyReason, + message: serverNotFullyReady + ? "Java language server initialization has not completed. Retry once after it becomes ready, or use text search." + : "No document symbols returned. Check that the file is recognized by the Java language server.", + }; responseCharCount = getResponseCharCount(noSymbolsPayload); return toResult(noSymbolsPayload); } @@ -225,13 +254,23 @@ const fileStructureTool: vscode.LanguageModelTool = { const result = symbolsToJson(symbols, 0, counter, limit); resultCount = counter.count; truncated = counter.truncated; - const file = vscode.workspace.asRelativePath(uri); - const fileStructurePayload = { file, symbols: result, ...(truncated && { truncated: true }) }; + const fileStructurePayload = { + documentUri: uri.toString(), + file: uri.fsPath, + symbols: result, + ...(truncated && { truncated: true }), + }; responseCharCount = getResponseCharCount(fileStructurePayload); return toResult(fileStructurePayload); } catch (e) { status = "error"; errorCode = errorCode || getToolErrorCode(e); + const fileError = getFileAccessError(e); + if (fileError) { + const payload = { error: fileError.message, errorCode, hint: fileError.hint }; + responseCharCount = getResponseCharCount(payload); + return toResult(payload); + } throw e; } finally { sendInfo("", { @@ -282,6 +321,8 @@ function symbolsToJson(symbols: vscode.DocumentSymbol[], depth: number, counter: } if (s.children?.length && depth < MAX_SYMBOL_DEPTH) { node.children = symbolsToJson(s.children, depth + 1, counter, limit); + } else if (s.children?.length) { + counter.truncated = true; } result.push(node); } @@ -302,12 +343,14 @@ const findSymbolTool: vscode.LanguageModelTool = { const startTime = Date.now(); let resultCount = 0; let totalResults = 0; - const limit = Math.min(Math.max(options.input.limit || 20, 1), 50); + const limit = Math.min(Math.max(Math.floor(options.input.limit ?? 20), 1), 50); let status = "success"; let errorCode = ""; let emptyReason = ""; let responseCharCount = 0; let retried = false; + let initialQueryDurationMs = 0; + let retryQueryDurationMs = 0; try { const rawQuery = (options.input.query ?? "").trim(); // Reject blank/whitespace-only queries early: an empty query triggers an @@ -316,55 +359,65 @@ const findSymbolTool: vscode.LanguageModelTool = { status = "error"; errorCode = "emptyQuery"; const emptyQueryPayload = { - error: "Query is empty. Provide a class, interface, method, or field name to search for.", + error: "Query is empty. Provide a Java type name or pattern.", }; responseCharCount = getResponseCharCount(emptyQueryPayload); return toResult(emptyQueryPayload); } - let symbols = await vscode.commands.executeCommand( - "vscode.executeWorkspaceSymbolProvider", rawQuery, - ); - // Server-side fallback: if the verbatim query misses, retry once with a + let symbols: vscode.SymbolInformation[] | undefined; + const initialQueryStart = Date.now(); + try { + symbols = await vscode.commands.executeCommand( + "vscode.executeWorkspaceSymbolProvider", rawQuery, + ); + } finally { + initialQueryDurationMs = Date.now() - initialQueryStart; + } + // Tool-side fallback: if the verbatim query misses, retry once with a // normalized identifier (strip package qualifier, generics, and parameter // lists) so the model does not have to chain repeated findSymbol calls itself. if (!symbols || symbols.length === 0) { const normalized = normalizeSymbolQuery(rawQuery); if (normalized && normalized !== rawQuery) { retried = true; - symbols = await vscode.commands.executeCommand( - "vscode.executeWorkspaceSymbolProvider", normalized, - ); + const retryQueryStart = Date.now(); + try { + symbols = await vscode.commands.executeCommand( + "vscode.executeWorkspaceSymbolProvider", normalized, + ); + } finally { + retryQueryDurationMs = Date.now() - retryQueryStart; + } } } if (!symbols || symbols.length === 0) { status = "empty"; - // Distinguish a transient "index not ready" state from a real no-match so the - // model can retry later instead of concluding the symbol does not exist. - const indexing = !languageServerApiManager.isFullyReady(); - emptyReason = indexing ? "indexingInProgress" : "workspaceSymbolNoMatch"; - const noMatchesPayload = indexing - ? { results: [], message: "Java language server is still indexing. Retry shortly or use grep_search as a fallback." } - : { results: [], message: "No symbols found." }; + const serverNotFullyReady = !languageServerApiManager.isFullyReady(); + emptyReason = serverNotFullyReady ? "serverNotFullyReady" : "workspaceSymbolNoMatch"; + const noMatchesPayload = { + results: [], + reason: emptyReason, + message: serverNotFullyReady + ? "Java language server initialization has not completed. Retry once after it becomes ready, or use text search." + : "No matching symbols returned. Methods require java.symbols.includeSourceMethodDeclarations; fields are not searched." + + " Use the known containing type's file outline or text search. Empty results do not prove a symbol is absent.", + }; responseCharCount = getResponseCharCount(noMatchesPayload); return toResult(noMatchesPayload); } totalResults = symbols.length; const results = symbols.slice(0, limit).map(s => { - const file = vscode.workspace.asRelativePath(s.location.uri); const { startLine, endLine } = toInclusiveLineRange(s.location.range); return { name: s.name, kind: vscode.SymbolKind[s.kind], container: s.containerName || undefined, - file, - startLine, - endLine, - readFileInput: toReadFileInput(file, startLine, endLine), - range: `L${startLine}-${endLine}`, + ...getDocumentLocation(s.location.uri), + selectionRange: { startLine, endLine }, }; }); resultCount = results.length; - const findSymbolPayload = { results, total: symbols.length }; + const findSymbolPayload = { results, total: symbols.length, ...(symbols.length > limit && { truncated: true }) }; responseCharCount = getResponseCharCount(findSymbolPayload); return toResult(findSymbolPayload); } catch (e) { @@ -382,6 +435,8 @@ const findSymbolTool: vscode.LanguageModelTool = { resultCount, totalResults, responseCharCount, + initialQueryDurationMs, + retryQueryDurationMs, durationMs: Date.now() - startTime, }); } diff --git a/test/index.ts b/test/index.ts index a08d7358..5098470a 100644 --- a/test/index.ts +++ b/test/index.ts @@ -31,6 +31,14 @@ async function main(): Promise { // Download VS Code, unzip it and run the integration test + // Run isolated LSP tool contract tests without requiring a Java project. + await runTests({ + vscodeExecutablePath, + extensionDevelopmentPath, + extensionTestsPath: path.join(extensionDevelopmentPath, "out", "test", "lsp-tools-suite"), + launchArgs: [`--user-data-dir=${userDir}`], + }); + // Run general test await runTests({ vscodeExecutablePath, diff --git a/test/lsp-tools-suite/index.ts b/test/lsp-tools-suite/index.ts new file mode 100644 index 00000000..4728daa3 --- /dev/null +++ b/test/lsp-tools-suite/index.ts @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import * as Mocha from "mocha"; +import * as path from "path"; + +export function run(): Promise { + const mocha = new Mocha({ ui: "tdd", color: true, timeout: 10000 }); + mocha.addFile(path.join(__dirname, "javaContextTools.test.js")); + return new Promise((resolve, reject) => { + mocha.run(failures => failures ? reject(new Error(`${failures} tests failed.`)) : resolve()); + }); +} diff --git a/test/lsp-tools-suite/javaContextTools.test.ts b/test/lsp-tools-suite/javaContextTools.test.ts new file mode 100644 index 00000000..52bae54b --- /dev/null +++ b/test/lsp-tools-suite/javaContextTools.test.ts @@ -0,0 +1,312 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import * as assert from "assert"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import * as vm from "vm"; +import * as vscode from "vscode"; + +interface Input { + query?: string; + uri?: string; + limit?: number; +} + +interface ResultSymbol { + documentUri: string; + file?: string; + outlineSupported: boolean; + unsupportedReason?: string; + selectionRange: { startLine: number; endLine: number }; + readFileInput?: unknown; + readFileRange: { offset: number; limit: number }; + children?: ResultSymbol[]; +} + +interface Payload { + results: ResultSymbol[]; + symbols: ResultSymbol[]; + documentUri: string; + file: string; + reason?: string; + message?: string; + errorCode?: string; + hint?: string; + total?: number; + truncated?: boolean; +} + +suite("Java LSP tool contracts", () => { + let tools: Map>; + let events: Record[]; + let calls: { command: string; argument: unknown }[]; + let folders: vscode.WorkspaceFolder[]; + let ready: boolean; + let statError: Error | undefined; + let provider: (command: string, argument: unknown) => unknown; + const root = vscode.Uri.file(path.join(os.tmpdir(), "java-lsp-contract")); + const source = vscode.Uri.joinPath(root, "src", "Main.java"); + + setup(() => { + tools = new Map(); + events = []; + calls = []; + folders = [{ uri: root, name: "project", index: 0 }]; + ready = true; + statError = undefined; + provider = () => []; + // Keep real VS Code URI/range/error/result types while isolating providers, readiness and telemetry. + const api = { + ...vscode, + workspace: { + get workspaceFolders() { return folders; }, + getWorkspaceFolder: (uri: vscode.Uri) => folders.find(folder => { + const uriPath = process.platform === "win32" ? uri.path.toLowerCase() : uri.path; + const folderPath = process.platform === "win32" ? folder.uri.path.toLowerCase() : folder.uri.path; + return uri.scheme === folder.uri.scheme && uri.authority === folder.uri.authority + && (uriPath === folderPath || uriPath.startsWith(folderPath + "/")); + }), + fs: { + stat: async () => { + if (statError) { + throw statError; + } + return { type: vscode.FileType.File, ctime: 0, mtime: 0, size: 100 }; + }, + }, + }, + commands: { + executeCommand: async (command: string, argument: unknown) => { + calls.push({ command, argument }); + return provider(command, argument); + }, + }, + lm: { + registerTool: (name: string, tool: vscode.LanguageModelTool) => { + tools.set(name, tool); + return new vscode.Disposable(() => tools.delete(name)); + }, + }, + }; + const exports = { registerJavaContextTools: (_context: { subscriptions: vscode.Disposable[] }) => undefined }; + const dependencies: Record = { + vscode: api, + path, + "../../commands": { Commands: {} }, + "../../languageServerApi/languageServerApiManager": { + languageServerApiManager: { isFullyReady: () => ready }, + }, + "vscode-extension-telemetry-wrapper": { + sendInfo: (_name: string, properties: Record) => events.push(properties), + }, + }; + const code = fs.readFileSync(path.resolve(__dirname, "../../src/copilot/tools/javaContextTools.js"), "utf8"); + vm.runInNewContext(code, { + exports, + require: (id: string) => { + assert.ok(id in dependencies, `Unexpected dependency: ${id}`); + return dependencies[id]; + }, + process, + Error, + }); + exports.registerJavaContextTools({ subscriptions: [] }); + }); + + async function invoke(name: string, input: Input): Promise { + const tool = tools.get(name); + assert.ok(tool); + const cancellation = new vscode.CancellationTokenSource(); + try { + const result = await tool.invoke({ input, toolInvocationToken: undefined }, cancellation.token); + assert.ok(result); + const part = result.content[0]; + assert.ok(part instanceof vscode.LanguageModelTextPart); + return JSON.parse(part.value); + } finally { + cancellation.dispose(); + } + } + + function symbol(uri = source): vscode.SymbolInformation { + return new vscode.SymbolInformation("Main", vscode.SymbolKind.Class, "example", + new vscode.Location(uri, new vscode.Range(4, 6, 4, 10))); + } + + function outline(): vscode.DocumentSymbol { + return new vscode.DocumentSymbol("Main", "", vscode.SymbolKind.Class, + new vscode.Range(2, 0, 20, 0), new vscode.Range(4, 6, 4, 10)); + } + + test("registers only the two existing tools", () => { + assert.deepStrictEqual([...tools.keys()], ["lsp_java_getFileStructure", "lsp_java_findSymbol"]); + }); + + test("keeps name ranges separate from full declaration read ranges", async () => { + const fileOutline = outline(); + fileOutline.children = [new vscode.DocumentSymbol("run", "", vscode.SymbolKind.Method, + new vscode.Range(7, 0, 12, 5), new vscode.Range(7, 9, 7, 12))]; + provider = command => command === "vscode.executeWorkspaceSymbolProvider" ? [symbol()] : [fileOutline]; + const found = await invoke("lsp_java_findSymbol", { query: "Main" }); + const hit = found.results[0]; + assert.deepStrictEqual(hit.selectionRange, { startLine: 5, endLine: 5 }); + assert.strictEqual(hit.readFileInput, undefined); + assert.strictEqual(hit.documentUri, source.toString()); + assert.strictEqual(hit.file, source.fsPath); + assert.strictEqual(hit.outlineSupported, true); + const structured = await invoke("lsp_java_getFileStructure", { uri: hit.documentUri }); + assert.strictEqual(structured.file, source.fsPath); + assert.deepStrictEqual(structured.symbols[0].readFileRange, { offset: 3, limit: 18 }); + assert.deepStrictEqual(structured.symbols[0].children?.[0].readFileRange, { offset: 8, limit: 6 }); + assert.strictEqual(calls.length, 2, "No eager per-candidate document-symbol queries"); + }); + + test("round-trips exact URIs and paths in multi-root workspaces", async () => { + const other = vscode.Uri.joinPath(root, "..", "other"); + folders.push({ uri: other, name: "other", index: 1 }); + const file = vscode.Uri.joinPath(other, "src", "A B.java"); + provider = command => command === "vscode.executeWorkspaceSymbolProvider" ? [symbol(file)] : [outline()]; + const found = await invoke("lsp_java_findSymbol", { query: "Main" }); + for (const uri of [found.results[0].documentUri, file.fsPath, "other/src/A B.java"]) { + const result = await invoke("lsp_java_getFileStructure", { uri }); + assert.strictEqual(result.documentUri, file.toString()); + } + }); + + test("rejects ambiguous display-name paths but accepts exact URIs", async () => { + folders.push({ uri: vscode.Uri.joinPath(root, "..", "other"), name: "project", index: 1 }); + const failed = await invoke("lsp_java_getFileStructure", { uri: "project/src/Main.java" }); + assert.strictEqual(failed.errorCode, "ambiguousWorkspacePath"); + assert.strictEqual(calls.length, 0); + provider = () => [outline()]; + assert.strictEqual((await invoke("lsp_java_getFileStructure", { uri: source.toString() })).documentUri, source.toString()); + }); + + for (const [uri, reason] of [ + [vscode.Uri.parse("jdt://contents/library.jar/example/Main.class?x=1"), "unsupportedUriScheme"], + [vscode.Uri.parse("vscode-remote://ssh-remote+host/project/Main.java"), "unsupportedUriScheme"], + [vscode.Uri.parse("untitled:Main.java"), "unsupportedUriScheme"], + [vscode.Uri.joinPath(root, "..", "outside", "Main.java"), "outsideWorkspace"], + ] as const) { + test(`preserves ${uri.scheme} URI while reporting ${reason}`, async () => { + provider = () => [symbol(uri)]; + const found = await invoke("lsp_java_findSymbol", { query: "Main" }); + assert.strictEqual(found.results[0].documentUri, uri.toString()); + assert.strictEqual(found.results[0].outlineSupported, false); + assert.strictEqual(found.results[0].unsupportedReason, reason); + assert.strictEqual(found.results[0].file, undefined); + const failed = await invoke("lsp_java_getFileStructure", { uri: uri.toString() }); + assert.strictEqual(failed.errorCode, reason); + assert.strictEqual(calls.length, 1, "Unsupported outline must not invoke a provider"); + }); + } + + test("rejects parent traversal and handles missing workspaces", async () => { + assert.strictEqual((await invoke("lsp_java_getFileStructure", { uri: "../outside/Main.java" })).errorCode, "outsideWorkspace"); + folders = []; + assert.strictEqual((await invoke("lsp_java_getFileStructure", { uri: source.toString() })).errorCode, "noWorkspaceFolder"); + assert.strictEqual(calls.length, 0); + }); + + for (const [error, code] of [ + [vscode.FileSystemError.FileNotFound(), "fileNotFound"], + [vscode.FileSystemError.FileNotADirectory(), "fileNotFound"], + [vscode.FileSystemError.NoPermissions(), "permissionDenied"], + [vscode.FileSystemError.Unavailable(), "fileSystemUnavailable"], + ] as const) { + test(`returns actionable ${code} without misclassifying filesystem errors`, async () => { + statError = error; + const failed = await invoke("lsp_java_getFileStructure", { uri: source.toString() }); + assert.strictEqual(failed.errorCode, code); + assert.ok(failed.hint); + assert.strictEqual(calls.length, 0); + const event = events[events.length - 1]; + assert.strictEqual(event.errorCode, code); + assert.strictEqual(event.status, "error"); + assert.ok(Number(event.responseCharCount) > 0); + }); + } + + test("propagates unexpected I/O failures and records an error", async () => { + statError = new Error("unexpected failure"); + await assert.rejects(invoke("lsp_java_getFileStructure", { uri: source.toString() }), /unexpected failure/); + assert.strictEqual(events[events.length - 1].errorCode, "unexpectedError"); + }); + + test("does not diagnose indexing from the server initialization flag", async () => { + ready = false; + for (const name of ["lsp_java_findSymbol", "lsp_java_getFileStructure"]) { + const notReady = await invoke(name, { query: "Main", uri: source.toString() }); + assert.strictEqual(notReady.reason, "serverNotFullyReady"); + assert.ok(!notReady.message?.includes("indexing")); + assert.strictEqual(events[events.length - 1].emptyReason, "serverNotFullyReady"); + } + ready = true; + const result = await invoke("lsp_java_findSymbol", { query: "field" }); + assert.strictEqual(result.reason, "workspaceSymbolNoMatch"); + assert.ok(result.message?.includes("fields are not searched")); + assert.ok(result.message?.includes("do not prove")); + }); + + test("normalizes an empty qualified lookup once and never rewrites user settings", async () => { + provider = (_command, argument) => argument === "Main" ? [symbol()] : []; + const found = await invoke("lsp_java_findSymbol", { query: "example.Main" }); + assert.strictEqual(found.results.length, 1); + assert.deepStrictEqual(calls.map(call => call.argument), ["example.Main", "Main"]); + assert.strictEqual(events[events.length - 1].retried, "true"); + assert.ok(Number(events[events.length - 1].initialQueryDurationMs) >= 0); + assert.ok(Number(events[events.length - 1].retryQueryDurationMs) >= 0); + }); + + test("does not retry unchanged queries and rejects blank queries", async () => { + await invoke("lsp_java_findSymbol", { query: "missing" }); + assert.strictEqual(calls.length, 1); + await invoke("lsp_java_findSymbol", { query: " " }); + assert.strictEqual(calls.length, 1); + assert.strictEqual(events[events.length - 1].errorCode, "emptyQuery"); + }); + + test("reports output truncation without claiming a provider-side search cap", async () => { + provider = () => [symbol(), symbol(), symbol()]; + const result = await invoke("lsp_java_findSymbol", { query: "Main", limit: 1 }); + assert.strictEqual(result.results.length, 1); + assert.strictEqual(result.total, 3); + assert.strictEqual(result.truncated, true); + assert.strictEqual(calls[0].argument, "Main"); + }); + + test("accepts file URIs without an authority delimiter", async () => { + provider = () => [outline()]; + const result = await invoke("lsp_java_getFileStructure", { uri: `file:${source.path}` }); + assert.strictEqual(result.documentUri, source.toString()); + }); + + test("records provider failures without returning a successful empty result", async () => { + provider = () => { throw new Error("provider failed"); }; + await assert.rejects(invoke("lsp_java_findSymbol", { query: "Main" }), /provider failed/); + assert.strictEqual(events[events.length - 1].status, "error"); + assert.ok(Number(events[events.length - 1].initialQueryDurationMs) >= 0); + assert.strictEqual(events[events.length - 1].retryQueryDurationMs, 0); + }); + + test("reports both node-count and depth truncation for outlines", async () => { + const top = outline(); + let current = top; + for (let depth = 0; depth < 5; depth++) { + const child = outline(); + current.children = [child]; + current = child; + } + provider = () => [top]; + const capped = await invoke("lsp_java_getFileStructure", { uri: source.toString(), limit: 1 }); + assert.strictEqual(capped.truncated, true); + assert.strictEqual(capped.symbols[0].children?.length, 0); + const deep = await invoke("lsp_java_getFileStructure", { uri: source.toString(), limit: 60 }); + assert.strictEqual(deep.truncated, true); + assert.strictEqual(events[events.length - 1].resultCount, 4); + provider = () => [outline()]; + assert.strictEqual((await invoke("lsp_java_getFileStructure", { uri: source.toString() })).truncated, undefined); + }); +}); diff --git a/test/runLspToolsTests.ts b/test/runLspToolsTests.ts new file mode 100644 index 00000000..767ec227 --- /dev/null +++ b/test/runLspToolsTests.ts @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import * as path from "path"; +import { runTests } from "@vscode/test-electron"; + +const root = path.resolve(__dirname, "../.."); +runTests({ + vscodeExecutablePath: process.env.VSCODE_EXECUTABLE_PATH, + extensionDevelopmentPath: root, + extensionTestsPath: path.join(__dirname, "lsp-tools-suite"), + launchArgs: [ + "--disable-extensions", + "--skip-welcome", + "--skip-release-notes", + `--user-data-dir=${path.join(root, ".vscode-test", "lsp-tools-user")}`, + `--extensions-dir=${path.join(root, ".vscode-test", "lsp-tools-extensions")}`, + ], +}).catch(error => { + process.stderr.write(`${error}\n`); + process.exitCode = 1; +});