diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e0e1d2f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +### 0.2.0 +- true gutter indicators +- contribution doc +- refactor gutters to be more testable + +### 0.1.3 +- colour updates +- context menu additions +- icon resizing + +### 0.1.2 +- minor doc tweaks +- cleanup default colours and use rgba + +### 0.1.1 +- give the icon a background + +### 0.1.0 +- display and remove lcov line coverage using commands +- modify highlight colour using workspace settings +- modify lcov name using workspace settings \ No newline at end of file diff --git a/README.md b/README.md index b9a2bc8..13c3336 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,16 @@ ## Features - simple line coverage rendering using lcov -- workspace settings to change the lcov name and highlight colours + +![Coverage Gutters features context](images/coverage-gutters-features-context.gif) + +- workspace settings to customize the features to your liking - colour compatibility with light and dark themes -![Coverage Gutters features reel](images/coverage-gutters-features.gif) +![Coverage Gutters features basic](images/coverage-gutters-features-basic.gif) ## Requirements -- vscode 1.5.0 and up +- vscode 1.9.0 and up - macos, linux or windows ## Extension Settings @@ -20,6 +23,8 @@ |`coverage-gutters.lcovname`|Allows specification of a custom lcov file name |`coverage-gutters.highlightlight`|Changes the highlight for light themes |`coverage-gutters.highlightdark`|Changes the Highlight for dark themes +|`coverage-gutters.gutterIconPathDark`|Relative path to an icon in the extension for dark themes +|`coverage-gutters.gutterIconPathLight`|Relative path to an icon in the extension for light themes |`coverage-gutters.customizable.menus-editor-context-displayCoverage-enabled`|Setting this to false will remove the command from the editor context menu and vice versa |`coverage-gutters.customizable.menus-editor-context-removeCoverage-enabled`|Setting this to false will remove the command from the editor context menu and vice versa @@ -35,11 +40,13 @@ Some examples for the highlight colour are as follows: - none ( just missing functionality :) ) ## Release Notes +### [Changelog](CHANGELOG.mb) -### 0.1.0 -- display and remove lcov line coverage using commands -- modify highlight colour using workspace settings -- modify lcov name using workspace settings +## Contribution Guidelines +- test backed code changes +- new code matches existing style +- bug fixes always welcome :) +- new feature proposals go through a github issue ----------------------------------------------------------------------------------------------------------- diff --git a/images/coverage-gutters-features-basic.gif b/images/coverage-gutters-features-basic.gif new file mode 100644 index 0000000..8786bfc Binary files /dev/null and b/images/coverage-gutters-features-basic.gif differ diff --git a/images/coverage-gutters-features-context.gif b/images/coverage-gutters-features-context.gif new file mode 100644 index 0000000..86ec218 Binary files /dev/null and b/images/coverage-gutters-features-context.gif differ diff --git a/images/coverage-gutters-features.gif b/images/coverage-gutters-features.gif deleted file mode 100644 index 19c7a5b..0000000 Binary files a/images/coverage-gutters-features.gif and /dev/null differ diff --git a/images/gutter-icon-dark.svg b/images/gutter-icon-dark.svg new file mode 100644 index 0000000..6fe14f6 --- /dev/null +++ b/images/gutter-icon-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/gutter-icon-light.svg b/images/gutter-icon-light.svg new file mode 100644 index 0000000..f537494 --- /dev/null +++ b/images/gutter-icon-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/package.json b/package.json index 1991cf6..396b040 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "vscode-coverage-gutters", "displayName": "Coverage Gutters", "description": "Display test coverage generated by lcov - works with many languages", - "version": "0.1.3", + "version": "0.2.0", "license": "MIT", "repository": { "type": "git", @@ -48,6 +48,16 @@ "default": "rgba(36, 56, 27, 0.75)", "description": "dark themed highlight for code coverage" }, + "coverage-gutters.gutterIconPathDark": { + "type": "string", + "default": "./images/gutter-icon-dark.svg", + "description": "path to an icon (svg, png, etc) for displaying in the gutter for dark themes" + }, + "coverage-gutters.gutterIconPathLight": { + "type": "string", + "default": "./images/gutter-icon-light.svg", + "description": "path to an icon (svg, png, etc) for displaying in the gutter for light themes" + }, "coverage-gutters.customizable.menus-editor-context-displayCoverage-enabled": { "type": "boolean", "default": true, diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..edb4ecf --- /dev/null +++ b/src/config.ts @@ -0,0 +1,81 @@ +'use strict'; + +import {TextEditorDecorationType, ExtensionContext} from "vscode"; + +export interface configStore { + lcovFileName: string, + coverageDecorationType: TextEditorDecorationType, + gutterDecorationType: TextEditorDecorationType +} + +export class Config { + private createTextEditorDecorationType; + private executeCommand; + private workspaceConfig; + private context: ExtensionContext; + + private lcovFileName: string; + private coverageDecorationType: TextEditorDecorationType; + private gutterDecorationType: TextEditorDecorationType; + + constructor( + createTextEditorDecorationType, + executeCommand, + workspaceConfig, + context: ExtensionContext + ) { + this.createTextEditorDecorationType = createTextEditorDecorationType; + this.executeCommand = executeCommand; + this.workspaceConfig = workspaceConfig; + this.context = context; + } + + public get(): configStore { + return { + lcovFileName: this.lcovFileName, + coverageDecorationType: this.coverageDecorationType, + gutterDecorationType: this.gutterDecorationType + } + } + + public setup(): configStore { + //Customizable UI configurations + const rootCustomConfig = this.workspaceConfig("coverage-gutters.customizable"); + const configsCustom = Object.keys(rootCustomConfig); + for(let element of configsCustom) { + this.executeCommand( + "setContext", + "config.coverage-gutters.customizable." + element, + rootCustomConfig.get(element)); + } + + //Basic configurations + const rootConfig = this.workspaceConfig("coverage-gutters"); + this.lcovFileName = rootConfig.get("lcovname") as string; + const coverageLightBackgroundColour = rootConfig.get("highlightlight") as string; + const coverageDarkBackgroundColour = rootConfig.get("highlightdark") as string; + const gutterIconPathDark = rootConfig.get("gutterIconPathDark") as string; + const gutterIconPathLight = rootConfig.get("gutterIconPathLight") as string; + + this.coverageDecorationType = this.createTextEditorDecorationType({ + isWholeLine: true, + light: { + backgroundColor: coverageLightBackgroundColour + }, + dark: { + backgroundColor: coverageDarkBackgroundColour + } + }); + + this.gutterDecorationType = this.createTextEditorDecorationType({ + light: { + gutterIconPath: this.context.asAbsolutePath(gutterIconPathLight) + }, + dark: { + gutterIconPath: this.context.asAbsolutePath(gutterIconPathDark) + } + }); + + return this.get(); + } +} \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index f426a08..0d28896 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,7 +4,7 @@ import * as vscode from "vscode"; import {Gutters} from "./gutters"; export function activate(context: vscode.ExtensionContext) { - let gutters = new Gutters(); + let gutters = new Gutters(context); let display = vscode.commands.registerCommand("extension.displayCoverage", () => { gutters.displayCoverageForActiveFile(); diff --git a/src/gutters.ts b/src/gutters.ts index 55ee307..73d7487 100644 --- a/src/gutters.ts +++ b/src/gutters.ts @@ -1,103 +1,50 @@ 'use strict'; -import * as vscode from "vscode"; -import * as parse from "lcov-parse"; -import {readFile} from "fs"; +import { + createTextEditorDecorationType, + executeCommand, + getConfiguration, + setDecorations, + findFiles +} from "./wrappers/vscode"; +import {readFile} from "./wrappers/fs"; +import {lcovParse} from "./wrappers/lcov-parse"; +import {Range, window, ExtensionContext} from "vscode"; + +import {Lcov, lcov} from "./lcov"; +import {Indicators, indicators} from "./indicators"; +import {Config, configStore} from "./config"; export class Gutters { - private lcovFileName: string; - private coverageDecorationType: vscode.TextEditorDecorationType; - private coverageLightBackgroundColour: string; - private coverageDarkBackgroundColour: string; - - constructor() { - const config = vscode.workspace.getConfiguration("coverage-gutters"); - - //Customizable UI configurations - const configsCustom = Object.keys(config.get("customizable")); - for(let element of configsCustom) { - vscode.commands.executeCommand( - "setContext", - "config.coverage-gutters.customizable." + element, - vscode.workspace.getConfiguration("coverage-gutters.customizable").get(element)); - } - - //Basic configurations - this.lcovFileName = config.get("lcovname") as string; - this.coverageLightBackgroundColour = config.get("highlightlight") as string; - this.coverageDarkBackgroundColour = config.get("highlightdark") as string; - - this.coverageDecorationType = vscode.window.createTextEditorDecorationType({ - isWholeLine: true, - light: { - backgroundColor: this.coverageLightBackgroundColour - }, - dark: { - backgroundColor: this.coverageDarkBackgroundColour - } - }); + private configStore: configStore; + private lcov: lcov; + private indicators: indicators; + + constructor(context: ExtensionContext) { + this.configStore = new Config( + createTextEditorDecorationType, + executeCommand, + getConfiguration, + context + ).setup(); + this.lcov = new Lcov(this.configStore, findFiles, readFile); + this.indicators = new Indicators(this.configStore, lcovParse, setDecorations); } public async displayCoverageForActiveFile() { try { - const activeFile = vscode.window.activeTextEditor.document.fileName; - const lcovPath = await this.findLcov(); - const lcovFile = await this.loadLcov(lcovPath); - const coveredLines = await this.extractCoverage(lcovFile, activeFile); - await this.renderIndicators(coveredLines); + const activeFile = window.activeTextEditor.document.fileName; + const lcovPath = await this.lcov.find(); + const lcovFile = await this.lcov.load(lcovPath); + const coveredLines = await this.indicators.extract(lcovFile, activeFile); + await this.indicators.render(coveredLines); } catch(e) { console.log(e); } } public dispose() { - vscode.window.activeTextEditor.setDecorations(this.coverageDecorationType, []); - } - - private findLcov(): Promise { - return new Promise((resolve, reject) => { - vscode.workspace.findFiles("**/" + this.lcovFileName, "**/node_modules/**", 1) - .then((uriLcov) => { - if(!uriLcov.length) return reject(new Error("Could not find a lcov file!")); - return resolve(uriLcov[0].fsPath); - }); - }); - } - - private renderIndicators(lines: Detail[]): Promise { - return new Promise((resolve, reject) => { - let renderLines = []; - lines.forEach((detail) => { - if(detail.hit > 0) { - renderLines.push(new vscode.Range(detail.line - 1, 0, detail.line - 1, 0)); - } - }); - vscode.window.activeTextEditor.setDecorations(this.coverageDecorationType, renderLines); - return resolve(); - }); - } - - private loadLcov(lcovPath: string): Promise { - return new Promise((resolve, reject) => { - readFile(lcovPath, (err, data) => { - if(err) return reject(err); - return resolve(data.toString()); - }); - }); - } - - private extractCoverage(lcovFile: string, file: string): Promise> { - return new Promise>((resolve, reject) => { - parse(lcovFile, (err, data) => { - if(err) return reject(err); - let section = data.find((section) => { - //prevent hazardous casing mishaps - return section.file.toLocaleLowerCase() === file.toLocaleLowerCase(); - }); - - if(!section) return reject(new Error("No coverage for file!")); - return resolve(section.lines.details); - }); - }); + setDecorations(this.configStore.coverageDecorationType, []); + setDecorations(this.configStore.gutterDecorationType, []); } } \ No newline at end of file diff --git a/src/indicators.ts b/src/indicators.ts new file mode 100644 index 0000000..f1f004c --- /dev/null +++ b/src/indicators.ts @@ -0,0 +1,51 @@ +"use strict"; + +import {configStore} from "./config"; +import {Range} from "vscode"; +import {Detail} from "lcov-parse"; + +export interface indicators { + render(lines: Array): Promise, + extract(lcovFile: string, file: string): Promise> +} + +export class Indicators implements indicators{ + private parseLcov; + private configStore: configStore; + private setDecorations; + + constructor(configStore, parseLcov, setDecorations) { + this.configStore = configStore; + this.parseLcov = parseLcov; + this.setDecorations = setDecorations; + } + + public render(lines: Detail[]): Promise { + return new Promise((resolve, reject) => { + let renderLines = []; + lines.forEach((detail) => { + if(detail.hit > 0) { + renderLines.push(new Range(detail.line - 1, 0, detail.line - 1, 0)); + } + }); + this.setDecorations(this.configStore.coverageDecorationType, renderLines); + this.setDecorations(this.configStore.gutterDecorationType, renderLines); + return resolve(); + }); + } + + public extract(lcovFile: string, file: string): Promise> { + return new Promise>((resolve, reject) => { + this.parseLcov(lcovFile, (err, data) => { + if(err) return reject(err); + let section = data.find((section) => { + //prevent hazardous casing mishaps + return section.file.toLocaleLowerCase() === file.toLocaleLowerCase(); + }); + + if(!section) return reject(new Error("No coverage for file!")); + return resolve(section.lines.details); + }); + }); + } +} \ No newline at end of file diff --git a/src/lcov.ts b/src/lcov.ts new file mode 100644 index 0000000..7905753 --- /dev/null +++ b/src/lcov.ts @@ -0,0 +1,38 @@ +'use strict'; +import {configStore} from "./config"; + +export interface lcov { + find(): Promise, + load(lcovPath: string): Promise +} + +export class Lcov implements lcov { + private configStore: configStore; + private findFiles; + private readFile; + + constructor(configStore: configStore, findFiles, readFile) { + this.configStore = configStore; + this.findFiles = findFiles; + this.readFile = readFile; + } + + public find(): Promise { + return new Promise((resolve, reject) => { + this.findFiles("**/" + this.configStore.lcovFileName, "**/node_modules/**", 1) + .then((uriLcov) => { + if(!uriLcov.length) return reject(new Error("Could not find a lcov file!")); + return resolve(uriLcov[0].fsPath); + }); + }); + } + + public load(lcovPath: string): Promise { + return new Promise((resolve, reject) => { + this.readFile(lcovPath, (err, data) => { + if(err) return reject(err); + return resolve(data.toString()); + }); + }); + } +} \ No newline at end of file diff --git a/src/wrappers/fs.ts b/src/wrappers/fs.ts new file mode 100644 index 0000000..a0e2106 --- /dev/null +++ b/src/wrappers/fs.ts @@ -0,0 +1,5 @@ +import {readFile as readFileFS} from "fs"; + +export function readFile(filename: string, callback: (err: NodeJS.ErrnoException, data: Buffer) => void) { + return readFileFS(filename, callback); +} \ No newline at end of file diff --git a/src/wrappers/lcov-parse.ts b/src/wrappers/lcov-parse.ts new file mode 100644 index 0000000..87f0094 --- /dev/null +++ b/src/wrappers/lcov-parse.ts @@ -0,0 +1,7 @@ +"use strict"; + +import {LcovSection, source} from "lcov-parse"; + +export function lcovParse(file: string, cb: (err: Error, data: Array) => void): void { + return source(file, cb); +} \ No newline at end of file diff --git a/src/wrappers/vscode.ts b/src/wrappers/vscode.ts new file mode 100644 index 0000000..5dbb556 --- /dev/null +++ b/src/wrappers/vscode.ts @@ -0,0 +1,32 @@ +"use strict"; + +import {window, commands, workspace} from "vscode"; +import { + DecorationRenderOptions, + TextEditorDecorationType, + WorkspaceConfiguration, + Range, + DecorationOptions, + CancellationToken, + Uri +} from "vscode" + +export function createTextEditorDecorationType(options: DecorationRenderOptions): TextEditorDecorationType { + return window.createTextEditorDecorationType(options); +} + +export function setDecorations(decorationType: TextEditorDecorationType, rangesOrOptions: Range[] | DecorationOptions[]) { + return window.activeTextEditor.setDecorations(decorationType, rangesOrOptions); +} + +export function executeCommand(command: string, ...rest: any[]): Thenable<{}> { + return commands.executeCommand(command, rest); +} + +export function findFiles(include: string, exclude: string, maxResults?: number, token?: CancellationToken): Thenable { + return workspace.findFiles(include, exclude, maxResults, token); +} + +export function getConfiguration(section?: string): WorkspaceConfiguration { + return workspace.getConfiguration(section); +} \ No newline at end of file diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..f56a299 --- /dev/null +++ b/test/config.test.ts @@ -0,0 +1,12 @@ +"use strict"; + +import * as assert from "assert"; + +import * as vscode from "vscode"; +import {Config} from "../src/config"; + +suite("Config Tests", function() { + test("Constructor should setup properly", function() { + + }); +}); \ No newline at end of file diff --git a/test/extension.test.ts b/test/extension.test.ts index 05838ac..46f608e 100644 --- a/test/extension.test.ts +++ b/test/extension.test.ts @@ -5,16 +5,24 @@ import * as assert from "assert"; import * as vscode from "vscode"; import * as myExtension from "../src/extension"; -suite("Extension Tests", () => { - test("Should activate on vscode open and produce error on sequential activates", () => { +suite("Extension Tests", function() { + test("Should activate have subscriptions of length 3 on macos or error on linxu / windows", function(done) { let ctx: vscode.ExtensionContext = { - subscriptions: [] + subscriptions: [], + asAbsolutePath: function() { + return "test"; + } }; try{ myExtension.activate(ctx); } catch(e) { + //only for linux / windows environments assert.equal("command with id already exists", e.message); + return done(); } + + assert.equal(ctx.subscriptions.length, 3); + return done(); }); }); \ No newline at end of file diff --git a/test/gutters.test.ts b/test/gutters.test.ts index cc37952..2328f7a 100644 --- a/test/gutters.test.ts +++ b/test/gutters.test.ts @@ -5,8 +5,19 @@ import * as assert from "assert"; import * as vscode from "vscode"; import {Gutters} from "../src/gutters"; -suite("Gutters Tests", () => { - test("Should setup gutters based on config values with no errors", function() { - const gutters = new Gutters(); +suite("Gutters Tests", function() { + test("Should setup gutters based on config values with no errors", function(done) { + try { + let ctx: vscode.ExtensionContext = { + subscriptions: [], + asAbsolutePath: function() { + return "test"; + } + }; + const gutters = new Gutters(ctx); + return done(); + } catch(e) { + return done(e); + } }); }); \ No newline at end of file diff --git a/test/indicators.test.ts b/test/indicators.test.ts new file mode 100644 index 0000000..e7cc380 --- /dev/null +++ b/test/indicators.test.ts @@ -0,0 +1,12 @@ +"use strict"; + +import * as assert from "assert"; + +import * as vscode from "vscode"; +import {Indicators} from "../src/indicators"; + +suite("Indicators Tests", function() { + test("Constructor should setup properly", function() { + + }); +}); \ No newline at end of file diff --git a/test/lcov.test.ts b/test/lcov.test.ts new file mode 100644 index 0000000..95452b2 --- /dev/null +++ b/test/lcov.test.ts @@ -0,0 +1,162 @@ +"use strict"; + +import * as assert from "assert"; + +import * as vscode from "vscode"; +import {Lcov} from "../src/lcov"; + +suite("Lcov Tests", function() { + test("Constructor should setup properly", function(done) { + try { + const lcov = new Lcov( + { + lcovFileName: "test.ts", + coverageDecorationType: { + key: "testKey", + dispose() {} + }, + gutterDecorationType: { + key: "testKey2", + dispose() {} + } + }, + function(){}, + function(path: string){} + ); + return done(); + } catch(e) { + assert.equal(1,2); + return done(); + } + }); + + test("#find: Should return error if no file found for lcovFileName", function(done) { + const lcov = new Lcov( + { + lcovFileName: "test.ts", + coverageDecorationType: { + key: "testKey", + dispose() {} + }, + gutterDecorationType: { + key: "testKey2", + dispose() {} + } + }, + function(path, exclude, filesToFind) { + assert.equal(path, "**/test.ts"); + assert.equal(exclude, "**/node_modules/**"); + assert.equal(filesToFind, 1); + return new Promise(function(resolve, reject) { + return resolve([]); + }); + }, + function(path: string){} + ); + + lcov.find() + .then(function() { + return done(new Error("Expected error did not fire!")); + }) + .catch(function(error) { + if(error.name === "AssertionError") return done(error); + if(error.message === "Could not find a lcov file!") return done(); + return done(error); + }); + }); + + test("#find: Should return a file system path", function(done) { + const lcov = new Lcov( + { + lcovFileName: "test.ts", + coverageDecorationType: { + key: "testKey", + dispose() {} + }, + gutterDecorationType: { + key: "testKey2", + dispose() {} + } + }, + function(path, exclude, filesToFind) { + assert.equal(path, "**/test.ts"); + assert.equal(exclude, "**/node_modules/**"); + assert.equal(filesToFind, 1); + return new Promise(function(resolve, reject) { + return resolve([{fsPath: "path/to/greatness/test.ts"}]); + }); + }, + function(path: string){} + ); + + lcov.find() + .then(function(fsPath) { + assert.equal(fsPath, "path/to/greatness/test.ts"); + return done(); + }) + .catch(function(error) { + return done(error); + }); + }); + + test("#load: Should reject when readFile returns an error", function(done) { + const lcov = new Lcov( + { + lcovFileName: "test.ts", + coverageDecorationType: { + key: "testKey", + dispose() {} + }, + gutterDecorationType: { + key: "testKey2", + dispose() {} + } + }, + function(path, exclude, filesToFind) {}, + function(path: string, cb) { + assert.equal(path, "pathtofile"); + return cb(new Error("could not read from fs")); + } + ); + + lcov.load("pathtofile") + .then(function() { + return done(new Error("Expected error did not fire!")); + }) + .catch(function(error) { + if(error.name === "AssertionError") return done(error); + if(error.message === "could not read from fs") return done(); + return done(error); + }); + }); + + test("#load: Should return a data string", function(done) { + const lcov = new Lcov( + { + lcovFileName: "test.ts", + coverageDecorationType: { + key: "testKey", + dispose() {} + }, + gutterDecorationType: { + key: "testKey2", + dispose() {} + } + }, + function(path, exclude, filesToFind) {}, + function(path: string, cb) { + assert.equal(path, "pathtofile"); + return cb(null, "lcovhere"); + } + ); + + lcov.load("pathtofile") + .then(function(dataString) { + assert.equal(dataString, "lcovhere"); + return done(); + }) + .catch(function(error) { + return done(error); + }); + }); +}); \ No newline at end of file diff --git a/types/lcov-parse/index.d.ts b/types/lcov-parse/index.d.ts index 1bbe939..80343c4 100644 --- a/types/lcov-parse/index.d.ts +++ b/types/lcov-parse/index.d.ts @@ -1,25 +1,23 @@ -interface Detail { - hit: number, - line: number -} - -interface Lines { - details: Array - hit: number, - found: number -} +declare namespace parse { + function source(str: string, cb: (err: Error, data: Array) => void): void -interface LcovSection { - branches: Object, - file: string, - functions: Object, - lines: Lines -} + interface Detail { + hit: number, + line: number + } -declare function parse(file: string, cb: (err: Error, data: Array) => void): void + interface Lines { + details: Array, + hit: number, + found: number + } -declare namespace parse { - function source(str: string, cb: (err: Error, data: Array) => void) + interface LcovSection { + branches: Object, + file: string, + functions: Object, + lines: Lines + } } declare module "lcov-parse" {