Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,11 @@ export default defineConfig(
},
},
{
// Test doubles cast deliberately, and tests assert on values they know exist.
// Test doubles cast deliberately, implement async interfaces without awaiting, and tests
// assert on values they know exist.
files: ['tests/**/*.ts'],
rules: {
'@typescript-eslint/require-await': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
Expand Down
38 changes: 21 additions & 17 deletions src/ceedlingEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,24 +27,24 @@
parseFileListBullets,
testCaseFilterMatchedExactly,
} from './ceedlingOutputParsing';
import { buildCommandLine, Cancellation, CeedlingResult, CeedlingRunner } from './ceedlingProcess';
import { buildCommandLine, type Cancellation, type CeedlingResult, CeedlingRunner } from './ceedlingProcess';
import {
ALTERNATE_DEFAULT_PROJECT_FILE,
capabilitiesOf,
CeedlingCapabilities,
type CeedlingCapabilities,
CeedlingLoadError,
CeedlingProject,
type CeedlingProject,
DEFAULT_CPPUNIT_REPORT_FILENAME,
DEFAULT_PROJECT_FILE,
ProjectSetting,
ProjectState,
type ProjectSetting,
type ProjectState,
projectState,
projectsFromSettings,
} from './ceedlingProjectConfig';
import type { Logger } from './logger';
import type { FileDiagnostic, ProblemMatcher, ProblemMatchingPattern } from './problemMatcher';
import { allIds, CeedlingFileNode, CeedlingProjectNode, CeedlingTestFunction, leafIds, RunReporter } from './testModel';
import { CppUnitReport, parseCppUnitReport, reportedNames, testResults } from './testResults';
import type { FileDiagnostic, ProblemMatcher } from './problemMatcher';
import { allIds, type CeedlingFileNode, type CeedlingProjectNode, type CeedlingTestFunction, leafIds, type RunReporter } from './testModel';
import { type CppUnitReport, parseCppUnitReport, reportedNames, testResults } from './testResults';
import { discoverTests } from './unityTestDiscovery';

export { CeedlingLoadError } from './ceedlingProjectConfig';
Expand All @@ -66,11 +66,11 @@
}

export const nodeFileSystem: EngineFileSystem = {
exists: (filePath) => fs.existsSync(filePath),

Check warning on line 69 in src/ceedlingEngine.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

'fs.existsSync' is restricted from being used. Synchronous file I/O blocks the extension host thread. Use fs.promises
isDirectory: (filePath) => fs.statSync(filePath, { throwIfNoEntry: false })?.isDirectory() ?? false,

Check warning on line 70 in src/ceedlingEngine.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

'fs.statSync' is restricted from being used. Synchronous file I/O blocks the extension host thread. Use fs.promises
readFile: (filePath) => fs.promises.readFile(filePath, 'utf8'),
unlink: (filePath) => fs.promises.unlink(filePath),
mtimeMs: (filePath) => fs.statSync(filePath, { throwIfNoEntry: false })?.mtimeMs,

Check warning on line 73 in src/ceedlingEngine.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

'fs.statSync' is restricted from being used. Synchronous file I/O blocks the extension host thread. Use fs.promises
};

