/** * Formatting helpers for ast-grep JSON output. * * ast-grep JSON uses zero-based line and column numbers. All human-facing * formatting converts to one-based positions. */ export interface JsonRange { end?: { line?: number; column?: number }; start?: { line?: number; column?: number }; } export interface JsonMatch { file?: unknown; language?: unknown; range?: JsonRange; replacement?: unknown; ruleId?: unknown; severity?: unknown; text?: unknown; } export interface FormattedMatch { column: number; file: string; language?: string; line: number; replacement?: string; ruleId?: string; severity?: string; text: string; } function asString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } /** * Parses an ast-grep JSON array of matches, recovering the last complete * top-level element when the output was cut mid-array. */ export function parseJsonArrayWithMeta(stdout: string): { matches: unknown[]; truncated: boolean } { const trimmed = stdout.trim(); if (trimmed === "") { return { matches: [], truncated: false }; } try { const parsed: unknown = JSON.parse(trimmed); return { matches: Array.isArray(parsed) ? parsed : [], truncated: false }; } catch { // Fall through to last-complete-element recovery. } if (!trimmed.startsWith("[")) { return { matches: [], truncated: false }; } const recovered = recoverLastCompleteElement(trimmed); return { matches: recovered ?? [], truncated: true }; } /** * Parses an ast-grep JSON array of matches. Returns [] on malformed input. * * When the array is cut mid-stream this recovers the longest prefix ending * at a complete top-level element (backward-compatible entry point). */ export function parseJsonArray(stdout: string): unknown[] { return parseJsonArrayWithMeta(stdout).matches; } /** * Walks a cut JSON array string tracking nesting depth and returns the * longest prefix that ends at a complete top-level `}` element and parses as * an array. Returns undefined when no such prefix exists. */ function recoverLastCompleteElement(text: string): unknown[] | undefined { const candidates: number[] = []; let depth = 0; let inString = false; let escaped = false; let index = 0; for (const ch of text) { index += 1; if (inString) { if (escaped) { escaped = false; } else if (ch === "\\") { escaped = true; } else if (ch === '"') { inString = false; } continue; } if (ch === '"') { inString = true; } else if (ch === "{" || ch === "[") { depth += 1; } else if (ch === "}" || ch === "]") { depth -= 1; if (ch === "}" && depth === 1) { // Position just past this complete top-level element. candidates.push(index); } } } // Try the longest candidate first (from the end of the text). for (let k = candidates.length - 1; k >= 0; k -= 1) { const end = candidates[k] as number; try { const parsed: unknown = JSON.parse(`${text.slice(0, end)}]`); if (Array.isArray(parsed)) { return parsed; } } catch { // Try the next earlier boundary. } } } function normalizeMatch(raw: unknown): JsonMatch | undefined { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { return; } return raw as JsonMatch; } /** Converts one ast-grep match object into a compact, stable record. */ export function toFormattedMatch(raw: unknown): FormattedMatch | undefined { const match = normalizeMatch(raw); if (match === undefined) { return; } const text = asString(match.text) ?? ""; const file = asString(match.file) ?? ""; const line = (match.range?.start?.line ?? 0) + 1; const column = (match.range?.start?.column ?? 0) + 1; const formatted: FormattedMatch = { text, file, line, column }; const replacement = asString(match.replacement); if (replacement !== undefined) { formatted.replacement = replacement; } const ruleId = asString(match.ruleId); if (ruleId !== undefined) { formatted.ruleId = ruleId; } const severity = asString(match.severity); if (severity !== undefined) { formatted.severity = severity; } const language = asString(match.language); if (language !== undefined) { formatted.language = language; } return formatted; } export interface FormatOptions { maxMatches?: number; } const DEFAULT_MAX_MATCHES = 200; /** Renders matches as compact one-line entries with a count header. */ export function formatMatches(matches: readonly FormattedMatch[], options: FormatOptions = {}): string { const max = options.maxMatches ?? DEFAULT_MAX_MATCHES; const shown = matches.slice(0, max); const lines = shown.map((match) => { const location = match.file === "" ? `#${match.line}:${match.column}` : `${match.file}:${match.line}:${match.column}`; const label = match.ruleId === undefined ? "" : ` [${match.ruleId}]`; const severity = match.severity === undefined ? "" : ` (${match.severity})`; const replacement = match.replacement === undefined ? "" : ` => ${match.replacement}`; return `${location}${label}${severity} ${match.text}${replacement}`; }); const header = `${matches.length} match${matches.length === 1 ? "" : "es"}`; const body = lines.join("\n"); const extra = matches.length > max ? `\n…[showing ${max} of ${matches.length}]` : ""; return body === "" ? `${header}: none` : `${header}:\n${body}${extra}`; } export interface OutlineItem { exported?: boolean; imported?: boolean; kind?: string; line: number; members?: OutlineItem[]; name?: string; signature: string; } export interface OutlineFile { items: OutlineItem[]; language?: string; path: string; } /** Converts one outline JSON file entry into a stable record. */ export function toOutlineFile(raw: unknown): OutlineFile | undefined { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { return; } const entry = raw as { path?: unknown; language?: unknown; items?: unknown; }; const path = asString(entry.path); if (path === undefined) { return; } const items: OutlineItem[] = []; if (Array.isArray(entry.items)) { for (const item of entry.items) { const parsed = toOutlineItem(item); if (parsed !== undefined) { items.push(parsed); } } } const language = asString(entry.language); return { path, ...(language === undefined ? {} : { language }), items, }; } function toOutlineItem(raw: unknown): OutlineItem | undefined { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { return; } const item = raw as { name?: unknown; signature?: unknown; range?: JsonRange; astKind?: unknown; isExport?: unknown; isExported?: unknown; isImport?: unknown; isImported?: unknown; members?: unknown; }; const signature = asString(item.signature)?.trim() ?? ""; const name = asString(item.name)?.trim() ?? ""; const label = signature === "" ? name : signature; if (label === "") { return; } const line = (item.range?.start?.line ?? 0) + 1; const kind = asString(item.astKind); const result: OutlineItem = { line, ...(name === "" ? {} : { name }), signature: label, ...(kind === undefined ? {} : { kind }), ...(item.isExported === true || item.isExport === true ? { exported: true } : {}), ...(item.isImported === true || item.isImport === true ? { imported: true } : {}), }; if (Array.isArray(item.members)) { const members: OutlineItem[] = []; for (const member of item.members) { const parsed = toOutlineItem(member); if (parsed !== undefined) { members.push(parsed); } } if (members.length > 0) { result.members = members; } } return result; } /** * Merges member data from an expanded-view outline run into a primary run. * * The expanded view is the only view whose JSON carries member signatures. * The default (auto) view carries member ranges but empty member * signatures; the signatures view omits members entirely. The merge fills * empty member signatures and restores members that the primary view * omitted. Items and members are matched by name and one-based line. */ export function mergeOutlineSignatures( primary: readonly OutlineFile[], expanded: readonly OutlineFile[], ): OutlineFile[] { const expandedByPath = new Map(expanded.map((file) => [file.path, file])); const merged = primary.map((file) => { const richer = expandedByPath.get(file.path); if (richer === undefined) { return file; } const items = file.items.map((item) => { const expandedItem = richer.items.find( (candidate) => candidate.name === item.name && candidate.line === item.line, ); if (expandedItem === undefined) { return item; } const members = mergeOutlineMembers(item.members, expandedItem.members); if (members === undefined || members === item.members) { return item; } return { ...item, members }; }); return items === file.items ? file : { ...file, items }; }); return merged; } /** Fills empty member signatures, or restores members the primary view omitted. */ function mergeOutlineMembers( primary: OutlineItem[] | undefined, expanded: OutlineItem[] | undefined, ): OutlineItem[] | undefined { if (expanded === undefined || expanded.length === 0) { return primary; } if (primary === undefined || primary.length === 0) { return [...expanded]; } const byKey = new Map(expanded.map((member) => [memberKey(member), member])); let changed = false; const merged = primary.map((member) => { const richer = byKey.get(memberKey(member)); // A member whose signature equals its name is a name fallback (the // primary view carried no signature). Only these get replaced. if ( richer !== undefined && member.name !== undefined && member.signature === member.name && richer.signature !== "" ) { changed = true; return { ...member, signature: richer.signature }; } return member; }); return changed ? merged : primary; } /** Stable merge key for an outline item: name and one-based line. */ function memberKey(item: OutlineItem): string { return item.name === undefined ? item.line.toString() : `${item.name}@${item.line}`; } /** Renders outline file entries as compact text with one-based line numbers. */ export function formatOutline(files: readonly OutlineFile[]): string { const blocks = files.map((file) => { const header = file.language === undefined ? file.path : `${file.path} (${file.language})`; const lines = file.items.map((item) => { const flags = [item.imported === true ? "imported" : "", item.exported === true ? "exported" : ""] .filter((flag) => flag !== "") .join(","); const flagText = flags === "" ? "" : ` [${flags}]`; const kindText = item.kind === undefined ? "" : ` kind: ${item.kind}`; const members = item.members === undefined ? "" : `\n${item.members.map((member) => ` ${member.line}: ${member.signature}`).join("\n")}`; return `${item.line}: ${item.signature}${flagText}${kindText}${members}`; }); return `${header}\n${lines.join("\n")}`; }); return blocks.join("\n\n"); }