134 lines
4.0 KiB
Python
134 lines
4.0 KiB
Python
"""Narrow repairs for malformed nested Markdown fence examples.
|
|
|
|
Models occasionally wrap a fenced example in an equally long ``markdown``
|
|
fence. CommonMark cannot nest those fences: the inner closing fence closes
|
|
the outer block and the intended outer close opens a new, unclosed block.
|
|
Only that unambiguous shape is repaired here; arbitrary unclosed fences are
|
|
reported but left untouched.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
|
|
_FENCE_RE = re.compile(r"^( {0,3})(`{3,}|~{3,})([^\r\n]*)(\r?\n)?$")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MarkdownFenceResult:
|
|
text: str
|
|
repairs: int = 0
|
|
unclosed_fence: bool = False
|
|
|
|
|
|
def _fence(line: str) -> tuple[str, str, int, str] | None:
|
|
match = _FENCE_RE.match(line)
|
|
if not match:
|
|
return None
|
|
token = match.group(2)
|
|
return match.group(1), token[0], len(token), match.group(3).strip()
|
|
|
|
|
|
def _next_nonblank(lines: list[str], start: int) -> int | None:
|
|
for idx in range(start, len(lines)):
|
|
if lines[idx].strip():
|
|
return idx
|
|
return None
|
|
|
|
|
|
def _replace_fence(line: str, length: int) -> str:
|
|
match = _FENCE_RE.match(line)
|
|
if not match:
|
|
return line
|
|
return (
|
|
match.group(1)
|
|
+ match.group(2)[0] * length
|
|
+ match.group(3)
|
|
+ (match.group(4) or "")
|
|
)
|
|
|
|
|
|
def _has_unclosed_fence(lines: list[str]) -> bool:
|
|
opened: tuple[str, int] | None = None
|
|
for line in lines:
|
|
parsed = _fence(line)
|
|
if parsed is None:
|
|
continue
|
|
_indent, char, length, info = parsed
|
|
if opened is None:
|
|
opened = (char, length)
|
|
elif not info and char == opened[0] and length >= opened[1]:
|
|
opened = None
|
|
return opened is not None
|
|
|
|
|
|
def normalize_markdown_fences(text: str) -> MarkdownFenceResult:
|
|
"""Repair only an equally-long fenced block nested in markdown/md.
|
|
|
|
Recognised shape (blank lines between the two closing fences are allowed):
|
|
``markdown opener -> language opener -> language close -> outer close``.
|
|
The outer pair is lengthened by one character. Other malformed input is
|
|
preserved so the platform never guesses broadly at author intent.
|
|
"""
|
|
if not text:
|
|
return MarkdownFenceResult(text=text)
|
|
|
|
lines = text.splitlines(keepends=True)
|
|
repairs = 0
|
|
idx = 0
|
|
while idx < len(lines):
|
|
outer = _fence(lines[idx])
|
|
if outer is None or outer[3].lower() not in {"markdown", "md"}:
|
|
idx += 1
|
|
continue
|
|
|
|
inner_idx = _next_nonblank(lines, idx + 1)
|
|
inner = _fence(lines[inner_idx]) if inner_idx is not None else None
|
|
if (
|
|
inner is None
|
|
or not inner[3]
|
|
or inner[1] != outer[1]
|
|
or inner[2] < outer[2]
|
|
):
|
|
idx += 1
|
|
continue
|
|
|
|
inner_close_idx = None
|
|
for candidate in range(inner_idx + 1, len(lines)):
|
|
closing = _fence(lines[candidate])
|
|
if (
|
|
closing is not None
|
|
and not closing[3]
|
|
and closing[1] == inner[1]
|
|
and closing[2] >= inner[2]
|
|
):
|
|
inner_close_idx = candidate
|
|
break
|
|
if inner_close_idx is None:
|
|
idx += 1
|
|
continue
|
|
|
|
outer_close_idx = _next_nonblank(lines, inner_close_idx + 1)
|
|
outer_close = _fence(lines[outer_close_idx]) if outer_close_idx is not None else None
|
|
if (
|
|
outer_close is None
|
|
or outer_close[3]
|
|
or outer_close[1] != outer[1]
|
|
or outer_close[2] < outer[2]
|
|
):
|
|
idx += 1
|
|
continue
|
|
|
|
repaired_length = max(outer[2], inner[2]) + 1
|
|
lines[idx] = _replace_fence(lines[idx], repaired_length)
|
|
lines[outer_close_idx] = _replace_fence(lines[outer_close_idx], repaired_length)
|
|
repairs += 1
|
|
idx = outer_close_idx + 1
|
|
|
|
normalized = "".join(lines)
|
|
return MarkdownFenceResult(
|
|
text=normalized,
|
|
repairs=repairs,
|
|
unclosed_fence=_has_unclosed_fence(lines),
|
|
)
|