#!/usr/bin/env python3 """Shift Markdown ATX headings down by a fixed number of levels.""" from __future__ import annotations import argparse import pathlib import re import sys HEADING_RE = re.compile(r"^( {0,3})(#{1,6})([ \t]+.*)$") FENCE_RE = re.compile(r"^( {0,3})(`{3,}|~{3,})") def shift_headings(text: str, levels: int) -> str: if levels <= 0: raise ValueError("levels must be a positive integer") lines = text.splitlines(keepends=True) in_fence = False fence_marker = "" output: list[str] = [] for line in lines: body = line[:-1] if line.endswith("\n") else line newline = "\n" if line.endswith("\n") else "" if body.endswith("\r"): body = body[:-1] newline = "\r" + newline fence_match = FENCE_RE.match(body) if fence_match: marker = fence_match.group(2) marker_char = marker[0] if not in_fence: in_fence = True fence_marker = marker_char * len(marker) elif marker_char == fence_marker[0] and len(marker) >= len(fence_marker): in_fence = False fence_marker = "" output.append(line) continue if in_fence: output.append(line) continue heading_match = HEADING_RE.match(body) if heading_match: indent, hashes, rest = heading_match.groups() new_level = min(6, len(hashes) + levels) output.append(f"{indent}{'#' * new_level}{rest}{newline}") else: output.append(line) return "".join(output) def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser( description="Shift Markdown ATX headings down by adding # characters." ) parser.add_argument("path", type=pathlib.Path, help="Markdown file to rewrite") parser.add_argument( "-l", "--levels", type=int, default=2, help="Number of heading levels to add. Default: 2", ) parser.add_argument( "-o", "--output", type=pathlib.Path, help="Write to this file instead of modifying the source file", ) parser.add_argument( "--dry-run", action="store_true", help="Print converted content to stdout without writing files", ) return parser.parse_args(argv) def main(argv: list[str]) -> int: args = parse_args(argv) if args.levels <= 0: print("error: --levels must be a positive integer", file=sys.stderr) return 2 try: source = args.path.read_text(encoding="utf-8") except OSError as exc: print(f"error: cannot read {args.path}: {exc}", file=sys.stderr) return 1 fixed = shift_headings(source, args.levels) if args.dry_run: sys.stdout.write(fixed) return 0 destination = args.output or args.path try: destination.write_text(fixed, encoding="utf-8") except OSError as exc: print(f"error: cannot write {destination}: {exc}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))