zcbot/core/markdown_guard.py

136 lines
4.1 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
assert inner_idx is not None
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
assert outer_close_idx is not None
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),
)