File size: 3,148 Bytes
8538f10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
"""
decklist.py

Parses a pasted decklist into (name, quantity) entries. Accepts the plain
text formats players actually paste, rather than one strict format:

    Sol Ring
    1 Sol Ring
    1x Sol Ring
    1 Sol Ring (CMM) 410            <- Moxfield / MTG Arena export
    1 Sol Ring (CMM) 410 *F*        <- foil / etched markers
    1 Sol Ring [CMM]                <- bracketed set code
    1x Sol Ring (cmm) 410 [Ramp]    <- Archidekt export with a category tag

Section headers ("Commander", "Deck:", "// Creatures", ...) are skipped.
Anything after a sideboard/maybeboard/considering header is ignored, since
those cards aren't part of the deck being completed.

Pure text handling only: resolving names against the card vocabulary
happens in server/deckbuilder.py.
"""

from __future__ import annotations

import re
from dataclasses import dataclass

_QTY_RE = re.compile(r"^(\d+)\s*x?\s+(.+)$", re.IGNORECASE)
# Trailing "(SET) 123", "(SET)", "[SET]", "*F*"/"*E*" markers and Archidekt
# "[Category]" / "^Tag^" annotations, stripped repeatedly from the right.
_TRAILING_NOISE_RE = re.compile(
    r"\s+(?:\([A-Za-z0-9]{2,6}\)(?:\s+[A-Za-z0-9\-★]+)?|\[[^\]]*\]|\*[A-Za-z]\*|\^[^^]*\^)\s*$"
)
_IGNORED_SECTION_RE = re.compile(
    r"^(sideboard|maybeboard|maybe|considering|tokens?)\s*(\(\d+\))?\s*:?$", re.IGNORECASE
)
_HEADER_RE = re.compile(
    r"^(commanders?|companion|deck|main\s*deck|mainboard|"
    r"creatures?|instants?|sorcery|sorceries|artifacts?|enchantments?|planeswalkers?|"
    r"battles?|lands?|other|spells?)\s*(\(\d+\))?\s*:?$",
    re.IGNORECASE,
)


@dataclass(frozen=True)
class DecklistEntry:
    name: str
    quantity: int
    line_number: int  # 1-based, for pointing the user at a bad line


def _strip_trailing_noise(text: str) -> str:
    while True:
        stripped = _TRAILING_NOISE_RE.sub("", text)
        if stripped == text:
            return text.strip()
        text = stripped


def parse_decklist(text: str) -> list[DecklistEntry]:
    """Returns one entry per card line, in input order. Duplicate names are
    NOT merged here -- the caller merges after name resolution, since two
    differently-spelled lines can resolve to the same card."""
    entries: list[DecklistEntry] = []
    for line_number, raw in enumerate(text.splitlines(), start=1):
        line = raw.strip()
        if not line or line.startswith("#"):
            continue

        # A "// Heading" or "SIDEBOARD:" style line: stop at ignored sections,
        # skip everything else that looks like a header.
        heading = line.lstrip("/").strip()
        if line.startswith("//") or line.endswith(":") or _HEADER_RE.match(heading):
            if _IGNORED_SECTION_RE.match(heading):
                break
            continue
        if _IGNORED_SECTION_RE.match(heading):
            break

        quantity = 1
        m = _QTY_RE.match(line)
        if m:
            quantity = int(m.group(1))
            line = m.group(2)

        name = _strip_trailing_noise(line)
        if name and quantity > 0:
            entries.append(DecklistEntry(name=name, quantity=quantity, line_number=line_number))
    return entries