-
Notifications
You must be signed in to change notification settings - Fork 428
feat: linkify Java stack traces in text documents (#1654) #1657
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
db3100a
feat: linkify Java stack traces in text documents (#1654)
wenytang-ms e43a338
feat(telemetry): track stack-trace console reach, clicks, and command…
wenytang-ms 8d57dca
refactor(telemetry): keep a single click event, drop reach + command …
wenytang-ms a12a7c6
feat: activate on Java workspaces so stack-trace linkify is on by def…
wenytang-ms fc2d181
fix: only linkify once the Java language server reaches Standard mode
wenytang-ms 96ed5c6
refactor(telemetry): drop the documentSource dimension, keep a bare c…
wenytang-ms 8565a6b
refactor: scope stack-trace linkify to untitled documents only
wenytang-ms 867f458
refactor: gate palette command on javaLSReady, share frame parser, ha…
wenytang-ms 287c30e
fix: address stack trace review feedback
wenytang-ms 7dea2b2
fix: harden stack trace command links
wenytang-ms 0c31940
refactor: clarify stack frame validation
wenytang-ms 381e4bd
fix: reject invalid stack frame line numbers
wenytang-ms 4d47da9
fix: streamline stack trace initialization
wenytang-ms c055125
feat: linkify stack traces in log files
wenytang-ms File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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`. | ||
| */ | ||
| 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); | ||
|
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, | ||
| }; | ||
|
wenytang-ms marked this conversation as resolved.
|
||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
|
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(); | ||
| } | ||
| }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.