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
24 changes: 14 additions & 10 deletions docs/Development.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Two reports point at what to fix next: lint findings and unit-test coverage gaps

- Run `just coverage`, or `npm run test:coverage`. It prints a table per `src/*.ts` file: statement, branch, function, and line coverage, plus the uncovered line numbers.
- `coverage/index.html` shows each file with its uncovered lines highlighted. `coverage/lcov.info` and `coverage/coverage-summary.json` serve other tools.
- Only unit tests are measured. VS Code adapters such as `testController.ts` and `main.ts` show 0% until their logic moves into tested modules or integration tests cover it.
- Only unit tests are measured. The VS Code adapters, `testController.ts`, `problemMatcher.ts`, `main.ts`, and `logger.ts`, show 0%. Their decisions live in tested pure modules, and what remains is wiring to the editor.

The unit tests compile into `out-test/`, apart from the extension bundle in `out/`. The test build compiles every file in `src/`, so a file no test touches still appears in the coverage table.

Expand All @@ -46,21 +46,25 @@ The extension connects Ceedling’s command line to VS Code’s native Testing A

### Orientation

The code has two layers. Most modules have no `vscode` dependency and are unit-tested in plain Node. A thin layer of VS Code adapters wires them to the editor.
The code has two layers. Most modules have no `vscode` dependency and are unit-tested in plain Node. A thin layer of VS Code adapters wires them to the editor. A decision an adapter makes belongs in a pure module, where a unit test can reach it.

| Module | Layer | Responsibility |
|---|---|---|
| `src/main.ts` | VS Code | Activation, commands, one controller per workspace folder |
| `src/testController.ts` | VS Code | TestItems, run and debug profiles, reporting into a `TestRun` |
| `src/problemMatcher.ts` | VS Code | The Problems panel’s `DiagnosticCollection`, keyed by run scope |
| `src/testController.ts` | VS Code | TestItems, run and debug profiles, watchers, and the `TestRun` sink |
| `src/problemMatcher.ts` | VS Code | Publishes diagnostics to the Problems panel’s `DiagnosticCollection` |
| `src/logger.ts` | VS Code | The “Ceedling Explorer” log output channel |
| `src/ceedlingEngine.ts` | Pure | Orchestration: discover, run, debug build, clean, clobber |
| `src/ceedlingProjectConfig.ts` | Pure | Settings → projects; resolved configuration → `ProjectState` |
| `src/ceedlingProcess.ts` | Pure | Running Ceedling, with per-run cancellation |
| `src/unityTestDiscovery.ts` | Pure | Test functions and cases in source, named as Unity names them |
| `src/testResults.ts` | Pure | The CppUnit report, mapped onto discovered ids |
| `src/runPlan.ts` | Pure | A run request → the fewest Ceedling invocations |
| `src/testModel.ts` | Pure | The test tree types and the `RunReporter` interface |
| `src/testModel.ts` | Pure | The test tree types, the `RunReporter` interface, and the controller’s label |
| `src/testReporting.ts` | Pure | A `RunReporter` that resolves ids to items and builds plain messages for a `RunSink` |
| `src/debugLaunch.ts` | Pure | The file a debug request debugs, and its launch configuration |
| `src/watchPolicy.ts` | Pure | What settings changes, file events, and edits make the controller do |
| `src/diagnosticsStore.ts` | Pure | Each run scope’s diagnostics, shown by file without duplicates |
| `src/singleFlight.ts` | Pure | Coalesces overlapping discovery requests into one rerun |
| `src/concurrency.ts` | Pure | Grouped concurrent mapping, a lock per key, and a limit shared by every caller |
| `src/ceedlingOutputParsing.ts` | Pure | Ceedling stdout, report names, and pretty labels |
Expand Down Expand Up @@ -88,9 +92,9 @@ The engine replaces its `projects` map only after every project loads. A failed
The controller keeps the tree current without polling. Its refreshes go through a `SingleFlight`, so startup, watchers, and settings changes arriving together cause one discovery plus at most one rerun.

