From 9658581af3b3c1f3a5840f3946eca9c75bb39623 Mon Sep 17 00:00:00 2001 From: Markis Taylor Date: Mon, 11 Mar 2024 22:42:48 -0400 Subject: [PATCH 1/4] refactor: parse-lcov --- src/parse-lcov.ts | 228 +++++++++++++++++++++------------------------- 1 file changed, 104 insertions(+), 124 deletions(-) diff --git a/src/parse-lcov.ts b/src/parse-lcov.ts index db3b810..31a68ef 100644 --- a/src/parse-lcov.ts +++ b/src/parse-lcov.ts @@ -1,141 +1,121 @@ import { existsSync, readFile } from "fs"; import { Coverage, CoverageCollection } from "./coverage-info"; -function walkFile(str: string): Promise { - return new Promise((resolve, reject) => { - let data: CoverageCollection = []; - let item: Coverage | undefined; - const lines = ["end_of_record"].concat(str.split("\n")); +type MetricsMap = { + [key: string]: (item: Coverage, args: string) => void; +}; + +/** + * @description This method creates a new Coverage object with default values. + */ +function createCoverageItem(): Coverage { + return { + file: "", + title: "", + lines: { + found: 0, + hit: 0, + details: [], + }, + functions: { + hit: 0, + found: 0, + details: [], + }, + branches: { + hit: 0, + found: 0, + details: [], + }, + }; +} - for (let line of lines) { - line = line.trim(); - const allparts = line.split(":"); - const metrics = allparts.shift(); - const args = allparts.join(":"); - const parts = [allparts.shift(), allparts.join(":")]; +/** + * @description This object maps lcov metrics to a handler function. + */ +const metricsMap: MetricsMap = { + TN: (item: Coverage, args: string) => (item.title = args.trim()), + SF: (item: Coverage, args: string) => (item.file = args.trim()), + LF: (item: Coverage, args: string) => + (item.lines.found = Number(args.trim())), + LH: (item: Coverage, args: string) => (item.lines.hit = Number(args.trim())), + DA: (item: Coverage, args: string) => { + const details = args.split(","); + item.lines.details.push({ + line: Number(details[0]), + hit: Number(details[1]), + }); + }, + FNF: (item: Coverage, args: string) => + (item.functions.found = Number(args.trim())), + FNH: (item: Coverage, args: string) => + (item.functions.hit = Number(args.trim())), + FNDA: (item: Coverage, args: string) => { + const details = args.split(","); + item.functions.details.push({ + line: Number(details[0]), + name: details[1], + }); + }, + BRF: (item: Coverage, args: string) => + (item.branches.found = Number(args.trim())), + BRH: (item: Coverage, args: string) => + (item.branches.hit = Number(args.trim())), + BRDA: (item: Coverage, args: string) => { + const details = args.split(","); + item.branches.details.push({ + line: Number(details[0]), + block: Number(details[1]), + branch: Number(details[2]), + hit: details[3] === "-" ? 0 : Number(details[3]), + }); + }, +}; - if (item) { - switch (metrics && metrics.toUpperCase()) { - case "TN": { - item.title = args.trim(); - break; - } - case "SF": { - item.file = args.trim(); - break; - } - case "FNF": { - item.functions.found = Number(args.trim()); - break; - } - case "FNH": { - item.functions.hit = Number(args.trim()); - break; - } - case "LF": { - item.lines.found = Number(args.trim()); - break; - } - case "LH": { - item.lines.hit = Number(args.trim()); - break; - } - case "DA": { - if (item) { - const details = args.split(","); - item.lines.details.push({ - line: Number(details[0]), - hit: Number(details[1]), - }); - } - break; - } - case "BRF": { - item.branches.found = Number(parts[1]); - break; - } - case "BRH": { - item.branches.hit = Number(parts[1]); - break; - } - case "FN": { - if (item) { - const fn = args.split(","); - item.functions.details.push({ - name: fn[1], - line: Number(fn[0]), - }); - } - break; - } - case "FNDA": { - if (item) { - const fn = args.split(","); - item.functions.details.some((i, k) => { - if (item && item.functions.details) { - if (i.name === fn[1] && i.hit === undefined) { - item.functions.details[k].hit = Number(fn[0]); - return true; - } - } - return false; - }); - } - break; - } - case "BRDA": { - if (item) { - const fn = args.split(","); - item.branches.details.push({ - line: Number(fn[0]), - block: Number(fn[1]), - branch: Number(fn[2]), - hit: fn[3] === "-" ? 0 : Number(fn[3]), - }); - } - break; - } - } - } +/** + * @description Parses a string of lcov data into a CoverageCollection + * @param str The string to parse + * @returns A CoverageCollection + */ +function parseFile(str: string): CoverageCollection { + let data: CoverageCollection = []; + let item: Coverage = createCoverageItem(); + const lines = str.split("\n"); - if (line.indexOf("end_of_record") > -1) { - item && data.push(item); - item = { - file: "", - title: "", - lines: { - found: 0, - hit: 0, - details: [], - }, - functions: { - hit: 0, - found: 0, - details: [], - }, - branches: { - hit: 0, - found: 0, - details: [], - }, - }; + for (let line of lines) { + line = line.trim(); + const allparts = line.split(":"); + const metrics = allparts.shift(); + const args = allparts.join(":"); + + if (item && metrics) { + const handler = metricsMap[metrics.toUpperCase()]; + if (handler) { + handler(item, args); } } - if (data.length) { - resolve(data); - } else { - reject("Failed to parse string"); + if (line.indexOf("end_of_record") > -1) { + item && data.push(item); + item = createCoverageItem(); } - }); + } + + return data; } -export function parse(file: string): Promise { +/** + * @description Parses a file of lcov data into a CoverageCollection + * @param file The file to parse + * @returns A CoverageCollection + * @throws Error if the file does not exist + */ +export async function parse(file: string): Promise { return new Promise((resolve, reject) => { !existsSync(file) - ? walkFile(file).then(resolve).catch(reject) + ? reject(new Error(`File not found: ${file}`)) : readFile(file, "utf8", (_, str) => { - walkFile(str).then(resolve).catch(reject); - }); + resolve(parseFile(str)); + }); }); } From 5187be24a1a5fc6b53cfe658e8942f39a2eb95ea Mon Sep 17 00:00:00 2001 From: Markis Taylor Date: Mon, 11 Mar 2024 23:25:47 -0400 Subject: [PATCH 2/4] refactor: ConfigurationOptions --- src/coverage-decorations.ts | 4 +- src/extension-configuration.ts | 171 +++++++++++------------------ src/extension.ts | 4 +- src/file-coverage-info-provider.ts | 4 +- src/parse-lcov.ts | 4 +- test/suite/extension.test.ts | 4 +- 6 files changed, 72 insertions(+), 119 deletions(-) diff --git a/src/coverage-decorations.ts b/src/coverage-decorations.ts index 0af2e23..b1f1130 100644 --- a/src/coverage-decorations.ts +++ b/src/coverage-decorations.ts @@ -9,7 +9,7 @@ import { MarkdownString, } from "vscode"; import { - CONFIG_OPTION_SHOW_DECORATIONS, + ConfigurationOptions, ExtensionConfiguration, } from "./extension-configuration"; import { Coverage } from "./coverage-info"; @@ -186,7 +186,7 @@ export class CoverageDecorations extends Disposable { */ private onConfigOptionUpdated(configOption: string): void { if ( - configOption === CONFIG_OPTION_SHOW_DECORATIONS && + configOption === ConfigurationOptions.showDecorations && window.activeTextEditor ) { const activeFile = window.activeTextEditor.document.uri.fsPath; diff --git a/src/extension-configuration.ts b/src/extension-configuration.ts index c3f64fa..8af0637 100644 --- a/src/extension-configuration.ts +++ b/src/extension-configuration.ts @@ -6,123 +6,80 @@ import { } from "vscode"; export const CONFIG_SECTION_NAME = "markiscodecoverage"; -export const CONFIG_OPTION_ENABLE_ON_STARTUP = "enableOnStartup"; -export const CONFIG_OPTION_SEARCH_CRITERIA = "searchCriteria"; -export const CONFIG_OPTION_COVERAGE_THRESHOLD = "coverageThreshold"; -export const CONFIG_OPTION_SHOW_DECORATIONS = "enableDecorations"; - -export const DEFAULT_SEARCH_CRITERIA = "coverage/lcov*.info"; -export const DEFAULT_CODE_COVERAGE_THRESHOLD = 80; +const DEFAULT_SHOW_COVERAGE = true; +const DEFAULT_SHOW_DECORATIONS = false; +const DEFAULT_SEARCH_CRITERIA = "coverage/lcov*.info"; +const DEFAULT_CODE_COVERAGE_THRESHOLD = 80; + +export enum ConfigurationOptions { + enableOnStartup = "enableOnStartup", + searchCriteria = "searchCriteria", + coverageThreshold = "coverageThreshold", + showDecorations = "enableDecorations", +} export class ExtensionConfiguration extends Disposable { - private readonly _onConfigOptionUpdated = new EventEmitter(); - private _isDisposed = false; - private _showCoverage = true; - private _searchCriteria = ""; - private _coverageThreshold = 0; - private _showDecorations = false; - - constructor(config: WorkspaceConfiguration) { - // use dummy function for callOnDispose since dispose() will be overrided - super(() => true); - - this.showCoverage = - !config.has(CONFIG_OPTION_ENABLE_ON_STARTUP) || - (config.get(CONFIG_OPTION_ENABLE_ON_STARTUP) ?? true); - - const configSearchCriteria = - config.has(CONFIG_OPTION_SEARCH_CRITERIA) && - config.get(CONFIG_OPTION_SEARCH_CRITERIA); - this.searchCriteria = - configSearchCriteria && typeof configSearchCriteria === "string" - ? configSearchCriteria - : DEFAULT_SEARCH_CRITERIA; - - this.coverageThreshold = - config.get(CONFIG_OPTION_COVERAGE_THRESHOLD) ?? - DEFAULT_CODE_COVERAGE_THRESHOLD; - - this.showDecorations = config.get(CONFIG_OPTION_SHOW_DECORATIONS, false); - } - - public override dispose(): void { - if (!this._isDisposed) { - this._onConfigOptionUpdated.dispose(); - - this._isDisposed = true; - } + constructor( + config: WorkspaceConfiguration, + public showCoverage = config.get( + ConfigurationOptions.enableOnStartup, + DEFAULT_SHOW_COVERAGE, + ), + public showDecorations = config.get( + ConfigurationOptions.showDecorations, + DEFAULT_SHOW_DECORATIONS, + ), + public searchCriteria = config.get( + ConfigurationOptions.searchCriteria, + DEFAULT_SEARCH_CRITERIA, + ), + public coverageThreshold = config.get( + ConfigurationOptions.coverageThreshold, + DEFAULT_CODE_COVERAGE_THRESHOLD, + ), + private configSectionName = CONFIG_SECTION_NAME, + private readonly _onConfigOptionUpdated: + | EventEmitter + | undefined = new EventEmitter(), + ) { + super(() => { + _onConfigOptionUpdated.dispose(); + }); } public onConfigOptionUpdated(listener: (e: string) => any): Disposable { - this._checkDisposed(); + if (!this._onConfigOptionUpdated) return Disposable.from(); return this._onConfigOptionUpdated.event(listener); } - get showCoverage() { - this._checkDisposed(); - return this._showCoverage; - } - set showCoverage(value: boolean) { - this._checkDisposed(); - this._showCoverage = value; - } - - get showDecorations() { - this._checkDisposed(); - return this._showDecorations; - } - set showDecorations(value: boolean) { - this._checkDisposed(); - this._showDecorations = value; - } - - get searchCriteria() { - this._checkDisposed(); - return this._searchCriteria; - } - set searchCriteria(value: string) { - this._checkDisposed(); - this._searchCriteria = value; - } - - get coverageThreshold() { - this._checkDisposed(); - return this._coverageThreshold; - } - set coverageThreshold(value: number) { - this._checkDisposed(); - this._coverageThreshold = value; - } - dispatchConfigUpdate( evtSrc: ConfigurationChangeEvent, latestSnapshot: WorkspaceConfiguration, ): void { - this._checkDisposed(); - - if (this._hasBeenUpdated(evtSrc, CONFIG_OPTION_SEARCH_CRITERIA)) { - const configSearchCriteria = - latestSnapshot.has(CONFIG_OPTION_SEARCH_CRITERIA) && - latestSnapshot.get(CONFIG_OPTION_SEARCH_CRITERIA); - this.searchCriteria = - configSearchCriteria && typeof configSearchCriteria === "string" - ? configSearchCriteria - : this.searchCriteria; + if (!this._onConfigOptionUpdated) return; - this._onConfigOptionUpdated.fire(CONFIG_OPTION_SEARCH_CRITERIA); - } else if (this._hasBeenUpdated(evtSrc, CONFIG_OPTION_COVERAGE_THRESHOLD)) { - this.coverageThreshold = - latestSnapshot.get(CONFIG_OPTION_COVERAGE_THRESHOLD) ?? - this.coverageThreshold; - - this._onConfigOptionUpdated.fire(CONFIG_OPTION_COVERAGE_THRESHOLD); - } else if (this._hasBeenUpdated(evtSrc, CONFIG_OPTION_SHOW_DECORATIONS)) { + if (this._hasBeenUpdated(evtSrc, ConfigurationOptions.searchCriteria)) { + this.searchCriteria = latestSnapshot.get( + ConfigurationOptions.searchCriteria, + DEFAULT_SEARCH_CRITERIA, + ); + this._onConfigOptionUpdated.fire(ConfigurationOptions.searchCriteria); + } else if ( + this._hasBeenUpdated(evtSrc, ConfigurationOptions.coverageThreshold) + ) { + this.coverageThreshold = latestSnapshot.get( + ConfigurationOptions.coverageThreshold, + DEFAULT_CODE_COVERAGE_THRESHOLD, + ); + this._onConfigOptionUpdated.fire(ConfigurationOptions.coverageThreshold); + } else if ( + this._hasBeenUpdated(evtSrc, ConfigurationOptions.showDecorations) + ) { this.showDecorations = latestSnapshot.get( - CONFIG_OPTION_SHOW_DECORATIONS, - this.showDecorations, + ConfigurationOptions.showDecorations, + DEFAULT_SHOW_DECORATIONS, ); - - this._onConfigOptionUpdated.fire(CONFIG_OPTION_SHOW_DECORATIONS); + this._onConfigOptionUpdated.fire(ConfigurationOptions.showDecorations); } } @@ -130,12 +87,8 @@ export class ExtensionConfiguration extends Disposable { evtSrc: ConfigurationChangeEvent, optionName: string, ): boolean { - return evtSrc.affectsConfiguration(`${CONFIG_SECTION_NAME}.${optionName}`); - } - - private _checkDisposed() { - if (this._isDisposed) { - throw new Error("illegal state - object is disposed"); - } + return evtSrc.affectsConfiguration( + `${this.configSectionName}.${optionName}`, + ); } } diff --git a/src/extension.ts b/src/extension.ts index 595fbd8..5f1ba3a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -20,8 +20,8 @@ import { LineCoverageInfo, } from "./coverage-info"; import { - CONFIG_OPTION_SEARCH_CRITERIA, CONFIG_SECTION_NAME, + ConfigurationOptions, ExtensionConfiguration, } from "./extension-configuration"; import { parse as parseLcov } from "./parse-lcov"; @@ -72,7 +72,7 @@ export async function activate( // Register watchers and listen if the coverage file directory has changed registerWatchers(); extensionConfiguration.onConfigOptionUpdated((e) => { - if (e && e === CONFIG_OPTION_SEARCH_CRITERIA) { + if (e && e === ConfigurationOptions.searchCriteria) { registerWatchers(); } }); diff --git a/src/file-coverage-info-provider.ts b/src/file-coverage-info-provider.ts index bf04777..bf60a2f 100644 --- a/src/file-coverage-info-provider.ts +++ b/src/file-coverage-info-provider.ts @@ -9,7 +9,7 @@ import { Uri, } from "vscode"; import { - CONFIG_OPTION_COVERAGE_THRESHOLD, + ConfigurationOptions, ExtensionConfiguration, } from "./extension-configuration"; import { Coverage } from "./coverage-info"; @@ -128,7 +128,7 @@ export class FileCoverageInfoProvider private handleConfigUpdate(e: string): void { if ( this._isDisposing || - e !== CONFIG_OPTION_COVERAGE_THRESHOLD || + e !== ConfigurationOptions.coverageThreshold || this._coverageThreshold === this._configuration.coverageThreshold ) { return; diff --git a/src/parse-lcov.ts b/src/parse-lcov.ts index 31a68ef..1a9524c 100644 --- a/src/parse-lcov.ts +++ b/src/parse-lcov.ts @@ -115,7 +115,7 @@ export async function parse(file: string): Promise { !existsSync(file) ? reject(new Error(`File not found: ${file}`)) : readFile(file, "utf8", (_, str) => { - resolve(parseFile(str)); - }); + resolve(parseFile(str)); + }); }); } diff --git a/test/suite/extension.test.ts b/test/suite/extension.test.ts index 9339138..4370715 100644 --- a/test/suite/extension.test.ts +++ b/test/suite/extension.test.ts @@ -13,7 +13,7 @@ import { } from "vscode"; import { ExtensionExports } from "../../src/extension"; import { - CONFIG_OPTION_SHOW_DECORATIONS, + ConfigurationOptions, CONFIG_SECTION_NAME, } from "../../src/extension-configuration"; import { Coverage } from "../../src/coverage-info"; @@ -123,7 +123,7 @@ suite("code-coverage", function () { test("check decorations can be generated from coverage", async () => { const configuration = workspace.getConfiguration(CONFIG_SECTION_NAME); await configuration.update( - CONFIG_OPTION_SHOW_DECORATIONS, + ConfigurationOptions.showDecorations, true, ConfigurationTarget.Global, ); From de02e122352a8b3eed89b9b22e098b6f802618d1 Mon Sep 17 00:00:00 2001 From: Markis Taylor Date: Tue, 12 Mar 2024 22:20:37 -0400 Subject: [PATCH 3/4] refactor: parse-lcov pt 2 --- src/parse-lcov.ts | 99 ++++++++++++++++++++++++++--------------------- 1 file changed, 54 insertions(+), 45 deletions(-) diff --git a/src/parse-lcov.ts b/src/parse-lcov.ts index 1a9524c..d1fad76 100644 --- a/src/parse-lcov.ts +++ b/src/parse-lcov.ts @@ -2,7 +2,7 @@ import { existsSync, readFile } from "fs"; import { Coverage, CoverageCollection } from "./coverage-info"; type MetricsMap = { - [key: string]: (item: Coverage, args: string) => void; + [key: string]: (item: Coverage, value: string) => void; }; /** @@ -34,41 +34,43 @@ function createCoverageItem(): Coverage { * @description This object maps lcov metrics to a handler function. */ const metricsMap: MetricsMap = { - TN: (item: Coverage, args: string) => (item.title = args.trim()), - SF: (item: Coverage, args: string) => (item.file = args.trim()), - LF: (item: Coverage, args: string) => - (item.lines.found = Number(args.trim())), - LH: (item: Coverage, args: string) => (item.lines.hit = Number(args.trim())), - DA: (item: Coverage, args: string) => { - const details = args.split(","); - item.lines.details.push({ - line: Number(details[0]), - hit: Number(details[1]), - }); + TN: (item, val) => { + item.title = val; }, - FNF: (item: Coverage, args: string) => - (item.functions.found = Number(args.trim())), - FNH: (item: Coverage, args: string) => - (item.functions.hit = Number(args.trim())), - FNDA: (item: Coverage, args: string) => { - const details = args.split(","); - item.functions.details.push({ - line: Number(details[0]), - name: details[1], - }); + SF: (item, val) => { + item.file = val; }, - BRF: (item: Coverage, args: string) => - (item.branches.found = Number(args.trim())), - BRH: (item: Coverage, args: string) => - (item.branches.hit = Number(args.trim())), - BRDA: (item: Coverage, args: string) => { - const details = args.split(","); - item.branches.details.push({ - line: Number(details[0]), - block: Number(details[1]), - branch: Number(details[2]), - hit: details[3] === "-" ? 0 : Number(details[3]), - }); + LF: (item, val) => { + item.lines.found = Number(val); + }, + LH: (item, val) => { + item.lines.hit = Number(val); + }, + DA: (item, val) => { + const [line, hit, ..._] = val.split(",").map((v) => Number(v)); + item.lines.details.push({ line, hit }); + }, + FNF: (item, val) => { + item.functions.found = Number(val); + }, + FNH: (item, val) => { + item.functions.hit = Number(val); + }, + FNDA: (item, val) => { + const [line, name, ..._] = val.split(","); + item.functions.details.push({ line: Number(line), name }); + }, + BRF: (item, val) => { + item.branches.found = Number(val); + }, + BRH: (item, val) => { + item.branches.hit = Number(val); + }, + BRDA: (item, val) => { + const [line, block, branch, hit, ..._] = val + .split(",") + .map((v) => (v === "-" ? 0 : Number(v))); + item.branches.details.push({ line, block, branch, hit }); }, }; @@ -78,25 +80,32 @@ const metricsMap: MetricsMap = { * @returns A CoverageCollection */ function parseFile(str: string): CoverageCollection { - let data: CoverageCollection = []; - let item: Coverage = createCoverageItem(); + const data: CoverageCollection = []; const lines = str.split("\n"); + let item = createCoverageItem(); - for (let line of lines) { - line = line.trim(); - const allparts = line.split(":"); - const metrics = allparts.shift(); - const args = allparts.join(":"); + for (const line of lines) { + const trimmedLine = line.trim(); + const allParts = trimmedLine.split(":"); + const metrics = allParts[0]; + const args = allParts.slice(1).join(":"); - if (item && metrics) { + if (metrics) { const handler = metricsMap[metrics.toUpperCase()]; if (handler) { - handler(item, args); + try { + handler(item, args.trim()); + } catch (e) { + console.error(`Error parsing line: ${line}`); + console.error(e); + } } } - if (line.indexOf("end_of_record") > -1) { - item && data.push(item); + if (trimmedLine.includes("end_of_record")) { + if (item) { + data.push(item); + } item = createCoverageItem(); } } From 56140680a29b443806eddc1fcad35d263787d08c Mon Sep 17 00:00:00 2001 From: Markis Taylor Date: Sun, 17 Mar 2024 17:42:36 -0400 Subject: [PATCH 4/4] refactor: less nesting --- src/file-coverage-info-provider.ts | 20 ++++++++++---------- src/parse-lcov.ts | 3 ++- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/file-coverage-info-provider.ts b/src/file-coverage-info-provider.ts index bf60a2f..e55681e 100644 --- a/src/file-coverage-info-provider.ts +++ b/src/file-coverage-info-provider.ts @@ -107,16 +107,16 @@ export class FileCoverageInfoProvider if (this._isDisposing || !this._showFileDecorations) return; const coverage = this._coverageByFile.get(uri.fsPath); - if (coverage) { - const percentCovered = calculateCoveragePercent(coverage); - if (percentCovered < this._coverageThreshold) { - return new FileDecoration( - FILE_DECORATION_BADGE, - `${FILE_DECORATION_TOOLTIP_PRELUDE} ${percentCovered}% vs. ${this._coverageThreshold}%.`, - new ThemeColor("markiscodecoverage.insufficientCoverageForeground"), - ); - } - } + if (!coverage) return; + + const percentCovered = calculateCoveragePercent(coverage); + if (percentCovered >= this._coverageThreshold) return; + + return new FileDecoration( + FILE_DECORATION_BADGE, + `$${FILE_DECORATION_TOOLTIP_PRELUDE} ${percentCovered} vs. ${this._coverageThreshold}.`, + new ThemeColor("markiscodecoverage.insufficientCoverageForeground"), + ); } /** diff --git a/src/parse-lcov.ts b/src/parse-lcov.ts index d1fad76..6942ca3 100644 --- a/src/parse-lcov.ts +++ b/src/parse-lcov.ts @@ -89,12 +89,13 @@ function parseFile(str: string): CoverageCollection { const allParts = trimmedLine.split(":"); const metrics = allParts[0]; const args = allParts.slice(1).join(":"); + const trimmedArgs = args.trim(); if (metrics) { const handler = metricsMap[metrics.toUpperCase()]; if (handler) { try { - handler(item, args.trim()); + handler(item, trimmedArgs); } catch (e) { console.error(`Error parsing line: ${line}`); console.error(e);