diff --git a/package.json b/package.json index cba4a021..84ecb69f 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ ], "activationEvents": [ "onLanguage:java", + "workspaceContains:**/*.java", "onDebugInitialConfigurations", "onDebugResolve:java", "onCommand:JavaDebug.SpecifyProgramArgs", @@ -151,6 +152,11 @@ { "command": "java.debug.variables.manualExpandLazyVariables", "title": "Manual Expand Lazy Variables" + }, + { + "command": "java.debug.analyzeStackTrace", + "title": "Analyze Stack Trace", + "category": "Java" } ], "menus": { @@ -233,6 +239,10 @@ } ], "commandPalette": [ + { + "command": "java.debug.analyzeStackTrace", + "when": "javaLSReady" + }, { "command": "java.debug.hotCodeReplace", "when": "false" diff --git a/src/extension.ts b/src/extension.ts index 36025559..64b1c094 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -26,6 +26,7 @@ import { pickJavaProcess } from "./processPicker"; import { IProgressReporter } from "./progressAPI"; import { progressProvider } from "./progressImpl"; import { JavaTerminalLinkProvder } from "./terminalLinkProvider"; +import { registerStackTraceLinkProvider } from "./stackTraceLinkProvider"; import { initializeThreadOperations } from "./threadOperations"; import * as utility from "./utility"; import { registerBreakpointCommands } from "./breakpointCommands"; @@ -54,6 +55,7 @@ function initializeExtension(_operationId: string, context: vscode.ExtensionCont registerBreakpointCommands(context); registerVariableMenuCommands(context); context.subscriptions.push(vscode.window.registerTerminalLinkProvider(new JavaTerminalLinkProvder())); + registerStackTraceLinkProvider(context); context.subscriptions.push(vscode.debug.registerDebugConfigurationProvider("java", new JavaDebugConfigurationProvider())); context.subscriptions.push(vscode.debug.registerDebugAdapterDescriptorFactory("java", new JavaDebugAdapterDescriptorFactory())); context.subscriptions.push(instrumentOperationAsVsCodeCommand("JavaDebug.SpecifyProgramArgs", async () => { diff --git a/src/stackFrameParser.ts b/src/stackFrameParser.ts new file mode 100644 index 00000000..daae3a5a --- /dev/null +++ b/src/stackFrameParser.ts @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +// The single source of truth for parsing a Java stack frame, shared by the terminal link provider +// and the document (stack-trace) link provider so the matching stays identical across surfaces. + +export interface IParsedStackFrame { + // The `com.foo.Bar.baz(Bar.java:42)` text handed to `resolveSourceUri` for source resolution. + stackTrace: string; + // The fully-qualified method (`com.foo.Bar.baz`), used for the class-name quick-pick fallback. + methodName: string; + // The positive, safe 1-based source line number parsed from the frame. + lineNumber: number; + // Offset of the frame within the input line (points at the class name, past the leading `at `). + startIndex: number; + // Length of the linkifiable frame text (equals `stackTrace.length`). + length: number; +} + +/** + * Parses the first Java stack frame (e.g. `\tat module/com.foo.Bar.baz(Bar.java:42)`) out of a line, + * or returns undefined when none is present. + * + * A fresh `RegExp` is created per call on purpose: provider callbacks can overlap asynchronously, + * so a shared stateful `RegExp` (were a `g`/`y` flag ever added) could corrupt `lastIndex`. + */ +export function parseJavaStackFrame(line: string): IParsedStackFrame | undefined { + // Group 2: optional module prefix, group 3: fully-qualified method, group 5: `File.java:line`. + const regex = /(\sat\s+)([\w$.]+\/)?(([\w$]+\.)+[<\w$>]+)\(([\w-$]+\.java:\d+)\)/; + const result = regex.exec(line); + if (!result || !result.length) { + return undefined; + } + + const stackTrace = `${result[2] || ""}${result[3]}(${result[5]})`; + const lineNumber = Number(result[5].split(":")[1]); + if (!Number.isSafeInteger(lineNumber) || lineNumber <= 0) { + return undefined; + } + + return { + stackTrace, + methodName: result[3], + lineNumber, + startIndex: result.index + result[1].length, + length: stackTrace.length, + }; +} diff --git a/src/stackTraceLinkProvider.ts b/src/stackTraceLinkProvider.ts new file mode 100644 index 00000000..2ddccf8b --- /dev/null +++ b/src/stackTraceLinkProvider.ts @@ -0,0 +1,209 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import { CancellationToken, commands, DocumentLink, DocumentLinkProvider, DocumentSelector, + env, ExtensionContext, languages, Position, ProviderResult, Range, TextDocument, Uri, + window, workspace } from "vscode"; +import { instrumentOperationAsVsCodeCommand, sendInfo } from "vscode-extension-telemetry-wrapper"; +import { resolveSourceUri } from "./languageServerPlugin"; +import { parseJavaStackFrame } from "./stackFrameParser"; +import { getJavaExtensionAPI, isJavaExtEnabled, ServerMode } from "./utility"; + +const ANALYZE_STACK_TRACE_COMMAND = "java.debug.analyzeStackTrace"; +const NAVIGATE_TO_STACK_FRAME_COMMAND = "_java.debug.navigateToStackFrame"; + +// Linkify stack traces in scratch documents and .log files. Other plaintext documents stay +// excluded so the extension does not passively scan unrelated files. +const STACK_TRACE_DOCUMENT_SELECTOR: DocumentSelector = [ + { scheme: "untitled" }, + { pattern: "**/*.log" }, +]; + +// Bound the work performed for large documents and pathological input. The per-line cap mitigates +// ReDoS in the nested-quantifier regex; the document budgets keep large logs from being fully scanned. +const MAX_SCANNED_LINE_LENGTH = 1000; +const MAX_SCANNED_LINES_PER_DOCUMENT = 10000; +const MAX_SCANNED_CHARACTERS_PER_DOCUMENT = 1000000; +const MAX_LINKS_PER_DOCUMENT = 2000; + +// Only resolve to source locations the language server is expected to return. +const ALLOWED_SOURCE_SCHEMES = new Set(["file", "jdt"]); + +interface IStackFrameLinkArgs { + stackTrace: string; + methodName: string; + lineNumber: number; +} + +function isStackFrameLinkArgs(args: unknown): args is IStackFrameLinkArgs { + if (typeof args !== "object" || args === null) { + return false; + } + + const stackTrace = "stackTrace" in args ? args.stackTrace : undefined; + const methodName = "methodName" in args ? args.methodName : undefined; + const lineNumber = "lineNumber" in args ? args.lineNumber : undefined; + if (typeof stackTrace !== "string" || typeof methodName !== "string" || typeof lineNumber !== "number" + || stackTrace.length === 0 || stackTrace.length > MAX_SCANNED_LINE_LENGTH + || !Number.isSafeInteger(lineNumber) || lineNumber <= 0) { + return false; + } + + const frame = parseJavaStackFrame(` at ${stackTrace}`); + return frame?.stackTrace === stackTrace + && frame.methodName === methodName + && frame.lineNumber === lineNumber; +} + +/** + * Linkifies Java stack frames pasted into untitled (scratch) documents, so that each frame can be + * clicked to jump to the corresponding source line - without requiring an active debug session. + * Resolution reuses the session-independent `resolveSourceUri` backend and is performed lazily, + * only when a link is clicked. + */ +export class JavaStackTraceLinkProvider implements DocumentLinkProvider { + public provideDocumentLinks(document: TextDocument, token: CancellationToken): ProviderResult { + const links: DocumentLink[] = []; + let scannedCharacters = 0; + const linesToScan = Math.min(document.lineCount, MAX_SCANNED_LINES_PER_DOCUMENT); + for (let i = 0; i < linesToScan; i++) { + if (token.isCancellationRequested || links.length >= MAX_LINKS_PER_DOCUMENT) { + break; + } + + const lineText = document.lineAt(i).text; + const lineScanCost = lineText.length + 1; + if (scannedCharacters + lineScanCost > MAX_SCANNED_CHARACTERS_PER_DOCUMENT) { + break; + } + scannedCharacters += lineScanCost; + + if (lineText.length > MAX_SCANNED_LINE_LENGTH) { + continue; + } + + const frame = parseJavaStackFrame(lineText); + if (!frame) { + continue; + } + + const range = new Range( + new Position(i, frame.startIndex), + new Position(i, frame.startIndex + frame.length), + ); + + const args: IStackFrameLinkArgs = { + stackTrace: frame.stackTrace, + methodName: frame.methodName, + lineNumber: frame.lineNumber, + }; + const target = Uri.parse(`command:${NAVIGATE_TO_STACK_FRAME_COMMAND}?${encodeURIComponent(JSON.stringify([args]))}`); + links.push(new DocumentLink(range, target)); + } + + return links; + } +} + +/** + * Resolves a stack frame to its source location and navigates to it. Mirrors the behavior of the + * terminal link provider: jump to the resolved source line, or fall back to a symbol quick pick. + */ +async function navigateToStackFrame(args: unknown): Promise { + if (!isStackFrameLinkArgs(args)) { + return; + } + + // Content-free telemetry: a single usage signal (click count), mirroring the terminal link + // provider. No dimensions - the pasted text is never recorded. + /* __GDPR__ + "navigateToJavaStackFrame" : { + "owner": "vscode-java-debug", + "comment": "Emitted when a user clicks a linkified Java stack frame; measures feature usage.", + "operationName": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + sendInfo("", { operationName: "navigateToJavaStackFrame" }); + + try { + const uri = await resolveSourceUri(args.stackTrace); + if (uri) { + const parsed = Uri.parse(uri); + if (!ALLOWED_SOURCE_SCHEMES.has(parsed.scheme)) { + return; + } + const targetLine = Math.max(args.lineNumber - 1, 0); + await window.showTextDocument(parsed, { + preserveFocus: true, + selection: new Range(new Position(targetLine, 0), new Position(targetLine, 0)), + }); + } else { + // No source found: open the symbol quick pick scoped to the class name. + const fullyQualifiedName = args.methodName.substring(0, args.methodName.lastIndexOf(".")); + const className = fullyQualifiedName.substring(fullyQualifiedName.lastIndexOf(".") + 1); + await commands.executeCommand("workbench.action.quickOpen", "#" + className); + } + } catch { + // The internal navigate command is always registered, but resolving a frame needs the Java + // language server in Standard mode. If it isn't (e.g. server restarting/downgraded) or the + // resolved document fails to open, fail quietly instead of surfacing an unhandled rejection. + } +} + +/** + * Opens a scratch document prefilled with the clipboard content. The document link provider scans + * a bounded portion after the document opens and makes any stack frames clickable. + */ +async function analyzeStackTrace(): Promise { + // The command itself is auto-instrumented via instrumentOperationAsVsCodeCommand, so no + // manual telemetry is needed here to track invocations. + const clipboardContent = await env.clipboard.readText(); + const document = await workspace.openTextDocument({ language: "log", content: clipboardContent }); + await window.showTextDocument(document); +} + +export function registerStackTraceLinkProvider(context: ExtensionContext): void { + // Register handlers immediately for programmatic invocations and existing command links. + // Palette visibility and creation of new links are gated on language-server readiness elsewhere. + context.subscriptions.push( + commands.registerCommand(NAVIGATE_TO_STACK_FRAME_COMMAND, navigateToStackFrame), + instrumentOperationAsVsCodeCommand(ANALYZE_STACK_TRACE_COMMAND, analyzeStackTrace), + ); + + // Linkifying a frame is only meaningful once the Java language server is in Standard mode, + // because resolving a frame to source (resolveSourceUri) requires a fully-loaded workspace. + // Defer registering the link provider until then, mirroring the run/debug CodeLens provider. + registerLinkProviderWhenReady(context); +} + +function registerLinkProviderWhenReady(context: ExtensionContext): void { + // Without the Java language server, frames cannot be resolved to source - nothing to linkify. + if (!isJavaExtEnabled()) { + return; + } + + const doRegister = () => context.subscriptions.push( + languages.registerDocumentLinkProvider(STACK_TRACE_DOCUMENT_SELECTOR, new JavaStackTraceLinkProvider()), + ); + + getJavaExtensionAPI().then((api) => { + if (!api) { + return; + } + + if (api.serverMode === ServerMode.LIGHTWEIGHT || api.serverMode === ServerMode.HYBRID) { + let registered = false; + const serverModeListener = api.onDidServerModeChange((mode: string) => { + if (mode === ServerMode.STANDARD && !registered) { + registered = true; + serverModeListener.dispose(); + doRegister(); + } + }); + context.subscriptions.push(serverModeListener); + } else { + // Already in Standard mode. + doRegister(); + } + }); +} diff --git a/src/terminalLinkProvider.ts b/src/terminalLinkProvider.ts index 33016f71..c7c9e9e8 100644 --- a/src/terminalLinkProvider.ts +++ b/src/terminalLinkProvider.ts @@ -5,6 +5,7 @@ import { CancellationToken, commands, Position, ProviderResult, Range, TerminalL TerminalLinkProvider, Uri, window } from "vscode"; import { sendInfo } from "vscode-extension-telemetry-wrapper"; import { resolveSourceUri } from "./languageServerPlugin"; +import { parseJavaStackFrame } from "./stackFrameParser"; export class JavaTerminalLinkProvder implements TerminalLinkProvider { /** @@ -17,17 +18,14 @@ export class JavaTerminalLinkProvder implements TerminalLinkProvider { const isDebuggerTerminal: boolean = context.terminal.name.startsWith("Run:") || context.terminal.name.startsWith("Debug:"); - const regex = new RegExp("(\\sat\\s+)([\\w$\\.]+\\/)?(([\\w$]+\\.)+[<\\w$>]+)\\(([\\w-$]+\\.java:\\d+)\\)"); - const result: RegExpExecArray | null = regex.exec(context.line); - if (result && result.length) { - const stackTrace = `${result[2] || ""}${result[3]}(${result[5]})`; - const sourceLineNumber = Number(result[5].split(":")[1]); + const frame = parseJavaStackFrame(context.line); + if (frame) { return [{ - startIndex: result.index + result[1].length, - length: stackTrace.length, - methodName: result[3], - stackTrace, - lineNumber: sourceLineNumber, + startIndex: frame.startIndex, + length: frame.length, + methodName: frame.methodName, + stackTrace: frame.stackTrace, + lineNumber: frame.lineNumber, isDebuggerTerminal, }]; } diff --git a/test/stackFrameParser.test.ts b/test/stackFrameParser.test.ts new file mode 100644 index 00000000..4c10d0c9 --- /dev/null +++ b/test/stackFrameParser.test.ts @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import * as assert from "assert"; + +import { parseJavaStackFrame } from "../src/stackFrameParser"; + +suite("parseJavaStackFrame", () => { + test("parses a tab-indented stack frame", () => { + const stackTrace = "com.example.App.main(App.java:42)"; + + assert.deepStrictEqual(parseJavaStackFrame(`\tat ${stackTrace}`), { + stackTrace, + methodName: "com.example.App.main", + lineNumber: 42, + startIndex: 4, + length: stackTrace.length, + }); + }); + + test("preserves a module prefix", () => { + const stackTrace = "java.base/java.util.ArrayList.forEach(ArrayList.java:1511)"; + + assert.deepStrictEqual(parseJavaStackFrame(`\tat ${stackTrace}`), { + stackTrace, + methodName: "java.util.ArrayList.forEach", + lineNumber: 1511, + startIndex: 4, + length: stackTrace.length, + }); + }); + + test("calculates the link range within prefixed output", () => { + const stackTrace = "com.example.Worker.run(Worker.java:7)"; + const line = `[stderr] \tat ${stackTrace} ~[app.jar:1.0]`; + const frame = parseJavaStackFrame(line); + + assert.ok(frame); + assert.strictEqual(frame.startIndex, line.indexOf(stackTrace)); + assert.strictEqual(frame.length, stackTrace.length); + assert.strictEqual(line.substring(frame.startIndex, frame.startIndex + frame.length), stackTrace); + }); + + test("rejects non-positive and unsafe line numbers", () => { + assert.strictEqual(parseJavaStackFrame("\tat com.example.App.main(App.java:0)"), undefined); + assert.strictEqual(parseJavaStackFrame("\tat com.example.App.main(App.java:9007199254740992)"), undefined); + }); +}); diff --git a/test/stackTraceLinkProvider.test.ts b/test/stackTraceLinkProvider.test.ts new file mode 100644 index 00000000..55e74d8f --- /dev/null +++ b/test/stackTraceLinkProvider.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import * as assert from "assert"; +import { CancellationTokenSource, workspace } from "vscode"; + +import { JavaStackTraceLinkProvider } from "../src/stackTraceLinkProvider"; + +suite("JavaStackTraceLinkProvider", () => { + async function provideLinks(content: string) { + const document = await workspace.openTextDocument({ language: "log", content }); + const cancellation = new CancellationTokenSource(); + + try { + return await Promise.resolve( + new JavaStackTraceLinkProvider().provideDocumentLinks(document, cancellation.token), + ); + } finally { + cancellation.dispose(); + } + } + + test("encodes command URI arguments as an array", async () => { + const stackTrace = "com.example.App.main(App.java:42)"; + const links = await provideLinks(`\tat ${stackTrace}`); + + assert.ok(links); + assert.strictEqual(links.length, 1); + + const target = links[0].target; + assert.ok(target); + assert.deepStrictEqual(JSON.parse(decodeURIComponent(target.query)), [{ + stackTrace, + methodName: "com.example.App.main", + lineNumber: 42, + }]); + }); + + test("stops scanning after the document character budget", async () => { + const longPrefix = `${"x".repeat(1000)}\n`.repeat(1000); + const links = await provideLinks(`${longPrefix}\tat com.example.App.main(App.java:42)`); + + assert.deepStrictEqual(links, []); + }); + + test("stops scanning after the document line budget", async () => { + const links = await provideLinks(`${"\n".repeat(10000)}\tat com.example.App.main(App.java:42)`); + + assert.deepStrictEqual(links, []); + }); +});