diff --git a/README.md b/README.md index e195fa8..2e6e3fd 100644 --- a/README.md +++ b/README.md @@ -101,20 +101,20 @@ Use the `--join-lists` or `-jl` option to suppress linebreaks after list items: ```json { "bar": "baz", - "foo": [1,2,3] + "foo": [1, 2, 3] } ``` Likewise, use the `--join-dict-items` or `-jd` option to suppress linebreaks after key/value pairs in a dict: ```json -{"bar": "baz","foo": [ - 1, - 2, - 3 - ]} +{"bar": "baz", "foo": [ + 1, + 2, + 3 +]} ``` Use `--condensed` or `-j` to apply both of these options: ```json -{"bar": "baz","foo": [1,2,3]} +{"bar": "baz", "foo": [1, 2, 3]} ``` The `--only-edits` or `-e` option will print out a list of edits rather than applying them to the input file in place. diff --git a/graphtage/json.py b/graphtage/json.py index 80c2724..c5bd639 100644 --- a/graphtage/json.py +++ b/graphtage/json.py @@ -109,9 +109,34 @@ def __init__(self): """ super().__init__('[', ']', ',') + def _joined(self, printer: Printer) -> bool: + return bool(getattr(printer, 'join_lists', False)) + def item_newline(self, printer: Printer, is_first: bool = False, is_last: bool = False): - if not hasattr(printer, 'join_lists') or not printer.join_lists: + """Separates two list items. + + A joined list keeps all of its items on one line, separated by a single space so that the result reads as + ``[1, 2, 3]`` rather than ``[1,2,3]``. + + """ + if not self._joined(printer): printer.newline() + elif not is_first and not is_last: + printer.write(' ') + + def items_indent(self, printer: Printer) -> Printer: + """Returns the printer context in which the list items are printed. + + A joined list emits no newlines of its own, so indenting its items would have no visible effect on the list + itself while still adding a level of indentation to any nested sequence that *does* break across lines. + + Returns: + Printer: :obj:`printer` itself if the list is joined, otherwise ``printer.indent()``. + + """ + if self._joined(printer): + return printer + return printer.indent() def print_ListNode(self, *args, **kwargs): """Prints a :class:`graphtage.ListNode`. @@ -146,9 +171,34 @@ class JSONDictFormatter(SequenceFormatter): def __init__(self): super().__init__('{', '}', ',') + def _joined(self, printer: Printer) -> bool: + return bool(getattr(printer, 'join_dict_items', False)) + def item_newline(self, printer: Printer, is_first: bool = False, is_last: bool = False): - if not hasattr(printer, 'join_dict_items') or not printer.join_dict_items: + """Separates two dict items. + + A joined dict keeps all of its items on one line, separated by a single space so that the result reads as + ``{"a": 1, "b": 2}`` rather than ``{"a": 1,"b": 2}``. + + """ + if not self._joined(printer): printer.newline() + elif not is_first and not is_last: + printer.write(' ') + + def items_indent(self, printer: Printer) -> Printer: + """Returns the printer context in which the dict items are printed. + + A joined dict emits no newlines of its own, so indenting its items would have no visible effect on the dict + itself while still adding a level of indentation to any nested sequence that *does* break across lines. + + Returns: + Printer: :obj:`printer` itself if the dict is joined, otherwise ``printer.indent()``. + + """ + if self._joined(printer): + return printer + return printer.indent() def print_MultiSetNode(self, *args, **kwargs): """Prints a :class:`graphtage.MultiSetNode`. diff --git a/test/test_json.py b/test/test_json.py new file mode 100644 index 0000000..ef82f12 --- /dev/null +++ b/test/test_json.py @@ -0,0 +1,92 @@ +from io import StringIO +from unittest import TestCase + +import graphtage +from graphtage.printer import Printer +from graphtage.utils import Tempfile + +NESTED = b'{"foo": [1, 2, 3], "bar": "baz"}' +DEEP = b'{"x": [{"y": [1, 2]}, 3]}' + + +def build(content: bytes) -> graphtage.TreeNode: + with Tempfile(content) as path: + return graphtage.FILETYPES_BY_TYPENAME["json"].build_tree(path) + + +def render(from_content: bytes, to_content: bytes | None = None, *, + join_lists: bool = False, join_dict_items: bool = False) -> str: + """Renders the diff of two JSON documents with the given join options. + + Args: + from_content: The JSON source of the "from" document. + to_content: The JSON source of the "to" document, or :const:`None` to diff ``from_content`` against itself. + join_lists: Whether to enable the ``--join-lists`` option. + join_dict_items: Whether to enable the ``--join-dict-items`` option. + + Returns: + str: The rendered diff. + + """ + if to_content is None: + to_content = from_content + stream = StringIO() + printer = Printer( + out_stream=stream, + ansi_color=False, + quiet=True, + options={"join_lists": join_lists, "join_dict_items": join_dict_items}, + ) + diff = build(from_content).diff(build(to_content)) + graphtage.FILETYPES_BY_TYPENAME["json"].get_default_formatter().print(printer, diff) + printer.flush(final=True) + return stream.getvalue() + + +class TestJSONJoinOptions(TestCase): + """Covers ``--join-lists`` and ``--join-dict-items``, which previously had no test coverage at all.""" + + def test_default_output_is_pretty_printed(self): + self.assertEqual( + '{\n "bar": "baz",\n "foo": [\n 1,\n 2,\n 3\n ]\n}', + render(NESTED) + ) + + def test_join_lists_keeps_the_dict_pretty_printed(self): + self.assertEqual('{\n "bar": "baz",\n "foo": [1, 2, 3]\n}', render(NESTED, join_lists=True)) + + def test_join_dict_items_does_not_indent_a_nested_list(self): + """A joined dict used to leave an unused indent on the printer, which the nested list then doubled. + + The list items came out eight spaces deep and the closing bracket four, as though the collapsed dict were + still occupying a level of indentation. + + """ + self.assertEqual('{"bar": "baz", "foo": [\n 1,\n 2,\n 3\n]}', render(NESTED, join_dict_items=True)) + + def test_condensed_joins_both_lists_and_dicts(self): + self.assertEqual( + '{"bar": "baz", "foo": [1, 2, 3]}', + render(NESTED, join_lists=True, join_dict_items=True) + ) + + def test_join_dict_items_indents_by_the_depth_that_breaks_lines(self): + """Only the sequences that actually emit newlines contribute a level of indentation.""" + self.assertEqual( + '{"x": [\n {"y": [\n 1,\n 2\n ]},\n 3\n]}', + render(DEEP, join_dict_items=True) + ) + + def test_join_lists_indents_by_the_depth_that_breaks_lines(self): + self.assertEqual('{\n "x": [{\n "y": [1, 2]\n }, 3]\n}', render(DEEP, join_lists=True)) + + def test_joined_items_are_separated_by_a_space(self): + """Joined output used to run the items together as ``[1,2,3]`` and ``{"bar": "baz","foo": …}``.""" + self.assertIn('[1, 2, 3]', render(NESTED, join_lists=True)) + self.assertIn('"baz", "foo"', render(NESTED, join_dict_items=True)) + + def test_joined_output_separates_inserted_and_removed_items(self): + self.assertEqual( + '{"a": [1, ~~2~~, 3], ++"b": 4++}', + render(b'{"a": [1, 2, 3]}', b'{"a": [1, 3], "b": 4}', join_lists=True, join_dict_items=True) + )