Add observability

This commit is contained in:
Karol Krzosa
2026-02-16 13:52:10 +01:00
parent 3359d63c7c
commit 568fe0f420
2 changed files with 252 additions and 54 deletions

View File

@@ -19,6 +19,10 @@
{
"command": "vsctags.reloadTags",
"title": "vsctags: Reload Tags"
},
{
"command": "vsctags.showLog",
"title": "vsctags: Show Log"
}
]
},

View File

@@ -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<string, string>;
}
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<string, string>();
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<number> {
// Group entries that need resolution by file
const byFile = new Map<string, CtagsEntry[]>();
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<string, CtagsEntry[]>;
@@ -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<boolean> {
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<string, number>();
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.");
}