- **Configuration files.** `CeedlingEngine.configurationFiles()` lists every file a project’s configuration can come from, existing or not: both default project files, any alternate project file, and file-path mixins. A `FileSystemWatcher` on each triggers discovery, so creating a project file in an empty folder starts one.
- **Test sources.** A watcher on each project directory’s `**/*.c` reparses a changed test file alone, through `rediscoverFile()`, with no Ceedling call. A created file that `mayBeTestFile()` accepts, or a deleted test file, triggers discovery, since only `ceedling files:test` knows the full list.
- **Unsaved edits.** `onDidChangeTextDocument` reparses an open test file from its buffer once typing pauses.
- **Settings.** A change to a setting in `DISCOVERY_SETTINGS` triggers discovery. Others apply on the next run.
- **Test sources.** A watcher on each project directory’s `**/*.c` reparses a changed test file alone, through `rediscoverFile()`, with no Ceedling call. A created file that `mayBeTestFile()` accepts, or a deleted test file, triggers discovery, since only `ceedling files:test` knows the full list. `sourceEventEffect()` makes that choice.
- **Unsaved edits.** `onDidChangeTextDocument` reparses an open test file from its buffer once typing pauses, through a `Debouncer` keyed by file.
- **Settings.** `settingsChangeEffects()` decides. A change to a setting that shapes discovery, such as `projects` or `mixins`, triggers discovery. Turning problem matching off clears its diagnostics. Others apply on the next run.

The TestController itself exists only while discovery finds a project, so a folder without one adds nothing to the Testing view.

Expand All @@ -100,7 +104,7 @@ A run turns the request into a plan, then reports each plan entry’s results.

- **Planning.** `buildRunPlan(projects, include, exclude)` returns `RunPlanEntry`s of three kinds. A `project` entry runs `test:all`. A `file` entry runs `test:<file>`. A `function` entry adds `--test-case=<name>`. Excluded items narrow the plan to function granularity at most. The reporter drops their results.
- **Running.** `runTests()` marks every id started, then takes its project directory’s lock. `buildAndRun()` deletes stale reports in `artifacts/test` and `artifacts/gcov`, runs Ceedling, appends the transcript to the run’s output, updates diagnostics, and reads the newest report.
- **Reporting.** `testResults(report, leafIds)` yields `passed`, `skipped`, `failed`, and `unreported` results. An `unreported` id gets an error naming the likely cause: discovery and Ceedling disagree about its name. With no report at all, every id errors, and the most severe compiler diagnostic leads the message.
- **Reporting.** `testResults(report, leafIds)` yields `passed`, `skipped`, `failed`, and `unreported` results. An `unreported` id gets an error naming the likely cause: discovery and Ceedling disagree about its name. With no report at all, every id errors, and the most severe compiler diagnostic leads the message. The controller’s reporter, from `createRunReporter()`, maps each id to its TestItem. It drops results for excluded items and, with a warning, for unknown ids, and turns a crash’s gdb log path into a link.
- **Edge cases.** A `--test-case` run whose report also names another function, from Ceedling’s substring filter, reruns the whole file. A cancelled run kills only its own process tree, through `CeedlingRunner`, and marks its tests skipped.

