From e60ed61026da4c95cfacc99a8c031a3505877aed Mon Sep 17 00:00:00 2001 From: Kuldeep Singh Chouhan <40597687+kool7@users.noreply.github.com> Date: Mon, 4 May 2026 11:31:57 +0530 Subject: [PATCH 1/4] fix: branch coverage support and smarter missing-line inference - parseCoverageSqlite now detects branch-coverage .coverage files (arc table) vs line-coverage (line_bits). Projects using branch=true in their coverage config (e.g. starlette) were showing 0% because data lives in arc, not line_bits. - inferMissingLines tracks triple-quoted strings and open bracket depth so continuation lines of multi-line expressions are not counted as missing statements. - codeLensProvider skips test files when excludeTestFiles is enabled. --- src/parsers/coverageParser.ts | 101 +++++++++++++++++++++++++----- src/providers/codeLensProvider.ts | 8 +++ 2 files changed, 92 insertions(+), 17 deletions(-) diff --git a/src/parsers/coverageParser.ts b/src/parsers/coverageParser.ts index efa28d8..9033cd1 100644 --- a/src/parsers/coverageParser.ts +++ b/src/parsers/coverageParser.ts @@ -130,20 +130,61 @@ function decodeNumBits(buf: Uint8Array): number[] { return lines; } -// Approximate missing lines by reading the source file (non-blank, non-comment lines). +// Approximate missing lines by reading the source file. +// Tracks triple-quoted strings and open brackets so continuation lines of +// multi-line expressions (list literals, __all__, function args, etc.) are +// not falsely counted as independent missing statements. function inferMissingLines(filePath: string, executedSet: Set, workspaceRoot: string): number[] { const resolved = path.isAbsolute(filePath) ? filePath : path.join(workspaceRoot, filePath); if (!fs.existsSync(resolved)) return []; - const lines = fs.readFileSync(resolved, 'utf-8').split('\n'); + + const sourceLines = fs.readFileSync(resolved, 'utf-8').split('\n'); const missing: number[] = []; - for (let i = 0; i < lines.length; i++) { + let inTripleString = false; + let tripleDelim = ''; + let bracketDepth = 0; + + for (let i = 0; i < sourceLines.length; i++) { const lineNum = i + 1; - if (executedSet.has(lineNum)) continue; - const trimmed = lines[i].trim(); - if (!trimmed || trimmed.startsWith('#') || - trimmed.startsWith('"""') || trimmed.startsWith("'''")) continue; - missing.push(lineNum); + const trimmed = sourceLines[i].trim(); + + if (inTripleString) { + if (trimmed.includes(tripleDelim)) inTripleString = false; + continue; + } + + if (!trimmed || trimmed.startsWith('#')) continue; + + if (bracketDepth > 0) { + for (const ch of trimmed) { + if (ch === '(' || ch === '[' || ch === '{') bracketDepth++; + else if (ch === ')' || ch === ']' || ch === '}') bracketDepth--; + } + continue; + } + + const dqIdx = trimmed.indexOf('"""'); + const sqIdx = trimmed.indexOf("'''"); + const hasTriple = dqIdx !== -1 || sqIdx !== -1; + if (hasTriple) { + const delim = (dqIdx !== -1 && (sqIdx === -1 || dqIdx < sqIdx)) ? '"""' : "'''"; + const openAt = trimmed.indexOf(delim); + const closeAt = trimmed.indexOf(delim, openAt + 3); + if (closeAt === -1) { + inTripleString = true; + tripleDelim = delim; + continue; + } + } + + for (const ch of trimmed) { + if (ch === '(' || ch === '[' || ch === '{') bracketDepth++; + else if (ch === ')' || ch === ']' || ch === '}') bracketDepth--; + } + + if (!executedSet.has(lineNum)) missing.push(lineNum); } + return missing; } @@ -176,21 +217,47 @@ export async function parseCoverageSqlite(coveragePath: string, workspaceRoot: s const fileRows = db.exec('SELECT id, path FROM file')[0]; if (!fileRows) return { files: {}, totals: { numStatements: 0, coveredStatements: 0, percentCovered: 0 }, source: 'sqlite' }; - for (const [id, filePath] of fileRows.values as [number, string][]) { - const stmt = db.prepare('SELECT numbits FROM line_bits WHERE file_id = ?'); - stmt.bind([id]); + // Detect whether this .coverage used branch tracking (arc table) or simple + // line tracking (line_bits). branch=true in pyproject/setup.cfg writes only + // to arc; branch=false (default) writes only to line_bits. + let useArcs = false; + try { + const arcCount = db.exec('SELECT COUNT(*) FROM arc')[0]?.values[0][0] as number ?? 0; + useArcs = arcCount > 0; + } catch { + useArcs = false; + } + for (const [id, filePath] of fileRows.values as [number, string][]) { const executedSet = new Set(); - while (stmt.step()) { - const row = stmt.getAsObject() as { numbits: Uint8Array }; - for (const line of decodeNumBits(row.numbits)) executedSet.add(line); + + if (useArcs) { + // Branch mode: rows are (fromno, tono) arcs. Negative values are virtual + // entry/exit markers. Any positive value means that line was executed. + const stmt = db.prepare('SELECT fromno, tono FROM arc WHERE file_id = ?'); + stmt.bind([id]); + while (stmt.step()) { + const { fromno, tono } = stmt.getAsObject() as { fromno: number; tono: number }; + if (fromno > 0) executedSet.add(fromno); + if (tono > 0) executedSet.add(tono); + } + stmt.free(); + } else { + const stmt = db.prepare('SELECT numbits FROM line_bits WHERE file_id = ?'); + stmt.bind([id]); + while (stmt.step()) { + const row = stmt.getAsObject() as { numbits: Uint8Array }; + for (const line of decodeNumBits(row.numbits)) executedSet.add(line); + } + stmt.free(); } - stmt.free(); const executedLines = [...executedSet].sort((a, b) => a - b); const missingLines = inferMissingLines(filePath as string, executedSet, workspaceRoot); const total = executedLines.length + missingLines.length; + if (total === 0) continue; + totalStmts += total; totalCovered += executedLines.length; @@ -198,7 +265,7 @@ export async function parseCoverageSqlite(coveragePath: string, workspaceRoot: s executedLines, missingLines, excludedLines: [], - percentCovered: total > 0 ? (executedLines.length / total) * 100 : 100, + percentCovered: (executedLines.length / total) * 100, }; } @@ -207,7 +274,7 @@ export async function parseCoverageSqlite(coveragePath: string, workspaceRoot: s totals: { numStatements: totalStmts, coveredStatements: totalCovered, - percentCovered: totalStmts > 0 ? (totalCovered / totalStmts) * 100 : 100, + percentCovered: totalStmts > 0 ? (totalCovered / totalStmts) * 100 : 0, }, source: 'sqlite', }; diff --git a/src/providers/codeLensProvider.ts b/src/providers/codeLensProvider.ts index 08ba851..00d4362 100644 --- a/src/providers/codeLensProvider.ts +++ b/src/providers/codeLensProvider.ts @@ -1,7 +1,14 @@ import * as vscode from 'vscode'; +import * as path from 'path'; import { CoverageReport, findFileInReport } from '../parsers/coverageParser.js'; import { getConfig } from '../config.js'; +function isTestFile(fsPath: string): boolean { + const basename = path.basename(fsPath); + return basename.startsWith('test_') || basename.endsWith('_test.py') || + fsPath.split(path.sep).some(seg => seg === 'tests' || seg === 'test'); +} + export class CoverageCodeLensProvider implements vscode.CodeLensProvider { private report: CoverageReport | undefined; private _onDidChangeCodeLenses = new vscode.EventEmitter(); @@ -15,6 +22,7 @@ export class CoverageCodeLensProvider implements vscode.CodeLensProvider { provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] { const cfg = getConfig(); if (!cfg.enableCodeLens || !this.report) return []; + if (cfg.excludeTestFiles && isTestFile(document.uri.fsPath)) return []; const fileCoverage = findFileInReport(this.report, document.uri.fsPath); if (!fileCoverage) return []; From f37131456916881508b6e80930f92227123f4fd4 Mon Sep 17 00:00:00 2001 From: Kuldeep Singh Chouhan <40597687+kool7@users.noreply.github.com> Date: Mon, 4 May 2026 11:47:26 +0530 Subject: [PATCH 2/4] fix: add Run pytest button, handleNoCoverage, debounced watcher, dashboard icon and filtered totals --- src/extension.ts | 103 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 90 insertions(+), 13 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index c476ed7..04664de 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs'; +import { exec, spawn } from 'child_process'; import { parseCoverageJson, parseCoverageXml, @@ -20,17 +21,15 @@ import { CoverageTreeProvider } from './providers/treeProvider.js'; let coveredDecoration: vscode.TextEditorDecorationType; let uncoveredDecoration: vscode.TextEditorDecorationType; let currentReport: CoverageReport | undefined; +let coverageRunInProgress = false; +let noCoveragePromptActive = false; +let reloadTimer: ReturnType | undefined; +let coverageOutputChannel: vscode.OutputChannel | undefined; const codeLensProvider = new CoverageCodeLensProvider(); const hoverProvider = new CoverageHoverProvider(); const treeProvider = new CoverageTreeProvider(); -function isTestFile(fsPath: string): boolean { - const basename = path.basename(fsPath); - return basename.startsWith('test_') || basename.endsWith('_test.py') || - fsPath.split(/[\\/]/).some(seg => seg === 'tests' || seg === 'test'); -} - export function activate(context: vscode.ExtensionContext) { createDecorations(); initStatusBar(context); @@ -91,14 +90,19 @@ function setupWatchers(context: vscode.ExtensionContext) { const root = vscode.workspace.workspaceFolders?.[0]; if (!root) return; + const debouncedReload = () => { + if (!getConfig().autoReloadOnChange) return; + clearTimeout(reloadTimer); + reloadTimer = setTimeout(() => loadAndApply(), 500); + }; + const patterns = ['coverage.json', 'coverage.xml', '.coverage']; patterns.forEach(pattern => { const w = vscode.workspace.createFileSystemWatcher( new vscode.RelativePattern(root, pattern) ); - const reload = () => { if (getConfig().autoReloadOnChange) loadAndApply(); }; - w.onDidCreate(reload); - w.onDidChange(reload); + w.onDidCreate(debouncedReload); + w.onDidChange(debouncedReload); w.onDidDelete(() => { if (!findAnyCoverageFile(root.uri.fsPath)) clearCoverage(); }); @@ -112,11 +116,12 @@ async function loadAndApply() { const result = await detectAndParse(workspaceFolder); if (!result) { - vscode.window.showWarningMessage( - 'Coverage Visualizer: No coverage file found. Run: pytest --cov=. --cov-report=json' - ); + if (!coverageRunInProgress && !findAnyCoverageFile(workspaceFolder)) { + await handleNoCoverage(workspaceFolder); + } return; } + coverageRunInProgress = false; const { report } = result; currentReport = report; @@ -129,7 +134,6 @@ async function loadAndApply() { const { excludeTestFiles } = getConfig(); const filteredFiles = Object.entries(report.files) - .filter(([, d]) => d.executedLines.length + d.missingLines.length > 0) .filter(([p]) => !excludeTestFiles || !isTestFile(p)) .map(([, d]) => d); const filteredCovered = filteredFiles.reduce((n, f) => n + f.executedLines.length, 0); @@ -143,6 +147,68 @@ async function loadAndApply() { updateDashboard(report); } +function resolvePython(workspaceFolder: string): string { + const candidates = [ + path.join(workspaceFolder, '.venv', 'bin', 'python'), + path.join(workspaceFolder, '.venv', 'Scripts', 'python.exe'), + ]; + for (const p of candidates) { + if (fs.existsSync(p)) return p; + } + return 'python'; +} + +function checkPython(python: string, cwd: string, code: string): Promise { + return new Promise(resolve => { + exec(`"${python}" -c "${code}"`, { cwd }, err => resolve(!err)); + }); +} + +async function handleNoCoverage(workspaceFolder: string) { + if (noCoveragePromptActive) return; + noCoveragePromptActive = true; + + try { + const python = resolvePython(workspaceFolder); + const [hasPytestCov, hasCoverage] = await Promise.all([ + checkPython(python, workspaceFolder, 'import pytest_cov'), + checkPython(python, workspaceFolder, 'import coverage'), + ]); + + if (!hasCoverage) { + vscode.window.showWarningMessage('coverage is not installed — add it as a dev dependency to enable auto-run.'); + return; + } + + const choice = await vscode.window.showInformationMessage( + 'No coverage found.', + 'Run pytest', + 'Cancel' + ); + if (choice !== 'Run pytest') return; + + const args = hasPytestCov + ? ['-m', 'pytest', '--cov=.', '--cov-report=json'] + : ['-m', 'coverage', 'run', '-m', 'pytest']; + + coverageRunInProgress = true; + coverageOutputChannel ??= vscode.window.createOutputChannel('Coverage Run'); + coverageOutputChannel.clear(); + coverageOutputChannel.show(true); + coverageOutputChannel.appendLine(`$ ${python} ${args.join(' ')}\n`); + + const proc = spawn(python, args, { cwd: workspaceFolder }); + proc.stdout.on('data', (d: Buffer) => coverageOutputChannel!.append(d.toString())); + proc.stderr.on('data', (d: Buffer) => coverageOutputChannel!.append(d.toString())); + proc.on('close', code => { + coverageRunInProgress = false; + coverageOutputChannel!.appendLine(`\n[exited ${code ?? '?'}]`); + }); + } finally { + noCoveragePromptActive = false; + } +} + async function detectAndParse( workspaceFolder: string ): Promise<{ report: CoverageReport; formatUsed: string } | undefined> { @@ -194,8 +260,18 @@ function clearCoverage() { }); } +function isTestFile(fsPath: string): boolean { + const basename = path.basename(fsPath); + return basename.startsWith('test_') || basename.endsWith('_test.py') || + fsPath.split(path.sep).some(seg => seg === 'tests' || seg === 'test'); +} function applyToEditor(editor: vscode.TextEditor, report: CoverageReport) { + if (getConfig().excludeTestFiles && isTestFile(editor.document.uri.fsPath)) { + editor.setDecorations(coveredDecoration, []); + editor.setDecorations(uncoveredDecoration, []); + return; + } const fileCoverage = findFileInReport(report, editor.document.uri.fsPath); if (!fileCoverage) { editor.setDecorations(coveredDecoration, []); @@ -215,5 +291,6 @@ function linesToDecorations(lines: number[]): vscode.DecorationOptions[] { export function deactivate() { coveredDecoration?.dispose(); uncoveredDecoration?.dispose(); + coverageOutputChannel?.dispose(); currentReport = undefined; } From c11812e6f6450e2587619b88a5130c44f0f41e1b Mon Sep 17 00:00:00 2001 From: Kuldeep Singh Chouhan <40597687+kool7@users.noreply.github.com> Date: Mon, 4 May 2026 11:48:30 +0530 Subject: [PATCH 3/4] fix: dashboard filtered totals, themed icon, and webview resource roots --- src/ui/dashboardPanel.ts | 90 ++++++++++++++++++++++++++-------------- 1 file changed, 59 insertions(+), 31 deletions(-) diff --git a/src/ui/dashboardPanel.ts b/src/ui/dashboardPanel.ts index 7088ba4..b5edbc5 100644 --- a/src/ui/dashboardPanel.ts +++ b/src/ui/dashboardPanel.ts @@ -25,12 +25,12 @@ export function showDashboard(report: CoverageReport, context: vscode.ExtensionC localResourceRoots: [vscode.Uri.joinPath(context.extensionUri, 'assets')], } ); - // ThemeIcon supported for WebviewPanel.iconPath since VS Code 1.87 (we target 1.90+). + // ThemeIcon is supported for WebviewPanel.iconPath since VS Code 1.87 (we target 1.90+). panel.iconPath = new vscode.ThemeIcon('graph'); panel.onDidDispose(() => { panel = undefined; }, null, context.subscriptions); } - panel.webview.html = buildHtml(report, context.extensionUri); + panel.webview.html = buildHtml(report, panel.webview, context.extensionUri); const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? ''; @@ -47,7 +47,7 @@ export function showDashboard(report: CoverageReport, context: vscode.ExtensionC } export function updateDashboard(report: CoverageReport) { - if (panel) panel.webview.html = buildHtml(report); + if (panel) panel.webview.html = buildHtml(report, panel.webview, undefined); } function buildHeaderIcon(extensionUri?: vscode.Uri): string { @@ -56,7 +56,6 @@ function buildHeaderIcon(extensionUri?: vscode.Uri): string { const svgUri = vscode.Uri.joinPath(extensionUri, 'assets', 'icon-dashboard.svg'); if (fs.existsSync(svgUri.fsPath)) { let svg = fs.readFileSync(svgUri.fsPath, 'utf-8') as string; - // Inline SVG inherits currentColor from CSS — correct on both light and dark themes. svg = svg.replace(/^(\s*= thresholdGood ? 'good' : pct >= thresholdWarn ? 'warn' : 'bad'; } -function buildHtml(report: CoverageReport, extensionUri?: vscode.Uri): string { +function buildHtml(report: CoverageReport, webview?: vscode.Webview, extensionUri?: vscode.Uri): string { const { thresholdGood, thresholdWarn, excludeTestFiles } = getConfig(); - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? ''; const files = Object.entries(report.files) - .filter(([, data]) => data.executedLines.length + data.missingLines.length > 0) .filter(([filePath]) => !excludeTestFiles || !isTestFile(filePath)) - .map(([filePath, data]) => { - const displayPath = filePath.startsWith(workspaceRoot) - ? filePath.slice(workspaceRoot.length).replace(/^[\\/]/, '') - : filePath; - return { filePath, displayPath, ...data }; - }) + .map(([filePath, data]) => ({ filePath, ...data })) .sort((a, b) => a.percentCovered - b.percentCovered); - // Recompute totals from the filtered list so ring and status bar always agree. const coveredStatements = files.reduce((n, f) => n + f.executedLines.length, 0); const numStatements = files.reduce((n, f) => n + f.executedLines.length + f.missingLines.length, 0); const percentCovered = numStatements > 0 ? (coveredStatements / numStatements) * 100 : 0; @@ -98,8 +89,8 @@ function buildHtml(report: CoverageReport, extensionUri?: vscode.Uri): string { const cls = colorClass(f.percentCovered, thresholdGood, thresholdWarn); const total = f.executedLines.length + f.missingLines.length; return ` - - ${f.displayPath} + + ${f.filePath} ${f.executedLines.length}/${total}
@@ -131,7 +122,7 @@ function buildHtml(report: CoverageReport, extensionUri?: vscode.Uri): string { background: var(--vscode-editor-background); padding: 24px; } - h1 { display: flex; align-items: center; gap: 10px; font-size: 1.3em; font-weight: 600; margin-bottom: 24px; opacity: 0.9; } + h1 { font-size: 1.3em; font-weight: 600; margin-bottom: 24px; opacity: 0.9; display: flex; align-items: center; gap: 10px; } .h1-icon { width: 28px; height: 28px; flex-shrink: 0; } .summary { display: flex; align-items: center; gap: 40px; @@ -154,21 +145,24 @@ function buildHtml(report: CoverageReport, extensionUri?: vscode.Uri): string { .stat-label { opacity: 0.6; font-size: 0.85em; } .toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; } .toolbar-left { display: flex; align-items: center; gap: 8px; flex: 1; } - h2 { font-size: 1em; font-weight: 600; opacity: 0.8; text-transform: uppercase; letter-spacing: 0.05em; } + h2 { font-size: 1em; font-weight: 600; opacity: 0.8; + text-transform: uppercase; letter-spacing: 0.05em; } .file-count { opacity: 0.45; font-size: 0.82em; } .sort-btn { background: var(--vscode-button-secondaryBackground, #3c3c3c); color: var(--vscode-button-secondaryForeground, #ccc); border: 1px solid var(--vscode-widget-border, #555); border-radius: 4px; padding: 3px 9px; font-size: 0.82em; - cursor: pointer; display: flex; align-items: center; gap: 4px; white-space: nowrap; + cursor: pointer; display: flex; align-items: center; gap: 4px; + white-space: nowrap; } .sort-btn:hover { background: var(--vscode-button-secondaryHoverBackground, #4a4a4a); } .filter-input { background: var(--vscode-input-background); border: 1px solid var(--vscode-input-border, #555); color: var(--vscode-input-foreground); - border-radius: 4px; padding: 4px 10px; font-size: 0.9em; width: 200px; outline: none; + border-radius: 4px; padding: 4px 10px; font-size: 0.9em; width: 200px; + outline: none; } .filter-input:focus { border-color: var(--vscode-focusBorder, #007fd4); } table { width: 100%; border-collapse: collapse; } @@ -178,7 +172,8 @@ function buildHtml(report: CoverageReport, extensionUri?: vscode.Uri): string { .file-row { cursor: pointer; transition: background 0.15s; } .file-row:hover { background: var(--vscode-list-hoverBackground); } .file-row.hidden { display: none; } - td { padding: 8px 10px; vertical-align: middle; border-bottom: 1px solid var(--vscode-widget-border, #222); } + td { padding: 8px 10px; vertical-align: middle; + border-bottom: 1px solid var(--vscode-widget-border, #222); } .filename { font-family: var(--vscode-editor-font-family, monospace); font-size: 0.9em; color: var(--vscode-textLink-foreground, #4daafc); @@ -205,16 +200,29 @@ function buildHtml(report: CoverageReport, extensionUri?: vscode.Uri): string {
- - ${percentCovered.toFixed(1)}% + + + ${percentCovered.toFixed(1)}% + covered
-
${coveredStatements}/ ${numStatements} statements covered
-
${files.filter(f => f.percentCovered === 100).length}files fully covered
-
${files.filter(f => f.percentCovered === 0).length}files with zero coverage
+
+ ${coveredStatements} + / ${numStatements} statements covered +
+
+ ${files.filter(f => f.percentCovered === 100).length} + files fully covered +
+
+ ${files.filter(f => f.percentCovered === 0).length} + files with zero coverage +
@@ -223,26 +231,40 @@ function buildHtml(report: CoverageReport, extensionUri?: vscode.Uri): string {

Files

${files.length} total - + + + + + + + + ${fileRows}
FileLines%
FileLines%
No files match your filter.
+

Source: ${report.source}