From 6d607597ff08649e376318ecc40aa2513e5d2e06 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Tue, 16 Dec 2025 00:58:47 -0800 Subject: [PATCH 1/4] fix: handle combined quotation shell arguments like Bash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrote the parser to use a character-by-character state machine instead of complex regex. This properly handles adjacent quoted and unquoted segments as a single argument, matching Bash behavior. Examples of now-correct parsing: - `--foo="bar"'baz'` → `--foo=barbaz` - `a" b"` → `a b` - `run:silent["echo 1"]["echo 2"]` → `run:silent[echo 1][echo 2]` Also fixed existing test cases that had incorrect expectations and added new test cases for combined quotation segments. --- index.ts | 69 ++++++++++++++++++++++++++++------------------ test/Index.spec.js | 36 +++++++++++++++--------- 2 files changed, 65 insertions(+), 40 deletions(-) diff --git a/index.ts b/index.ts index 52c424b..f2ec52b 100644 --- a/index.ts +++ b/index.ts @@ -4,14 +4,6 @@ export default function parseArgsStringToArgv( env?: string, file?: string ): string[] { - // ([^\s'"]([^\s'"]*(['"])([^\3]*?)\3)+[^\s'"]*) Matches nested quotes until the first space outside of quotes - - // [^\s'"]+ or Match if not a space ' or " - - // (['"])([^\5]*?)\5 or Match "quoted text" without quotes - // `\3` and `\5` are a backreference to the quote style (' or ") captured - const myRegexp = /([^\s'"]([^\s'"]*(['"])([^\3]*?)\3)+[^\s'"]*)|[^\s'"]+|(['"])([^\5]*?)\5/gi; - const myString = value; const myArray: string[] = []; if (env) { myArray.push(env); @@ -19,27 +11,50 @@ export default function parseArgsStringToArgv( if (file) { myArray.push(file); } - let match: RegExpExecArray | null; - do { - // Each call to exec returns the next regex match as an array - match = myRegexp.exec(myString); - if (match !== null) { - // Index 1 in the array is the captured group if it exists - // Index 0 is the matched text, which we use if no captured group exists - myArray.push(firstString(match[1], match[6], match[0])!); - } - } while (match !== null); - return myArray; -} + let current = ""; + let inQuote: string | null = null; + let hasToken = false; // Track if we've started a token (for empty quotes) + let i = 0; -// Accepts any number of arguments, and returns the first one that is a string -// (even an empty string) -function firstString(...args: Array): string | undefined { - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (typeof arg === "string") { - return arg; + while (i < value.length) { + const char = value[i]; + + if (inQuote) { + // Inside quotes - look for the closing quote + if (char === inQuote) { + // End of quoted section + inQuote = null; + } else { + // Add character to current token (without the quotes) + current += char; + } + } else { + // Outside quotes + if (char === '"' || char === "'") { + // Start of quoted section - this means we have a token even if empty + inQuote = char; + hasToken = true; + } else if (/\s/.test(char)) { + // Whitespace - end current token if we have one + if (hasToken) { + myArray.push(current); + current = ""; + hasToken = false; + } + } else { + // Regular character - add to current token + current += char; + hasToken = true; + } } + i++; + } + + // Don't forget the last token + if (hasToken) { + myArray.push(current); } + + return myArray; } diff --git a/test/Index.spec.js b/test/Index.spec.js index a72ac45..0ee13df 100644 --- a/test/Index.spec.js +++ b/test/Index.spec.js @@ -29,7 +29,7 @@ describe("Process ", function () { parseAndValidate( value.replace(/"/g, "'"), expectedWithSingleQuotes, - false + false, ); } } @@ -77,7 +77,7 @@ describe("Process ", function () { parseAndValidate( '-testing test -valid=true --quotes "test quotes"', ["-testing", "test", "-valid=true", "--quotes", "test quotes"], - true + true, ); done(); }); @@ -86,7 +86,7 @@ describe("Process ", function () { parseAndValidate( '-testing test -valid=true --quotes ""', ["-testing", "test", "-valid=true", "--quotes", ""], - true + true, ); done(); }); @@ -94,22 +94,22 @@ describe("Process ", function () { it("a complex string with nested quotes", function (done) { parseAndValidate( '--title "Peter\'s Friends" --name \'Phil "The Power" Taylor\'', - ["--title", "Peter's Friends", "--name", 'Phil "The Power" Taylor'] + ["--title", "Peter's Friends", "--name", 'Phil "The Power" Taylor'], ); done(); }); it("a complex key value with quotes", function (done) { parseAndValidate("--name='Phil Taylor' --title=\"Peter's Friends\"", [ - "--name='Phil Taylor'", - '--title="Peter\'s Friends"', + "--name=Phil Taylor", + "--title=Peter's Friends", ]); done(); }); it("a complex key value with nested quotes", function (done) { parseAndValidate("--name='Phil \"The Power\" Taylor'", [ - "--name='Phil \"The Power\" Taylor'", + '--name=Phil "The Power" Taylor', ]); done(); }); @@ -117,8 +117,8 @@ describe("Process ", function () { it("nested quotes with no spaces", function (done) { parseAndValidate( 'jake run:silent["echo 1"] --trace', - ["jake", 'run:silent["echo 1"]', "--trace"], - true + ["jake", "run:silent[echo 1]", "--trace"], + true, ); done(); }); @@ -126,17 +126,27 @@ describe("Process ", function () { it("multiple nested quotes with no spaces", function (done) { parseAndValidate( 'jake run:silent["echo 1"]["echo 2"] --trace', - ["jake", 'run:silent["echo 1"]["echo 2"]', "--trace"], - true + ["jake", "run:silent[echo 1][echo 2]", "--trace"], + true, ); done(); }); it("complex multiple nested quotes", function (done) { - parseAndValidate('cli value("echo")[\'grep\']+"Peter\'s Friends"', [ + parseAndValidate('cli value["echo"][\'grep\']+"Peter\'s Friends"', [ "cli", - 'value("echo")[\'grep\']+"Peter\'s Friends"', + "value[echo][grep]+Peter's Friends", ]); done(); }); + + it("combined quotation segments", function (done) { + parseAndValidate("--foo=\"bar\"'baz'", ["--foo=barbaz"]); + done(); + }); + + it("unquoted text followed by quoted text with space", function (done) { + parseAndValidate('a" b"', ["a b"]); + done(); + }); }); From c5966beecb7d7bdb0d6f89cff94de6861c92e449 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Tue, 16 Dec 2025 01:03:13 -0800 Subject: [PATCH 2/4] Add empty array test --- test/Index.spec.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/Index.spec.js b/test/Index.spec.js index 0ee13df..03107a5 100644 --- a/test/Index.spec.js +++ b/test/Index.spec.js @@ -43,6 +43,11 @@ describe("Process ", function () { done(); }); + it("an empty string should return an empty array", function (done) { + parseAndValidate("", []); + done(); + }); + it("an arguments array correctly without file and env", function (done) { parseAndValidate("-test", ["-test"]); done(); From d9e6c793a8db8d77a74df74d5a74beada2dfc8d8 Mon Sep 17 00:00:00 2001 From: Michael Ferris Date: Mon, 28 Sep 2026 20:53:19 -0400 Subject: [PATCH 3/4] test: cover bash-compatible quotation merging plus pending bash-gap TODOs --- test/Index.spec.js | 72 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/test/Index.spec.js b/test/Index.spec.js index 03107a5..23360e3 100644 --- a/test/Index.spec.js +++ b/test/Index.spec.js @@ -154,4 +154,76 @@ describe("Process ", function () { parseAndValidate('a" b"', ["a b"]); done(); }); + + describe("bash-compatible combined quotations (PR #24 follow-up)", function () { + it("quoted text directly followed by unquoted text (issue #23)", function (done) { + parseAndValidate('"a"b', ["ab"], true); + done(); + }); + + it("quoted text with an unquoted prefix and suffix", function (done) { + parseAndValidate('-"a"b', ["-ab"]); + done(); + }); + + it("empty quotes before unquoted text", function (done) { + parseAndValidate('""foo', ["foo"], true); + done(); + }); + + it("empty quotes after unquoted text", function (done) { + parseAndValidate('foo""', ["foo"], true); + done(); + }); + + it("empty quotes inside unquoted text", function (done) { + parseAndValidate('foo""bar', ["foobar"], true); + done(); + }); + + it("adjacent single- and double-quoted segments", function (done) { + parseAndValidate('\'a\'"b"', ["ab"]); + done(); + }); + + it("multiple adjacent quoted segments plus unquoted text", function (done) { + parseAndValidate('"a"\'b\'c', ["abc"]); + done(); + }); + + it("a key=value pair with a quoted value containing a space", function (done) { + parseAndValidate('--key="some value"', ["--key=some value"], true); + done(); + }); + + it("complex nested quotes with parentheses", function (done) { + parseAndValidate('cli value("echo")[\'grep\']+"Peter\'s Friends"', [ + "cli", + "value(echo)[grep]+Peter's Friends", + ]); + done(); + }); + + it("leading, trailing and repeated whitespace", function (done) { + parseAndValidate(" a b ", ["a", "b"]); + done(); + }); + + it("tab-separated arguments", function (done) { + parseAndValidate("a\tb", ["a", "b"]); + done(); + }); + + it("multiple empty quoted arguments", function (done) { + parseAndValidate('"" ""', ["", ""], true); + done(); + }); + + xit("TODO: backslash escapes like bash", function (done) { + // Bash: `a\ b` is a single argument `a b`. The parser currently keeps + // the backslash and splits: ["a\\", "b"]. Pending escape handling. + parseAndValidate("a\\ b", ["a b"]); + done(); + }); + }); }); From a6e51d2e01297f8833e5bd4a5ae281775afefcf2 Mon Sep 17 00:00:00 2001 From: Michael Ferris Date: Mon, 28 Sep 2026 20:53:19 -0400 Subject: [PATCH 4/4] docs: document bash-compatible parsing for 0.4.0 (PR #24 by TooTallNate) --- CHANGELOG.md | 20 +++++++++++++++++++- README.md | 29 +++++++++++++++++++++++++++-- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2991b48..db48732 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## v0.4.0 (2026-09-28) +**Parsing Behavior Changes** (thanks to [@TooTallNate](https://github.com/TooTallNate) via [PR #24](https://github.com/mccormicka/string-argv/pull/24)) +- Rewrote the parser as a character-by-character state machine instead of the previous regex, so adjacent quoted and unquoted segments are treated as a single argument, matching bash word-splitting +- Quote characters are now always stripped, including word-internal and `key=value` positions: + - `--foo="bar"'baz'` -> `--foo=barbaz` + - `a" b"` -> `a b` + - `"a"b` -> `ab` + - `run:silent["echo 1"]["echo 2"]` -> `run:silent[echo 1][echo 2]` + - `--name='Phil Taylor'` -> `--name=Phil Taylor` +- Fixes [#20](https://github.com/mccormicka/string-argv/issues/20) (word-internal quotes were not stripped) +- Fixes [#23](https://github.com/mccormicka/string-argv/issues/23) (unquoted tail after a quoted section was split into a separate argument) +- Known differences from bash, unchanged by this release: backslash escapes are not processed, `$variables`/globs are left literal, and an unclosed quote consumes the rest of the input instead of raising an error +- Security dependency bumps: minimatch 3.1.5 via [#25](https://github.com/mccormicka/string-argv/pull/25), brace-expansion 1.1.16 via [#27](https://github.com/mccormicka/string-argv/pull/27) + +## v0.3.2 (2023-05-01) +- Update TypeScript to 5.0.4 [b1015b8](https://github.com/mccormicka/string-argv/commit/b1015b8911b1505ed2ccd546fa0c35dc1dfcda07) +- CI workflow (`node.js.yml`) added via [#22](https://github.com/mccormicka/string-argv/pull/22) + ## v0.3.1 (2022-09-16) - Now provides both esm and cjs builds - Update TypeScript to 4.8.3 @@ -10,4 +28,4 @@ ## v0.2.0 (2019-04-14) **Parsing Behavior Changes** -- Now parses multiple nested quotes and content when there are no spaces [7d9b897](https://github.com/mccormicka/string-argv/commit/7d9b89730ea112b829f2591e3e9cae4c0d0cc285) \ No newline at end of file +- Now parses multiple nested quotes and content when there are no spaces [7d9b897](https://github.com/mccormicka/string-argv/commit/7d9b89730ea112b829f2591e3e9cae4c0d0cc285) diff --git a/README.md b/README.md index b6986fc..f315219 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,8 @@ console.log(args); '--quotes', 'test quotes', 'nested \'quotes\'', - '--key="some value"', - '--title="Peter\'s Friends"' ] + '--key=some value', + '--title=Peter\'s Friends' ] **/ ``` @@ -56,3 +56,28 @@ __required__: __arguments__ String: arguments that you would normally pass to th __optional__: __environment__ String: Adds to the environment position in the argv array. If ommitted then there is no need to call argv.split(2) to remove the environment/file values. However if your cli.parse method expects a valid argv value then you should include this value. __optional__: __file__ String: file that called the arguments. If omitted then there is no need to call argv.split(2) to remove the environment/file values. However if your cli.parse method expects a valid argv value then you should include this value. + +## Parsing behavior (bash-compatible quoting) + +Quoting follows bash word-splitting rules: `'` and `"` group text into a single +argument, adjacent quoted and unquoted segments are concatenated, and the quote +characters themselves are removed. Whitespace separates arguments only when it +appears outside of quotes. + +``` +--foo="bar"'baz' -> [--foo=barbaz] +a" b" -> [a b] +"a"b -> [ab] +jake run:silent["echo 1"]["echo 2"] -> [jake, run:silent[echo 1][echo 2], --trace] +--name='Phil Taylor' -> [--name=Phil Taylor] +--title="Peter's Friends" -> [--title=Peter's Friends] +``` + +A quote of the opposite kind inside quotes is kept literally +(`--name='Phil "The Power" Taylor'` -> `--name=Phil "The Power" Taylor`), +and empty quotes produce an empty argument (`""` -> `[""]`). + +Known differences from bash: +- backslash escapes are not processed +- `$variables`, globs and other expansions are left literal +- an unclosed quote consumes the rest of the input instead of raising a syntax error diff --git a/package-lock.json b/package-lock.json index 82a0b01..516dc35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "string-argv", - "version": "0.3.2", + "version": "0.4.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "string-argv", - "version": "0.3.2", + "version": "0.4.0", "license": "MIT", "devDependencies": { "jasmine": "^4.4.0", diff --git a/package.json b/package.json index ee8dd54..7847ac3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "string-argv", "description": "string-argv parses a string into an argument array to mimic process.argv. This is useful when testing Command Line Utilities that you want to pass arguments to.", - "version": "0.3.2", + "version": "0.4.0", "contributors": [ { "name": "Michael Ferris",