src/goCover.ts (434 lines of code) (raw):

/* eslint-disable no-useless-escape */ /* eslint-disable no-prototype-builtins */ /* eslint-disable @typescript-eslint/no-explicit-any */ /*--------------------------------------------------------- * Copyright (C) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See LICENSE in the project root for license information. *--------------------------------------------------------*/ 'use strict'; import fs = require('fs'); import path = require('path'); import vscode = require('vscode'); import { CommandFactory } from './commands'; import { getGoConfig } from './config'; import { isModSupported } from './goModules'; import { getImportPathToFolder } from './goPackages'; import { getTestFlags, goTest, showTestOutput, TestConfig } from './testUtils'; import { fixDriveCasingInWindows } from './utils/pathUtils'; let gutterSvgs: { [key: string]: string }; interface Highlight { top: vscode.TextEditorDecorationType; mid: vscode.TextEditorDecorationType; bot: vscode.TextEditorDecorationType; all: vscode.TextEditorDecorationType; } export let decorators: { type: 'highlight' | 'gutter'; coveredGutter: vscode.TextEditorDecorationType; uncoveredGutter: vscode.TextEditorDecorationType; coveredHighlight: Highlight; uncoveredHighlight: Highlight; }; export let decoratorConfig: { [key: string]: any; type: 'highlight' | 'gutter'; coveredHighlightColor: string; uncoveredHighlightColor: string; coveredBorderColor: string; uncoveredBorderColor: string; coveredGutterStyle: string; uncoveredGutterStyle: string; }; // a list of modified, unsaved go files with actual code edits (rather than comment edits) let modifiedFiles: { [key: string]: boolean; } = {}; /** * Initializes the decorators used for Code coverage. * @param ctx The extension context */ export function initCoverageDecorators(ctx: vscode.ExtensionContext) { // Initialize gutter svgs gutterSvgs = { blockred: ctx.asAbsolutePath('media/gutter-blockred.svg'), blockgreen: ctx.asAbsolutePath('media/gutter-blockgreen.svg'), blockblue: ctx.asAbsolutePath('media/gutter-blockblue.svg'), blockyellow: ctx.asAbsolutePath('media/gutter-blockyellow.svg'), slashred: ctx.asAbsolutePath('media/gutter-slashred.svg'), slashgreen: ctx.asAbsolutePath('media/gutter-slashgreen.svg'), slashblue: ctx.asAbsolutePath('media/gutter-slashblue.svg'), slashyellow: ctx.asAbsolutePath('media/gutter-slashyellow.svg'), verticalred: ctx.asAbsolutePath('media/gutter-vertred.svg'), verticalgreen: ctx.asAbsolutePath('media/gutter-vertgreen.svg'), verticalblue: ctx.asAbsolutePath('media/gutter-vertblue.svg'), verticalyellow: ctx.asAbsolutePath('media/gutter-vertyellow.svg') }; const goConfig = getGoConfig(); updateCodeCoverageDecorators(goConfig.get('coverageDecorator')); } /** * Updates the decorators used for Code coverage. * @param coverageDecoratorConfig The coverage decorated as configured by the user */ export function updateCodeCoverageDecorators(coverageDecoratorConfig: any) { // These defaults are chosen to be distinguishable in nearly any color scheme (even Red) // as well as by people who have difficulties with color perception. // It appears that the contributions in package.json are only used to check what users // put in settings.json, while the defaults come from the defaults section of // go.coverageDecorator in package.json. decoratorConfig = { type: 'highlight', coveredHighlightColor: 'rgba(64,128,128,0.5)', coveredBorderColor: 'rgba(64,128,128,1.0)', uncoveredHighlightColor: 'rgba(128,64,64,0.25)', uncoveredBorderColor: 'rgba(128,64,64,1.0)', coveredGutterStyle: 'blockblue', uncoveredGutterStyle: 'slashyellow' }; // Update from configuration. if (typeof coverageDecoratorConfig !== 'object') { vscode.window.showWarningMessage("invalid go.coverageDecorator type, expected an 'object'"); } else { for (const k in coverageDecoratorConfig) { if (coverageDecoratorConfig.hasOwnProperty(k)) { decoratorConfig[k] = coverageDecoratorConfig[k]; } else { vscode.window.showWarningMessage(`invalid coverage parameter ${k}`); } } } setDecorators(); vscode.window.visibleTextEditors.forEach(applyCodeCoverage); } export function setDecorators() { disposeDecorators(); if (!decorators) { initForTest(); } // only happens in tests const f = (x: { overviewRulerColor: string; backgroundColor: string }, arg: string) => { const y = { overviewRulerLane: 2, borderStyle: arg, borderWidth: '2px' }; return Object.assign(y, x); }; const cov = { overviewRulerColor: 'green', backgroundColor: decoratorConfig.coveredHighlightColor, borderColor: decoratorConfig.coveredBorderColor }; const uncov = { overviewRulerColor: 'red', backgroundColor: decoratorConfig.uncoveredHighlightColor, borderColor: decoratorConfig.uncoveredBorderColor }; const ctop = f(cov, 'solid solid none solid'); const cmid = f(cov, 'none solid none solid'); const cbot = f(cov, 'none solid solid solid'); const cone = f(cov, 'solid solid solid solid'); const utop = f(uncov, 'solid solid none solid'); const umid = f(uncov, 'none solid none solid'); const ubot = f(uncov, 'none solid solid solid'); const uone = f(uncov, 'solid solid solid solid'); decorators = { type: decoratorConfig.type, coveredGutter: vscode.window.createTextEditorDecorationType({ gutterIconPath: gutterSvgs[decoratorConfig.coveredGutterStyle] }), uncoveredGutter: vscode.window.createTextEditorDecorationType({ gutterIconPath: gutterSvgs[decoratorConfig.uncoveredGutterStyle] }), coveredHighlight: { all: vscode.window.createTextEditorDecorationType(cone), top: vscode.window.createTextEditorDecorationType(ctop), mid: vscode.window.createTextEditorDecorationType(cmid), bot: vscode.window.createTextEditorDecorationType(cbot) }, uncoveredHighlight: { all: vscode.window.createTextEditorDecorationType(uone), top: vscode.window.createTextEditorDecorationType(utop), mid: vscode.window.createTextEditorDecorationType(umid), bot: vscode.window.createTextEditorDecorationType(ubot) } }; } /** * Disposes decorators so that the current coverage is removed from the editor. */ function disposeDecorators() { if (decorators) { decorators.coveredGutter.dispose(); decorators.uncoveredGutter.dispose(); decorators.coveredHighlight.all.dispose(); decorators.coveredHighlight.top.dispose(); decorators.coveredHighlight.mid.dispose(); decorators.coveredHighlight.bot.dispose(); decorators.uncoveredHighlight.all.dispose(); decorators.uncoveredHighlight.top.dispose(); decorators.uncoveredHighlight.mid.dispose(); decorators.uncoveredHighlight.bot.dispose(); } } export interface CoverageData { uncoveredOptions: vscode.DecorationOptions[]; coveredOptions: vscode.DecorationOptions[]; } let coverageData: { [key: string]: CoverageData } = {}; // actual file path to the coverage data. let isCoverageApplied = false; export function emptyCoverageData(): CoverageData { return { uncoveredOptions: [], coveredOptions: [] }; } /** * Clear the coverage on all files */ export function clearCoverage() { coverageData = {}; disposeDecorators(); isCoverageApplied = false; } /** * Extract the coverage data from the given cover profile & apply them on the files in the open editors. * @param coverProfilePath Path to the file that has the cover profile data * @param packageDirPath Absolute path of the package for which the coverage was calculated * @param dir Directory to execute go list in */ export function applyCodeCoverageToAllEditors(coverProfilePath: string, dir?: string): Promise<void> { const v = new Promise<void>((resolve, reject) => { try { const showCounts = getGoConfig().get('coverShowCounts') as boolean; const coveragePath = new Map<string, CoverageData>(); // <filename> from the cover profile to the coverage data. // Clear existing coverage files clearCoverage(); // collect the packages named in the coverage file const seenPaths = new Set<string>(); // for now read synchronously and hope for no errors const contents = fs.readFileSync(coverProfilePath).toString(); contents.split('\n').forEach((line) => { // go test coverageprofile generates output: // filename:StartLine.StartColumn,EndLine.EndColumn Hits CoverCount // where the filename is either the import path + '/' + base file name, or // the actual file path (either absolute or starting with .) // See https://golang.org/issues/40251. // // The first line will be like "mode: set" which we will ignore. // TODO: port https://golang.org/cl/179377 for faster parsing. const parse = line.match(/^(\S+)\:(\d+)\.(\d+)\,(\d+)\.(\d+)\s(\d+)\s(\d+)/); if (!parse) { return; } let filename = parse[1]; if (filename.startsWith('.' + path.sep)) { // If it's a relative file path, convert it to an absolute path. // From now on, we can assume that it's a real file name if it is // an absolute path. filename = path.resolve(filename); } // If this is not a real file name, that's package_path + file name, // Record it in seenPaths for `go list` call to resolve package path -> // directory mapping. if (!path.isAbsolute(filename)) { const lastSlash = filename.lastIndexOf('/'); if (lastSlash !== -1) { seenPaths.add(filename.slice(0, lastSlash)); } } // and fill in coveragePath const coverage = coveragePath.get(parse[1]) || emptyCoverageData(); // When line directive is used this information is artificial and // the source code file can be non-existent or wrong (go.dev/issues/41222). // There is no perfect way to guess whether the line/col in coverage profile // is bogus. At least, we know that 0 or negative values are not true line/col. const startLine = parseInt(parse[2], 10); const startCol = parseInt(parse[3], 10); const endLine = parseInt(parse[4], 10); const endCol = parseInt(parse[5], 10); if (startLine < 1 || startCol < 1 || endLine < 1 || endCol < 1) { return; } const range = new vscode.Range( // Convert lines and columns to 0-based startLine - 1, startCol - 1, endLine - 1, endCol - 1 ); const counts = parseInt(parse[7], 10); // If is Covered (CoverCount > 0) if (counts > 0) { coverage.coveredOptions.push(...elaborate(range, counts, showCounts)); } else { coverage.uncoveredOptions.push(...elaborate(range, counts, showCounts)); } coveragePath.set(filename, coverage); }); getImportPathToFolder([...seenPaths], dir).then((pathsToDirs) => { createCoverageData(pathsToDirs, coveragePath); setDecorators(); vscode.window.visibleTextEditors.forEach(applyCodeCoverage); resolve(); }); } catch (e) { vscode.window.showInformationMessage((e as any).msg); reject(e); } }); return v; } // add decorations to the range export function elaborate(r: vscode.Range, count: number, showCounts: boolean): vscode.DecorationOptions[] { // irrelevant for "gutter" if (!decorators || decorators.type === 'gutter') { return [{ range: r }]; } const ans: vscode.DecorationOptions[] = []; const dc = decoratorConfig; const backgroundColor = [dc.uncoveredHighlightColor, dc.coveredHighlightColor]; const txt: vscode.ThemableDecorationAttachmentRenderOptions = { contentText: count > 0 && showCounts ? `--${count}--` : '', backgroundColor: backgroundColor[count === 0 ? 0 : 1] }; const v: vscode.DecorationOptions = { range: r, hoverMessage: `${count} executions`, renderOptions: { before: txt } }; ans.push(v); return ans; } export function createCoverageData(pathsToDirs: Map<string, string>, coveragePath: Map<string, CoverageData>) { coveragePath.forEach((cd, ip) => { if (path.isAbsolute(ip)) { setCoverageDataByFilePath(ip, cd); return; } const lastSlash = ip.lastIndexOf('/'); if (lastSlash === -1) { setCoverageDataByFilePath(ip, cd); return; } const maybePkgPath = ip.slice(0, lastSlash); const fileDir = pathsToDirs.get(maybePkgPath) || path.resolve(maybePkgPath); const file = fileDir + path.sep + ip.slice(lastSlash + 1); setCoverageDataByFilePath(file, cd); }); } /** * Set the object that holds the coverage data for given file path. * @param filePath * @param data */ function setCoverageDataByFilePath(filePath: string, data: CoverageData) { if (filePath.startsWith('_')) { filePath = filePath.substr(1); } if (process.platform === 'win32') { const parts = filePath.split('/'); if (parts.length) { filePath = parts.join(path.sep); } } coverageData[filePath] = data; } /** * Apply the code coverage highlighting in given editor * @param editor */ export function applyCodeCoverage(editor: vscode.TextEditor | undefined) { if (!editor || editor.document.languageId !== 'go' || editor.document.fileName.endsWith('_test.go')) { return; } let doc = editor.document.fileName; if (path.isAbsolute(doc)) { doc = fixDriveCasingInWindows(doc); } const cfg = getGoConfig(editor.document.uri); const coverageOptions = cfg['coverageOptions']; for (const filename in coverageData) { if (doc !== fixDriveCasingInWindows(filename)) { continue; } isCoverageApplied = true; const cd = coverageData[filename]; if (coverageOptions === 'showCoveredCodeOnly' || coverageOptions === 'showBothCoveredAndUncoveredCode') { if (decorators.type === 'gutter') { editor.setDecorations(decorators.coveredGutter, cd.coveredOptions); } else { detailed(editor, decorators.coveredHighlight, cd.coveredOptions); } } if (coverageOptions === 'showUncoveredCodeOnly' || coverageOptions === 'showBothCoveredAndUncoveredCode') { if (decorators.type === 'gutter') { editor.setDecorations(decorators.uncoveredGutter, cd.uncoveredOptions); } else { detailed(editor, decorators.uncoveredHighlight, cd.uncoveredOptions); } } } } function detailed(editor: vscode.TextEditor, h: Highlight, opts: vscode.DecorationOptions[]) { const tops: vscode.DecorationOptions[] = []; const mids: vscode.DecorationOptions[] = []; const bots: vscode.DecorationOptions[] = []; const alls: vscode.DecorationOptions[] = []; opts.forEach((opt) => { const r = opt.range; if (r.start.line === r.end.line) { alls.push(opt); return; } for (let line = r.start.line; line <= r.end.line; line++) { if (line === r.start.line) { const use: vscode.DecorationOptions = { range: editor.document.validateRange( new vscode.Range(line, r.start.character, line, Number.MAX_SAFE_INTEGER) ), hoverMessage: opt.hoverMessage, renderOptions: opt.renderOptions }; tops.push(use); } else if (line < r.end.line) { const use = { range: editor.document.validateRange(new vscode.Range(line, 0, line, Number.MAX_SAFE_INTEGER)), hoverMessage: opt.hoverMessage }; mids.push(use); } else { const use = { range: new vscode.Range(line, 0, line, r.end.character), hoverMessage: opt.hoverMessage }; bots.push(use); } } }); if (tops.length > 0) { editor.setDecorations(h.top, tops); } if (mids.length > 0) { editor.setDecorations(h.mid, mids); } if (bots.length > 0) { editor.setDecorations(h.bot, bots); } if (alls.length > 0) { editor.setDecorations(h.all, alls); } } /** * Listener for file save that clears potential stale coverage data. * Local cache tracks files with changes outside of comments to determine * files for which the save event can cause stale coverage data. * @param e TextDocument */ export function removeCodeCoverageOnFileSave(e: vscode.TextDocument) { if (e.languageId !== 'go' || !isCoverageApplied) { return; } if (vscode.window.visibleTextEditors.every((editor) => editor.document !== e)) { return; } if (modifiedFiles[e.fileName]) { clearCoverage(); modifiedFiles = {}; // reset the list of modified files } } /** * Listener for file change that tracks files with changes outside of comments * to determine files for which an eventual save can cause stale coverage data. * @param e TextDocumentChangeEvent */ export function trackCodeCoverageRemovalOnFileChange(e: vscode.TextDocumentChangeEvent) { if (e.document.languageId !== 'go' || !e.contentChanges.length || !isCoverageApplied) { return; } if (vscode.window.visibleTextEditors.every((editor) => editor.document !== e.document)) { return; } if (isPartOfComment(e)) { return; } modifiedFiles[e.document.fileName] = true; } /** * If current editor has Code coverage applied, then remove it. * Else run tests to get the coverage and apply. */ export const toggleCoverageCurrentPackage: CommandFactory = () => async () => { const editor = vscode.window.activeTextEditor; if (!editor) { vscode.window.showInformationMessage('No editor is active.'); return; } if (isCoverageApplied) { clearCoverage(); return; } const goConfig = getGoConfig(); const cwd = path.dirname(editor.document.uri.fsPath); const testFlags = getTestFlags(goConfig); const isMod = await isModSupported(editor.document.uri); const testConfig: TestConfig = { goConfig, dir: cwd, flags: testFlags, background: true, isMod, applyCodeCoverage: true }; return goTest(testConfig).then((success) => { if (!success) { showTestOutput(); } }); }; export function isPartOfComment(e: vscode.TextDocumentChangeEvent): boolean { return e.contentChanges.every((change) => { // We cannot be sure with using just regex on individual lines whether a multi line change is part of a comment or not // So play it safe and treat it as not a comment if (!change.range.isSingleLine || change.text.includes('\n')) { return false; } const text = e.document.lineAt(change.range.start).text; const idx = text.search('//'); return idx > -1 && idx <= change.range.start.character; }); } // These routines enable testing without starting an editing session. export function coverageFilesForTest(): { [key: string]: CoverageData } { return coverageData; } export function initForTest() { if (!decoratorConfig) { // this code is unnecessary except for testing, where there may be no workspace // nor the normal flow of initializations const x = 'rgba(0,0,0,0)'; if (!gutterSvgs) { gutterSvgs = { x }; } decoratorConfig = { type: 'highlight', coveredHighlightColor: x, uncoveredHighlightColor: x, coveredBorderColor: x, uncoveredBorderColor: x, coveredGutterStyle: x, uncoveredGutterStyle: x }; } }