Skip to content
Draft
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
43 changes: 43 additions & 0 deletions src/stackFrameParser.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
wenytang-ms marked this conversation as resolved.
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,
};
}
184 changes: 184 additions & 0 deletions src/stackTraceLinkProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// 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";

// 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" },
];

// 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;
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"]);

// 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;
lineNumber: number;
}

/**
* 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[] = [];
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 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: IStackFrameLinkArgs): Promise<void> {
if (!args || !args.stackTrace) {
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 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<void> {
// 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 });
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;
context.subscriptions.push(api.onDidServerModeChange((mode: string) => {
if (mode === ServerMode.STANDARD && !registered) {
registered = true;
doRegister();
}
}));
} 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
43 changes: 43 additions & 0 deletions test/stackFrameParser.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});