From 80fceda02e57bc5fc7bb76d51d56c3577c8ea51b Mon Sep 17 00:00:00 2001
From: Luka Leer <luka.leer@gmail.com>
Date: Fri, 27 Dec 2024 12:07:55 +0100
Subject: [PATCH] fix: support Deno's JSON with comments (#523)

---
 docs/cli/shortcuts.md                      |  2 +-
 src/command-parser/expand-wildcard.spec.ts | 54 ++++++++++++--
 src/command-parser/expand-wildcard.ts      | 16 +++--
 src/jsonc.spec.ts                          | 84 ++++++++++++++++++++++
 src/jsonc.ts                               | 32 +++++++++
 5 files changed, 177 insertions(+), 11 deletions(-)
 create mode 100644 src/jsonc.spec.ts
 create mode 100644 src/jsonc.ts

diff --git a/docs/cli/shortcuts.md b/docs/cli/shortcuts.md
index d0a79d8a..c5ef6844 100644
--- a/docs/cli/shortcuts.md
+++ b/docs/cli/shortcuts.md
@@ -1,6 +1,6 @@
 # Command Shortcuts
 
-Package managers that execute scripts from a `package.json` or `deno.json` file can be shortened when in concurrently.<br/>
+Package managers that execute scripts from a `package.json` or `deno.(json|jsonc)` file can be shortened when in concurrently.<br/>
 The following are supported:
 
 | Syntax          | Expands to            |
diff --git a/src/command-parser/expand-wildcard.spec.ts b/src/command-parser/expand-wildcard.spec.ts
index 93cd58dd..abfe7972 100644
--- a/src/command-parser/expand-wildcard.spec.ts
+++ b/src/command-parser/expand-wildcard.spec.ts
@@ -1,4 +1,4 @@
-import fs from 'fs';
+import fs, { PathOrFileDescriptor } from 'fs';
 
 import { CommandInfo } from '../command';
 import { ExpandWildcard } from './expand-wildcard';
