diff --git a/package.json b/package.json index 5f051ad..582db77 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,10 @@ { "command": "vsctags.reloadTags", "title": "vsctags: Reload Tags" + }, + { + "command": "vsctags.showLog", + "title": "vsctags: Show Log" } ] }, diff --git a/src/extension.ts b/src/extension.ts index 8b71419..2b37820 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,6 +1,19 @@ import * as vscode from "vscode"; import * as path from "path"; -import * as fs from "fs"; +import * as fs from "fs/promises"; + +// ---- Output Channel for observability ---- +let log: vscode.OutputChannel; + +function logInfo(msg: string) { + const ts = new Date().toISOString(); + log.appendLine(`[${ts}] ${msg}`); +} + +function logError(msg: string) { + const ts = new Date().toISOString(); + log.appendLine(`[${ts}] ERROR: ${msg}`); +} /** A single parsed ctags entry */ interface CtagsEntry { @@ -12,7 +25,8 @@ interface CtagsEntry { fields: Map; } -function parseCtagsFile(content: string, workspaceRoot: string): CtagsEntry[] { +/** Parse raw tag lines into entries (without resolving pattern line numbers) */ +function parseCtagsLines(content: string): CtagsEntry[] { const entries: CtagsEntry[] = []; const lines = content.split("\n"); for (const line of lines) { @@ -30,10 +44,10 @@ function parseCtagsFile(content: string, workspaceRoot: string): CtagsEntry[] { let pattern = ""; let kind = ""; - let lineNumber = 0; + let lineNumber = -1; // -1 means "needs resolution" const fields = new Map(); - const exCmdEnd = rest.indexOf(';\"'); + const exCmdEnd = rest.indexOf(';"'); if (exCmdEnd !== -1) { pattern = rest.substring(0, exCmdEnd); const afterExCmd = rest.substring(exCmdEnd + 2); @@ -49,56 +63,86 @@ function parseCtagsFile(content: string, workspaceRoot: string): CtagsEntry[] { pattern = rest; } + // Resolve line number from fields or numeric pattern const lineField = fields.get("line"); if (lineField) { lineNumber = Math.max(0, parseInt(lineField, 10) - 1); } else if (/^\d+$/.test(pattern.trim())) { lineNumber = Math.max(0, parseInt(pattern.trim(), 10) - 1); - } else { - lineNumber = resolvePatternLineNumber(pattern, file, workspaceRoot); } + // otherwise lineNumber stays -1 => needs pattern resolution entries.push({ name, file, pattern, kind, lineNumber, fields }); } return entries; } -function resolvePatternLineNumber( - pattern: string, - relativeFile: string, - workspaceRoot: string, -): number { - let searchText = pattern; - if (searchText.startsWith("/^")) { - searchText = searchText.substring(2); - } else if (searchText.startsWith("/")) { - searchText = searchText.substring(1); - } - if (searchText.endsWith("$/")) { - searchText = searchText.substring(0, searchText.length - 2); - } else if (searchText.endsWith("/")) { - searchText = searchText.substring(0, searchText.length - 1); +/** Extract the search text from a ctags /^...$/ pattern */ +function extractSearchText(pattern: string): string { + let s = pattern; + if (s.startsWith("/^")) { s = s.substring(2); } + else if (s.startsWith("/")) { s = s.substring(1); } + if (s.endsWith("$/")) { s = s.substring(0, s.length - 2); } + else if (s.endsWith("/")) { s = s.substring(0, s.length - 1); } + s = s.replace(/\\\//g, "/").replace(/\\\\/g, "\\"); + return s; +} + +/** Resolve pattern-based line numbers by reading source files (async, batched by file) */ +async function resolvePatternLineNumbers(entries: CtagsEntry[], workspaceRoot: string): Promise { + // Group entries that need resolution by file + const byFile = new Map(); + let needsResolution = 0; + for (const entry of entries) { + if (entry.lineNumber === -1) { + needsResolution++; + let list = byFile.get(entry.file); + if (!list) { list = []; byFile.set(entry.file, list); } + list.push(entry); + } } - searchText = searchText.replace(/\\\//g, "/").replace(/\\\\/g, "\\"); + if (needsResolution === 0) { return 0; } - if (searchText.length === 0) { - return 0; - } + logInfo(` Resolving ${needsResolution} pattern-based line numbers across ${byFile.size} files...`); + let resolved = 0; + let fileErrors = 0; - const absPath = path.join(workspaceRoot, relativeFile); - try { - const fileContent = fs.readFileSync(absPath, "utf-8"); - const fileLines = fileContent.split("\n"); - for (let i = 0; i < fileLines.length; i++) { - if (fileLines[i].includes(searchText)) { - return i; + for (const [relFile, fileEntries] of byFile) { + const absPath = path.join(workspaceRoot, relFile); + try { + const fileContent = await fs.readFile(absPath, "utf-8"); + const fileLines = fileContent.split("\n"); + + for (const entry of fileEntries) { + const searchText = extractSearchText(entry.pattern); + if (searchText.length === 0) { + entry.lineNumber = 0; + continue; + } + let found = false; + for (let i = 0; i < fileLines.length; i++) { + if (fileLines[i].includes(searchText)) { + entry.lineNumber = i; + resolved++; + found = true; + break; + } + } + if (!found) { + entry.lineNumber = 0; // fallback + } + } + } catch { + fileErrors++; + for (const entry of fileEntries) { + entry.lineNumber = 0; } } - } catch { - // file not readable } - return 0; + + logInfo(` Resolved ${resolved}/${needsResolution} patterns (${fileErrors} unreadable files)`); + return needsResolution; } type CtagsIndex = Map; @@ -162,25 +206,123 @@ function getWordAtPosition( return document.getText(range); } +/** Format milliseconds into a human-readable string */ +function formatDuration(ms: number): string { + if (ms < 1) { return `${(ms * 1000).toFixed(0)}\u00b5s`; } + if (ms < 1000) { return `${ms.toFixed(1)}ms`; } + return `${(ms / 1000).toFixed(2)}s`; +} + // ---- Extension State ---- let allEntries: CtagsEntry[] = []; let tagIndex: CtagsIndex = new Map(); let wsRoot = ""; +let statusBarItem: vscode.StatusBarItem; +let isLoading = false; -function loadTags(): boolean { +function updateStatusBar() { + if (isLoading) { + statusBarItem.text = "$(sync~spin) vsctags: loading..."; + statusBarItem.tooltip = "Loading tags file..."; + } else if (allEntries.length > 0) { + statusBarItem.text = `$(tag) vsctags: ${allEntries.length}`; + statusBarItem.tooltip = `${allEntries.length} tags loaded\n${tagIndex.size} unique symbols\nClick to reload`; + } else { + statusBarItem.text = "$(tag) vsctags: no tags"; + statusBarItem.tooltip = "No tags file found. Click to reload."; + } + statusBarItem.command = "vsctags.reloadTags"; + statusBarItem.show(); +} + +async function loadTags(): Promise { if (!wsRoot) { return false; } + if (isLoading) { + logInfo("Load already in progress, skipping."); + return false; + } + + isLoading = true; + updateStatusBar(); + const tagsPath = path.join(wsRoot, "tags"); + const totalStart = performance.now(); + try { - const content = fs.readFileSync(tagsPath, "utf-8"); - allEntries = parseCtagsFile(content, wsRoot); - tagIndex = buildIndex(allEntries); - console.log(`[vsctags] Loaded ${allEntries.length} tags from ${tagsPath}`); + // Stage 1: Read file + logInfo("Stage 1/4: Reading tags file..."); + const t1 = performance.now(); + const content = await fs.readFile(tagsPath, "utf-8"); + const fileSizeKB = (Buffer.byteLength(content, "utf-8") / 1024).toFixed(1); + const readTime = performance.now() - t1; + logInfo(` Read ${fileSizeKB} KB in ${formatDuration(readTime)}`); + + // Stage 2: Parse lines + logInfo("Stage 2/4: Parsing tag entries..."); + const t2 = performance.now(); + const entries = parseCtagsLines(content); + const parseTime = performance.now() - t2; + logInfo(` Parsed ${entries.length} entries in ${formatDuration(parseTime)}`); + + // Stage 3: Resolve patterns + logInfo("Stage 3/4: Resolving pattern line numbers..."); + const t3 = performance.now(); + const patternsResolved = await resolvePatternLineNumbers(entries, wsRoot); + const resolveTime = performance.now() - t3; + if (patternsResolved > 0) { + logInfo(` Pattern resolution took ${formatDuration(resolveTime)}`); + } else { + logInfo(` No patterns to resolve (all entries have line numbers)`); + } + + // Stage 4: Build index + logInfo("Stage 4/4: Building symbol index..."); + const t4 = performance.now(); + const index = buildIndex(entries); + const indexTime = performance.now() - t4; + logInfo(` Indexed ${index.size} unique symbols in ${formatDuration(indexTime)}`); + + // Commit + allEntries = entries; + tagIndex = index; + + // Summary + const totalTime = performance.now() - totalStart; + const summary = [ + `--- Load complete ---`, + ` Tags: ${allEntries.length} entries, ${tagIndex.size} unique symbols`, + ` File size: ${fileSizeKB} KB`, + ` Timings:`, + ` Read: ${formatDuration(readTime)}`, + ` Parse: ${formatDuration(parseTime)}`, + ` Resolve: ${formatDuration(resolveTime)}`, + ` Index: ${formatDuration(indexTime)}`, + ` Total: ${formatDuration(totalTime)}`, + ].join("\n"); + logInfo(summary); + + // Collect kind stats + const kindCounts = new Map(); + for (const entry of allEntries) { + const k = entry.kind || "(unknown)"; + kindCounts.set(k, (kindCounts.get(k) || 0) + 1); + } + const kindLines = Array.from(kindCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([k, c]) => ` ${k}: ${c}`); + logInfo(` Tag kinds:\n${kindLines.join("\n")}`); + + isLoading = false; + updateStatusBar(); return true; - } catch { - console.log(`[vsctags] No tags file found at ${tagsPath}`); + } catch (err) { + const totalTime = performance.now() - totalStart; + logError(`Failed to load tags from ${tagsPath} after ${formatDuration(totalTime)}: ${err}`); allEntries = []; tagIndex = new Map(); + isLoading = false; + updateStatusBar(); return false; } } @@ -196,6 +338,7 @@ class CtagsDefinitionProvider implements vscode.DefinitionProvider { if (!word) { return undefined; } const entries = tagIndex.get(word); if (!entries || entries.length === 0) { return undefined; } + logInfo(`Definition lookup: "${word}" -> ${entries.length} result(s)`); return entries.map((e) => entryToLocation(e, wsRoot)); } } @@ -225,6 +368,7 @@ class CtagsHoverProvider implements vscode.HoverProvider { class CtagsWorkspaceSymbolProvider implements vscode.WorkspaceSymbolProvider { provideWorkspaceSymbols(query: string): vscode.SymbolInformation[] { + const t = performance.now(); const results: vscode.SymbolInformation[] = []; const lowerQuery = query.toLowerCase(); @@ -242,6 +386,7 @@ class CtagsWorkspaceSymbolProvider implements vscode.WorkspaceSymbolProvider { ); if (results.length >= 500) { break; } } + logInfo(`Workspace symbol search: "${query}" -> ${results.length} result(s) in ${formatDuration(performance.now() - t)}`); return results; } } @@ -250,6 +395,7 @@ class CtagsDocumentSymbolProvider implements vscode.DocumentSymbolProvider { provideDocumentSymbols( document: vscode.TextDocument, ): vscode.SymbolInformation[] { + const t = performance.now(); const relPath = vscode.workspace.asRelativePath(document.uri, false); const results: vscode.SymbolInformation[] = []; @@ -265,6 +411,7 @@ class CtagsDocumentSymbolProvider implements vscode.DocumentSymbolProvider { ); } } + logInfo(`Document symbols: "${relPath}" -> ${results.length} symbol(s) in ${formatDuration(performance.now() - t)}`); return results; } } @@ -278,6 +425,7 @@ class CtagsReferenceProvider implements vscode.ReferenceProvider { if (!word) { return undefined; } const entries = tagIndex.get(word); if (!entries || entries.length === 0) { return undefined; } + logInfo(`References lookup: "${word}" -> ${entries.length} result(s)`); return entries.map((e) => entryToLocation(e, wsRoot)); } } @@ -285,12 +433,37 @@ class CtagsReferenceProvider implements vscode.ReferenceProvider { // ---- Activation ---- export function activate(context: vscode.ExtensionContext) { + log = vscode.window.createOutputChannel("vsctags"); + context.subscriptions.push(log); + + logInfo("Extension activating..."); + const folders = vscode.workspace.workspaceFolders; - if (!folders || folders.length === 0) { return; } + if (!folders || folders.length === 0) { + logInfo("No workspace folder open. Extension idle."); + return; + } wsRoot = folders[0].uri.fsPath; + logInfo(`Workspace root: ${wsRoot}`); - loadTags(); + // Status bar + statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 0); + context.subscriptions.push(statusBarItem); + updateStatusBar(); + // Initial load with progress + vscode.window.withProgress( + { location: vscode.ProgressLocation.Window, title: "vsctags: Loading tags..." }, + async (progress) => { + progress.report({ message: "Reading tags file..." }); + const ok = await loadTags(); + if (ok) { + progress.report({ message: `Loaded ${allEntries.length} tags` }); + } + }, + ); + + // Register providers for all languages const allLangs = { scheme: "file" }; context.subscriptions.push( @@ -300,30 +473,42 @@ export function activate(context: vscode.ExtensionContext) { vscode.languages.registerDocumentSymbolProvider(allLangs, new CtagsDocumentSymbolProvider()), vscode.languages.registerReferenceProvider(allLangs, new CtagsReferenceProvider()), ); + logInfo("Language providers registered (definition, hover, workspace symbols, document symbols, references)"); // Watch the tags file for changes const tagsPattern = new vscode.RelativePattern(folders[0], "tags"); const watcher = vscode.workspace.createFileSystemWatcher(tagsPattern); - watcher.onDidChange(() => { - loadTags(); - vscode.window.showInformationMessage("[vsctags] Tags file reloaded."); + watcher.onDidChange(async () => { + logInfo("Tags file changed on disk. Reloading..."); + await loadTags(); + vscode.window.showInformationMessage(`[vsctags] Reloaded ${allEntries.length} tags.`); }); - watcher.onDidCreate(() => { - loadTags(); - vscode.window.showInformationMessage("[vsctags] Tags file loaded."); + watcher.onDidCreate(async () => { + logInfo("Tags file created. Loading..."); + await loadTags(); + vscode.window.showInformationMessage(`[vsctags] Loaded ${allEntries.length} tags.`); }); watcher.onDidDelete(() => { + logInfo("Tags file deleted."); allEntries = []; tagIndex = new Map(); + updateStatusBar(); vscode.window.showInformationMessage("[vsctags] Tags file removed."); }); context.subscriptions.push(watcher); + logInfo("File watcher registered for tags file"); + // Manual reload command context.subscriptions.push( - vscode.commands.registerCommand("vsctags.reloadTags", () => { - if (loadTags()) { + vscode.commands.registerCommand("vsctags.reloadTags", async () => { + logInfo("Manual reload requested."); + const ok = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: "vsctags: Reloading tags..." }, + async () => loadTags(), + ); + if (ok) { vscode.window.showInformationMessage(`[vsctags] Reloaded ${allEntries.length} tags.`); } else { vscode.window.showWarningMessage("[vsctags] No tags file found."); @@ -331,7 +516,16 @@ export function activate(context: vscode.ExtensionContext) { }), ); - console.log(`[vsctags] Activated with ${allEntries.length} tags.`); + // Show output channel command + context.subscriptions.push( + vscode.commands.registerCommand("vsctags.showLog", () => { + log.show(); + }), + ); + + logInfo("Extension activated."); } -export function deactivate() {} +export function deactivate() { + logInfo("Extension deactivated."); +}