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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ Status lines showing iteration progress go to stderr:
|--------|-------------|
| `--file=a.go,b.go,c.go` | Comma-separated list |
| `--expected="returns 200\, not 500"` | `\,` is a literal comma: one value, no fan-out |
| `--dir='C:\out\\,D:\out'` | `\\,` is a literal backslash then a separator: two values `C:\out\` and `D:\out` |
| `--file="src/*.go"` | Glob pattern (expands to matching files) |
| `--file="src/**/*.go"` | Recursive glob (`**` matches any depth) |
| `--file=@list.txt` | Reads one value per line from a file |
Expand Down
1 change: 1 addition & 0 deletions changelog.d/fixed-357-backslash-before-comma.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A backslash right before a separator comma can now be written as `\\,`, so a value can end in a literal backslash (for example `C:\out\\,D:\out`); backslashes elsewhere are unchanged. (#357)
34 changes: 27 additions & 7 deletions dist/resolver.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/resolver.js.map

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions dist/resolver.test.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/resolver.test.js.map

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions dist/ui-inline.test.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/ui-inline.test.js.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 18 additions & 1 deletion dist/ui.html
Original file line number Diff line number Diff line change
Expand Up @@ -815,7 +815,24 @@
function splitValues(raw, source) {
if (!raw) return [];
if (source === 'file') return [raw];
return raw.split(/(?<!\\),/).map(s => s.trim()).filter(Boolean).map(s => s.replace(/\\,/g, ','));
// Mirrors splitUnescaped in resolver.ts: `\,` is a literal comma, `\\,` a
// literal backslash followed by a separator.
const pieces = [];
let piece = '';
let last = 0;
for (const m of raw.matchAll(/(\\*),/g)) {
const bs = m[1].length;
piece += raw.slice(last, m.index) + '\\'.repeat(bs >> 1);
last = m.index + m[0].length;
if (bs % 2 === 1) {
piece += ',';
} else {
pieces.push(piece);
piece = '';
}
}
pieces.push(piece + raw.slice(last));
return pieces.map(s => s.trim()).filter(Boolean);
}

function highlightUnfilled(html) {
Expand Down
2 changes: 1 addition & 1 deletion skills/promptargs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ promptargs review --file="src/*.go" --no-interactive
promptargs review --file=@list.txt --no-interactive
```

