From 3e60997edba46544557b3a775bdb1538e07c3edf Mon Sep 17 00:00:00 2001
From: Zach White <skullydazed@gmail.com>
Date: Thu, 25 Mar 2021 04:38:10 -0700
Subject: [PATCH] Add a `qmk format-json` command that will format JSON files
 (#12372)

* Add a command to format json files

* change to work after rebase

* add test for qmk format-json

* add documentation for qmk format-json

* Update lib/python/qmk/cli/format/json.py
---
 docs/cli_commands.md                      |  10 ++
 lib/python/qmk/cli/__init__.py            |   1 +
 lib/python/qmk/cli/c2json.py              |   2 +-
 lib/python/qmk/cli/format/__init__.py     |   1 +
 lib/python/qmk/cli/format/json.py         |  66 ++++++++
 lib/python/qmk/cli/generate/api.py        |   2 +-
 lib/python/qmk/cli/generate/info_json.py  |   2 +-
 lib/python/qmk/cli/info.py                |   2 +-
 lib/python/qmk/cli/kle2json.py            |   2 +-
 lib/python/qmk/info_json_encoder.py       |  96 -----------
 lib/python/qmk/json_encoders.py           | 192 ++++++++++++++++++++++
 lib/python/qmk/tests/minimal_info.json    |  13 ++
 lib/python/qmk/tests/minimal_keymap.json  |   7 +
 lib/python/qmk/tests/pytest_export.json   |   6 -
 lib/python/qmk/tests/test_cli_commands.py |  24 +++
 15 files changed, 319 insertions(+), 107 deletions(-)
 create mode 100644 lib/python/qmk/cli/format/__init__.py
 create mode 100755 lib/python/qmk/cli/format/json.py
 delete mode 100755 lib/python/qmk/info_json_encoder.py
 create mode 100755 lib/python/qmk/json_encoders.py
 create mode 100644 lib/python/qmk/tests/minimal_info.json
 create mode 100644 lib/python/qmk/tests/minimal_keymap.json
 delete mode 100644 lib/python/qmk/tests/pytest_export.json

diff --git a/docs/cli_commands.md b/docs/cli_commands.md
index bb5df899680..6498b28b883 100644
--- a/docs/cli_commands.md
+++ b/docs/cli_commands.md
@@ -131,6 +131,16 @@ Check your environment and report problems only:
 
     qmk doctor -n
 
+## `qmk format-json`
+
+Formats a JSON file in a (mostly) human-friendly way. Will usually correctly detect the format of the JSON (info.json or keymap.json) but you can override this with `--format` if neccesary.
+
+**Usage**:
+
+```
+qmk format-json [-f FORMAT] <json_file>
+```
+
 ## `qmk info`
 
 Displays information about keyboards and keymaps in QMK. You can use this to get information about a keyboard, show the layouts, display the underlying key matrix, or to pretty-print JSON keymaps.
diff --git a/lib/python/qmk/cli/__init__.py b/lib/python/qmk/cli/__init__.py
index a5f1f476791..1349e68a9bf 100644
--- a/lib/python/qmk/cli/__init__.py
+++ b/lib/python/qmk/cli/__init__.py
@@ -16,6 +16,7 @@ from . import docs
 from . import doctor
 from . import fileformat
 from . import flash
+from . import format
 from . import generate
 from . import hello
 from . import info
diff --git a/lib/python/qmk/cli/c2json.py b/lib/python/qmk/cli/c2json.py
index a97e212223b..1fa833b647a 100644
--- a/lib/python/qmk/cli/c2json.py
+++ b/lib/python/qmk/cli/c2json.py
@@ -6,7 +6,7 @@ from milc import cli
 
 import qmk.keymap
 import qmk.path
-from qmk.info_json_encoder import InfoJSONEncoder
+from qmk.json_encoders import InfoJSONEncoder
 from qmk.keyboard import keyboard_folder
 
 
diff --git a/lib/python/qmk/cli/format/__init__.py b/lib/python/qmk/cli/format/__init__.py
new file mode 100644
index 00000000000..741ec778b11
--- /dev/null
+++ b/lib/python/qmk/cli/format/__init__.py
@@ -0,0 +1 @@
+from . import json
diff --git a/lib/python/qmk/cli/format/json.py b/lib/python/qmk/cli/format/json.py
new file mode 100755
index 00000000000..1358c70e7a5
--- /dev/null
+++ b/lib/python/qmk/cli/format/json.py
@@ -0,0 +1,66 @@
+"""JSON Formatting Script
+
+Spits out a JSON file formatted with one of QMK's formatters.
+"""
+import json
+
+from jsonschema import ValidationError
+from milc import cli
+
+from qmk.info import info_json
+from qmk.json_schema import json_load, keyboard_validate
+from qmk.json_encoders import InfoJSONEncoder, KeymapJSONEncoder
+from qmk.path import normpath
+
+
+@cli.argument('json_file', arg_only=True, type=normpath, help='JSON file to format')
+@cli.argument('-f', '--format', choices=['auto', 'keyboard', 'keymap'], default='auto', arg_only=True, help='JSON formatter to use (Default: autodetect)')
+@cli.subcommand('Generate an info.json file for a keyboard.', hidden=False if cli.config.user.developer else True)
+def format_json(cli):
+    """Format a json file.
+    """
+    json_file = json_load(cli.args.json_file)
+
+    if cli.args.format == 'auto':
+        try:
+            keyboard_validate(json_file)
+            json_encoder = InfoJSONEncoder
+
+        except ValidationError as e:
+            cli.log.warning('File %s did not validate as a keyboard:\n\t%s', cli.args.json_file, e)
+            cli.log.info('Treating %s as a keymap file.', cli.args.json_file)
+            json_encoder = KeymapJSONEncoder
+
+    elif cli.args.format == 'keyboard':
+        json_encoder = InfoJSONEncoder
+    elif cli.args.format == 'keymap':
+        json_encoder = KeymapJSONEncoder
+    else:
+        # This should be impossible
+        cli.log.error('Unknown format: %s', cli.args.format)
+        return False
+
+    if json_encoder == KeymapJSONEncoder and 'layout' in json_file:
+        # Attempt to format the keycodes.
+        layout = json_file['layout']
+        info_data = info_json(json_file['keyboard'])
+
+        if layout in info_data.get('layout_aliases', {}):
+            layout = json_file['layout'] = info_data['layout_aliases'][layout]
+
+        if layout in info_data.get('layouts'):
+            for layer_num, layer in enumerate(json_file['layers']):
+                current_layer = []
+                last_row = 0
+
+                for keymap_key, info_key in zip(layer, info_data['layouts'][layout]['layout']):
+                    if last_row != info_key['y']:
+                        current_layer.append('JSON_NEWLINE')
+                        last_row = info_key['y']
+
+                    current_layer.append(keymap_key)
+
+                json_file['layers'][layer_num] = current_layer
+
+    # Display the results
+    print(json.dumps(json_file, cls=json_encoder))
diff --git a/lib/python/qmk/cli/generate/api.py b/lib/python/qmk/cli/generate/api.py
index 29059083458..70019428f04 100755
--- a/lib/python/qmk/cli/generate/api.py
+++ b/lib/python/qmk/cli/generate/api.py
@@ -8,7 +8,7 @@ from milc import cli
 
 from qmk.datetime import current_datetime
 from qmk.info import info_json
-from qmk.info_json_encoder import InfoJSONEncoder
+from qmk.json_encoders import InfoJSONEncoder
 from qmk.json_schema import json_load
 from qmk.keyboard import list_keyboards
 
diff --git a/lib/python/qmk/cli/generate/info_json.py b/lib/python/qmk/cli/generate/info_json.py
index 6c00ba7d8a8..1af7f04392e 100755
--- a/lib/python/qmk/cli/generate/info_json.py
+++ b/lib/python/qmk/cli/generate/info_json.py
@@ -9,7 +9,7 @@ from milc import cli
 
 from qmk.decorators import automagic_keyboard, automagic_keymap
 from qmk.info import info_json
-from qmk.info_json_encoder import InfoJSONEncoder
+from qmk.json_encoders import InfoJSONEncoder
 from qmk.json_schema import load_jsonschema
 from qmk.keyboard import keyboard_folder
 from qmk.path import is_keyboard
diff --git a/lib/python/qmk/cli/info.py b/lib/python/qmk/cli/info.py
index 88b65686f54..aac507c1a5d 100755
--- a/lib/python/qmk/cli/info.py
+++ b/lib/python/qmk/cli/info.py
@@ -7,7 +7,7 @@ import platform
 
 from milc import cli
 
-from qmk.info_json_encoder import InfoJSONEncoder
+from qmk.json_encoders import InfoJSONEncoder
 from qmk.constants import COL_LETTERS, ROW_LETTERS
 from qmk.decorators import automagic_keyboard, automagic_keymap
 from qmk.keyboard import keyboard_folder, render_layouts, render_layout
diff --git a/lib/python/qmk/cli/kle2json.py b/lib/python/qmk/cli/kle2json.py
index 3bb74435828..91499c9af32 100755
--- a/lib/python/qmk/cli/kle2json.py
+++ b/lib/python/qmk/cli/kle2json.py
@@ -8,7 +8,7 @@ from milc import cli
 from kle2xy import KLE2xy
 
 from qmk.converter import kle2qmk
-from qmk.info_json_encoder import InfoJSONEncoder
+from qmk.json_encoders import InfoJSONEncoder
 
 
 @cli.argument('filename', help='The KLE raw txt to convert')
diff --git a/lib/python/qmk/info_json_encoder.py b/lib/python/qmk/info_json_encoder.py
deleted file mode 100755
index 60dae7247f9..00000000000
--- a/lib/python/qmk/info_json_encoder.py
+++ /dev/null
@@ -1,96 +0,0 @@
-"""Class that pretty-prints QMK info.json files.
-"""
-import json
-from decimal import Decimal
-
-
-class InfoJSONEncoder(json.JSONEncoder):
-    """Custom encoder to make info.json's a little nicer to work with.
-    """
-    container_types = (list, tuple, dict)
-    indentation_char = " "
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.indentation_level = 0
-
-        if not self.indent:
-            self.indent = 4
-
-    def encode(self, obj):
-        """Encode JSON objects for QMK.
-        """
-        if isinstance(obj, Decimal):
-            if obj == int(obj):  # I can't believe Decimal objects don't have .is_integer()
-                return int(obj)
-            return float(obj)
-
-        elif isinstance(obj, (list, tuple)):
-            if self._primitives_only(obj):
-                return "[" + ", ".join(self.encode(element) for element in obj) + "]"
-
-            else:
-                self.indentation_level += 1
-                output = [self.indent_str + self.encode(element) for element in obj]
-                self.indentation_level -= 1
-                return "[\n" + ",\n".join(output) + "\n" + self.indent_str + "]"
-
-        elif isinstance(obj, dict):
-            if obj:
-                if self.indentation_level == 4:
-                    # These are part of a layout, put them on a single line.
-                    return "{ " + ", ".join(f"{self.encode(key)}: {self.encode(element)}" for key, element in sorted(obj.items())) + " }"
-
-                else:
-                    self.indentation_level += 1
-                    output = [self.indent_str + f"{json.dumps(key)}: {self.encode(value)}" for key, value in sorted(obj.items(), key=self.sort_root_dict)]
-                    self.indentation_level -= 1
-                    return "{\n" + ",\n".join(output) + "\n" + self.indent_str + "}"
-            else:
-                return "{}"
-        else:
-            return super().encode(obj)
-
-    def _primitives_only(self, obj):
-        """Returns true if the object doesn't have any container type objects (list, tuple, dict).
-        """
-        if isinstance(obj, dict):
-            obj = obj.values()
-
-        return not any(isinstance(element, self.container_types) for element in obj)
-
-    def sort_root_dict(self, key):
-        """Forces layout to the back of the sort order.
-        """
-        key = key[0]
-
-        if self.indentation_level == 1:
-            if key == 'manufacturer':
-                return '10keyboard_name'
-
-            elif key == 'keyboard_name':
-                return '11keyboard_name'
-
-            elif key == 'maintainer':
-                return '12maintainer'
-
-            elif key in ('height', 'width'):
-                return '40' + str(key)
-
-            elif key == 'community_layouts':
-                return '97community_layouts'
-
-            elif key == 'layout_aliases':
-                return '98layout_aliases'
-
-            elif key == 'layouts':
-                return '99layouts'
-
-            else:
-                return '50' + str(key)
-
-        return key
-
-    @property
-    def indent_str(self):
-        return self.indentation_char * (self.indentation_level * self.indent)
diff --git a/lib/python/qmk/json_encoders.py b/lib/python/qmk/json_encoders.py
new file mode 100755
index 00000000000..9f3da022b48
--- /dev/null
+++ b/lib/python/qmk/json_encoders.py
@@ -0,0 +1,192 @@
+"""Class that pretty-prints QMK info.json files.
+"""
+import json
+from decimal import Decimal
+
+newline = '\n'
+
+
+class QMKJSONEncoder(json.JSONEncoder):
+    """Base class for all QMK JSON encoders.
+    """
+    container_types = (list, tuple, dict)
+    indentation_char = " "
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.indentation_level = 0
+
+        if not self.indent:
+            self.indent = 4
+
+    def encode_decimal(self, obj):
+        """Encode a decimal object.
+        """
+        if obj == int(obj):  # I can't believe Decimal objects don't have .is_integer()
+            return int(obj)
+
+        return float(obj)
+
+    def encode_list(self, obj):
+        """Encode a list-like object.
+        """
+        if self.primitives_only(obj):
+            return "[" + ", ".join(self.encode(element) for element in obj) + "]"
+
+        else:
+            self.indentation_level += 1
+            output = [self.indent_str + self.encode(element) for element in obj]
+            self.indentation_level -= 1
+
+            return "[\n" + ",\n".join(output) + "\n" + self.indent_str + "]"
+
+    def encode(self, obj):
+        """Encode keymap.json objects for QMK.
+        """
+        if isinstance(obj, Decimal):
+            return self.encode_decimal(obj)
+
+        elif isinstance(obj, (list, tuple)):
+            return self.encode_list(obj)
+
+        elif isinstance(obj, dict):
+            return self.encode_dict(obj)
+
+        else:
+            return super().encode(obj)
+
+    def primitives_only(self, obj):
+        """Returns true if the object doesn't have any container type objects (list, tuple, dict).
+        """
+        if isinstance(obj, dict):
+            obj = obj.values()
+
+        return not any(isinstance(element, self.container_types) for element in obj)
+
+    @property
+    def indent_str(self):
+        return self.indentation_char * (self.indentation_level * self.indent)
+
+
+class InfoJSONEncoder(QMKJSONEncoder):
+    """Custom encoder to make info.json's a little nicer to work with.
+    """
+    def encode_dict(self, obj):
+        """Encode info.json dictionaries.
+        """
+        if obj:
+            if self.indentation_level == 4:
+                # These are part of a layout, put them on a single line.
+                return "{ " + ", ".join(f"{self.encode(key)}: {self.encode(element)}" for key, element in sorted(obj.items())) + " }"
+
+            else:
+                self.indentation_level += 1
+                output = [self.indent_str + f"{json.dumps(key)}: {self.encode(value)}" for key, value in sorted(obj.items(), key=self.sort_dict)]
+                self.indentation_level -= 1
+                return "{\n" + ",\n".join(output) + "\n" + self.indent_str + "}"
+        else:
+            return "{}"
+
+    def sort_dict(self, key):
+        """Forces layout to the back of the sort order.
+        """
+        key = key[0]
+
+        if self.indentation_level == 1:
+            if key == 'manufacturer':
+                return '10keyboard_name'
+
+            elif key == 'keyboard_name':
+                return '11keyboard_name'
+
+            elif key == 'maintainer':
+                return '12maintainer'
+
+            elif key in ('height', 'width'):
+                return '40' + str(key)
+
+            elif key == 'community_layouts':
+                return '97community_layouts'
+
+            elif key == 'layout_aliases':
+                return '98layout_aliases'
+
+            elif key == 'layouts':
+                return '99layouts'
+
+            else:
+                return '50' + str(key)
+
+        return key
+
+
+class KeymapJSONEncoder(QMKJSONEncoder):
+    """Custom encoder to make keymap.json's a little nicer to work with.
+    """
+    def encode_dict(self, obj):
+        """Encode dictionary objects for keymap.json.
+        """
+        if obj:
+            self.indentation_level += 1
+            output_lines = [f"{self.indent_str}{json.dumps(key)}: {self.encode(value)}" for key, value in sorted(obj.items(), key=self.sort_dict)]
+            output = ',\n'.join(output_lines)
+            self.indentation_level -= 1
+
+            return f"{{\n{output}\n{self.indent_str}}}"
+
+        else:
+            return "{}"
+
+    def encode_list(self, obj):
+        """Encode a list-like object.
+        """
+        if self.indentation_level == 2:
+            indent_level = self.indentation_level + 1
+            # We have a list of keycodes
+            layer = [[]]
+
+            for key in obj:
+                if key == 'JSON_NEWLINE':
+                    layer.append([])
+                else:
+                    layer[-1].append(f'"{key}"')
+
+            layer = [f"{self.indent_str*indent_level}{', '.join(row)}" for row in layer]
+
+            return f"{self.indent_str}[\n{newline.join(layer)}\n{self.indent_str*self.indentation_level}]"
+
+        elif self.primitives_only(obj):
+            return "[" + ", ".join(self.encode(element) for element in obj) + "]"
+
+        else:
+            self.indentation_level += 1
+            output = [self.indent_str + self.encode(element) for element in obj]
+            self.indentation_level -= 1
+
+            return "[\n" + ",\n".join(output) + "\n" + self.indent_str + "]"
+
+    def sort_dict(self, key):
+        """Sorts the hashes in a nice way.
+        """
+        key = key[0]
+
+        if self.indentation_level == 1:
+            if key == 'version':
+                return '00version'
+
+            elif key == 'author':
+                return '01author'
+
+            elif key == 'notes':
+                return '02notes'
+
+            elif key == 'layers':
+                return '98layers'
+
+            elif key == 'documentation':
+                return '99documentation'
+
+            else:
+                return '50' + str(key)
+
+        return key
diff --git a/lib/python/qmk/tests/minimal_info.json b/lib/python/qmk/tests/minimal_info.json
new file mode 100644
index 00000000000..b91c23bd3d1
--- /dev/null
+++ b/lib/python/qmk/tests/minimal_info.json
@@ -0,0 +1,13 @@
+{
+    "keyboard_name": "tester",
+    "maintainer": "qmk",
+    "height": 5,
+    "width": 15,
+    "layouts": {
+        "LAYOUT": {
+            "layout": [
+                { "label": "KC_A", "x": 0, "y": 0, "matrix": [0, 0] }
+            ]
+        }
+    }
+}
diff --git a/lib/python/qmk/tests/minimal_keymap.json b/lib/python/qmk/tests/minimal_keymap.json
new file mode 100644
index 00000000000..258f9e8a9aa
--- /dev/null
+++ b/lib/python/qmk/tests/minimal_keymap.json
@@ -0,0 +1,7 @@
+{
+    "keyboard": "handwired/pytest/basic",
+    "keymap": "test",
+    "layers": [["KC_A"]],
+    "layout": "LAYOUT_ortho_1x1",
+    "version": 1
+}
diff --git a/lib/python/qmk/tests/pytest_export.json b/lib/python/qmk/tests/pytest_export.json
deleted file mode 100644
index 5fb0d624f8d..00000000000
--- a/lib/python/qmk/tests/pytest_export.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
-    "keyboard":"handwired/pytest/basic",
-    "keymap":"pytest_unittest",
-    "layout":"LAYOUT",
-    "layers":[["KC_A"]]
-}
diff --git a/lib/python/qmk/tests/test_cli_commands.py b/lib/python/qmk/tests/test_cli_commands.py
index a97472e6be1..c57d2b7fc79 100644
--- a/lib/python/qmk/tests/test_cli_commands.py
+++ b/lib/python/qmk/tests/test_cli_commands.py
@@ -259,3 +259,27 @@ def test_generate_layouts():
     result = check_subcommand('generate-layouts', '-kb', 'handwired/pytest/basic')
     check_returncode(result)
     assert '#define LAYOUT_custom(k0A) {' in result.stdout
