|
| 1 | +import * as vscode from 'vscode'; |
| 2 | +import * as path from 'path'; |
| 3 | +import { FindingTreeItem } from '../tree/findingsTreeDataProvider'; |
| 4 | +import { RawEventData } from '../types/rawEvent'; |
| 5 | + |
| 6 | +const MAX_STACK_FRAMES = 5; |
| 7 | +const MESSAGE_WIDTH = 100; |
| 8 | + |
| 9 | +function wordWrap(text: string, maxWidth: number): string { |
| 10 | + const lines: string[] = []; |
| 11 | + const paragraphs = text.split('\n'); |
| 12 | + |
| 13 | + for (const paragraph of paragraphs) { |
| 14 | + if (paragraph.trim().length === 0) { |
| 15 | + lines.push(''); |
| 16 | + continue; |
| 17 | + } |
| 18 | + |
| 19 | + const words = paragraph.split(/\s+/); |
| 20 | + let currentLine = ''; |
| 21 | + |
| 22 | + for (const word of words) { |
| 23 | + const testLine = currentLine.length === 0 ? word : `${currentLine} ${word}`; |
| 24 | + |
| 25 | + if (testLine.length <= maxWidth) { |
| 26 | + currentLine = testLine; |
| 27 | + } else { |
| 28 | + if (currentLine.length > 0) { |
| 29 | + lines.push(currentLine); |
| 30 | + } |
| 31 | + currentLine = word; |
| 32 | + } |
| 33 | + } |
| 34 | + |
| 35 | + if (currentLine.length > 0) { |
| 36 | + lines.push(currentLine); |
| 37 | + } |
| 38 | + } |
| 39 | + |
| 40 | + return lines.join('\n'); |
| 41 | +} |
| 42 | + |
| 43 | +function createFileLink(stackFrame: string, workspaceFolder: vscode.WorkspaceFolder): string { |
| 44 | + // Parse format: "path/to/file.ts:42" or "path/to/file.ts" or "path/to/file.ts:-1" |
| 45 | + const match = stackFrame.match(/^(.+?)(?::(-?\d+))?$/); |
| 46 | + if (!match) return stackFrame; |
| 47 | + |
| 48 | + const [, filePath, lineStr] = match; |
| 49 | + let lineNumber: number | null = null; |
| 50 | + |
| 51 | + // Parse and validate line number |
| 52 | + if (lineStr) { |
| 53 | + const parsedLine = parseInt(lineStr, 10); |
| 54 | + // Only use line number if it's a positive integer (>= 1) |
| 55 | + if (parsedLine >= 1) { |
| 56 | + lineNumber = parsedLine; |
| 57 | + } |
| 58 | + } |
| 59 | + |
| 60 | + // Resolve to absolute path first |
| 61 | + const absolutePath = path.isAbsolute(filePath) |
| 62 | + ? filePath |
| 63 | + : path.join(workspaceFolder.uri.fsPath, filePath); |
| 64 | + |
| 65 | + // Convert to workspace-relative path for both display and link |
| 66 | + const relativePath = vscode.workspace.asRelativePath(absolutePath); |
| 67 | + |
| 68 | + // Create link URL (workspace-relative with line number fragment if present) |
| 69 | + const linkUrl = lineNumber ? `${relativePath}#L${lineNumber}` : relativePath; |
| 70 | + |
| 71 | + // Create display text (with line number after colon if present) |
| 72 | + const displayText = lineNumber ? `${relativePath}:${lineNumber}` : relativePath; |
| 73 | + |
| 74 | + return `[${displayText}](${linkUrl})`; |
| 75 | +} |
| 76 | + |
| 77 | +function formatFindingSynopsis( |
| 78 | + findingTreeItem: FindingTreeItem, |
| 79 | + workspaceFolder: vscode.WorkspaceFolder |
| 80 | +): string { |
| 81 | + const resolvedFinding = findingTreeItem.finding; |
| 82 | + const finding = resolvedFinding.finding; |
| 83 | + |
| 84 | + // Build synopsis in markdown format |
| 85 | + const parts: string[] = []; |
| 86 | + |
| 87 | + // Header with rule title |
| 88 | + parts.push(`# ${finding.ruleTitle}`); |
| 89 | + parts.push(''); |
| 90 | + |
| 91 | + // Rule ID |
| 92 | + parts.push(`This AppMap matches the following analysis rule: \`${finding.ruleId}\``); |
| 93 | + parts.push(''); |
| 94 | + |
| 95 | + // Rule description |
| 96 | + if (resolvedFinding.rule.description) { |
| 97 | + parts.push(resolvedFinding.rule.description); |
| 98 | + parts.push(''); |
| 99 | + } |
| 100 | + |
| 101 | + // Impact domain and occurrence count |
| 102 | + const metadata: string[] = []; |
| 103 | + if (resolvedFinding.impactDomain) { |
| 104 | + metadata.push(`**Impact Domain**: ${resolvedFinding.impactDomain}`); |
| 105 | + } |
| 106 | + if (finding.occurranceCount && finding.occurranceCount > 1) { |
| 107 | + metadata.push(`**Occurrences**: ${finding.occurranceCount}`); |
| 108 | + } |
| 109 | + if (metadata.length > 0) { |
| 110 | + parts.push(metadata.join(' | ')); |
| 111 | + parts.push(''); |
| 112 | + } |
| 113 | + |
| 114 | + // Message (word-wrapped in code block) |
| 115 | + parts.push(`## Finding Details`); |
| 116 | + parts.push('```'); |
| 117 | + parts.push(wordWrap(finding.message, MESSAGE_WIDTH)); |
| 118 | + parts.push('```'); |
| 119 | + parts.push(''); |
| 120 | + |
| 121 | + // Group message if different from main message |
| 122 | + if (finding.groupMessage && finding.groupMessage !== finding.message) { |
| 123 | + parts.push(`## Group Summary`); |
| 124 | + parts.push('```'); |
| 125 | + parts.push(wordWrap(finding.groupMessage, MESSAGE_WIDTH)); |
| 126 | + parts.push('```'); |
| 127 | + parts.push(''); |
| 128 | + } |
| 129 | + |
| 130 | + // Source location |
| 131 | + if (resolvedFinding.locationLabel) { |
| 132 | + parts.push(`## Source Location`); |
| 133 | + parts.push(createFileLink(resolvedFinding.locationLabel, workspaceFolder)); |
| 134 | + parts.push(''); |
| 135 | + } |
| 136 | + |
| 137 | + // Stack trace (top N frames) |
| 138 | + if (finding.stack && finding.stack.length > 0) { |
| 139 | + parts.push(`## Stack Trace`); |
| 140 | + const stackFrames = finding.stack.slice(0, MAX_STACK_FRAMES); |
| 141 | + stackFrames.forEach((frame) => { |
| 142 | + parts.push(`- ${createFileLink(frame, workspaceFolder)}`); |
| 143 | + }); |
| 144 | + if (finding.stack.length > MAX_STACK_FRAMES) { |
| 145 | + parts.push(`- ... (${finding.stack.length - MAX_STACK_FRAMES} more frames)`); |
| 146 | + } |
| 147 | + parts.push(''); |
| 148 | + } |
| 149 | + |
| 150 | + // Participating events if available |
| 151 | + // Note: participatingEvents is incorrectly typed as Event in @appland/scanner, |
| 152 | + // but actually contains raw JSON data with snake_case properties |
| 153 | + if (finding.participatingEvents && Object.keys(finding.participatingEvents).length > 0) { |
| 154 | + const rawEvents = finding.participatingEvents as unknown as Record<string, RawEventData>; |
| 155 | + parts.push(`## Code Event Context`); |
| 156 | + for (const [name, rawEvent] of Object.entries(rawEvents)) { |
| 157 | + const event: RawEventData = rawEvent; |
| 158 | + // Add event type/method if available |
| 159 | + if (event.http_server_request) { |
| 160 | + const req = event.http_server_request; |
| 161 | + const normalizedPath = |
| 162 | + req.normalized_path_info && req.normalized_path_info !== req.path_info |
| 163 | + ? ` (${req.normalized_path_info})` |
| 164 | + : ''; |
| 165 | + parts.push(`- **${name}**: ${req.request_method} ${req.path_info}${normalizedPath}`); |
| 166 | + } else if (event.sql_query) { |
| 167 | + parts.push(`- **${name}** (SQL):`); |
| 168 | + parts.push(' ```sql'); |
| 169 | + parts.push(` ${event.sql_query.sql || 'N/A'}`); |
| 170 | + parts.push(' ```'); |
| 171 | + if (event.sql_query.database_type) { |
| 172 | + parts.push(` Database: ${event.sql_query.database_type}`); |
| 173 | + } |
| 174 | + } else { |
| 175 | + const location = [event.defined_class, event.static ? '.' : '#', event.method_id] |
| 176 | + .filter(Boolean) |
| 177 | + .join(''); |
| 178 | + parts.push(`- **${name}**: ${location}`); |
| 179 | + } |
| 180 | + } |
| 181 | + parts.push(''); |
| 182 | + } |
| 183 | + |
| 184 | + // AppMap file (workspace-relative path as clickable link) |
| 185 | + const workspaceRelativePath = vscode.workspace.asRelativePath(resolvedFinding.appMapUri.fsPath); |
| 186 | + parts.push(`## AppMap`); |
| 187 | + parts.push(`[${workspaceRelativePath}](${workspaceRelativePath})`); |
| 188 | + parts.push(''); |
| 189 | + |
| 190 | + // References |
| 191 | + parts.push(`## References`); |
| 192 | + if (resolvedFinding.rule.references && Object.keys(resolvedFinding.rule.references).length > 0) { |
| 193 | + parts.push(`- ${resolvedFinding.rule.url}`); |
| 194 | + for (const [name, url] of Object.entries(resolvedFinding.rule.references)) { |
| 195 | + parts.push(`- [${name}](${url.toString()})`); |
| 196 | + } |
| 197 | + parts.push(''); |
| 198 | + } |
| 199 | + |
| 200 | + return parts.join('\n'); |
| 201 | +} |
| 202 | + |
| 203 | +export default function registerCommand(context: vscode.ExtensionContext): void { |
| 204 | + context.subscriptions.push( |
| 205 | + vscode.commands.registerCommand( |
| 206 | + 'appmap.openFindingAsMarkdown', |
| 207 | + async (findingTreeItem: FindingTreeItem) => { |
| 208 | + try { |
| 209 | + // Get workspace folder for resolving relative paths |
| 210 | + const workspaceFolder = findingTreeItem.finding.folder; |
| 211 | + const synopsis = formatFindingSynopsis(findingTreeItem, workspaceFolder); |
| 212 | + |
| 213 | + // Create untitled markdown document |
| 214 | + const document = await vscode.workspace.openTextDocument({ |
| 215 | + content: synopsis, |
| 216 | + language: 'markdown', |
| 217 | + }); |
| 218 | + |
| 219 | + // Show the document first (required for preview command to work) |
| 220 | + await vscode.window.showTextDocument(document); |
| 221 | + |
| 222 | + // Then open the markdown preview |
| 223 | + await vscode.commands.executeCommand('markdown.showPreview', document.uri); |
| 224 | + } catch (error) { |
| 225 | + const errorMessage = error instanceof Error ? error.message : String(error); |
| 226 | + vscode.window.showErrorMessage(`Failed to open finding as markdown: ${errorMessage}`); |
| 227 | + console.error('Open finding as markdown error:', error); |
| 228 | + } |
| 229 | + } |
| 230 | + ) |
| 231 | + ); |
| 232 | +} |
0 commit comments