export type EngineDeps = {
Expand Down Expand Up @@ -230,7 +230,7 @@
const loaded = new Map<string, LoadedProject>();
const errors: string[] = [];
for (const project of this.candidates) {
const capabilities = capabilitiesOf(await this.checkCeedlingVersion(project));

Check warning on line 233 in src/ceedlingEngine.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

Unexpected `await` inside a loop
if (project.ambiguousProjectFile && capabilities.ceedlingYml) {
errors.push(
`Both project.yml and ceedling.yml exist in ${project.absPath}, so Ceedling can't tell which is project '${project.key}'. ` +
Expand All @@ -238,7 +238,7 @@
);
continue;
}
const resolved = await this.resolveConfig(project, capabilities);

Check warning on line 241 in src/ceedlingEngine.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

Unexpected `await` inside a loop
if (resolved === undefined) {
errors.push(
`Failed to resolve Ceedling's configuration for project '${project.key}' with 'ceedling dumpconfig'. ` +
Expand Down Expand Up @@ -267,7 +267,7 @@

const labels = this.prettyLabels();
for (const project of loaded.values()) {
project.files = await this.testFileNodes(project, await this.listTestFiles(project), labels);

Check warning on line 270 in src/ceedlingEngine.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

Unexpected `await` inside a loop

Check warning on line 270 in src/ceedlingEngine.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

Unexpected `await` inside a loop
}
this.projects = loaded;
const diagnosticsIds = new Set([...loaded.values()].flatMap((p) => [p.key, ...p.files.map((f) => fileDiagnosticsId(p.key, f.id))]));
Expand Down Expand Up @@ -381,8 +381,8 @@
const result = await this.ceedling(['version'], { cwd: project.absPath });
const reported = parseCeedlingVersionString(result.stdout);
const version = reported === undefined ? null : semver.coerce(reported);
this.logger.debug(`checkCeedlingVersion(${project.key}): ${reported}`);
if (version === null) {
this.logger.debug(`checkCeedlingVersion(${project.key}): ${reported ?? 'none'}`);
if (reported === undefined || version === null) {
throw new CeedlingLoadError(
`Could not run 'ceedling version' for project '${project.key}'. Check that Ceedling is installed and runs ` +
`in the configured shell (ceedlingExplorer.shellPath).\n${result.stdout}\n${result.stderr}`
Expand All @@ -394,13 +394,13 @@
`${MINIMUM_CEEDLING_VERSION} or higher. Please upgrade your Ceedling installation.`
);
}
return reported!;
return reported;
}

// The project's fully merged configuration, from Ceedling itself: project file, mixins, and
// plugin defaults, as a real build sees them. Ceedling 1.2.0 can write it to stdout. Earlier
// versions write only to a file, so this goes through a uniquely named temporary file.
private async resolveConfig(project: CeedlingProject, capabilities: CeedlingCapabilities): Promise<unknown | undefined> {
private async resolveConfig(project: CeedlingProject, capabilities: CeedlingCapabilities): Promise<unknown> {
if (capabilities.dumpconfigStdout) {
const result = await this.ceedling(['dumpconfig', '--stdout'], { project });
if (result.error) {
Expand Down Expand Up @@ -461,16 +461,18 @@
const options = { testPrefix: project.state.testPrefix, useParamTests: project.state.useParamTests };
const testLabel = pretty.tests ? this.labelRegex(() => buildTestLabelRegex(project.state.testPrefix)) : undefined;
const fileLabel = pretty.files ? this.labelRegex(() => buildFileLabelRegex(project.state.testFilePrefix)) : undefined;
const label = (regex: RegExp | undefined, text: string) => (regex && regex.exec(text)?.[1]) || text;
// `||`, not `??`: an empty capture, such as a file named only by its prefix, keeps the full text.
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- see above
const label = (regex: RegExp | undefined, text: string) => regex?.exec(text)?.[1] || text;
const nodes: CeedlingFileNode[] = [];
for (const file of files) {
const absPath = this.paths.resolve(project.absPath, file);
let discovered: ReturnType<typeof discoverTests>;
try {
discovered = discoverTests(source ?? await this.fs.readFile(absPath), options);

Check warning on line 472 in src/ceedlingEngine.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

Unexpected `await` inside a loop
} catch (e) {
// An unreadable file, or a :test_runner: :test_prefix: that isn't a valid regex.
this.logger.warn(`testFileNodes: skipping '${absPath}' (test prefix '${options.testPrefix}'): ${e}`);
this.logger.warn(`testFileNodes: skipping '${absPath}' (test prefix '${options.testPrefix}'): ${util.format(e)}`);
continue;
}
for (const warning of discovered.warnings) {
Expand All @@ -497,7 +499,7 @@
try {
return build();
} catch (e) {
this.logger.warn(`Pretty labels are unavailable: ${e}`);
this.logger.warn(`Pretty labels are unavailable: ${util.format(e)}`);
return undefined;
}
}
Expand Down Expand Up @@ -557,7 +559,8 @@
const diagnostics = this.problemMatcher.match(
result.stdout, result.stderr, project.absPath,
config.get<string>('problemMatching.mode', 'disabled'),
config.get<ProblemMatchingPattern[]>('problemMatching.patterns', []),
// Untyped user JSON. ProblemMatcher validates each pattern.
config.get<unknown[]>('problemMatching.patterns', []),
);
if (scope.kind === 'project') {
this.problemMatcher.retainIds((id) => !id.startsWith(`${project.key}::`));
Expand Down Expand Up @@ -608,7 +611,7 @@
// here, so each trailing part of the file's directory is tried, and the newest build wins.
private executableSubpath(project: LoadedProject, file: CeedlingFileNode): string {
const segments = file.id.split(/[\\/]/);
const name = segments.pop()!.replace(/\.[^.]*$/, '');
const name = (segments.pop() ?? file.id).replace(/\.[^.]*$/, '');
const executable = `${name}/${name}${project.state.executableExtension}`;
const outputDirectory = this.paths.resolve(project.absPath, project.state.buildRoot, 'test', 'out');
const [newest] = segments
Expand All @@ -630,7 +633,7 @@

private async deleteReports(project: LoadedProject): Promise<void> {
for (const reportPath of this.reportPaths(project).filter((p) => this.fs.exists(p))) {
await this.fs.unlink(reportPath).catch((e) => this.logger.warn(`Failed to delete stale report '${reportPath}': ${e}`));

Check warning on line 636 in src/ceedlingEngine.ts

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

Unexpected `await` inside a loop
}
}

Expand All @@ -656,6 +659,7 @@
return this.exclusive(async () => {
let succeeded = true;
for (const project of this.candidates) {
// eslint-disable-next-line no-await-in-loop -- one project at a time, under the build mutex
const result = await this.ceedling(args, { project, cancellation: token });
succeeded = succeeded && !result.error;
}
Expand Down
8 changes: 4 additions & 4 deletions src/ceedlingOutputParsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ export function parseFileListBullets(stdout: string): string[] {
return stdout.replace(/\r\n?/g, '\n').split('\n')
.map((line) => /^\s*[-•*]\s+(.*)$/.exec(line))
.filter((match): match is RegExpExecArray => match !== null)
.map((match) => match[1].trim());
.map((match) => (match[1] ?? '').trim());
}

// The version in `ceedling version` stdout, e.g. "1.0.0" or a build's "1.1.0-5bbbc8f". undefined
// when there is none. The caller decides what that means.
export function parseCeedlingVersionString(stdout: string): string | undefined {
const match = /^\s*Ceedling\s*(?:::|=>)\s*(.*)$/m.exec(stdout);
return match ? match[1].trim() : undefined;
return match?.[1]?.trim();
}

// Captures a test file's pretty label: its name after the configured prefix, without directory or
Expand All @@ -34,7 +34,7 @@ export function buildTestLabelRegex(testPrefix: string): RegExp {
// The function name within a reported test name, without its file or parametrized arguments:
// "test/foo.c::test_add(2, 3)" gives "test_add".
export function extractTestFunctionName(reportedName: string): string {
const afterFile = reportedName.includes('::') ? reportedName.split('::').pop()! : reportedName;
const afterFile = reportedName.split('::').pop() ?? reportedName;
const parenIndex = afterFile.indexOf('(');
return parenIndex === -1 ? afterFile : afterFile.slice(0, parenIndex);
}
Expand All @@ -52,7 +52,7 @@ export function testCaseFilterMatchedExactly(requestedFunctionName: string, repo
// whole. That version embeds the gdb text in the message instead of writing a log.
export function extractGdbLogReference(message: string): { text: string, logPath: string | undefined } {
const match = message.match(/\n?\(([^()\n]+\.gdb\.log)\)\s*$/);
if (!match || match.index === undefined) {
if (match?.index === undefined) {
return { text: message, logPath: undefined };
}
return { text: message.slice(0, match.index), logPath: match[1] };
Expand Down
37 changes: 20 additions & 17 deletions src/ceedlingProcess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,11 @@ export class CeedlingRunner {
return Promise.resolve({ error: null, stdout: '', stderr: '', cancelled: true });
}
return new Promise((resolve) => {
// Declared ahead of exec: a test double may call back before exec returns.
let child: child_process.ChildProcess | undefined;
let subscription: { dispose(): void } | undefined;
let finished = false;
let cancelled = false;
child = this.exec(
// Shared with the exit callback, which a test double may call before exec even returns.
// The child is tracked as live only if it is still running once exec returns.
const state: { tracked?: child_process.ChildProcess, subscription?: { dispose(): void }, finished: boolean, cancelled: boolean } =
{ finished: false, cancelled: false };
const child = this.exec(
buildCommandLine(invocation.command, invocation.args),
{
cwd: invocation.cwd,
Expand All @@ -89,22 +88,23 @@ export class CeedlingRunner {
maxBuffer: MAX_OUTPUT_BYTES,
},
(error, stdout, stderr) => {
finished = true;
if (child) this.live.delete(child);
subscription?.dispose();
state.finished = true;
if (state.tracked) this.live.delete(state.tracked);
state.subscription?.dispose();
resolve({
error,
stdout: invocation.stripAnsi ? stripAnsi(stdout) : stdout,
stderr: invocation.stripAnsi ? stripAnsi(stderr) : stderr,
cancelled,
cancelled: state.cancelled,
});
},
);
if (!finished) {
if (!state.finished) {
state.tracked = child;
this.live.add(child);
subscription = cancellation?.onCancellationRequested(() => {
cancelled = true;
this.stop(child!);
state.subscription = cancellation?.onCancellationRequested(() => {
state.cancelled = true;
this.stop(child);
});
}
});
Expand Down Expand Up @@ -159,8 +159,7 @@ export function descendantsOf(root: number, parents: ReadonlyMap<number, number>
}
const found: number[] = [];
const pending = [root];
while (pending.length > 0) {
const pid = pending.pop()!;
for (let pid = pending.pop(); pid !== undefined; pid = pending.pop()) {
found.push(pid);
pending.push(...(children.get(pid) ?? []));
}
Expand All @@ -171,15 +170,19 @@ export function descendantsOf(root: number, parents: ReadonlyMap<number, number>
// itself contain spaces and parentheses, so fields are read after its last closing parenthesis.
export function parseProcStatParent(stat: string): number | undefined {
const fields = stat.slice(stat.lastIndexOf(')') + 1).trim().split(/\s+/);
const parent = parseInt(fields[1], 10);
const parent = parseInt(fields[1] ?? '', 10);
return stat.includes(')') && Number.isFinite(parent) ? parent : undefined;
}

function linuxProcessParents(): Map<number, number> {
const parents = new Map<number, number>();
// Synchronous on purpose: a consistent snapshot of the tree before any kill. /proc is in memory,
// and this runs only on cancellation.
// eslint-disable-next-line no-restricted-properties -- a consistent snapshot, see above
for (const entry of fs.readdirSync('/proc')) {
if (!/^\d+$/.test(entry)) continue;
try {
// eslint-disable-next-line no-restricted-properties -- see above
const parent = parseProcStatParent(fs.readFileSync(`/proc/${entry}/stat`, 'utf8'));
if (parent !== undefined) parents.set(Number(entry), parent);
} catch {
Expand Down
4 changes: 2 additions & 2 deletions src/ceedlingProjectConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ function projectFromSetting(
let key = setting.path;
if (setting.path.endsWith('.yml')) {
const segments = setting.path.split(/[\\/]/);
configuredFile = segments.pop()!;
configuredFile = segments.pop() ?? setting.path;
directory = segments.join('/') || '.';
if (configuredFile !== DEFAULT_PROJECT_FILE && configuredFile !== ALTERNATE_DEFAULT_PROJECT_FILE) {
key = configuredFile.slice(0, -'.yml'.length);
Expand All @@ -137,7 +137,7 @@ function projectFiles(
const hasDefault = exists(paths.join(absPath, DEFAULT_PROJECT_FILE));
const hasAlternate = exists(paths.join(absPath, ALTERNATE_DEFAULT_PROJECT_FILE));
const isDefaultName = configuredFile === DEFAULT_PROJECT_FILE || configuredFile === ALTERNATE_DEFAULT_PROJECT_FILE;
const projectFileName = isDefaultName ? configuredFile! : (hasAlternate && !hasDefault ? ALTERNATE_DEFAULT_PROJECT_FILE : DEFAULT_PROJECT_FILE);
const projectFileName = isDefaultName ? configuredFile : (hasAlternate && !hasDefault ? ALTERNATE_DEFAULT_PROJECT_FILE : DEFAULT_PROJECT_FILE);
return {
projectFileName,
ymlFileName: configuredFile ?? projectFileName,
Expand Down
2 changes: 1 addition & 1 deletion src/logger.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import util from 'util';
import vscode from 'vscode';
import { LogLevel, LogOutputChannel } from 'vscode';
import { LogLevel, type LogOutputChannel } from 'vscode';

// Writes to the "Ceedling Explorer" log output channel, created on first use. Its level follows
// VS Code's own log level setting for the channel.
Expand Down
2 changes: 1 addition & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ function debugTestExecutableGuard(): never {
throw new Error("Start debugging from a test’s debug icon in the Testing view, not with F5 or via the Run and Debug panel.");
}

export async function activate(context: vscode.ExtensionContext) {
export function activate(context: vscode.ExtensionContext) {
isDevelopmentMode = context.extensionMode === vscode.ExtensionMode.Development;
extensionPath = context.extensionPath;
const workspaceFolderCount = vscode.workspace.workspaceFolders?.length ?? 0;
Expand Down
15 changes: 7 additions & 8 deletions src/problemMatcher.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
Diagnostic,
DiagnosticCollection,
type DiagnosticCollection,
DiagnosticSeverity,
Position,
Range,
Expand All @@ -10,13 +10,12 @@ import {
import {
getPatternProblems,
getPatternsPreset,
MatchedProblem,
type MatchedProblem,
normalizePatterns,
ProblemMatchingPattern,
type ProblemMatchingPattern,
} from './problemMatching';
import { Logger } from './logger';

export { ProblemMatchingPattern } from './problemMatching';
import util from 'util';
import { type Logger } from './logger';

// A Diagnostic with the absolute path of the file it belongs to.
export interface FileDiagnostic {
Expand Down Expand Up @@ -52,7 +51,7 @@ export class ProblemMatcher {
}

// Every problem the configured patterns find in a run's output. No state changes.
match(stdout: string, stderr: string, projectPath: string, mode: string, patterns: ProblemMatchingPattern[]): FileDiagnostic[] {
match(stdout: string, stderr: string, projectPath: string, mode: string, patterns: readonly unknown[]): FileDiagnostic[] {
let normalizedPatterns: ProblemMatchingPattern[];
if (mode === "patterns") {
const { valid, invalid } = normalizePatterns(patterns);
Expand All @@ -69,7 +68,7 @@ export class ProblemMatcher {
try {
fileDiagnostics.push(...getPatternProblems(stdout, stderr, projectPath, pattern).map(toFileDiagnostic));
} catch (e) {
this.logger.warn(`match: pattern threw, likely an invalid regexp (${pattern.regexp}): ${e}`);
this.logger.warn(`match: pattern threw, likely an invalid regexp (${pattern.regexp}): ${util.format(e)}`);
}
}
this.logger.debug(`match: mode=${mode}, patterns=${normalizedPatterns.length}, diagnostics=${fileDiagnostics.length}`);
Expand Down
Loading
Loading