diff --git a/ctstruct/parsers/__init__.py b/ctstruct/parsers/__init__.py index caad660..fc7afd4 100644 --- a/ctstruct/parsers/__init__.py +++ b/ctstruct/parsers/__init__.py @@ -1,4 +1,5 @@ from .base import BaseParser from .markdown import MarkdownParser +from .emoji_markup import EmojiMarkupParser -__all__ = ["BaseParser", "MarkdownParser"] +__all__ = ["BaseParser", "MarkdownParser", "EmojiMarkupParser"] diff --git a/ctstruct/parsers/emoji_markup.py b/ctstruct/parsers/emoji_markup.py new file mode 100644 index 0000000..6764bf7 --- /dev/null +++ b/ctstruct/parsers/emoji_markup.py @@ -0,0 +1,111 @@ +""" +ctstruct/parsers/emoji_markup.py +이모지 마크업 포맷 → CTTree 파서 + +지원 포맷: + ✅제목 → 문서 제목 (root 속성) + 🔹1단계 섹션 → SECTION (level=1) + 🔸2단계 섹션 → SECTION (level=2) + (카테고리) 이름 → 카테고리 태그가 붙은 LIST + LIST_ITEM들 + 이름1 이름2 → 카테고리 없는 LIST + LIST_ITEM들 (줄 연속 가능) +""" + +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): + """ + ✅ / 🔹 / 🔸 이모지로 계층을 표현하는 텍스트 포맷 파서. + + 명단은 공백으로 구분된 이름 목록이며, 빈 줄 또는 새로운 섹션/카테고리 + 마커가 나올 때까지 같은 그룹으로 묶입니다. + + Examples: + >>> parser = EmojiMarkupParser() + >>> tree = parser.parse(text) + >>> tree.pretty() + """ + + _TITLE = "✅" + _SEC1 = "🔹" + _SEC2 = "🔸" + _RE_CAT = re.compile(r'^\(([^)]+)\)\s*(.*)') + + def parse(self, text: str) -> CTTree: + tree = CTTree() + + # 현재 컨텍스트 추적 + sec1: CTNode | None = None + sec2: CTNode | None = None + + # 아직 트리에 붙이지 않은 이름 버퍼 + pending_names: list[str] = [] + pending_category: str | None = None + pending_parent: CTNode | None = None # 이름 그룹이 붙을 부모 노드 + + def flush() -> None: + """버퍼에 쌓인 이름을 LIST로 만들어 부모에 붙인다.""" + 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 = [] + pending_category = None + + for raw in text.splitlines(): + line = raw.strip() + + if not line: + flush() + continue + + # ✅ 문서 제목 + if line.startswith(self._TITLE): + flush() + tree.root.set_attr("title", line[len(self._TITLE):].strip()) + continue + + # 🔹 1단계 섹션 + if line.startswith(self._SEC1): + flush() + sec1 = CTNode(NodeType.SECTION, line[len(self._SEC1):].strip(), {"level": 1}) + sec2 = None + pending_parent = sec1 + tree.root.append(sec1) + continue + + # 🔸 2단계 섹션 + if line.startswith(self._SEC2): + flush() + sec2 = CTNode(NodeType.SECTION, line[len(self._SEC2):].strip(), {"level": 2}) + pending_parent = sec2 + parent = sec1 if sec1 is not None else tree.root + parent.append(sec2) + continue + + # (카테고리) 이름들 — 새 카테고리이면 기존 버퍼를 먼저 flush + 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 diff --git a/tests/test_emoji_markup_parser.py b/tests/test_emoji_markup_parser.py new file mode 100644 index 0000000..04e8c31 --- /dev/null +++ b/tests/test_emoji_markup_parser.py @@ -0,0 +1,164 @@ +""" +tests/test_emoji_markup_parser.py +EmojiMarkupParser 단위 테스트 +""" + +import pytest +from ctstruct import NodeType +from ctstruct.parsers import EmojiMarkupParser + + +SAMPLE = """\ +✅예시 양식 + +🔹1번 항목 +홍길동 홍길순 홍길영 홍길준 +홍길호 홍길윤 + +🔹2번 항목 +🔸2-1 항목 +(건강) 홍길찬 홍길만 + +🔸2-2 항목 +(직장) 홍길주 +""" + + +@pytest.fixture +def tree(): + return EmojiMarkupParser().parse(SAMPLE) + + +# ── 문서 제목 ───────────────────────────────────────────────────────────────── + +class TestTitle: + def test_title_set(self, tree): + assert tree.root.attr("title") == "예시 양식" + + +# ── 섹션 구조 ───────────────────────────────────────────────────────────────── + +class TestSections: + def test_two_top_sections(self, tree): + secs = [n for n in tree.root.children if n.node_type == NodeType.SECTION] + assert len(secs) == 2 + + def test_section1_name(self, tree): + sec = tree.root.children[0] + assert sec.content == "1번 항목" + assert sec.attr("level") == 1 + + def test_section2_name(self, tree): + sec = tree.root.children[1] + assert sec.content == "2번 항목" + assert sec.attr("level") == 1 + + def test_subsections_under_section2(self, tree): + sec2 = tree.root.children[1] + subsecs = [n for n in sec2.children if n.node_type == NodeType.SECTION] + assert len(subsecs) == 2 + assert subsecs[0].content == "2-1 항목" + assert subsecs[1].content == "2-2 항목" + + def test_subsection_level(self, tree): + sec2 = tree.root.children[1] + for sub in sec2.children: + assert sub.attr("level") == 2 + + +# ── 명단 ───────────────────────────────────────────────────────────────────── + +class TestNameLists: + def test_section1_has_one_list(self, tree): + sec1 = tree.root.children[0] + lists = [n for n in sec1.children if n.node_type == NodeType.LIST] + assert len(lists) == 1 + + def test_section1_names_across_lines(self, tree): + sec1 = tree.root.children[0] + lst = next(n for n in sec1.children if n.node_type == NodeType.LIST) + names = [item.content for item in lst.children] + assert names == ["홍길동", "홍길순", "홍길영", "홍길준", "홍길호", "홍길윤"] + + def test_section1_no_category(self, tree): + sec1 = tree.root.children[0] + lst = next(n for n in sec1.children if n.node_type == NodeType.LIST) + assert lst.attr("category") is None + + def test_subsec_21_category(self, tree): + sub21 = tree.root.children[1].children[0] + lst = next(n for n in sub21.children if n.node_type == NodeType.LIST) + assert lst.attr("category") == "건강" + + def test_subsec_21_names(self, tree): + sub21 = tree.root.children[1].children[0] + lst = next(n for n in sub21.children if n.node_type == NodeType.LIST) + names = [item.content for item in lst.children] + assert names == ["홍길찬", "홍길만"] + + def test_subsec_22_category(self, tree): + sub22 = tree.root.children[1].children[1] + lst = next(n for n in sub22.children if n.node_type == NodeType.LIST) + assert lst.attr("category") == "직장" + + def test_subsec_22_names(self, tree): + sub22 = tree.root.children[1].children[1] + lst = next(n for n in sub22.children if n.node_type == NodeType.LIST) + names = [item.content for item in lst.children] + assert names == ["홍길주"] + + def test_all_list_items_are_leaf(self, tree): + for item in tree.find(NodeType.LIST_ITEM): + assert item.is_leaf + + +# ── 전체 통계 ───────────────────────────────────────────────────────────────── + +class TestStats: + def test_total_names(self, tree): + assert len(tree.find(NodeType.LIST_ITEM)) == 9 + + def test_total_lists(self, tree): + assert len(tree.find(NodeType.LIST)) == 3 + + def test_total_sections(self, tree): + secs = tree.find(NodeType.SECTION) + assert len(secs) == 4 # 🔹2 + 🔸2 + + +# ── 엣지 케이스 ─────────────────────────────────────────────────────────────── + +class TestEdgeCases: + def test_empty_string(self): + tree = EmojiMarkupParser().parse("") + assert tree.root.attr("title") is None + assert len(tree.root.children) == 0 + + def test_title_only(self): + tree = EmojiMarkupParser().parse("✅제목만") + assert tree.root.attr("title") == "제목만" + assert len(tree.root.children) == 0 + + def test_multiple_categories_in_section(self): + text = "🔹항목\n(A) 홍길동\n\n(B) 홍길순\n" + tree = EmojiMarkupParser().parse(text) + sec = tree.root.children[0] + lists = [n for n in sec.children if n.node_type == NodeType.LIST] + assert len(lists) == 2 + assert lists[0].attr("category") == "A" + assert lists[1].attr("category") == "B" + + def test_names_without_section(self): + tree = EmojiMarkupParser().parse("홍길동 홍길순\n홍길영\n") + lst = tree.find(NodeType.LIST) + assert len(lst) == 1 + assert len(lst[0].children) == 3 + + def test_category_names_on_separate_line(self): + # 카테고리 줄 다음에 이름이 없고 다음 줄에 이름이 있는 경우 + text = "🔹항목\n(건강)\n홍길동 홍길순\n" + tree = EmojiMarkupParser().parse(text) + sec = tree.root.children[0] + lst = next(n for n in sec.children if n.node_type == NodeType.LIST) + assert lst.attr("category") == "건강" + assert len(lst.children) == 2