Skip to content

Decklist Parsing

Pablo Martínez López edited this page Oct 20, 2026 · 3 revisions

Decklist Parsing

English · Español

Proxy Builder turns the text a user pastes (or an Archidekt URL) into a structured list of lines, and later turns the finished deck back into text for Moxfield, Arena, MTGO or plain text. Both directions live in the pure crate proxy-core (src-tauri/crates/proxy-core/src/decklist/), so they have no network, no Tauri and no database; the Tauri layer only wraps them in commands.

Boundaries: this page covers reading and writing list text. Looking the cards up (Scryfall, languages, images) is Scryfall-Integration; what happens to the resulting cards in the deck is Deck-Model.

Files

File Responsibility
src-tauri/crates/proxy-core/src/decklist/mod.rs parse(), ParsedDecklist, ParsedLine, Section, LineKind, LineError; the grammar.
src-tauri/crates/proxy-core/src/decklist/exportar.rs exportar() and FormatoLista: deck -> text for Moxfield / Arena / MTGO / plain text.
src-tauri/src/commands/decklist.rs Tauri commands parse_decklist, deck_list_text, deck_list_save.
src-tauri/src/archidekt.rs id_de_url(), desde_json(): import of a public Archidekt deck by URL.
src-tauri/src/commands/cards.rs import_archidekt command (does the HTTP call, then calls archidekt::desde_json).
src/features/decklist/editor-lista.tsx The list text box: calls parse_decklist 250 ms after the last keystroke and marks invalid lines.
src/features/decklist/acciones.ts cargarLista(): parse_decklist then resolve_decklist.
src/features/export/menu-exportar.tsx The "..." menu next to "Export PDF": copy/save the list.

The parser never guesses silently: every input line comes back as a ParsedLine with a kind, and a line that cannot be understood carries a LineError code so the UI can mark it. The UI translates the code; Rust never writes prose (see IPC-and-Error-Handling).

Data model

All types derive TS with #[ts(export)] and are generated into src/bindings/ by pnpm bindings.

ParsedDecklist:

Field Meaning
lines: Vec<ParsedLine> One entry per input line (blank and comment lines included), in order.
card_count: u32 Sum of qty of lines that are Card and included.
invalid_count: u32 Number of Invalid lines.

ParsedLine:

Field Meaning
line_no 1-based line number.
raw The original text, untouched.
kind LineKind: Card, Section, Comment, Empty, Invalid.
qty, name Copies and cleaned card name (only meaningful for Card).
set, collector Lower-cased set code (cmm) and collector number, if the line had them.
foil true if the line carried a foil/etched/commander mark (see "Marks").
section Section: Main, Commander, Sideboard, Companion, Maybeboard, Tokens.
included false for the Maybeboard and for Archidekt categories flagged {noDeck}. Only included lines are resolved.
error LineError: ZeroQuantity, BadQuantity, TooMany (> 999 copies, MAX_COPIAS), EmptyName.

Section::included_by_default() is true for everything except Maybeboard.

Accepted formats

The module header documents the intent: accept whatever the reference site accepts, plus the exports of the common sites. Concretely (all covered by formatos_de_arena_moxfield_y_archidekt and neighbours in the mod.rs tests):

Source Example line
Plain text Sol Ring, 2x Sol Ring, 3 x Island, 2X Counterspell
With printing 1 Sol Ring (CMM) 410, Bird (teoc) (set only), 1 Llanowar Elves (PLST) DOM-168, 1 Sol Ring (SLD) 2063★
MTG Arena 4 Lightning Bolt (M10) 146, About / Name My deck header block, Deck / Sideboard / Commander headers, old SB: 2 Duress prefix
Moxfield 1 Fire // Ice (MH2) 290 *F*, *E*, *CMDR*, headers with counts such as Commander (1)
Archidekt 1x Sol Ring (cmm) 410 [Commander{top}] ^Have,#37d67a^
Double-faced / split 1 Delver of Secrets // Insectile Aberration (ISD) 51, 1 Who // What // When // Where // Why

Grammar, step by step

