fix(arg-parser): a quoted value stops at a quote that starts a word and never swallows a later flag

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-28 15:18:55 +01:00
commit 8be0c05221
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
2 changed files with 32 additions and 6 deletions

View file

@ -135,10 +135,17 @@ const isSpace = (c) => /\s/.test(c);
/**
* Split argument text into words. Words are separated by whitespace. A word
* that starts with `'` or `"` and has the same quote again at the end of a
* later word is a balanced pair: the pair groups the words between them into
* one value and is removed. A quote with no such partner is a literal
* character (`user's` stays `user's`). Nothing is expanded.
* that starts with `'` or `"` opens a quote. Its closing quote is the next
* same-kind quote that ends a word (followed by whitespace or the end of the
* text) and does not start one; a same-kind quote inside a word is skipped.
* The search stops at a same-kind quote that starts a word (one preceded by
* whitespace): then there is no pair. A pair groups the words between the
* quotes into one value and is removed. A quote with no partner is a literal
* character (`user's` stays `user's`). Examples: `'Fix the user's login'` is
* one value, `Fix the user's login`; `"my dir"/x --since "HEAD~1"` gives the
* literal words `"my` and `dir"/x`, then `--since` and `HEAD~1`, so a quoted
* value never swallows a later flag. Nothing is expanded: the function only
* slices the string.
*
* @param {string} text
* @returns {Array<{ value: string, start: number, end: number }>}
@ -156,8 +163,8 @@ export function tokenize(text) {
const q = text[i];
if (q === '"' || q === "'") {
let j = text.indexOf(q, i + 1);
while (j !== -1 && j + 1 < n && !isSpace(text[j + 1])) j = text.indexOf(q, j + 1);
if (j !== -1) {
while (j !== -1 && !isSpace(text[j - 1]) && j + 1 < n && !isSpace(text[j + 1])) j = text.indexOf(q, j + 1);
if (j !== -1 && !isSpace(text[j - 1])) {
out.push({ value: text.slice(i + 1, j), start, end: j + 1 });
i = j + 1;
continue;

View file

@ -0,0 +1,19 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { parseArgs, tokenize } from '../../lib/parsers/arg-parser.mjs';
const values = (text) => tokenize(text).map((t) => t.value);
test('[TOK] a quoted value never swallows a later flag, and an apostrophe inside a word keeps its value', () => {
const text = '--project "my dir"/x --since "HEAD~1"';
assert.equal(parseArgs(text, 'trekreview').flags['--since'], 'HEAD~1',
'the quote after `dir` is followed by `/`, so it does not close; the search stops at the quote that starts `"HEAD~1"`');
assert.deepEqual(values(text), ['--project', '"my', 'dir"/x', '--since', 'HEAD~1']);
// Control: an apostrophe inside a word in single quotes keeps one value.
assert.deepEqual(values(`'Fix the user's login'`), ["Fix the user's login"]);
// Control: two adjacent quoted words stay two values.
assert.deepEqual(values(`"a" "b"`), ['a', 'b']);
// Control: the other kind of quote inside a quoted value does not split it.
assert.deepEqual(values(`"say 'hi' now"`), ["say 'hi' now"]);
});