@@ -23,12 +23,53 @@ afterEach(() => {
 });
 
 describe('ExpandWildcard#readDeno', () => {
-    it('can read deno', () => {
+    it('can read deno.json', () => {
         const expectedDeno = {
             name: 'deno',
             version: '1.14.0',
         };
-        jest.spyOn(fs, 'readFileSync').mockImplementation((path) => {
+        jest.spyOn(fs, 'existsSync').mockImplementation((path: PathOrFileDescriptor) => {
+            return path === 'deno.json';
+        });
+        jest.spyOn(fs, 'readFileSync').mockImplementation((path: PathOrFileDescriptor) => {
+            if (path === 'deno.json') {
+                return JSON.stringify(expectedDeno);
+            }
+            return '';
+        });
+
+        const actualReadDeno = ExpandWildcard.readDeno();
+        expect(actualReadDeno).toEqual(expectedDeno);
+    });
+
+    it('can read deno.jsonc', () => {
+        const expectedDeno = {
+            name: 'deno',
+            version: '1.14.0',
+        };
+        jest.spyOn(fs, 'existsSync').mockImplementation((path: PathOrFileDescriptor) => {
+            return path === 'deno.jsonc';
+        });
+        jest.spyOn(fs, 'readFileSync').mockImplementation((path: PathOrFileDescriptor) => {
+            if (path === 'deno.jsonc') {
+                return '/* comment */\n' + JSON.stringify(expectedDeno);
+            }
+            return '';
+        });
+
+        const actualReadDeno = ExpandWildcard.readDeno();
+        expect(actualReadDeno).toEqual(expectedDeno);
+    });
+
+    it('prefers deno.json over deno.jsonc', () => {
+        const expectedDeno = {
+            name: 'deno',
+            version: '1.14.0',
+        };
+        jest.spyOn(fs, 'existsSync').mockImplementation((path: PathOrFileDescriptor) => {
+            return path === 'deno.json' || path === 'deno.jsonc';
+        });
+        jest.spyOn(fs, 'readFileSync').mockImplementation((path: PathOrFileDescriptor) => {
             if (path === 'deno.json') {
                 return JSON.stringify(expectedDeno);
             }
@@ -40,6 +81,7 @@ describe('ExpandWildcard#readDeno', () => {
     });
 
     it('can handle errors reading deno', () => {
+        jest.spyOn(fs, 'existsSync').mockReturnValue(true);
         jest.spyOn(fs, 'readFileSync').mockImplementation(() => {
             throw new Error('Error reading deno');
         });
@@ -55,7 +97,7 @@ describe('ExpandWildcard#readPackage', () => {
             name: 'concurrently',
             version: '6.4.0',
         };
-        jest.spyOn(fs, 'readFileSync').mockImplementation((path) => {
+        jest.spyOn(fs, 'readFileSync').mockImplementation((path: PathOrFileDescriptor) => {
             if (path === 'package.json') {
                 return JSON.stringify(expectedPackage);
             }
@@ -105,7 +147,7 @@ it('expands to nothing if no scripts exist in package.json', () => {
     expect(parser.parse(createCommandInfo('npm run foo-*-baz qux'))).toEqual([]);
 });
 
-it('expands to nothing if no tasks exist in deno.json and no scripts exist in package.json', () => {
+it('expands to nothing if no tasks exist in Deno config and no scripts exist in NodeJS config', () => {
     readDeno.mockReturnValue({});
     readPackage.mockReturnValue({});
 
@@ -192,7 +234,7 @@ describe.each(['npm run', 'yarn run', 'pnpm run', 'bun run', 'node --run'])(
             expect(readPackage).toHaveBeenCalledTimes(1);
         });
 
-        it("doesn't read deno.json", () => {
+        it("doesn't read Deno config", () => {
             readPackage.mockReturnValue({});
 
             parser.parse(createCommandInfo(`${command} foo-*-baz qux`));
diff --git a/src/command-parser/expand-wildcard.ts b/src/command-parser/expand-wildcard.ts
index 810750f3..f82b8bb8 100644
--- a/src/command-parser/expand-wildcard.ts
+++ b/src/command-parser/expand-wildcard.ts
@@ -2,6 +2,7 @@ import fs from 'fs';
 import _ from 'lodash';
 
 import { CommandInfo } from '../command';
+import JSONC from '../jsonc';
 import { CommandParser } from './command-parser';
 
 // Matches a negative filter surrounded by '(!' and ')'.
@@ -9,14 +10,21 @@ const OMISSION = /\(!([^)]+)\)/;
 
 /**
  * Finds wildcards in 'npm/yarn/pnpm/bun run', 'node --run' and 'deno task'
- * commands and replaces them with all matching scripts in the `package.json`
- * and `deno.json` files of the current directory.
+ * commands and replaces them with all matching scripts in the NodeJS and Deno
+ * configuration files of the current directory.
  */
 export class ExpandWildcard implements CommandParser {
     static readDeno() {
         try {
-            const json = fs.readFileSync('deno.json', { encoding: 'utf-8' });
-            return JSON.parse(json);
+            let json: string = '{}';
+
+            if (fs.existsSync('deno.json')) {
+                json = fs.readFileSync('deno.json', { encoding: 'utf-8' });
+            } else if (fs.existsSync('deno.jsonc')) {
+                json = fs.readFileSync('deno.jsonc', { encoding: 'utf-8' });
+            }
+
+            return JSONC.parse(json);
         } catch (e) {
             return {};
         }
diff --git a/src/jsonc.spec.ts b/src/jsonc.spec.ts
new file mode 100644
index 00000000..4e506037
--- /dev/null
+++ b/src/jsonc.spec.ts
@@ -0,0 +1,84 @@
+/*
+ORIGINAL https://www.npmjs.com/package/tiny-jsonc
+BY Fabio Spampinato
+MIT license
+
+Copied due to the dependency not being compatible with CommonJS
+*/
+
+import JSONC from './jsonc';
+
+const Fixtures = {
+    errors: {
+        comment: '// asd',
+        empty: '',
+        prefix: 'invalid 123',
+        suffix: '123 invalid',
+        multiLineString: `
+        {
+            "foo": "/*
+            */"
+        }
+        `,
+    },
+    parse: {
+        input: `
+        // Example // Yes
+        /* EXAMPLE */ /* YES */
+        {
+            "one": {},
+            "two" :{},
+            "three": {
+                "one": null,
+                "two" :true,
+                "three": false,
+                "four": "asd\\n\\u0022\\"",  
+                "five": -123.123e10,
+                "six": [ 123, true, [],],
+            },
+        }
+        // Example // Yes
+        /* EXAMPLE */ /* YES */
+        `,
+        output: {
+            one: {},
+            two: {},
+            three: {
+                one: null,
+                two: true,
+                three: false,
+                four: 'asd\n\u0022"',
+                five: -123.123e10,
+                six: [123, true, []],
+            },
+        },
+    },
+};
+
+describe('Tiny JSONC', () => {
+    it('supports strings with comments and trailing commas', () => {
+        const { input, output } = Fixtures.parse;
+
+        expect(JSONC.parse(input)).toEqual(output);
+    });
+
+    it('throws on invalid input', () => {
+        const { prefix, suffix } = Fixtures.errors;
+
+        expect(() => JSONC.parse(prefix)).toThrow(SyntaxError);
+        expect(() => JSONC.parse(suffix)).toThrow(SyntaxError);
+    });
+
+    it('throws on insufficient input', () => {
+        const { comment, empty } = Fixtures.errors;
+
+        expect(() => JSONC.parse(comment)).toThrow(SyntaxError);
+        expect(() => JSONC.parse(empty)).toThrow(SyntaxError);
+    });
+
+    it('throws on multi-line strings', () => {
+        const { multiLineString } = Fixtures.errors;
+
+        expect(() => JSONC.parse(multiLineString)).toThrow(SyntaxError);
+    });
+});
diff --git a/src/jsonc.ts b/src/jsonc.ts
new file mode 100644
index 00000000..7cef4b59
--- /dev/null
+++ b/src/jsonc.ts
@@ -0,0 +1,32 @@
+/*
+ORIGINAL https://www.npmjs.com/package/tiny-jsonc
+BY Fabio Spampinato
+MIT license
+
+Copied due to the dependency not being compatible with CommonJS
+*/
+
+/* HELPERS */
+const stringOrCommentRe = /("(?:\\?[^])*?")|(\/\/.*)|(\/\*[^]*?\*\/)/g;
+const stringOrTrailingCommaRe = /("(?:\\?[^])*?")|(,\s*)(?=]|})/g;
+
+/* MAIN */
+const JSONC = {
+    parse: (text: string) => {
+        text = String(text); // To be extra safe
+
+        try {
+            // Fast path for valid JSON
+            return JSON.parse(text);
+        } catch {
+            // Slow path for JSONC and invalid inputs
+            return JSON.parse(
+                text.replace(stringOrCommentRe, '$1').replace(stringOrTrailingCommaRe, '$1'),
+            );
+        }
+    },
+    stringify: JSON.stringify,
+};
+
+/* EXPORT */
+export default JSONC;