From db3100adb477816dc6c55a7646d1e902601de82b Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Wed, 22 Jul 2026 16:41:22 +0800 Subject: [PATCH 01/14] feat: linkify Java stack traces in text documents (#1654) Add a DocumentLinkProvider that makes Java stack frames clickable in untitled documents and .log files, so a trace pasted from a log/CI/bug report can be navigated to source without an active debug session. Reuses the session-independent resolveSourceUri backend; frames are resolved lazily on click, with a source-scheme allowlist and length/ReDoS guards. Also adds an optional 'Java: Analyze Stack Trace' command that opens a scratch document prefilled from the clipboard. --- package.json | 5 ++ src/extension.ts | 2 + src/stackTraceLinkProvider.ts | 123 ++++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 src/stackTraceLinkProvider.ts diff --git a/package.json b/package.json index cba4a021..a413a8b1 100644 --- a/package.json +++ b/package.json @@ -151,6 +151,11 @@ { "command": "java.debug.variables.manualExpandLazyVariables", "title": "Manual Expand Lazy Variables" + }, + { + "command": "java.debug.analyzeStackTrace", + "title": "Analyze Stack Trace", + "category": "Java" } ], "menus": { 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/stackTraceLinkProvider.ts b/src/stackTraceLinkProvider.ts new file mode 100644 index 00000000..fa9528ad --- /dev/null +++ b/src/stackTraceLinkProvider.ts @@ -0,0 +1,123 @@ +// 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"; + +const ANALYZE_STACK_TRACE_COMMAND = "java.debug.analyzeStackTrace"; +const NAVIGATE_TO_STACK_FRAME_COMMAND = "_java.debug.navigateToStackFrame"; + +// Matches a Java stack frame such as `at module/com.foo.Bar.baz(Bar.java:42)`. +// Group 2: optional module prefix, group 3: fully-qualified method, group 5: `File.java:line`. +const STACK_FRAME_REGEX = /(\sat\s+)([\w$.]+\/)?(([\w$]+\.)+[<\w$>]+)\(([\w-$]+\.java:\d+)\)/; + +// Guard against pathological input: cap the length of a scanned line (mitigates ReDoS on the +// nested-quantifier regex) and the number of links produced for very large logs. +const MAX_SCANNED_LINE_LENGTH = 1000; +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; +} + +/** + * Linkifies Java stack frames in text documents (e.g. pasted traces or opened `.log` files), + * 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[] = []; + for (let i = 0; i < document.lineCount; i++) { + if (token.isCancellationRequested || links.length >= MAX_LINKS_PER_DOCUMENT) { + break; + } + + const lineText = document.lineAt(i).text; + if (lineText.length > MAX_SCANNED_LINE_LENGTH) { + continue; + } + + const result = STACK_FRAME_REGEX.exec(lineText); + if (!result || !result.length) { + continue; + } + + const stackTrace = `${result[2] || ""}${result[3]}(${result[5]})`; + const lineNumber = Number(result[5].split(":")[1]); + const startIndex = result.index + result[1].length; + const range = new Range(new Position(i, startIndex), new Position(i, startIndex + stackTrace.length)); + + const args: IStackFrameLinkArgs = { stackTrace, methodName: result[3], 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: IStackFrameLinkArgs): Promise { + if (!args || !args.stackTrace) { + return; + } + + // Never log the pasted content itself - only the fact that a navigation happened. + sendInfo("", { operationName: "navigateToJavaStackFrame" }); + + 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); + 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); + commands.executeCommand("workbench.action.quickOpen", "#" + className); + } +} + +/** + * Opens a scratch document for pasting an external stack trace. If the clipboard already holds a + * stack trace, it is prefilled so the frames become clickable immediately. + */ +async function analyzeStackTrace(): Promise { + const clipboard = await env.clipboard.readText(); + const content = STACK_FRAME_REGEX.test(clipboard) ? clipboard : ""; + const document = await workspace.openTextDocument({ language: "log", content }); + await window.showTextDocument(document); +} + +export function registerStackTraceLinkProvider(context: ExtensionContext): void { + const selector: DocumentSelector = [ + { scheme: "untitled" }, + { language: "log" }, + { pattern: "**/*.log" }, + ]; + + context.subscriptions.push( + languages.registerDocumentLinkProvider(selector, new JavaStackTraceLinkProvider()), + commands.registerCommand(NAVIGATE_TO_STACK_FRAME_COMMAND, navigateToStackFrame), + instrumentOperationAsVsCodeCommand(ANALYZE_STACK_TRACE_COMMAND, analyzeStackTrace), + ); +} From e43a3385f1ae79fa8f3328267081c9eaaba418f7 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Wed, 22 Jul 2026 16:50:00 +0800 Subject: [PATCH 02/14] feat(telemetry): track stack-trace console reach, clicks, and command usage Add three content-free telemetry events (with __GDPR__ annotations) forming an adoption funnel: provideJavaStackTraceLinks (reach, deduped once per document), navigateToJavaStackFrame (click engagement, with documentSource + resolution outcome), and analyzeJavaStackTrace (command entry usage, with prefilledFromClipboard). All dimensions are categorical; the pasted trace text is never recorded. Also bound clipboard scanning length. --- src/stackTraceLinkProvider.ts | 88 ++++++++++++++++++++++++++++++----- 1 file changed, 76 insertions(+), 12 deletions(-) diff --git a/src/stackTraceLinkProvider.ts b/src/stackTraceLinkProvider.ts index fa9528ad..5dbbec3f 100644 --- a/src/stackTraceLinkProvider.ts +++ b/src/stackTraceLinkProvider.ts @@ -22,10 +22,29 @@ 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"]); +// Cap how much of the clipboard we scan when deciding whether to prefill (ReDoS hygiene). +const MAX_CLIPBOARD_SCAN_LENGTH = 20000; + +// Records one content-free "impression" per document that first yields links, so feature reach +// (documents containing navigable traces) can be tracked separately from click engagement. +const impressionRecorded = new WeakSet(); + interface IStackFrameLinkArgs { stackTrace: string; methodName: string; lineNumber: number; + documentSource: string; +} + +// Categorizes where a trace lives without leaking the file path (telemetry stays content-free). +function categorizeDocumentSource(document: TextDocument): string { + if (document.uri.scheme === "untitled") { + return "untitled"; + } + if (document.uri.scheme === "file") { + return document.languageId === "log" ? "logFile" : "otherFile"; + } + return "other"; } /** @@ -36,6 +55,7 @@ interface IStackFrameLinkArgs { */ export class JavaStackTraceLinkProvider implements DocumentLinkProvider { public provideDocumentLinks(document: TextDocument, token: CancellationToken): ProviderResult { + const documentSource = categorizeDocumentSource(document); const links: DocumentLink[] = []; for (let i = 0; i < document.lineCount; i++) { if (token.isCancellationRequested || links.length >= MAX_LINKS_PER_DOCUMENT) { @@ -57,11 +77,24 @@ export class JavaStackTraceLinkProvider implements DocumentLinkProvider { const startIndex = result.index + result[1].length; const range = new Range(new Position(i, startIndex), new Position(i, startIndex + stackTrace.length)); - const args: IStackFrameLinkArgs = { stackTrace, methodName: result[3], lineNumber }; + const args: IStackFrameLinkArgs = { stackTrace, methodName: result[3], lineNumber, documentSource }; const target = Uri.parse(`command:${NAVIGATE_TO_STACK_FRAME_COMMAND}?${encodeURIComponent(JSON.stringify(args))}`); links.push(new DocumentLink(range, target)); } + if (links.length > 0 && !impressionRecorded.has(document)) { + impressionRecorded.add(document); + /* __GDPR__ + "provideJavaStackTraceLinks" : { + "owner": "vscode-java-debug", + "comment": "Emitted once per document that first yields navigable Java stack-trace links; measures feature reach.", + "operationName": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "documentSource": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + sendInfo("", { operationName: "provideJavaStackTraceLinks", documentSource }); + } + return links; } } @@ -75,21 +108,41 @@ async function navigateToStackFrame(args: IStackFrameLinkArgs): Promise { return; } - // Never log the pasted content itself - only the fact that a navigation happened. - sendInfo("", { operationName: "navigateToJavaStackFrame" }); - const uri = await resolveSourceUri(args.stackTrace); - if (uri) { - const parsed = Uri.parse(uri); - if (!ALLOWED_SOURCE_SCHEMES.has(parsed.scheme)) { - return; - } + const parsed = uri ? Uri.parse(uri) : undefined; + + let resolution: string; + if (!parsed) { + resolution = "fallbackQuickPick"; + } else if (ALLOWED_SOURCE_SCHEMES.has(parsed.scheme)) { + resolution = "resolved"; + } else { + resolution = "schemeRejected"; + } + + // Content-free telemetry: only categorical dimensions, never the pasted text. + /* __GDPR__ + "navigateToJavaStackFrame" : { + "owner": "vscode-java-debug", + "comment": "Emitted when a user clicks a linkified Java stack frame; measures click engagement and resolution success.", + "operationName": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "documentSource": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "resolution": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + sendInfo("", { + operationName: "navigateToJavaStackFrame", + documentSource: args.documentSource || "unknown", + resolution, + }); + + if (parsed && ALLOWED_SOURCE_SCHEMES.has(parsed.scheme)) { const targetLine = Math.max(args.lineNumber - 1, 0); window.showTextDocument(parsed, { preserveFocus: true, selection: new Range(new Position(targetLine, 0), new Position(targetLine, 0)), }); - } else { + } else if (!parsed) { // 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); @@ -103,8 +156,19 @@ async function navigateToStackFrame(args: IStackFrameLinkArgs): Promise { */ async function analyzeStackTrace(): Promise { const clipboard = await env.clipboard.readText(); - const content = STACK_FRAME_REGEX.test(clipboard) ? clipboard : ""; - const document = await workspace.openTextDocument({ language: "log", content }); + const prefilled = STACK_FRAME_REGEX.test(clipboard.slice(0, MAX_CLIPBOARD_SCAN_LENGTH)); + + /* __GDPR__ + "analyzeJavaStackTrace" : { + "owner": "vscode-java-debug", + "comment": "Emitted when a user runs the 'Analyze Stack Trace' command; measures explicit entry-point usage.", + "operationName": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "prefilledFromClipboard": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + sendInfo("", { operationName: "analyzeJavaStackTrace", prefilledFromClipboard: String(prefilled) }); + + const document = await workspace.openTextDocument({ language: "log", content: prefilled ? clipboard : "" }); await window.showTextDocument(document); } From 8d57dca4251e116f8579fd3356a431adf8de6715 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Wed, 22 Jul 2026 16:56:54 +0800 Subject: [PATCH 03/14] refactor(telemetry): keep a single click event, drop reach + command events Simplify to one usage signal (navigateToJavaStackFrame with a documentSource dimension), mirroring the existing handleJavaTerminalLink pattern. Drop the per-document reach event (and its WeakSet) and the manual command event - command invocations are already auto-instrumented by instrumentOperationAsVsCodeCommand. --- src/stackTraceLinkProvider.ts | 69 ++++++++--------------------------- 1 file changed, 16 insertions(+), 53 deletions(-) diff --git a/src/stackTraceLinkProvider.ts b/src/stackTraceLinkProvider.ts index 5dbbec3f..a9fe92a7 100644 --- a/src/stackTraceLinkProvider.ts +++ b/src/stackTraceLinkProvider.ts @@ -25,10 +25,6 @@ const ALLOWED_SOURCE_SCHEMES = new Set(["file", "jdt"]); // Cap how much of the clipboard we scan when deciding whether to prefill (ReDoS hygiene). const MAX_CLIPBOARD_SCAN_LENGTH = 20000; -// Records one content-free "impression" per document that first yields links, so feature reach -// (documents containing navigable traces) can be tracked separately from click engagement. -const impressionRecorded = new WeakSet(); - interface IStackFrameLinkArgs { stackTrace: string; methodName: string; @@ -82,19 +78,6 @@ export class JavaStackTraceLinkProvider implements DocumentLinkProvider { links.push(new DocumentLink(range, target)); } - if (links.length > 0 && !impressionRecorded.has(document)) { - impressionRecorded.add(document); - /* __GDPR__ - "provideJavaStackTraceLinks" : { - "owner": "vscode-java-debug", - "comment": "Emitted once per document that first yields navigable Java stack-trace links; measures feature reach.", - "operationName": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "documentSource": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - sendInfo("", { operationName: "provideJavaStackTraceLinks", documentSource }); - } - return links; } } @@ -108,41 +91,30 @@ async function navigateToStackFrame(args: IStackFrameLinkArgs): Promise { return; } - const uri = await resolveSourceUri(args.stackTrace); - const parsed = uri ? Uri.parse(uri) : undefined; - - let resolution: string; - if (!parsed) { - resolution = "fallbackQuickPick"; - } else if (ALLOWED_SOURCE_SCHEMES.has(parsed.scheme)) { - resolution = "resolved"; - } else { - resolution = "schemeRejected"; - } - - // Content-free telemetry: only categorical dimensions, never the pasted text. + // Content-free telemetry: a single usage signal, mirroring the terminal link provider. + // `documentSource` is the only dimension (paste vs. opened log), never the pasted text. /* __GDPR__ "navigateToJavaStackFrame" : { "owner": "vscode-java-debug", - "comment": "Emitted when a user clicks a linkified Java stack frame; measures click engagement and resolution success.", + "comment": "Emitted when a user clicks a linkified Java stack frame; measures feature usage.", "operationName": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "documentSource": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "resolution": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + "documentSource": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } } */ - sendInfo("", { - operationName: "navigateToJavaStackFrame", - documentSource: args.documentSource || "unknown", - resolution, - }); + sendInfo("", { operationName: "navigateToJavaStackFrame", documentSource: args.documentSource || "unknown" }); - if (parsed && ALLOWED_SOURCE_SCHEMES.has(parsed.scheme)) { + 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); window.showTextDocument(parsed, { preserveFocus: true, selection: new Range(new Position(targetLine, 0), new Position(targetLine, 0)), }); - } else if (!parsed) { + } 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); @@ -155,20 +127,11 @@ async function navigateToStackFrame(args: IStackFrameLinkArgs): Promise { * stack trace, it is prefilled so the frames become clickable immediately. */ async function analyzeStackTrace(): Promise { + // The command itself is auto-instrumented via instrumentOperationAsVsCodeCommand, so no + // manual telemetry is needed here to track invocations. const clipboard = await env.clipboard.readText(); - const prefilled = STACK_FRAME_REGEX.test(clipboard.slice(0, MAX_CLIPBOARD_SCAN_LENGTH)); - - /* __GDPR__ - "analyzeJavaStackTrace" : { - "owner": "vscode-java-debug", - "comment": "Emitted when a user runs the 'Analyze Stack Trace' command; measures explicit entry-point usage.", - "operationName": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "prefilledFromClipboard": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - sendInfo("", { operationName: "analyzeJavaStackTrace", prefilledFromClipboard: String(prefilled) }); - - const document = await workspace.openTextDocument({ language: "log", content: prefilled ? clipboard : "" }); + const content = STACK_FRAME_REGEX.test(clipboard.slice(0, MAX_CLIPBOARD_SCAN_LENGTH)) ? clipboard : ""; + const document = await workspace.openTextDocument({ language: "log", content }); await window.showTextDocument(document); } From a12a7c66990569d48f6d61ded45294678a37a5ba Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Wed, 22 Jul 2026 17:04:26 +0800 Subject: [PATCH 04/14] feat: activate on Java workspaces so stack-trace linkify is on by default Add workspaceContains:**/*.java so the passive DocumentLinkProvider is registered as soon as a Java project is opened - without waiting for the user to open a .java file or run a command. This matches exactly the scope where resolveSourceUri can resolve frames to source, so the feature is automatic wherever it can deliver value. --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index a413a8b1..7bc446f6 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ ], "activationEvents": [ "onLanguage:java", + "workspaceContains:**/*.java", "onDebugInitialConfigurations", "onDebugResolve:java", "onCommand:JavaDebug.SpecifyProgramArgs", From fc2d18117f7ab066b0f6634714bb0e100e66f18d Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Wed, 22 Jul 2026 17:10:40 +0800 Subject: [PATCH 05/14] fix: only linkify once the Java language server reaches Standard mode Frame resolution (resolveSourceUri) requires a fully-loaded workspace, so registering the document link provider before jdtls is ready produces links that cannot resolve. Defer provider registration until ServerMode.STANDARD via redhat.java's onDidServerModeChange, mirroring initializeCodeLensProvider. Commands stay registered immediately. --- src/stackTraceLinkProvider.ts | 53 ++++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/src/stackTraceLinkProvider.ts b/src/stackTraceLinkProvider.ts index a9fe92a7..73e2b9e1 100644 --- a/src/stackTraceLinkProvider.ts +++ b/src/stackTraceLinkProvider.ts @@ -6,10 +6,19 @@ import { CancellationToken, commands, DocumentLink, DocumentLinkProvider, Docume window, workspace } from "vscode"; import { instrumentOperationAsVsCodeCommand, sendInfo } from "vscode-extension-telemetry-wrapper"; import { resolveSourceUri } from "./languageServerPlugin"; +import { getJavaExtensionAPI, isJavaExtEnabled, ServerMode } from "./utility"; const ANALYZE_STACK_TRACE_COMMAND = "java.debug.analyzeStackTrace"; const NAVIGATE_TO_STACK_FRAME_COMMAND = "_java.debug.navigateToStackFrame"; +// Documents we linkify: pasted traces (untitled), the `log` language, and `.log` files. Kept +// narrow on purpose so we don't scan every plaintext file the user opens. +const STACK_TRACE_DOCUMENT_SELECTOR: DocumentSelector = [ + { scheme: "untitled" }, + { language: "log" }, + { pattern: "**/*.log" }, +]; + // Matches a Java stack frame such as `at module/com.foo.Bar.baz(Bar.java:42)`. // Group 2: optional module prefix, group 3: fully-qualified method, group 5: `File.java:line`. const STACK_FRAME_REGEX = /(\sat\s+)([\w$.]+\/)?(([\w$]+\.)+[<\w$>]+)\(([\w-$]+\.java:\d+)\)/; @@ -136,15 +145,45 @@ async function analyzeStackTrace(): Promise { } export function registerStackTraceLinkProvider(context: ExtensionContext): void { - const selector: DocumentSelector = [ - { scheme: "untitled" }, - { language: "log" }, - { pattern: "**/*.log" }, - ]; - + // The commands are always available: the palette command must not depend on server + // readiness, and the click handler is only ever reached from links the provider creates. context.subscriptions.push( - languages.registerDocumentLinkProvider(selector, new JavaStackTraceLinkProvider()), 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; + context.subscriptions.push(api.onDidServerModeChange((mode: string) => { + if (mode === ServerMode.STANDARD && !registered) { + registered = true; + doRegister(); + } + })); + } else { + // Already in Standard mode. + doRegister(); + } + }); } From 96ed5c6ffa3532b1725a6962c41aae7442488f5b Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Wed, 22 Jul 2026 17:14:44 +0800 Subject: [PATCH 06/14] refactor(telemetry): drop the documentSource dimension, keep a bare click count The single navigateToJavaStackFrame event is enough to measure adoption; the documentSource breakdown is not needed. Remove the dimension, the IStackFrameLinkArgs field, and the categorizeDocumentSource helper. --- src/stackTraceLinkProvider.ts | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/src/stackTraceLinkProvider.ts b/src/stackTraceLinkProvider.ts index 73e2b9e1..e1559d1a 100644 --- a/src/stackTraceLinkProvider.ts +++ b/src/stackTraceLinkProvider.ts @@ -38,18 +38,6 @@ interface IStackFrameLinkArgs { stackTrace: string; methodName: string; lineNumber: number; - documentSource: string; -} - -// Categorizes where a trace lives without leaking the file path (telemetry stays content-free). -function categorizeDocumentSource(document: TextDocument): string { - if (document.uri.scheme === "untitled") { - return "untitled"; - } - if (document.uri.scheme === "file") { - return document.languageId === "log" ? "logFile" : "otherFile"; - } - return "other"; } /** @@ -60,7 +48,6 @@ function categorizeDocumentSource(document: TextDocument): string { */ export class JavaStackTraceLinkProvider implements DocumentLinkProvider { public provideDocumentLinks(document: TextDocument, token: CancellationToken): ProviderResult { - const documentSource = categorizeDocumentSource(document); const links: DocumentLink[] = []; for (let i = 0; i < document.lineCount; i++) { if (token.isCancellationRequested || links.length >= MAX_LINKS_PER_DOCUMENT) { @@ -82,7 +69,7 @@ export class JavaStackTraceLinkProvider implements DocumentLinkProvider { const startIndex = result.index + result[1].length; const range = new Range(new Position(i, startIndex), new Position(i, startIndex + stackTrace.length)); - const args: IStackFrameLinkArgs = { stackTrace, methodName: result[3], lineNumber, documentSource }; + const args: IStackFrameLinkArgs = { stackTrace, methodName: result[3], lineNumber }; const target = Uri.parse(`command:${NAVIGATE_TO_STACK_FRAME_COMMAND}?${encodeURIComponent(JSON.stringify(args))}`); links.push(new DocumentLink(range, target)); } @@ -100,17 +87,16 @@ async function navigateToStackFrame(args: IStackFrameLinkArgs): Promise { return; } - // Content-free telemetry: a single usage signal, mirroring the terminal link provider. - // `documentSource` is the only dimension (paste vs. opened log), never the pasted text. + // 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" }, - "documentSource": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + "operationName": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } } */ - sendInfo("", { operationName: "navigateToJavaStackFrame", documentSource: args.documentSource || "unknown" }); + sendInfo("", { operationName: "navigateToJavaStackFrame" }); const uri = await resolveSourceUri(args.stackTrace); if (uri) { From 8565a6bfb73bb8afc67409d9d801f6a573caeedc Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Thu, 23 Jul 2026 09:46:02 +0800 Subject: [PATCH 07/14] refactor: scope stack-trace linkify to untitled documents only Drop the `log` language and `**/*.log` selectors so linkification only targets pasted traces in untitled (scratch) documents. A `.log` opened without a Java project can't resolve frames anyway, so scanning them (or every plaintext file) adds no value. Keeps the feature narrow to the two intended paste flows: a new tab and the Analyze Stack Trace command. Copilot-Session: d80e7a97-119d-4f89-a02a-be25b076e806 --- src/stackTraceLinkProvider.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/stackTraceLinkProvider.ts b/src/stackTraceLinkProvider.ts index e1559d1a..3a2fba5f 100644 --- a/src/stackTraceLinkProvider.ts +++ b/src/stackTraceLinkProvider.ts @@ -11,12 +11,11 @@ import { getJavaExtensionAPI, isJavaExtEnabled, ServerMode } from "./utility"; const ANALYZE_STACK_TRACE_COMMAND = "java.debug.analyzeStackTrace"; const NAVIGATE_TO_STACK_FRAME_COMMAND = "_java.debug.navigateToStackFrame"; -// Documents we linkify: pasted traces (untitled), the `log` language, and `.log` files. Kept -// narrow on purpose so we don't scan every plaintext file the user opens. +// Only linkify pasted traces in untitled (scratch) documents - including the one opened by the +// `Analyze Stack Trace` command. Kept deliberately narrow: a `.log` opened without a Java project +// couldn't resolve anyway, so we don't scan `.log` files or every plaintext file the user opens. const STACK_TRACE_DOCUMENT_SELECTOR: DocumentSelector = [ { scheme: "untitled" }, - { language: "log" }, - { pattern: "**/*.log" }, ]; // Matches a Java stack frame such as `at module/com.foo.Bar.baz(Bar.java:42)`. @@ -24,7 +23,7 @@ const STACK_TRACE_DOCUMENT_SELECTOR: DocumentSelector = [ const STACK_FRAME_REGEX = /(\sat\s+)([\w$.]+\/)?(([\w$]+\.)+[<\w$>]+)\(([\w-$]+\.java:\d+)\)/; // Guard against pathological input: cap the length of a scanned line (mitigates ReDoS on the -// nested-quantifier regex) and the number of links produced for very large logs. +// nested-quantifier regex) and the number of links produced for very large pasted traces. const MAX_SCANNED_LINE_LENGTH = 1000; const MAX_LINKS_PER_DOCUMENT = 2000; @@ -41,10 +40,10 @@ interface IStackFrameLinkArgs { } /** - * Linkifies Java stack frames in text documents (e.g. pasted traces or opened `.log` files), - * 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. + * 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 { From 867f458c1ca05c36f5415efd599bc75a86d05547 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Thu, 23 Jul 2026 10:14:29 +0800 Subject: [PATCH 08/14] refactor: gate palette command on javaLSReady, share frame parser, harden navigate - Add a commandPalette when-clause (javaLSReady) to java.debug.analyzeStackTrace so it only shows once the Java language server is ready - matching where the linkify provider actually registers, and avoiding a dead-end scratch doc in non-Java windows. - Extract the duplicated frame regex + parsing into stackFrameParser.ts, shared by the terminal and document link providers so matching stays identical. The helper builds a fresh RegExp per call, preserving the terminal provider's no-shared-stateful-RegExp contract. - Wrap resolveSourceUri/navigation in navigateToStackFrame in try/catch (and await the navigation) so a not-ready/restarting language server can't surface an unhandled rejection. Copilot-Session: d80e7a97-119d-4f89-a02a-be25b076e806 --- package.json | 4 +++ src/stackFrameParser.ts | 43 ++++++++++++++++++++++++ src/stackTraceLinkProvider.ts | 62 ++++++++++++++++++++--------------- src/terminalLinkProvider.ts | 18 +++++----- 4 files changed, 90 insertions(+), 37 deletions(-) create mode 100644 src/stackFrameParser.ts diff --git a/package.json b/package.json index 7bc446f6..84ecb69f 100644 --- a/package.json +++ b/package.json @@ -239,6 +239,10 @@ } ], "commandPalette": [ + { + "command": "java.debug.analyzeStackTrace", + "when": "javaLSReady" + }, { "command": "java.debug.hotCodeReplace", "when": "false" diff --git a/src/stackFrameParser.ts b/src/stackFrameParser.ts new file mode 100644 index 00000000..f9274089 --- /dev/null +++ b/src/stackFrameParser.ts @@ -0,0 +1,43 @@ +// 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 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. `at 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]})`; + return { + stackTrace, + methodName: result[3], + lineNumber: Number(result[5].split(":")[1]), + startIndex: result.index + result[1].length, + length: stackTrace.length, + }; +} diff --git a/src/stackTraceLinkProvider.ts b/src/stackTraceLinkProvider.ts index 3a2fba5f..0f239200 100644 --- a/src/stackTraceLinkProvider.ts +++ b/src/stackTraceLinkProvider.ts @@ -6,6 +6,7 @@ import { CancellationToken, commands, DocumentLink, DocumentLinkProvider, Docume 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"; @@ -18,10 +19,6 @@ const STACK_TRACE_DOCUMENT_SELECTOR: DocumentSelector = [ { scheme: "untitled" }, ]; -// Matches a Java stack frame such as `at module/com.foo.Bar.baz(Bar.java:42)`. -// Group 2: optional module prefix, group 3: fully-qualified method, group 5: `File.java:line`. -const STACK_FRAME_REGEX = /(\sat\s+)([\w$.]+\/)?(([\w$]+\.)+[<\w$>]+)\(([\w-$]+\.java:\d+)\)/; - // Guard against pathological input: cap the length of a scanned line (mitigates ReDoS on the // nested-quantifier regex) and the number of links produced for very large pasted traces. const MAX_SCANNED_LINE_LENGTH = 1000; @@ -58,17 +55,21 @@ export class JavaStackTraceLinkProvider implements DocumentLinkProvider { continue; } - const result = STACK_FRAME_REGEX.exec(lineText); - if (!result || !result.length) { + const frame = parseJavaStackFrame(lineText); + if (!frame) { continue; } - const stackTrace = `${result[2] || ""}${result[3]}(${result[5]})`; - const lineNumber = Number(result[5].split(":")[1]); - const startIndex = result.index + result[1].length; - const range = new Range(new Position(i, startIndex), new Position(i, startIndex + stackTrace.length)); + const range = new Range( + new Position(i, frame.startIndex), + new Position(i, frame.startIndex + frame.length), + ); - const args: IStackFrameLinkArgs = { stackTrace, methodName: result[3], lineNumber }; + 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)); } @@ -97,22 +98,28 @@ async function navigateToStackFrame(args: IStackFrameLinkArgs): Promise { */ sendInfo("", { operationName: "navigateToJavaStackFrame" }); - const uri = await resolveSourceUri(args.stackTrace); - if (uri) { - const parsed = Uri.parse(uri); - if (!ALLOWED_SOURCE_SCHEMES.has(parsed.scheme)) { - return; + 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); } - const targetLine = Math.max(args.lineNumber - 1, 0); - 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); - 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. } } @@ -124,7 +131,8 @@ async function analyzeStackTrace(): Promise { // The command itself is auto-instrumented via instrumentOperationAsVsCodeCommand, so no // manual telemetry is needed here to track invocations. const clipboard = await env.clipboard.readText(); - const content = STACK_FRAME_REGEX.test(clipboard.slice(0, MAX_CLIPBOARD_SCAN_LENGTH)) ? clipboard : ""; + const looksLikeTrace = parseJavaStackFrame(clipboard.slice(0, MAX_CLIPBOARD_SCAN_LENGTH)) !== undefined; + const content = looksLikeTrace ? clipboard : ""; const document = await workspace.openTextDocument({ language: "log", content }); await window.showTextDocument(document); } 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, }]; } From 287c30e7c961d22bc40c84441c89113c8136790c Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Thu, 23 Jul 2026 10:33:06 +0800 Subject: [PATCH 09/14] fix: address stack trace review feedback Cap clipboard prefill content, clarify command registration readiness, and add regression coverage for shared stack frame parsing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e50dc26-84c4-4532-a7d3-88f6fe016780 --- src/stackTraceLinkProvider.ts | 14 +++++++----- test/stackFrameParser.test.ts | 43 +++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) create mode 100644 test/stackFrameParser.test.ts diff --git a/src/stackTraceLinkProvider.ts b/src/stackTraceLinkProvider.ts index 0f239200..33ab5e61 100644 --- a/src/stackTraceLinkProvider.ts +++ b/src/stackTraceLinkProvider.ts @@ -27,8 +27,9 @@ 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"]); -// Cap how much of the clipboard we scan when deciding whether to prefill (ReDoS hygiene). -const MAX_CLIPBOARD_SCAN_LENGTH = 20000; +// Bound both stack-trace detection and scratch-document prefill so a large clipboard cannot create +// an expensive untitled document (and keeps the detection regex input bounded). +const MAX_CLIPBOARD_PREFILL_LENGTH = 20000; interface IStackFrameLinkArgs { stackTrace: string; @@ -131,15 +132,16 @@ async function analyzeStackTrace(): Promise { // The command itself is auto-instrumented via instrumentOperationAsVsCodeCommand, so no // manual telemetry is needed here to track invocations. const clipboard = await env.clipboard.readText(); - const looksLikeTrace = parseJavaStackFrame(clipboard.slice(0, MAX_CLIPBOARD_SCAN_LENGTH)) !== undefined; - const content = looksLikeTrace ? clipboard : ""; + const clipboardContent = clipboard.slice(0, MAX_CLIPBOARD_PREFILL_LENGTH); + const looksLikeTrace = parseJavaStackFrame(clipboardContent) !== undefined; + const content = looksLikeTrace ? clipboardContent : ""; const document = await workspace.openTextDocument({ language: "log", content }); await window.showTextDocument(document); } export function registerStackTraceLinkProvider(context: ExtensionContext): void { - // The commands are always available: the palette command must not depend on server - // readiness, and the click handler is only ever reached from links the provider creates. + // 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), diff --git a/test/stackFrameParser.test.ts b/test/stackFrameParser.test.ts new file mode 100644 index 00000000..01d9ccba --- /dev/null +++ b/test/stackFrameParser.test.ts @@ -0,0 +1,43 @@ +// 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); + }); +}); From 7dea2b21cec013229c08af6143aa94c75b1b484d Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Thu, 23 Jul 2026 10:53:17 +0800 Subject: [PATCH 10/14] fix: harden stack trace command links Encode command URI arguments as an array and validate navigation command inputs before resolving source locations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e50dc26-84c4-4532-a7d3-88f6fe016780 --- src/stackTraceLinkProvider.ts | 25 +++++++++++++++++--- test/stackTraceLinkProvider.test.ts | 36 +++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 test/stackTraceLinkProvider.test.ts diff --git a/src/stackTraceLinkProvider.ts b/src/stackTraceLinkProvider.ts index 33ab5e61..dbdaa5a6 100644 --- a/src/stackTraceLinkProvider.ts +++ b/src/stackTraceLinkProvider.ts @@ -37,6 +37,25 @@ interface IStackFrameLinkArgs { lineNumber: number; } +function isStackFrameLinkArgs(args: unknown): args is IStackFrameLinkArgs { + if (typeof args !== "object" || args === null + || !("stackTrace" in args) || typeof args.stackTrace !== "string" + || !("methodName" in args) || typeof args.methodName !== "string" + || !("lineNumber" in args) || typeof args.lineNumber !== "number") { + return false; + } + + if (args.stackTrace.length === 0 || args.stackTrace.length > MAX_SCANNED_LINE_LENGTH + || !Number.isSafeInteger(args.lineNumber) || args.lineNumber <= 0) { + return false; + } + + const frame = parseJavaStackFrame(` at ${args.stackTrace}`); + return frame?.stackTrace === args.stackTrace + && frame.methodName === args.methodName + && frame.lineNumber === args.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. @@ -71,7 +90,7 @@ export class JavaStackTraceLinkProvider implements DocumentLinkProvider { methodName: frame.methodName, lineNumber: frame.lineNumber, }; - const target = Uri.parse(`command:${NAVIGATE_TO_STACK_FRAME_COMMAND}?${encodeURIComponent(JSON.stringify(args))}`); + const target = Uri.parse(`command:${NAVIGATE_TO_STACK_FRAME_COMMAND}?${encodeURIComponent(JSON.stringify([args]))}`); links.push(new DocumentLink(range, target)); } @@ -83,8 +102,8 @@ export class JavaStackTraceLinkProvider implements DocumentLinkProvider { * 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: IStackFrameLinkArgs): Promise { - if (!args || !args.stackTrace) { +async function navigateToStackFrame(args: unknown): Promise { + if (!isStackFrameLinkArgs(args)) { return; } diff --git a/test/stackTraceLinkProvider.test.ts b/test/stackTraceLinkProvider.test.ts new file mode 100644 index 00000000..976157e4 --- /dev/null +++ b/test/stackTraceLinkProvider.test.ts @@ -0,0 +1,36 @@ +// 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", () => { + test("encodes command URI arguments as an array", async () => { + const stackTrace = "com.example.App.main(App.java:42)"; + const document = await workspace.openTextDocument({ + language: "log", + content: `\tat ${stackTrace}`, + }); + const cancellation = new CancellationTokenSource(); + + try { + const links = await Promise.resolve( + new JavaStackTraceLinkProvider().provideDocumentLinks(document, cancellation.token), + ); + 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, + }]); + } finally { + cancellation.dispose(); + } + }); +}); From 0c319400f8ca33bd55520e655260365d80fc886b Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Thu, 23 Jul 2026 11:07:59 +0800 Subject: [PATCH 11/14] refactor: clarify stack frame validation Use local values for command argument narrowing and make the parser example reflect its required leading whitespace. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e50dc26-84c4-4532-a7d3-88f6fe016780 --- src/stackFrameParser.ts | 2 +- src/stackTraceLinkProvider.ts | 21 +++++++++++---------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/stackFrameParser.ts b/src/stackFrameParser.ts index f9274089..3d88143b 100644 --- a/src/stackFrameParser.ts +++ b/src/stackFrameParser.ts @@ -18,7 +18,7 @@ export interface IParsedStackFrame { } /** - * Parses the first Java stack frame (e.g. `at module/com.foo.Bar.baz(Bar.java:42)`) out of a line, + * 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, diff --git a/src/stackTraceLinkProvider.ts b/src/stackTraceLinkProvider.ts index dbdaa5a6..228622be 100644 --- a/src/stackTraceLinkProvider.ts +++ b/src/stackTraceLinkProvider.ts @@ -38,22 +38,23 @@ interface IStackFrameLinkArgs { } function isStackFrameLinkArgs(args: unknown): args is IStackFrameLinkArgs { - if (typeof args !== "object" || args === null - || !("stackTrace" in args) || typeof args.stackTrace !== "string" - || !("methodName" in args) || typeof args.methodName !== "string" - || !("lineNumber" in args) || typeof args.lineNumber !== "number") { + if (typeof args !== "object" || args === null) { return false; } - if (args.stackTrace.length === 0 || args.stackTrace.length > MAX_SCANNED_LINE_LENGTH - || !Number.isSafeInteger(args.lineNumber) || args.lineNumber <= 0) { + 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 ${args.stackTrace}`); - return frame?.stackTrace === args.stackTrace - && frame.methodName === args.methodName - && frame.lineNumber === args.lineNumber; + const frame = parseJavaStackFrame(` at ${stackTrace}`); + return frame?.stackTrace === stackTrace + && frame.methodName === methodName + && frame.lineNumber === lineNumber; } /** From 381e4bd160021053b1313ed4696e44045a894193 Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Thu, 23 Jul 2026 11:27:18 +0800 Subject: [PATCH 12/14] fix: reject invalid stack frame line numbers Validate parsed line numbers centrally so all stack trace link surfaces reject non-positive and unsafe values. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e50dc26-84c4-4532-a7d3-88f6fe016780 --- src/stackFrameParser.ts | 9 +++++++-- test/stackFrameParser.test.ts | 5 +++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/stackFrameParser.ts b/src/stackFrameParser.ts index 3d88143b..daae3a5a 100644 --- a/src/stackFrameParser.ts +++ b/src/stackFrameParser.ts @@ -9,7 +9,7 @@ export interface IParsedStackFrame { stackTrace: string; // The fully-qualified method (`com.foo.Bar.baz`), used for the class-name quick-pick fallback. methodName: string; - // The 1-based source line number parsed from the frame. + // 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; @@ -33,10 +33,15 @@ export function parseJavaStackFrame(line: string): IParsedStackFrame | 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: Number(result[5].split(":")[1]), + lineNumber, startIndex: result.index + result[1].length, length: stackTrace.length, }; diff --git a/test/stackFrameParser.test.ts b/test/stackFrameParser.test.ts index 01d9ccba..4c10d0c9 100644 --- a/test/stackFrameParser.test.ts +++ b/test/stackFrameParser.test.ts @@ -40,4 +40,9 @@ suite("parseJavaStackFrame", () => { 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); + }); }); From 4d47da9a66a9135c1967086f5a41033b07bd7de4 Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Thu, 23 Jul 2026 13:01:41 +0800 Subject: [PATCH 13/14] fix: streamline stack trace initialization Defer clipboard parsing to the bounded document link provider and dispose the one-shot server mode listener after registration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e50dc26-84c4-4532-a7d3-88f6fe016780 --- src/stackTraceLinkProvider.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/stackTraceLinkProvider.ts b/src/stackTraceLinkProvider.ts index 228622be..a4431724 100644 --- a/src/stackTraceLinkProvider.ts +++ b/src/stackTraceLinkProvider.ts @@ -145,17 +145,15 @@ async function navigateToStackFrame(args: unknown): Promise { } /** - * Opens a scratch document for pasting an external stack trace. If the clipboard already holds a - * stack trace, it is prefilled so the frames become clickable immediately. + * Opens a scratch document prefilled with bounded clipboard content. The document link provider + * scans the content 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 clipboard = await env.clipboard.readText(); const clipboardContent = clipboard.slice(0, MAX_CLIPBOARD_PREFILL_LENGTH); - const looksLikeTrace = parseJavaStackFrame(clipboardContent) !== undefined; - const content = looksLikeTrace ? clipboardContent : ""; - const document = await workspace.openTextDocument({ language: "log", content }); + const document = await workspace.openTextDocument({ language: "log", content: clipboardContent }); await window.showTextDocument(document); } @@ -190,12 +188,14 @@ function registerLinkProviderWhenReady(context: ExtensionContext): void { if (api.serverMode === ServerMode.LIGHTWEIGHT || api.serverMode === ServerMode.HYBRID) { let registered = false; - context.subscriptions.push(api.onDidServerModeChange((mode: string) => { + 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(); From c0551250fcfabbdd72114927bbdef08457906273 Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Thu, 23 Jul 2026 13:37:14 +0800 Subject: [PATCH 14/14] feat: linkify stack traces in log files Preserve complete clipboard content and bound document link scans by total characters and lines so saved log files can be handled safely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e50dc26-84c4-4532-a7d3-88f6fe016780 --- src/stackTraceLinkProvider.ts | 33 ++++++++++--------- test/stackTraceLinkProvider.test.ts | 49 +++++++++++++++++++---------- 2 files changed, 51 insertions(+), 31 deletions(-) diff --git a/src/stackTraceLinkProvider.ts b/src/stackTraceLinkProvider.ts index a4431724..2ddccf8b 100644 --- a/src/stackTraceLinkProvider.ts +++ b/src/stackTraceLinkProvider.ts @@ -12,25 +12,23 @@ import { getJavaExtensionAPI, isJavaExtEnabled, ServerMode } from "./utility"; const ANALYZE_STACK_TRACE_COMMAND = "java.debug.analyzeStackTrace"; const NAVIGATE_TO_STACK_FRAME_COMMAND = "_java.debug.navigateToStackFrame"; -// Only linkify pasted traces in untitled (scratch) documents - including the one opened by the -// `Analyze Stack Trace` command. Kept deliberately narrow: a `.log` opened without a Java project -// couldn't resolve anyway, so we don't scan `.log` files or every plaintext file the user opens. +// 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" }, ]; -// Guard against pathological input: cap the length of a scanned line (mitigates ReDoS on the -// nested-quantifier regex) and the number of links produced for very large pasted traces. +// 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"]); -// Bound both stack-trace detection and scratch-document prefill so a large clipboard cannot create -// an expensive untitled document (and keeps the detection regex input bounded). -const MAX_CLIPBOARD_PREFILL_LENGTH = 20000; - interface IStackFrameLinkArgs { stackTrace: string; methodName: string; @@ -66,12 +64,20 @@ function isStackFrameLinkArgs(args: unknown): args is IStackFrameLinkArgs { export class JavaStackTraceLinkProvider implements DocumentLinkProvider { public provideDocumentLinks(document: TextDocument, token: CancellationToken): ProviderResult { const links: DocumentLink[] = []; - for (let i = 0; i < document.lineCount; i++) { + 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; } @@ -145,14 +151,13 @@ async function navigateToStackFrame(args: unknown): Promise { } /** - * Opens a scratch document prefilled with bounded clipboard content. The document link provider - * scans the content after the document opens and makes any stack frames clickable. + * 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 clipboard = await env.clipboard.readText(); - const clipboardContent = clipboard.slice(0, MAX_CLIPBOARD_PREFILL_LENGTH); + const clipboardContent = await env.clipboard.readText(); const document = await workspace.openTextDocument({ language: "log", content: clipboardContent }); await window.showTextDocument(document); } diff --git a/test/stackTraceLinkProvider.test.ts b/test/stackTraceLinkProvider.test.ts index 976157e4..55e74d8f 100644 --- a/test/stackTraceLinkProvider.test.ts +++ b/test/stackTraceLinkProvider.test.ts @@ -7,30 +7,45 @@ import { CancellationTokenSource, workspace } from "vscode"; import { JavaStackTraceLinkProvider } from "../src/stackTraceLinkProvider"; suite("JavaStackTraceLinkProvider", () => { - test("encodes command URI arguments as an array", async () => { - const stackTrace = "com.example.App.main(App.java:42)"; - const document = await workspace.openTextDocument({ - language: "log", - content: `\tat ${stackTrace}`, - }); + async function provideLinks(content: string) { + const document = await workspace.openTextDocument({ language: "log", content }); const cancellation = new CancellationTokenSource(); try { - const links = await Promise.resolve( + return await Promise.resolve( new JavaStackTraceLinkProvider().provideDocumentLinks(document, cancellation.token), ); - 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, - }]); } 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, []); }); });