""" ctstruct/parsers/emoji_markup.py 이모지 마크업 포맷 → CTTree 파서 지원 포맷: 제목 → 문서 제목 (root 속성) 1단계 → SECTION (level=1) 2단계 → SECTION (level=2) … (카테고리) 이름 → 카테고리 태그가 붙은 LIST + LIST_ITEM들 이름1 이름2 → 카테고리 없는 LIST + LIST_ITEM들 (줄 연속 가능) 기본 구분자: title_mark = "✅" section_marks = ["🔹", "🔸"] """ from __future__ import annotations import re from ..node import CTNode from ..tree import CTTree from ..types import NodeType from .base import BaseParser class EmojiMarkupParser(BaseParser): """ 임의 구분자로 계층을 표현하는 텍스트 포맷 파서. 구분자는 이모지, 특수문자, 문자열 등 어떤 값도 사용할 수 있습니다. Args: title_mark: 문서 제목 줄 앞에 붙는 마커 (기본 "✅") section_marks: 섹션 레벨별 마커 목록 (기본 ["🔹", "🔸"]) 인덱스 0 → level 1, 인덱스 1 → level 2, … 더 긴 마커가 먼저 매칭되도록 내부에서 길이 내림차순 정렬됩니다. Examples: # 기본 이모지 구분자 parser = EmojiMarkupParser() # 마크다운 스타일 구분자 parser = EmojiMarkupParser(title_mark="# ", section_marks=["## ", "### "]) # 커스텀 구분자 parser = EmojiMarkupParser(title_mark="[제목]", section_marks=["[1]", "[2]", "[3]"]) """ _RE_CAT = re.compile(r'^\(([^)]+)\)\s*(.*)', re.DOTALL) def __init__( self, title_mark: str = "✅", section_marks: list[str] | None = None, ) -> None: self.title_mark = title_mark self.section_marks = section_marks if section_marks is not None else ["🔹", "🔸"] # 긴 마커를 먼저 검사해 prefix 충돌 방지 (예: "##" vs "#") self._sorted_marks: list[tuple[str, int]] = sorted( ((m, i + 1) for i, m in enumerate(self.section_marks)), key=lambda x: len(x[0]), reverse=True, ) def parse(self, text: str) -> CTTree: tree = CTTree() # 각 레벨별 현재 활성 섹션 노드 (인덱스 = level-1) section_stack: list[CTNode | None] = [None] * len(self.section_marks) pending_names: list[str] = [] pending_category: str | None = None pending_parent: CTNode | None = None def flush() -> None: nonlocal pending_names, pending_category, pending_parent if not pending_names: return attrs = {"category": pending_category} if pending_category else {} lst = CTNode(NodeType.LIST, attributes=attrs) for name in pending_names: lst.append(CTNode(NodeType.LIST_ITEM, name)) target = pending_parent if pending_parent is not None else tree.root target.append(lst) pending_names.clear() pending_category = None def nearest_parent(level: int) -> CTNode: """level 보다 얕은 가장 가까운 활성 섹션, 없으면 root.""" for i in range(level - 2, -1, -1): if section_stack[i] is not None: return section_stack[i] # type: ignore[return-value] return tree.root for raw in text.splitlines(): line = raw.strip() if not line: flush() continue # 제목 마커 if line.startswith(self.title_mark): flush() tree.root.set_attr("title", line[len(self.title_mark):].strip()) continue # 섹션 마커 (긴 것 우선 매칭) matched = False for mark, level in self._sorted_marks: if line.startswith(mark): flush() content = line[len(mark):].strip() node = CTNode(NodeType.SECTION, content, {"level": level}) # 현재 레벨 이하의 스택 초기화 for i in range(level - 1, len(section_stack)): section_stack[i] = None section_stack[level - 1] = node pending_parent = node nearest_parent(level).append(node) matched = True break if matched: continue # 카테고리 마커 → 새 그룹 시작 m = self._RE_CAT.match(line) if m: flush() pending_category = m.group(1).strip() rest = m.group(2).strip() if rest: pending_names.extend(rest.split()) continue # 이름 목록 (연속 줄은 같은 그룹으로 병합) pending_names.extend(line.split()) flush() return tree