From ffd725031f95992c9ad1c0903bd7cef7b46bbd09 Mon Sep 17 00:00:00 2001 From: mununki Date: Thu, 30 Apr 2026 01:39:06 +0900 Subject: [PATCH] Improve dependency analysis performance --- .gitignore | 3 +- CHANGELOG.md | 10 ++ lib/dependency_graph.ml | 68 ++++++-- test/test_namespace.ml | 22 +++ vscode-rescriptdep/src/extension.ts | 231 +++++++++++++++++++--------- 5 files changed, 249 insertions(+), 85 deletions(-) diff --git a/.gitignore b/.gitignore index 82b2fd2..8ecbc72 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ _build +_opam deps.dot deps.json .DS_Store -.rescriptdep_cache.marshal \ No newline at end of file +.rescriptdep_cache.marshal diff --git a/CHANGELOG.md b/CHANGELOG.md index b83371b..8789018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Added regression coverage for dependency graph dependent-index updates + +### Changed +- Improved dependency graph performance by maintaining a reverse dependent index instead of scanning the full graph for each dependent lookup +- Reduced VS Code inline value usage analysis work with debounced requests, short-lived result caching, and JSON output parsing + +### Fixed +- Prevented stale VS Code inline value usage decorations from appearing after source edits or outdated CLI responses +- Improved inline value usage cache invalidation when ReScript build artifacts change ## [0.1.2] - 2026-04-22 ### Fixed diff --git a/lib/dependency_graph.ml b/lib/dependency_graph.ml index ac7fdd2..162304e 100644 --- a/lib/dependency_graph.ml +++ b/lib/dependency_graph.ml @@ -10,17 +10,66 @@ type module_metadata = { path : string option } (* Graph representation including module metadata *) type t = { dependencies : string list StringMap.t; + dependents : string list StringMap.t; metadata : module_metadata StringMap.t; } (* Empty graph *) -let empty = { dependencies = StringMap.empty; metadata = StringMap.empty } +let empty = + { + dependencies = StringMap.empty; + dependents = StringMap.empty; + metadata = StringMap.empty; + } + +let add_dependent dependents dependency module_name = + let current = + try StringMap.find dependency dependents with Not_found -> [] + in + let updated = + if List.mem module_name current then current else module_name :: current + in + StringMap.add dependency updated dependents + +let remove_dependent dependents dependency module_name = + match StringMap.find_opt dependency dependents with + | None -> dependents + | Some current -> + let updated = List.filter (fun m -> m <> module_name) current in + if updated = [] then StringMap.remove dependency dependents + else StringMap.add dependency updated dependents + +let build_dependents dependencies = + StringMap.fold + (fun module_name deps dependents -> + List.fold_left + (fun acc dep -> add_dependent acc dep module_name) + dependents deps) + dependencies StringMap.empty + +let make dependencies metadata = + { dependencies; dependents = build_dependents dependencies; metadata } (* Add a module and its dependencies to the graph *) let add graph module_name dependencies path = let metadata = { path } in + let dependencies = List.sort_uniq String.compare dependencies in + let dependents = + match StringMap.find_opt module_name graph.dependencies with + | None -> graph.dependents + | Some old_dependencies -> + List.fold_left + (fun acc dep -> remove_dependent acc dep module_name) + graph.dependents old_dependencies + in + let dependents = + List.fold_left + (fun acc dep -> add_dependent acc dep module_name) + dependents dependencies + in { dependencies = StringMap.add module_name dependencies graph.dependencies; + dependents; metadata = StringMap.add module_name metadata graph.metadata; } @@ -53,9 +102,9 @@ let get_modules graph = StringMap.bindings graph.dependencies |> List.map fst (* Find direct dependents of a module (modules that depend on it) *) let find_dependents graph module_name = - StringMap.fold - (fun m deps acc -> if List.mem module_name deps then m :: acc else acc) - graph.dependencies [] + match StringMap.find_opt module_name graph.dependents with + | Some dependents -> List.sort_uniq String.compare dependents + | None -> [] (* Check if a cycle exists in the dependency graph starting from a module *) let has_cycle graph start_module = @@ -274,14 +323,14 @@ let create_filtered_graph graph = let filtered_deps = create_subgraph_preserve_deps graph project_modules in (* Create a new graph with the filtered dependencies and original metadata *) - { dependencies = filtered_deps; metadata = graph.metadata } + make filtered_deps graph.metadata (* Create a focused graph centered around a specific module *) let create_focused_graph graph center_module = (* Check if the module exists *) if not (StringMap.mem center_module graph.dependencies) then (* Return empty graph if module doesn't exist *) - { dependencies = StringMap.empty; metadata = StringMap.empty } + empty else (* 1. Get the center module dependencies *) let center_deps = get_dependencies graph center_module in @@ -295,12 +344,7 @@ let create_focused_graph graph center_module = (* 3. Start building a new graph with just the center module *) (* Preserve metadata - add center module *) let center_metadata = get_module_metadata graph center_module in - let result = - { - dependencies = StringMap.singleton center_module center_deps; - metadata = StringMap.singleton center_module center_metadata; - } - in + let result = add empty center_module center_deps center_metadata.path in (* 4. Add dependency modules and their metadata *) let result = diff --git a/test/test_namespace.ml b/test/test_namespace.ml index c4ae867..1acff55 100644 --- a/test/test_namespace.ml +++ b/test/test_namespace.ml @@ -92,6 +92,27 @@ let test_focus_with_namespaced_input () = (modules = [ "Dep01"; "NS_alias" ]) "Expected focus mode to resolve namespaced input to the existing module" +let test_dependency_graph_dependents_index_updates () = + let graph = + Rescriptdep.Dependency_graph.empty + |> fun graph -> + Rescriptdep.Dependency_graph.add graph "Consumer" [ "Dep01" ] None + |> fun graph -> Rescriptdep.Dependency_graph.add graph "Dep01" [] None + in + assert_true + (Rescriptdep.Dependency_graph.find_dependents graph "Dep01" = [ "Consumer" ]) + "Expected dependents index to include direct dependent"; + + let graph = + Rescriptdep.Dependency_graph.add graph "Consumer" [ "Dep02" ] None + in + assert_true + (Rescriptdep.Dependency_graph.find_dependents graph "Dep01" = []) + "Expected dependents index to drop stale dependencies after module update"; + assert_true + (Rescriptdep.Dependency_graph.find_dependents graph "Dep02" = [ "Consumer" ]) + "Expected dependents index to include updated dependency" + let test_batch_check_matches_canonical_and_qualified_ast_entries () = let source_file = Filename.temp_file "rescriptdep-namespace" ".res" in let ast_path = Filename.remove_extension source_file ^ ".ast" in @@ -135,5 +156,6 @@ let () = test_is_valid_module_name (); test_extract_dependencies_from_namespaced_imports (); test_focus_with_namespaced_input (); + test_dependency_graph_dependents_index_updates (); test_batch_check_matches_canonical_and_qualified_ast_entries (); print_endline "Namespace handling tests passed" diff --git a/vscode-rescriptdep/src/extension.ts b/vscode-rescriptdep/src/extension.ts index 4de4de5..6274ee6 100644 --- a/vscode-rescriptdep/src/extension.ts +++ b/vscode-rescriptdep/src/extension.ts @@ -21,6 +21,135 @@ let isValueUsageCountEnabled: boolean = true; // Store the current usage count decoration let usageCountDecoration: vscode.TextEditorDecorationType | undefined = undefined; let lastDecoratedLine: number | undefined = undefined; +let usageCountDebounceTimer: ReturnType | undefined = undefined; +let usageCountRequestSerial = 0; + +const VALUE_USAGE_DEBOUNCE_MS = 350; +const VALUE_USAGE_CACHE_TTL_MS = 30000; +const usageCountCache = new Map(); + +function clearUsageCountDecoration(editor?: vscode.TextEditor) { + if (usageCountDecoration) { + editor?.setDecorations(usageCountDecoration, []); + usageCountDecoration.dispose(); + usageCountDecoration = undefined; + } + lastDecoratedLine = undefined; +} + +function setUsageCountDecoration(editor: vscode.TextEditor, line: number, usageCount: string) { + clearUsageCountDecoration(editor); + + usageCountDecoration = vscode.window.createTextEditorDecorationType({ + isWholeLine: true, + after: { + contentText: `Used ${usageCount} times`, + color: '#b5cea8', + margin: '0 0 0 8px', + fontStyle: 'italic', + }, + }); + editor.setDecorations(usageCountDecoration, [ + { range: new vscode.Range(line, 0, line, 0) } + ]); + lastDecoratedLine = line; +} + +function parseValueUsageCount(output: string): string { + try { + const parsed = JSON.parse(output) as { modules?: Array<{ count?: number }> }; + if (!Array.isArray(parsed.modules)) { + return '?'; + } + const total = parsed.modules.reduce((sum, module) => sum + (typeof module.count === 'number' ? module.count : 0), 0); + return String(total); + } catch (error) { + console.log('[Bibimbob] Could not parse value usage JSON output:', error, output); + return '?'; + } +} + +async function updateValueUsageDecoration( + context: vscode.ExtensionContext, + editor: vscode.TextEditor, + line: number, + valueName: string, + documentVersion: number +) { + const document = editor.document; + if (document.languageId !== 'rescript' || !document.fileName.endsWith('.res') || line >= document.lineCount) { + return; + } + + if (document.version !== documentVersion || editor.selection.active.line !== line) { + return; + } + + const lineText = document.lineAt(line).text; + const letLineRegex = /^\s*let\s+([a-zA-Z_][a-zA-Z0-9_]*)\b[^=]*=/; + const match = letLineRegex.exec(lineText); + if (!match || match[1] !== valueName) { + clearUsageCountDecoration(editor); + return; + } + + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) { + console.log('[Bibimbob] No workspace folders found'); + return; + } + + const fileName = document.fileName; + const workspaceRoot = workspaceFolders[0].uri.fsPath; + const projectRoot = await findProjectRootForFile(fileName, workspaceRoot) || workspaceRoot; + const bsDir = path.join(projectRoot, 'lib', 'bs'); + const sourceMtime = fs.existsSync(fileName) ? fs.statSync(fileName).mtimeMs : 0; + const bsMtime = fs.existsSync(bsDir) ? fs.statSync(bsDir).mtimeMs : 0; + const cacheKey = [projectRoot, fileName, sourceMtime, bsMtime, documentVersion, line + 1, valueName].join(':'); + const cached = usageCountCache.get(cacheKey); + const now = Date.now(); + + if (cached && now - cached.timestamp < VALUE_USAGE_CACHE_TTL_MS) { + setUsageCountDecoration(editor, line, cached.count); + return; + } + + const requestSerial = ++usageCountRequestSerial; + const moduleName = path.basename(fileName, '.res'); + const lineNumber = line + 1; + const args = ['-m', moduleName, '-vb', valueName, '-vl', String(lineNumber), '-f', 'json', bsDir]; + + let usageCount = '?'; + try { + const cliPath = await findRescriptDepCLI(context); + console.log('[Bibimbob] Running CLI:', cliPath, args); + const result = await runRescriptDep(cliPath, args); + usageCount = parseValueUsageCount(result); + usageCountCache.set(cacheKey, { count: usageCount, timestamp: Date.now() }); + } catch (err) { + usageCount = 'error'; + console.log('[Bibimbob] CLI call failed:', err); + } + + if ( + requestSerial !== usageCountRequestSerial + || vscode.window.activeTextEditor !== editor + || document.version !== documentVersion + || editor.selection.active.line !== line + || line >= document.lineCount + ) { + return; + } + + const currentLineText = document.lineAt(line).text; + const currentMatch = letLineRegex.exec(currentLineText); + if (!currentMatch || currentMatch[1] !== valueName) { + return; + } + + setUsageCountDecoration(editor, line, usageCount); + console.log('[Bibimbob] Decoration set for line', line, '(after let declaration)'); +} export function activate(context: vscode.ExtensionContext) { // Command for full dependency graph @@ -41,8 +170,15 @@ export function activate(context: vscode.ExtensionContext) { // Command to toggle value usage count display let toggleValueUsageCountCommand = vscode.commands.registerCommand(TOGGLE_VALUE_USAGE_COUNT, () => { isValueUsageCountEnabled = !isValueUsageCountEnabled; + if (!isValueUsageCountEnabled) { + if (usageCountDebounceTimer) { + clearTimeout(usageCountDebounceTimer); + usageCountDebounceTimer = undefined; + } + usageCountRequestSerial++; + clearUsageCountDecoration(vscode.window.activeTextEditor); + } vscode.window.showInformationMessage(`Value usage count display is now ${isValueUsageCountEnabled ? 'enabled' : 'disabled'}.`); - // Optionally trigger update/decorate here in later steps }); context.subscriptions.push(fullGraphCommand); @@ -50,8 +186,15 @@ export function activate(context: vscode.ExtensionContext) { context.subscriptions.push(unusedModulesCommand); context.subscriptions.push(toggleValueUsageCountCommand); + context.subscriptions.push(vscode.workspace.onDidChangeTextDocument(event => { + if (event.document.languageId === 'rescript') { + usageCountCache.clear(); + usageCountRequestSerial++; + } + })); + // Listen for cursor movement in .res files - vscode.window.onDidChangeTextEditorSelection(async (event) => { + context.subscriptions.push(vscode.window.onDidChangeTextEditorSelection((event) => { if (!isValueUsageCountEnabled) { return; } @@ -74,81 +217,25 @@ export function activate(context: vscode.ExtensionContext) { // Remove previous decoration if cursor moved away if (usageCountDecoration && (lastDecoratedLine !== position.line || !match)) { console.log('[Bibimbob] Removing previous decoration'); - editor.setDecorations(usageCountDecoration, []); - usageCountDecoration.dispose(); - usageCountDecoration = undefined; - lastDecoratedLine = undefined; + clearUsageCountDecoration(editor); + } + + if (usageCountDebounceTimer) { + clearTimeout(usageCountDebounceTimer); + usageCountDebounceTimer = undefined; } if (match) { - // Extract only the let name to use as valueName - const fileName = document.fileName; - const moduleName = path.basename(fileName, '.res'); const valueName = match[1]; - - // Find CLI path and bsDir - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders) { - console.log('[Bibimbob] No workspace folders found'); - return; - } - const context = { extensionPath: vscode.extensions.getExtension('mununki.vscode-bibimbob')?.extensionPath || '' } as vscode.ExtensionContext; - const cliPath = await findRescriptDepCLI(context); - const workspaceRoot = workspaceFolders[0].uri.fsPath; - const projectRoot = await findProjectRootForFile(fileName, workspaceRoot) || workspaceRoot; - const bsDir = path.join(projectRoot, 'lib', 'bs'); - - // Run CLI to get usage count (add -f dot flag) - // Pass the line number of the let declaration as -vl (1-based) - const lineNumber = position.line + 1; - const args = ['-m', moduleName, '-vb', valueName, '-vl', String(lineNumber), '-f', 'dot', bsDir]; - let usageCount = '?'; - try { - console.log('[Bibimbob] Running CLI:', cliPath, args); - const result = await runRescriptDep(cliPath, args); - console.log('[Bibimbob] CLI output:', result); - // Improved regex: match all [label="...\ncount: N"] - const allCounts = Array.from(result.matchAll(/\[label=\"[^\"]*\\ncount: (\d+)\"\]/g)); - if (allCounts.length > 0) { - allCounts.forEach((m, i) => { - console.log(`[Bibimbob] Matched DOT line #${i + 1}:`, m[0], 'Count:', m[1]); - }); - const total = allCounts.reduce((sum, m) => sum + parseInt(m[1], 10), 0); - usageCount = String(total); - console.log('[Bibimbob] Summed usage count:', usageCount); - } else { - usageCount = '?'; - console.log('[Bibimbob] Could not parse any usage count from DOT output. Full output:', result); - } - } catch (err) { - usageCount = 'error'; - console.log('[Bibimbob] CLI call failed:', err); - } - - // Create and show decoration at the end of the let declaration line - const decoText = `Used ${usageCount} times`; - // Dispose previous decoration if exists - if (usageCountDecoration) { - editor.setDecorations(usageCountDecoration, []); - usageCountDecoration.dispose(); - } - usageCountDecoration = vscode.window.createTextEditorDecorationType({ - isWholeLine: true, - after: { - contentText: decoText, - color: '#b5cea8', - margin: '0 0 0 8px', - fontStyle: 'italic', - }, - }); - const decoLine = position.line; - editor.setDecorations(usageCountDecoration, [ - { range: new vscode.Range(decoLine, 0, decoLine, 0) } - ]); - lastDecoratedLine = position.line; - console.log('[Bibimbob] Decoration set for line', decoLine, '(after let declaration)'); + const documentVersion = document.version; + usageCountDebounceTimer = setTimeout(() => { + usageCountDebounceTimer = undefined; + updateValueUsageDecoration(context, editor, position.line, valueName, documentVersion).catch(error => { + console.log('[Bibimbob] Failed to update value usage decoration:', error); + }); + }, VALUE_USAGE_DEBOUNCE_MS); } - }); + })); } // Helper function to get current module name from active editor