diff --git a/src/jinjaturtle/erb.py b/src/jinjaturtle/erb.py index 8e16694..2c5788e 100644 --- a/src/jinjaturtle/erb.py +++ b/src/jinjaturtle/erb.py @@ -157,6 +157,9 @@ class ErbTranslator: return self.ruby_value(expr) def statement_to_erb(self, stmt: str) -> str: + if stmt.endswith("-"): + stmt = stmt[:-1].rstrip() + if stmt.startswith("for "): m = re.match( 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 self.loop_stack: self.loop_stack.pop() - return "<% end -%>" + return "<% end %>" if stmt.startswith("if "): cond = stmt[3:].strip() diff --git a/src/jinjaturtle/handlers/yaml.py b/src/jinjaturtle/handlers/yaml.py index f402600..19013f8 100644 --- a/src/jinjaturtle/handlers/yaml.py +++ b/src/jinjaturtle/handlers/yaml.py @@ -246,8 +246,18 @@ class YamlHandler(DictLikeHandler): 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): stripped = raw_line.lstrip() + is_blank = not stripped.strip() + is_comment = stripped.startswith("#") indent = len(raw_line) - len(stripped) # 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, # or when indentation moves above the parent collection. 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: skip_until_indent = None out_lines.append(raw_line) @@ -277,7 +302,7 @@ class YamlHandler(DictLikeHandler): continue # Skip this line # Blank or comment lines - if not stripped or stripped.startswith("#"): + if is_blank or is_comment: out_lines.append(raw_line) continue @@ -490,7 +515,7 @@ class YamlHandler(DictLikeHandler): lines.append(j2.for_start(item_var, collection_var)) lines.append(j2.for_end()) - return "\n".join(lines) + "\n" + return "\n".join(lines) def _dict_to_yaml_lines( self, diff --git a/src/jinjaturtle/j2.py b/src/jinjaturtle/j2.py index 800de44..498a39e 100644 --- a/src/jinjaturtle/j2.py +++ b/src/jinjaturtle/j2.py @@ -43,8 +43,10 @@ def to_json( 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.""" + if trim_right: + 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}") -def for_end() -> str: - return statement("endfor") +def for_end(*, trim_right: bool = False) -> str: + return statement("endfor", trim_right=trim_right) def yaml_scalar_expression(var_name: str, raw_value: str | None = None) -> str: diff --git a/tests/test_yaml_handler.py b/tests/test_yaml_handler.py index 4ccd77b..5c70e27 100644 --- a/tests/test_yaml_handler.py +++ b/tests/test_yaml_handler.py @@ -10,6 +10,7 @@ from jinjaturtle.core import ( analyze_loops, flatten_config, generate_ansible_yaml, + generate_erb_template, generate_jinja2_template, ) 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 +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): from jinja2 import Template