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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"lint": "eslint .",
"build": "npm run build:webpack && npm run build:targets",
"build:firefox": "npm run build",
"test:e2e": "node --experimental-detect-module --test scripts/e2e-page-lifecycle.test.mjs scripts/e2e-session-persistence.test.mjs scripts/e2e-session-restore-choice.test.mjs scripts/e2e-multifile-build.test.mjs scripts/e2e-workspace-file-tracking.test.mjs scripts/e2e-workspace-scan-progress.test.mjs scripts/e2e-terminal-mkdir.test.mjs scripts/e2e-terminal-stop.test.mjs scripts/e2e-terminal-git-removal.test.mjs scripts/e2e-terminal-stop-icon.test.mjs scripts/e2e-browser-compatibility.test.mjs scripts/e2e-firefox-compatibility.test.mjs scripts/e2e-firefox-jspi-stdin.test.mjs scripts/e2e-wasi-shim.test.mjs scripts/e2e-run-request.test.mjs scripts/e2e-release-packaging.test.mjs",
"test:e2e": "node --experimental-detect-module --test scripts/e2e-page-lifecycle.test.mjs scripts/e2e-session-persistence.test.mjs scripts/e2e-session-restore-choice.test.mjs scripts/e2e-multifile-build.test.mjs scripts/e2e-workspace-file-tracking.test.mjs scripts/e2e-workspace-scan-progress.test.mjs scripts/e2e-terminal-gpp-glob.test.mjs scripts/e2e-terminal-mkdir.test.mjs scripts/e2e-terminal-stop.test.mjs scripts/e2e-terminal-git-removal.test.mjs scripts/e2e-terminal-stop-icon.test.mjs scripts/e2e-browser-compatibility.test.mjs scripts/e2e-firefox-compatibility.test.mjs scripts/e2e-firefox-jspi-stdin.test.mjs scripts/e2e-wasi-shim.test.mjs scripts/e2e-run-request.test.mjs scripts/e2e-release-packaging.test.mjs",
"test:e2e:compiler": "npm run test:preflight-clang && node --experimental-detect-module --test scripts/e2e-compiler-link.test.mjs",
"test:preflight-clang": "node scripts/preflight-clang-artifacts.js",
"test:browser:chrome": "npm run test:e2e:compiler && node scripts/smoke-browser.mjs chrome",
Expand Down
44 changes: 44 additions & 0 deletions scripts/e2e-multifile-build.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
selectRunBinaryBytes,
selectWorkspaceSources,
buildCompileOverlay,
expandGxxGlobArgs,
globPatternToRegExp,
isProjectSource,
normalizeOverlayPath,
} from '../src/ui/build-request.mjs';
Expand Down Expand Up @@ -65,6 +67,48 @@ test('e2e: terminal relative source paths resolve from the workspace cwd', () =>
assert.equal(resolveWorkspacePath('/', './src/main.cpp'), 'src/main.cpp');
});

test('e2e: g++ glob expansion supports direct paths and deterministic ordering', () => {
const expanded = expandGxxGlobArgs(
['src/*.cpp'],
['src/zeta.cpp', 'src/lib/nested.cpp', 'src/alpha.cpp', 'main.cpp'],
'/'
);

assert.deepEqual(expanded, ['src/alpha.cpp', 'src/zeta.cpp']);
});

test('e2e: g++ glob expansion supports question-mark and bracket expressions', () => {
const files = ['test_a.cpp', 'test_b.cpp', 'test_c.cpp', 'test_aa.cpp'];

assert.deepEqual(expandGxxGlobArgs(['test_?.cpp'], files), [
'test_a.cpp', 'test_b.cpp', 'test_c.cpp',
]);
assert.deepEqual(expandGxxGlobArgs(['test_[ab].cpp'], files), [
'test_a.cpp', 'test_b.cpp',
]);
assert.deepEqual(expandGxxGlobArgs(['test_[!ab].cpp'], files), ['test_c.cpp']);
assert.deepEqual(expandGxxGlobArgs(['test_[^a].cpp'], files), [
'test_b.cpp', 'test_c.cpp',
]);
});

