Skip to content

Commit 8371d7d

Browse files
committed
feat(pkg): add support to empty bracket syntax
Adds ability to using empty bracket syntax as a shortcut to appending items to the end of an array when using `npm pkg set`, e.g: npm pkg set keywords[]=foo Relates to: npm/rfcs#402 PR-URL: #3539 Credit: @ruyadorno Close: #3539 Reviewed-by: @darcyclarke, @ljharb
1 parent 98905ae commit 8371d7d

File tree

4 files changed

+376
-16
lines changed

4 files changed

+376
-16
lines changed

docs/content/commands/npm-pkg.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,13 @@ Returned values are always in **json** format.
9898
npm pkg set contributors[0].name='Foo' contributors[0].email='[email protected]'
9999
```
100100
101+
You may also append items to the end of an array using the special
102+
empty bracket notation:
103+
104+
```bash
105+
npm pkg set contributors[].name='Foo' contributors[].name='Bar'
106+
```
107+
101108
It's also possible to parse values as json prior to saving them to your
102109
`package.json` file, for example in order to set a `"private": true`
103110
property:

lib/utils/queryable.js

Lines changed: 75 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,27 @@
11
const util = require('util')
22
const _data = Symbol('data')
33
const _delete = Symbol('delete')
4+
const _append = Symbol('append')
45

5-
const sqBracketsMatcher = str => str.match(/(.+)\[([^\]]+)\](.*)$/)
6+
const sqBracketsMatcher = str => str.match(/(.+)\[([^\]]+)\]\.?(.*)$/)
67

7-
const cleanLeadingDot = str =>
8-
str && str.startsWith('.') ? str.substr(1) : str
8+
// replaces any occurence of an empty-brackets (e.g: []) with a special
9+
// Symbol(append) to represent it, this is going to be useful for the setter
10+
// method that will push values to the end of the array when finding these
11+
const replaceAppendSymbols = str => {
12+
const matchEmptyBracket = str.match(/^(.*)\[\]\.?(.*)$/)
13+
14+
if (matchEmptyBracket) {
15+
const [, pre, post] = matchEmptyBracket
16+
return [...replaceAppendSymbols(pre), _append, post].filter(Boolean)
17+
}
18+
19+
return [str]
20+
}
921

1022
const parseKeys = (key) => {
1123
const sqBracketItems = new Set()
24+
sqBracketItems.add(_append)
1225
const parseSqBrackets = (str) => {
1326
const index = sqBracketsMatcher(str)
1427

@@ -21,7 +34,7 @@ const parseKeys = (key) => {
2134
// foo.bar[foo.bar] should split into { foo: { bar: { 'foo.bar': {} } }
2235
/* eslint-disable-next-line no-new-wrappers */
2336
const foundKey = new String(index[2])
24-
const postSqBracketPortion = cleanLeadingDot(index[3])
37+
const postSqBracketPortion = index[3]
2538

2639
// we keep track of items found during this step to make sure
2740
// we don't try to split-separate keys that were defined within
@@ -43,7 +56,11 @@ const parseKeys = (key) => {
4356
]
4457
}
4558

46-
return [str]
59+
// at the end of parsing, any usage of the special empty-bracket syntax
60+
// (e.g: foo.array[]) has not yet been parsed, here we'll take care
61+
// of parsing it and adding a special symbol to represent it in
62+
// the resulting list of keys
63+
return replaceAppendSymbols(str)
4764
}
4865

4966
const res = []
@@ -79,6 +96,14 @@ const getter = ({ data, key }) => {
7996
let label = ''
8097

8198
for (const k of keys) {
99+
// empty-bracket-shortcut-syntax is not supported on getter
100+
if (k === _append) {
101+
throw Object.assign(
102+
new Error('Empty brackets are not valid syntax for retrieving values.'),
103+
{ code: 'EINVALIDSYNTAX' }
104+
)
105+
}
106+
82107
// extra logic to take into account printing array, along with its
83108
// special syntax in which using a dot-sep property name after an
84109
// arry will expand it's results, e.g:
@@ -119,13 +144,39 @@ const setter = ({ data, key, value, force }) => {
119144
// ['foo', 'bar', 'baz'] -> { foo: { bar: { baz: {} } }
120145
const keys = parseKeys(key)
121146
const setKeys = (_data, _key) => {
122-
// handles array indexes, making sure the new array is created if
123-
// missing and properly casting the index to a number
124-
const maybeIndex = Number(_key)
125-
if (!Number.isNaN(maybeIndex)) {
147+
// handles array indexes, converting valid integers to numbers,
148+
// note that occurences of Symbol(append) will throw,
149+
// so we just ignore these for now
150+
let maybeIndex = Number.NaN
151+
try {
152+
maybeIndex = Number(_key)
153+
} catch (err) {}
154+
if (!Number.isNaN(maybeIndex))
126155
_key = maybeIndex
127-
if (!Object.keys(_data).length)
128-
_data = []
156+
157+
// creates new array in case key is an index
158+
// and the array obj is not yet defined
159+
const keyIsAnArrayIndex = _key === maybeIndex || _key === _append
160+
const dataHasNoItems = !Object.keys(_data).length
161+
if (keyIsAnArrayIndex && dataHasNoItems && !Array.isArray(_data))
162+
_data = []
163+
164+
// converting from array to an object is also possible, in case the
165+
// user is using force mode, we should also convert existing arrays
166+
// to an empty object if the current _data is an array
167+
if (force && Array.isArray(_data) && !keyIsAnArrayIndex)
168+
_data = { ..._data }
169+
170+
// the _append key is a special key that is used to represent
171+
// the empty-bracket notation, e.g: arr[] -> arr[arr.length]
172+
if (_key === _append) {
173+
if (!Array.isArray(_data)) {
174+
throw Object.assign(
175+
new Error(`Can't use append syntax in non-Array element`),
176+
{ code: 'ENOAPPEND' }
177+
)
178+
}
179+
_key = _data.length
129180
}
130181