parse(texto) walks texto.lines() once, carrying two pieces of state across lines: the current Section (starts as Main) and a flag en_about (inside an Arena "About" block). Each line goes through linea():

  1. Blank line: kind = Empty, resets en_about. It does not change the section.
  2. Comment or header: if the trimmed line starts with // or #, those characters are stripped and what remains is tested as a header; otherwise the whole line is. If it is a header (seccion_de_cabecera), the line becomes Section and the current section changes; if it started with ///# but is not a header it is a Comment and the section does not change.
    • Header matching lower-cases, trims a trailing :, and drops a trailing (n) count (Commander (1), Sideboard (15)).
    • Recognised words: deck, main, mainboard, main deck, maindeck, creatures, lands, spells, about -> Main; commander(s) -> Commander; sideboard, side, sb -> Sideboard; companion(s) -> Companion; maybeboard, maybe, considering -> Maybeboard; tokens, token -> Tokens.
    • Why headers are also accepted as comments: some sites paste the header as a comment when copying a list as text (#SIDEBOARD, // Sideboard (15)); a test (cabeceras_escritas_como_comentario) pins this.
  3. Arena About block: after an About header, a line starting with Name is a Comment (the deck's name, not a card) until the next blank line.
  4. SB: prefix: SB: (case-insensitive) puts only that line in Sideboard without changing the running section.
  5. Archidekt suffixes (quitar_archidekt): a trailing ^...^ tag block is dropped, then a trailing [...] category is read. A category containing {nodeck} or starting with maybeboard sets included = false; starting with maybeboard also sets the section to Maybeboard; starting with commander or sideboard sets that section for the line.
  6. Marks: *F*, *E*, *CMDR*, *Foil*, *Etched* (case-insensitive, anywhere) are removed and set foil = true. Quirk: *CMDR* also sets foil (the same regex handles all of them); nothing downstream currently reads ParsedLine.foil (verified: no consumer outside decklist/mod.rs).
  7. Quantity (RE_CANTIDAD: digits, optional x/X, then whitespace, then the rest). No leading number means quantity 1. A line that looks like a bad quantity (RE_CANTIDAD_MAL: x3 ..., 3xx ..., a lone 4 or 4x) becomes Invalid with BadQuantity, or EmptyName when it is only digits/x. Quantity 0 -> ZeroQuantity; above 999 -> TooMany.
  8. Set and number (RE_SET): name (SET) [number] where SET is 2-8 alphanumerics in parentheses. The number is the next non-space token and may be anything (410, 2063★, DOM-168, 254p, 38a). Parentheses with spaces inside (e.g. a card name with a parenthetical) do not match and stay part of the name.
  9. Name cleanup (limpiar_nombre): typographic quotes become ASCII (', "), runs of whitespace collapse. An empty name is Invalid/EmptyName.
  10. The surviving line is a Card with qty, name, set, collector, section, and included (from the section default, possibly overridden in step 5).

Edge cases and gotchas

  • Any whole line equal to a header word is a header, even without // or #. A card literally named like a header word on its own line would be eaten as a section change.
  • Inferred from the regex: a card whose name starts with a number and a space (e.g. 1996 World Champion) is read as quantity 1996 and rejected with TooMany; 7 Wonders-style names lose their number. Write 1 1996 World Champion instead.
  • 1Island (no space) is not a quantity: it is read as a card named 1Island with quantity 1.
  • Comments never change the section; blank lines do not either (only an About block ends at a blank line).
  • Windows line endings are handled by str::lines(); raw keeps the line text without the terminator.
  • The parser is deliberately lenient about card existence: it only checks syntax. A misspelt name parses fine and fails later as Unresolved in the resolver.

Where sections go: banquillo, opcional

The Section of a line is the only place where "this card belongs to the sideboard" is known at parse time. resolve.rs copies it onto the created cards as a mark (DeckCard.marca, type MarcaCarta in src-tauri/crates/proxy-core/src/model.rs):

Line section Mark on the resolved copies Effect
Sideboard MarcaCarta::Banquillo Printed, but excluded from deck size / copies / statistics; exported under the sideboard.
Maybeboard MarcaCarta::Opcional Printed, excluded from counts.
anything else none Counts normally.

proxy_core::model::cuentan() is the single function that decides what counts (it also handles Variante; a design variant counts only if no unmarked copy of the same name exists). Details: Deck-Model and Statistics.

Note: resolve() only processes lines with kind == Card && included. Through parse(), a Maybeboard line has included = false, so the Opcional branch in resolve.rs is only reached if a caller sets included = true on such a line; the UI currently has no switch for that (no frontend code reads included). The Archidekt importer drops anything the site marks outside the deck for the same reason.

Exporting the list

exportar(deck: &Deck, formato: FormatoLista) -> String builds the text from the deck's cards, not from the pasted text. FormatoLista: Moxfield, Arena, Mtgo, Texto.

How a line set is built (juntar):

  1. Sections are not stored on cards. They are recovered by re-parsing deck.decklist_text (secciones_de) and looking each card up by lower-cased name; a double-faced card also matches by its front face (seccion_de). A card absent from the text defaults to Main.
  2. Cards are filtered by the marks: cuentan(&deck.cards) removes optional and surplus-variant copies; Banquillo-marked cards are forced into Sideboard regardless of the text.
  3. Tokens, blanks, Maybeboard/Tokens sections and nameless cards are skipped (va_en_la_lista). A Custom card linked to an external source (Cockatrice token) is a token; other custom cards are skipped only if their type line says token/emblem.
  4. Copies of the same printing in the same section are merged into one line with a count (match on section + name + set + collector number). Different printings stay on different lines.
  5. For double-faced cards delante is the front face name (Arena and MTGO want that); Moxfield and plain text keep the full A // B name.

Output per format (pinned by tests para_moxfield, para_arena, para_mtgo_el_comandante_va_en_el_banquillo, en_texto_plano):

Format Layout
Moxfield Commander + companion + main as one block of N Name (SET) number, then a blank line, SIDEBOARD: and the sideboard.
Arena Headed blocks Commander, Companion, Deck, Sideboard (empty blocks omitted), front-face names, (SET) number.
Mtgo N Name with the main deck first, then a blank line, then commander/companion/sideboard together (MTGO has no commander slot).
Texto N Name, commander+main together, a blank line, then the sideboard.

Set codes are upper-cased on export. A set without number prints (SET) only.

Commands

Command Signature Notes
parse_decklist (text: String) -> ParsedDecklist Synchronous and cheap; called while typing (debounced 250 ms in editor-lista.tsx) and again by cargarLista().
deck_list_text (deck: Deck, format: FormatoLista) -> String Used by the "copy list" entries of menu-exportar.tsx.
deck_list_save (deck, format, path) -> () Path must be absolute and end in .txt (else InvalidInput); written with escribir_atomico. The UI uses it for "Save for MTGO".
import_archidekt (url: String, include_sideboard: bool) -> ArchidektImport { text, list } See below.

Archidekt import

sequenceDiagram
    participant UI as decklist-panel.tsx
    participant C as import_archidekt (cards.rs)
    participant A as archidekt.rs
    participant W as archidekt.com
    UI->>C: url, includeSideboard
    C->>A: id_de_url(url)
    A-->>C: deck id (or None -> InvalidInput bad_url)
    C->>W: GET /api/decks/{id}/
    W-->>C: JSON (404/403 -> NotFound)
    C->>A: desde_json(json, include_sideboard)
    A-->>C: Importada { texto, lista, ids }
    C-->>UI: ArchidektImport { text, list }
    UI->>UI: put text in the list box (no history entry)
Loading
  • id_de_url accepts archidekt.com/decks/<id>/..., www., with or without scheme, and .../api/decks/<id>/. Anything else returns None, which the command reports as InvalidInput with motivo = bad_url.
  • The HTTP call is made with a plain reqwest::Client (user agent ProxyBuilder/<version>), not through fuentes::Externo (see External-Sources); status 404/403 map to NotFound, other non-2xx to Network.
  • desde_json reads only the fields it needs (categories[].includedInDeck, cards[].quantity/categories/card.uid/collectorNumber/edition.editioncode/oracleCard.name). A card is skipped if its categories are all excluded from the deck, if it is in the Sideboard category and include_sideboard is false, or if quantity is 0. Commander and Sideboard categories set the line section.
  • It produces a text of lines "{qty} {name} ({set}) {number}", re-parses that text with decklist::parse (so "what you see is what gets resolved"), then restores the sections it knew (the text itself carries no headers). It also builds ids: HashMap<line_no, uid> with the exact Scryfall id of each printing, but import_archidekt returns only text and list; the ids are not sent to the front end. The exact printing is preserved anyway because the generated line includes set code and collector number, which the resolver looks up as a SetNumber identifier.
  • Because the section headers are not in the returned text, loading the text through the normal path (cargarLista -> parse_decklist) puts everything in Main; sideboard cards imported from Archidekt only end up marked Banquillo if the user's text has a Sideboard header. Inferred from desde_json (the text has no headers) and cargarLista (re-parses the text box).
  • The fixture src-tauri/src/fixtures/archidekt-1.json is a real deck used by el_deck_real_de_archidekt_se_convierte_en_lista.

Failure modes

Situation Result
Unreadable line LineKind::Invalid + LineError; shown in the editor; counted in invalid_count. Does not stop the load.
cardCount == 0 cargarLista() shows "The list has no cards" and does nothing.
deck_list_save bad path AppError { code: InvalidInput, params: { detalle } }.
Archidekt URL not recognised / deck private or missing / network InvalidInput(motivo=bad_url) / NotFound / Network.
Archidekt JSON malformed InvalidInput with detalle.

Tests that guard it

  • src-tauri/crates/proxy-core/src/decklist/mod.rs (mod tests): formats, marks, sections, comments-as-headers, About block, SB:, invalid lines, line numbering.
  • src-tauri/crates/proxy-core/src/decklist/exportar.rs (mod tests): the four formats, merging, marks, different printings.
  • src-tauri/src/archidekt.rs (mod tests): URL forms, real deck fixture, broken JSON.
  • src/features/decklist/__tests__/acciones.test.ts: cargarLista flow with a mocked Rust side.

Where to start when extending

  • New list syntax: add a regex/branch in linea() and a case row in the big table test of mod.rs; keep raw untouched and return an Invalid with a LineError rather than dropping lines. New error variants need a translation in the front end (useLang().mensaje).
  • New export target: add a FormatoLista variant and a match arm in exportar(); add the menu entry in src/features/export/menu-exportar.tsx.
  • New import site: follow archidekt.rs (pure JSON -> ParsedDecklist function, tested against a fixture) and add the network call in a command; if it downloads anything beyond JSON, route it through fuentes::Externo.

See also

Documentation · Documentación

Language: English · Español

Use and build · Uso y desarrollo

Architecture · Arquitectura

Data · Datos

Getting cards · Conseguir cartas

Layout, images, PDF · Maquetación, imagen y PDF

Card designer · Diseñador de cartas

Game table · Mesa de juego

Frontend · Frontend

Reference · Referencia

Clone this wiki locally