diff --git a/debian/changelog b/debian/changelog index 9db1779..909c34d 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +jinjaturtle (0.3.4) unstable; urgency=medium + + * Render json files in a more pretty way + + -- Miguel Jacq Sun, 21 Dec 2025 11:20:00 +1100 + jinjaturtle (0.3.3) unstable; urgency=medium * Fixes for tomli on Ubuntu 22 diff --git a/pyproject.toml b/pyproject.toml index 2d29795..c983dd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "jinjaturtle" -version = "0.3.3" +version = "0.3.4" description = "Convert config files into Ansible defaults and Jinja2 templates." authors = ["Miguel Jacq "] license = "GPL-3.0-or-later" diff --git a/src/jinjaturtle/handlers/json.py b/src/jinjaturtle/handlers/json.py index 035efdc..a0b0017 100644 --- a/src/jinjaturtle/handlers/json.py +++ b/src/jinjaturtle/handlers/json.py @@ -29,6 +29,34 @@ class JsonHandler(DictLikeHandler): # As before: ignore original_text and rebuild structurally return self._generate_json_template(role_prefix, parsed) + JSON_INDENT = 2 + + def _leading_indent(self, s: str, idx: int) -> int: + """Return the number of leading spaces on the line containing idx.""" + line_start = s.rfind("\n", 0, idx) + 1 + indent = 0 + while line_start + indent < len(s) and s[line_start + indent] == " ": + indent += 1 + return indent + + def _replace_marker_with_pretty_loop( + self, + s: str, + marker: str, + replacement_builder, + ) -> str: + """ + Replace a quoted marker with an indentation-aware multiline snippet. + `marker` must include the surrounding JSON quotes, e.g. '"__LOOP_SCALAR__...__"'. + """ + marker_re = re.compile(re.escape(marker)) + + def _repl(m: re.Match[str]) -> str: + base_indent = self._leading_indent(m.string, m.start()) + return replacement_builder(base_indent) + + return marker_re.sub(_repl, s) + def generate_jinja2_template_with_loops( self, parsed: Any, @@ -127,65 +155,94 @@ class JsonHandler(DictLikeHandler): r'"__SCALAR__([a-zA-Z_][a-zA-Z0-9_]*)__"', r"{{ \1 | tojson }}", json_str ) - # Post-process to replace loop markers with actual Jinja loops + # Post-process to replace loop markers with actual Jinja loops (indent-aware) for candidate in loop_candidates: collection_var = self.make_var_name(role_prefix, candidate.path) item_var = candidate.loop_var if candidate.item_schema == "scalar": - # Replace scalar loop marker with Jinja for loop marker = f'"__LOOP_SCALAR__{collection_var}__{item_var}__"' - replacement = self._generate_json_scalar_loop( - collection_var, item_var, candidate + json_str = self._replace_marker_with_pretty_loop( + json_str, + marker, + lambda base, cv=collection_var, iv=item_var, c=candidate: self._generate_json_scalar_loop( + cv, iv, c, base + ), ) - json_str = json_str.replace(marker, replacement) elif candidate.item_schema in ("simple_dict", "nested"): - # Replace dict loop marker with Jinja for loop marker = f'"__LOOP_DICT__{collection_var}__{item_var}__"' - replacement = self._generate_json_dict_loop( - collection_var, item_var, candidate + json_str = self._replace_marker_with_pretty_loop( + json_str, + marker, + lambda base, cv=collection_var, iv=item_var, c=candidate: self._generate_json_dict_loop( + cv, iv, c, base + ), ) - json_str = json_str.replace(marker, replacement) return json_str + "\n" def _generate_json_scalar_loop( - self, collection_var: str, item_var: str, candidate: LoopCandidate + self, + collection_var: str, + item_var: str, + candidate: LoopCandidate, + base_indent: int, ) -> str: - """Generate a Jinja for loop for a scalar list in JSON.""" - # Use tojson filter to properly handle strings (quotes them) and other types - # Include array brackets around the loop - return ( - f"[{{% for {item_var} in {collection_var} %}}" - f"{{{{ {item_var} | tojson }}}}" - f"{{% if not loop.last %}}, {{% endif %}}" - f"{{% endfor %}}]" - ) - - def _generate_json_dict_loop( - self, collection_var: str, item_var: str, candidate: LoopCandidate - ) -> str: - """Generate a Jinja for loop for a dict list in JSON.""" + """Generate an indentation-preserving Jinja for-loop for a scalar JSON list.""" if not candidate.items: return "[]" - # Get first item as template - sample_item = candidate.items[0] - - # Build the dict template - use tojson for all values to handle types correctly - fields = [] - for key, value in sample_item.items(): - if key == "_key": - continue - # Use tojson filter to properly serialize all types (strings, numbers, booleans) - fields.append(f'"{key}": {{{{ {item_var}.{key} | tojson }}}}') - - dict_template = "{" + ", ".join(fields) + "}" + inner_indent = base_indent + self.JSON_INDENT + inner = " " * inner_indent + base = " " * base_indent + # Put the `{% for %}` at the start of the first item line so we don't emit + # a blank line between iterations under default Jinja whitespace settings. return ( - f"{{% for {item_var} in {collection_var} %}}" - f"{dict_template}" - f"{{% if not loop.last %}}, {{% endif %}}" - f"{{% endfor %}}" + f"[\n" + f"{{% for {item_var} in {collection_var} %}}{inner}{{{{ {item_var} | tojson }}}}" + f"{{% if not loop.last %}},{{% endif %}}\n" + f"{{% endfor %}}{base}]" + ) + + def _generate_json_dict_loop( + self, + collection_var: str, + item_var: str, + candidate: LoopCandidate, + base_indent: int, + ) -> str: + """Generate an indentation-preserving Jinja for-loop for a list of dicts in JSON.""" + if not candidate.items: + return "[]" + + # Get first item as template (preserve key order from the sample) + sample_item = candidate.items[0] + keys = [k for k in sample_item.keys() if k != "_key"] + + inner_indent = base_indent + self.JSON_INDENT # list item indent + field_indent = inner_indent + self.JSON_INDENT # dict field indent + inner = " " * inner_indent + field = " " * field_indent + base = " " * base_indent + + # Build a pretty dict body that matches json.dumps(indent=2) style. + dict_lines: list[str] = [ + "{" + ] # first line has no indent; we prepend `inner` when emitting + for i, key in enumerate(keys): + comma = "," if i < len(keys) - 1 else "" + dict_lines.append( + f'{field}"{key}": {{{{ {item_var}.{key} | tojson }}}}{comma}' + ) + # Comma between *items* goes after the closing brace. + dict_lines.append(f"{inner}}}{{% if not loop.last %}},{{% endif %}}") + dict_body = "\n".join(dict_lines) + + # Put the `{% for %}` at the start of the first item line to avoid blank lines. + return ( + f"[\n" + f"{{% for {item_var} in {collection_var} %}}{inner}{dict_body}\n" + f"{{% endfor %}}{base}]" )