scripts/softwrap.py
scripts/softwrap.pyBrowse 4 files
4,683 tokens
17,652 bytes
Token encoding: o200k_base
Snapshot 39882c6
← Back to SKILL.md
1#!/usr/bin/env python32r"""Soft-wrap (unwrap) prose in Markdown / MyST files.3 4Joins each hard-wrapped paragraph and list item into a single logical line so the5editor and renderer handle line wrapping. The change is whitespace-only: it only6ever collapses the newlines *inside* a paragraph or list item to single spaces.7 8Left byte-for-byte unchanged:9 - YAML front matter (leading --- ... --- block)10 - fenced code blocks, including nested fences, fenced directives11 (```{list-table}, ```{eval-rst}, ```{toctree}, ```{code-cell}, ...), and a12 fence that opens on the same line as a list marker (- ```shell)13 - $$ ... $$ display math and \begin{env} ... \end{env} (amsmath) blocks14 - GFM pipe tables: a row containing a pipe followed by a |---|:--:| delimiter15 row, then the body rows -- every table row keeps its own line16 - colon-fence directive markers/options (:::{note}, :open:, ...) -- the prose17 *inside* a colon directive is still reflowed, only the markers stay put18 - MyST (target)= anchors, ATX headings, thematic breaks, block quotes19 - CommonMark indented code blocks: wherever no paragraph is open, a line20 indented four or more spaces (or a tab) opens a block that runs verbatim to21 the next non-blank line indented less than four22 - raw HTML lines at any indentation, and every line of a multi-line HTML23 comment24 - link reference definitions ([label]: url) and definition-list items (: def)25 - any paragraph containing a hard line break (trailing two spaces or "\")26 27Single-line runs are emitted unchanged, so the diff contains ONLY genuine joins.28 29Usage:30 softwrap.py PATH [PATH ...] # reflow files / dirs / globs in place31 softwrap.py --check PATH ... # report what would change; exit 1 if any32 softwrap.py --quiet PATH ... # only print files that changed33 34A dir argument is searched recursively for *.md and *.markdown.35"""36import argparse37import glob38import os39import re40import sys41 42H_RE = re.compile(r"^\s{0,3}#{1,6}(\s|$)")43TARGET_RE = re.compile(r"^\(([\w.\-]+)\)=\s*$")44COLON_RE = re.compile(r"^\s*:{3,}")45FENCE_OPEN_RE = re.compile(r"^(\s*)(`{3,}|~{3,})(.*)$")46FENCE_CLOSE_RE = re.compile(r"^(\s*)(`{3,}|~{3,})\s*$")47TBREAK_RE = re.compile(r"^\s{0,3}([-*_])([ \t]*\1){2,}[ \t]*$")48OPT_RE = re.compile(r"^\s*:[A-Za-z0-9_][A-Za-z0-9_+-]*:(\s.*)?$")49BQ_RE = re.compile(r"^\s{0,3}>")50MARKER_RE = re.compile(r"^(\s*)([-*+]|\d+[.)])\s")51BLANK_RE = re.compile(r"^\s*$")52# Raw HTML lines, at any indentation. CommonMark only opens an HTML block within53# 3 leading spaces, but indentation is not a reliable signal here: a raw HTML54# block nested in a directive body carries the body's indentation, and some Ray55# directives (query-param-ref) re-parse their content with docutils, where an56# indented ``.. raw:: html`` block is raw HTML rather than an indented code57# block. Joining those lines isn't this pass's business either way, so treat a58# raw HTML line as a boundary wherever it sits.59HTML_RE = re.compile(r"^\s*<(/?[A-Za-z][\w-]*|!--)")60HTML_COMMENT_OPEN_RE = re.compile(r"^\s*<!--")61HTML_COMMENT_CLOSE_RE = re.compile(r"-->")62MATH_FENCE_RE = re.compile(r"^\s*\$\$\s*$")63DOLLAR_SPAN_RE = re.compile(r"\$\$")64AMS_BEGIN_RE = re.compile(r"^\s*\\begin\{[A-Za-z*]+\}")65AMS_END_RE = re.compile(r"^\s*\\end\{[A-Za-z*]+\}")66DEFLIST_RE = re.compile(r"^\s{0,3}:\s")67LINKDEF_RE = re.compile(r"^\s{0,3}\[[^\]]+\]:\s")68MARKER_FENCE_RE = re.compile(r"^(\s*([-*+]|\d+[.)])\s+)(`{3,}|~{3,})")69# sphinx-design card separators: ^^^ ends the card header, +++ starts the footer.70# sphinx-design matches each with an anchored ``^\^{3,}\s*$`` / ``^\+{3,}\s*$``,71# so the marker only works on a line of its own. Joining it into the surrounding72# prose silently drops the card header -- and the CommonMark render oracle can't73# see it, because sphinx-design directives aren't part of the oracle's grammar.74CARD_SEP_RE = re.compile(r"^\s*(\^{3,}|\+{3,})\s*$")75HARDBREAK_RE = re.compile(r"(\S +|\\)$")76# CommonMark indented code block: four spaces (or a tab) opens a code block77# anywhere a paragraph isn't already open, and it runs until the next non-blank78# line indented less than four. Joining those lines leaves a block that still79# renders as code and still holds every non-whitespace byte, but whose contents80# no longer run --81# ``pip install ray`` and ``ray start --head`` become one command. No check in82# verify.py can see it: the content invariant and the CommonMark render oracle83# both collapse whitespace without exempting ``<pre>``, and the joined form is a84# stable fixed point, so idempotency holds too. Same for the render diff in the85# rst-to-myst skill, which normalizes the serialized ``<article>`` the same way.86# The cost is that anything else sitting four spaces deep with no paragraph open87# -- a nested list, a list-item continuation paragraph -- is left wrapped88# instead of joined. That's the under-reflow direction, which is the safe one.89INDENT_CODE_RE = re.compile(r"^(?: {4,}|\t)")90 91MARKDOWN_EXTS = (".md", ".markdown")92 93 94def has_hard_break(run):95 """True if any line in the run ends with a Markdown hard line break."""96 return any(HARDBREAK_RE.search(line) for line in run)97 98 99def is_pipe_delim(line):100 """True if line is a GFM table delimiter row, e.g. ``|---|:--:|---|``.101 102 A delimiter row contains only pipes, hyphens, colons, and whitespace, with103 at least one hyphen and at least one pipe. Its presence on the line right104 after a row that contains a pipe is what marks a GFM pipe table -- the same105 signal the CommonMark/GFM table parser uses.106 """107 s = line.strip()108 return bool(s) and all(c in "|-: \t" for c in s) and "-" in s and "|" in s109 110 111def join_run(run):112 """Collapse a run of wrapped prose lines into one line.113 114 A single-line run, or any run containing a hard line break, is returned115 verbatim so rendering is preserved exactly.116 """117 if len(run) == 1 or has_hard_break(run):118 return list(run)119 first = run[0]120 lead = first[: len(first) - len(first.lstrip())]121 parts = [p.strip() for p in run]122 parts = [p for p in parts if p]123 return [lead + " ".join(parts)]124 125 126def reflow(text):127 """Return (new_text, joins) where joins is the number of multi-line runs joined."""128 lines = text.split("\n")129 out = []130 run = []131 joins = 0132 133 def flush():134 nonlocal joins135 if run:136 joined = join_run(run)137 if len(joined) < len(run):138 joins += 1139 out.extend(joined)140 run.clear()141 142 n = len(lines)143 i = 0144 # Front matter: only when the very first line is a --- fence.145 if n > 0 and lines[0].strip() == "---":146 out.append(lines[0])147 i = 1148 while i < n:149 out.append(lines[i])150 closed = lines[i].strip() == "---"151 i += 1152 if closed:153 break154 155 in_fence = False156 fence_char = ""157 fence_len = 0158 in_math = False # $$ ... $$ display-math block159 in_amsmath = False # \begin{env} ... \end{env}160 in_html_comment = False # <!-- ... --> spanning lines161 while i < n:162 ln = lines[i]163 if in_html_comment:164 out.append(ln)165 if HTML_COMMENT_CLOSE_RE.search(ln):166 in_html_comment = False167 i += 1168 continue169 if in_fence:170 out.append(ln)171 m = FENCE_CLOSE_RE.match(ln)172 if m and m.group(2)[0] == fence_char and len(m.group(2)) >= fence_len:173 in_fence = False174 i += 1175 continue176 if in_math:177 out.append(ln)178 if MATH_FENCE_RE.match(ln):179 in_math = False180 i += 1181 continue182 if in_amsmath:183 out.append(ln)184 if AMS_END_RE.match(ln):185 in_amsmath = False186 i += 1187 continue188 # An indented code block, recognized the way CommonMark recognizes one:189 # the only thing it can't interrupt is a paragraph. A blank line isn't190 # required -- indented code opens immediately after a heading, a closing191 # fence, or a thematic break -- so the condition is an empty ``run``,192 # which is precisely "no paragraph is open right now". Checked ahead of193 # the fence rule because four spaces beat a fence marker in CommonMark194 # too.195 if INDENT_CODE_RE.match(ln) and not run:196 flush()197 while i < n and (198 BLANK_RE.match(lines[i]) or INDENT_CODE_RE.match(lines[i])199 ):200 out.append(lines[i])201 i += 1202 continue203 mo = FENCE_OPEN_RE.match(ln)204 if mo:205 flush()206 fence_char = mo.group(2)[0]207 fence_len = len(mo.group(2))208 in_fence = True209 out.append(ln)210 i += 1211 continue212 if MATH_FENCE_RE.match(ln): # lone $$ opens a display-math block213 flush()214 in_math = True215 out.append(ln)216 i += 1217 continue218 # An HTML comment that opens without closing on the same line runs219 # verbatim to its --> so a comment's interior lines stay as the author220 # wrote them.221 if HTML_COMMENT_OPEN_RE.match(ln) and not HTML_COMMENT_CLOSE_RE.search(ln):222 flush()223 in_html_comment = True224 out.append(ln)225 i += 1226 continue227 if CARD_SEP_RE.match(ln): # sphinx-design ^^^ / +++ card separator228 flush()229 out.append(ln)230 i += 1231 continue232 if AMS_BEGIN_RE.match(ln): # \begin{equation} ... \end{equation}233 flush()234 in_amsmath = True235 out.append(ln)236 i += 1237 continue238 # GFM pipe table: a row containing a pipe immediately followed by a239 # delimiter row. Emit header, delimiter, and the body rows verbatim so240 # every row keeps its own line -- joining rows would destroy the table.241 if "|" in ln and i + 1 < n and is_pipe_delim(lines[i + 1]):242 flush()243 out.append(ln) # header row244 out.append(lines[i + 1]) # delimiter row245 i += 2246 while i < n and lines[i].strip() and "|" in lines[i]:247 out.append(lines[i]) # body row248 i += 1249 continue250 if (251 BLANK_RE.match(ln)252 or BQ_RE.match(ln)253 or H_RE.match(ln)254 or HTML_RE.match(ln)255 or TARGET_RE.match(ln)256 or COLON_RE.match(ln)257 or TBREAK_RE.match(ln)258 or OPT_RE.match(ln)259 or DEFLIST_RE.match(ln)260 or LINKDEF_RE.match(ln)261 or DOLLAR_SPAN_RE.search(ln)262 ):263 flush()264 out.append(ln)265 i += 1266 continue267 mf = MARKER_FENCE_RE.match(ln)268 if mf: # a fenced code block opens on the same line as a list marker269 flush()270 fence_char = mf.group(3)[0]271 fence_len = len(mf.group(3))272 in_fence = True273 out.append(ln)274 i += 1275 continue276 if MARKER_RE.match(ln):277 flush()278 run.append(ln)279 i += 1280 continue281 run.append(ln)282 i += 1283 flush()284 return "\n".join(out), joins285 286 287def collect(paths):288 files = []289 for p in paths:290 if os.path.isdir(p):291 for root, _, names in os.walk(p):292 for nm in sorted(names):293 if nm.endswith(MARKDOWN_EXTS):294 files.append(os.path.join(root, nm))295 elif os.path.isfile(p):296 if p.endswith(MARKDOWN_EXTS):297 files.append(p)298 else:299 files.extend(g for g in sorted(glob.glob(p)) if g.endswith(MARKDOWN_EXTS))300 # de-dupe, keep order301 seen = set()302 uniq = []303 for f in files:304 rp = os.path.realpath(f)305 if rp not in seen:306 seen.add(rp)307 uniq.append(f)308 return uniq309 310 311# Each case is (name, input, expected output). Cases assert the boundaries that the312# CommonMark render oracle in verify.py cannot see, so a regression here would313# otherwise pass all three of that script's checks.314SELFTEST_CASES = [315 (316 "plain prose joins",317 "One line\nand another.\n",318 "One line and another.\n",319 ),320 (321 "sphinx-design card separators stay on their own line",322 ":::{grid-item-card}\n**Title**\n^^^\nBody text\nwrapped here.\n\n+++\nFooter.\n:::\n",323 ":::{grid-item-card}\n**Title**\n^^^\nBody text wrapped here.\n\n+++\nFooter.\n:::\n",324 ),325 (326 "RST simple table inside eval-rst is untouched",327 "```{eval-rst}\n====== ======\nCol A Col B\n====== ======\na b\n====== ======\n```\n",328 "```{eval-rst}\n====== ======\nCol A Col B\n====== ======\na b\n====== ======\n```\n",329 ),330 (331 "hard break suppresses the join",332 "Line one \nline two.\n",333 "Line one \nline two.\n",334 ),335 (336 "pipe table rows keep their lines",337 "| a | b |\n|---|---|\n| 1 | 2 |\n",338 "| a | b |\n|---|---|\n| 1 | 2 |\n",339 ),340 (341 "indented raw HTML inside a directive body keeps its lines",342 ":::{query-param-ref} ray-overview/examples\n:parameters: ?tags=llm\n\n.. raw:: html\n\n <svg width='24' height='24'>\n <g>\n <path d='M15 9Z'> </path>\n </g>\n </svg>Explore the examples\n:::\n",343 ":::{query-param-ref} ray-overview/examples\n:parameters: ?tags=llm\n\n.. raw:: html\n\n <svg width='24' height='24'>\n <g>\n <path d='M15 9Z'> </path>\n </g>\n </svg>Explore the examples\n:::\n",344 ),345 (346 "an indented code block keeps its lines",347 "Install it:\n\n pip install ray\n ray start --head\n\nThen open\nthe dashboard.\n",348 "Install it:\n\n pip install ray\n ray start --head\n\nThen open the dashboard.\n",349 ),350 (351 "an indented line can't open a code block mid-paragraph",352 "Prose that\n keeps going.\n",353 "Prose that keeps going.\n",354 ),355 (356 "an indented code block opens with no blank line after a heading",357 "## Install\n pip install ray\n ray start --head\n",358 "## Install\n pip install ray\n ray start --head\n",359 ),360 (361 "an indented code block opens with no blank line after a closing fence",362 "```python\nx = 1\n```\n pip install ray\n ray start --head\n",363 "```python\nx = 1\n```\n pip install ray\n ray start --head\n",364 ),365 (366 "a multi-line HTML comment keeps its lines",367 "<!-- DJS: this note\nspans two lines. -->\n\nProse that\njoins.\n",368 "<!-- DJS: this note\nspans two lines. -->\n\nProse that joins.\n",369 ),370]371 372 373def selftest():374 """Run the built-in regression cases. Returns a process exit code."""375 failures = 0376 for name, src, want in SELFTEST_CASES:377 got, _ = reflow(src)378 if got != want:379 failures += 1380 print(f"FAIL {name}\n want: {want!r}\n got: {got!r}")381 else:382 print(f"ok {name}")383 # Idempotency: a second pass must be a no-op.384 again, _ = reflow(got)385 if again != got:386 failures += 1387 print(f"FAIL {name} (not idempotent)\n second pass: {again!r}")388 print(f"\n{len(SELFTEST_CASES)} case(s), {failures} failure(s).")389 return 1 if failures else 0390 391 392def main():393 ap = argparse.ArgumentParser(description="Soft-wrap prose in Markdown/MyST files.")394 ap.add_argument("paths", nargs="*", help="files, directories, or globs")395 ap.add_argument(396 "--selftest", action="store_true", help="run built-in regression cases and exit"397 )398 ap.add_argument(399 "--check",400 action="store_true",401 help="don't write; exit 1 if any file would change",402 )403 ap.add_argument("--quiet", action="store_true", help="only list changed files")404 args = ap.parse_args()405 406 if args.selftest:407 return selftest()408 if not args.paths:409 ap.error("provide at least one path, or --selftest")410 411 files = collect(args.paths)412 if not files:413 print("no markdown files matched", file=sys.stderr)414 return 2415 416 any_change = False417 bad = 0418 for path in files:419 original = open(path, encoding="utf-8").read()420 new, joins = reflow(original)421 # Hard safety gate: non-whitespace content must be byte-identical.422 invariant_ok = re.sub(r"\s+", "", original) == re.sub(r"\s+", "", new)423 changed = new != original424 any_change = any_change or changed425 if not invariant_ok:426 bad += 1427 if not args.check and changed and invariant_ok:428 open(path, "w", encoding="utf-8").write(new)429 if args.quiet:430 if changed:431 print(path)432 else:433 if changed:434 verb = "would change" if args.check else "changed"435 else:436 verb = "unchanged"437 flag = "" if invariant_ok else " !! CONTENT-CHANGED, NOT WRITTEN !!"438 print(f"{verb:<13} joins={joins:<4} {path}{flag}")439 440 if bad:441 print(442 f"\n{bad} file(s) failed the content invariant; refusing to trust output.",443 file=sys.stderr,444 )445 return 3446 if args.check and any_change:447 return 1448 return 0449 450 451if __name__ == "__main__":452 sys.exit(main())453