From 8d0eae0193659a55f00c598a5a282eba1a7f9fa7 Mon Sep 17 00:00:00 2001
From: Ruy Adorno <ruyadorno@hotmail.com>
Date: Mon, 12 Jul 2021 18:18:05 -0400
Subject: [PATCH] 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: https://github.com/npm/rfcs/pull/402
---
 docs/content/commands/npm-pkg.md |  7 +++
 lib/utils/queryable.js           | 58 +++++++++++++++++---
 test/lib/pkg.js                  | 32 ++++++++++++
 test/lib/utils/queryable.js      | 90 ++++++++++++++++++++++++++++++++
 4 files changed, 179 insertions(+), 8 deletions(-)

diff --git a/docs/content/commands/npm-pkg.md b/docs/content/commands/npm-pkg.md
index 7ff0a4d97930f..fe87411e41ee1 100644
--- a/docs/content/commands/npm-pkg.md
+++ b/docs/content/commands/npm-pkg.md
@@ -98,6 +98,13 @@ Returned values are always in **json** format.
     npm pkg set contributors[0].name='Foo' contributors[0].email='foo@bar.ca'
     ```
 
+    You may also append items to the end of an array using the special
+    empty bracket notation:
+
+    ```bash
+    npm pkg set contributors[].name='Bar' contributors[].email='bar@bar.ca'
+    ```
+
     It's also possible to parse values as json prior to saving them to your
     `package.json` file, for example in order to set a `"private": true`
     property:
diff --git a/lib/utils/queryable.js b/lib/utils/queryable.js
index 173877e64817c..45c144dec6abe 100644
--- a/lib/utils/queryable.js
+++ b/lib/utils/queryable.js
@@ -1,14 +1,30 @@
 const util = require('util')
 const _data = Symbol('data')
 const _delete = Symbol('delete')
+const _append = Symbol('append')
 
 const sqBracketsMatcher = str => str.match(/(.+)\[([^\]]+)\](.*)$/)
 
 const cleanLeadingDot = str =>
   str && str.startsWith('.') ? str.substr(1) : str
 
+// replaces any occurence of an empty-brackets (e.g: []) with a special
+// Symbol(append) to represent it, this is going to be useful for the setter
+// method that will push values to the end of the array when finding these
+const replaceAppendSymbols = str => {
+  const matchEmptyBracket = str.match(/^(.*)\[\](.*)$/)
+
+  if (matchEmptyBracket) {
+    const [, pre, post] = matchEmptyBracket
+    return [pre, _append, cleanLeadingDot(post)].filter(Boolean)
+  }
+
+  return [str]
+}
+
 const parseKeys = (key) => {
   const sqBracketItems = new Set()
+  sqBracketItems.add(_append)
   const parseSqBrackets = (str) => {
     const index = sqBracketsMatcher(str)
 
@@ -43,7 +59,11 @@ const parseKeys = (key) => {
       ]
     }
 
-    return [str]
+    // at the end of parsing, any usage of the special empty-bracket syntax
+    // (e.g: foo.array[]) has  not yet bene parsed, here we'll take care
+    // of parsing it and adding a special symbol to represent it in
+    // the resulting list of keys
+    return replaceAppendSymbols(str)
   }
 
   const res = []
@@ -79,6 +99,14 @@ const getter = ({ data, key }) => {
   let label = ''
 
   for (const k of keys) {
+    // empty-bracket-shortcut-syntax is not supported on getter
+    if (k === _append) {
+      throw Object.assign(
+        new Error('Empty brackets are not valid syntax for retrieving values.'),
+        { code: 'EINVALIDSYNTAX' }
+      )
+    }
+
     // extra logic to take into account printing array, along with its
     // special syntax in which using a dot-sep property name after an
     // arry will expand it's results, e.g:
@@ -119,14 +147,27 @@ const setter = ({ data, key, value, force }) => {
   // ['foo', 'bar', 'baz'] -> { foo: { bar: { baz:  {} } }
   const keys = parseKeys(key)
   const setKeys = (_data, _key) => {
-    // handles array indexes, making sure the new array is created if
-    // missing and properly casting the index to a number
-    const maybeIndex = Number(_key)
-    if (!Number.isNaN(maybeIndex)) {
+    // handles array indexes, converting valid integers to numbers,
+    // note that occurences of Symbol(append) will throw,
+    // so we just ignore these for now
+    let maybeIndex = Number.NaN
+    try {
+      maybeIndex = Number(_key)
+    } catch (err) {}
+    if (!Number.isNaN(maybeIndex))
       _key = maybeIndex
-      if (!Object.keys(_data).length)
-        _data = []
-    }
+
+    // creates new array in case key is an index
+    // and the array obj is not yet defined
+    const keyIsAnArrayIndex = _key === maybeIndex || _key === _append
+    const noArray = !Object.keys(_data).length || _data.length == null
+    if (keyIsAnArrayIndex && noArray)
+      _data = []
+
+    // the _append key is a special key that is used to represent
+    // the empty-bracket notation, e.g: arr[] -> arr[arr.length]
+    if (_key === _append)
+      _key = _data.length
 
     // retrieves the next data object to recursively iterate on,
     // throws if trying to override a literal value or add props to an array
@@ -141,6 +182,7 @@ const setter = ({ data, key, value, force }) => {
       // appended to the resulting obj is not an array index, then it
       // should throw since we can't append arbitrary props to arrays
       const shouldNotAddPropsToArrays =
+        typeof keys[0] !== 'symbol' &&
         Array.isArray(_data[_key]) &&
         Number.isNaN(Number(keys[0]))
 
diff --git a/test/lib/pkg.js b/test/lib/pkg.js
index 42eb7c0cc5e9c..688df6859054a 100644
--- a/test/lib/pkg.js
+++ b/test/lib/pkg.js
@@ -291,6 +291,38 @@ t.test('set single field', t => {
   })
 })
 
+t.test('push to array syntax', t => {
+  const json = {
+    name: 'foo',
+    version: '1.1.1',
+    keywords: [
+      'foo',
+    ],
+  }
+  npm.localPrefix = t.testdir({
+    'package.json': JSON.stringify(json),
+  })
+
+  pkg.exec(['set', 'keywords[]=bar', 'keywords[]=baz'], err => {
+    if (err)
+      throw err
+
+    t.strictSame(
+      readPackageJson(),
+      {
+        ...json,
+        keywords: [
+          'foo',
+          'bar',
+          'baz',
+        ],
+      },
+      'should append to arrays using empty bracket syntax'
+    )
+    t.end()
+  })
+})
+
 t.test('set multiple fields', t => {
   const json = {
     name: 'foo',
diff --git a/test/lib/utils/queryable.js b/test/lib/utils/queryable.js
index 2e66eeeb9e080..6082faa4c2bc7 100644
--- a/test/lib/utils/queryable.js
+++ b/test/lib/utils/queryable.js
@@ -130,6 +130,14 @@ t.test('query', async t => {
     q.query('missing[bar]'),
     undefined,
     'should return undefined also')
+  t.throws(() => q.query('lorem.dolor[]'),
+    { code: 'EINVALIDSYNTAX' },
+    'should throw if using empty brackets notation'
+  )
+  t.throws(() => q.query('lorem.dolor[].sit[0]'),
+    { code: 'EINVALIDSYNTAX' },
+    'should throw if using nested empty brackets notation'
+  )
 
   const qq = new Queryable({
     foo: {
@@ -602,6 +610,88 @@ t.test('set arrays', async t => {
     { code: 'EOVERRIDEVALUE' },
     'should throw an override error'
   )
+
+  qqq.set('arr[]', 'c')
+  t.strictSame(
+    qqq.toJSON(),
+    {
+      arr: [
+        'a',
+        'b',
+        'c',
+      ],
+    },
+    'should be able to append to array using empty bracket notation'
+  )
+
+  qqq.set('arr[].foo', 'foo')
+  t.strictSame(
+    qqq.toJSON(),
+    {
+      arr: [
+        'a',
+        'b',
+        'c',
+        {
+          foo: 'foo',
+        },
+      ],
+    },
+    'should be able to append objects to array using empty bracket notation'
+  )
+
+  qqq.set('arr[].bar.name', 'BAR')
+  t.strictSame(
+    qqq.toJSON(),
+    {
+      arr: [
+        'a',
+        'b',
+        'c',
+        {
+          foo: 'foo',
+        },
+        {
+          bar: {
+            name: 'BAR',
+          },
+        },
+      ],
+    },
+    'should be able to append more objects to array using empty brackets'
+  )
+
+  qqq.set('foo.bar.baz[].lorem.ipsum', 'something')
+  t.strictSame(
+    qqq.toJSON(),
+    {
+      arr: [
+        'a',
+        'b',
+        'c',
+        {
+          foo: 'foo',
+        },
+        {
+          bar: {
+            name: 'BAR',
+          },
+        },
+      ],
+      foo: {
+        bar: {
+          baz: [
+            {
+              lorem: {
+                ipsum: 'something',
+              },
+            },
+          ],
+        },
+      },
+    },
+    'should be able to append to array using empty brackets in nested objs'
+  )
 })
 
 t.test('delete values', async t => {