131182
// retrieves the next data object to recursively iterate on,
@@ -141,20 +192,30 @@ const setter = ({ data, key, value, force }) => {
141192
// appended to the resulting obj is not an array index, then it
142193
// should throw since we can't append arbitrary props to arrays
143194
const shouldNotAddPropsToArrays =
195+
typeof keys[0] !== 'symbol' &&
144196
Array.isArray(_data[_key]) &&
145197
Number.isNaN(Number(keys[0]))
146198

147199
const overrideError =
148200
haveContents &&
149-
(shouldNotOverrideLiteralValue || shouldNotAddPropsToArrays)
150-
201+
shouldNotOverrideLiteralValue
151202
if (overrideError) {
152203
throw Object.assign(
153-
new Error(`Property ${key} already has a value in place.`),
204+
new Error(`Property ${_key} already exists and is not an Array or Object.`),
154205
{ code: 'EOVERRIDEVALUE' }
155206
)
156207
}
157208

209+
const addPropsToArrayError =
210+
haveContents &&
211+
shouldNotAddPropsToArrays
212+
if (addPropsToArrayError) {
213+
throw Object.assign(
214+
new Error(`Can't add property ${key} to an Array.`),
215+
{ code: 'ENOADDPROP' }
216+
)
217+
}
218+
158219
return typeof _data[_key] === 'object' ? _data[_key] || {} : {}
159220
}
160221

test/lib/pkg.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,38 @@ t.test('set single field', t => {
291291
})
292292
})
293293

294+
t.test('push to array syntax', t => {
295+
const json = {
296+
name: 'foo',
297+
version: '1.1.1',
298+
keywords: [
299+
'foo',
300+
],
301+
}
302+
npm.localPrefix = t.testdir({
303+
'package.json': JSON.stringify(json),
304+
})
305+
306+
pkg.exec(['set', 'keywords[]=bar', 'keywords[]=baz'], err => {
307+
if (err)
308+
throw err
309+
310+
t.strictSame(
311+
readPackageJson(),
312+
{
313+
...json,
314+
keywords: [
315+
'foo',
316+
'bar',
317+
'baz',
318+
],
319+
},
320+
'should append to arrays using empty bracket syntax'
321+
)
322+
t.end()
323+
})
324+
})
325+
294326
t.test('set multiple fields', t => {
295327
const json = {
296328
name: 'foo',

0 commit comments

Comments
 (0)