Add observability
This commit is contained in:
@@ -19,6 +19,10 @@
|
||||
{
|
||||
"command": "vsctags.reloadTags",
|
||||
"title": "vsctags: Reload Tags"
|
||||
},
|
||||
{
|
||||
"command": "vsctags.showLog",
|
||||
"title": "vsctags: Show Log"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
302
src/extension.ts
302
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<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.");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user