Render json files in a more pretty way
All checks were successful
CI / test (push) Successful in 39s
Lint / test (push) Successful in 28s
Trivy / test (push) Successful in 17s

This commit is contained in:
Miguel Jacq 2025-12-21 11:16:47 +11:00
parent 953ba11036
commit 3e7d1703b3
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
3 changed files with 104 additions and 41 deletions

View file

@ -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}]"
)