""" ctstruct/parsers/markdown.py Markdown → CTTree 파서 """ from __future__ import annotations import re from ..node import CTNode from ..tree import CTTree from ..types import NodeType, ListStyle from .base import BaseParser class MarkdownParser(BaseParser): """ Markdown 텍스트를 CTTree 로 파싱. 지원 요소: - ATX 헤딩 (# ~ ######) - 문단 - 순서없는 목록 (-, *, +) - 순서있는 목록 (1. 2. …) - 코드 블록 (``` … ```) - 인용구 (>) - 구분선 (--- / ***) - frontmatter (--- … ---) """ # ── 정규식 패턴 ────────────────────────────────────────── _RE_HEADING = re.compile(r'^(#{1,6})\s+(.+)$') _RE_UL_ITEM = re.compile(r'^(\s*)[*\-+]\s+(.+)$') _RE_OL_ITEM = re.compile(r'^(\s*)\d+\.\s+(.+)$') _RE_CODE_FENCE = re.compile(r'^```(\w*)$') _RE_BLOCKQUOTE = re.compile(r'^>\s?(.*)$') _RE_DIVIDER = re.compile(r'^(\-{3,}|\*{3,}|_{3,})$') _RE_FRONTMATTER = re.compile(r'^---\s*$') def parse(self, text: str) -> CTTree: tree = CTTree() lines = text.splitlines() i = 0 # 1) frontmatter metadata, i = self._parse_frontmatter(lines, i) if metadata: tree.root.set_attr("metadata", metadata) # 2) 본문 파싱 current_section: CTNode | None = None while i < len(lines): line = lines[i] # 빈 줄 → 스킵 if not line.strip(): i += 1 continue # 코드 블록 m = self._RE_CODE_FENCE.match(line) if m: lang = m.group(1) code_lines, i = self._collect_code_block(lines, i + 1) node = CTNode(NodeType.CODE_BLOCK, "\n".join(code_lines), {"language": lang}) self._attach(tree, current_section, node) continue # 헤딩 m = self._RE_HEADING.match(line) if m: level = len(m.group(1)) text_content = m.group(2).strip() heading = CTNode(NodeType.HEADING, text_content, {"level": level}) if level == 1: # 새 섹션 시작 current_section = CTNode(NodeType.SECTION) current_section.append(heading) tree.root.append(current_section) else: self._attach(tree, current_section, heading) i += 1 continue # 구분선 if self._RE_DIVIDER.match(line): node = CTNode(NodeType.DIVIDER) self._attach(tree, current_section, node) i += 1 continue # 인용구 m = self._RE_BLOCKQUOTE.match(line) if m: quote_lines = [m.group(1)] i += 1 while i < len(lines) and self._RE_BLOCKQUOTE.match(lines[i]): quote_lines.append(self._RE_BLOCKQUOTE.match(lines[i]).group(1)) # type: ignore i += 1 node = CTNode(NodeType.BLOCKQUOTE, "\n".join(quote_lines)) self._attach(tree, current_section, node) continue # 목록 if self._RE_UL_ITEM.match(line) or self._RE_OL_ITEM.match(line): lst_node, i = self._parse_list(lines, i) self._attach(tree, current_section, lst_node) continue # 문단 para_lines = [line] i += 1 while i < len(lines) and lines[i].strip() and not self._is_block_start(lines[i]): para_lines.append(lines[i]) i += 1 node = CTNode(NodeType.PARAGRAPH, " ".join(para_lines)) self._attach(tree, current_section, node) return tree # ── 헬퍼 ───────────────────────────────────────────────── @staticmethod def _attach(tree: CTTree, section: CTNode | None, node: CTNode) -> None: if section is not None: section.append(node) else: tree.root.append(node) def _parse_frontmatter(self, lines: list[str], i: int) -> tuple[dict, int]: if i >= len(lines) or not self._RE_FRONTMATTER.match(lines[i]): return {}, i i += 1 meta: dict = {} while i < len(lines) and not self._RE_FRONTMATTER.match(lines[i]): if ":" in lines[i]: k, _, v = lines[i].partition(":") meta[k.strip()] = v.strip() i += 1 return meta, i + 1 def _collect_code_block(self, lines: list[str], i: int) -> tuple[list[str], int]: code: list[str] = [] while i < len(lines): if lines[i].strip() == "```": return code, i + 1 code.append(lines[i]) i += 1 return code, i def _parse_list(self, lines: list[str], i: int) -> tuple[CTNode, int]: is_ordered = bool(self._RE_OL_ITEM.match(lines[i])) style = ListStyle.ORDERED if is_ordered else ListStyle.UNORDERED lst = CTNode(NodeType.LIST, attributes={"style": style.value}) pattern = self._RE_OL_ITEM if is_ordered else self._RE_UL_ITEM while i < len(lines): m = pattern.match(lines[i]) if not m: break lst.append(CTNode(NodeType.LIST_ITEM, m.group(2))) i += 1 return lst, i def _is_block_start(self, line: str) -> bool: return bool( self._RE_HEADING.match(line) or self._RE_CODE_FENCE.match(line) or self._RE_BLOCKQUOTE.match(line) or self._RE_DIVIDER.match(line) or self._RE_UL_ITEM.match(line) or self._RE_OL_ITEM.match(line) )