From 41ea6f7f958b51a69f40a82cf3a897f405a9ee2f Mon Sep 17 00:00:00 2001 From: Kevin Buffardi Date: Tue, 22 Sep 2026 16:52:41 -0700 Subject: [PATCH] feat(terminal): expand g++ wildcard arguments Expand workspace file patterns for g++ while preserving literal unmatched patterns and clang++ behavior.\n\nCloses #102 --- package.json | 2 +- scripts/e2e-multifile-build.test.mjs | 44 ++++++ scripts/e2e-terminal-gpp-glob.test.mjs | 62 +++++++++ ...e-102-gplusplus-wildcard-expansion-plan.md | 131 ++++++++++++++++++ src/ui/build-request.mjs | 66 +++++++++ src/ui/terminal.js | 9 +- 6 files changed, 310 insertions(+), 4 deletions(-) create mode 100644 scripts/e2e-terminal-gpp-glob.test.mjs create mode 100644 specs/issue-102-gplusplus-wildcard-expansion-plan.md diff --git a/package.json b/package.json index a5404d6..f741f3e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/e2e-multifile-build.test.mjs b/scripts/e2e-multifile-build.test.mjs index 8c508ac..08b71f3 100644 --- a/scripts/e2e-multifile-build.test.mjs +++ b/scripts/e2e-multifile-build.test.mjs @@ -8,6 +8,8 @@ import { selectRunBinaryBytes, selectWorkspaceSources, buildCompileOverlay, + expandGxxGlobArgs, + globPatternToRegExp, isProjectSource, normalizeOverlayPath, } from '../src/ui/build-request.mjs'; @@ -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', () => { diff --git a/scripts/e2e-terminal-gpp-glob.test.mjs b/scripts/e2e-terminal-gpp-glob.test.mjs new file mode 100644 index 0000000..f6462ea --- /dev/null +++ b/scripts/e2e-terminal-gpp-glob.test.mjs @@ -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']); +}); diff --git a/specs/issue-102-gplusplus-wildcard-expansion-plan.md b/specs/issue-102-gplusplus-wildcard-expansion-plan.md new file mode 100644 index 0000000..6de982e --- /dev/null +++ b/specs/issue-102-gplusplus-wildcard-expansion-plan.md @@ -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. diff --git a/src/ui/build-request.mjs b/src/ui/build-request.mjs index 1e31cbd..aec32cb 100644 --- a/src/ui/build-request.mjs +++ b/src/ui/build-request.mjs @@ -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; diff --git a/src/ui/terminal.js b/src/ui/terminal.js index 3a4c15c..8941529 100644 --- a/src/ui/terminal.js +++ b/src/ui/terminal.js @@ -24,6 +24,7 @@ import * as fitAddonPkg from '@xterm/addon-fit'; import * as webLinksAddonPkg from '@xterm/addon-web-links'; import { + expandGxxGlobArgs, parseGxxArgs, resolveWorkspacePath, resolveRunTarget, @@ -764,7 +765,7 @@ async function executeCommand(cmdLine) { switch (cmd) { case 'g++': case 'clang++': - cmdGxx(args); + cmdGxx(cmd, args); break; case 'clear': clearScreen(); @@ -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 @@ -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 });