**One Ceedling command per project directory at a time.** Every Ceedling command that loads a project, `dumpconfig` and `files:test` included, rewrites the vendored Unity and CMock sources under `build/vendor/` in place. A second command in the same directory can leave a build compiling a half-written header. So `dumpconfig`, `files:test`, runs, debug builds, and clean/clobber each hold their directory’s lock (`KeyedLock`, via `inDirectory()`). `ceedling version` loads no project and needs no lock. Different directories proceed independently, so builds in separate projects may overlap.
Expand All @@ -109,7 +113,7 @@ Diagnostics are keyed by run scope: the project key for a project run, `projectK

### Debugging

`handleDebug()` debugs one test file. `compileForDebug()` runs the file exactly as a run does and reports its real results. It then returns the executable’s path relative to Ceedling’s test output directory, `name/name<executableExtension>`. `resolveLaunchConfig()` splices that path into every `${command:ceedlingExplorer.debugTestExecutable}` in the named launch configuration. A unique `__ceedlingDebugRequest` value in that configuration identifies its debug session among any others.
`handleDebug()` debugs one test file. `compileForDebug()` runs the file exactly as a run does and reports its real results. It then returns the executable’s path relative to Ceedling’s test output directory, `name/name<executableExtension>`. `resolveLaunchConfig()` in `debugLaunch.ts` splices that path into every `${command:ceedlingExplorer.debugTestExecutable}` in a copy of the named launch configuration. A unique `__ceedlingDebugRequest` value in that configuration identifies its debug session among any others.

### Test seams

Expand Down
46 changes: 46 additions & 0 deletions src/debugLaunch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// What a Testing view debug request debugs, and the launch configuration that debugs it. It has
// no `vscode` dependency. The controller starts the debug session.

import { type RunPlanEntry } from './runPlan';
import { type CeedlingFileNode } from './testModel';

// The launch.json token that stands for the built test executable, relative to Ceedling's test
// output directory.
const EXECUTABLE_TOKEN = /\$\{command:ceedlingExplorer\.debugTestExecutable\}/g;

// Ceedling builds a whole test file into one executable, so a request covering several files
// debugs the first. `severalFiles` says the rest were left out.
export function debugTarget(plan: readonly RunPlanEntry[]): { file: CeedlingFileNode, severalFiles: boolean } | undefined {
const files = plan.flatMap((entry) => entry.kind === 'project' ? entry.files : [entry.file]);
const file = files[0];
if (!file) {
return undefined;
}
return { file, severalFiles: new Set(files.map((f) => `${f.projectKey}::${f.id}`)).size > 1 };
}

// A copy of the named configuration with the executable's path spliced into every occurrence of
// the token. VS Code never resolves the token itself, so debugging doesn't depend on when it
// would. undefined when launch.json has no configuration of that name.
export function resolveLaunchConfig<Config extends { name: string }>(configurations: readonly Config[], name: string, executableSubpath: string): Config | undefined {
const found = configurations.find((c) => c.name === name);
if (!found) {
return undefined;
}
return splice(found, executableSubpath) as Config;
}

// A deep copy with the token replaced in every string. Replacing within strings, rather than in
// serialized JSON, keeps a backslash or quote in the path from being read as JSON escaping.
function splice(value: unknown, executableSubpath: string): unknown {
if (typeof value === 'string') {
return value.replace(EXECUTABLE_TOKEN, () => executableSubpath);
}
if (Array.isArray(value)) {
return value.map((element) => splice(element, executableSubpath));
}
if (typeof value === 'object' && value !== null) {
return Object.fromEntries(Object.entries(value).map(([key, element]) => [key, splice(element, executableSubpath)]));
}
return value;
}
50 changes: 50 additions & 0 deletions src/diagnosticsStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// The diagnostics each run reported, kept by id: a project, or one of its files. The Problems
// panel shows their union, by file, without duplicates, since a project run and a file run can
// report the same compiler error. It has no `vscode` dependency. ProblemMatcher publishes
// `byFile()` to a DiagnosticCollection.
export class DiagnosticsStore<D extends { file: string }> {
private readonly entries = new Map<string, D[]>();

constructor(private readonly same: (a: D, b: D) => boolean) { }

set(id: string, diagnostics: D[]): void {
this.entries.set(id, diagnostics);
}

// Keeps only the diagnostics under `id` that `keep` accepts. False when `id` has none.
retain(id: string, keep: (diagnostic: D) => boolean): boolean {
const current = this.entries.get(id);
if (!current) {
return false;
}
this.entries.set(id, current.filter(keep));
return true;
}

// Keeps only the ids `keep` accepts, e.g. dropping a deleted test file's entry.
retainIds(keep: (id: string) => boolean): void {
for (const id of [...this.entries.keys()]) {
if (!keep(id)) {
this.entries.delete(id);
}
}
}

clear(): void {
this.entries.clear();
}

byFile(): Map<string, D[]> {
const byFile = new Map<string, D[]>();
for (const diagnostics of this.entries.values()) {
for (const diagnostic of diagnostics) {
const shown = byFile.get(diagnostic.file) ?? [];
if (!shown.some((existing) => this.same(existing, diagnostic))) {
shown.push(diagnostic);
}
byFile.set(diagnostic.file, shown);
}
}
return byFile;
}
}
6 changes: 3 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import vscode from 'vscode';
import { CeedlingTestController } from './testController';
import { Logger } from './logger';
import { CeedlingTestController } from './testController';
import { controllerLabel } from './testModel';

const logger = new Logger();
// Keyed by folder URI. VS Code reuses folder objects in practice but doesn't promise to.
const controllers = new Map<string, CeedlingTestController>();
let isDevelopmentMode = false;
let extensionPath = '';

// The Testing view names each folder's tests when there is more than one folder.
function labelFor(workspaceFolder: vscode.WorkspaceFolder): string {
return (vscode.workspace.workspaceFolders?.length ?? 0) > 1 ? `Ceedling (${workspaceFolder.name})` : 'Ceedling';
return controllerLabel(workspaceFolder.name, vscode.workspace.workspaceFolders?.length ?? 0);
}

function addWorkspaceFolder(workspaceFolder: vscode.WorkspaceFolder): void {
Expand Down
37 changes: 11 additions & 26 deletions src/problemMatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type ProblemMatchingPattern,
} from './problemMatching';
import util from 'util';
import { DiagnosticsStore } from './diagnosticsStore';
import { type Logger } from './logger';

// A Diagnostic with the absolute path of the file it belongs to.
Expand All @@ -39,11 +40,11 @@ function toFileDiagnostic(problem: MatchedProblem): FileDiagnostic {
}

// The Problems panel side of problem matching. Pattern matching itself lives in the pure
// problemMatching.ts. This class holds the vscode state: the DiagnosticCollection, and each id's
// diagnostics. An id is whatever a run keys its diagnostics under, a project or one of its files.
// The panel shows the union of every id's diagnostics, without duplicates.
// problemMatching.ts, and each id's diagnostics in DiagnosticsStore. An id is whatever a run keys
// its diagnostics under, a project or one of its files. This class publishes the store to the
// DiagnosticCollection after every change.
export class ProblemMatcher {
private readonly entries = new Map<string, FileDiagnostic[]>();
private readonly store = new DiagnosticsStore<FileDiagnostic>((a, b) => sameDiagnostic(a.diagnostic, b.diagnostic));
private readonly diagnosticCollection: DiagnosticCollection;

constructor(private readonly logger: Logger) {
Expand Down Expand Up @@ -76,31 +77,25 @@ export class ProblemMatcher {
}

set(id: string, diagnostics: FileDiagnostic[]): void {
this.entries.set(id, diagnostics);
this.store.set(id, diagnostics);
this.publish();
}

// Keeps only the diagnostics under `id` that `keep` accepts.
retain(id: string, keep: (diagnostic: FileDiagnostic) => boolean): void {
const current = this.entries.get(id);
if (current) {
this.entries.set(id, current.filter(keep));
if (this.store.retain(id, keep)) {
this.publish();
}
}

// Keeps only the ids `keep` accepts, e.g. dropping a deleted test file's entry.
retainIds(keep: (id: string) => boolean): void {
for (const id of [...this.entries.keys()]) {
if (!keep(id)) {
this.entries.delete(id);
}
}
this.store.retainIds(keep);
this.publish();
}

clear(): void {
this.entries.clear();
this.store.clear();
this.diagnosticCollection.clear();
}

Expand All @@ -110,19 +105,9 @@ export class ProblemMatcher {
}

private publish(): void {
const byFile = new Map<string, Diagnostic[]>();
for (const fileDiagnostics of this.entries.values()) {
for (const { file, diagnostic } of fileDiagnostics) {
const diagnostics = byFile.get(file) ?? [];
if (!diagnostics.some((existing) => sameDiagnostic(existing, diagnostic))) {
diagnostics.push(diagnostic);
}
byFile.set(file, diagnostics);
}
}
this.diagnosticCollection.clear();
for (const [file, diagnostics] of byFile) {
this.diagnosticCollection.set(Uri.file(file), diagnostics);
for (const [file, diagnostics] of this.store.byFile()) {
this.diagnosticCollection.set(Uri.file(file), diagnostics.map((d) => d.diagnostic));
}
}
}
Expand Down
Loading
Loading