117 lines
3.1 KiB
Python
117 lines
3.1 KiB
Python
#!/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:]))
|