To pass a single value that contains a comma, escape it as `\,` (`--expected="returns 200\, not 500"`); this also applies to interactive answers and `@file` lines.
To pass a single value that contains a comma, escape it as `\,` (`--expected="returns 200\, not 500"`); this also applies to interactive answers and `@file` lines. To end a value with a backslash before a separator comma, double it: `\\,` (`--dir='C:\out\\,D:\out'` gives `C:\out\` and `D:\out`).

Multiple arrays are zipped 1:1 by default; add `--cross` for every combination.

Expand Down
22 changes: 22 additions & 0 deletions src/resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,28 @@ describe('resolve array expansion', () => {
assert.deepStrictEqual(r.iterations.map(i => i.pa_f), ['x,y', 'z']);
});

it('treats \\\\, as a literal backslash followed by a separator', async () => {
const r = await resolve([v('pa_f')], { pa_f: 'C:\\dir\\\\,D:\\x' }, false);
assert.deepStrictEqual(r.iterations.map(i => i.pa_f), ['C:\\dir\\', 'D:\\x']);
});

it('reads backslash runs before a comma in pairs', async () => {
const r = await resolve([v('pa_f')], { pa_f: 'a\\\\\\,b,c\\\\\\\\,d' }, false);
assert.deepStrictEqual(r.iterations.map(i => i.pa_f), ['a\\,b', 'c\\\\', 'd']);
});

it('keeps backslashes that are not before a comma', async () => {
const r = await resolve([v('pa_f')], { pa_f: 'C:\\a\\\\b,x\\' }, false);
assert.deepStrictEqual(r.iterations.map(i => i.pa_f), ['C:\\a\\\\b', 'x\\']);
});

it('unescapes \\\\, in @file lines to a backslash and comma', async () => {
const listFile = join(tmp, 'escaped-backslash.txt');
writeFileSync(listFile, 'x\\\\,y\nz\n');
const r = await resolve([v('pa_f')], { pa_f: `@${listFile}` }, false);
assert.deepStrictEqual(r.iterations.map(i => i.pa_f), ['x\\,y', 'z']);
});

it('reads @file values one per line', async () => {
const listFile = join(tmp, 'list.txt');
writeFileSync(listFile, 'one\n two \n\nthree\n');
Expand Down
35 changes: 28 additions & 7 deletions src/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,34 @@ function expandPiece(piece: string): string[] {
return [piece];
}

// Split on commas not preceded by a backslash, then turn `\,` into a literal comma.
// A run of backslashes right before a comma is read in pairs: each `\\` is a
// literal backslash, and an odd one left over makes the comma literal. So
// `a\,b` is one value `a,b`, while `a\\,b` is `a\` then `b`. Backslashes
// elsewhere are kept as-is.
const BACKSLASHES_BEFORE_COMMA = /(\\*),/g;

function unescapeCommas(raw: string): string {
return raw.replace(BACKSLASHES_BEFORE_COMMA, (_, bs: string) => '\\'.repeat(bs.length >> 1) + ',');
}

// Split on unescaped commas, then unescape what is left (see above).
function splitUnescaped(raw: string): string[] {
return raw
.split(/(?<!\\),/)
.map(s => s.trim())
.filter(Boolean)
.map(s => s.replace(/\\,/g, ','));
const pieces: string[] = [];
let piece = '';
let last = 0;
for (const m of raw.matchAll(BACKSLASHES_BEFORE_COMMA)) {
const bs = m[1].length;
piece += raw.slice(last, m.index) + '\\'.repeat(bs >> 1);
last = m.index + m[0].length;
if (bs % 2 === 1) {
piece += ',';
} else {
pieces.push(piece);
piece = '';
}
}
pieces.push(piece + raw.slice(last));
return pieces.map(s => s.trim()).filter(Boolean);
}

function expandArrayValue(raw: string, varName?: string): string[] {
Expand All @@ -47,7 +68,7 @@ function expandArrayValue(raw: string, varName?: string): string[] {
.split('\n')
.map((l: string) => l.trim())
.filter(Boolean)
.map((l: string) => l.replace(/\\,/g, ','))
.map(unescapeCommas)
.flatMap(expandPiece);
} catch (err) {
const e = err as NodeJS.ErrnoException;
Expand Down
3 changes: 3 additions & 0 deletions src/ui-inline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ test('ui splitValues mirrors CLI comma-separated handling', () => {
assert.deepEqual(uiSplitValues('a.go, b.go ,c.go', 'manual'), ['a.go', 'b.go', 'c.go']);
assert.deepEqual(uiSplitValues('a,,b,', 'manual'), ['a', 'b']);
assert.deepEqual(uiSplitValues('', 'manual'), []);
assert.deepEqual(uiSplitValues('a\\,b,c', 'manual'), ['a,b', 'c']);
assert.deepEqual(uiSplitValues('C:\\dir\\\\,D:\\x', 'manual'), ['C:\\dir\\', 'D:\\x']);
assert.deepEqual(uiSplitValues('a\\\\\\,b,c\\\\\\\\,d', 'manual'), ['a\\,b', 'c\\\\', 'd']);
// @file values are opaque single entries in the UI (server has no file access)
assert.deepEqual(uiSplitValues('one,two', 'file'), ['one,two']);
});
Expand Down
19 changes: 18 additions & 1 deletion src/ui.html
Original file line number Diff line number Diff line change
Expand Up @@ -815,7 +815,24 @@
function splitValues(raw, source) {
if (!raw) return [];
if (source === 'file') return [raw];
return raw.split(/(?<!\\),/).map(s => s.trim()).filter(Boolean).map(s => s.replace(/\\,/g, ','));
// Mirrors splitUnescaped in resolver.ts: `\,` is a literal comma, `\\,` a
// literal backslash followed by a separator.
const pieces = [];
let piece = '';
let last = 0;
for (const m of raw.matchAll(/(\\*),/g)) {
const bs = m[1].length;
piece += raw.slice(last, m.index) + '\\'.repeat(bs >> 1);
last = m.index + m[0].length;
if (bs % 2 === 1) {
piece += ',';
} else {
pieces.push(piece);
piece = '';
}
}
pieces.push(piece + raw.slice(last));
return pieces.map(s => s.trim()).filter(Boolean);
}

function highlightUnfilled(html) {
Expand Down
Loading