Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
],
"activationEvents": [
"onLanguage:java",
"workspaceContains:**/*.java",
"onDebugInitialConfigurations",
"onDebugResolve:java",
"onCommand:JavaDebug.SpecifyProgramArgs",
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -233,6 +239,10 @@
}
],
"commandPalette": [
{
"command": "java.debug.analyzeStackTrace",
"when": "javaLSReady"
},
{
"command": "java.debug.hotCodeReplace",
"when": "false"
Expand Down
2 changes: 2 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 () => {
Expand Down
48 changes: 48 additions & 0 deletions src/stackFrameParser.ts
Original file line number Diff line number Diff line change
@@ -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`.
*/
Comment thread
wenytang-ms marked this conversation as resolved.
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);
Comment thread
wenytang-ms marked this conversation as resolved.
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,
};
Comment thread
wenytang-ms marked this conversation as resolved.
}
209 changes: 209 additions & 0 deletions src/stackTraceLinkProvider.ts
Original file line number Diff line number Diff line change
@@ -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<string>(["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<DocumentLink[]> {
const links: DocumentLink[] = [];
let scannedCharacters = 0;
const linesToScan = Math.min(document.lineCount, MAX_SCANNED_LINES_PER_DOCUMENT);
Comment thread
wenytang-ms marked this conversation as resolved.
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<void> {
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<void> {
// 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();
}
});
}
18 changes: 8 additions & 10 deletions src/terminalLinkProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IJavaTerminalLink> {
/**
Expand All @@ -17,17 +18,14 @@ export class JavaTerminalLinkProvder implements TerminalLinkProvider<IJavaTermin
*/
public provideTerminalLinks(context: TerminalLinkContext, _token: CancellationToken): ProviderResult<IJavaTerminalLink[]> {
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,
}];
}
Expand Down
48 changes: 48 additions & 0 deletions test/stackFrameParser.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading