From daa590117d0ec8adcf2aae70ddef3f135b2c77ab Mon Sep 17 00:00:00 2001 From: gm Date: Mon, 1 Jun 2026 18:14:04 +0900 Subject: [PATCH] =?UTF-8?q?feat(parser):=20add=20EmojiMarkupParser=20for?= =?UTF-8?q?=20=E2=9C=85/=F0=9F=94=B9/=F0=9F=94=B8=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - βœ… β†’ root title, πŸ”Ή β†’ SECTION(level=1), πŸ”Έ β†’ SECTION(level=2) - (μΉ΄ν…Œκ³ λ¦¬) 이름듀 β†’ LIST with category attribute + LIST_ITEM children - μΉ΄ν…Œκ³ λ¦¬ μ—†λŠ” 이름 쀄은 빈 쀄/μƒˆ μ„Ήμ…˜ μ „κΉŒμ§€ 동일 LIST둜 묢음 - 22개 ν…ŒμŠ€νŠΈ μΆ”κ°€ (ꡬ쑰, λͺ…단, μ—£μ§€μΌ€μ΄μŠ€) Co-Authored-By: Claude Sonnet 4.6 --- ctstruct/parsers/__init__.py | 3 +- ctstruct/parsers/emoji_markup.py | 111 ++++++++++++++++++++ tests/test_emoji_markup_parser.py | 164 ++++++++++++++++++++++++++++++ 3 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 ctstruct/parsers/emoji_markup.py create mode 100644 tests/test_emoji_markup_parser.py 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