diff --git a/README.md b/README.md index 23d4ff7..3449a3a 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,9 @@ After adding a hook to your pre-commit config, it's not a bad idea to run `pre-c - __format-autopkg-yaml-recipes__ - This hook auto-formats AutoPkg YAML recipes (`*.recipe.yaml`): reorders top-level keys, moves `NAME` to the top of `Input`, places `Arguments` last in each processor, and inserts a blank line before each top-level section. Comments and quoted strings (including YAML 1.1 boolean literals like `'YES'` and `'NO'`) are preserved. + This hook auto-formats AutoPkg YAML recipes (`*.recipe.yaml`): reorders top-level keys, moves `NAME` to the top of `Input`, places `Arguments` last in each processor, and inserts a blank line before each top-level section. Comments and quoted strings (including YAML 1.1 boolean literals like `'YES'` and `'NO'`) are preserved, and comment-only lines are reindented to match the content they precede. + + Pass `--no-blank-line-before-processor` (via the hook's `args`) to skip inserting a blank line before each `- Processor:` entry — useful when another hook manages spacing between processors, and it keeps a comment tight against the processor it documents. ### [Jamf](https://www.jamf.com/) diff --git a/pre_commit_macadmin_hooks/format_autopkg_yaml_recipes.py b/pre_commit_macadmin_hooks/format_autopkg_yaml_recipes.py index 0b931f0..31f5454 100644 --- a/pre_commit_macadmin_hooks/format_autopkg_yaml_recipes.py +++ b/pre_commit_macadmin_hooks/format_autopkg_yaml_recipes.py @@ -71,11 +71,23 @@ def _reorder_recipe(recipe) -> None: recipe.move_to_end(key) -def _insert_section_blank_lines(output: str) -> str: - """Ensure a single blank line precedes each top-level recipe section.""" +def _insert_section_blank_lines( + output: str, blank_line_before_processor: bool = True +) -> str: + """Ensure a single blank line precedes each top-level recipe section. + + When blank_line_before_processor is False, "- Processor:" lines are left + untouched (no blank line inserted before them), which keeps a comment tight + against the processor it documents. Use this when a separate hook manages + blank lines between processors. + """ + triggers = _TOP_LEVEL_TRIGGERS + if not blank_line_before_processor: + triggers = tuple(t for t in triggers if t != "- Processor:") + result: list[str] = [] for line in output.split("\n"): - if not line.startswith(_TOP_LEVEL_TRIGGERS): + if not line.startswith(triggers): result.append(line) continue @@ -94,7 +106,55 @@ def _insert_section_blank_lines(output: str) -> str: return "\n".join(result) -def tidy_recipe(path: str, yaml: ruamel.yaml.YAML) -> None: +def _realign_comments(output: str) -> str: + """Reindent comment-only lines to match the surrounding content. + + ruamel.yaml round-trips comments at their original absolute column, so a + comment does not move when the structural indentation around it changes + (most visibly on Process list items, which are re-emitted with the dash at + column 0). Reindent each comment-only line to the indentation of the next + content line, falling back to the previous content line for trailing + comments at the end of a block. Inline (trailing) comments are untouched + because they are not comment-only lines. + """ + + def _indent_of(text: str) -> int: + return len(text) - len(text.lstrip(" ")) + + def _is_comment(text: str) -> bool: + return text.lstrip(" ").startswith("#") + + lines = output.split("\n") + result = list(lines) + for idx, line in enumerate(lines): + if not _is_comment(line): + continue + target = next( + ( + _indent_of(nxt) + for nxt in lines[idx + 1 :] + if nxt.strip() and not _is_comment(nxt) + ), + None, + ) + if target is None: + target = next( + ( + _indent_of(prev) + for prev in reversed(result[:idx]) + if prev.strip() and not _is_comment(prev) + ), + None, + ) + if target is not None: + result[idx] = " " * target + line.lstrip(" ") + + return "\n".join(result) + + +def tidy_recipe( + path: str, yaml: ruamel.yaml.YAML, blank_line_before_processor: bool = True +) -> None: """Tidy a single AutoPkg YAML recipe in place.""" with open(path) as in_file: original = in_file.read() @@ -107,7 +167,10 @@ def tidy_recipe(path: str, yaml: ruamel.yaml.YAML) -> None: buf = io.StringIO() yaml.dump(recipe, buf) - formatted = _insert_section_blank_lines(buf.getvalue()) + formatted = _insert_section_blank_lines( + buf.getvalue(), blank_line_before_processor=blank_line_before_processor + ) + formatted = _realign_comments(formatted) # Skip the write so pre-commit doesn't flag the file as modified on a no-op. if formatted == original: @@ -123,6 +186,15 @@ def build_argument_parser() -> argparse.ArgumentParser: description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) parser.add_argument("filenames", nargs="*", help="Filenames to format.") + parser.add_argument( + "--no-blank-line-before-processor", + action="store_true", + help=( + "Do not insert a blank line before each '- Processor:' entry. " + "Useful when a separate hook manages spacing between processors; " + "also keeps comments tight against the processor they document." + ), + ) return parser @@ -135,7 +207,11 @@ def main(argv: list[str] | None = None) -> int: retval = 0 for filename in args.filenames: try: - tidy_recipe(filename, yaml) + tidy_recipe( + filename, + yaml, + blank_line_before_processor=not args.no_blank_line_before_processor, + ) except DuplicateKeyError as err: print(f"{filename}: yaml duplicate key: {err}") retval = 1 diff --git a/tests/test_format_autopkg_yaml_recipes.py b/tests/test_format_autopkg_yaml_recipes.py index 1ac95a0..4883106 100644 --- a/tests/test_format_autopkg_yaml_recipes.py +++ b/tests/test_format_autopkg_yaml_recipes.py @@ -139,6 +139,70 @@ def test_comments_preserved(self): self.assertEqual(format_autopkg_yaml_recipes.main([path]), 0) self.assertIn("# the recipe id", self._read(path)) + def test_comments_realigned_to_reindented_process(self): + # Process list indented 4 spaces; the formatter re-emits the dash at + # column 0, and the comment-only lines must follow the new indentation + # rather than keep their original absolute columns. + path = self._write( + "Identifier: com.example.test\n" + "Process:\n" + " # comment before first processor\n" + " - Processor: URLDownloader\n" + " Arguments:\n" + " # comment about url\n" + " url: https://example.com/file.dmg\n" + " # comment before second processor\n" + " - Processor: EndOfCheckPhase\n" + ) + self.assertEqual(format_autopkg_yaml_recipes.main([path]), 0) + lines = self._read(path).split("\n") + # Comments before a processor align with the dash (column 0). + self.assertIn("# comment before first processor", lines) + self.assertIn("# comment before second processor", lines) + # The comment inside Arguments aligns with its sibling key (column 4). + self.assertIn(" # comment about url", lines) + self.assertIn(" url: https://example.com/file.dmg", lines) + + def test_no_blank_line_before_processor_keeps_comment_tight(self): + # With the flag, no blank line is inserted before "- Processor:", so a + # comment stays directly above the processor it documents. + path = self._write( + "Identifier: com.example.test\n" + "Process:\n" + " # download the app\n" + " - Processor: URLDownloader\n" + " Arguments:\n" + " url: https://example.com/file.dmg\n" + " # end the check phase\n" + " - Processor: EndOfCheckPhase\n" + ) + self.assertEqual( + format_autopkg_yaml_recipes.main( + ["--no-blank-line-before-processor", path] + ), + 0, + ) + lines = self._read(path).split("\n") + # No blank line between the comment and its processor. + first = lines.index("# download the app") + self.assertEqual(lines[first + 1], "- Processor: URLDownloader") + second = lines.index("# end the check phase") + self.assertEqual(lines[second + 1], "- Processor: EndOfCheckPhase") + + def test_blank_line_before_processor_is_default(self): + # Without the flag, the default behavior still inserts a blank line + # before non-first "- Processor:" entries. + path = self._write( + "Identifier: com.example.test\n" + "Process:\n" + " - Processor: URLDownloader\n" + " - Processor: EndOfCheckPhase\n" + ) + self.assertEqual(format_autopkg_yaml_recipes.main([path]), 0) + lines = self._read(path).split("\n") + second = lines.index("- Processor: EndOfCheckPhase") + self.assertEqual(lines[second - 1], "") + def test_invalid_yaml_returns_one(self): path = self._write("Identifier: com.example.test\n : : bad\n") self.assertEqual(format_autopkg_yaml_recipes.main([path]), 1)