feat(parser): add EmojiMarkupParser for ✅/🔹/🔸 format
- ✅ → root title, 🔹 → SECTION(level=1), 🔸 → SECTION(level=2) - (카테고리) 이름들 → LIST with category attribute + LIST_ITEM children - 카테고리 없는 이름 줄은 빈 줄/새 섹션 전까지 동일 LIST로 묶음 - 22개 테스트 추가 (구조, 명단, 엣지케이스) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
from .base import BaseParser
|
from .base import BaseParser
|
||||||
from .markdown import MarkdownParser
|
from .markdown import MarkdownParser
|
||||||
|
from .emoji_markup import EmojiMarkupParser
|
||||||
|
|
||||||
__all__ = ["BaseParser", "MarkdownParser"]
|
__all__ = ["BaseParser", "MarkdownParser", "EmojiMarkupParser"]
|
||||||
|
|||||||
111
ctstruct/parsers/emoji_markup.py
Normal file
111
ctstruct/parsers/emoji_markup.py
Normal file
@@ -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
|
||||||
164
tests/test_emoji_markup_parser.py
Normal file
164
tests/test_emoji_markup_parser.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user