test('e2e: g++ glob expansion resolves from cwd and preserves unmatched literals', () => {
const files = ['src/main.cpp', 'src/util.cpp', 'main.cpp'];

assert.deepEqual(expandGxxGlobArgs(['*.cpp'], files, '/src'), [
'src/main.cpp', 'src/util.cpp',
]);
assert.deepEqual(expandGxxGlobArgs(['missing*.cpp'], files, '/src'), [
'src/missing*.cpp',
]);
});

test('e2e: g++ glob expansion treats regex syntax literally and tolerates malformed classes', () => {
assert.deepEqual(expandGxxGlobArgs(['main+*.cpp'], ['main+one.cpp']), ['main+one.cpp']);
assert.deepEqual(expandGxxGlobArgs(['broken[.cpp'], ['brokenX.cpp']), ['broken[.cpp']);
assert.doesNotThrow(() => globPatternToRegExp('file[\\].cpp'));
});

// ── Dirty-tab overlay assembly ────────────────────────────────────────────────

test('e2e: dirty open-tab content overrides on-disk workspace content', () => {
Expand Down
62 changes: 62 additions & 0 deletions scripts/e2e-terminal-gpp-glob.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import test from 'node:test';
import assert from 'node:assert/strict';

import {
__executeTerminalCommandForTesting,
__setTerminalTestHarness,
setWorkspace,
} from '../src/ui/terminal.js';

function setupTerminalHarness(entries) {
const compileCalls = [];
__setTerminalTestHarness({
term: { clear() {}, write() {} },
onCompile(request) { compileCalls.push(request); },
});
setWorkspace({ name: 'project', entries });
return { compileCalls };
}

test('e2e: g++ expands workspace globs and preserves compiler options', async () => {
const ctx = setupTerminalHarness([
{ path: 'zeta.cpp', kind: 'file' },
{ path: 'alpha.cpp', kind: 'file' },
{ path: 'notes.txt', kind: 'file' },
]);

await __executeTerminalCommandForTesting('g++ -Wall *.cpp -o app');

assert.equal(ctx.compileCalls.length, 1);
assert.deepEqual(ctx.compileCalls[0].sourcePaths, ['alpha.cpp', 'zeta.cpp']);
assert.deepEqual(ctx.compileCalls[0].flags, ['-Wall']);
assert.equal(ctx.compileCalls[0].outputName, 'app');
});

test('e2e: g++ expands globs relative to the terminal cwd', async () => {
const ctx = setupTerminalHarness([
{ path: 'src', kind: 'directory' },
{ path: 'src/main.cpp', kind: 'file' },
{ path: 'src/util.cpp', kind: 'file' },
]);

await __executeTerminalCommandForTesting('cd src');
await __executeTerminalCommandForTesting('g++ *.cpp');

assert.deepEqual(ctx.compileCalls[0].sourcePaths, ['src/main.cpp', 'src/util.cpp']);
});

test('e2e: clang++ keeps wildcard arguments literal', async () => {
const ctx = setupTerminalHarness([{ path: 'main.cpp', kind: 'file' }]);

await __executeTerminalCommandForTesting('clang++ *.cpp');

assert.deepEqual(ctx.compileCalls[0].sourcePaths, ['*.cpp']);
});

test('e2e: g++ preserves an unmatched wildcard argument', async () => {
const ctx = setupTerminalHarness([{ path: 'main.cpp', kind: 'file' }]);

await __executeTerminalCommandForTesting('g++ missing*.cpp');

assert.deepEqual(ctx.compileCalls[0].sourcePaths, ['missing*.cpp']);
});
131 changes: 131 additions & 0 deletions specs/issue-102-gplusplus-wildcard-expansion-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# Implementation Plan: Issue #102 — `g++` wildcard expansion

## Overview

Make `g++` expand `*`, `?`, and `[...]` in every non-option argument against
the currently opened workspace. Matching is path-aware and non-recursive;
unmatched patterns remain literal so the compiler retains its normal diagnostic.
The change applies to `g++` only—other terminal commands, including `clang++`,
are out of scope for this issue.

## Architecture decisions

- Put the matching logic in `src/ui/build-request.mjs`, the existing pure module
for terminal compile-request helpers. This keeps it independent of xterm and
directly testable under Node.
- Invoke the helper only from `cmdGxx` in `src/ui/terminal.js`, after existing
option parsing and before source paths are sent to the compiler. Pass the
command identity into the handler (or split the handlers), so the shared
`clang++` path does not expand patterns.
- Match only indexed workspace files. `*` and `?` do not cross `/`, so there is
no implicit recursive `**` behaviour.
- Support positive (`[ab]`, `[a-z]`) and negated (`[!ab]`, `[^a-z]`) bracket
classes, without allowing a bracket expression to match `/`.
- Sort matches for each pattern to make compiler requests reproducible; preserve
unmatched patterns literally.

## Task 1: Add pure glob expansion helpers

**Description:** Add exported helpers in `src/ui/build-request.mjs` to identify
glob syntax, translate a single path pattern to a slash-aware regular expression,
and expand source arguments using workspace-relative file paths and the current
working directory.

**Acceptance criteria:**

- [ ] Supports `*`, `?`, positive bracket classes/ranges such as `[ab]` and
`[a-z]`, and negated classes such as `[!ab]` and `[^a-z]`.
- [ ] `src/*.cpp` matches direct files in `src`, not `src/lib/main.cpp`.
- [ ] Invalid or unterminated bracket syntax is treated as literal text.
- [ ] A pattern with no matches is returned unchanged.
- [ ] Matches for an individual pattern are deterministic.

**Verification:** Add and run pure Node tests for the helper.

**Dependencies:** None.

**Files likely touched:**

- `src/ui/build-request.mjs`
- `scripts/e2e-multifile-build.test.mjs`

**Estimated scope:** Small (2 files).

## Task 2: Wire expansion into `g++` compilation

**Description:** Distinguish `g++` from the shared `clang++` dispatch, then
expand only `parseGxxArgs(...).sourcePaths` for `g++` against the terminal's
current workspace file index. Retain existing compile-request, output-name,
flag, and no-source editor-buffer behaviour.

**Acceptance criteria:**

- [ ] `g++ *.cpp` sends the matching root files to the compile callback.
- [ ] `cd src` followed by `g++ *.cpp` sends `src/...` paths.
- [ ] `g++ src/*.cpp -o app -Wall` preserves its output name and flags.
- [ ] `g++ missing*.cpp` sends the literal unmatched argument.
- [ ] `clang++ *.cpp` sends a literal `*.cpp` argument.
- [ ] Commands other than `g++` retain their existing literal argument behaviour.

**Verification:** Add terminal-harness tests that assert compile callback payloads.

**Dependencies:** Task 1.

**Files likely touched:**

- `src/ui/terminal.js`
- `scripts/e2e-terminal-gpp-glob.test.mjs`
- `package.json` (register the focused test in `test:e2e`)

**Estimated scope:** Medium (2–3 files).

## Task 3: Regression coverage and validation

**Description:** Cover literal inputs, each wildcard form, paths, option
positions, current-directory resolution, non-recursive matching, deterministic
ordering, and unmatched-pattern preservation. Run focused tests first, then all
repository checks.

**Acceptance criteria:**

- [ ] Existing literal multi-file compilation behaviour remains unchanged.
- [ ] `echo`, `cat`, `touch`, and `clang++` do not receive wildcard expansion.
- [ ] Lint, production build, and the full E2E suite pass.

**Verification:**

- [ ] `node --experimental-detect-module --test scripts/e2e-multifile-build.test.mjs`
- [ ] `node --experimental-detect-module --test scripts/e2e-terminal-gpp-glob.test.mjs`
- [ ] `npm run lint`
- [ ] `npm run build`
- [ ] `npm run test:e2e`

**Dependencies:** Tasks 1–2.

**Files likely touched:**

- `scripts/e2e-multifile-build.test.mjs`
- `scripts/e2e-terminal-gpp-glob.test.mjs`
- `package.json` (if needed)

**Estimated scope:** Small (2–3 files).

## Checkpoint: Before implementation

- [ ] The Issue #102 acceptance criteria and this plan agree.
- [ ] A human has reviewed and approved this plan.

## Risks and mitigations

| Risk | Mitigation |
| --- | --- |
| A glob accidentally spans directories | Translate `*`, `?`, and bracket expressions to exclude `/`; test nested files. |
| Regex metacharacters widen a match | Escape all non-glob literals and test filenames containing regex-special characters. |
| Option parsing changes | Reuse `parseGxxArgs`; expand only its non-option source paths. |
| Scope leaks to other commands | Keep the invocation exclusively inside `cmdGxx`. |

## Out of scope

- Recursive globbing (`**`).
- Expansion for terminal commands other than `g++`.
- Shell quoting/escaping semantics beyond the terminal's existing tokenizer.
66 changes: 66 additions & 0 deletions src/ui/build-request.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,72 @@ export function resolveWorkspacePath(cwd, input) {
return parts.join('/');
}

/** True when a path contains a supported wildcard expression. */
export function hasGlobPattern(path) {
const value = String(path || '');
return /[*?]/.test(value) || /\[(?:!|\^)?[^\]/]+\]/.test(value);
}

function escapeRegExpCharacter(character) {
return /[|\\{}()[\]^$+*?.]/.test(character) ? `\\${character}` : character;
}

/**
* Convert a supported workspace glob to a regular expression. Wildcards never
* cross a path separator, which keeps matching non-recursive.
*/
export function globPatternToRegExp(pattern) {
const value = String(pattern || '');
let expression = '^';

for (let index = 0; index < value.length; index++) {
const character = value[index];
if (character === '*') {
expression += '[^/]*';
} else if (character === '?') {
expression += '[^/]';
} else if (character === '[') {
const closingIndex = value.indexOf(']', index + 1);
const content = value.slice(index + 1, closingIndex);
if (closingIndex === -1 || !content || content.includes('/')) {
expression += '\\[';
} else {
const negated = content[0] === '!' || content[0] === '^';
const classContent = negated ? content.slice(1) : content;
if (!classContent) {
expression += '\\[';
} else {
const escapedClassContent = classContent.replace(/\\/g, '\\\\');
expression += `(?=[^/])[${negated ? '^' : ''}${escapedClassContent}]`;
index = closingIndex;
}
}
} else {
expression += escapeRegExpCharacter(character);
}
}

return new RegExp(`${expression}$`);
}

/**
* Expand globbed `g++` source arguments against workspace files. Inputs and
* outputs are workspace-relative paths. Patterns without matches stay literal
* so the compiler can report its native missing-file diagnostic.
*/
export function expandGxxGlobArgs(sourcePaths = [], workspaceFilePaths = [], cwd = '/') {
const files = workspaceFilePaths.map(normalizeOverlayPath);

return sourcePaths.flatMap((sourcePath) => {
const resolved = resolveWorkspacePath(cwd, sourcePath);
if (!hasGlobPattern(resolved)) return [resolved];

const matcher = globPatternToRegExp(resolved);
const matches = files.filter((path) => matcher.test(path)).sort();
return matches.length ? matches : [resolved];
});
}

/**
* Parse `g++`/`clang++` arguments, preserving positional source files and the
* `-o` output name (both previously discarded). Recognised flags are split out;
Expand Down
9 changes: 6 additions & 3 deletions src/ui/terminal.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import * as fitAddonPkg from '@xterm/addon-fit';
import * as webLinksAddonPkg from '@xterm/addon-web-links';

import {
expandGxxGlobArgs,
parseGxxArgs,
resolveWorkspacePath,
resolveRunTarget,
Expand Down Expand Up @@ -764,7 +765,7 @@ async function executeCommand(cmdLine) {
switch (cmd) {
case 'g++':
case 'clang++':
cmdGxx(args);
cmdGxx(cmd, args);
break;
case 'clear':
clearScreen();
Expand Down Expand Up @@ -818,7 +819,7 @@ async function executeCommand(cmdLine) {

// ── Individual command handlers ───────────────────────────────────────────────

function cmdGxx(args) {
function cmdGxx(command, args) {
const { std, outputName, flags, sourcePaths } = parseGxxArgs(args);

// No explicit sources → compile the single editor buffer (works with or
Expand All @@ -845,7 +846,9 @@ function cmdGxx(args) {
return;
}

const resolved = sourcePaths.map((p) => resolveWorkspacePath(workspaceCwd, p));
const resolved = command === 'g++'
? expandGxxGlobArgs(sourcePaths, [...workspaceFiles].map(normalizePath), workspaceCwd)
: sourcePaths.map((path) => resolveWorkspacePath(workspaceCwd, path));
term.write(`${C.dim}Compiling ${resolved.join(' ')} with -std=${std}…${C.reset}${CRLF}`);
busy = true;
_onCompile?.({ sourcePaths: resolved, flags, std, outputName, cwd: workspaceCwd });
Expand Down
Loading