From 733b7265dc579d58bf206fe2f00ca65e287d1ec5 Mon Sep 17 00:00:00 2001 From: Mo Doaie Date: Wed, 1 Jul 2026 11:27:35 +0100 Subject: [PATCH 1/4] feat(function-usage): add reverse call graph visualization for functions - Introduced a new command `codevisualizer.visualizeFunctionUsage` to visualize the usage of functions in TypeScript and JavaScript. - Implemented `FunctionUsageProvider` to handle the display of the usage graph in a webview. - Created `CallGraphAnalyzer` to analyze function definitions and their callers within the workspace. - Added `CallGraphMermaidGenerator` to render the usage graph in Mermaid format. - Enhanced `TsAstParser` to extract function call information for building the call graph. - Updated `package.json` to include new scripts and dependencies for local installation and testing. - Improved the overall structure and organization of the codebase to support the new functionality. --- package-lock.json | 9 - package.json | 49 ++- scripts/install-local.js | 214 ++++++++++++ src/core/callgraph/CallGraphAnalyzer.ts | 316 ++++++++++++++++++ .../callgraph/CallGraphMermaidGenerator.ts | 74 ++++ .../typescript/TsAstParser.ts | 150 +++++++++ .../language-services/typescript/index.ts | 11 + src/extension.ts | 45 +++ src/view/FunctionUsageProvider.ts | 312 +++++++++++++++++ 9 files changed, 1154 insertions(+), 26 deletions(-) create mode 100644 scripts/install-local.js create mode 100644 src/core/callgraph/CallGraphAnalyzer.ts create mode 100644 src/core/callgraph/CallGraphMermaidGenerator.ts create mode 100644 src/view/FunctionUsageProvider.ts diff --git a/package-lock.json b/package-lock.json index eba8496..9f18637 100644 --- a/package-lock.json +++ b/package-lock.json @@ -610,7 +610,6 @@ "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/types": "8.46.3", @@ -1137,7 +1136,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1184,7 +1182,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -1353,7 +1350,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -1801,7 +1797,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4246,7 +4241,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4320,7 +4314,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4425,7 +4418,6 @@ "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -4475,7 +4467,6 @@ "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", diff --git a/package.json b/package.json index 5d70741..ae1e6d9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codevisualizer", "displayName": "CodeVisualizer", - "version": "1.0.6", + "version": "1.0.9", "publisher": "ducphamngoc", "description": "Real-time interactive flowcharts for your code", "repository": { @@ -31,6 +31,23 @@ "php" ], "main": "./dist/extension.js", + "scripts": { + "vscode:prepublish": "npm run package", + "clean": "rimraf dist out", + "compile": "npm run clean && webpack", + "watch": "webpack --watch", + "package": "npm run clean && webpack --mode production --devtool hidden-source-map", + "compile-tests": "tsc -p . --outDir out", + "watch-tests": "tsc -p . -w --outDir out", + "pretest": "npm run compile-tests && npm run compile && npm run lint", + "lint": "eslint src --ext ts", + "lint:fix": "eslint src --ext ts --fix", + "test": "vscode-test", + "publish:patch": "vsce publish patch", + "publish:minor": "vsce publish minor", + "publish:major": "vsce publish major", + "install:local": "node ./scripts/install-local.js" + }, "activationEvents": [ "onStartupFinished" ], @@ -109,6 +126,11 @@ "command": "codevisualizer.visualizeCodebase", "title": "Visualize Codebase Flow", "icon": "$(organization)" + }, + { + "command": "codevisualizer.visualizeFunctionUsage", + "title": "CodeVisualizer: Visualize Function Usage", + "icon": "$(references)" } ], "menus": { @@ -122,6 +144,11 @@ "command": "codevisualizer.openFlowchartInPanel", "when": "editorTextFocus", "group": "navigation@2" + }, + { + "command": "codevisualizer.visualizeFunctionUsage", + "when": "editorTextFocus && editorLangId =~ /^(typescript|typescriptreact|javascript|javascriptreact)$/", + "group": "navigation@3" } ], "view/title": [ @@ -164,6 +191,10 @@ }, { "command": "codevisualizer.visualizeCodebase" + }, + { + "command": "codevisualizer.visualizeFunctionUsage", + "when": "editorLangId =~ /^(typescript|typescriptreact|javascript|javascriptreact)$/" } ], "explorer/context": [ @@ -294,22 +325,6 @@ } } }, - "scripts": { - "vscode:prepublish": "npm run package", - "clean": "rimraf dist out", - "compile": "npm run clean && webpack", - "watch": "webpack --watch", - "package": "npm run clean && webpack --mode production --devtool hidden-source-map", - "compile-tests": "tsc -p . --outDir out", - "watch-tests": "tsc -p . -w --outDir out", - "pretest": "npm run compile-tests && npm run compile && npm run lint", - "lint": "eslint src --ext ts", - "lint:fix": "eslint src --ext ts --fix", - "test": "vscode-test", - "publish:patch": "vsce publish patch", - "publish:minor": "vsce publish minor", - "publish:major": "vsce publish major" - }, "devDependencies": { "@types/mocha": "^10.0.10", "@types/node": "^20.x", diff --git a/scripts/install-local.js b/scripts/install-local.js new file mode 100644 index 0000000..0f60665 --- /dev/null +++ b/scripts/install-local.js @@ -0,0 +1,214 @@ +#!/usr/bin/env node +/* + * Build, package, and (re)install CodeVisualizer into your local VS Code globally. + * + * One command to go from source -> installed extension: + * 1. Builds the production webpack bundle (npm run package). + * 2. Packages a .vsix with @vscode/vsce (via npx, no global install needed). + * 3. Installs/updates it into VS Code with `code --install-extension --force`. + * + * After it finishes, the extension is available in EVERY VS Code window. + * Reload the window (Ctrl+Shift+P -> "Developer: Reload Window") or restart + * VS Code to pick up the new build. + * + * Usage: + * node scripts/install-local.js [options] + * npm run install:local -- [options] + * + * Options: + * --code VS Code CLI to install into (default: "code"; use "code-insiders"). + * --skip-build Repackage + reinstall the current dist/ without rebuilding. + * --keep-vsix Keep the generated .vsix (default: removed after install). + */ +"use strict"; + +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const repoRoot = path.resolve(__dirname, ".."); + +// --- arg parsing ------------------------------------------------------------- +const args = process.argv.slice(2); +function flag(name) { + return args.includes(name); +} +function opt(name, fallback) { + const i = args.indexOf(name); + return i !== -1 && args[i + 1] ? args[i + 1] : fallback; +} +const codeCli = opt("--code", "code"); +const skipBuild = flag("--skip-build"); +const keepVsix = flag("--keep-vsix"); + +// --- helpers ----------------------------------------------------------------- +const colors = { + cyan: (s) => `\x1b[36m${s}\x1b[0m`, + yellow: (s) => `\x1b[33m${s}\x1b[0m`, + green: (s) => `\x1b[32m${s}\x1b[0m`, + gray: (s) => `\x1b[90m${s}\x1b[0m`, +}; + +function run(parts) { + // Pass the full command line as a single string with shell:true so Windows + // resolves npm/npx/code (.cmd shims) just like a terminal. Building one string + // (rather than an args array) avoids Node's DEP0190 shell-args warning. + const line = parts.join(" "); + const res = spawnSync(line, { + cwd: repoRoot, + stdio: "inherit", + shell: true, + }); + if (res.status !== 0) { + throw new Error(`Command failed (exit ${res.status}): ${line}`); + } +} + +// Quote a path/arg for safe inclusion in a shell command line. +function q(s) { + return `"${s}"`; +} + +function which(cmd) { + if (path.isAbsolute(cmd)) { + return fs.existsSync(cmd); + } + const probe = process.platform === "win32" ? "where" : "which"; + return spawnSync(`${probe} ${cmd}`, { shell: true }).status === 0; +} + +function windowsCodeCliCandidates(cmd) { + const suffix = cmd === "code-insiders" ? "Insiders\\bin\\code-insiders.cmd" : "bin\\code.cmd"; + return [ + process.env.LOCALAPPDATA && + path.join(process.env.LOCALAPPDATA, "Programs", `Microsoft VS Code${cmd === "code-insiders" ? " Insiders" : ""}`, "bin", cmd === "code-insiders" ? "code-insiders.cmd" : "code.cmd"), + process.env.PROGRAMFILES && + path.join(process.env.PROGRAMFILES, "Microsoft VS Code", suffix), + process.env["PROGRAMFILES(X86)"] && + path.join(process.env["PROGRAMFILES(X86)"], "Microsoft VS Code", suffix), + ].filter(Boolean); +} + +function resolveCodeCli(cmd) { + if (which(cmd)) { + return cmd; + } + + if (process.platform !== "win32" || (cmd !== "code" && cmd !== "code-insiders")) { + return null; + } + + const found = windowsCodeCliCandidates(cmd).find((candidate) => fs.existsSync(candidate)); + return found ? q(found) : null; +} + +// Return the installed version of an extension id (e.g. "publisher.name"), or +// null if it is not installed. Uses `code --list-extensions --show-versions`, +// whose lines look like "publisher.name@1.2.3". +function installedVersion(cli, extId) { + const res = spawnSync(`${cli} --list-extensions --show-versions`, { + shell: true, + encoding: "utf8", + }); + if (res.status !== 0 || !res.stdout) { + return null; + } + const prefix = `${extId}@`; + const line = res.stdout + .split(/\r?\n/) + .map((l) => l.trim()) + .find((l) => l.startsWith(prefix)); + return line ? line.slice(prefix.length) : null; +} + +// --- main -------------------------------------------------------------------- +function main() { + console.log(colors.cyan(`==> Repo: ${repoRoot}`)); + + if (!which("npm")) { + throw new Error("npm not found on PATH. Install Node.js first."); + } + const resolvedCodeCli = resolveCodeCli(codeCli); + if (!resolvedCodeCli) { + throw new Error( + `VS Code CLI '${codeCli}' not found on PATH. In VS Code run ` + + `Ctrl+Shift+P -> "Shell Command: Install 'code' command in PATH", then retry.` + ); + } + + const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")); + const vsixName = `${pkg.name}-${pkg.version}.vsix`; + const vsixPath = path.join(repoRoot, vsixName); + const extId = `${pkg.publisher}.${pkg.name}`; + console.log(colors.cyan(`==> Extension: ${extId}@${pkg.version}`)); + + // 0. Refuse to reinstall over an identical version. VS Code only picks up a + // new build when the version changes, so installing the same version again is + // almost always a mistake (the editor keeps running the old bits). If a + // different version is already installed, remember it so we can uninstall it + // first and replace it cleanly. + const existingVersion = installedVersion(resolvedCodeCli, extId); + if (existingVersion === pkg.version) { + throw new Error( + `'${extId}' ${pkg.version} is already installed. Bump the "version" in ` + + `package.json before reinstalling, otherwise VS Code will keep running ` + + `the old build.` + ); + } + if (existingVersion) { + console.log( + colors.gray(`==> Found installed ${extId}@${existingVersion}; will replace it.`) + ); + } + + // 1. Install deps if needed + if (!fs.existsSync(path.join(repoRoot, "node_modules"))) { + console.log(colors.yellow("==> node_modules missing; running npm install...")); + run(["npm", "install"]); + } + + // 2. Build + if (!skipBuild) { + console.log(colors.yellow("==> Building production bundle (npm run package)...")); + run(["npm", "run", "package"]); + } else { + console.log(colors.gray("==> Skipping build (--skip-build).")); + } + + // 3. Package the .vsix + // node_modules is excluded via .vscodeignore and everything is bundled by + // webpack into dist/, so package with --no-dependencies. + console.log(colors.yellow("==> Packaging .vsix...")); + run(["npx", "--yes", "@vscode/vsce", "package", "--no-dependencies", "--out", q(vsixPath)]); + + // 4. Uninstall the previously installed version (if any), then install. + if (existingVersion) { + console.log(colors.yellow(`==> Uninstalling ${extId}@${existingVersion}...`)); + run([resolvedCodeCli, "--uninstall-extension", extId]); + } + console.log(colors.yellow(`==> Installing into '${codeCli}'...`)); + run([resolvedCodeCli, "--install-extension", q(vsixPath), "--force"]); + + // 5. Cleanup + if (!keepVsix) { + fs.rmSync(vsixPath, { force: true }); + console.log(colors.gray(`==> Removed ${vsixName} (use --keep-vsix to keep it).`)); + } else { + console.log(colors.gray(`==> Kept ${vsixPath}`)); + } + + console.log(""); + console.log(colors.green(`DONE. '${pkg.displayName}' ${pkg.version} installed globally.`)); + console.log( + colors.green( + "Reload VS Code (Ctrl+Shift+P -> 'Developer: Reload Window') to use the new build." + ) + ); +} + +try { + main(); +} catch (err) { + console.error(`\x1b[31mERROR:\x1b[0m ${err.message}`); + process.exit(1); +} diff --git a/src/core/callgraph/CallGraphAnalyzer.ts b/src/core/callgraph/CallGraphAnalyzer.ts new file mode 100644 index 0000000..108b0fb --- /dev/null +++ b/src/core/callgraph/CallGraphAnalyzer.ts @@ -0,0 +1,316 @@ +import * as path from "path"; +import * as fs from "fs"; +import { getTypeScriptParser } from "../language-services/typescript"; +import { TsFunctionInfo } from "../language-services/typescript/TsAstParser"; + +/** A single function/method definition discovered in the workspace. */ +export interface FunctionDef { + name: string; + file: string; // absolute path + startIndex: number; + endIndex: number; + line: number; // 1-based, for display + kind: TsFunctionInfo["kind"]; + calls: string[]; +} + +/** A node in the rendered usage graph (one per function name). */ +export interface UsageGraphNode { + id: string; // sanitized, stable id used as the Mermaid node id + name: string; // original function name + file: string; // absolute path of the representative definition + relativePath: string; + line: number; // 1-based + startIndex: number; + endIndex: number; + isTarget: boolean; +} + +export interface UsageGraphEdge { + from: string; // node id (caller) + to: string; // node id (callee) +} + +export interface UsageGraph { + nodes: UsageGraphNode[]; + edges: UsageGraphEdge[]; + /** True if the graph was truncated because the node limit was reached. */ + truncated: boolean; + /** Number of distinct definitions found for the target name. */ + targetDefinitionCount: number; +} + +/** Hard caps to keep large codebases responsive. */ +const MAX_NODES = 200; +const MAX_DEPTH = 25; + +/** + * Scans TypeScript/JavaScript files in a workspace and builds a reverse call + * (usage) graph: given a function, who calls it, recursively up the chain. + * + * Resolution is name-based (a call to `foo()` matches any definition named + * `foo`). This is approximate but matches the lightweight, dependency-free + * style of the rest of the extension and works well in practice. + */ +export class CallGraphAnalyzer { + private workspaceRoot: string; + private supportedExtensions = new Set([ + ".ts", + ".tsx", + ".mts", + ".cts", + ".js", + ".jsx", + ".mjs", + ".cjs", + ]); + + private allDefs: FunctionDef[] = []; + private defsByName: Map = new Map(); + /** callee name -> definitions whose body calls that name. */ + private callersByCallee: Map = new Map(); + + constructor(workspaceRoot: string) { + this.workspaceRoot = workspaceRoot; + } + + /** Parses every supported file and indexes definitions and call relations. */ + public async analyze(): Promise { + this.allDefs = []; + this.defsByName.clear(); + this.callersByCallee.clear(); + + const parser = await getTypeScriptParser(); + const files = await this.getAllSupportedFiles(); + + for (const file of files) { + let content: string; + try { + content = await fs.promises.readFile(file, "utf-8"); + } catch { + continue; + } + + let functions: TsFunctionInfo[]; + try { + functions = parser.extractFunctionsWithCalls(content); + } catch (error) { + console.error(`CallGraphAnalyzer: failed to parse ${file}:`, error); + continue; + } + + for (const fn of functions) { + const def: FunctionDef = { + name: fn.name, + file, + startIndex: fn.startIndex, + endIndex: fn.endIndex, + line: fn.startLine + 1, + kind: fn.kind, + calls: fn.calls, + }; + this.allDefs.push(def); + + const byName = this.defsByName.get(def.name); + if (byName) { + byName.push(def); + } else { + this.defsByName.set(def.name, [def]); + } + + for (const callee of fn.calls) { + const callers = this.callersByCallee.get(callee); + if (callers) { + callers.push(def); + } else { + this.callersByCallee.set(callee, [def]); + } + } + } + } + } + + /** + * Builds the usage graph for a target function: all (transitive) callers. + * @param targetName the function name under the cursor. + * @param targetFile absolute path of the file the cursor is in. + * @param targetPosition byte offset of the cursor (to pick the right + * definition when several share the same name). + */ + public buildUsageGraph( + targetName: string, + targetFile?: string, + targetPosition?: number + ): UsageGraph { + const nodes = new Map(); + const edgeKeys = new Set(); + const edges: UsageGraphEdge[] = []; + let truncated = false; + + const targetDefs = this.defsByName.get(targetName) || []; + + const ensureNode = (name: string, isTarget: boolean): UsageGraphNode => { + const id = this.sanitizeId(name); + let node = nodes.get(id); + if (node) { + if (isTarget) { + node.isTarget = true; + } + return node; + } + const def = this.pickDefinition( + name, + isTarget ? targetFile : undefined, + isTarget ? targetPosition : undefined + ); + node = { + id, + name, + file: def?.file ?? targetFile ?? "", + relativePath: def?.file + ? path.relative(this.workspaceRoot, def.file) + : "", + line: def?.line ?? 0, + startIndex: def?.startIndex ?? targetPosition ?? 0, + endIndex: def?.endIndex ?? targetPosition ?? 0, + isTarget, + }; + nodes.set(id, node); + return node; + }; + + const addEdge = (fromName: string, toName: string) => { + const from = this.sanitizeId(fromName); + const to = this.sanitizeId(toName); + const key = `${from} ${to}`; + if (edgeKeys.has(key)) { + return; + } + edgeKeys.add(key); + edges.push({ from, to }); + }; + + ensureNode(targetName, true); + + // Breadth-first walk up the caller chain. + const visited = new Set([targetName]); + let frontier: string[] = [targetName]; + let depth = 0; + + while (frontier.length > 0 && depth < MAX_DEPTH) { + const next: string[] = []; + for (const current of frontier) { + const callers = this.callersByCallee.get(current) || []; + for (const callerDef of callers) { + const caller = callerDef.name; + if (caller === current) { + continue; // ignore direct self-recursion edges + } + ensureNode(caller, false); + addEdge(caller, current); + + if (!visited.has(caller)) { + if (nodes.size >= MAX_NODES) { + truncated = true; + continue; + } + visited.add(caller); + next.push(caller); + } + } + } + frontier = next; + depth++; + } + + if (depth >= MAX_DEPTH && frontier.length > 0) { + truncated = true; + } + + return { + nodes: [...nodes.values()], + edges, + truncated, + targetDefinitionCount: targetDefs.length, + }; + } + + /** + * Chooses the most relevant definition for a name. When a cursor file and + * position are provided, prefer the definition that contains it. + */ + private pickDefinition( + name: string, + preferFile?: string, + preferPosition?: number + ): FunctionDef | undefined { + const defs = this.defsByName.get(name); + if (!defs || defs.length === 0) { + return undefined; + } + + if (preferFile !== undefined && preferPosition !== undefined) { + const exact = defs.find( + (d) => + d.file === preferFile && + preferPosition >= d.startIndex && + preferPosition <= d.endIndex + ); + if (exact) { + return exact; + } + } + if (preferFile !== undefined) { + const sameFile = defs.find((d) => d.file === preferFile); + if (sameFile) { + return sameFile; + } + } + return defs[0]; + } + + private sanitizeId(name: string): string { + const cleaned = name.replace(/[^A-Za-z0-9_]/g, "_"); + return /^[A-Za-z_]/.test(cleaned) ? cleaned : `fn_${cleaned}`; + } + + private async getAllSupportedFiles(): Promise { + const files: string[] = []; + + const walkDir = async (dir: string): Promise => { + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if ( + entry.name.startsWith(".") || + entry.name === "node_modules" || + entry.name === "dist" || + entry.name === "build" || + entry.name === "out" + ) { + continue; + } + + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + await walkDir(fullPath); + } else if (entry.isFile()) { + if ( + this.supportedExtensions.has(path.extname(entry.name)) && + !entry.name.endsWith(".d.ts") + ) { + files.push(fullPath); + } + } + } + }; + + await walkDir(this.workspaceRoot); + return files; + } +} diff --git a/src/core/callgraph/CallGraphMermaidGenerator.ts b/src/core/callgraph/CallGraphMermaidGenerator.ts new file mode 100644 index 0000000..4f55108 --- /dev/null +++ b/src/core/callgraph/CallGraphMermaidGenerator.ts @@ -0,0 +1,74 @@ +import { UsageGraph, UsageGraphNode } from "./CallGraphAnalyzer"; + +/** Location metadata used by the webview to navigate to a function on click. */ +export interface CallGraphNodeLocation { + file: string; + start: number; + end: number; +} + +export interface CallGraphRenderResult { + mermaid: string; + /** node id -> source location, consumed by the webview for navigation. */ + locations: Record; +} + +/** + * Renders a usage (reverse call) graph as Mermaid. Edges point from caller to + * callee, so the target function sits at the bottom and its (transitive) + * callers flow in from the top. + */ +export class CallGraphMermaidGenerator { + public generate(graph: UsageGraph): CallGraphRenderResult { + const lines: string[] = ["flowchart TD"]; + const locations: Record = {}; + + for (const node of graph.nodes) { + lines.push(` ${node.id}["${this.renderLabel(node)}"]`); + locations[node.id] = { + file: node.file, + start: node.startIndex, + end: node.endIndex, + }; + } + + for (const edge of graph.edges) { + lines.push(` ${edge.from} --> ${edge.to}`); + } + + // Highlight the target node. + const target = graph.nodes.find((n) => n.isTarget); + lines.push( + " classDef targetStyle fill:#ffd54f,stroke:#ff6f00,stroke-width:3px,color:#000" + ); + lines.push( + " classDef callerStyle fill:#e3f2fd,stroke:#1976d2,stroke-width:1.5px,color:#000" + ); + if (target) { + lines.push(` class ${target.id} targetStyle`); + } + const callerIds = graph.nodes + .filter((n) => !n.isTarget) + .map((n) => n.id); + if (callerIds.length > 0) { + lines.push(` class ${callerIds.join(",")} callerStyle`); + } + + return { mermaid: lines.join("\n"), locations }; + } + + private renderLabel(node: UsageGraphNode): string { + const second = node.relativePath + ? `${this.escape(node.relativePath)}:${node.line}` + : "definition not found"; + return `${this.escape(node.name)}
${second}`; + } + + private escape(text: string): string { + return text + .replace(/"/g, "#quot;") + .replace(//g, "#62;") + .replace(/\n/g, " "); + } +} diff --git a/src/core/language-services/typescript/TsAstParser.ts b/src/core/language-services/typescript/TsAstParser.ts index 451d29c..4b86392 100644 --- a/src/core/language-services/typescript/TsAstParser.ts +++ b/src/core/language-services/typescript/TsAstParser.ts @@ -9,6 +9,21 @@ import { import { ProcessResult, LoopContext } from "../../common/AstParserTypes"; import { ensureParserInit } from "../common/ParserInit"; +/** + * Raw information about a single function/method definition together with the + * names of the functions it calls. Used to build cross-file call graphs. + */ +export interface TsFunctionInfo { + name: string; + kind: "function" | "method" | "arrow"; + startIndex: number; + endIndex: number; + /** 0-based line number where the definition starts. */ + startLine: number; + /** Distinct names of functions/methods called within this definition's body. */ + calls: string[]; +} + export class TsAstParser extends AbstractParser { private currentFunctionIsArrow = false; @@ -96,6 +111,141 @@ export class TsAstParser extends AbstractParser { return arrowFunc?.childForFieldName("name")?.text || "[anonymous arrow]"; } + /** Function-expression node types (may be anonymous or bound to a name). */ + private static readonly FUNCTION_EXPRESSION_TYPES = new Set([ + "arrow_function", + "function", + "function_expression", + "generator_function", + ]); + + /** Node types that are always their own named definition. */ + private static readonly NAMED_DEFINITION_TYPES = new Set([ + "function_declaration", + "generator_function_declaration", + "method_definition", + ]); + + /** + * Extracts every named function/method/arrow-function definition in the + * source along with the names of the functions it calls. Calls made inside a + * nested *named* function are attributed to that function; calls inside + * anonymous callbacks are attributed to the enclosing function. This is the + * building block for cross-file call/usage graphs. + */ + public extractFunctionsWithCalls(sourceCode: string): TsFunctionInfo[] { + const tree = this.parser.parse(sourceCode); + const results: TsFunctionInfo[] = []; + + const pushDef = ( + defNode: Parser.SyntaxNode, + nameNode: Parser.SyntaxNode | null, + bodyNode: Parser.SyntaxNode | null, + kind: TsFunctionInfo["kind"] + ) => { + const name = nameNode?.text; + if (!name) return; + results.push({ + name, + kind, + startIndex: defNode.startIndex, + endIndex: defNode.endIndex, + startLine: defNode.startPosition.row, + calls: this.collectCalleeNames(bodyNode), + }); + }; + + for (const f of tree.rootNode.descendantsOfType("function_declaration")) { + pushDef(f, f.childForFieldName("name"), f.childForFieldName("body"), "function"); + } + + for (const m of tree.rootNode.descendantsOfType("method_definition")) { + pushDef(m, m.childForFieldName("name"), m.childForFieldName("body"), "method"); + } + + // Arrow/function expressions bound to a variable (const fn = () => {}). + for (const v of tree.rootNode.descendantsOfType("variable_declarator")) { + const value = v.childForFieldName("value"); + if (value && TsAstParser.FUNCTION_EXPRESSION_TYPES.has(value.type)) { + pushDef(v, v.childForFieldName("name"), value.childForFieldName("body"), "arrow"); + } + } + + // Class field arrow functions (onClick = () => {}), common in React etc. + for (const p of tree.rootNode.descendantsOfType("public_field_definition")) { + const value = p.childForFieldName("value"); + if (value && TsAstParser.FUNCTION_EXPRESSION_TYPES.has(value.type)) { + pushDef(p, p.childForFieldName("name"), value.childForFieldName("body"), "method"); + } + } + + return results; + } + + /** + * Collects the distinct names of functions/methods invoked within a body. + * Calls inside anonymous callbacks (e.g. `arr.map(x => foo(x))`) are + * attributed to the enclosing function, but calls inside nested *named* + * definitions are not (they are recorded against that definition instead). + */ + private collectCalleeNames(body: Parser.SyntaxNode | null): string[] { + const names = new Set(); + if (!body) return []; + + const visit = (node: Parser.SyntaxNode) => { + for (const child of node.namedChildren) { + // Skip nested named definitions; their calls belong to them. + if (this.isNamedDefinition(child)) { + continue; + } + if (child.type === "call_expression") { + const callee = this.calleeName(child.childForFieldName("function")); + if (callee) names.add(callee); + } + visit(child); + } + }; + + visit(body); + return [...names]; + } + + /** + * True when a node is a function that has its own definition entry: a + * declaration/method, or a function expression bound to a name (variable or + * class field). Anonymous function expressions return false so their calls + * are attributed to the enclosing function. + */ + private isNamedDefinition(node: Parser.SyntaxNode): boolean { + if (TsAstParser.NAMED_DEFINITION_TYPES.has(node.type)) { + return true; + } + if (TsAstParser.FUNCTION_EXPRESSION_TYPES.has(node.type)) { + const parentType = node.parent?.type; + return ( + parentType === "variable_declarator" || + parentType === "public_field_definition" + ); + } + return false; + } + + /** + * Resolves the simple name being called from a call expression's "function" + * node (e.g. `foo()` -> "foo", `obj.bar()` / `this.bar()` -> "bar"). + */ + private calleeName(fn: Parser.SyntaxNode | null): string | undefined { + if (!fn) return undefined; + if (fn.type === "identifier") return fn.text; + if (fn.type === "member_expression") { + return fn.childForFieldName("property")?.text || undefined; + } + if (fn.type === "parenthesized_expression" && fn.namedChild(0)) { + return this.calleeName(fn.namedChild(0)); + } + return undefined; + } + public generateFlowchart( sourceCode: string, functionName?: string, diff --git a/src/core/language-services/typescript/index.ts b/src/core/language-services/typescript/index.ts index dccff9e..2dc488d 100644 --- a/src/core/language-services/typescript/index.ts +++ b/src/core/language-services/typescript/index.ts @@ -25,4 +25,15 @@ export async function analyzeTypeScriptCode( return parser.generateFlowchart(code, undefined, position); } +/** + * Returns the initialized shared TypeScript parser instance. Used by features + * that need lower-level parsing (e.g. cross-file call graphs). + */ +export async function getTypeScriptParser(): Promise { + if (!parserPromise) { + throw new Error("TypeScript language service not initialized."); + } + return parserPromise; +} + export { TsAstParser }; diff --git a/src/extension.ts b/src/extension.ts index 089bf5a..150f1b5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,6 +2,8 @@ import * as vscode from "vscode"; import { FlowchartViewProvider } from "./view/FlowchartViewProvider"; import { FlowchartPanelProvider } from "./view/FlowchartPanelProvider"; import { CodebaseFlowProvider } from "./view/CodebaseFlowProvider"; +import { FunctionUsageProvider } from "./view/FunctionUsageProvider"; +import { getTypeScriptParser } from "./core/language-services/typescript"; import { initLanguageServices } from "./core/language-services"; import { LLMManager } from "./core/llm/LLMManager"; import { setExtensionContext } from "./core/llm/LLMContext"; @@ -235,6 +237,49 @@ export async function activate(context: vscode.ExtensionContext) { } }), + // Function Usage: reverse call graph for the function under the cursor (TS/JS) + vscode.commands.registerCommand("codevisualizer.visualizeFunctionUsage", async () => { + console.log("Command codevisualizer.visualizeFunctionUsage executed"); + try { + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showWarningMessage("Open a file and place the cursor inside a function."); + return; + } + + const supportedLanguages = [ + "typescript", + "typescriptreact", + "javascript", + "javascriptreact", + ]; + if (!supportedLanguages.includes(editor.document.languageId)) { + vscode.window.showInformationMessage( + "Function usage graphs are currently only supported for TypeScript/JavaScript." + ); + return; + } + + const parser = await getTypeScriptParser(); + const sourceCode = editor.document.getText(); + const position = editor.document.offsetAt(editor.selection.active); + const functionName = parser.findFunctionAtPosition(sourceCode, position); + + if (!functionName || functionName.startsWith("[anonymous")) { + vscode.window.showWarningMessage( + "Place the cursor inside a named function to visualize its usage." + ); + return; + } + + const usageProvider = new FunctionUsageProvider(context.extensionUri); + await usageProvider.show(functionName, editor.document.uri.fsPath, position); + } catch (error) { + console.error("Error in visualizeFunctionUsage:", error); + vscode.window.showErrorMessage(`Failed to visualize function usage: ${error}`); + } + }), + ); } diff --git a/src/view/FunctionUsageProvider.ts b/src/view/FunctionUsageProvider.ts new file mode 100644 index 0000000..8aa32c0 --- /dev/null +++ b/src/view/FunctionUsageProvider.ts @@ -0,0 +1,312 @@ +import * as vscode from "vscode"; +import { CallGraphAnalyzer } from "../core/callgraph/CallGraphAnalyzer"; +import { + CallGraphMermaidGenerator, + CallGraphNodeLocation, +} from "../core/callgraph/CallGraphMermaidGenerator"; +import { EnvironmentDetector } from "../core/utils/EnvironmentDetector"; + +const MERMAID_VERSION = "11.8.0"; +const SVG_PAN_ZOOM_VERSION = "3.6.1"; + +/** + * Shows a reverse call (usage) graph for a single function: every function + * that calls it, recursively up the call chain. Nodes are clickable and jump + * to the corresponding definition. + */ +export class FunctionUsageProvider { + private _panel: vscode.WebviewPanel | undefined; + private _extensionUri: vscode.Uri; + private _disposables: vscode.Disposable[] = []; + private _locations: Record = {}; + + constructor(extensionUri: vscode.Uri) { + this._extensionUri = extensionUri; + } + + public async show( + targetName: string, + targetFile: string, + targetPosition: number, + viewColumn: vscode.ViewColumn = vscode.ViewColumn.Beside + ): Promise { + if (!this._panel) { + const baseOptions = { + enableScripts: true, + localResourceRoots: [this._extensionUri], + retainContextWhenHidden: true, + }; + this._panel = vscode.window.createWebviewPanel( + "codevisualizer.functionUsage", + `Usage: ${targetName}`, + viewColumn, + EnvironmentDetector.getWebviewPanelOptions(baseOptions) + ); + + this._panel.onDidDispose( + () => { + this._panel = undefined; + }, + null, + this._disposables + ); + + this._panel.webview.onDidReceiveMessage( + async (message) => { + if (message.command === "openFunction") { + await this.openFunction(message.payload); + } + }, + null, + this._disposables + ); + } else { + this._panel.title = `Usage: ${targetName}`; + this._panel.reveal(viewColumn); + } + + const webview = this._panel.webview; + webview.html = this.getLoadingHtml(`Analyzing usage of "${targetName}"...`); + + try { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + webview.html = this.getLoadingHtml("No workspace folder found."); + return; + } + const workspaceRoot = workspaceFolders[0].uri.fsPath; + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Finding usages of "${targetName}"...`, + cancellable: false, + }, + async (progress) => { + progress.report({ increment: 0, message: "Scanning files..." }); + + const analyzer = new CallGraphAnalyzer(workspaceRoot); + await analyzer.analyze(); + + progress.report({ increment: 60, message: "Building call graph..." }); + + const graph = analyzer.buildUsageGraph( + targetName, + targetFile, + targetPosition + ); + + const { mermaid, locations } = new CallGraphMermaidGenerator().generate( + graph + ); + this._locations = locations; + + progress.report({ increment: 100, message: "Complete!" }); + + const callerCount = graph.nodes.filter((n) => !n.isTarget).length; + webview.html = this.getWebviewContent( + mermaid, + webview, + targetName, + callerCount, + graph.truncated + ); + } + ); + } catch (error) { + console.error("Function usage analysis failed:", error); + const message = + error instanceof Error ? error.message : "An unknown error occurred"; + webview.html = this.getLoadingHtml(`Error: ${message}`); + } + } + + private async openFunction(payload: CallGraphNodeLocation): Promise { + if (!payload || !payload.file) { + return; + } + try { + const doc = await vscode.workspace.openTextDocument( + vscode.Uri.file(payload.file) + ); + const editor = await vscode.window.showTextDocument( + doc, + vscode.ViewColumn.One + ); + const start = doc.positionAt(payload.start); + const end = doc.positionAt(payload.end); + const range = new vscode.Range(start, end); + editor.selection = new vscode.Selection(start, start); + editor.revealRange(range, vscode.TextEditorRevealType.InCenter); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + vscode.window.showErrorMessage(`Could not open function: ${message}`); + } + } + + private getWebviewContent( + mermaidCode: string, + webview: vscode.Webview, + targetName: string, + callerCount: number, + truncated: boolean + ): string { + const nonce = this.getNonce(); + const theme = + vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark + ? "dark" + : "default"; + + const summary = + callerCount === 0 + ? `No callers found for "${targetName}" in this workspace.` + : `${callerCount} function${callerCount === 1 ? "" : "s"} (directly or transitively) call "${targetName}".`; + const truncatedNote = truncated + ? ' (graph truncated — too many callers)' + : ""; + + return ` + + + + + + Function Usage + + + + + + +
+
${mermaidCode.replace(/<\/script>/gi, "<\\/script>")}
+
+
${mermaidCode.replace(//g, ">")}
+ + + + `; + } + + private getLoadingHtml(message: string): string { + return ` + + +

${this.escapeHtml(message)}

`; + } + + private escapeHtml(text: string): string { + const map: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }; + return text.replace(/[&<>"']/g, (m) => map[m]); + } + + private getNonce(): string { + let text = ""; + const possible = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + for (let i = 0; i < 32; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; + } + + public dispose(): void { + if (this._panel) { + this._panel.dispose(); + } + while (this._disposables.length) { + this._disposables.pop()?.dispose(); + } + } +} From 5cae4ff1ae187220af6fc00fa5d250f1aae3b678 Mon Sep 17 00:00:00 2001 From: Mo Doaie Date: Wed, 1 Jul 2026 14:00:03 +0100 Subject: [PATCH 2/4] feat(view): add drill-in flowcharts for function usage graphs * Keep usage graphs and flowcharts in separate panels so callers can be explored without losing context. * Clean up command labels and menus to surface the new navigation flow more clearly. --- README.md | 41 ++- package.json | 75 +++- src/core/callgraph/CallGraphAnalyzer.ts | 8 +- .../callgraph/CallGraphMermaidGenerator.ts | 8 +- src/view/BaseFlowchartProvider.ts | 341 +++++++++++++++--- src/view/FunctionFlowPopupProvider.ts | 108 ++++++ src/view/FunctionUsageProvider.ts | 131 ++++++- 7 files changed, 640 insertions(+), 72 deletions(-) create mode 100644 src/view/FunctionFlowPopupProvider.ts diff --git a/README.md b/README.md index 1c2aedc..cab7f49 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ This extension leverages cutting-edge technologies to deliver fast, accurate cod > **Note on Language Support:** > - **Function-Level Flowcharts**: Supports Python, TypeScript/JavaScript, Java, C++, C, Rust, Go, and PHP > - **Codebase Dependency Visualization**: Currently supports TypeScript/JavaScript, Python, and PHP (more languages coming soon) +> - **Function Usage Graphs**: Currently supports TypeScript/JavaScript only > - **AI-Powered Features**: Available only for Function-Level Flowcharts #### Function-Level Flowchart Generation @@ -119,6 +120,16 @@ Analyze and visualize your entire codebase structure, revealing module dependenc - **Interactive Navigation**: Zoom, pan, and explore even the largest dependency graphs smoothly - **Folder Hierarchy**: Smart subgraphs organized by your directory structure +#### Function Usage Graphs + +Trace where a TypeScript/JavaScript function is called across the workspace with an interactive reverse call graph. + +**Capabilities:** +- **Caller Graphs**: Visualize direct and transitive callers for the function under the cursor +- **Code Navigation**: Click graph nodes to jump to caller definitions +- **Drill Into Code Flow**: Use the code-flow icon on a caller node to open that function's flowchart without leaving the usage graph +- **Multi-Window Workflow**: Keep usage graphs and drilled-in flowcharts in linked VS Code editor groups for side-by-side exploration + #### AI-Powered Features (Function Flowcharts) **Note:** AI features enhance function-level flowcharts only, making complex logic instantly readable. @@ -161,7 +172,7 @@ Get CodeVisualizer up and running in your VS Code environment in just a few clic - Or use Ollama for completely local AI processing 4. **Start Visualizing** - - Right-click any function → "CodeVisualizer: Open flowchart in new window" + - Right-click any function → "Open Flowchart in New Window" - Right-click any folder → "Visualize Codebase Flow"

(back to top)

@@ -173,7 +184,7 @@ Get CodeVisualizer up and running in your VS Code environment in just a few clic 1. Open any supported source file in VS Code 2. Right-click in the editor -3. Select **"CodeVisualizer: Open flowchart in new window"** +3. Select **"Open Flowchart in New Window"** - Alternatively, use Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`) and type "CodeVisualizer" 4. Explore the interactive flowchart: - Click nodes to jump to corresponding code @@ -192,6 +203,16 @@ Get CodeVisualizer up and running in your VS Code environment in just a few clic - Identify circular dependencies - Understand your project architecture at a glance +### Visualizing Function Usage + +1. Open a TypeScript/JavaScript source file in VS Code +2. Place the cursor inside a named function +3. Right-click in the editor and select **"Visualize Function Usage"** +4. Explore the usage graph: + - Click a caller node to jump to its definition + - Click the code-flow icon on a caller node to open that caller's function flowchart + - Move or split the code-flow panel to keep usage and flow views side by side + ### AI-Enhanced Labels (Function Flowcharts) 1. Enable AI labels in Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`) and type "CodeVisualizer: Enable AI Labels" @@ -226,6 +247,14 @@ Get CodeVisualizer up and running in your VS Code environment in just a few clic **Planned Support:** Java, C++, C, Rust, Go dependency analysis coming in future releases. +### Function Usage Graphs + +| Language | Status | File Extensions | +|----------|--------|----------------| +| TypeScript/JavaScript | Supported | `.ts`, `.tsx`, `.js`, `.jsx`, `.mts`, `.cts`, `.mjs`, `.cjs` | + +**Planned Support:** Function usage graphs for additional languages are planned for future releases. +

(back to top)

@@ -249,6 +278,14 @@ Get CodeVisualizer up and running in your VS Code environment in just a few clic 6. **Visualization**: Generates Mermaid flowchart with color-coded nodes and edges 7. **Rendering**: Displays an interactive graph with zoom, pan, and navigation features +### Function Usage Analysis Pipeline + +1. **Workspace Scan**: Finds supported TypeScript/JavaScript source files +2. **Function Indexing**: Extracts named functions, methods, and their call sites +3. **Reverse Graph Building**: Builds a caller graph for the function under the cursor +4. **Visualization**: Renders an interactive Mermaid graph with node navigation +5. **Drill-In Flowcharts**: Opens caller function flowcharts from usage graph nodes for deeper inspection + ### AI Label Generation (Function Flowcharts) 1. **Extraction**: Extracts node labels from generated Mermaid code diff --git a/package.json b/package.json index ae1e6d9..ba9ae54 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codevisualizer", "displayName": "CodeVisualizer", - "version": "1.0.9", + "version": "1.1.0", "publisher": "ducphamngoc", "description": "Real-time interactive flowcharts for your code", "repository": { @@ -74,81 +74,111 @@ "commands": [ { "command": "codevisualizer.generateFlowchart", - "title": "CodeVisualizer: Generate Flowchart", + "title": "Generate Flowchart", + "category": "CodeVisualizer", "icon": "$(graph)" }, { "command": "codevisualizer.openFlowchartInPanel", - "title": "CodeVisualizer: Open Flowchart in New Window", + "title": "Open Flowchart in New Window", + "category": "CodeVisualizer", "icon": "$(open-preview)" }, { "command": "codevisualizer.openFlowchartToSide", - "title": "CodeVisualizer: Open Flowchart to the Side", + "title": "Open Flowchart to the Side", + "category": "CodeVisualizer", "icon": "$(split-horizontal)" }, { "command": "codevisualizer.openFlowchartInNewColumn", - "title": "CodeVisualizer: Open Flowchart in New Column", + "title": "Open Flowchart in New Column", + "category": "CodeVisualizer", "icon": "$(split-vertical)" }, { "command": "codevisualizer.maximizeFlowchartPanel", - "title": "CodeVisualizer: Maximize Flowchart Panel", + "title": "Maximize Flowchart Panel", + "category": "CodeVisualizer", "icon": "$(screen-full)" }, { "command": "codevisualizer.refreshFlowchart", - "title": "CodeVisualizer: Refresh Flowchart", + "title": "Refresh Flowchart", + "category": "CodeVisualizer", "icon": "$(refresh)" }, { "command": "codevisualizer.exportFlowchart", - "title": "CodeVisualizer: Export Flowchart", + "title": "Export Flowchart", + "category": "CodeVisualizer", "icon": "$(export)" }, { "command": "codevisualizer.llm.enableLabels", - "title": "CodeVisualizer: Enable AI Labels", + "title": "Enable AI Labels", + "category": "CodeVisualizer", "icon": "$(sparkle)" }, { "command": "codevisualizer.llm.changeModel", - "title": "CodeVisualizer: Change AI Model", + "title": "Change AI Model", + "category": "CodeVisualizer", "icon": "$(settings-gear)" }, { "command": "codevisualizer.llm.resetCache", - "title": "CodeVisualizer: Reset AI Cache", + "title": "Reset AI Cache", + "category": "CodeVisualizer", "icon": "$(trash)" }, { "command": "codevisualizer.visualizeCodebase", "title": "Visualize Codebase Flow", + "category": "CodeVisualizer", "icon": "$(organization)" }, { "command": "codevisualizer.visualizeFunctionUsage", - "title": "CodeVisualizer: Visualize Function Usage", + "title": "Visualize Function Usage", + "category": "CodeVisualizer", "icon": "$(references)" } ], + "submenus": [ + { + "id": "codevisualizer.editorContextMenu", + "label": "Code Visualizer", + "icon": "media/icon.svg" + }, + { + "id": "codevisualizer.explorerContextMenu", + "label": "Code Visualizer", + "icon": "media/icon.svg" + } + ], "menus": { "editor/context": [ { - "command": "codevisualizer.generateFlowchart", - "when": "editorTextFocus && editorHasSelection", + "submenu": "codevisualizer.editorContextMenu", + "when": "editorTextFocus", "group": "navigation@1" + } + ], + "codevisualizer.editorContextMenu": [ + { + "command": "codevisualizer.generateFlowchart", + "when": "editorHasSelection", + "group": "1_flowchart@1" }, { "command": "codevisualizer.openFlowchartInPanel", - "when": "editorTextFocus", - "group": "navigation@2" + "group": "1_flowchart@2" }, { "command": "codevisualizer.visualizeFunctionUsage", - "when": "editorTextFocus && editorLangId =~ /^(typescript|typescriptreact|javascript|javascriptreact)$/", - "group": "navigation@3" + "when": "editorLangId =~ /^(typescript|typescriptreact|javascript|javascriptreact)$/", + "group": "1_flowchart@3" } ], "view/title": [ @@ -199,10 +229,17 @@ ], "explorer/context": [ { - "command": "codevisualizer.visualizeCodebase", + "submenu": "codevisualizer.explorerContextMenu", "when": "explorerResourceIsFolder", "group": "navigation@1" } + ], + "codevisualizer.explorerContextMenu": [ + { + "command": "codevisualizer.visualizeCodebase", + "when": "explorerResourceIsFolder", + "group": "1_flowchart@1" + } ] }, "configuration": { diff --git a/src/core/callgraph/CallGraphAnalyzer.ts b/src/core/callgraph/CallGraphAnalyzer.ts index 108b0fb..492e0d4 100644 --- a/src/core/callgraph/CallGraphAnalyzer.ts +++ b/src/core/callgraph/CallGraphAnalyzer.ts @@ -270,8 +270,12 @@ export class CallGraphAnalyzer { } private sanitizeId(name: string): string { - const cleaned = name.replace(/[^A-Za-z0-9_]/g, "_"); - return /^[A-Za-z_]/.test(cleaned) ? cleaned : `fn_${cleaned}`; + const cleaned = + name + .replace(/[^A-Za-z0-9_]/g, "_") + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, "") || "anonymous"; + return `fn_${cleaned}`; } private async getAllSupportedFiles(): Promise { diff --git a/src/core/callgraph/CallGraphMermaidGenerator.ts b/src/core/callgraph/CallGraphMermaidGenerator.ts index 4f55108..6bfb020 100644 --- a/src/core/callgraph/CallGraphMermaidGenerator.ts +++ b/src/core/callgraph/CallGraphMermaidGenerator.ts @@ -59,16 +59,18 @@ export class CallGraphMermaidGenerator { private renderLabel(node: UsageGraphNode): string { const second = node.relativePath - ? `${this.escape(node.relativePath)}:${node.line}` + ? `${this.escape(node.relativePath.replace(/\\/g, "/"))}:${node.line}` : "definition not found"; - return `${this.escape(node.name)}
${second}`; + return `${this.escape(node.name)} - ${second}`; } private escape(text: string): string { return text + .replace(/\\/g, "\\\\") .replace(/"/g, "#quot;") .replace(//g, "#62;") - .replace(/\n/g, " "); + .replace(/`/g, "#96;") + .replace(/\r?\n/g, " "); } } diff --git a/src/view/BaseFlowchartProvider.ts b/src/view/BaseFlowchartProvider.ts index 944961a..1572d72 100644 --- a/src/view/BaseFlowchartProvider.ts +++ b/src/view/BaseFlowchartProvider.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; import { analyzeCode } from "../core/analyzer"; -import { LocationMapEntry } from "../ir/ir"; +import { LocationMapEntry, NodeType } from "../ir/ir"; import { EnhancedMermaidGenerator } from "../core/EnhancedMermaidGenerator"; import { LLMManager } from "../core/llm/LLMManager"; import { getExtensionContext } from "../core/llm/LLMContext"; @@ -62,6 +62,11 @@ export type UserInteractionEndMessage = { payload: {}; }; +export type DrillIntoFunctionMessage = { + command: "drillIntoFunction"; + payload: { start: number; end: number }; +}; + export type WebviewMessage = | HighlightCodeMessage | ExportMessage @@ -72,7 +77,16 @@ export type WebviewMessage = | DisableLLMLabelsMessage | SetupLLMMessage | UserInteractionStartMessage - | UserInteractionEndMessage; + | UserInteractionEndMessage + | DrillIntoFunctionMessage; + +/** Node types that represent a call to another function/method worth drilling into. */ +const DRILLABLE_NODE_TYPES = new Set([ + NodeType.FUNCTION_CALL, + NodeType.METHOD_CALL, + NodeType.MACRO_CALL, + NodeType.SUBROUTINE, +]); export interface FlowchartViewContext { isPanel: boolean; @@ -213,12 +227,25 @@ export abstract class BaseFlowchartProvider { switch (message.command) { case "highlightCode": { const { start, end } = message.payload; - const editor = vscode.window.activeTextEditor; - if (editor) { - const startPos = editor.document.positionAt(start); - const endPos = editor.document.positionAt(end); + // Use the document this flowchart was generated from (rather than + // whatever editor happens to be active) so that clicking a box in a + // popup flowchart always navigates to the right file. + const document = this._currentDocument; + if (document) { + const startPos = document.positionAt(start); + const endPos = document.positionAt(end); const range = new vscode.Range(startPos, endPos); + // Reuse the already-active editor if it's already showing this + // document (the common case) so we don't disturb its view column; + // otherwise (e.g. clicking a box inside a popup) open it fresh. + const activeEditor = vscode.window.activeTextEditor; + const editor = + activeEditor && activeEditor.document === document + ? activeEditor + : await vscode.window.showTextDocument(document, { + viewColumn: vscode.ViewColumn.One, + }); editor.selection = new vscode.Selection(range.start, range.end); editor.revealRange(range, vscode.TextEditorRevealType.InCenter); } @@ -285,6 +312,60 @@ export abstract class BaseFlowchartProvider { this._userInteracting = false; break; } + + case "drillIntoFunction": { + await this.drillIntoFunction(message.payload); + break; + } + } + } + + /** + * Resolves the definition of the call at the given offset (using the + * workspace's language providers) and opens a standalone popup showing + * that callee's own code flow, without disturbing this flowchart. + */ + private async drillIntoFunction(payload: { + start: number; + end: number; + }): Promise { + const document = this._currentDocument; + if (!document) { + return; + } + try { + const position = document.positionAt(payload.start); + const results = await vscode.commands.executeCommand< + (vscode.Location | vscode.LocationLink)[] | undefined + >("vscode.executeDefinitionProvider", document.uri, position); + + if (!results || results.length === 0) { + vscode.window.showInformationMessage( + "Could not resolve a definition for this call." + ); + return; + } + + const first = results[0]; + const targetUri = "targetUri" in first ? first.targetUri : first.uri; + const targetRange = + "targetSelectionRange" in first && first.targetSelectionRange + ? first.targetSelectionRange + : "targetRange" in first + ? first.targetRange + : first.range; + + // Lazily required to avoid a circular import: FunctionFlowPopupProvider + // extends BaseFlowchartProvider. + const { FunctionFlowPopupProvider } = require("./FunctionFlowPopupProvider") as + typeof import("./FunctionFlowPopupProvider"); + const popup = new FunctionFlowPopupProvider(this._extensionUri); + await popup.showFor(targetUri, targetRange); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + vscode.window.showErrorMessage( + `Could not open function definition: ${message}` + ); } } @@ -303,6 +384,13 @@ export abstract class BaseFlowchartProvider { return line.startsWith("click ", index); } + private static sanitizeMermaidNodeId(nodeId: string): string { + return nodeId + .replace(/\s+/g, "_") + .replace(/[^\w]/g, "") + .replace(/_+/g, "_"); + } + private extractClickHandlers(src: string): string[] { if (this._cachedClickHandlers && this._cachedClickHandlers.source === src) { return this._cachedClickHandlers.lines; @@ -429,7 +517,7 @@ export abstract class BaseFlowchartProvider { const webview = this.getWebview(); if (webview) { // Sanitize nodeId before sending to webview to match sanitized IDs in the diagram - const sanitizedNodeId = nodeId ? nodeId.replace(/\s+/g, '_').replace(/[^\w-]/g, '_').replace(/_+/g, '_') : null; + const sanitizedNodeId = nodeId ? BaseFlowchartProvider.sanitizeMermaidNodeId(nodeId) : null; webview.postMessage({ command: "highlightNode", payload: { nodeId: sanitizedNodeId }, @@ -484,8 +572,29 @@ export abstract class BaseFlowchartProvider { return; } - const position = editor.document.offsetAt(editor.selection.active); - const document = editor.document; + await this.updateViewForDocument(editor.document, editor.selection.active); + } + + protected async updateViewForDocument( + document: vscode.TextDocument, + activePosition: vscode.Position + ): Promise { + const webview = this.getWebview(); + if (!webview) { + return; + } + + // Prevent updates when user is interacting with the webview + if (this._userInteracting) { + return; + } + + // Prevent multiple simultaneous updates + if (this._isUpdating) { + return; + } + + const position = document.offsetAt(activePosition); // Check if we need to update - avoid unnecessary regeneration const shouldUpdate = this._shouldUpdate(document, position); @@ -493,7 +602,7 @@ export abstract class BaseFlowchartProvider { // Just update highlighting if we're still in the same function if ( this._currentFunctionRange && - this._currentFunctionRange.contains(editor.selection.active) + this._currentFunctionRange.contains(activePosition) ) { const entry = this._locationMap.find( (e) => position >= e.start && position <= e.end @@ -550,12 +659,31 @@ export abstract class BaseFlowchartProvider { // Calculate code metrics const codeMetrics = MetricsAnalyzer.analyze(flowchartIR, document.getText()); + // Node ids are sanitized in-place by the generator above, so this map's + // keys line up with the ids that end up in the rendered SVG. + const callNodeLocations: Record = {}; + const nodeLocations: Record = {}; + for (const entry of flowchartIR.locationMap) { + nodeLocations[BaseFlowchartProvider.sanitizeMermaidNodeId(entry.nodeId)] = { + start: entry.start, + end: entry.end, + }; + } + for (const node of flowchartIR.nodes) { + if (node.nodeType && DRILLABLE_NODE_TYPES.has(node.nodeType) && node.location) { + callNodeLocations[node.id] = { + start: node.location.start, + end: node.location.end, + }; + } + } + const ctx = getExtensionContext(); const availability = ctx ? await LLMManager.getAvailability(ctx) : { enabled: false, provider: "openai", model: "" }; - this.setWebviewHtml(this.getWebviewContent(mermaidCode, this.getNonce(), availability, codeMetrics)); + this.setWebviewHtml(this.getWebviewContent(mermaidCode, this.getNonce(), availability, codeMetrics, callNodeLocations, nodeLocations)); // After updating the view, immediately highlight the node for the current cursor - const offset = editor.document.offsetAt(editor.selection.active); + const offset = document.offsetAt(activePosition); const entry = this._locationMap.find( (e) => offset >= e.start && offset <= e.end ); @@ -614,7 +742,9 @@ export abstract class BaseFlowchartProvider { flowchartSyntax: string, nonce: string, llmAvailability?: { enabled: boolean; provider: string; model: string }, - codeMetrics?: CodeMetrics + codeMetrics?: CodeMetrics, + callNodeLocations?: Record, + nodeLocations?: Record ): string { const theme = vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark @@ -723,6 +853,25 @@ export abstract class BaseFlowchartProvider { #mermaid-source { display: none; } + /* Drill-in icon shown on function/method-call nodes */ + .drill-icon { cursor: pointer; } + .drill-icon .drill-icon-bg { + fill: var(--vscode-button-background); + stroke: var(--vscode-button-border, var(--vscode-panel-border)); + stroke-width: 1px; + opacity: 0.85; + } + .drill-icon:hover .drill-icon-bg { + fill: var(--vscode-button-hoverBackground); + opacity: 1; + } + .drill-icon .drill-icon-glyph { + font-size: 13px; + fill: var(--vscode-button-foreground); + pointer-events: none; + user-select: none; + } + /* Other styles for controls */ ${ this.getStylesForControls(context) } @@ -737,13 +886,32 @@ export abstract class BaseFlowchartProvider {