Skip to content

Commit b9545b3

Browse files
committed
[New] parse: add opt-in splitUnquoted option for shell field-splitting of unquoted expansions (#1)
1 parent 1b36468 commit b9545b3

4 files changed

Lines changed: 129 additions & 8 deletions

File tree

README.md

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,20 @@ output
6767
[ 'beep', '--boop=/home/robot' ]
6868
```
6969

70+
## parse with unquoted variable splitting
71+
72+
```js
73+
var parse = require('shell-quote/parse');
74+
var xs = parse('a $T', { T: 'c d' }, { splitUnquoted: true });
75+
console.dir(xs);
76+
```
77+
78+
output
79+
80+
```
81+
[ 'a', 'c', 'd' ]
82+
```
83+
7084
## parsing shell operators
7185

7286
```js
@@ -137,6 +151,13 @@ Return an array of arguments from the quoted string `cmd`.
137151
Interpolate embedded bash-style `$VARNAME` and `${VARNAME}` variables with
138152
the `env` object which like bash will replace undefined variables with `""`.
139153

154+
By default an expanded variable is a single token even when unquoted.
155+
Pass `{ splitUnquoted: true }` to split an unquoted expansion into multiple tokens the way a shell performs field splitting,
156+
using the default `IFS` (space, tab, newline).
157+
Pass a string to use its characters as the `IFS` instead
158+
(for example `{ splitUnquoted: ':' }`).
159+
A quoted expansion (`"$VAR"`) is never split.
160+
140161
Only simple `$VARNAME` and `${VARNAME}` interpolation is supported.
141162
Bash parameter expansion beyond a plain variable name is not evaluated:
142163
forms such as array subscripts (`${arr[i]}`), length (`${#arr[@]}`),
@@ -147,12 +168,13 @@ are not interpreted.
147168
Whitespace inside `${...}` throws a `Bad substitution` error.
148169

149170
`env` is usually an object but it can also be a function to perform lookups.
150-
When `env(key)` returns a string, its result will be output just like `env[key]`
151-
would. When `env(key)` returns an object, it will be inserted into the result
171+
When `env(key)` returns a string, its result will be output just like `env[key]` would.
172+
When `env(key)` returns an object, it will be inserted into the result
152173
array like the operator objects.
153174

154-
When a bash operator is encountered, the element in the array with be an object
155-
with an `"op"` key set to the operator string. For example:
175+
When a bash operator is encountered,
176+
the element in the array with be an object with an `"op"` key set to the operator string.
177+
For example:
156178

157179
```
158180
'beep || boop > /byte'
@@ -180,8 +202,6 @@ MIT
180202
[npm-version-svg]: https://versionbadg.es/ljharb/shell-quote.svg
181203
[deps-svg]: https://david-dm.org/ljharb/shell-quote.svg
182204
[deps-url]: https://david-dm.org/ljharb/shell-quote
183-
[dev-deps-svg]: https://david-dm.org/ljharb/shell-quote/dev-status.svg
184-
[dev-deps-url]: https://david-dm.org/ljharb/shell-quote#info=devDependencies
185205
[npm-badge-png]: https://nodei.co/npm/shell-quote.png?downloads=true&stars=true
186206
[license-image]: https://img.shields.io/npm/l/shell-quote.svg
187207
[license-url]: LICENSE

parse.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ declare namespace parse {
2222
export interface ParseOptions {
2323
/** Custom escape character. Defaults to `\\`. */
2424
escape?: string;
25+
/** Field-splits an unquoted variable expansion, the way a shell does using `IFS`; quoted expansions are never split. `true` uses the default IFS (space, tab, newline); a string uses its characters as the IFS. An empty string, `false`, or omitting the option disables splitting. Defaults to false. */
26+
splitUnquoted?: boolean | string;
2527
}
2628

2729
export type Env =

parse.js

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,15 @@ function getVar(env, pre, key) {
8383
/**
8484
* @param {string} string
8585
* @param {Env} [env]
86-
* @param {{ escape?: string }} [opts]
86+
* @param {{ escape?: string, splitUnquoted?: boolean | string }} [opts]
8787
* @returns {ParseEntry[]}
8888
*/
8989
function parseInternal(string, env, opts) {
9090
if (!opts) {
9191
opts = {};
9292
}
9393
var BS = opts.escape || '\\';
94+
var ifs = opts.splitUnquoted === true ? ' \t\n' : (typeof opts.splitUnquoted === 'string' ? opts.splitUnquoted : '');
9495
var BAREWORD = '(\\' + BS + '[\'"' + META + ']|[^\\s\'"' + META + '])+';
9596

9697
var chunker = new RegExp([
@@ -133,6 +134,11 @@ function parseInternal(string, env, opts) {
133134
var quote = false;
134135
var esc = false;
135136
var out = '';
137+
/** @type {string[]} */
138+
var words = [];
139+
var sawQuote = false;
140+
/** @type {number | null} */
141+
var pendingNw = null;
136142
var isGlob = false;
137143
/** @type {number} */
138144
var i;
@@ -184,8 +190,30 @@ function parseInternal(string, env, opts) {
184190
return getVar(/** @type {NonNullable<typeof env>} */ (env), '', varname);
185191
}
186192

193+
function flushRun() {
194+
if (pendingNw === null) {
195+
return;
196+
}
197+
if (pendingNw === 0) {
198+
if (out !== '') {
199+
words[words.length] = out;
200+
out = '';
201+
}
202+
} else {
203+
words[words.length] = out;
204+
out = '';
205+
for (var fe = 1; fe < pendingNw; fe += 1) {
206+
words[words.length] = '';
207+
}
208+
}
209+
pendingNw = null;
210+
}
211+
187212
for (i = 0; i < s.length; i++) {
188213
var c = s.charAt(i);
214+
if (ifs && c !== DS) {
215+
flushRun();
216+
}
189217
isGlob = isGlob || (!quote && (c === '*' || c === '?'));
190218
if (esc) {
191219
out += c;
@@ -212,6 +240,7 @@ function parseInternal(string, env, opts) {
212240
}
213241
} else if (c === DQ || c === SQ) {
214242
quote = c;
243+
sawQuote = true;
215244
} else if (controlRE.test(c)) {
216245
return /** @type {ControlOperator} */ ({ op: s });
217246
} else if (hash.test(c)) {
@@ -224,7 +253,22 @@ function parseInternal(string, env, opts) {
224253
} else if (c === BS) {
225254
esc = true;
226255
} else if (c === DS) {
227-
out += parseEnvVar();
256+
var value = parseEnvVar();
257+
if (!ifs) {
258+
out += value;
259+
} else {
260+
for (var vi = 0; vi < value.length; vi += 1) {
261+
var vc = value.charAt(vi);
262+
if (ifs.indexOf(vc) < 0) {
263+
flushRun();
264+
out += vc;
265+
} else if (pendingNw === null) {
266+
pendingNw = vc === ' ' || vc === '\t' || vc === '\n' ? 0 : 1;
267+
} else if (vc !== ' ' && vc !== '\t' && vc !== '\n') {
268+
pendingNw += 1;
269+
}
270+
}
271+
}
228272
} else {
229273
out += c;
230274
}
@@ -234,6 +278,20 @@ function parseInternal(string, env, opts) {
234278
return /** @type {GlobPattern} */ ({ op: 'glob', pattern: out });
235279
}
236280

281+
if (ifs) {
282+
if (pendingNw !== null && pendingNw > 0) {
283+
words[words.length] = out;
284+
out = '';
285+
for (var te = 1; te < pendingNw; te += 1) {
286+
words[words.length] = '';
287+
}
288+
}
289+
if (out !== '' || (sawQuote && words.length === 0)) {
290+
words[words.length] = out;
291+
}
292+
return words;
293+
}
294+
237295
return out;
238296
}).reduce(function (prev, arg) { // finalize parsed arguments
239297
if (typeof arg === 'undefined') {

test/parse.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,47 @@ test('nested parameter expansion', function (t) {
9090
t.end();
9191
});
9292

93+
test('splitUnquoted: field-splits unquoted variable expansion (#1)', function (t) {
94+
var env = { T: 'c d', E: '', S: ' c d ', W: ' ' };
95+
var opts = { splitUnquoted: true };
96+
97+
t.same(parse('test a b $T', env, opts), ['test', 'a', 'b', 'c', 'd'], 'unquoted expansion splits into separate tokens');
98+
t.same(parse('a$T', env, opts), ['ac', 'd'], 'the first field joins the preceding text');
99+
t.same(parse('$T x', env, opts), ['c', 'd', 'x'], 'the last field is a token of its own');
100+
t.same(parse('x${T}y', env, opts), ['xc', 'dy'], 'fields join text on both sides');
101+
t.same(parse('$S', env, opts), ['c', 'd'], 'leading, trailing, and repeated whitespace collapses');
102+
t.same(parse('a$S', env, opts), ['a', 'c', 'd'], 'leading whitespace closes the preceding field');
103+
t.same(parse('$W', env, opts), [], 'an all-whitespace expansion produces no tokens');
104+
t.same(parse('a$W b', env, opts), ['a', 'b'], 'an all-whitespace expansion just separates fields');
105+
t.same(parse('$E', env, opts), [], 'an empty unquoted expansion produces no token');
106+
t.same(parse('"$T"', env, opts), ['c d'], 'a quoted expansion is never split');
107+
t.same(parse('-x "" -y', env, opts), ['-x', '', '-y'], 'a quoted empty string is still preserved');
108+
t.same(parse('a $F b', function () { return 'c d'; }, opts), ['a', 'c', 'd', 'b'], 'the env-function path splits too');
109+
110+
t.same(parse('test a b $T', env), ['test', 'a', 'b', 'c d'], 'without the option, unquoted expansion is not split');
111+
112+
t.end();
113+
});
114+
115+
test('splitUnquoted: a string value is a custom IFS (#1)', function (t) {
116+
function o(ifs) { return { splitUnquoted: ifs }; }
117+
118+
t.same(parse('${V}', { V: 'a:b' }, o(':')), ['a', 'b'], 'a non-whitespace IFS char splits fields');
119+
t.same(parse('${V}', { V: 'a::b' }, o(':')), ['a', '', 'b'], 'adjacent non-whitespace delimiters yield an empty field');
120+
t.same(parse('${V}', { V: ':a:' }, o(':')), ['', 'a'], 'a leading delimiter yields a leading empty; a trailing one does not');
121+
t.same(parse('${V}', { V: 'a::' }, o(':')), ['a', ''], 'a trailing double delimiter yields one empty field');
122+
t.same(parse('${V}', { V: ':' }, o(':')), [''], 'a lone delimiter yields a single empty field');
123+
t.same(parse('${V}', { V: '::' }, o(':')), ['', ''], 'two delimiters yield two empty fields');
124+
t.same(parse('${V}${W}', { V: 'a:', W: ':b' }, o(':')), ['a', '', 'b'], 'delimiters spanning an expansion boundary merge into one run');
125+
t.same(parse('a${V}${W}z', { V: ':x:', W: ':y:' }, o(':')), ['a', 'x', '', 'y', 'z'], 'fields join literal text on both sides across expansions');
126+
t.same(parse('${V}', { V: 'a : b' }, o(' :')), ['a', 'b'], 'whitespace around a non-whitespace delimiter is absorbed');
127+
t.same(parse('${V}', { V: 'a b:c' }, o(' :')), ['a', 'b', 'c'], 'mixed IFS: whitespace and non-whitespace each delimit');
128+
t.same(parse('${V}', { V: 'a,b' }, o(',')), ['a', 'b'], 'any character can be the IFS');
129+
t.same(parse('${V}', { V: 'a b' }, o('')), ['a b'], 'an empty IFS string disables splitting');
130+
131+
t.end();
132+
});
133+
93134
test('parse stays linear in token count (GHSA-395f-4hp3-45gv)', function (t) {
94135
// the old concat-in-reduce finalizer was O(n^2): this many tokens took
95136
// ~minutes, so under the unfixed code this test hangs rather than passes

0 commit comments

Comments
 (0)