Fix newlines in yaml
All checks were successful
CI / test (push) Successful in 1m9s
Lint / test (push) Successful in 38s

This commit is contained in:
Miguel Jacq 2026-06-20 17:52:04 +10:00
parent 94aeaf6e7a
commit bbbbe33e54
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
4 changed files with 84 additions and 7 deletions

View file

@ -157,6 +157,9 @@ class ErbTranslator:
return self.ruby_value(expr) return self.ruby_value(expr)
def statement_to_erb(self, stmt: str) -> str: def statement_to_erb(self, stmt: str) -> str:
if stmt.endswith("-"):
stmt = stmt[:-1].rstrip()
if stmt.startswith("for "): if stmt.startswith("for "):
m = re.match( m = re.match(
r"^for\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+([A-Za-z_][A-Za-z0-9_]*)$", r"^for\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+([A-Za-z_][A-Za-z0-9_]*)$",
@ -172,7 +175,7 @@ class ErbTranslator:
if stmt == "endfor": if stmt == "endfor":
if self.loop_stack: if self.loop_stack:
self.loop_stack.pop() self.loop_stack.pop()
return "<% end -%>" return "<% end %>"
if stmt.startswith("if "): if stmt.startswith("if "):
cond = stmt[3:].strip() cond = stmt[3:].strip()

View file

@ -246,8 +246,18 @@ class YamlHandler(DictLikeHandler):
return None, None return None, None
return None, None return None, None
def next_significant_line(index: int) -> tuple[int, str] | None:
for future_line in lines[index + 1 :]:
future_stripped = future_line.lstrip()
if not future_stripped.strip() or future_stripped.startswith("#"):
continue
return len(future_line) - len(future_stripped), future_stripped
return None
for line_index, raw_line in enumerate(lines): for line_index, raw_line in enumerate(lines):
stripped = raw_line.lstrip() stripped = raw_line.lstrip()
is_blank = not stripped.strip()
is_comment = stripped.startswith("#")
indent = len(raw_line) - len(stripped) indent = len(raw_line) - len(stripped)
# If we're skipping lines inside a collection replaced by a loop, # If we're skipping lines inside a collection replaced by a loop,
@ -261,7 +271,22 @@ class YamlHandler(DictLikeHandler):
# Stop only when a non-list item at the parent indentation appears, # Stop only when a non-list item at the parent indentation appears,
# or when indentation moves above the parent collection. # or when indentation moves above the parent collection.
if skip_until_indent is not None: if skip_until_indent is not None:
if not stripped or stripped.startswith("#"): if is_blank:
next_line = next_significant_line(line_index)
if next_line is None:
skip_until_indent = None
out_lines.append(raw_line)
else:
next_indent, next_stripped = next_line
still_in_collection = next_indent > skip_until_indent or (
next_indent == skip_until_indent
and next_stripped.startswith("- ")
)
if not still_in_collection:
skip_until_indent = None
out_lines.append(raw_line)
continue
if is_comment:
if indent <= skip_until_indent: if indent <= skip_until_indent:
skip_until_indent = None skip_until_indent = None
out_lines.append(raw_line) out_lines.append(raw_line)
@ -277,7 +302,7 @@ class YamlHandler(DictLikeHandler):
continue # Skip this line continue # Skip this line
# Blank or comment lines # Blank or comment lines
if not stripped or stripped.startswith("#"): if is_blank or is_comment:
out_lines.append(raw_line) out_lines.append(raw_line)
continue continue
@ -490,7 +515,7 @@ class YamlHandler(DictLikeHandler):
lines.append(j2.for_start(item_var, collection_var)) lines.append(j2.for_start(item_var, collection_var))
lines.append(j2.for_end()) lines.append(j2.for_end())
return "\n".join(lines) + "\n" return "\n".join(lines)
def _dict_to_yaml_lines( def _dict_to_yaml_lines(
self, self,

View file

@ -43,8 +43,10 @@ def to_json(
return filtered(value, f"to_json({', '.join(args)})") return filtered(value, f"to_json({', '.join(args)})")
def statement(value: str) -> str: def statement(value: str, *, trim_right: bool = False) -> str:
"""Return a Jinja2 statement tag for an already-built statement body.""" """Return a Jinja2 statement tag for an already-built statement body."""
if trim_right:
return f"{{% {value} -%}}"
return f"{{% {value} %}}" return f"{{% {value} %}}"
@ -64,8 +66,8 @@ def for_start(item_var: str, collection_var: str) -> str:
return statement(f"for {item_var} in {collection_var}") return statement(f"for {item_var} in {collection_var}")
def for_end() -> str: def for_end(*, trim_right: bool = False) -> str:
return statement("endfor") return statement("endfor", trim_right=trim_right)
def yaml_scalar_expression(var_name: str, raw_value: str | None = None) -> str: def yaml_scalar_expression(var_name: str, raw_value: str | None = None) -> str:

View file

@ -10,6 +10,7 @@ from jinjaturtle.core import (
analyze_loops, analyze_loops,
flatten_config, flatten_config,
generate_ansible_yaml, generate_ansible_yaml,
generate_erb_template,
generate_jinja2_template, generate_jinja2_template,
) )
from jinjaturtle.handlers.yaml import YamlHandler from jinjaturtle.handlers.yaml import YamlHandler
@ -169,6 +170,52 @@ def test_yaml_indentless_sequence_loop_roundtrips_semantically(tmp_path: Path):
assert " vagrant:" not in template assert " vagrant:" not in template
def test_yaml_loop_preserves_blank_separator_after_list(tmp_path: Path):
from jinja2 import Template
cases = [
(
"blank",
"require:\n - rubocop-performance\n - rubocop-rspec\n\nAllCops:\n NewCops: enable\n",
"\n - rubocop-rspec\n\nAllCops:",
"<% end %>\nAllCops:",
),
(
"no_blank",
"require:\n - rubocop-performance\n - rubocop-rspec\nAllCops:\n NewCops: enable\n",
"\n - rubocop-rspec\nAllCops:",
"<% end %>AllCops:",
),
]
for label, text, rendered_expected, erb_expected in cases:
path = tmp_path / f"{label}.yml"
path.write_text(text, encoding="utf-8")
fmt, parsed = parse_config(path)
loop_candidates = analyze_loops(fmt, parsed)
flat_items = flatten_config(fmt, parsed, loop_candidates)
defaults = yaml.safe_load(
generate_ansible_yaml("role", flat_items, loop_candidates)
)
template = generate_jinja2_template(
fmt, parsed, "role", original_text=text, loop_candidates=loop_candidates
)
rendered = Template(template).render(**defaults)
assert rendered_expected in rendered
erb_template = generate_erb_template(
fmt,
parsed,
"role",
original_text=text,
loop_candidates=loop_candidates,
flat_items=flat_items,
)
assert erb_expected in erb_template
def test_yaml_loop_preserves_following_top_level_comments(tmp_path: Path): def test_yaml_loop_preserves_following_top_level_comments(tmp_path: Path):
from jinja2 import Template from jinja2 import Template