+
+
+def test_format_json_keyboard():
+    result = check_subcommand('format-json', '--format', 'keyboard', 'lib/python/qmk/tests/minimal_info.json')
+    check_returncode(result)
+    assert result.stdout == '{\n    "keyboard_name": "tester",\n    "maintainer": "qmk",\n    "height": 5,\n    "width": 15,\n    "layouts": {\n        "LAYOUT": {\n            "layout": [\n                { "label": "KC_A", "matrix": [0, 0], "x": 0, "y": 0 }\n            ]\n        }\n    }\n}\n'
+
+
+def test_format_json_keymap():
+    result = check_subcommand('format-json', '--format', 'keymap', 'lib/python/qmk/tests/minimal_keymap.json')
+    check_returncode(result)
+    assert result.stdout == '{\n    "version": 1,\n    "keyboard": "handwired/pytest/basic",\n    "keymap": "test",\n    "layout": "LAYOUT_ortho_1x1",\n    "layers": [\n                [\n                        "KC_A"\n                ]\n    ]\n}\n'
+
+
+def test_format_json_keyboard_auto():
+    result = check_subcommand('format-json', '--format', 'auto', 'lib/python/qmk/tests/minimal_info.json')
+    check_returncode(result)
+    assert result.stdout == '{\n    "keyboard_name": "tester",\n    "maintainer": "qmk",\n    "height": 5,\n    "width": 15,\n    "layouts": {\n        "LAYOUT": {\n            "layout": [\n                { "label": "KC_A", "matrix": [0, 0], "x": 0, "y": 0 }\n            ]\n        }\n    }\n}\n'
+
+
+def test_format_json_keymap_auto():
+    result = check_subcommand('format-json', '--format', 'auto', 'lib/python/qmk/tests/minimal_keymap.json')
+    check_returncode(result)
+    assert result.stdout == '{\n    "keyboard": "handwired/pytest/basic",\n    "keymap": "test",\n    "layers": [\n        ["KC_A"]\n    ],\n    "layout": "LAYOUT_ortho_1x1",\n    "version": 1\n}\n'