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
20 changes: 19 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
- Now parses multiple nested quotes and content when there are no spaces [7d9b897](https://github.com/mccormicka/string-argv/commit/7d9b89730ea112b829f2591e3e9cae4c0d0cc285)
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' ]
**/
```

Expand All @@ -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
69 changes: 42 additions & 27 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,42 +4,57 @@ 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);
}
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<any>): 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;
}
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
113 changes: 100 additions & 13 deletions test/Index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ describe("Process ", function () {
parseAndValidate(
value.replace(/"/g, "'"),
expectedWithSingleQuotes,
false
false,
);
}
}
Expand All @@ -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();
Expand Down Expand Up @@ -77,7 +82,7 @@ describe("Process ", function () {
parseAndValidate(
'-testing test -valid=true --quotes "test quotes"',
["-testing", "test", "-valid=true", "--quotes", "test quotes"],
true
true,
);
done();
});
Expand All @@ -86,57 +91,139 @@ describe("Process ", function () {
parseAndValidate(
'-testing test -valid=true --quotes ""',
["-testing", "test", "-valid=true", "--quotes", ""],
true
true,
);
done();
});

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();
});

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();
});

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();
});

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();
});
});
});
Loading