#!/usr/bin/env python3 """Shared Markdown container, fence, and inline-code recognition.""" from __future__ import annotations import re OPEN_FENCE_RE = re.compile(r"^([ \t]*)(`{3,}|~{3,})(.*?)(?:\r?\n)?$") LIST_FENCE_RE = re.compile( r"^(?P[ \t]*)(?P[-+*]|[0-9]{1,9}[.)])" r"(?P[ \t]+)(?P`{3,}|~{3,})(?P.*)$" ) BLOCKQUOTE_PREFIX_RE = re.compile(r"^ {0,3}>[ \t]?") RAW_HTML_TAG_RE = re.compile( r"^[ ]{0,3}<(?Ppre|script|style|textarea)(?:[ \t>]|$)", re.IGNORECASE, ) BLOCK_HTML_TAG_RE = re.compile( r"^[ ]{0,3}/]|$)", re.IGNORECASE, ) COMPLETE_HTML_TAG_RE = re.compile( r"^[ ]{0,3}`]+|'[^']*'|\"[^\"]*\"))?)*" r"[ \t]*/?>[ \t]*$" ) THEMATIC_BREAK_RE = re.compile( r"^[ ]{0,3}(?:(?:\*[ \t]*){3,}|(?:_[ \t]*){3,}|(?:-[ \t]*){3,})$" ) def html_tag_spans(text: str) -> list[tuple[int, int]]: """Return HTML tag spans while excluding comments and URI autolinks.""" spans: list[tuple[int, int]] = [] cursor = 0 while cursor < len(text): start = text.find("<", cursor) if start < 0: break name = re.match(r"= len(text) or text[prefix_end] not in " \t\r\n/>": cursor = start + 1 continue quote: str | None = None end = prefix_end while end < len(text): character = text[end] if quote is not None: if character == quote: quote = None elif character in "\"'": quote = character elif character == ">": spans.append((start, end + 1)) end += 1 break end += 1 cursor = max(start + 1, end) return spans def indentation_columns(value: str) -> int: columns = 0 for character in value: columns += 4 - (columns % 4) if character == "\t" else 1 return columns def strip_blockquotes(value: str, required_depth: int | None = None) -> tuple[str, int]: depth = 0 rest = value while required_depth is None or depth < required_depth: match = BLOCKQUOTE_PREFIX_RE.match(rest) if match is None: break rest = rest[match.end() :] depth += 1 return rest, depth def list_continuation_indent(line: str, current: int = 0) -> int: """Track the indentation owned by the current simple list item.""" content = line.rstrip("\r\n") rest, _ = strip_blockquotes(content) item = re.match( r"^(?P[ \t]*)(?P[-+*]|[0-9]{1,9}[.)])(?P[ \t]+)", rest, ) if item: prefix = item.group("leading") + item.group("marker") + item.group("gap") return indentation_columns(prefix) if not rest.strip(): return current leading = re.match(r"^[ \t]*", rest) columns = indentation_columns(leading.group(0) if leading else "") return current if current and columns >= current else 0 def indented_code_container( line: str, list_indent: int = 0, ) -> tuple[int, int] | None: """Return blockquote depth/list indent for an indented code line.""" content = line.rstrip("\r\n") rest, quote_depth = strip_blockquotes(content) leading = re.match(r"^[ \t]*", rest) columns = indentation_columns(leading.group(0) if leading else "") required = list_indent + 4 if list_indent else 4 return (quote_depth, list_indent) if rest.strip() and columns >= required else None def opening_fence( line: str, list_context_indent: int = 0, ) -> tuple[str, int, int, str, int] | None: """Return marker char/len, quote depth, close mode, and list indentation.""" content = line.rstrip("\r\n") rest, quote_depth = strip_blockquotes(content) list_match = LIST_FENCE_RE.match(rest) if list_match: marker = list_match.group("fence") if marker.startswith("`") and "`" in list_match.group("info"): return None continuation = ( list_match.group("leading") + list_match.group("list") + list_match.group("gap") ) return marker[0], len(marker), quote_depth, "list", indentation_columns( continuation ) match = OPEN_FENCE_RE.match(rest) if match is None: return None indentation = match.group(1) marker = match.group(2) if marker.startswith("`") and "`" in match.group(3): return None columns = indentation_columns(indentation) if list_context_indent and list_context_indent <= columns <= list_context_indent + 3: return marker[0], len(marker), quote_depth, "list", list_context_indent if indentation.replace(" ", "") == "" and len(indentation) <= 3: return marker[0], len(marker), quote_depth, "top", 0 # Four or more columns outside a list are indented code, not a fence. return None def closing_fence( line: str, marker_char: str, marker_len: int, quote_depth: int, close_mode: str, list_indent: int, ) -> bool: content = line.rstrip("\r\n") rest, actual_quote_depth = strip_blockquotes(content, quote_depth) if actual_quote_depth != quote_depth: return False if close_mode not in {"top", "list"}: if not rest.startswith(close_mode): return False rest = rest[len(close_mode) :] match = re.match( rf"^(?P[ \t]*){re.escape(marker_char)}{{{marker_len},}}[ \t]*$", rest, ) if match is None: return False columns = indentation_columns(match.group("indent")) if close_mode == "top": return columns <= 3 if close_mode == "list": return list_indent <= columns <= list_indent + 3 return columns <= 3 def fence_container_continues( line: str, quote_depth: int, close_mode: str, list_indent: int, ) -> bool: """Whether an open fence's blockquote/list container owns this line.""" content = line.rstrip("\r\n") rest, actual_quote_depth = strip_blockquotes(content, quote_depth) if actual_quote_depth != quote_depth: return False if close_mode != "list" or not rest.strip(): return True leading = re.match(r"^[ \t]*", rest) return indentation_columns(leading.group(0) if leading else "") >= list_indent def advance_html_block( line: str, state: tuple[str, str] | None, paragraph_open: bool = False, ) -> tuple[bool, tuple[str, str] | None]: """Classify reader-visible raw HTML lines where Markdown fences are inert.""" content = line.rstrip("\r\n") structural, _ = strip_blockquotes(content) list_item = re.match( r"^[ \t]*(?:[-+*]|[0-9]{1,9}[.)])[ \t]+", structural, ) if list_item: structural = structural[list_item.end() :] if state is not None: kind, value = state if kind == "blank": if not structural.strip(): return False, None return True, state if kind == "tag": closing = re.search( rf"", structural, re.IGNORECASE ) return True, None if closing else state return True, None if value in structural else state raw = RAW_HTML_TAG_RE.match(structural) if raw: tag = raw.group("tag").lower() closing = re.search( rf"", structural, re.IGNORECASE ) return True, None if closing else ("tag", tag) if BLOCK_HTML_TAG_RE.match(structural): return True, ("blank", "") stripped = ( structural.lstrip(" ") if len(structural) - len(structural.lstrip(" ")) <= 3 else structural ) for opener, closer in ((""), ("") ): if stripped.startswith(opener): return True, None if closer in stripped[len(opener) :] else ("token", closer) if re.match(r"^" in stripped[2:] else ("token", ">") # CommonMark HTML block type 7 cannot interrupt an open paragraph. if not paragraph_open and COMPLETE_HTML_TAG_RE.match(structural): return True, ("blank", "") return False, None def ordered_list_interrupts_paragraph(value: str) -> bool: """Return whether a CommonMark ordered marker can interrupt prose.""" match = re.match( r"^[ ]{0,3}(?P[0-9]{1,9})[.)][ \t]+(?=\S)", value, ) return bool(match and int(match.group("number")) == 1) def is_thematic_break(value: str) -> bool: return THEMATIC_BREAK_RE.fullmatch(value.rstrip("\r\n")) is not None def line_interrupts_paragraph(line: str, quote_depth: int = 0) -> bool: """Recognize block starts that terminate multiline inline constructs.""" content = line.rstrip("\r\n") rest, actual_quote_depth = strip_blockquotes(content) if actual_quote_depth != quote_depth or not rest.strip(): return True if re.match(r"^[ ]{0,3}#{1,6}(?:[ \t]+|$)", rest): return True if re.match(r"^[ ]{0,3}[-+*][ \t]+(?=\S)", rest): return True if ordered_list_interrupts_paragraph(rest): return True if is_thematic_break(rest) or re.fullmatch(r"[ ]{0,3}(?:=+|-+)[ \t]*", rest): return True if opening_fence(line) is not None: return True stripped = rest.lstrip(" ") if len(rest) - len(stripped) <= 3 and stripped.startswith("